우리가 수업 시간에 Java Stream과 Stream API를 배우다 보면 객체 리스트의 합계를 구하는 순간부터 헷갈리는 경우가 많습니다. reduce()를 써야 하는지, mapToInt()가 왜 필요한지, 단순 합계 작업인데도 코드가 길어지는 이유가 무엇인지 막히기 쉽습니다. 이 글은 그런 지점을 한 번에 정리하려는 통합 가이드입니다. 특히 mapToInt, sum, reduce를 비교하면서, 어떤 상황에서 어떤 선택이 더 자연스러운지 실수 교정 중심으로 설명하겠습니다.
먼저 바로잡고 싶은 오해: 합계 코드는 하나만 있는 것이 아닙니다
수업에서 가장 자주 보던 오해는 두 가지였습니다.
첫째, 스트림으로 합계를 구할 때는 무조건 reduce(0, ...)를 쓰면 된다고 생각하는 경우입니다. 둘째, List<Integer>는 숫자 리스트처럼 보이니까 바로 sum()을 호출할 수 있다고 여기는 경우입니다.
둘 다 반은 맞고 반은 틀립니다. 합계라는 목적만 놓고 보면 reduce()도 가능하고 mapToInt().sum()도 가능합니다. 다만 코드의 의도와 가독성을 따지면 선택이 달라집니다.
- 단순한 숫자 합계:
mapToInt(...).sum()이 더 읽기 쉬운 경우가 많음 - 합계 외의 누적 규칙까지 직접 표현:
reduce()가 더 유연함
이 차이를 모르면 Stream API를 “문법 암기”로 배우게 됩니다. 실제로는 메서드 이름이 아니라 데이터 흐름과 누적 방식을 이해하는 쪽이 훨씬 중요합니다.
List<Integer>가 바로 더해지지 않는 이유
겉으로는 숫자 목록처럼 보여도 List<Integer>는 기본형 int의 모음이 아니라 Integer 객체의 모음입니다. 자바 컬렉션은 기본형을 직접 담을 수 없기 때문에 래퍼 클래스가 필요합니다.
예를 들어 아래 리스트를 보겠습니다.
List<Integer> numbers = Arrays.asList(10, 20, 30, 40, 50);
여기서 numbers.stream()의 타입은 Stream<Integer>입니다. 그런데 sum()은 보통 IntStream에서 쓰는 집계 메서드입니다. 그래서 아래처럼 객체 스트림을 기본형 스트림으로 바꾸는 작업이 필요합니다.
int sum = numbers.stream()
.mapToInt(Integer::intValue)
.sum();
Integer::intValue는 각 Integer 객체에서 실제 int 값을 꺼내는 메서드 참조입니다. 이 흐름을 말로 바꾸면 이렇습니다.
- 리스트를 스트림으로 바꾼다
- 각 요소를
int로 변환한다 - 전체 합을 계산한다
처음에는 한 단계가 더 있는 것처럼 보여 번거롭게 느껴지지만, 객체 리스트를 다루는 자바의 기본 원리를 이해하면 오히려 자연스럽게 보입니다.
합계만 구한다면 mapToInt().sum()이 더 직관적입니다
단순 합계는 가능한 한 의도가 드러나게 쓰는 편이 좋습니다. 그런 점에서 아래 코드는 읽는 사람이 바로 이해할 수 있습니다.
import java.util.Arrays;
import java.util.List;
public class StreamSumExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(10, 20, 30, 40, 50);
int sum = numbers.stream()
.mapToInt(Integer::intValue)
.sum();
System.out.println("숫자 리스트: " + numbers);
System.out.println("합계: " + sum);
}
}
실행 결과:
숫자 리스트: [10, 20, 30, 40, 50]
합계: 150
이 방식의 장점은 분명합니다.
- 코드 목적이 바로 보입니다.
- “숫자로 바꿔서 합한다”는 흐름이 선명합니다.
- 빈 리스트여도
sum()결과가0이라 기본 합산에는 안전한 편입니다.
실무에서도 장바구니 금액, 학생 점수, 재고 수량처럼 단순 합계를 구할 때는 이 패턴을 가장 먼저 떠올리는 경우가 많습니다.
reduce()는 합계 공식을 외우는 메서드가 아니라 누적을 표현하는 도구입니다
reduce()는 여러 요소를 하나의 결과로 줄이는 최종 연산입니다. 합계 계산도 그중 하나일 뿐입니다.
가장 기본적인 합계 예제는 아래처럼 씁니다.
import java.util.Arrays;
import java.util.List;
public class StreamReduceSumExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
.reduce(0, (total, number) -> total + number);
System.out.println("숫자 리스트: " + numbers);
System.out.println("합계: " + sum);
}
}
실행 결과:
숫자 리스트: [1, 2, 3, 4, 5]
합계: 15
여기서 중요한 것은 문법보다 계산 흐름입니다.
초기값 total = 0
0 + 1 = 1
1 + 2 = 3
3 + 3 = 6
6 + 4 = 10
10 + 5 = 15
이 과정을 손으로 한 번만 써 보면 reduce()가 훨씬 덜 어렵습니다. 또 하나 강조하고 싶은 점은 변수 이름입니다. a, b처럼 짧게 써도 되지만, 학습 단계에서는 total, number, score처럼 역할이 보이는 이름이 이해에 큰 도움을 줍니다.
초기값을 습관처럼 0으로 넣으면 틀릴 수 있습니다
여기서부터 실수가 많이 나옵니다. 합계 예제만 반복해서 보다 보면 reduce()의 첫 번째 인자는 무조건 0이라고 착각하기 쉽습니다. 하지만 초기값은 단순 자리 채우기가 아니라 계산의 출발점입니다.
합계에서는 0이 자연스럽습니다
int sum = numbers.stream()
.reduce(0, (total, number) -> total + number);
곱셈에서는 1이 자연스럽습니다
import java.util.Arrays;
import java.util.List;
public class StreamReduceMultiplyExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(2, 3, 4);
int result = numbers.stream()
.reduce(1, (total, number) -> total * number);
System.out.println("곱셈 결과: " + result);
}
}
실행 결과:
곱셈 결과: 24
곱셈에서 초기값을 0으로 두면 어떤 값이 와도 결과는 0이 됩니다. 이 지점에서 학생들이 “람다는 맞게 썼는데 왜 결과가 이상하지?” 하고 멈추는 경우가 많았습니다. 사실 문제는 람다가 아니라 초기값 선택이었습니다.
최댓값 계산에서는 더 조심해야 합니다
아래 코드는 얼핏 맞아 보이지만 틀릴 수 있습니다.
import java.util.Arrays;
import java.util.List;
public class WrongMaxExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(-5, -2, -9);
int max = numbers.stream()
.reduce(0, (currentMax, number) -> currentMax > number ? currentMax : number);
System.out.println("최댓값: " + max);
}
}
실행 결과:
최댓값: 0
리스트에 없는 0이 결과에 끼어들었습니다. 초기값이 실제 데이터처럼 비교에 참여했기 때문입니다. 이런 경우는 초기값 없는 reduce()를 검토하는 편이 맞습니다.
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
public class CorrectMaxExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(-5, -2, -9);
Optional<Integer> max = numbers.stream()
.reduce((currentMax, number) -> currentMax > number ? currentMax : number);
max.ifPresent(value -> System.out.println("최댓값: " + value));
}
}
실행 결과:
최댓값: -2
여기서 반환 타입이 Optional<Integer>가 되는 이유도 꼭 같이 이해해야 합니다. 스트림이 비어 있을 수 있기 때문입니다.
객체 리스트 합계에서는 “무엇을 꺼내서 더할지”부터 정해야 합니다
실제 코드에서는 List<Integer>보다 객체 리스트가 더 흔합니다. 학생 목록에서 점수를 합산하거나, 상품 목록에서 가격을 합산하거나, 주문 목록에서 수량 합계를 구하는 식입니다.
이럴 때 처음 막히는 이유는 객체 전체를 더하려 하기 때문입니다. 객체는 그대로 합산할 수 없고, 먼저 합산할 숫자 필드를 꺼내야 합니다.
import java.util.Arrays;
import java.util.List;
class Student {
private final String name;
private final int score;
public Student(String name, int score) {
this.name = name;
this.score = score;
}
public String getName() {
return name;
}
public int getScore() {
return score;
}
}
public class StudentScoreSumExample {
public static void main(String[] args) {
List<Student> students = Arrays.asList(
new Student("민수", 80),
new Student("지아", 90),
new Student("현우", 75)
);
int totalScore = students.stream()
.mapToInt(Student::getScore)
.sum();
System.out.println("점수 합계: " + totalScore);
}
}
실행 결과:
점수 합계: 245
같은 작업을 reduce()로도 표현할 수 있습니다.
int totalScore = students.stream()
.map(Student::getScore)
.reduce(0, (total, score) -> total + score);
둘 다 맞습니다. 다만 합계 자체가 목적이라면 mapToInt().sum() 쪽이 더 짧고 분명합니다.
직접 정리해 보니 가장 자주 헷갈리는 지점
수업에서 자주 본 실수와 직접 코드를 실행하며 다시 확인한 기준을 묶어 보면, 아래 네 가지에서 많이 막힙니다.
| 헷갈리는 지점 | 잘못 쓰기 쉬운 방식 | 더 적절한 판단 |
|---|---|---|
List<Integer> 합계 | numbers.stream().sum() | mapToInt(Integer::intValue).sum() 사용 |
| 객체 리스트 합계 | 객체를 바로 더하려고 시도 | 먼저 map, mapToInt로 숫자 필드 추출 |
reduce() 초기값 | 무조건 0 사용 | 연산 성격에 맞는 시작값 선택 |
| 스트림 사용 후 원본 변화 | 스트림이 리스트를 바꾼다고 오해 | 스트림 연산은 원본 컬렉션을 직접 수정하지 않음 |
특히 마지막 항목은 의외로 자주 나옵니다. 스트림을 통과했다고 해서 원본 리스트가 새 값으로 바뀌는 것은 아닙니다. map, filter, reduce, sum은 데이터를 조회·변환·집계하는 흐름이지, 원본 컬렉션을 직접 바꾸는 작업이 아닙니다. 원본을 수정하려면 별도의 변경 코드가 필요합니다.
null, 빈 리스트, 병렬 스트림에서 한 번 더 점검할 것
기본 예제는 짧지만, 실제 작업에서는 아래 조건을 꼭 한 번 더 봐야 합니다.
1) null 값이 섞여 있지 않은가
List<Integer>에 null이 섞여 있으면 Integer::intValue에서 예외가 날 수 있습니다.
numbers.stream()
.filter(Objects::nonNull)
.mapToInt(Integer::intValue)
.sum();
객체 리스트에서도 숫자 필드를 꺼내기 전 입력 데이터 상태를 먼저 보는 습관이 좋습니다.
2) 빈 리스트의 의미가 정말 0인가
sum()은 빈 스트림에 대해 0을 반환합니다. 기술적으로는 편리하지만, 업무 의미상 “데이터가 없어서 0”과 “실제 합계가 0”을 구분해야 할 수도 있습니다. 그런 경우는 단순 합산 결과만 보지 말고 입력 데이터 존재 여부도 함께 확인해야 합니다.
3) 병렬 스트림에서는 외부 상태 변경을 피해야 합니다
합계처럼 결합 법칙이 자연스러운 연산은 비교적 안전하지만, reduce() 내부에서 외부 변수를 변경하거나 실행 순서에 의존하는 코드는 피하는 편이 좋습니다. 병렬 처리에서는 특히 더 그렇습니다.
수업 실습 문제와 풀이 코드
아래 코드는 한 파일에서 여러 상황을 직접 확인할 수 있게 구성했습니다. 합계, 곱셈, 객체 리스트 합계를 한 번에 연습할 수 있습니다.
import java.util.Arrays;
import java.util.List;
class Product {
private final String name;
private final int price;
public Product(String name, int price) {
this.name = name;
this.price = price;
}
public int getPrice() {
return price;
}
public String getName() {
return name;
}
}
public class StreamPracticeAllInOne {
public static void main(String[] args) {
// 문제 1: 숫자 리스트 합계
List<Integer> numbers1 = Arrays.asList(3, 6, 9);
int sum1 = numbers1.stream()
.reduce(0, (total, number) -> total + number);
System.out.println("문제 1 합계: " + sum1);
// 문제 2: 숫자 리스트 곱셈
List<Integer> numbers2 = Arrays.asList(2, 3, 4);
int multiply = numbers2.stream()
.reduce(1, (total, number) -> total * number);
System.out.println("문제 2 곱셈 결과: " + multiply);
// 문제 3: 객체 리스트 가격 합계 - reduce 사용
List<Product> products = Arrays.asList(
new Product("키보드", 30000),
new Product("마우스", 15000),
new Product("USB 케이블", 5000)
);
int totalPriceByReduce = products.stream()
.map(Product::getPrice)
.reduce(0, (total, price) -> total + price);
System.out.println("문제 3 가격 합계(reduce): " + totalPriceByReduce);
// 문제 4: 객체 리스트 가격 합계 - mapToInt와 sum 사용
int totalPriceBySum = products.stream()
.mapToInt(Product::getPrice)
.sum();
System.out.println("문제 4 가격 합계(mapToInt + sum): " + totalPriceBySum);
// 원본 컬렉션이 바뀌지 않는지 확인
System.out.println("원본 상품 개수: " + products.size());
System.out.println("원본 첫 상품 이름: " + products.get(0).getName());
}
}
실행 결과:
문제 1 합계: 18
문제 2 곱셈 결과: 24
문제 3 가격 합계(reduce): 50000
문제 4 가격 합계(mapToInt + sum): 50000
원본 상품 개수: 3
원본 첫 상품 이름: 키보드
직접 실행할 때는 아래도 바꿔 보길 권합니다.
reduce()의 초기값을 일부러 잘못 넣어 보기- 리스트를 빈 리스트로 바꿔 보기
null을 섞어 보고 어디서 예외가 나는지 확인하기map(...).reduce(...)와mapToInt(...).sum()의 가독성을 비교해 보기
이렇게 손으로 확인하면 문법보다 판단 기준이 남습니다.
어떤 코드를 선택하면 좋은지, 결국은 의도와 유지보수성입니다
단순 합계를 구하는 상황에서는 다음처럼 판단하면 크게 틀리지 않습니다.
- 합계가 목적이면
mapToInt(...).sum()을 우선 고려 - 합계 외의 누적 규칙을 설명해야 하면
reduce()사용 - 초기값이 결과에 영향을 줄 수 있으면 정말 그 값이 자연스러운지 먼저 검토
- 객체 리스트라면 합산 대상 필드를 먼저 추출
- 원본 컬렉션이 바뀐다고 기대하지 말 것
직접 정리해 보니, 초보 단계에서 제일 중요한 것은 “어떤 메서드를 외울까”보다 “지금 내가 하려는 집계 작업의 성격이 무엇인가”를 먼저 묻는 습관이었습니다. 이 기준만 잡히면 Stream API가 훨씬 덜 복잡하게 느껴집니다.
참고 자료
- [Oracle Java Stream API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Stream.html)
- [Oracle Collectors API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Collectors.html)
- [Oracle Comparator API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Comparator.html)

저는 대학에서 컴퓨터 관련 학과 학생들을 가르치고 있는 교수 김기태입니다. Spring Boot, React, Python 등을 활용한 풀스택 웹 개발과 최신 AI 서비스 설계를 주로 다루고 있으며, 학생들이 현업 실무에 바로 적용할 수 있는 기술 역량을 키울 수 있도록 강의와 실습을 설계하고 있습니다.


