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

람다와 스트림, 다섯 번째 아이템

Posted by SungBeom on April 23, 2020 · 9 mins read

Intro

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

스트림 패러다임

스트림 패러다임의 핵심은 계산을 일련의 변환(transformation)으로 재구성하는 부분이다. 이때 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수(오직 입력만이 결과에 영향을 주는 함수)여야 한다. 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는다. 이렇게 하려면 중간 단계든 종단 단계든 스트림 연산에 건네는 함수 객체는 모두 부작용(side effect)이 없어야 한다.

텍스트 파일에서 단어별 수를 세어 빈도표로 만드는 일을 하는 코드를 보자.

1
2
3
4
5
6
// 스트림을 제대로 활용해 빈도표를 초기화한다.
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
    freq = words
        .collect(groupingBy(String::toLowerCase, counting()));
}
cs

스트림, 람다, 메소드 참조를 사용했고, 결과도 올바르다. 하지만 절대 스트림 코드라 할 수 없는데, 스트림 API의 이점을 살리지 못하여 같은 기능을 반복적 코드보다 조금 더 길고, 읽기 어렵고, 유지보수에도 좋지 않게 만들어졌다. 이 코드의 모든 작업이 종단 연산인 forEach에서 일어나는데, 이때 스트림이 수행한 연산 결과를 보여주는 일 이상의 외부 상태를 수정하는 람다를 실행하면서 문제가 생긴다. 올바르게 작성한 모습을 살펴보자.

1
2
3
4
5
6
// 스트림을 제대로 활용해 빈도표를 초기화한다.
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
    freq = words
            .collect(groupingBy(String::toLowerCase, counting()));
}
cs

앞서와 같은 일을 하지만, 이번엔 스트림 API를 제대로 사용했을 뿐만 아니라 짧고 명확하다. forEach 연산은 종단 연산 중 기능이 가장 적고 가장 덜 스르림우며, 대놓고 반복적이라서 병렬화할 수도 없다. forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자.

수집기

위 코드는 수집기(collector)를 사용하는데, 스트림을 사용하려면 꼭 배워야 하는 개념이다. 축소(reduction, 스트림의 원소들을 객체 하나에 취합) 전략을 캡슐화한 블랙박스 객체라고 생각하면 쉽다. 수집기가 생성하는 객체는 일반적으로 컬렉션이며, 그래서 "collector"라는 이름을 쓴다.

수집기를 사용하면 스트림의 원소를 손쉽게 컬렉션으로 모을 수 있다. 수집기는 총 세 가지로, toList(), toSet(), toCollection(collectionFactory)가 그 주인공이다. 이들은 차례로 리스트, 집합, 프로그래머가 지정한 컬렉션 타입을 반환한다. 이를 이용해 빈도표에서 가장 흔한 단어 10개를 뽑아내는 스트림 파이프라인을 작성해보자.

1
2
3
4
5
6
// 빈도표에서 가장 흔한 단어 10개를 뽑아내는 파이프라인
List<String> topLen = freq.keySet().stream()
    .sorted(comparing(freq::get).reversed())
    .limit(10)
    .collect(toList());
}
cs

comparing 메소드는 키 추출 함수를 받는 비교자 생성 메소드이다. 여기서 키 추출 함수로 쓰인 freq:get은 입력받은 단어(키)를 빈도표에서 찾아(추출) 그 빈도를 반환한다. 그런 다음 가장 흔한 단어가 위로 오도록 비교자(comparing)를 역순(reversed)으로 정렬한다(sorted). 마지막에 toList는 Collectors 메소드로, 이처럼 Collectors의 멤버를 정적 임포트하여 쓰면 스트림 파이프라인 가독성이 좋아져, 흔히들 이렇게 사용한다.

수집기: toMap

가장 간단한 맵 수집기는 toMap(keyMapper, valueMapper)로, 보다시피 스트림 원소를 키에 매핑하는 함수와 값에 매핑하는 함수를 인수로 받는다. 간단한 toMap 형태는 스트림의 각 원소가 고유한 키에 매핑되어 있을 때 적합하다. 스트림 원소 다수가 같은 키를 사용한다면 파이프라인이 IllegalStateException을 던지며 종료될 것이다.

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

인수 3개를 받는 toMap은 어떤 키와 그 키에 연관된 원소들 중 하나를 골라 연관 짓는 맵을 만들 때 유용하다. 또한 충돌이 나면 마지막 값을 취하는(last-write-wins) 수집기를 만들 때도 유용하다. 많은 스트림의 결과가 비결정적이지만, 매핑 함수가 키 하나에 연결해준 값들이 모두 같을 때, 혹은 값이 다르더라도 모두 허용되는 값일 때 이렇게 동작하는 수집기가 필요하다.

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

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

