1. stream
스트림은 그저 또 하나의 API가 아닌, 함수형 프로그래밍에 기초한 패러다임이다. 스트림이 제공하는 표현력, 속도, (상황에 따라서는) 병렬성을 얻으려면 패러다임까지 함께 받아들어야 한다.
stream paradigm
스트림 패러다임의 핵심은 계산을 일련의 변환(transformation)으로 재구성하는 부분이다.
이때 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수함수여야 한다.
순수함수
- 순수함수란 오직 입력만이 결과에 영향을 주는 함수를 말한다.
- 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는다.
즉, 같은 입력에 대해서는 항상 같은 출력을 보장하며, 부작용(side effect)이 없다.
예를 들어 아래 함수는 순수 함수이다.
순수 함수 예시 (Pure Function)
public class MathUtils {
public static int add(int a, int b) {
return a + b;
}
}
- 외부 변수에 의존하지 않는다.
- 외부 상태를 변경하지 않는다.
- 같은 입력이면 항상 같은 출력을 반환한다.
순수하지 않은 함수 예시 (Impure Function)
public class Counter {
private int count = 0;
public int increment() {
count++; // 외부 상태(count)를 변경함
return count;
}
}
count는 객체 상태이므로, 함수 호출마다 결과가 달라질 수 있다.- 외부 상태를 변경하고 있으므로 순수 함수가 아니다.
예시
두 코드 예시는 같은 기능(단어별 수를 세어 빈도표로 만드는 일)을 수행하지만, 함수형 프로그래밍 관점에서 "순수성(purity)"의 차이가 있다.
코드 1 – 순수하지 않은 방식
public class TestExample {
Map<String, Long> testMap = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
words.forEach(word -> {
testMap.merge(word.toLowerCase(), 1L, Long::sum); // 외부 상태 수정
});
}
}
여기서 보면, forEach 내부에서 testMap을 직접 수정하고 있다. merge(...) 메서드가 실행될 때마다 testMap 내용이 바뀐다.
이건 함수 내부 코드가 외부 상태를 바꾼 것다.
- testMap은 외부 상태(클래스 필드)이다.
- forEach 내에서 testMap을 수정한다. → 부작용(side effect) 발생함.
함수가 testMap 외부 변수에 영향을 주기 때문에, 이 방식은 순수하지 않은 방식이다.
코드 2 - 순수 함수 방식
public class TestExample {
Map<String, Long> testMap;
try (Stream<String> words = new Scanner(file).tokens()) {
testMap = words.collect(groupingBy(String::toLowerCase, counting()));
}
}
여기선 외부의 testMap을 건드리지 않는다. collect(...) 함수는 입력(단어들)에 기반해서 새로운 맵을 반환한다.
즉, 함수는 입력 → 출력만 다루고, 외부에 영향 안 준다.
collect(...)는 입력 스트림만을 바탕으로 새로운 Map을 반환한다.- 외부 상태를 수정하지 않고, 새 결과를 생성한다. → 부작용 없음.
따라서 이 방식은 순수 함수적 스타일이다.
포인트
forEach는 스트림의 "최종 결과를 소비(보고)"할 때만 사용하고,결과를 "계산하거나 생성"할 때는 사용하지 말자.
1. forEach는 부작용 기반(side-effect based) 연산이다.
내부에서 외부 상태를 수정해야만 결과를 만들 수 있다.
순수 함수 스타일이 깨지고, 예측 불가능하거나 병렬 처리에 불리해진다.
// 부작용 발생: 외부 map을 수정함
words.forEach(word -> testMap.merge(word, 1L, Long::sum));
2. 스트림 연산은 계산 자체도 표현식으로 처리하는 것이 바람직하다.
map, filter, collect 같은 스트림 연산자들은 결과를 새로운 값으로 만들어서 반환한다.
이 방식은 순수 함수적, 부작용 없음, 가독성 우수, 테스트 용이하다.
Map<String, Long> result = words.collect(
groupingBy(String::toLowerCase, counting())
); // 입력 기반으로 새로운 결과 생성 (순수)
언제 forEach를 써야 할까?
출력, 로깅, 디버깅, 파일 쓰기
// 결과 소비(부작용 허용되는 영역)
items.stream()
.map(Item::getName)
.forEach(System.out::println);
Collector 클래스
Collector는 스트림 요소들을 모아 하나의 결과로 만드는 데 사용하는 인터페이스이다.
- 수집기가 생성하는 객체는 일반적으로 컬렉션이며, 그스트림의 원소를 손쉽게 컬렉션으로 모을 수 있다.
- 총 3가지로 toList(), toSet(), toCollection(collectionFactory)다.
1. toList()
예시 : 빈도표에서 가장 흔한 단어 10개를 뽑아내는 파이프라
public class TestExample {
Map<String, Long> freq = new HashMap<>();
List<String> topTen = freq.keySet().stream()
.sorted(Comparator.comparing(freq::get).reversed()) // 정렬 (비교자.역순)
.limit(10) // 10개 제한
.collect(Collectors.toList()); // 리스트로
}
.sorted(Comparator.comparing(freq::get).reversed())
- 각 단어(key)의 값을 기준으로 정렬한다. (값은 빈도 수)
freq::get을 통해 key에 대한 값을 가져와 비교한다..reversed()로 내림차순 정렬한다. (즉, 빈도가 높은 단어가 먼저)
.collect(Collectors.toList())
최종적으로 결과를 List<String>으로 반환한다.
2. toMap()
toMap(keyMapper, valueMapper)로 스트림 원소를 키에 매핑하는 함수와 값에 매핑하는 함수를 인수로 받는다.
1. 열거 타입 상수에 매핑하는 예시
private static final Map<String, Operation> stringToEnum =
Stream.of(values()).collect(toMap(Object::toString, e -> e));
- 스트림의 각 원소가 고유한 키에 매핑되어 있을 때 적합하다. (Key-Value 1:1 매핑)
- 만약 스트림 원소들 중 중복된 키를 사용하는 경우,
toMap()은 기본적으로 IllegalStateException을 던지며 파이프라인이 종료된다. - 중복 키가 예상될 경우, 충돌 해결 함수를 직접 제공하거나 toMap() 대신 groupingBy()를 사용해야 한다.
2. 각 키와 해당 키의 특정 원소를 연관 짓는 맵을 생성하는 예시
인수 3개를 받는 toMap은 어떤 키와 그 키에 연관된 원소들 중 하나를 골라 연관 짓는 맵을 만들 때 유용하다. 예컨대 다양한 음악가의 앨범들을 담은 스트림을 가지고, 음악가와 그 음악가의 베스트 앨범을 연관 짓고 싶다고 해보자.
Map<Artist, Album> topHits = albums.collect(
toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));
maxBy(comparing(Album::getSales)) 는 두 Album 중 판매량이 더 큰 객체를 반환하는 함수이다.
maxBy는Comparator<T>를 받아서두 객체 중 큰 값을 선택하는 BinaryOperator<T>를 반환하는 메서드입니다.
이 점을 이용해 toMap의 병합 함수로 쓸 수 있다.
- key가 중복될 때: 병합 함수가 호출되어 판매량이 큰 앨범을 선택한다.
- 결과는 각 아티스트별 베스트 앨범으로 이루어진 Map이다.
3. 마지막에 쓴 값을 취하는 Collector
toMap(keyMapper, valueMapper, (oldVal, newVal) -> newVal)
인수 3개인 toMap은 충돌이 나면 마지막 값을 취하는 수집기를 만들 때도 유용하다.
3. groupingBy()
입력으로 분류 함수(classifier)를 받고 출력으로는 원소들을 카테고리별로 모아 놓은 Map을 담는 Collector를 반환한다.
예시
words.collect(groupingBy(word -> alphaetize(word)));
alphabetize(word)는 각 단어를 분류할 기준(예: 첫 글자 등)을 반환하는 함수라고 가정한다.- 결과는 각 키(분류 기준)에 따라 단어 리스트를 담은 Map이다.
예시
Map<String, Long> freq = words.collect(groupingBy(String::toLowerCase, counting())
다운스트림 수집기로 counting()을 건네는 방법도 있다.
이렇게 하면 각 카테고리(key)를 (원소를 담은 컬렉션이 아닌) 해당 카테고리에 속하는 원소의 개수(value)와 매핑한 맵을 얻는다.
4. toSet()
스트림의 요소들을 중복 없이(Set) 모아 컬렉션을 만드는 역할을 한다.
Set<String> uniqueWords = words.collect(Collectors.toSet());
words 스트림 내 중복된 단어가 제거되고, 유일한 단어들만 모여 Set이 만들어진다.
toSet() 대신에 toCollection()을 사용하면 원하는 특정 컬렉션 타입을 직접 지정해서 수집할 수 있다.
Stream<T> stream = ...;
Collection<T> collection = stream.collect(Collectors.toCollection(collectionFactory));
collectionFactory는 Supplier<Collection<T>> 형태의 함수이다.
이 함수가 호출되어 결과를 저장할 컬렉션 인스턴스를 만든다.
collect(counting()) 형태로 사용할일은 전혀 없다.
counting()은 스트림 요소의 개수를 세는 Collector이다.
- 왜 안 쓰는 경우가 많은가?
- 대부분 stream.count()를 직접 사용하기 때문이다.
- collect(counting())는 내부적으로 count() 호출과 비슷하지만, 불필요하게 Collector를 사용해서 복잡해 보일 수 있다.
5. joining()
- joining()은 CharSequence(예: String) 요소들만 처리할 수 있는 Collector이다.
- 스트림의 모든 문자열을 하나의 문자열로 연결(concatenate)한다.
기본 사용 — 단순 연결
String result = streamOfStrings.collect(Collectors.joining());
구분자 지정
String result = streamOfStrings.collect(Collectors.joining(", "));
접두어, 접미어 지정
String result = streamOfStrings.collect(Collectors.joining(", ", "[", "]"));
결론
- 스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수 객체에 있다.
- 스트림뿐 아니라 스트림 관련 객체에 건네지는 모든 함수 객체가 부작용이 없어야 한다.
- 종단 연산 중 forEach는 스트림이 수행한 계산 결과를 보고할 때만 이용해야 한다.
- 스트림을 올바로 사용하려면 수집기를 잘 알아둬야 한다. (toList, toSet, groupingBy, joining)
'Dev Books > Effective Java' 카테고리의 다른 글
| [item 48] 스트림 병렬화는 주의해서 적용하라. (0) | 2025.07.16 |
|---|---|
| [item 47] 반환 타입으로는 스트림보다 컬렉션이 낫다 (1) | 2025.07.15 |
| [item 45] 스트림은 주의해서 사용하라 (4) | 2025.07.09 |
| [item 44] 표준 함수형 인터페이스를 사용하라 (0) | 2025.07.01 |
| [item 43] 람다보다는 메서드 참조를 사용하라 (0) | 2025.06.30 |
댓글