Skip to content

Latest commit

 

History

History
183 lines (149 loc) · 6.96 KB

스트림에서는_부작용없는_함수를_사용하라.md

File metadata and controls

183 lines (149 loc) · 6.96 KB

스트림에서는 부작용 없는 함수를 사용하라

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가 발생하는지 보시죠.

예시 1

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 원본을 변경하는 연산이 들어간다면? 실제로 원하는 결과와는 전혀 다른 결과가 발생할 수 있게 되는 것입니다.

예시 2

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에 값을 추가하다 보니 종국에는 에러를 발생시킵니다.

스크린샷 2022-02-27 오전 12 38 49

이럴때 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))));

이외에도 CollectionStream으로 변경하여 문자열로 잇거나, 필터링하여 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을 똑똑하게 사용할 수 있습니다.