수집기: groupingBy

groupingBy 메소드는 입력으로 분류 함수(classifier)를 받고 출력으로는 원소들을 카테고리별로 모아 놓은 맵을 담은 수집기를 반환한다. 분류 함수는 입력받은 원소가 속하는 카테고리를 반환하고, 이 카테고리가 해당 원소의 맵 키로 쓰인다. 다중정의된 groupingBy 중 형태가 가장 간단한 것은 분류 함수 하나를 인수로 받아 맵을 반환하고, 반환된 맵에 담긴 각각의 값은 해당 카테고리에 속하는 원소들을 모두 담은 리스트다.

groupingBy가 반환하는 수집기가 리스트 외의 값을 갖는 맵을 생성하게 하려면, 분류 함수와 함께 다운스트림(downstream) 수집기도 명시해야 한다. 다운스트림 수집기의 역할은 해당 카테고리의 모든 원소를 담은 스트림으로부터 값을 생성하는 일이다. 이 매개변수를 사용하는 가장 간단한 방법은 toSet()을 넘기는 것으로, groupingBy는 원소들의 리스트가 아닌 집합(Set)을 값으로 갖는 맵을 만들어낸다.

toSet() 대신 toCollection(collectionFactory)를 건네는 방법도 있다. 이 방법은 리스트나 집합 대신 컬렉션을 값으로 갖는 맵을 생성하고 원하는 컬렉션 타입을 선택할 수 있다는 유연성을 얻을 수 있다. 다운스트림 수집기로 counting()을 건네는 방법도 있는데, 이렇게 하면 각 카테고리(키)를 원소를 담은 컬렉션이 아닌 해당 카테고리에 속하는 원소의 개수(값)와 매핑한 맵을 얻는다.

groupingBy의 세 번째 버전은 다운스트림 수집기에 더해 맵 팩토리도 지정할 수 있게 해준다. 이 메소드는 mapFactory 매개변수가 downStream 매개변수보다 앞에 놓여서 점층적 인수 목록 패턴(telescoping argument list pattern)에 어긋나지만, 사용하면 맵과 그 안에 담긴 컬렉션의 타입을 모두 지정할 수 있다.

총 세 가지 groupingBy 각각에 대응하는 groupingByConcurrent 메소드들도 있는데, 대응하는 메소드의 동시 수행 버전으로, ConcurrentHashMap 인스턴스를 만들어준다.

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

수집기: counting, minBy, maxBy, joining

counting 메소드가 반환하는 수집기는 다운스트림 수집기 전용이다. Stream의 count 메소드를 직접 사용하여 같은 기능을 수행할 수 있으니 collect(counting()) 형태로 사용할 일은 전혀 없다. Collections에서 이 수집기들은 스트림 기능의 일부를 복제하여 다운스트림 수집기를 작은 스트림처럼 동작하게 하는 목적으로 사용된다.

남은 메소드인 minBy, maxBy, joining은 Collectors에 정의되어 있지만 '수집'과는 관련이 없다. 그중 minBy와 maxBy는 인수로 받은 비교자를 이용해 스트림에서 값이 가장 작은 혹은 가장 큰 원소를 찾아 반환한다.

마지막 메소드인 joining은 문자열 등의 CharSequence 인스턴스의 스트림에만 적용할 수 있다. 이 중 매개변수가 없는 joining은 단순히 원소들을 연결(concatenate)하는 수집기를 반환한다. 한편 인수 하나짜리 joining은 CharSequence 타입의 구분문자(delimiter)를 매개변수로 받는다. 연결 부위에 이 구분문자를 삽입하는데, 예컨대 구분문자로 쉼표(,)를 입력하면 CSV 형태의 문자열을 만들어준다. 인수 3개짜리 joining은 구분문자에 더해 접두문자(prefix)와 접미문자(suffix)도 받는다. 예컨대 접두, 구분, 접미문자를 각각 '[', ',', ']'로 지정하여 얻은 수집기는 [came, saw, conquered]처럼 마치 컬렉션을 출력한 듯한 문자열을 생성한다.


핵심 정리

스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수 객체에 있다. 스트림뿐 아니라 스트림 관련 객체에 건네지는 모든 함수 객체가 부작용이 없어야 한다. 종단 연산 중 forEach는 스트림이 수행한 계산 결과를 보고할 때만 이용해야 한다. 계산 자체에는 이용하지 말자. 스트림을 올바로 사용하려면 수집기를 잘 알아둬야 한다. 가장 중요한 수집기 팩토리는 toList, toSet, toMap, groupingBy, joining이다.