1. reduce 메서드: 범용 축약
프로그래밍에서는 컬렉션을 하나의 최종 값으로 “축약”해야 할 때가 자주 있습니다: 합계를 구하고, 곱을 계산하고, 문자열을 이어 붙이고, 집계 지표를 계산하거나 요소를 새로운 구조로 모으는 일 등입니다. 예전에는 루프와 누산기 변수를 사용해 수동으로 처리했습니다. 오늘날 Stream API는 범용 메서드 reduce와 collect를 통해 코드를 간결하고 선언적으로 작성할 수 있는 우아한 방법을 제공합니다.
- reduce — 스트림을 하나의 최종 값으로 축약합니다(합계, 곱, 연결 등).
- collect — 스트림을 컬렉션, 문자열, 맵 또는 임의의 구조로 변환합니다.
이제 차근차근 살펴봅시다.
왜 reduce가 필요한가?
reduce는 요소들을 누산 함수로 하나의 값으로 “축약”하는 터미널 메서드입니다. 개념적으로는 컬렉션을 순회하며 결과를 단계적으로 누적하는 것과 같습니다.
reduce 메서드 시그니처
Stream API에는 reduce()의 대표적인 변형이 세 가지 있습니다:
Optional<T> reduce(BinaryOperator<T> accumulator)
T reduce(T identity, BinaryOperator<T> accumulator)
<U> U reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator<U> combiner)
- accumulator — 현재까지의 누적 값과 다음 요소를 받아 새로운 결과를 반환하는 함수.
- identity — 누산기의 초기값(예: 합계의 0, 곱의 1).
- combiner — 병렬 스트림에서 중간 결과를 합칠 때 사용.
reduce 사용 예시
예제 1: 숫자 합계
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
// identity 없이 사용하는 reduce — 결과는 Optional
Optional<Integer> sum1 = numbers.stream()
.reduce((a, b) -> a + b);
System.out.println(sum1.orElse(0)); // 15
// identity와 함께 사용하는 reduce — 결과는 항상 존재
int sum2 = numbers.stream()
.reduce(0, (a, b) -> a + b);
System.out.println(sum2); // 15
예제 2: 모든 숫자의 곱
int product = numbers.stream()
.reduce(1, (a, b) -> a * b);
System.out.println(product); // 120
예제 3: 문자열 연결
List<String> words = List.of("Java", "Stream", "API");
String phrase = words.stream()
.reduce("", (a, b) -> a + " " + b);
System.out.println(phrase.trim()); // Java Stream API
예제 4: 최댓값 찾기
Optional<Integer> max = numbers.stream()
.reduce(Integer::max);
max.ifPresent(System.out::println); // 5
예제 5: 모든 문자열 길이의 합계
List<String> texts = List.of("고양이", "개", "코끼리");
int totalLength = texts.stream()
.map(String::length)
.reduce(0, Integer::sum);
System.out.println(totalLength); // 14
reduce 동작 방식
reduce의 로직은 아래 루프와 동등합니다:
T result = identity;
for (T element : collection) {
result = accumulator.apply(result, element);
}
return result;
identity가 지정되지 않은 경우 스트림의 첫 번째 요소가 시작 값이 되며, 메서드는 Optional을 반환합니다(스트림이 비어 있으면 비어 있을 수 있음).
2. collect 메서드: 범용 변환
collect는 스트림을 컬렉션, 문자열, 맵 또는 다른 임의의 구조로 바꾸는 터미널 메서드입니다. 이를 위해 “컬렉터”(Collector)를 사용하며, 수집 과정을 기술합니다. 대부분은 Collectors 클래스의 기성 컬렉터를 사용합니다.
가장 인기 있는 컬렉터
- Collectors.toList() — 요소를 List로 모읍니다.
- Collectors.toSet() — 요소를 Set으로 모읍니다.
- Collectors.toMap() — 요소를 Map으로 모읍니다.
- Collectors.joining() — 문자열을 하나로 이어 붙입니다.
- Collectors.groupingBy() — 기준에 따라 요소를 그룹화합니다.
- Collectors.counting() — 요소 개수를 셉니다.
- Collectors.summarizingInt() — 숫자에 대한 통계를 수집합니다(합계, 평균, 최소/최대).
collect 사용 예시
예제 1: 리스트로 수집
List<String> names = List.of("아냐", "보리스", "바샤", "아냐");
List<String> uniqueNames = names.stream()
.distinct()
.collect(Collectors.toList());
System.out.println(uniqueNames); // [아냐, 보리스, 바샤]
예제 2: 집합(Set)으로 수집
Set<String> nameSet = names.stream()
.collect(Collectors.toSet());
System.out.println(nameSet); // [아냐, 보리스, 바샤] (순서는 보장되지 않음)
예제 3: 문자열로 수집
String csv = names.stream()
.collect(Collectors.joining(", "));
System.out.println(csv); // 아냐, 보리스, 바샤, 아냐
예제 4: Map으로 수집
다음과 같은 클래스가 있다고 가정해 봅시다:
public class Employee {
private String name;
private String department;
public Employee(String name, String department) {
this.name = name;
this.department = department;
}
public String getName() { return name; }
public String getDepartment() { return department; }
}
“이름 → 부서” 맵을 만들어 봅시다:
List<Employee> employees = List.of(
new Employee("아냐", "IT"),
new Employee("보리스", "HR"),
new Employee("바샤", "IT")
);
Map<String, String> nameToDept = employees.stream()
.collect(Collectors.toMap(
Employee::getName,
Employee::getDepartment,
(oldValue, newValue) -> newValue // 이름 중복 처리
));
System.out.println(nameToDept); // {아냐=IT, 보리스=HR, 바샤=IT}
예제 5: 고유 요소를 Set으로 모으기
Set<String> unique = names.stream()
.collect(Collectors.toSet());
System.out.println(unique);
예제 6: 숫자 통계 수집
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
IntSummaryStatistics stats = numbers.stream()
.collect(Collectors.summarizingInt(Integer::intValue));
System.out.println(stats.getSum()); // 15
System.out.println(stats.getAverage()); // 3.0
System.out.println(stats.getMax()); // 5
System.out.println(stats.getMin()); // 1
3. 비교: 언제 reduce, 언제 collect
reduce — 합계, 곱, 최댓값, 문자열 연결처럼 이항 연산으로 하나의 최종 값을 얻어야 할 때 사용합니다.
collect — 요소를 컬렉션/맵/문자열로 모으거나 Collector로 더 복잡한 집계를 해야 할 때 사용합니다. 이런 작업에는 보통 collect가 더 강력하고 효율적입니다.
표: reduce vs collect
| 작업 | 무엇을 사용할까 | 예시 |
|---|---|---|
| 숫자 합계 | |
|
| 곱 | |
|
| List로 수집 | |
|
| Map으로 수집 | |
|
| 그룹화 | |
|
| 문자열 연결 | reduce / collect | reduce("", String::concat) 또는 Collectors.joining() |
4. 실습 문제
문제 1: 리스트의 모든 문자열 길이 합계 구하기
List<String> words = List.of("고양이", "개", "코끼리");
int totalLength = words.stream()
.mapToInt(String::length)
.sum(); // 또는 reduce 사용: .reduce(0, Integer::sum)
System.out.println(totalLength); // 14
문제 2: 고유 요소를 Set으로 수집
List<String> fruits = List.of("사과", "배", "사과", "오렌지");
Set<String> uniqueFruits = fruits.stream()
.collect(Collectors.toSet());
System.out.println(uniqueFruits); // [사과, 배, 오렌지]
문제 3: 객체 리스트에서 Map 만들기
List<Employee> employees = List.of(
new Employee("아냐", "IT"),
new Employee("보리스", "HR"),
new Employee("바샤", "IT")
);
Map<String, String> nameToDept = employees.stream()
.collect(Collectors.toMap(
Employee::getName,
Employee::getDepartment,
(oldValue, newValue) -> newValue // 이름이 같을 경우
));
System.out.println(nameToDept);
문제 4: 모든 이름을 쉼표로 이어 붙이기
String allNames = employees.stream()
.map(Employee::getName)
.collect(Collectors.joining(", "));
System.out.println(allNames); // 아냐, 보리스, 바샤
5. 구현 세부사항과 주의점
Optional과 reduce
reduce를 identity 없이 사용하면 결과는 Optional입니다. 이는 안전합니다: 스트림이 비어 있으면 결과도 비어 있습니다. ifPresent(...), orElse(...), orElseThrow(...) 등으로 적절히 처리하는 것을 잊지 마세요.
Optional<Integer> max = numbers.stream().reduce(Integer::max);
max.ifPresent(System.out::println);
커스텀 컬렉터: 극한을 원한다면
표준 컬렉터로 충분하지 않다면 직접 Collector를 구현할 수도 있습니다. 하지만 99%의 과제에는 Collectors의 기성 컬렉터로 충분합니다.
컬렉터와 병렬 스트림
Collectors의 기성 컬렉터는 parallelStream()에서도 올바르게 동작하도록 설계되어 있습니다. 병렬 스트림의 forEach 안에서 공유 변경 가능한 컬렉션에 요소를 직접 추가하지 마세요 — 데이터 레이스가 발생합니다.
6. reduce와 collect 사용 시 흔한 실수
오류 №1: reduce 이후 Optional을 확인하지 않음. 스트림이 비어 있으면 identity 없이 사용하는 reduce는 빈 Optional을 반환합니다. get()을 호출하면 NoSuchElementException이 발생합니다. ifPresent, orElse, orElseThrow를 사용하세요.
오류 №2: reduce로 컬렉션을 모으려 함. 가능하긴 하지만 이 목적에는 collect가 더 적합하고 빠릅니다:
// 비효율적!
List<String> list = stream.reduce(
new ArrayList<>(),
(acc, elem) -> { acc.add(elem); return acc; },
(acc1, acc2) -> { acc1.addAll(acc2); return acc1; }
);
// 더 나은 방법:
List<String> list2 = stream.collect(Collectors.toList());
오류 №3: toMap에서 키 중복을 처리하지 않음. 키가 충돌하면 예외가 발생합니다. 충돌을 해소할 수 있도록 toMap의 세 번째 인수를 추가하세요.
오류 №4: 병렬 스트림에서 동기화 없이 변경 가능한 컬렉션 사용. collect에서는 표준 컬렉터를 사용하세요 — 병렬 모드에서도 올바르게 동작합니다. parallelStream()의 forEach에서 list.add()를 호출하지 마세요.
오류 №5: 복잡한 작업에 reduce와 collect를 혼동. reduce — 간단한 집계(합계, 최댓값 등)에 적합합니다. collect — 컬렉션으로 모으기, 그룹화, Map 구성, 복합 집계에 적합합니다.
GO TO FULL VERSION