자바 함수형 마스터 — Stream API 완전 정리

2026-05-03확률과 통계 마스터 노트

자바 함수형 마스터 노트 시리즈 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() = OptionalDouble
  • boxed() = Stream<Integer>로 박싱
  • 병렬 스트림 = 만능 X
  • 효과 — CPU 집약·1만+ 데이터·순수 함수·순서 무관
  • 비효과 — 작은 데이터·I/O·순서 의존·부작용
  • 부작용 람다는 race condition

시리즈 다른 편

공식 문서: Oracle Stream Package Summary 에서 더 깊이.

다음 글(5편)에서는 Modern Java — Java 9~17의 실용 신기능 (var·List.of·Record·Sealed·텍스트 블록·Switch 표현식·패턴 매칭)까지 풀어 갑니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!