본문 바로가기
Dev/Java

[Effective Java] Item 46. 스트림에서는 Side Effect 없는 함수를 사용하라

by 재영(ReO) 2023. 3. 7.

아래는 우아한테크코스 5기 Level 1의 블랙잭 미션을 진행하며 작성한 코드이다.

public static Participants from(List<String> names) {
        List<Participant> participants = new ArrayList<>();
        participants.add(new Dealer());
        names.forEach(name -> participants.add(new Player(name)));

위 코드에는 잘못된 점이 있다.
무엇이 잘못되었을까?

정답은, 이 글을 쭉 읽다보면 알 수 있다.

 

 

위의 코드에 대해 리뷰어가 커멘트를 남겨주었다.
이유가 궁금하여 관련 내용을 찾아봤고, Effective Java의 Item 46에서 해답을 찾았다.

 

 

 

스트림(Stream)은 왜 만들어졌을까?


스트림은 그저 또 하나의 API가 아니다.
스트림은 함수형 프로그래밍에 기초한 패러다임이다.
그러므로 스트림이 제공하는 표현력, 속도, 병렬성을 얻으려면 API는 물론이고 패러다임 자체를 함께 받아들여야 한다.

그렇다면 함수형 프로그래밍이란 무엇일까?

 

 

함수형 프로그래밍


함수형 프로그래밍이란 컴퓨터 프로그래밍의 패러다임 중 하나이다.
함수형 프로그래밍은 상태 변경을 최소화하거나 없애는 것을 목표로 하며, 함수를 중심으로 프로그래밍하는 방식이다.
함수형 프로그래밍에서는 함수를 수학적 함수처럼 취급한다.

함수형 프로그래밍은 다음과 같은 특징을 갖는다.
1. 순수 함수(Pure Function) : 함수의 반환 값은 함수가 받은 인자 값에만 의존한다. 따라서 같은 인자로 호출할 때 항상 같은 결과를 반환한다. 또한, 함수가 외부 상태를 변경하지 않는다.

2. 불변성(Immutability) : 변수나 객체의 상태를 변경할 수 없다. 대신에 새로운 값을 만들어내는 방식으로 동작한다.

3. 고계 함수(High-Order Function) : 함수를 인자로 받거나, 함수를 반환할 수 있다.

4. 재귀(Recursion) : 재귀 함수를 이용하여 반복문 대신에 문제를 해결한다.

함수형 프로그래밍은 병렬 처리, 코드 재사용성, 테스트 용이성 등 다양한 장점을 가지고 있다.


 

 

 

그렇다면 forEach의 사용이 무엇이 잘못됐을까?

 

스트림 패러다임의 핵심은 계산을 일련의 변환(transformation)으로 재구성하는 부분이다.
각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.
즉, (중간 단계든 종단 단계든) 스트림 연산에 건네는 함수 객체는 모두 side effect가 없어야 한다.


다시 한번, 위의 코드를 보자.

public static Participants from(List<String> names) {
        List<Participant> participants = new ArrayList<>();
        participants.add(new Dealer());
        names.forEach(name -> participants.add(new Player(name)));

forEach를 사용하여 외부 상태인 participants에 새로운 Player객체를 add()하고 있다.

함수형 프로그래밍의 특징인 순수 함수를 위반한 것이다.

 

Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
    words.forEach(word -> {
        freq.merge(word.toLowerCase()), 1L, Long::sum);
    });
}

위의 코드도 비슷한 맥락에서 옳지 않은 스트림 용법이라고 볼 수 있다.
스트림도 사용했고, 람다도 사용했고, 메서드도 사용했으며 결과도 올바르지만, 스트림을 제대로 사용했다고 볼 수 없다.

위의 코드는 스트림 코드를 가장한 반복적 코드이다.

스트림 API의 이점을 살리지 못하여 같은 기능의 반복적 코드보다 길고, 읽기 어려우며 유지보수에도 좋지 않다.

코드의 모든 작업이 종단 연산인 forEach에서 일어나는데, 이 때 외부 상태인 freq을 수정하는 람다를 실행한다.

 

그렇다면 위의 코드를 어떻게 스트림답게 사용할 수 있을까?

Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
    freq = words.
        collect(groupingBy(String::toLowerCase, counting()));
}

