DEV ℧ Developer Diary

[EffectiveJava] item46 - 스트림에서는 부작용 없는 함수를 사용하라

스트림은 처음 봐서는 이해하기 어려울 수 있다.

스트림은 그저 또하나의 API가 아닌, 함수형 프로그래밍에 기초한 패러다임이기 때문이다. 스트림이 제공하는 표현력, 속도, 병렬성을 얻으려면 API는 말할 것도 없고 이 패러다임 까지 함께 받아들어야 한다.

스트림 패러다임

스트림 패러다임의 핵심은 계산을 일련의 변환(transformations)으로 재구성 하는 부분이다. 이때 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.
이렇게 하려면 스트림 연산에 건네느 함수 객체는 모두 부작용(side effect)이 없어야 한다.

순수 함수란?
오직 입력만이 결과에 영향을 주는 함수를 말한다.

스트림 패러다임을 이해하지 못한 채 API만 사용했다 - 따라 하지 말 것!

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에서 일어난다. forEach가 그저 스트림이 수행한 연산결과를 보여주는 일 이상을 하기 때문이다.

스트림을 제대로 활용해 빈도표를 초기화 한다.

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

이번엔 collect를 이용해 스트림API를 제대로 사용했다.

다시한번 forEach로 돌아가자면 for-each 반복문은 forEach 종단 연산과 비슷하게 생겼다. 하지만 forEach 연산은 종단 연산 중 기능이 가장 적고 ‘덜’ 스트림답다.

forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자.

수집기(collector)

위의 예시에서는 수집기(collector)를 사용하는데, 스트림을 사용하려면 꼭 배워야 하는 새로운 개념이다.

java.util.stream.Collectors 클래스는 메서드를 무려 39개(java10 에서는 총 43개)를 가지고 있고, 그중에는 타입 매개변수가 5개나 되는것이 있다.

수집기에 익숙해지기 전까지는 그저 축소(reduction) 전략을 캡슐화한 블랙박스 객체라고 생각하면 된다.
수집기가 생성하는 객체는 일반적으로 컬렉션이며, 그래서 “collector”라는 이름을 쓴다.

축소(reduction) 전략 : 스트림의 원소들을 객체 하나에 취합한다는 뜻.

한번 39개의 메소드를 차례차례 살펴보도록 하자.

toList, toSet, toCollection(collectionFactory)

수집기를 사용하면 스트림의 원소를 손쉽게 컬렉션으로 모을 수 있다. 수집기는 총 세가지가 있다.

  • toList: 리스트
  • toSet: 집합
  • toCollection(collectionFactory): 프로그래머가 지정한 컬렉션 타입

위와 같은 타입을 반환한다.

빈도표에서 가장 흔한 단어 10개를 뽑아내는 파이프라인

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

위의 예제에서 어려운 부분은 sorted에 넘긴 비교자, comparing(freq::get).reversed()이다.
comparing 메서드는 키 추출 함수를 받는 비교자 생성 메서드이고, 키추출 함수로 쓰인 freq::get은 입력받은 단어(키)를 빈도표에서 찾아(추출) 그 빈도를 반환한다.
그런다음 가장 흔한 단어가 위에 오도록 비교자(comparing)를 역순(reversed) 한다.

toMap

가장 간단한 맵 수집기는 toMap(keyMapper, valueMapper)로, 스트림 원소를 키에 매핑하는 함수와 값을 매핑하는 함수를 인수로 받는다.

toMap 수집기를 사용하여 문자열을 열거 타입 상수에 매핑한다.

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

위의 형태는 간단한 toMap으로 스팀 원소 다수가 같은 키를 사용한다면 파이프라인이 IllegalStateException을 던지며 종료될 것이다.

더 복잡한 형태의 toMap

더 복잡한 형태의 toMap이나 groupingBy는 이런 충돌을 다루는 다양한 전략을 제공한다.
예컨대 toMap에 키 매퍼와 값 매퍼는 물론 병합(merge) 함수까지 제공할 수 있다.

병합 함수의 형태는 BinaryOperator<U> 이며, 여기서 U는 해당 맵의 값 타입이다.

인수를 3개 받는 toMap

인수를 3개 받는 toMap은 어떤 키와 그 키에 연관된 원소들 중 하나를 골라 연관 짓는 맵을 만들 때 유용하다.

각 키와 해당 키의 특정 원소를 연관 짓는 맵을 생성하는 수집기

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

말로 풀어보자면 “앨범 스트림을 맵으로 바꾸는데, 이 맵은 각 음악가와 음악가의 베스트 앨범을 짝지은 것”이 된다. 우리가 풀려고 한 문제를 그대로 기술한 코드가 되었다.

또한 인수가 3개인 toMap은 충돌이 나면 마지막 값을 취하는 (last-write-wins) 수집기를 만들때도 유용하다. 많은 스트림의 결과가 비결정적이지만, 매핑 함수가 키 하나에 연결해준 값들이 모두 같을때, 이렇게 동작하는 수집기가 필요하다.

