자바 백엔드 입문 9편. 컬렉션을 함수형으로 다루는 자바 Stream API와 람다 표현식의 표준 패턴을 컨베이어 벨트 비유로 풀어쓴 학습 노트.
이 글은 자바 백엔드 입문 시리즈 59편 중 9편이에요. 5편 자바 컬렉션 의 List·Map을 "한 줄로 변환·필터링" 하는 모던 자바의 진수 — Stream API 와 람다를 풀어 가요.
Stream이 헷갈리는 이유
자바 코드 보면 stream().filter(...).map(...).collect(...) 같은 줄줄이 메서드 호출이 등장해요. 처음 보면 "이게 뭐지? 마법 같은 한 줄?" 가 안 잡혀요.
이 글에서는 컨베이어 벨트 비유로 풀어요. Stream = "컨베이어 벨트", filter = "불량품 거르기", map = "가공", collect = "상자에 담기". 끝까지 따라오시면 한국 회사 자바 코드의 70% 풍경이 한눈에 들어와요.
람다 표현식 — 함수를 값처럼
Stream 이해의 전제. 람다 = "이름 없는 함수". 자바 8(2014) 도입.
// 익명 클래스 (옛 스타일)
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello");
}
};
// 람다 표현식 (자바 8+)
Runnable r = () -> System.out.println("Hello");
10줄이 1줄로. 함수를 "변수에 담아 전달하는" 함수형 프로그래밍 패러다임.
람다 문법
// (매개변수) -> 본문
() -> System.out.println("Hi") // 매개변수 없음
x -> x * 2 // 한 개 (괄호 생략 가능)
(x, y) -> x + y // 두 개
(int x, int y) -> x + y // 타입 명시
(x, y) -> { // 여러 줄 본문
int sum = x + y;
return sum;
}
메서드 레퍼런스 — 더 짧게
// 람다
list.forEach(s -> System.out.println(s));
// 메서드 레퍼런스 (더 깔끔)
list.forEach(System.out::println);
Class::method 또는 instance::method 표기. 람다가 "기존 메서드 한 줄 호출" 일 때 매우 자주.
Stream — 컨베이어 벨트
Stream = "컬렉션 위에 흐르는 데이터 파이프라인". 자바 8(2014) 도입.
List<String> names = List.of("Alice", "Bob", "Charlie", "David");
// 길이 5 이상인 이름만 대문자로
List<String> result = names.stream()
.filter(name -> name.length() >= 5) // 거르기
.map(String::toUpperCase) // 가공
.collect(Collectors.toList()); // 수집
System.out.println(result); // [ALICE, CHARLIE, DAVID]
for 루프 + if 조합 5~6줄이 한 표현식으로. 선언적 스타일 — "무엇을 하라" 만 적고 "어떻게" 는 라이브러리에 맡김.
파이프라인 3단계
[소스] → [중간 연산 0+개] → [종료 연산 1개]
1. 소스 — 컬렉션의 시작점
list.stream() // List·Set
map.entrySet().stream() // Map
Stream.of("a", "b", "c") // 직접 생성
Arrays.stream(arr) // 배열
2. 중간 연산 — 변환·필터링 (Stream 반환, 지연 평가)
| 메서드 | 의미 |
|---|---|
filter(pred) |
조건 통과한 요소만 |
map(fn) |
변환 (T → R) |
flatMap(fn) |
중첩 평탄화 |
distinct() |
중복 제거 |
sorted() |
정렬 |
limit(n) |
처음 n개 |
skip(n) |
처음 n개 건너뜀 |
peek(c) |
중간 디버깅 (값 안 바꿈) |
3. 종료 연산 — 결과 도출 (Stream 종료)
| 메서드 | 의미 |
|---|---|
collect(Collectors.toList()) |
List로 수집 |
collect(Collectors.toSet()) |
Set으로 |
collect(Collectors.toMap(k, v)) |
Map으로 |
count() |
개수 |
forEach(c) |
각 요소 실행 (반환 X) |
findFirst() |
첫 요소 (8편 Optional 반환) |
anyMatch(p)·allMatch(p)·noneMatch(p) |
boolean |
reduce(init, fn) |
축약 (합·곱 등) |
min(cmp)·max(cmp) |
최소·최대 |
toList() (자바 16+) |
짧은 표기 |
지연 평가 — 중요한 특성
중간 연산은 호출 즉시 실행 안 됨 — 종료 연산이 호출돼야 비로소 파이프라인 가동.
Stream<String> stream = names.stream()
.filter(n -> { System.out.println("filter " + n); return n.length() > 3; })
.map(n -> { System.out.println("map " + n); return n.toUpperCase(); });
// 출력 X — 아직 실행 안 됨
stream.collect(Collectors.toList()); // 이때 비로소 filter·map 호출
이 "지연 평가" 덕분에 — 1억 개 데이터에서 처음 3개만 필요할 때 1억 번 다 돌지 않고 "3개 찾으면 즉시 종료".
자주 쓰는 패턴 6가지
(1) filter + map + collect
List<String> activeUserNames = users.stream()
.filter(User::isActive)
.map(User::getName)
.collect(Collectors.toList());
(2) groupingBy — 그룹화
Map<String, List<User>> usersByCountry = users.stream()
.collect(Collectors.groupingBy(User::getCountry));
// 카운트만
Map<String, Long> countByCountry = users.stream()
.collect(Collectors.groupingBy(User::getCountry, Collectors.counting()));
SQL의 GROUP BY와 같은 의미. 한국 회사 백엔드 매우 자주.
(3) toMap — 키-값 변환
Map<Long, User> userMap = users.stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
Function.identity() = "받은 그대로 반환" (즉, x -> x 의 명시적 표기).
(4) reduce — 축약
int totalAmount = orders.stream()
.mapToInt(Order::getAmount)
.sum();
// 또는
int total = orders.stream()
.map(Order::getAmount)
.reduce(0, Integer::sum);
(5) joining — 문자열 연결
String csv = users.stream()
.map(User::getName)
.collect(Collectors.joining(", ")); // "Alice, Bob, Charlie"
(6) findFirst — Optional 반환
Optional<User> firstActive = users.stream()
.filter(User::isActive)
.findFirst();
병렬 Stream — parallelStream()
long count = users.parallelStream()
.filter(User::isActive)
.count();
stream() 대신 parallelStream() 박으면 — 멀티 스레드로 병렬 처리. 다만 함정:
- 데이터 적으면(< 수만) 오히려 느림 (스레드 오버헤드)
- 순서 보장 X
- ForkJoinPool 공유 — 다른 작업과 충돌 가능
한국 회사 실무 — 일반 Stream이 99%. parallelStream은 거의 안 씀. 진짜 빠르게 만들고 싶으면 비동기·아키텍처 차원으로 풀어야지 parallelStream 한 줄로 해결되는 경우 드뭄.
Stream API 안티패턴
(1) for 루프 단순 대체
// ❌ 그냥 for 루프가 더 명확
users.stream().forEach(user -> log.info(user.getName()));
// ✅ for-each 가 더 깔끔
for (User user : users) {
log.info(user.getName());
}
Stream 의 진가는 "변환·필터·집계" 가 동반될 때. 단순 순회는 for-each 가 명확.
(2) Stream 재사용
Stream<String> stream = list.stream();
stream.filter(...).collect(...);
stream.map(...).collect(...); // ❌ IllegalStateException — Stream은 일회용
Stream 은 "한 번 종료 연산 실행하면 끝". 다시 쓰려면 list.stream() 으로 새로 생성.
(3) forEach 안에서 외부 변수 변경
// ❌ 부작용 — 순서 보장 X, 병렬 시 에러
List<String> result = new ArrayList<>();
users.stream().forEach(u -> result.add(u.getName()));
// ✅ collect 사용
List<String> result = users.stream()
.map(User::getName)
.collect(Collectors.toList());
Spring·JPA에서 자주
이 시리즈 거의 모든 글에 등장.
// 컨트롤러
@GetMapping
public List<OrderResponse> list() {
return orderService.findAll().stream()
.map(OrderResponse::from)
.collect(Collectors.toList());
}
// 통계
Map<String, Long> statsByStatus = orderRepository.findAll().stream()
.collect(Collectors.groupingBy(Order::getStatus, Collectors.counting()));
자바 백엔드 = 매일 Stream 다루는 일.
컬렉션에서 변환·필터·집계가 동반되면 Stream. 단순 순회는 for-each. .stream().filter().map().collect() 4단 콤보가 80% 시나리오 커버. 메서드 레퍼런스(::)로 더 깔끔하게.
한 줄 정리 — 자바 Stream API + 람다 = 컬렉션을 함수형으로 다루는 표준. filter·map·collect 파이프라인이 70% 패턴. parallelStream은 함정 많아 거의 안 씀. for 루프 단순 대체는 안티패턴.
시험 직전 한 번 더 — Stream + 람다 입문자가 매번 헷갈리는 것
- 람다 = 익명 함수 (자바 8+)
- 문법 =
(매개변수) -> 본문 - 메서드 레퍼런스 =
Class::method·instance::method - Stream = 컬렉션 위 파이프라인 (자바 8+)
- 파이프라인 = 소스 → 중간 연산 → 종료 연산
- 중간 연산 =
filter·map·flatMap·distinct·sorted·limit·skip·peek - 종료 연산 =
collect·count·forEach·findFirst·anyMatch·reduce - 지연 평가 = 종료 연산 호출 전엔 실행 X
- collect(toList) = List로 수집 (자바 16+
.toList()단축) - collect(groupingBy) = SQL GROUP BY와 같은 의미
- collect(toMap) = 키-값 변환
- collect(joining) = 문자열 연결
- reduce = 축약 (합·곱·최소·최대)
mapToInt(...).sum()= 합계 표준 패턴- Stream은 일회용 — 종료 연산 후 재사용 X
- parallelStream = 멀티 스레드 (한국 실무 거의 안 씀)
- 안티패턴 1 = for 루프 단순 대체 (
.forEach만) - 안티패턴 2 = Stream 재사용 (IllegalStateException)
- 안티패턴 3 = forEach 안에서 외부 변수 변경
findFirst()·findAny()=Optional반환 (8편)- 자바 백엔드 = 매일 Stream 다루는 일
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
다음 글: