숏컷 코드
List<String> result = names.stream()
.filter(name -> name.length() >= 3)
.map(String::toUpperCase)
.toList();문법
어떤 Stream 흐름을 먼저 떠올리면 되나
| 상황 | 먼저 떠올릴 것 |
|---|---|
| 필터링 후 변환 | stream().filter().map() |
| 개수 세기 | .count() |
| 첫 번째 일치 항목 | .findFirst() |
| 리스트로 수집 | .toList() |
| 외부 상태 변경이 많음 | 전통 for 루프 검토 |
Stream vs 컬렉션: 저장이 아닌 파이프라인
컬렉션은 데이터를 저장하는 자료구조이고, Stream은 데이터를 흘려 보내며 연산을 연결하는 파이프라인 모델입니다. Stream을 만든다고 데이터가 복사되지 않으며, 원본 컬렉션을 변경하지 않습니다.
List<String> names = List.of("Kim", "Lee", "Park", "Jo");
// Stream은 원본을 바꾸지 않음 — 새 리스트를 만들어 반환
List<String> longNames = names.stream()
.filter(n -> n.length() >= 3)
.toList();
// names는 그대로 [Kim, Lee, Park, Jo]intermediate / terminal 연산 구분
filter, map, sorted, distinct 같은 intermediate 연산은 새 Stream을 반환합니다. 실제로 실행되지는 않고, terminal 연산이 올 때까지 지연(lazy)됩니다. toList, count, forEach, reduce 같은 terminal 연산이 실행을 끝맺고 결과를 만듭니다.
// intermediate: filter, map (아직 실행 안 됨)
// terminal: toList (여기서 실제 실행)
List<String> result = names.stream()
.filter(n -> n.startsWith("K")) // intermediate
.map(String::toUpperCase) // intermediate
.toList(); // terminal — 여기서 실행선언적 데이터 처리
Stream의 장점은 "무엇을 할 것인가"를 선언적으로 표현할 수 있다는 것입니다. 필터링-변환-집계 흐름이 자연스럽게 읽힙니다. 다만 side-effect가 많거나 여러 조건이 복잡하게 얽힌 경우에는 전통적인 반복문이 더 명확할 수 있습니다.
// Stream이 어울리는 경우 — 선언적 변환/집계
Map<String, Long> countByDept = employees.stream()
.collect(Collectors.groupingBy(Employee::getDept, Collectors.counting()));
// 전통 loop가 더 명확한 경우 — 중간 상태 변경이 많을 때
List<Order> result = new ArrayList<>();
for (Order o : orders) {
o.applyDiscount();
if (o.isValid()) result.add(o);
}Stream과 loop를 고르는 실제 기준
값을 필터링하고 변환해 새 결과를 만드는 흐름이면 Stream이 잘 맞습니다. 반대로 기존 객체를 여러 단계로 수정하거나, 중간 상태를 추적해야 하면 전통적인 loop가 더 읽기 쉽습니다.
// ✅ 새 결과를 만드는 변환 파이프라인
List<String> emails = users.stream()
.filter(User::isActive)
.map(User::getEmail)
.toList();
// ✅ 기존 객체를 수정하고 분기 처리하는 흐름
for (Order order : orders) {
order.recalculateTotal();
if (order.isExpired()) {
order.markCancelled();
}
}체크포인트
| 상황 | 적합한 선택 |
|---|---|
| 필터링 후 변환 | stream().filter(...).map(...) |
| 요소 개수 세기 | .count() |
| 첫 번째 일치 요소 | .filter(...).findFirst() |
| 리스트로 수집 | .toList() 또는 .collect(toList()) |
| 중간 상태 변경이 많은 루프 | 전통적인 for 루프 |
주의할 점
Stream은 모든 loop를 대체하는 만능 문법이 아닙니다. side-effect가 있는 작업을 Stream에 억지로 넣으면 오히려 읽기 어려워집니다.
// ❌ side-effect를 Stream에 넣는 경우 — forEach 안에서 외부 상태 변경
List<String> errors = new ArrayList<>();
items.stream()
.filter(item -> !item.isValid())
.forEach(item -> errors.add(item.getId())); // 외부 리스트 변경
// ✅ 수집 자체를 Stream으로 — side-effect 없이
List<String> errors = items.stream()
.filter(item -> !item.isValid())
.map(Item::getId)
.toList();terminal 연산이 없으면 Stream 파이프라인은 실행되지 않습니다.
// ❌ 아무 일도 일어나지 않음 — terminal 연산 없음
names.stream()
.filter(name -> name.startsWith("K"))
.map(String::toUpperCase);
// ✅ terminal 연산으로 실행
List<String> result = names.stream()
.filter(name -> name.startsWith("K"))
.map(String::toUpperCase)
.toList();참고 링크
2 sources