마지막에 쓴 값을 취하는 수집기

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

인수를 4개 받는 toMap

마지막 toMap은 네번째 인수로 맵 팹터리를 받는다. 이 인수로는 EnumMap이나 TreeMap처럼 원하는 특정 맵 구현체를 직접 지정할 수 있다.

이상의 세가지 toMap에는 변종이 있는데, 그중 toConcurrentMap은 병렬 실행된 후 결과로 ConcurrentHashMap 인스턴스를 생성한다.

groupingBy

이번에는 Collectors가 제공하는 또 다른 메서드인 groupingBy에 대해 알아보자.

이 메서드는 입력으로 분류 함수(classifier)를 받고 출력으로는 원소들을 카테고리별로 모아 놓은 맵을 담은 수집기를 반환한다. 분류함수는 입력받은 원소가 속하는 카테고리를 반환한다.

다중정의된 groupingBy중 형태가 가장 간단한것은 분류 함수 하나를 인수로 받아 맵을 반환하는 것이다. 반환된 맵에 담긴 각각의 값은 해당 카테고리에 속하는 원소들을 모두 담은 리스트다.

words.collect(groupingBy(word -> alphabetize(word)))

리스트 외의 값을 생성하는 groupingBy

groupingBy 가 반환하는 수집기가 리스트외의 값을 갖는 맵을 생성하게 하려면, 분류 함수와 함께 다운스트림(downstream) 수집기도 명시해야한다.

다운스트림(downstream) 수집기 : 해당 카테고리의 모든 원소를 담은 스트림으로부터 값을 생성하는 일

명시할 수 있는 다운스트림은 아래와 같다.

  • toSet() : 원소들의 집합(Set)을 값으로 갖는 맵을 만들어낸다.
  • toCollection(collectionFactory): 원소들의 컬렉션을 값으로 갖는 맵을 생성한다.
  • counting(): 각 카테고리(키)를(컬렉션이 아닌) 해당 카테고리에 속하는원소의 개수(값)와 매핑한 맵을 얻는다.

counting()의 예시

Map<String, Long> freq = words
    .collet(groupingBy(String::toLowerCase, counting()));

맵 팩터리를 지정하는 groupingBy

groupingBy 에는 다운스트림 수집기에 더해 맵 팩터리를 지정할 수 있게 하는 메서드가 있다.

참고로 이 메서드는 mapFactory 매개변수가 downStream 매개변수보다 앞에 놓여, 점층적 인수 목록 패턴에 어긋난다.

이 메서드를 사용하면 맵과 그안에 담긴 컬렉션의 타입을 모두 지정할 수 있다. 예를 들면 TreeSetTreeMap을 반환하는 수집기를 만들 수 있다.

이상의 총 세가지 groupingBy 각각에 대응 하는 groupingByConcurrent 메서드들도 볼 수 있다. 동시 수행 버전인 ConcurrentHashMap 인스턴스를 만들어준다.

partitioningBy

groupingBy의 사촌격인 partitioningBy는 분류 함수 자리에 프레디키트(predicate)를 받고 키가 Boolean인 맵을 반환한다.
프레디키트에 더해 다운스트림 수집기까지 입력받는 버전도 다중정의되어 있다.

summing, averaging, summarizing

앞서 설명한 counting 메서드는 다운스트림 수집기 전용이어서 collect(counting()) 형태로 사용할 일이 전혀 없다. 이런 속성의 메서드가 16개나 더있다.

그중 9개는 이름이 summing, averaging, summarizing으로 시작하며, 각각 int, long, double 스트림용으로 하나씩 존재한다.

reducing 메서드

다중정의된 reducing메서드들 filtering, mapping, flatMapping, collectingAntThen 메서드들은 대부분 스트림 기능을 일부 복제하여 다운 스트림 수집기를 작은 스트림처럼 동작하게 한것이므로, 몰라도 크게 상관이 없다.

minBy, maxBy

minBy와 maxBy는 “수집”과는 관련이 없지만 Collectors에 정의되어 있다.

  • minBy: 인수로 받은 비교자를 이용해 스트림에서 값이 가장 작은 원소를 찾아 반환한다.
  • maxBy: 인수로 받은 비교자를 이용해 스트림에서 값이 큰 작은 원소를 찾아 반환한다.

joining

이 메서드는 (문자열 등의) CharSequence 인스턴스의 스트림에만 적용할 수 있다. 이중 매개변수가 없는 joining 은 단순히 원소들을 연결하는 수집기를 반환한다.

인수를 하나받은 joining

인수하나짜리 joining은 CharSequence 타입의 구분문자(delimiter)를 매개변수로 받는다.

인수를 세개받는 joining

구분문자에 더해 접두문자(prefix), 접미문자(suffix)도 받는다.

참조 : Collectors에 대한 API문서를 보고 하나씩 짚어가는것도 좋다.