1. 스트림
스트림 패러다임의 핵심은 계산을 일련의 변환으로 재구성하는 것이다. 이 때, 각 변환 단계는 가능한 이전 단계의 결과를 받아 처리하는 순수 함수(입력만이 결과에 영향을 주는 함수)여야 한다. 이를 위해선 중간이든, 종단 단계든 스트림 연산 간 함수 객체는 모두 부작용이 없어야 한다. 아래 텍스트 파일에서 단어별 수를 세어 빈도표를 만드는 코드를 살펴보자.
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
words.forEach(word -> {
freq.merge(word.toLowerCase(), 1L, Long::sum);
}
);
문제가 있는 코드다. 스트림, 람다, 메서드 참조가 모두 들어갔지만 반복 코드를 가장한 스트림 코드이다. 길고, 어렵고, 유지보수가 쉽지 않다. 종단 연산을 수행하는 forEach 내부에서, 스트림 연산 외부에 있는 freq 를 수정하게 된다. 나쁜 코드다. 올바르게 수정해보자.
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
freq = words.collect(groupingBy(String::toLowerCase, counting()));
};
짧고, 명확하다. 스트림 API 를 잘 활용했다. forEach 종단 연산은 for-each 반목문 처럼 생겼는데, 가장 '덜' 스트림답다. 대놓고 반복적이어서 병렬화할 수도 없다. forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자. 이 코드에서 collector 를 사용하는데, 스트림을 위해서라면 꼭 필요한 개념이다. 아래에서 자세히 살펴보자.
2. Collectors 클래스
java.util.stream.Collectors 클래스는 메서드를 39개나 가지고 있다. 물론 세부 내용을 잘 몰라도 사용할 수 있다. 여기서 '축소 전략' 이라는 개념이 나오는데, 여기서 축소는 스트림의 원소들을 객체 하나에 취합한다는 뜻이다. 수집기가 생성하는 객체는 일반적으로 컬렉션이어서, collector 라는 이름을 쓴다. 수집기를 통해 스트림의 원소를 컬렉션으로 쉽게 모을 수 있다.
2-1. 컬렉션 수집기
컬렉션 수집기 종류: toList() - 리스트 반환, toSet() - 집합 반환, toCollection(collectionFactory) - 지정한 컬렉션 반환
이를 활용해 빈도표에서 가장 흔한 단어 10개를 뽑아보자.
List<String> topTen = freq.keySet().stream()
.sorted(comparing(freq::get)).revered()
.limit(10)
.collect(toList());
comparing 메서드를 통해 키를 추출한다. freq::get 은 입력받은 단어를 빈도표에서 찾아 빈도를 추출한다. 그 후에 역순으로 정렬하고, 단어 10개를 뽑아 리스트로 반환하게 된다. 위 세 수집기를 제외하고, 나머지 36개의 메서드 중 맵 수집기를 알아보자.
2-2. 맵 수집기
가장 간단한 맵 수집기는 toMap(keyMapper, valueMpper) 가 있다. 이 메서드는 keyMapper와 valueMapper라는 두 개의 매퍼 함수를 사용하여 스트림의 각 요소에서 키와 값을 생성한다. 아래 예시를 살펴보자.
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");
// 이름을 키로, 이름의 길이를 값으로 매핑
Map<String, Integer> nameLengthMap = names.stream()
.collect(Collectors.toMap(
name -> name, // keyMapper: 이름을 키로 사용
name -> name.length() // valueMapper: 이름의 길이를 값으로 사용
));
// 결과 출력
nameLengthMap.forEach((key, value) ->
System.out.println("Key: " + key + ", Value: " + value));
// 결과
Key: Alice, Value: 5
Key: Bob, Value: 3
Key: Charlie, Value: 7
Key: David, Value: 5
toMap 형태는 스트림의 각 원소가 고유한 키에 매핑되어 있을 때 적합하다. 만약 같은 키를 사용한다면 IllegalStateException 을 던지며 아래와 같이 종료된다.

