Stream의 핵심 패러다임은 계산을 변환으로 재구성 하는 것입니다.
각 변환의 단계는 이전 단계의 결과를 받아 처리하는 순수한 함수여야 합니다.
즉, 다른 가변 상태를 참조하지 않으면서 함수 스스로도 다른 상태를 변경하면 안된다는 의미입니다.
아래 코드를 봅시다.
public static Map<String, Long> streamSideEffect(final Stream<String> strings) {
Map<String, Long> sideEffect = new HashMap<>();
strings.forEach(string -> {
sideEffect.merge(string, 1L, Long::sum);
});
return sideEffect;
}
현재 Stream 파이프라인은 forEach
종단 연산에서 sideEffect map의 상태를 변경하고 있습니다.
forEach
를 사용하는 것은 Stream을 사용하는 것이 아닌 단순 반복문 사용에 불과하며 또다른 Side Effect를 발생시킵니다.
아래 코드를 통해 어떤 SideEffect가 발생하는지 보시죠.
public class StreamLaziness {
private static IntStream createAStreamAndPerformSomeSideEffectWithPeek() {
return IntStream.of(1, 2, 3)
.peek(number -> System.out.printf("First. My number is %d\n", number))
.map(number -> number + 1);
}
private static void consumeTheStream(IntStream stream) {
stream.filter(number -> number % 2 == 0)
.forEach(number -> System.out.printf("Third. My number is %d\n", number));
}
public static void main(String[] args) {
IntStream stream = createAStreamAndPerformSomeSideEffectWithPeek();
System.out.println("Second. I should be the second group of prints");
consumeTheStream(stream);
}
}
먼저 forEach , peek
를 이용해 상태를 변경하는 경우 발생할 SideEffect입니다.
일반적으로 위 코드를 작성하면 아래와 같은 순서로 console에 print 될 것으로 예상합니다.
First. My number is 1
First. My number is 2
First. My number is 3
Second. I should be the second group of prints
Third. My number is 2
Third. My number is 4
하지만 실제로는 아래와 같이 출력되죠.
Second. I should be the second group of prints
First. My number is 1
Third. My number is 2
First. My number is 2
First. My number is 3
Third. My number is 4
중간 연산이라는 peek
메서드의 특성상 종단 연산 forEach
가 실행되는 시점에 Lazy 하게 Stream 파이프라인이 실행되는 것이라고 볼 수 있는데요.
만약 중간에 Stream 원본을 변경하는 연산이 들어간다면? 실제로 원하는 결과와는 전혀 다른 결과가 발생할 수 있게 되는 것입니다.
static void func1(){
List<Integer> matched = new ArrayList<>();
List<Integer> elements = new ArrayList<>();
for(int i=0 ; i< 100 ; i++) {
elements.add(i);
}
elements.parallelStream()
.forEach(e -> {
if(e>=50) {
System.out.println(Thread.currentThread().getId() + " " + matched);
matched.add(e);
}
});
System.out.println(matched.size());
}
실행 결과는 아래와 같습니다.
13 []
18 [null]
18 [81, 78]
.
.
.
17 [81, 78, 79, 80, 75, 76, 77, 84, 85, 86, 50, 51, 52, 68, 96, 97, 92, 87, 65, 66, 67, 88, 89, 62]
17 [81, 78, 79, 80, 75, 76, 77, 84, 85, 86, 50, 51, 52, 68, 96, 97, 92, 87, 65, 66, 67, 88, 89, 62, 63]
15 [81, 78, 79, 80, 75, 76, 77, 84, 85, 86, 50, 51, 52, 68, 96, 97, 92, 87, 65, 66, 67, 88, 89, 값62, 63, 64, 71]
쓰레드에 따라 값이 제각각일 뿐 아니라 추가되는 순서도 다르고 동시성을 보장하지 못하는 자료구조ArrayList
에 값을 추가하다 보니 종국에는 에러를 발생시킵니다.
이럴때 Stream API의 부작용 없는 메서드를 사용하면 코드의 가독성 뿐 아니라 위와 같은 Side Effect를 해결할 수 있습니다.
static void func2(){
List<Integer> elements = new ArrayList<>();
for(int i=0 ; i< 100 ; i++) {
elements.add(i);
}
List<Integer> matched = elements.parallelStream()
.filter(e -> e >= 50)
.collect(toList()); // 가독성을 높이기 위해 Collectors를 static import
System.out.println(matched.size() + " " + matched);
}
50 [50, 51, 52, 53, 54, 55, 56, 57, 58, 59, ... 98, 99]
바로 collect()
종단 연산인데요.
collect()
종단 연산은 Stream 연산을 객체 하나로 수집한다는 의미의 Collectors
클래스를 활용할 수 있습니다.
아래 코드처럼 다양한 Collections를 만들 수 있습니다.
// toList를 이용하여이름 List를 반환한다.
static List<String> collectToList(final List<User> users) {
return users.stream()
.map(User::getName)
.collect(toList());
}
// toCollection을 이용하여 원하는 Collection을 반환한다.
static Set<User> collectToSet(final List<User> users) {
return users.stream()
.sorted(comparingInt(User::getAge))
.collect(toCollection(TreeSet::new));
}
// 동명이인을 Map으로 grouping 할 수 있다.
static Map<String, Integer> groupingToMap(final List<User> users) {
return users.stream()
.collect(groupingBy(User::getName, summingInt(User::getAge)));
}
// downStream(나이 비교 최대값)을 이용하여 Map을 만들 수 있다.
static Map<String, User> collectorsToMap(final List<User> users) {
return users.stream()
.collect(toMap(User::getName, user -> user, BinaryOperator.maxBy(comparingInt(User::getAge))));
이외에도 Collection을 Stream으로 변경하여 문자열로 잇거나, 필터링하여 Map으로 전환하는 방법도 존재합니다.
// 이름을 문자열로 이어붙인다.
static String joiningCollection(final List<User> users) {
return users.stream()
.map(User::getName)
.collect(joining(", "));
}
// true / false로 파티셔닝 한다.
static Map<Boolean, List<User>> partitioningByFilter(final List<User> users) {
return users.stream()
.collect(partitioningBy(user -> user.getAge() > 25));
}
정리하자면 간단하다 forEach()
는 print시에만 사용합시다. 쓰레드 안정성이 떨어질 뿐 아니라 가독성도 떨어집니다.
Stream을 올바르게 사용하려면 SideEffect가 존재하지 않는 종단 연산을 사용해봅시다.
특히 Collectors클래스가 중요합니다! 다양한 메서드를 학습하여 익숙해지면 Stream을 똑똑하게 사용할 수 있습니다.