스트림을 제대로 활용하려면 먼저 람다식에 대해 알아야 한다!
간단히 설명한 이전 링크를 먼저 들어가서 읽어주세요!
2023.03.07 - [JAVA] - [JAVA] 람다와 스트림(Lambda, Stream)(1): 람다는 함수형 인터페이스의 구현체이다?
💋 스트림의 시작 (자바8...ing)
스트림의 시작은 무엇이었을까? 반복문을 대체하기 위해 만들어졌을까...? 그건 아니다!
스트림을 단순히 for문을 대체하기 위해 남용하는 사람들이 많다. 그치만 스트림은 그런 용도로 시작된 것이 아니다!
그렇다면 스트림은 왜 생겼을까?
스트림 API는 다량의 데이터 처리 작업을 돕기 위해 자바8부터 추가된 것이다.
이제까지는 List/Set이나 배열을 다룰 때 동일한 기능의 데이터 처리 작업이 서로 다른 메서드로 정의되어 있어, 일관성이 없었다. 하지만 자바8부터 도입된 스트림을 사용하여 일관성 있는 데이터 처리를 할 수 있게 되었다.
스트림은 데이터 소스가 다르더라도, 일단 스트림을 생성할 수만 있다면, 과거에 그 데이터의 형태가 무엇이었던 간에 같은 방식으로 다룰 수 있게 되어 코드의 재사용성이 높아진다.
스트림을 처음 사용할 수 있게 되었을 때, 나는 모든 반복문을 스트림으로 변경했다. 데이터는 대부분 같은 형식으로 가공되어 제공되는 경우가 많기 때문에 반복문으로 처리하기 용이했기 때문에 스트림과 반복문의 사용 범위가 비슷한 것일 뿐, 두 가지는 완전히 대체될 수도 없다. 스트림을 반복문의 대용으로 생각하는 것은 따라서 스트림을 제대로 이해하는 것에 방해가 될 수 있다.
스트림은 그렇다면 어떻게 이루어져 있을까?
우선 내가 가진 데이터 소스로부터 스트림을 생성해야 한다.
그 후에 스트림을 만든 목적대로 원하는 형태로 데이터를 가공할텐데, 이것을 중간연산이라고 부른다.
마지막으로 스트림을 소모하여 닫아주거나 원하는 형태로 반환하는 최종연산을 한다.
이 글은 스트림의 생성, 중간연산, 최종연산까지 차례대로 다룰 예정이며, 이제까지 우테코 프리코스를 하면서 만났던 다양한 코드 예시를 활용할 예정이다.
👍 스트림의 생성
스트림을 생성하는 방법은 정말 다양하다. 위에서 말했듯이 어떤 데이터의 형태건 스트림을 생성할 수 있다는 말이다. Collection을 구현한 List, Set, 또 배열, 숫자, 람다식 등등 데이터만 있으면 뭐든 스트림으로 만들 수 있다.
지금부터 순서대로 생성 방법에 대해 설명할 것이다.
1. Collection (List, Set)
상위 클래스인 Collection에 stream()이 정의되어 있어, Collection을 구현한 List와 Set은 곧바로 .stream()을 호출해 생성한다.
List<String> bridge로부터 바로 .stream()을 호출하면 리스트에 대한 스트림을 생성할 수 있다.
private static List<BridgeSign> convertToBridgeSign(List<String> bridge) {
return bridge.stream()
.map(BridgeSign::from)
.collect(Collectors.toList());
}
2. 배열
배열을 소스로 만들고 싶다면, Stream, Array에 static 메서드로 정의된 메서드들을 사용해서 생성할 수 있다.
나는 배열을 많이 사용하지 않아서, 주로 enum에서 .values()로 리턴받은 값으로부터 조회해 해당하는 값을 리턴할 때, 많이 사용하게 되는 것 같다.
public static RestartCommand from(String command) {
return Arrays.stream(RestartCommand.values())
.filter(option -> option.command.equals(command))
.findAny()
.orElseThrow(IllegalArgumentException::new);
}
3. 숫자
먼저 Random한 Integer, Long, Double로부터 스트림을 생성할 수 있다.
IntStream intStream = new Random().ints();
LongStream longStream = new Random().longs();
DoubleStream doubleStream = new Random().doubles();
또, Stream 클래스의 generate() 메서드는 람다식을 파라미터로 받아, 계산된 값을 데이터로 하는 무한 스트림을 만들 수 있다. Bridge 미션에서 유용하게 사용할 수 있다.
public List<String> makeBridge(int size) {
List<String> bridge = new ArrayList<>();
for (int i = 0; i < size; i++) {
int number = bridgeNumberGenerator.generate();
String bridgeSign = BridgeSign.numberToSign(number);
bridge.add(bridgeSign);
}
return bridge;
}
size만큼 반복적으로 같은 데이터의 처리 작업을 해야 하는 메서드이다. 이 메서드는 IntStream을 통해 아래와 같이 바꿀 수 있다.
public List<String> makeBridge(int size) {
return IntStream
.generate(bridgeNumberGenerator::generate)
.limit(size)
.mapToObj(BridgeSign::numberToSign)
.collect(Collectors.toList());
}
이외에도 스트림을 생성할 수 있는 다른 방법들이 더 존재하지만, 스트림 생성만 보다가 지칠 것 같으므로 스트림의 연산으로 넘어가겠다.
중간연산은 연산 결과도 스트림이기 때문에, 연속해서 계속 중간연산을 연결하면 원하는 복잡한 데이터 처리를 할 수 있게 된다.
👍 스트림의 중간연산
중간연산도 마찬가지로, 예시를 위주로 설명하겠다!
또한 여기에서 언급한 것들 외에도 다양한 연산이 있으니, 연습해보면 좋을 것 같다.
1. distinct()
중복을 제거한다.
아래 예시에서는 PlayerName으로 중복되는 값이 있는지 검사하고, 중복 값이 존재하면 예외를 던진다.
private void validateDuplicatedNames(List<PlayerName> names) {
if (names.size() != names.stream().distinct().count()) {
throw new IllegalArgumentException("플레이어 이름은 중복될 수 없음!");
}
}
2. filter()
조건을 만족하는 데이터만을 남기고, 나머지는 제외한다.
public static Menu findMenuByName(String name) {
return menus.stream()
.filter(element -> element.getName().equals(name))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("그런 메뉴는 없습니다"));
}
메뉴 데이터가 담겨있는 menus로부터 스트림을 생성한 후, 파라미터로 들어온 name과 스트림의 데이터 중 이름을 비교해 같은 것만을 골라내고 있습니다.
3. sorted()
스트림 속 데이터를 정렬한다.
파라미터로 Comparator를 지정해서 스트림을 정렬할 수 있고, 지정하지 않으면 기본 정렬 기준인 Comparable로 정렬한다.
4. map()
데이터에서 원하는 형태로 변환한다.
파라미터로 input과 output이 모두 존재하는 람다식을 받아서 기존 요소를 새로운 요소로 매핑시킨다.
코드는 이해할 필요 없다! 아래 예시를 보자.
옆에 흐릿한 Stream<String> 이 것을 따라가 보자.
처음에 List<String> 형태의 names로부터 스트림을 생성했기 때문에 String으로 이루어진 스트림이 생성되었다. 후에 각 요소 name에 대해서, name -> new Name(name)을 실행했더니 이제는 원시값이 포장된 Name 타입으로 이루어진 스트림으로 가공되었다. 후에 new Player()까지 호출하니 Player로 이루어진 스트림으로 가공되었다.
private int calculateScore() {
return cards.stream()
.mapToInt(Card::getScore)
.sum();
}
mapToInt는 스트림을 IntStream으로 바꿔준다. IntStream, LongStream, DoubleStream같은
5. flatMap()
블랙잭 미션에서, flatMap을 활용할 수 있는 사례가 있어서 설명하겠다!
private static Deque<Card> initializeCards() {
Deque<Card> cards = new ArrayDeque<>();
for (Value value : Value.values()) {
for (Shape shape : Shape.values()) {
cards.push(new Card(value, shape));
}
}
return cards;
}
카드게임을 위해서 52장의 카드를 만드는 과정이다.
여기서 Value는 Ace, 2, 3, ... Jack을 의미하는 enum이고 Shape은 스페이드, 다이아몬드, 하트, 클로버를 가지고 있는 enum이다. .values()는 enum 내에 정의된 인스턴스들을 반환하는 메서드이다.
이중 for문을 사용해서 각각의 요소들을 조합한 카드를 만든다. 예) 3스페이드, A클로버
이 과정을 스트림으로 변환하면 어떨까? 왠지 map을 활용해서 하면 좋을 것 같다.
private static Deque<Card> initializeCards() {
List<Card> cards = Arrays.stream(Value.values())
.map(value -> Arrays.stream(Shape.values())
.map(shape -> new Card(value, shape)))
.collect(Collectors.toList());
return new ArrayDeque<>(cards);
}
이렇게 만들면 완벽하게 작동할 것 같은데... 어라라?
불쾌한 빨간줄이 뜨고 있다... 이유는 각 요소들이 하나의 스트림으로 합쳐지지 않고, 스트림의 스트림 형태로 되고 있다.
이럴 때에 간단히 flatMap을 활용해 해결할 수 있다!
private static Deque<Card> initializeCards() {
return Arrays.stream(Value.values())
.flatMap(value -> Arrays.stream(Shape.values())
.map(shape -> new Card(value, shape)))
.collect(Collectors.toCollection(ArrayDeque::new));
}
이렇게 collect를 활용한 반환 타입까지 바꾸어 완벽하게 만들어 주었다!
중간연산도 더 많은 종류가 있지만, 이쯤 하고 이제 최종 연산으로 넘어가볼까 한다!
👍 스트림의 최종연산
최종 연산은 스트림의 요소를 소모하므로 한 번 하고 나면 스트림은 닫혀 버린다. 최종 연산을 여러 번 하고 싶다면 스트림을 여러 번 생성해야 한다ㅠㅠ
1. forEach()
스트림의 요소를 소모하는 최종연산이다. 반환 타입이 void이고, 주로 데이터 출력에 사용된다. 파라미터로 Consumer를 요구한다.
menus.forEach(System.out::println);
2. allMatch, anyMatch, noneMatch
조건 검사에 사용된다. boolean을 반환하며 조건에 모든 요소가 일치하는지, 일부가 일치하는지, 모든 요소가 불일치하는지 확인할 수 있다. 파라미터로 Predicate을 요구한다.
private boolean hasAce() {
return cards.stream().anyMatch(Card::isAce);
}
cards에 ACE 카드가 존재하는지 확인하는 메서드인데, 카드가 에이스이면 true를 반환하는 Card.isAce() 메서드가 한 번이라도 true를 반환하면 anyMatch() 메서드는 true를 반환하게 될 것이다.
3. reduce()
스트림의 데이터를 줄여 나가면서 연산을 수행하고 최종 결과를 반환한다. 맨 앞부터 2개씩 연산하고, 그 결과로 하나씩 더 연산해 나간다. 모든 데이터를 다 소모하면 결과를 반환하고 끝난다.
public static void main(String[] args) {
int sum = IntStream.rangeClosed(1, 5).reduce(0, (number1, number2) -> number1 + number2);
int count = IntStream.rangeClosed(1, 5).reduce(0, (number1, number2) -> number1 + 1);
System.out.println(sum); // 15
System.out.println(count); // 5
}
데이터 전체의 개수를 세거나, 데이터의 합을 구하는 메서드를 reduce()를 통해 구현할 수 있다. 여기서 중요한 내용은 아니긴 하지만, 참고로 mapToInt를 한 후에 IntStream의 count()나 sum()을 사용하는 쪽이 더 좋다. 아래처럼 말이다. (위에서 이미 등장한 코드이다.)
private int calculateScore() {
return cards.stream()
.mapToInt(Card::getScore)
.sum();
}
4. collect()
파라미터로 Collector라는 인터페이스 타입을 받아서, 데이터를 원하는 타입으로 반환한다.
내부에 Collector를 구현한 클래스의 객체라면 뭐든 반환하게 할 수 있다. 사실 다 외우려고 하기보다는 IDE가 추천해주는 대로 가는 것이 유익할 때가 가장 많다^..^... 나도 주관식으로 쓰라고 하면 잘 못 쓰지만, IntelliJ가 추천해주는 것 중 선택하는 것은 자신있다.
Collectors 클래스 안에는 이미 static 메서드로 변환할 수 있는 데이터 타입들을 다 담아놨다.
캡쳐한 것만 이만큼이고 진짜 겁나 많다. 그러니, 그대가 다루려는 데이터 타입이 뭔지는 모르겠지만, 애지간해서는 이 안에 다 있지 않을까..? 반환 타입을 결심하고 IDE에 조금 기대보면 사실 원하는 건 모두 이룰 수 있다. (아마도)
5. joining()
스트림을 String으로 결합한다.
나는 OutputView에서 굉장히 유용하게 사용해 왔는데, 예시를 하나 들어 보겠다.
%s 자리에 Stream<String>으로 이루어진 Player들의 이름을 사이에 쉼표를 찍어서 넣고 싶다.....
어떻게 해야하지...? StringBuilder를 만들어 append한다, 문자열을 겁나 +로 이어본다... 쉼표는 어디에 어떻게 찍을지 좀 머리가 아프네..? 할 때! .collect()에서 Collectors가 이미 친히 static 메서드로 만들어 놓은 joining을 사용하면 된다!
delimiter는 사이에 넣을 거를 쓰라는 말이고 따라서 List가 "깃짱", "이리내"였다면, 위의 코드를 통해서 "깃짱, 이리내"로 합쳐진다. 만약에 delimiter에 아무것도 안 넣어놓으면 "깃짱이리내"로 합쳐진다.
이외에도 collect()에 대해서 많은 내용이 있지만, 나는 여기까지만 다루어볼 예정이다.
우리는 이제 스트림을 꽤나 활용할 수 있다. 데이터타입이 어떻건 생성할 수 있고, 그다음 내가 원하는 방식대로 가공할 수 있으며, 내가 원하는 형태로 반환하거나 소모해버릴 수 있다.
스트림을 사용해보자! 신난다!
다음 편에서는 스트림을 사용해야 할 때와 사용하지 말아야 할 때에 대해서 다루어볼 것이다.
정답은 없다. 프로그래밍은 늘 그렇다.
나와 내 동료가 다 좋아하면 맞는 것 아닐까...?
따라서 나는 내 동료와 내 상사가 스트림을 사랑해서 모든거 스트림으로 바꾸자고 하면 사용하는 것이 맞고, 내 동료나 상사가 서툴거나 스트림을 배제하는 철학이 있다면 사용하지 않는 것이 맞다고 생각한다. 그치만 어떨 때 좋고 어떨 때 안좋을 지에 대해서 한 번 써보려고 한다. 3편까지 봐줄꺼징?
스트림 하면 이것밖에 모르던 그때가 좀 더 재미있었던 것 같기도 하다...
언제 쓸 지 모르지만, 깃짱 ㅍㅇㄸ!
'JAVA' 카테고리의 다른 글
[JAVA] 컴포지션(Composition): 조합이 뭘까? 컴포지션의 개념/사용, 상속을 사용한 코드를 조합을 사용한 코드로 바꿔보기! (4) | 2023.03.23 |
---|---|
[JAVA] 함수형 프로그래밍: 개념, 스트림에서 부작용 없는 함수를 사용해야 하는 이유, 순수 함수 (20) | 2023.03.22 |
[JAVA] 람다와 스트림(Lambda, Stream)(1): 람다는 함수형 인터페이스의 구현체이다? (5) | 2023.03.07 |
[JAVA] 예외처리(1): 에러 VS 예외, 컴파일 예외 VS 런타임 예외, 헷갈리는 용어 정리(Checked, Unchecked Exception, 검사 예외 등등) (0) | 2023.02.22 |
[JAVA] 널(null) 안전성과 옵셔널(Optional) (0) | 2023.02.19 |