1. 지연 평가란?
지연 처리, 또는 “지연 평가”(lazy evaluation)는 결과가 정말 필요해질 때까지 데이터에 대한 연산을 미루는 원칙입니다. Stream API의 맥락에서 이것은 다음을 의미합니다: 컬렉션에 대해 변환 체인을 작성해도 Java는 즉시 실행하지 않고 — 종단 연산이 호출될 때까지 기다립니다. 그리고 그때 비로소 전체 체인이 계산됩니다.
왜 필요할까요? 첫째, 자원을 절약합니다: 결국 필요하지 않은 요소는 아예 처리되지 않습니다. 둘째, 성능이 향상됩니다 — 여러 중간 컬렉션을 만들지 않고도 긴 체인을 구성할 수 있습니다. 마지막으로 “단락 평가”가 가능합니다: 첫 번째로 적합한 요소를 찾는 즉시 이후 처리는 중단됩니다.
비유: 게으른 웨이터를 떠올려 보세요. 당신이 말합니다: “메뉴 가져오고, 그다음 커피, 그리고 케이크.” 그는 고개를 끄덕이지만 아무것도 하지 않습니다… 당신이 “이제 진짜 가져와”라고 덧붙이기 전까지는요. 그제야 주문을 수행하러 가는데 — 케이크가 없으면 커피만 가져올 수도 있습니다. 스트림의 지연 평가도 대략 이렇게 동작합니다.
2. 중간 연산과 종단 연산
중간 연산(intermediate):
- filter
- map
- sorted
- distinct
- peek (디버깅용)
- 기타
중간 연산은 새로운 Stream을 반환하지만 계산을 시작하지 않습니다. 처리의 “실행 계획”만 세웁니다.
종단 연산(terminal):
- collect
- forEach
- reduce
- count
- findFirst, findAny
- anyMatch, allMatch, noneMatch
- 기타
오직 종단 연산만 전체 체인의 실행을 시작합니다.
예시: 종단 연산이 없으면 아무 일도 일어나지 않음
List<String> names = List.of("Alisa", "Bob", "Vasya");
names.stream()
.filter(name -> {
System.out.println("필터링 중: " + name);
return name.startsWith("A");
});
// 아무 메시지도 출력되지 않습니다! 위 코드는 체인만 “설계”할 뿐입니다.
이제 종단 연산을 추가해 봅시다:
names.stream()
.filter(name -> {
System.out.println("필터링 중: " + name);
return name.startsWith("A");
})
.forEach(System.out::println);
// 이제 콘솔에 출력이 나타납니다!
결과:
필터링 중: Alisa
필터링 중: Bob
필터링 중: Vasya
Alisa
3. 지연 평가의 장점
자원 절약
지연 평가는 필요하지 않은 요소에 시간과 메모리를 낭비하지 않게 해 줍니다. 예를 들어 첫 번째로 적합한 객체만 찾으면, 그 지점에서 처리가 멈춥니다.
List<String> names = List.of("Alisa", "Bob", "Vasya", "Anna");
String firstA = names.stream()
.filter(name -> {
System.out.println("검사 중: " + name);
return name.startsWith("A");
})
.findFirst()
.orElse("찾지 못함");
System.out.println("결과: " + firstA);
출력:
검사 중: Alisa
결과: Alisa
주의: 나머지 요소는 아예 검사되지 않습니다!
중간 컬렉션 없이 긴 체인
filter, map, sorted 등 다수의 연산을 중간 컬렉션을 만들지 않고 결합할 수 있습니다.
List<String> names = List.of("Alisa", "Bob", "Vasya", "Anna");
List<String> result = names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.sorted()
.toList(); // Java 16+에서는 .toList(); 이전에는 — .collect(Collectors.toList())
단락 평가
“적합한 요소가 하나라도 있는지”만 알면 되는 경우, 나머지는 검사하지 않습니다:
boolean hasLongName = names.stream()
.anyMatch(name -> {
System.out.println("검사 중: " + name);
return name.length() > 10;
});
// 첫 번째 요소가 길면 — 나머지는 검사하지 않습니다!
4. 예시: 지연 평가는 이렇게 동작합니다
예시 1: 종단 연산이 없으면 아무 일도 일어나지 않음
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
numbers.stream()
.filter(n -> {
System.out.println("필터링 중: " + n);
return n % 2 == 0;
});
// 출력 없음!
예시 2: 종단 연산이 있는 체인
numbers.stream()
.filter(n -> {
System.out.println("필터링 중: " + n);
return n % 2 == 0;
})
.map(n -> {
System.out.println("곱하는 중: " + n);
return n * 10;
})
.forEach(System.out::println);
출력:
필터링 중: 1
필터링 중: 2
곱하는 중: 2
20
필터링 중: 3
필터링 중: 4
곱하는 중: 4
40
필터링 중: 5
중요한 메모: 연산은 요소 단위로 수행됩니다. 즉, 각 요소마다 차례대로 filter → map → forEach가 실행됩니다. “먼저 전부 필터링하고, 그다음 전부 변환한다”는 식의 두 번의 별도 순회가 아닙니다.
예시 3: 디버깅을 위한 peek 사용
numbers.stream()
.filter(n -> n % 2 == 0)
.peek(n -> System.out.println("필터 통과: " + n))
.map(n -> n * 10)
.peek(n -> System.out.println("map 이후: " + n))
.forEach(System.out::println);
5. 유용한 뉘앙스
부작용이 있는 스트림 사용을 피하세요
지연성 때문에 즉시 실행될 것이라 기대하면 문제가 생길 수 있습니다. map, filter 또는 peek 내부에서의 부작용(파일 쓰기, 외부 상태 변경)은 모든 요소에 대해, 기대한 순서로, 혹은 종단 연산 없이 아예 실행되지 않을 수 있습니다.
가능한 한 일찍 필터링하세요
불필요한 요소를 일찍 걸러 내고 이후 작업량을 줄이기 위해 filter를 체인의 앞쪽에 배치하세요.
첫 번째 결과만 필요합니까? 해당 종단 연산을 사용하세요
첫 번째로 적합한 요소만 필요하다면 findFirst 또는 findAny를 호출하세요. 그러면 스트림은 결과를 찾는 즉시 중단할 수 있습니다.
스트림은 원본 컬렉션을 변경하기 위한 도구가 아닙니다
스트림은 원본 컬렉션에 요소를 추가/삭제하는 데 적합하지 않습니다. 컬렉션 구조를 수정하려면 다른 메커니즘을 사용하세요.
지연 스트림 동작 시각화
List<String> words = List.of("cat", "dog", "elephant", "fox", "giraffe");
words.stream()
.filter(w -> w.length() > 3)
.map(String::toUpperCase)
.forEach(System.out::println);
동작 흐름:
| 단계 | cat | dog | elephant | fox | giraffe |
|---|---|---|---|---|---|
|
✗ | ✗ | ✓ | ✗ | ✓ |
|
— | — | ELEPHANT | — | GIRAFFE |
|
— | — | 출력 | — | 출력 |
표: eager와 lazy 접근 비교
| 접근 방식 | 처리는 언제 실행되나요? | 메모리 사용량 | 성능 |
|---|---|---|---|
| Eager (즉시) | 호출 즉시 | 많을 수 있음 | 때때로 느림 |
| Lazy (지연) | 필요할 때만 | 최소 | 보통 더 빠름 |
즉시 접근 — 예를 들면 컬렉션을 여러 번 순회하면서 중간 리스트를 직접 만드는 경우입니다.
지연 접근 — 스트림입니다: 결과가 필요해지기 전까지 아무것도 하지 않습니다.
6. 지연 스트림 사용 시 흔한 실수
실수 №1: 즉시 결과를 기대함. 초보자는 filter나 map 호출이 곧바로 실행된다고 생각합니다. 하지만 종단 연산(예: collect, forEach)이 없으면 아무 일도 일어나지 않습니다 — 그래서 “디버깅이 안 된다”, “아무것도 출력되지 않는다”는 상황이 생깁니다.
실수 №2: 중간 연산에서의 부작용. 파일 쓰기, 외부 변수 변경을 map/filter/peek 내부에서 수행하는 것은 나쁜 관행입니다. 지연성과 최적화 때문에 이런 동작은 완전하게, 기대한 순서대로 실행되지 않거나, 종단 연산 없이 아예 실행되지 않을 수 있습니다.
실수 №3: 종단 연산 호출을 잊음. 스트림 체인을 작성했지만 collect, forEach 등으로 마무리하지 않은 경우. 결과 — “아무 일도 일어나지 않음”.
실수 №4: 모든 요소가 처리될 것이라 기대함. findFirst나 anyMatch 같은 연산은 첫 결과에서 파이프라인을 중단합니다. 나머지 요소는 처리되지 않습니다 — 그래서 “왜 내 println이 모두에 대해 실행되지 않았지?”라는 놀라움이 생깁니다.
실수 №5: 스트림으로 원본 컬렉션을 변경하려 함. 스트림은 원본 컬렉션을 수정(요소 추가/삭제)하도록 설계되지 않았습니다. 컬렉션 구조를 변경하려면 컬렉션의 전용 메서드나 이터레이터를 사용하세요.
GO TO FULL VERSION