위의 코드는 짧고 명확하다. 또한 words를 입력으로 받아서, groupingBy라는 Collector의 메서드를 사용하여 새로운 객체를 freq에 assign한다. 순수함수이며 불변성을 보장한다. 이와 같은 이유로 스트림 API를 제대로 사용했다고 볼 수 있다.

 

 

 

그러므로 forEach의 사용을 지양하자.
forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자.
forEach 연산은 종단 연산 중 기능이 가장 적고 '덜' 스트림답다.

 

 

 

 

그렇다면 종단 연산 중 어떤 것이 스트림답다고 할 수 있을까?

 

Collectors


스트림을 사용하려면 꼭 배워야하는 개념 중 수집기(Collector)가 있다.
Collector는 메서드를 무려 43개나 가지고 있으며, parameter가 5개인 것도 있다.
다행히 세부 내용은 잘 몰라도 된다.
그저 축소(reduction) 전략을 캡슐화한 블랙박스 객체라고 생각하면 된다.
일반적으로 컬렉션을 생성하기 때문에 Collector라고 명명되었다.

여기서 축소란, 스트림의 원소들을 객체 하나에 취합한다는 뜻이다.

 

 

 

 

주로 쓰이는 수집기 중 대표적 3가지


가장 주로 쓰이는 수집기 중 대표적인 3가지가 있다.
1. toList()

2. toSet()

3. toCollection(collectionFactory)

(그리고 이에 관한 불변객체를 반환해주는 메서드가 각각 존재한다. (ex. Collections.toUnmodifiableList))

이름에서 직관적으로 알 수 있듯이, 위의 세 수집기는 스트림 파이프라인을 통과한 데이터를 이용하여 각각 List, Set, Collection의 구현체를 반환한다.

Map<String, Long> freq;
List<String> topTen = freq.ketSet().stream()
    .sorted(comparing(freq::get).reversed())
    .limit(10)
    .collect(toList());

위의 코드는 빈도표인 freq에서 가장 흔한 단어 10개를 뽑아내는 파이프라인이다.
마지막 .collect(toList())에서 가장 흔한 단어 10개를 List로 반환하고 있다.

 

 

 

 

toMap()


또 하나의 대표적인 수집기로는 toMap()이 존재한다.
toMap()은 세 가지 버전을 가지고 있다.

 

 

첫 번째 버전

toMap의 첫번째 버전은 두 개의 parameter를 갖는다.
이 parameter는 각각 key, value가 된다.

private static final Map<String, Operation> stringToEnum = 
    Stream.of(values()).collect(
        toMap(Object::toString, e -> e));

위의 코드는 values()에서 각 원소를 스트림 파이프라인에 넣는다. 그 후 각 원소를 toString한 데이터를 key, 그리고 각 원소(Operation)를 value로 가지는 Map을 반환한다.

 

List<String> names = Arrays.asList("John", "Mary", "Tom", "Mary");
Map<String, Integer> nameMap = names.stream()
    .collect(Collectors.toMap(Function.identity(), String::length));
System.out.println(nameMap);

위의 코드는 각 사람의 이름이 담겨 있는 names라는 List<string>의 데이터를 파이프라인에 넣는다. 이 파이프 라인은 각 사람의 이름 자체를 key로, 이름의 길이를 value로 가지는 map을 반환한다. 


그런데 혹시, 뭔가 쎄~한 느낌이 들지 않는가?


위의 코드에서 "Mary"라는 이름은 중복되고 있다.
이런 경우, 먼저 파이프라인을 통과한 names의 두번째 원소인 "Mary"가 먼저 4의 value를 가지며 Map에 들어가있을 것이다.
그러면 네 번째 원소인 "Mary"를 맵에 넣을 때, 기존 value와 새로운 value를 어떻게 처리해야 할 지에 대한 지침이 필요해진다.
그래서, 두 번째 버전이 필요하다.

 

두 번째 버전

toMap()의 두 번째 버전은, 세 개의 parameter를 갖는다. 세 번째 parameter는 병합(merge) 함수로, 충돌에 대한 전략을 넣어줄 수 있다.

List<String> names = Arrays.asList("John", "Mary", "Tom", "Mary");
        Map<String, Integer> nameMap = names.stream()
            .collect(Collectors.toMap(Function.identity(), String::length, Integer::sum));

위 코드에서, 세 번째 인자로 Integer::sum을 갖는 것을 볼 수 있다. Integer인 value들이 충돌할 경우, sum을 한다는 뜻이다.
이 경우 nameMap은 {Tom=3, John=4, Mary=8}이 된다.

Map<Artist, Album> topHits = albums.collect(
    toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));

위 코드는, Album 객체들이 담겨있는 albums에서, Map<Artist, Album>을 생성한다.
key는 Album의 artist, value는 Album 객체 자체, 그리고 충돌이 생길 경우 maxBy라는 정적 팩터리 메서드를 사용한다.
maxBy는 BinaryOperator에서 정적 임포트한 메서드이며 Comparator<T>를 입력받아 BinaryOperator<T>를 반환한다.
즉, 충돌이 생길 경우 두 Album 객체의 sales를 비교하여 둘 중에 더 큰 값을 value로 취하도록 하는 것이다.

toMap(keyMapper, valueMapper, (oldVal, newVal) -> newVal)

위의 코드처럼, 이미 존재하는 메서드 뿐만 아니라 직접 람다식을 통해 병합에 대한 전략을 넣어줄 수도 있다.

 

 

그런데, Map의 특정한 구현체인 TreeMap으로 반환을 하고 싶을 수 있다.

이러한 경우를 위해 toMap의 세 번째 버전이 존재한다.

 

 

세 번째 버전

List<String> names = Arrays.asList("John", "Mary", "Tom", "Mary");
Map<String, Integer> nameMap = names.stream()
    .collect(Collectors.toMap(Function.identity(), String::length, (u, v) -> u, TreeMap::new));
System.out.println(nameMap);

세 번째 버전의 toMap은, 네 번째 parameter로 특정 구현체를 받는다.
그러면 반환되는 Map이, parameter로 지정된 특정 맵 구현체로 반환된다.

위 코드의 경우는 {John=4, Mary=4, Tom=3}이라는 TreeMap을 nameMap에 assign하고 있다.

 

 

 

 

 

groupingBy


Collector에는 특정 조건을 기준으로 원소들을 묶어서 반환하는, groupingBy라는 메서드도 있다.

입력은 분류 함수(classifier)이며, 출력은 원소들을 카테고리별로 모아 놓은 Map이다.

 

첫 번째 버전

List<String> words = Arrays.asList("apple", "banana", "orange", "pear", "grape");
Map<Integer, List<String>> result = words.stream().collect(Collectors.groupingBy(String::length));

위 코드는 words의 원소들을 length 별로 분류한 Map을 반환한다. ({5=[apple, grape], 6=[banana, orange], 4=[pear]})
원소들을 분류하는 기준인 "카테고리"가 맵의 key가 되고, 그 "카테고리"에 해당하는 원소들을 담은 리스트를 value로 갖는다.

 

 

혹시 이 Map의 value로, "리스트" 대신 다른 컬렉션에 원소들을 담고 싶지 않은가?

 

 

두 번째 버전

List<String> words = Arrays.asList("hello", "world", "java", "programming", "language", "java");

Map<Character, Set<String>> result1 = words.stream()
        .collect(Collectors.groupingBy(
                word -> word.charAt(0), Collectors.toSet()));
                

Map<Character, Set<String>> result2 = words.stream()
        .collect(Collectors.groupingBy(
                word -> word.charAt(0), Collectors.toCollection(TreeSet::new)));

위의 코드들에서, 각 파이프라인에 있는 groupingBy는 세 번째 인자로 각각 Collectors.toSet()과 Collectors.toCollection(TreeSet::new)를 받고 있다. 이렇게 세 번째 parameter로 특정 컬렉션을 선택해서 넣어주면 그 컬렉션으로 반환이 가능하다.

 

 

혹은 카테고리 별로 아이템들을 굳이 몰라도 되는 경우는 어떨까? 즉, 어떤 아이템이 있는지보다 그 갯수가 중요한 경우에는 다음과 같이 코드를 작성하면 된다.

