자바 함수형 마스터 노트 시리즈 4편. Stream의 6대 특성과 Pull 방식, 스트림 생성 4가지 경로, 중간 연산(filter·map·flatMap·sorted·distinct·limit·peek·takeWhile/dropWhile), 최종 연산(forEach·collect·count·match·reduce·findFirst), Collectors 12종, Optional 안전한 사용법, IntStream 원시 스트림, 병렬 스트림 함정까지.
이 글은 자바 함수형 마스터 노트 시리즈의 네 번째 편입니다. 3편(함수형 인터페이스)에서 Predicate·Function을 정리했다면, 이번엔 그것들을 받는 컬렉션 처리 엔진 — Stream API.
자바 컬렉션 처리의 패러다임 전환. for 루프와 if 분기로 풀던 작업이 **선언형(declarative)**으로 바뀝니다. "어떻게"가 아닌 "무엇을"만 적습니다. SQL 쿼리 같은 흐름.
처음 Stream이 어렵게 느껴지는 이유
처음 강의를 들을 때 Stream 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, 메서드가 너무 많아 보입니다. filter·map·flatMap·sorted·peek·collect·reduce·…. 어떤 게 중간 연산이고 어떤 게 최종인지 헷갈립니다. 둘째, Optional·Collectors까지 한꺼번에 쏟아져서. 이게 다 어떻게 엮이는지 큰 그림이 안 잡힙니다.
해결법은 한 가지예요. **"파이프라인 3단계"**로 보는 것. 소스 → 중간 연산(0개~N개) → 최종 연산(1개). 항상 이 흐름. 중간 연산은 Stream을 반환, 최종 연산은 결과(또는 void)를 반환. 이 그림이 잡히면 메서드는 카테고리별로 채우기만 하면 됩니다.
Stream 6대 특성
1. 컬렉션을 추상화한 시퀀스
2. 함수형 (filter·map 같은 연산이 함수형 인터페이스 받음)
3. 지연 평가 (Lazy) — 최종 연산 전엔 실행 X
4. 일회성 (한 번 소비하면 끝)
5. 원본 변경 X (불변)
6. 병렬 처리 가능 (parallel())
여기서 정말 중요한 시험 함정 — Stream은 컬렉션 아닙니다. 데이터 처리 파이프라인. 한 번 사용하면 끝, 재사용 X.
Stream<String> s = list.stream();
s.forEach(System.out::println);
s.count(); // IllegalStateException — 이미 소비된 스트림
Pull vs Push
컬렉션 = Push (모든 데이터를 미리 메모리에)
Stream = Pull (필요할 때만 한 요소씩)
이래서 Stream은 무한 시퀀스도 가능 (Stream.iterate).
스트림 생성 4가지 경로
1. Collection.stream()
List<String> list = List.of("a", "b", "c");
Stream<String> s = list.stream();
2. Arrays.stream()
int[] arr = {1, 2, 3};
IntStream s = Arrays.stream(arr);
String[] strs = {"a", "b", "c"};
Stream<String> s2 = Arrays.stream(strs);
3. Stream.of()
Stream<String> s = Stream.of("a", "b", "c");
Stream<Integer> nums = Stream.of(1, 2, 3, 4, 5);
4. Stream.iterate / Stream.generate
// 무한 시퀀스
Stream.iterate(1, n -> n + 1).limit(10); // 1~10
Stream.generate(Math::random).limit(5); // 랜덤 5개
// Java 9+ — 종료 조건 가능
Stream.iterate(1, n -> n < 100, n -> n * 2);
// 1, 2, 4, 8, 16, 32, 64
여기서 시험 함정이 하나 있어요. Stream.iterate·Stream.generate는 무한. 반드시 limit()로 끊어야. 안 끊으면 무한 루프.
중간 연산 — 12개 핵심
Stream을 반환 = 체이닝 가능. 모두 지연 평가.
filter — 조건 통과만
list.stream()
.filter(n -> n > 5)
.toList();
map — 변환
list.stream()
.map(String::toUpperCase)
.toList();
flatMap — 평탄화
List<List<Integer>> nested = List.of(
List.of(1, 2),
List.of(3, 4),
List.of(5)
);
nested.stream()
.flatMap(List::stream)
.toList();
// [1, 2, 3, 4, 5]
여기서 정말 중요한 시험 함정 — map vs flatMap. map은 1:1 변환, flatMap은 1:N → 전체 flat. 중첩 컬렉션 풀 때 flatMap.
sorted — 정렬
list.stream().sorted(); // 자연 순서
list.stream().sorted(Comparator.reverseOrder());
list.stream().sorted(Comparator.comparing(Person::getAge));
list.stream().sorted(Comparator.comparing(Person::getAge).reversed());
distinct — 중복 제거
Stream.of(1, 2, 2, 3, 3, 3).distinct(); // 1, 2, 3
equals() 기반.
limit / skip — 자르기
list.stream().limit(10); // 처음 10개
list.stream().skip(5); // 처음 5개 건너뛰기
list.stream().skip(5).limit(10); // 5번부터 14번까지
peek — 디버깅
list.stream()
.peek(x -> System.out.println("after filter: " + x))
.map(x -> x * 2)
.peek(x -> System.out.println("after map: " + x))
.toList();
여기서 시험 함정이 하나 있어요. peek는 디버깅 용도. 값을 변경하면 안 됨 (forEach와 다름). 부작용 없는 관찰용.
takeWhile / dropWhile (Java 9+)
Stream.of(1, 2, 3, 4, 5, 1, 2)
.takeWhile(n -> n < 4); // 1, 2, 3 — 처음으로 false 만나면 멈춤
Stream.of(1, 2, 3, 4, 5, 1, 2)
.dropWhile(n -> n < 4); // 4, 5, 1, 2 — 처음 false 이후 모두
filter와 다름 — 순서 의존. 정렬된 스트림에 강력.
mapToInt / mapToLong / mapToDouble
list.stream()
.mapToInt(String::length)
.sum(); // 원시 IntStream의 sum() 사용 가능
여기서 정말 중요한 시험 함정 — Stream<Integer>.sum() 안 됨. 원시 IntStream으로 변환해야. 박싱 비용도 회피.
최종 연산 — 11개 핵심
Stream 외 결과 반환 = 파이프라인 트리거.
forEach — 부작용
list.stream().forEach(System.out::println);
list.parallelStream().forEachOrdered(System.out::println); // 순서 보장
collect — 수집
List<String> list = stream.collect(Collectors.toList());
Set<String> set = stream.collect(Collectors.toSet());
// Java 16+ 단축
List<String> list = stream.toList(); // 불변 리스트
count
long n = list.stream().filter(p -> p.getAge() > 18).count();
min / max
Optional<Integer> min = list.stream().min(Comparator.naturalOrder());
Optional<Person> oldest = list.stream().max(Comparator.comparing(Person::getAge));
findFirst / findAny
Optional<String> first = list.stream().filter(...).findFirst();
Optional<String> any = list.parallelStream().filter(...).findAny();
여기서 시험 함정이 하나 있어요. findAny는 병렬 스트림에서 더 빠름. 어느 요소든 OK이므로 첫 발견 시 즉시 반환. findFirst는 순서 보장.
anyMatch / allMatch / noneMatch
boolean any = list.stream().anyMatch(p -> p.getAge() > 60);
boolean all = list.stream().allMatch(p -> p.getAge() > 0);
boolean none = list.stream().noneMatch(p -> p.getAge() < 0);
short-circuit — 결정 즉시 종료.
reduce — 누적
// 합계
int sum = nums.stream().reduce(0, Integer::sum);
// 빈 스트림 처리 — Optional 반환
Optional<Integer> sum2 = nums.stream().reduce(Integer::sum);
// 다른 타입으로 누적
String concat = strs.stream().reduce("", String::concat);
여기서 정말 중요한 시험 함정 — reduce 시그니처 3종. (1) reduce(identity, accumulator) — 항상 결과 반환, (2) reduce(accumulator) — Optional 반환 (빈 스트림 대비), (3) reduce(identity, accumulator, combiner) — 병렬용.
Collectors — collect의 동반자
import static java.util.stream.Collectors.*;
stream.collect(toList());
stream.collect(toSet());
stream.collect(toMap(Person::getId, Function.identity()));
stream.collect(joining(", ", "[", "]"));
stream.collect(groupingBy(Person::getCity));
stream.collect(partitioningBy(p -> p.getAge() > 18));
stream.collect(counting());
stream.collect(summingInt(Person::getAge));
stream.collect(averagingInt(Person::getAge));
stream.collect(minBy(Comparator.comparing(Person::getAge)));
stream.collect(maxBy(Comparator.comparing(Person::getAge)));
stream.collect(toUnmodifiableList()); // Java 10+
groupingBy — 그룹핑
Map<String, List<Person>> byCity = list.stream()
.collect(groupingBy(Person::getCity));
// 다단 그룹핑
Map<String, Map<Integer, List<Person>>> byCityAndAge = list.stream()
.collect(groupingBy(Person::getCity, groupingBy(Person::getAge)));
// 그룹별 카운트
Map<String, Long> countByCity = list.stream()
.collect(groupingBy(Person::getCity, counting()));
partitioningBy — Predicate로 2분할
Map<Boolean, List<Person>> adultsAndKids = list.stream()
.collect(partitioningBy(p -> p.getAge() >= 18));
adultsAndKids.get(true); // 성인
adultsAndKids.get(false); // 미성년
여기서 시험 함정이 하나 있어요. groupingBy 키는 임의 / partitioningBy 키는 Boolean 2개. 단순 2분할은 partitioningBy가 빠름.
joining — 문자열 결합
String csv = list.stream().collect(joining(","));
String pretty = list.stream().collect(joining(", ", "[", "]"));
Optional — null의 안전한 컨테이너
Java 8 도입. null 체크 안 잊게 강제하는 타입.
Optional<String> name = Optional.of("Alice");
Optional<String> empty = Optional.empty();
Optional<String> nullable = Optional.ofNullable(maybeNull); // null이면 empty
// 값 꺼내기
if (name.isPresent()) {
String s = name.get();
}
// 더 좋은 방법
name.ifPresent(System.out::println);
// 기본값
String s = name.orElse("Default");
String s2 = name.orElseGet(() -> computeDefault());
String s3 = name.orElseThrow(() -> new RuntimeException("not found"));
// 변환
Optional<Integer> len = name.map(String::length);
// flatMap
Optional<String> upper = name.flatMap(s -> Optional.of(s.toUpperCase()));
// Java 11+
boolean empty = name.isEmpty();
여기서 정말 중요한 시험 함정 — Optional은 필드·인자에 쓰지 말 것. 반환 타입에만 권장. 직렬화·메모리 비용·중첩 Optional 함정 발생.
// X — 안티패턴
public class User {
private Optional<String> name; // 안 됨
}
public void process(Optional<String> name) { ... } // 안 됨
// O — 권장
public Optional<User> findById(long id) { ... } // OK
원시 타입 스트림 — IntStream / LongStream / DoubleStream
박싱 비용 회피.
IntStream.range(1, 11); // 1~10
IntStream.rangeClosed(1, 10); // 1~10 (10 포함)
int sum = IntStream.range(1, 11).sum();
double avg = IntStream.range(1, 11).average().getAsDouble();
int max = IntStream.range(1, 11).max().getAsInt();
// Stream<Integer>로 변환
IntStream.range(1, 11).boxed().collect(toList());
여기서 시험 함정이 하나 있어요. IntStream의 average()는 OptionalDouble 반환. 빈 스트림 대비. getAsDouble()로 추출.
병렬 스트림
list.parallelStream().filter(...).toList();
list.stream().parallel().filter(...).toList();
내부적으로 Fork/Join 풀 사용. CPU 코어 수만큼 분산.
언제 효과?
- CPU 집약 — 무거운 계산 (정렬·복잡한 변환)
- 데이터 양 많음 — 1만+ 권장
- 순서 무관
- 부작용 없음 (순수 함수)
언제 비효과?
- 작은 데이터 (오버헤드가 더 큼)
- I/O 작업 (스레드 블록)
- 순서 의존 작업
- 부작용 있는 람다
여기서 정말 중요한 시험 함정 — 병렬 스트림은 만능 X. 무조건 빠른 게 아닙니다. 작은 컬렉션·I/O·순서 의존엔 오히려 느려짐. 측정 후 적용.
// 위험 — 부작용
List<Integer> result = new ArrayList<>();
list.parallelStream().forEach(result::add); // race condition
// 안전 — collect
List<Integer> result = list.parallelStream().collect(toList());
인터페이스 default 메서드와 Stream
Collection 인터페이스에 stream()이 default로 추가됨. 모든 컬렉션이 자동으로 stream 지원.
public interface Collection<E> {
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
}
자바 8 인터페이스 진화의 결정적 사례.
실전 — Stream 파이프라인 패턴
// 직원 데이터에서 부서별 평균 연봉 (성인만)
Map<String, Double> avgSalaryByDept = employees.stream()
.filter(e -> e.getAge() >= 18)
.collect(groupingBy(
Employee::getDepartment,
averagingDouble(Employee::getSalary)
));
// 가장 비싼 책 5권
List<Book> top5 = books.stream()
.sorted(Comparator.comparing(Book::getPrice).reversed())
.limit(5)
.toList();
// 단어 빈도수
Map<String, Long> wordCount = Arrays.stream(text.split("\\s+"))
.map(String::toLowerCase)
.collect(groupingBy(Function.identity(), counting()));
// 모든 작가의 모든 책 한 리스트로
List<Book> allBooks = authors.stream()
.flatMap(a -> a.getBooks().stream())
.distinct()
.toList();
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 4편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- Stream = 데이터 처리 파이프라인, 컬렉션 X
- 6 특성 — 시퀀스·함수형·지연·일회성·불변·병렬
- 한 번 소비 후 재사용 X
- 파이프라인 = 소스 → 중간(0~N) → 최종(1)
- 생성 — Collection.stream / Arrays.stream / Stream.of / iterate·generate
- iterate·generate는 무한 — limit 필수
- 중간 12 — filter·map·flatMap·sorted·distinct·limit·skip·peek·takeWhile·dropWhile·mapToInt
- map vs flatMap — flatMap은 1:N + flat
- takeWhile/dropWhile = 순서 의존 (Java 9+)
- peek는 디버깅용, 값 변경 X
- 최종 11 — forEach·collect·count·min·max·findFirst·findAny·anyMatch·allMatch·noneMatch·reduce
- findFirst = 순서 / findAny = 병렬 빠름
- short-circuit — anyMatch·allMatch·noneMatch
- reduce 3 시그니처 — identity+accum / accum (Optional) / identity+accum+combiner
- Collectors 12 — toList·toSet·toMap·joining·groupingBy·partitioningBy·counting·summing·averaging·minBy·maxBy·toUnmodifiableList
- groupingBy = 임의 키 / partitioningBy = Boolean 2분할
- 다단 groupingBy —
groupingBy(A, groupingBy(B)) - Optional = null 컨테이너
of/ofNullable/empty/isPresent/ifPresent/orElse/orElseGet/orElseThrow- Java 11+ —
isEmpty() - Optional은 반환 타입에만 — 필드·인자 X
- 원시 스트림 — IntStream·LongStream·DoubleStream
- range(시작, 끝) — 끝 미포함 / rangeClosed — 끝 포함
average()= OptionalDoubleboxed()= Stream<Integer>로 박싱- 병렬 스트림 = 만능 X
- 효과 — CPU 집약·1만+ 데이터·순수 함수·순서 무관
- 비효과 — 작은 데이터·I/O·순서 의존·부작용
- 부작용 람다는 race condition
시리즈 다른 편
- 1편 — JVM·OOP·컬렉션 기초
- 2편 — 람다 표현식
- 3편 — 함수형 인터페이스
- 4편 — Stream API (현재 글)
- 5편 — Modern Java (9~17)
- 6편 — Java 21 가상 스레드
공식 문서: Oracle Stream Package Summary 에서 더 깊이.
다음 글(5편)에서는 Modern Java — Java 9~17의 실용 신기능 (var·List.of·Record·Sealed·텍스트 블록·Switch 표현식·패턴 매칭)까지 풀어 갑니다.