그렇다면 방법이 없는걸까? 아니다. 세 번째 매개변수로 병합 함수 mergeFunction을 지정하여 문제를 해결하자.
// 중복된 길이를 가진 이름
List<String> names = Arrays.asList("Anna", "Emma", "Aria", "Ella");
// 키가 중복되는 경우 처리
Map<Integer, String> lengthToNameMap = names.stream()
.collect(Collectors.toMap(
String::length, // 키: 이름의 길이
name -> name, // 값: 이름
(existing, replacement) -> existing + ", " + replacement // 병합 함수
));
// 결과 출력
lengthToNameMap.forEach((key, value) ->
System.out.println("Key: " + key + ", Value: " + value));
// 결과
Key: 4, Value: Anna, Emma, Aria, Ella
이렇게 하면 키 중복 문제가 발생할 때 원하는 방식으로 데이터를 병합할 수 있을 것이다.
2-3. groupingBy 메서드
Collectors.groupingBy 메서드는 스트림의 요소를 특정 기준에 따라 그룹화할 때 사용되고, 스트림의 요소를 키로 그룹화하여 Map<K, List<V>> 형태로 수집할 수 있다. 아래 예시를 살펴보자.
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Anna");
// 이름의 길이를 기준으로 그룹화
Map<Integer, List<String>> groupedByLength = names.stream()
.collect(Collectors.groupingBy(String::length));
// 결과 출력
groupedByLength.forEach((key, value) ->
System.out.println("Key (Length): " + key + ", Value: " + value));
// 결과
Key (Length): 5, Value: [Alice, David]
Key (Length): 3, Value: [Bob]
Key (Length): 7, Value: [Charlie]
Key (Length): 4, Value: [Anna]
groupBy 가 반환을 Map<Integer, List<String>> 형태로 했는데, 리스트 외의 값을 갖는 맵을 생성하려면 다운스트림 수집기를 명시해야 한다. 다운 스트림 수집기를 통해 해당 카테고리의 모든 원소를 담은 스트림으로부터 값을 생성할 수 있다. 예를 들어, 아래와 같이 리스트의 길이를 구할 수도 있다.
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Anna");
// 이름의 길이를 기준으로 그룹화하고 개수를 센다
Map<Integer, Long> countByLength = names.stream()
.collect(Collectors.groupingBy(
String::length, // 키: 이름의 길이
Collectors.counting() // 값: 그룹의 요소 개수
));
// 결과 출력
countByLength.forEach((key, value) ->
System.out.println("Key (Length): " + key + ", Count: " + value));
// 결과
Key (Length): 5, Count: 2
Key (Length): 3, Count: 1
Key (Length): 7, Count: 1
Key (Length): 4, Count: 1
또는, 이름을 콤마로 합쳐 String 으로 표현하고 싶다면 Collectors.couonting() 대신 Collectors.joining(", ") 을 사용하면 된다.
2-4. joining 메서드
이 메서드는 CharSequence 인스턴스의 스트림에만 적용 가능하고, 이를 문자열로 결합해준다. 매개변수 개수에 따라 쓰임이 다르다.
joining() 은 스트림의 모든 문자열을 구분자 없이 결합한다.
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
// 스트림의 요소를 단순히 결합
String result = names.stream()
.collect(Collectors.joining());
// 결과 출력
System.out.println(result);
// 결과
AliceBobCharlie
joining(CharSequence delimiter) 구분자를 지정하여 스트림의 요소를 결합할 수 있다.
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
// 스트림의 요소를 콤마로 구분하여 결합
String result = names.stream()
.collect(Collectors.joining(", "));
// 결과 출력
System.out.println(result);
// 결과
Alice, Bob, Charlie
joining(CharSequence delimiter, CharSequence prefix, CharSequence suffix) 구분자, 접두/접미사를 지정한다.
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
// 콤마로 구분하고 대괄호로 감싸기
String result = names.stream()
.collect(Collectors.joining(", ", "[", "]"));
// 결과 출력
System.out.println(result);
// 결과
[Alice, Bob, Charlie]
'1️⃣ 백앤드 > 이펙티브 자바' 카테고리의 다른 글
| [아이템48] 스트림 병렬화는 주의해서 적용하라 (0) | 2024.11.20 |
|---|---|
| [아이템47] 반환 타입으로는 스트림보다 컬렉션이 낫다 (0) | 2024.11.19 |
| [아이템45] 스트림은 주의해서 사용하라 (0) | 2024.11.14 |
| [아이템44] 표준 함수형 인터페이스를 사용하라 (1) | 2024.11.13 |
| [아이템43] 람다보다는 메서드 참조를 사용하라 (0) | 2024.11.12 |