Map<Person.Gender, TreeSet<Person>> personSetByGender = people.stream()
        .collect(Collectors.groupingBy(Person::getGender, Collectors.counting()));

위 코드는 세 번째 parameter로 Collectors.counting()을 받음으로써 각 카테코리별 원소의 갯수를 value로 가지는 Map을 반환한다. (예를 들면, {남=3, 여=2}) 이와 같이 다운스트림 수집기로 counting()을 넘기면 아이템들을 넣은 컬렉션이 아닌, 같은 키를 가지는 아이템들의 수를 value로 가지게 할 수 있다.

 

 

세 번째 버전

Map<Person.Gender, TreeSet<Person>> personSetByGender = people.stream()
        .collect(Collectors.groupingBy(Person::getGender, TreeMap::new, Collectors.toCollection(TreeSet::new)));

toMap의 세 번째 버전과 마찬가지로, groupingBy 또한 반환하는 맵의 구현체를 다른 것으로 할 수 있다.
다만 위의 코드를 보면, 반환되는 맵의 구현체인 TreeMap이 두 번째 인자로 들어가있는 것을 볼 수 있다.
두 번째 버전에서는 두 번째 인자가 value에 들어가는 컬렉션을 지정해주었기 때문에,
이 버전의 groupingBy는 점층적 인수 목록 패턴을 위반했다.
그러므로, 사용시 유의해야 한다.

앞에서 본 세 가지 버전에 각각 대응하는 groupingByConcurrent 메서드가 존재한다.
대응하는 메서드의 동시 수행 버전으로, 각각은 ConcurrentHashMap 인스턴스를 반환한다.

 

groupingBy의 사촌격인 partitioningBy도 존재한다.
이 메서드는 분류 함수 자리에 predicate를 받고, 키가 Boolean인 맵을 반환한다.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

Map<Boolean, List<Integer>> evenOddMap = numbers.stream()
    .collect(Collectors.partitioningBy(n -> n % 2 == 0));

아래 코드의 실행 결과는 {false=[1, 3, 5, 7, 9], true=[2, 4, 6, 8, 10]}이 된다. partitioningBy의 predicate가 짝수인 경우 true를 반환하기 때문이다.

 

 

 

그러나, collect(counting())의 형태로 사용할 일은 전혀 없다.
stream의 count메서드를 직접 사용하여 같은 기능을 수행할 수 있기 때문이다.
Collections에는 사실 이런 속성의 메서드가 16개나 더 있다. summing, averaging, summarizing으로 시작하고 각각 int, long, double 스트림용으로 하나씩 존재한다.
reducing, filtering, mapping, flatMapping, collectingAndThen 등의 메서드도 있으나, 몰라도 무방하다.

 

 

수집과는 관계없는 Collector의 메서드도 존재한다.
minBy와 maxBy는 인수로 받은 비교자를 이용해 스트림에서 값이 가장 작은, 혹은 큰 원소를 찾아 반환한다.
stream 인터페이스의 min과 max 메서드를 살짝 일반화한 것이고, BinaryOperator의 minBy, maxBy 메서드가 반환하는 이진 연산자의 수집기 버전이다.

 

joining은 문자열 등의 CharSequence 인스턴스의 스트림에만 적용 가능하다. 매개 변수가 없으면 단순히 연결(concatenate)하는 수집기이고, 매개변수가 있으면 이를 delimiter로 사용한다. 매개변수가 3개짜리인 joining도 있는데, prefix(접두어), suffix(접미어)도 받는다. 

 

 

 

이상으로 스트림 파이프라인 프로그래밍의 핵심 패러다임과 forEach 사용을 지양해야하는 이유, 그리고 "스트림다운" 종단연산 중 Collector의 메서드 43개를 살펴봤다.

잘 활용하여 스트림 다운 스트림을 사용할 수 있도록 하자. 

'Dev > Java' 카테고리의 다른 글

ConcurrentHashMap이란?  (0) 2023.03.07
다운스트림(Down Stream)이란?  (0) 2023.03.07
Java 8 -> Java 11에서 추가된 요소들  (1) 2022.10.31
Java 컨벤션 요약  (0) 2022.10.31