Stream은 단순한 API가 아니라 함수형 프로그래밍에 기초한 패러다임이다. 따라서 API만 익히는 것이 아니라 패러다임도 받아들여야 한다. 스트림 패러다임의 핵심은 계산을 일련의 변환으로 재구성하는 부분이다. 이때 각 변환 단계에서는 재구성을 하는데, 이전 단계의 결과를 받아 처리하는 순수 함수이어야 한다. 순수함수란 입력만이 결과에 영향을 주는 함수를 말하며, 다른 가변 상태를 참조하지 않고, 함수도 외부의 상태를 변경하지 않는다. 이렇게 하려면 (중간 단계든 종단 단계든) 스트림 연산에 건네는 함수 객체는 모두 부작용(side effect)이 없어야 한다.
아래 코드는 스트림 패러다임을 이해하지 못한채 API만 사용한 경우이다.
// 텍스트 파일에서 단어별 수를 세어 빈도표로 만드는 코드
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) { // tokens은 java9부터 지원
words.forEach(word -> {
freq.merge(word.toLowerCase(), 1L, Long::sum);
});
}
이 코드는 스트림을 가장한 반복적 코드이며, 스트림의 이점을 살리지 못했다. 종단연산로 사용된 forEach는 외부 상태(빈도표)를 수정하는 람다를 실행하면서 문제가 생긴다. forEach가 그저 스트림이 수행한 연산 결과를 보여주는 일 이상을 하는 것(이 예에서는 람다가 상태를 수정함)을 보니 나쁜 코드일 것 같은 짐작이 간다.
아래는 스트림 패러다임을 이해하고 제대로 활용한 코드이다.
// 텍스트 파일에서 단어별 수를 세어 빈도표로 만드는 코드
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
freq = words.collect(groupingBy(String::toLowerCase, counting()));
}
자바 프로그래머라면 for-each 반복문을 사용할 줄 알 텐데, for-each 반복문은 forEach 종단 연산과 비슷하게 생겼다. 하지만 forEach 연산은 종단 연산 중 기능이 가장 적고 가장 ‘덜’ 스트림답다. 대놓고 반복적이라서 병렬화를 할 수도 없다.
forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자.
물론 가끔은 스트림 계산 결과를 기존 컬렉션에 추가하는 등의 다른 용도로도 쓸 수 있다.
종단연산으로 foreach같은 계산보단 Collector(수집기)를 권장한다.
java.util.stream.Collectors
를 사용하면 스트림의 원소를 컬렉션으로 쉽게 모을 수 있다.
수집기는 총 세 가지로 toList()
, toSet()
, toCollection(collectionFactory)
가 있다.
이들은 차례로 리스트, 집합, 프로그래머가 지정한 컬렉션 타입을 반환한다.
수집기의 다양한 예시를 통해 살펴보자.
사용하는 방법이다.
//빈도표에서 가장 흔한 단어 10개를 뽑아내는 파이프라인
public static void main(String[] args) {
List<String> words = new ArrayList<>(Arrays.asList("a", "b", "c", "d", "e", "f", "c", "c", "d"));
Map<String, Long> freq = words.stream()
.collect(groupingBy(String::toLowerCase, counting()));
List<String> topTen = freq.keySet().stream()
.sorted(comparing(freq::get).reversed())
.limit(10)
.toList();
out.println("words = " + words);
out.println("freq = " + freq);
out.println("topTen = " + topTen);
}
//결과: words = [a, b, c, d, e, f, c, c, d]
// freq = {a=1, b=1, c=3, d=2, e=1, f=1}
// topTen = [c, d, a, b, e, f]
가장 흔한 단어가 위로 오도록 비교자(comparing)를 역순(reversed)으로 정렬(sorted)한다.
//문자열의 길이를 키로 하는 맵 생성
public static void main(String[] args) {
List<String> words = List.of("apple", "banana", "cherry", "date", "egg", "fig", "grape");
// toMap 활용: 문자열 길이를 키로, 단어를 값으로
Map<Integer, String> lengthToWord = words.stream()
.collect(toMap(
String::length, // 키: 문자열의 길이
word -> word, // 값: 해당 문자열
(existing, replacement) -> existing)); // 충돌 발생 시 기존 값을 유지
out.println("words = " + words);
out.println("lengthToWord = " + lengthToWord);
}
//결과: words = [apple, banana, cherry, date, egg, fig, grape]
// lengthToWord = {3=egg, 4=date, 5=apple, 6=banana}
위 예시는 toMap
의 직관적인 사용 사례로, 1번째 인자로 String::length
로 키를 정의하여 각 단어의 길이를 기준으로 매핑, 2번째 인자로 word -> word
로 값을 단순히 해당 단어로 설정, 3번째 인자로 충돌 해결 전략(두 단어가 같은 길이를 가진 경우 기존 값을 유지하도록 (existing, replacement) -> existing
를 사용)을 적용하였다.
toMap은 데이터 요약, 변환, 분류 등에 사용할 수 있으며 메서드를 살펴보며 자세히 알아보자.
public static <T, K, U>
Collector<T, ?, Map<K,U>> toMap(Function<? super T, ? extends K> keyMapper,
Function<? super T, ? extends U> valueMapper) {
return new CollectorImpl<>(HashMap::new,
uniqKeysMapAccumulator(keyMapper, valueMapper),
uniqKeysMapMerger(),
CH_ID);
}