-
[Effective Java] 스트림에서는 부작용 없는 함수를 사용하라Java 2023. 2. 3. 10:24
스트림은 처음 봐서는 이해하기 어려울 수 있다. 원하는 작업을 스트림 파이프라인으로 표현하는 것조차 어려울지 모른다. 성공하여 프로그램이 동작하더라도 장점이 무엇인지 쉽게 와 닿지 않을 수도 있다.
- 단순 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는 그저 스트림이 수행한 연산 결과를 보여주는 일만 해야하는데, 그 이상을 하고 있다.
- forEach 연산은 종단 연산 중 기능이 가장 적고 가장 '덜' 스트림답다.
- forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자.
올바른 예시
Map<String, Long> freq; try (Stream<String> words = new Scanner(file).tokens()) { freq = words.collect(groupingBy(String::toLowerCase, counting())); }
- 스트림을 제대로 활활용해 빈도표를 초기화한다.
- 짧고 명확하다.
- 이 코드는 수집기(collector)를 사용하는데, 스트림을 사용하려면 꼭 배워야하는 새로운 개념이다.
수집기(collector)
- 수집기를 사용하면 스트림의 원소를 손쉽게 컬렉션으로 모을 수 있다.
- 수집기는 총 세 가지로, toList(), toSet(), toCollection(collectionFactory)가 있다.
// 빈도표에서 가장 흔한 단어 10개를 뽑아내는 스트림 파이프라인 List<String> topTen = freq.keySet().stream() .sorted(comparing(freq::get).reversed()) .limit(10) .collect(toList()); }
- 마지막 toList는 Collectors의 메서드다. 이처럼 Collectors의 멤버를 정적 임포트하여 쓰면 스트림 파이프라인 가독성이 좋아져, 흔히들 이렇게 사용한다.
- comparing : 키 추출 함수를 받는 비교자 생성 메서드
- freq::get : 입력받은 단어(키)를 빈도표에서 찾아 그 빈도를 반환한다.
- reversed : 가장 흔한 단어가 위에 오도록 비교자(comparing)를 역순으로 정렬한다(sorted)
Collectors의 메서드들
toMap
toMap(keyMapper, valueMapper)
- 가장 간다한 맵 수집기
- 스트림 원소를 키에 매핑하는 함수와 값에 매핑하는 함수를 인수로 받는다.
// toMap 수집기를 사용하여 문자열을 열거 타입 상수에 매핑한다. private static final Map<String, Operation> stringToEnum = Stream.of(values()).collect( toMap(Obejct::toString, e->e));
- 스트림의 각 원소가 고유한 키에 매핑되어 있을 때 적합하다.
- 스트림 원소 다수가 같은 키를 사용한다면 파이프라인이 IllegalStateException을 던지며 종료될 것이다.
인수 3개를 받는 toMap
인수 3개를 받는 toMap은 어떤 키와 그 키에 연관된 원소들 중 하나를 골라 연관 짓는 맵을 만들 때 유용하다.
예) 다양한 음악가의 앨범들을 담은 스트림을 가지고, 음악가와 그 음악가의 베스트 앨범을 연관 짓는 수집기
//각 키와 해당 키의 특정 원소를 연관 짓는 맵을 생성하는 수집기 Map<Artist, Album> topHits = albums.collect( toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));
- 비교자로 BinaryOperator에서 정적 임포트한 maxBy라는 정적 팩터리 메서드를 사용했다.
- 말로 풀어보자면 "앨범 스트림을 맵으로 바꾸는데, 이 맵은 각 음악가와 그 음악가의 베스트 앨범을 짝지은 것이다"는 이야기다.
// 마지막에 쓴 값을 취하는 수집기 toMap(keyMapper, valueMapper, (oldVal, newVal) -> newVal
- 매핑 함수가 키 하나에 연결해준 값들이 모두 같을 때, 혹은 값이 다르더라도 모두 허용되는 값일 때 이렇게 동작하는 수집기가 필요하다.
인수 4개를 받는 toMap
네 번째 인수로 맵 팩터리를 받는다. 이 인수로는 EnumMap이나 treeMap처럼 원하는 특정 맵 구현체를 직접 지정할 수 있다.
GroupingBy
- 입력으로 분류 함수(classifier)를 받고 출력으로는 원소들을 카테고리별로 모아 놓은 맵을 담은 수집기를 반환한다.
- 분류 함수는 입력받은 원소가 속하는 카테고리를 반환한다.
// 알파벳화한 단어를 알파벳화 결과가 같은 단어들의 리스트로 매핑하는 맵 생성 words.collect(groupingBy(word -> alphabetsize(word)))
다운스트림 수집기
groupingBy가 반환하는 수집기가 리스트 외의 값을 갖는 맵을 생성하게 하려면, 분류 함수와 함께 다운스트림(downstream) 수집기도 명시해야 한다.
- 인자로 다운스트림 수집기를 같이 전달해주면 원소 리스트를 다른 타입으로 바꿀 수 있다.
- toSet()을 넘기면 groupbingBy는 원소들의 리스트가 아닌 집합(Set)을 값으로 갖는 맵을 만들어낸다.
- toCollection(collectionFactory)를 건네면 컬렉션을 값으로 갖는 맵을 생성한다.
- 다운스트림 수집기로 counting()을 건네는 방법도 있다. - 각 카테고리(키) 에 속하는 원소의 개수를 매핑한 맵을 얻을 수 있다.
Map<String, Long> freq = words .collect(groupingBy(String::toLowerCase, counting()));
partitioningBy
- 많이 쓰이진 않지만 groupingBy의 사촌격인 partitioningBy도 있다.
- 분류 함수 자리에 프레디키드(predicate)를 받고 키가 Boolean인 맵을 반환한다.
- 프레디키트에 더해 다운스트림 수집기까지 입력받는 버전도 다중정의되어 있다.
joining
- 이 메서드는 (문자열 등의) CharSequence 인스턴스의 스트림에만 적요알 수 있다.
- 이 중 매개변수가 없는 joining은 단순히 원소들을 연결하는 수집기를 반환한다.
- 인수 하나짜리 joining은 charSequence 타입의 구분문자를 매개변수로 받는다.
- 인수 3개짜리 joining은 구분문자에 더해 접두문자(prefix)와 접미문자(suffix)도 받는다.
- 접두, 구분, 접미문자를 각각 '[' / ',' / ']' 로 지정하여 얻은 수집기는 [came, saw, conquered]처럼 마치 컬렉션을 출력한 듯한 문자열을 생성한다.
핵심 정리
- 스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수 객체에 있다. 스트림뿐 아니라 스트림 관련 객체에 건네지는 모든 함수 객체가 부작용이 없어야 한다.
- 종단 연산 중 forEach는 스트림이 수행한 계산 결과를 보고할 때만 이용해야 한다. 계산 자체에는 이용하지 말자.
- 스트림을 올바로 사용하려면 수집기를 잘 알아둬야 한다. 가장 중요한 수집기 팩터리는 toList, toSet, toMap, groupingBy, joining 이다.
'Java' 카테고리의 다른 글
[Effective Java] 스트림 병렬화는 주의해서 적용하라 (0) 2023.02.03 [Effective Java] 반환 타입으로는 스트림보다 컬렉션이 낫다 (0) 2023.02.03 [Effective Java] 스트림은 주의해서 사용하라 (0) 2023.02.02 [Effective Java] 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라 (0) 2023.02.02 [Effective Java] ordinal 인덱싱 대신 EnumMap을 사용하라 (0) 2023.02.02