CodeGym /행동 /JAVA 25 SELF /Stream API의 지연 평가(lazy evaluation)

Stream API의 지연 평가(lazy evaluation)

JAVA 25 SELF
레벨 33 , 레슨 1
사용 가능

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

중요한 메모: 연산은 요소 단위로 수행됩니다. 즉, 각 요소마다 차례대로 filtermapforEach가 실행됩니다. “먼저 전부 필터링하고, 그다음 전부 변환한다”는 식의 두 번의 별도 순회가 아닙니다.

예시 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
filter
map
ELEPHANT GIRAFFE
forEach
출력 출력

표: eager와 lazy 접근 비교

접근 방식 처리는 언제 실행되나요? 메모리 사용량 성능
Eager (즉시) 호출 즉시 많을 수 있음 때때로 느림
Lazy (지연) 필요할 때만 최소 보통 더 빠름

즉시 접근 — 예를 들면 컬렉션을 여러 번 순회하면서 중간 리스트를 직접 만드는 경우입니다.
지연 접근 — 스트림입니다: 결과가 필요해지기 전까지 아무것도 하지 않습니다.

6. 지연 스트림 사용 시 흔한 실수

실수 №1: 즉시 결과를 기대함. 초보자는 filtermap 호출이 곧바로 실행된다고 생각합니다. 하지만 종단 연산(예: collect, forEach)이 없으면 아무 일도 일어나지 않습니다 — 그래서 “디버깅이 안 된다”, “아무것도 출력되지 않는다”는 상황이 생깁니다.

실수 №2: 중간 연산에서의 부작용. 파일 쓰기, 외부 변수 변경을 map/filter/peek 내부에서 수행하는 것은 나쁜 관행입니다. 지연성과 최적화 때문에 이런 동작은 완전하게, 기대한 순서대로 실행되지 않거나, 종단 연산 없이 아예 실행되지 않을 수 있습니다.

실수 №3: 종단 연산 호출을 잊음. 스트림 체인을 작성했지만 collect, forEach 등으로 마무리하지 않은 경우. 결과 — “아무 일도 일어나지 않음”.

실수 №4: 모든 요소가 처리될 것이라 기대함. findFirstanyMatch 같은 연산은 첫 결과에서 파이프라인을 중단합니다. 나머지 요소는 처리되지 않습니다 — 그래서 “왜 내 println이 모두에 대해 실행되지 않았지?”라는 놀라움이 생깁니다.

실수 №5: 스트림으로 원본 컬렉션을 변경하려 함. 스트림은 원본 컬렉션을 수정(요소 추가/삭제)하도록 설계되지 않았습니다. 컬렉션 구조를 변경하려면 컬렉션의 전용 메서드나 이터레이터를 사용하세요.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION