본문 바로가기

즉시 평가와 지연 평가

메서드 인수가 호출 전에 모두 계산된다는 규칙을 실행 결과로 확인하고, 값 대신 Supplier를 넘겨 필요할 때만 계산하는 지연 평가와 그 비용·주의점을 익힙니다.

앞 글에서 Optional의 값을 꺼내는 메서드로 orElse와 orElseGet을 보았습니다. 둘 다 비어 있을 때 쓸 기본값을 정하는데, orElse는 값을 받고 orElseGet은 값을 만드는 함수를 받습니다. 결과가 같다면 왜 굳이 두 가지가 있을까요? 답은 Java가 메서드 인수를 언제 계산하느냐에 있습니다.

이 글에서는 Optional을 잠시 내려놓고, 평범한 메서드로 이 규칙을 먼저 확인합니다. 그런 다음 Supplier를 이용해 계산을 필요한 순간까지 미루는 방법과, 미룰 때 생기는 비용과 주의점을 살펴봅니다. 여기서 익힌 규칙을 다음 글에서 orElse와 orElseGet에 그대로 적용합니다.

쓰지도 않을 대체값을 먼저 만드는 코드

주 데이터가 있으면 그것을 쓰고, 없으면 대체값을 쓰는 choose 메서드를 생각해 봅시다. 대체값을 만드는 createFallback은 호출될 때마다 횟수를 세고 메시지를 출력합니다. 실제 프로그램이라면 데이터베이스 조회나 무거운 객체 생성처럼 비용이 드는 작업이 들어갈 자리입니다.

lab/EagerFallbackEvaluationBug.java
import java.util.concurrent.atomic.AtomicInteger;

public final class EagerFallbackEvaluationBug {
    public static void main(String[] args) {
        AtomicInteger calls = new AtomicInteger();
        String result = choose(true, "primary", createFallback(calls));
        System.out.println("result=" + result + ", wrong-fallback-calls=" + calls.get());
    }

    private static String choose(boolean primaryAvailable, String primary, String fallback) {
        return primaryAvailable ? primary : fallback;
    }

    private static String createFallback(AtomicInteger calls) {
        calls.incrementAndGet();
        System.out.println("fallback-created");
        return "fallback";
    }
}
실행 결과
fallback-created
result=primary, wrong-fallback-calls=1

choose에 true를 넘겼으니 결과는 primary입니다. 그런데 대체값을 쓰지 않았는데도 fallback-created가 출력됐고, 호출 횟수도 1입니다. 그 메시지가 결과보다 먼저 나왔다는 점도 눈여겨보세요. createFallback은 choose가 실행되기 전에 이미 호출된 것입니다.

이유는 Java가 메서드를 호출하기 전에 인수 식을 모두 계산하기 때문입니다. choose(true, "primary", createFallback(calls))를 만나면 Java는 먼저 세 인수 식의 값을 구합니다. 세 번째 인수 식이 메서드 호출이므로 여기서 createFallback이 실행되고, 그 결과인 문자열 "fallback"이 준비된 다음에야 choose가 호출됩니다. choose 안에서 그 값을 쓰든 버리든, 이미 끝난 계산을 되돌릴 수는 없습니다. 이렇게 값이 실제로 필요한지와 상관없이 미리 계산하는 방식을 즉시 평가(eager evaluation)라고 합니다.

대체값이 "fallback" 같은 문자열 리터럴이라면 미리 계산해도 손해가 거의 없습니다. 하지만 데이터베이스를 조회하거나 큰 객체를 만들거나 로그와 통계를 기록하는 작업이라면 이야기가 다릅니다. 쓰지도 않을 결과 때문에 시간이 들고, 일어나지 않은 일이 기록에 남습니다. 위 예제의 wrong-fallback-calls=1이 바로 그런 쓸데없는 실행의 흔적입니다.

인수는 왼쪽부터 차례로, 호출 전에 계산된다

Java 언어 명세(JLS)는 이 순서를 정확히 정해 두었습니다. JLS 15.7.4에 따르면 각 인수 식은 그 오른쪽에 있는 인수 식의 어느 부분보다도 먼저 완전히 계산됩니다. 또 어떤 인수 식이 예외로 끝나면, 그 오른쪽의 인수 식은 전혀 계산되지 않은 것처럼 보여야 합니다. 메서드 본문은 모든 인수가 준비된 뒤에 실행됩니다. 아래 코드로 이 순서를 확인해 보겠습니다.

src/ArgumentEvaluationOrder.java
public final class ArgumentEvaluationOrder {
    public static void main(String[] args) {
        String result = combine(mark("first"), mark("second"));
        System.out.println("result=" + result);

        boolean shortCircuit = false && expensiveBoolean();
        System.out.println("short-circuit=" + shortCircuit);
    }

    private static String mark(String value) {
        System.out.println("evaluated=" + value);
        return value;
    }

    private static String combine(String left, String right) {
        return left + ":" + right;
    }

    private static boolean expensiveBoolean() {
        throw new AssertionError("must not run");
    }
}
실행 결과
evaluated=first
evaluated=second
result=first:second
short-circuit=false

mark("first")와 mark("second")가 왼쪽부터 차례로 실행되어 첫 두 줄이 나오고, 두 인수가 모두 준비된 뒤에야 combine이 실행되어 셋째 줄의 결과가 만들어집니다. 만약 첫 번째 mark가 예외를 던졌다면, 두 번째 mark도 combine도 실행되지 않았을 것입니다.

넷째 줄은 다른 규칙을 보여 줍니다. false && expensiveBoolean()에서 &&는 왼쪽이 false이면 오른쪽을 계산하지 않습니다. 오른쪽이 무엇이든 결과는 이미 false로 정해졌기 때문입니다. expensiveBoolean은 호출되면 AssertionError를 던지도록 만들어 두었는데 프로그램이 정상적으로 끝났으니, 정말로 호출되지 않은 것입니다. 이처럼 결과를 정하는 데 필요한 부분만 계산하는 것을 단락 평가(short-circuit evaluation)라고 합니다. ||와 조건 연산자 ?:도 필요한 쪽만 계산합니다.

정리하면 &&, ||, ?: 같은 연산자는 필요한 피연산자만 계산하지만, 메서드의 인수는 메서드가 그 값을 쓰든 말든 모두 계산됩니다. 앞의 choose도 안에서는 ?:로 필요한 쪽만 골랐지만, 그 분기에 이르기 전에 인수 계산이 이미 끝나 있었던 것입니다.

이 규칙에는 실용적인 교훈도 따라옵니다. 인수 식이 출력이나 카운터 증가 같은 부수 효과를 가지면, 프로그램의 동작이 인수의 계산 순서에 묶여 버립니다. 그러면 메서드의 매개변수 순서를 정리하는 단순한 리팩터링만으로도 동작이 바뀔 수 있습니다. 그래서 인수 식은 되도록 부수 효과 없이 값만 계산하는 식으로 씁니다.

Supplier로 계산을 미루기

그렇다면 대체값을 정말 필요할 때만 만들려면 어떻게 해야 할까요? 값을 넘기는 대신 '값을 만드는 방법'을 넘기면 됩니다. 표준 함수형 인터페이스와 함수 조합에서 본 Supplier<T>는 인수 없이 T 값을 하나 돌려주는 get() 메서드를 가진 함수형 인터페이스입니다. choose가 String 대신 Supplier<String>을 받게 바꾸고, 대체값이 필요한 분기에서만 get()을 호출하게 합니다.

src/LazyFallbackSupplier.java
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;

public final class LazyFallbackSupplier {
    public static void main(String[] args) {
        AtomicInteger calls = new AtomicInteger();
        Supplier<String> fallback = () -> createFallback(calls);

        System.out.println(choose(true, "primary", fallback));
        System.out.println("calls-after-primary=" + calls.get());
        System.out.println(choose(false, "primary", fallback));
        System.out.println("calls-after-miss=" + calls.get());
    }

    private static String choose(boolean available, String primary, Supplier<String> fallback) {
        return available ? primary : fallback.get();
    }

    private static String createFallback(AtomicInteger calls) {
        calls.incrementAndGet();
        return "fallback";
    }
}
실행 결과
primary
calls-after-primary=0
fallback
calls-after-miss=1

이번에는 choose(true, ...)가 primary를 돌려주는 동안 호출 횟수가 0에 머뭅니다. true 분기에서는 fallback.get()을 부르지 않으므로 createFallback도 실행되지 않기 때문입니다. choose(false, ...)에서야 get()이 호출되어 fallback을 돌려주고, 호출 횟수가 1로 늘어납니다.

여기서 '인수는 호출 전에 계산된다'는 규칙은 그대로라는 점에 주의하세요. 달라진 것은 인수를 계산한 결과가 무엇이냐입니다. 람다식 () -> createFallback(calls)를 계산하면 Supplier 객체를 얻을 뿐, 람다 본문은 실행되지 않습니다. 본문은 누군가 그 객체의 get()을 호출할 때 비로소 실행됩니다. 이렇게 값이 실제로 필요해질 때까지 계산을 미루는 방식을 지연 평가(lazy evaluation)라고 합니다.

값 인수와 공급자의 대체 계산 횟수

값 인수 버전은 choose 진입 전에 대체값을 계산하고 Supplier 버전은 true에서 생략 후 false에서만 호출합니다. 실제 calls 카운터의 1과 0→1 전환이 핵심입니다.

값 인수와 공급자의 대체 계산 횟수

값 인수와 공급자의 대체 계산 횟수

값 계산과 공급자 호출

값 인수와 공급자의 대체 계산 횟수

값 인수는 choose 진입 전에 계산됩니다. Supplier 본문은 선택한 분기에서 get을 호출할 때만 실행됩니다.

같은 대체 함수도 호출 주체와 시점이 다르다원EagerFallbackEvaluationBug와LazyFallbackSupplier는별도실행/별도counter다. 왼쪽또는윗흐름은값인수가choose진입전에계산돼true여도1회이다. 오른쪽또는아랫흐름은같은Supplier로true다음false를호출하여get의누적0→1을보인다. 6노드4관계이며mobile동일하고Supplier가자동으로메모이제이션하는그림은아니다.값 인수 버전true · primary대체 값 먼저 계산choose 진입 전 1회choose true 반환primary · 이미 1회Supplier 버전true → false 연속true는 get 생략primary · 누적 0회false에서 get 호출fallback · 누적 1회
같은 대체 함수도 호출 주체와 시점이 다르다원EagerFallbackEvaluationBug와LazyFallbackSupplier는별도실행/별도counter다. 왼쪽또는윗흐름은값인수가choose진입전에계산돼true여도1회이다. 오른쪽또는아랫흐름은같은Supplier로true다음false를호출하여get의누적0→1을보인다. 6노드4관계이며mobile동일하고Supplier가자동으로메모이제이션하는그림은아니다.값 인수 버전true · primary대체 값 먼저 계산choose 진입 전 1회choose true 반환primary · 이미 1회Supplier 버전true → false 연속true는 get 생략primary · 누적 0회false에서 get 호출fallback · 누적 1회

값 인수·주 경로

대체 계산 시점
choose 진입 전 계산
반환과 호출 횟수
primary 반환; 대체 계산 1회

공급자·첫 주 경로

대체 계산 시점
get을 호출하지 않음
반환과 호출 횟수
primary 반환; 누적 0회

공급자·다음 미적중

대체 계산 시점
choose 안에서 get 호출
반환과 호출 횟수
fallback 반환; 누적 1회

첫 행은 버그 예제, 다음 두 행은 LazyFallbackSupplier의 연속 호출입니다. 수치는 두 원문의 제어 흐름에서 도출했으며 실행 계측값이 아닙니다.

이 예의 조건

  • 값 인수 EagerFallbackEvaluationBug와 Supplier LazyFallbackSupplier는 별도 main·별도 counter입니다. 공통 관찰 단계마다 비교합니다.
  • Supplier main만 true 다음 false를 같은 공급자로 호출하며 자동 메모이제이션하지 않습니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2원 EagerFallbackEvaluationBug와 LazyFallbackSupplier는 별도 실행/별도 counter다. 왼쪽 또는 위 흐름은 값 인수가 choose 진입 전에 계산돼 true여도 1회이다. 오른쪽 또는 아래 흐름은 같은 Supplier로 true 다음 false를 호출하여 get의 누적 0→1을 보인다. 6개 노드와 4개 관계이며 mobile도 동일하고 Supplier가 자동으로 메모이제이션한다는 뜻이 아니다.
원 도표의 설명과 주석
구분내용
설명원 EagerFallbackEvaluationBug와 LazyFallbackSupplier는 별도 실행/별도 counter다. 왼쪽 또는 위 흐름은 값 인수가 choose 진입 전에 계산돼 true여도 1회이다. 오른쪽 또는 아래 흐름은 같은 Supplier로 true 다음 false를 호출하여 get의 누적 0→1을 보인다. 6개 노드와 4개 관계이며 mobile도 동일하고 Supplier가 자동으로 메모이제이션한다는 뜻이 아니다.
전체 읽기 정보값 인수와 공급자의 대체 계산 횟수 값 인수와 공급자의 대체 계산 횟수 값 인수와 공급자의 대체 계산 횟수 값 계산과 공급자 호출 값 인수와 공급자의 대체 계산 횟수 값 인수는 choose 진입 전에 계산됩니다. Supplier 본문은 선택한 분기에서 get을 호출할 때만 실행됩니다. 값 인수·주 경로 대체 계산 시점 choose 진입 전 계산 반환과 호출 횟수 primary 반환; 대체 계산 1회 공급자·첫 주 경로 대체 계산 시점 get을 호출하지 않음 반환과 호출 횟수 primary 반환; 누적 0회 공급자·다음 미적중 대체 계산 시점 choose 안에서 get 호출 반환과 호출 횟수 fallback 반환; 누적 1회 첫 행은 버그 예제, 다음 두 행은 LazyFallbackSupplier의 연속 호출입니다. 수치는 두 원문의 제어 흐름에서 도출했으며 실행 계측값이 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
선택 전에 인수 준비

값 버전은 대체 문자열을 계산하고 Supplier 버전은 실행할 참조만 만듭니다.

  • 값 버전 준비: createFallback 실행 → counter=1
  • Supplier 준비: 람다 참조 생성 → counter=0
  • 객체 소유: 두 counter는 서로 다른 객체
  • 일반 값 인수는 choose 진입 전에 실행되지만 람다 본문은 아직 실행되지 않습니다.
두 버전의 true 선택

각 choose(true)가 primary를 반환하며 Supplier는 get을 호출하지 않습니다.

  • 값 버전 결과: primary · 이미 counter=1
  • Supplier 결과: primary · counter=0
  • Supplier get: true에서는 생략
  • primary를 선택해도 값 버전의 선행 계산은 되돌리지 않습니다.
각 main의 후속 관찰

값 main은 1회에서 끝나고 Supplier main만 이어 false를 호출해 fallback을 만듭니다.

  • 값 main 종료: counter=1 유지
  • Supplier false: fallback.get() → fallback
  • Supplier 누적: counter 0→1
  • false 후속 호출은 Supplier 프로그램의 흐름이며 값 main에 추가 호출을 붙이지 않습니다.

값 인수와 공급자의 대체 계산 횟수

값 인수 버전은 choose 진입 전에 대체값을 계산하고 Supplier 버전은 true에서 생략 후 false에서만 호출합니다. 실제 calls 카운터의 1과 0→1 전환이 핵심입니다.

값 인수와 공급자의 대체 계산 횟수

값 인수와 공급자의 대체 계산 횟수

값 계산과 공급자 호출

값 인수와 공급자의 대체 계산 횟수

값 인수는 choose 진입 전에 계산됩니다. Supplier 본문은 선택한 분기에서 get을 호출할 때만 실행됩니다.

같은 대체 함수도 호출 주체와 시점이 다르다원EagerFallbackEvaluationBug와LazyFallbackSupplier는별도실행/별도counter다. 왼쪽또는윗흐름은값인수가choose진입전에계산돼true여도1회이다. 오른쪽또는아랫흐름은같은Supplier로true다음false를호출하여get의누적0→1을보인다. 6노드4관계이며mobile동일하고Supplier가자동으로메모이제이션하는그림은아니다.값 인수 버전true · primary대체 값 먼저 계산choose 진입 전 1회choose true 반환primary · 이미 1회Supplier 버전true → false 연속true는 get 생략primary · 누적 0회false에서 get 호출fallback · 누적 1회
같은 대체 함수도 호출 주체와 시점이 다르다원EagerFallbackEvaluationBug와LazyFallbackSupplier는별도실행/별도counter다. 왼쪽또는윗흐름은값인수가choose진입전에계산돼true여도1회이다. 오른쪽또는아랫흐름은같은Supplier로true다음false를호출하여get의누적0→1을보인다. 6노드4관계이며mobile동일하고Supplier가자동으로메모이제이션하는그림은아니다.값 인수 버전true · primary대체 값 먼저 계산choose 진입 전 1회choose true 반환primary · 이미 1회Supplier 버전true → false 연속true는 get 생략primary · 누적 0회false에서 get 호출fallback · 누적 1회

값 인수·주 경로

대체 계산 시점
choose 진입 전 계산
반환과 호출 횟수
primary 반환; 대체 계산 1회

공급자·첫 주 경로

대체 계산 시점
get을 호출하지 않음
반환과 호출 횟수
primary 반환; 누적 0회

공급자·다음 미적중

대체 계산 시점
choose 안에서 get 호출
반환과 호출 횟수
fallback 반환; 누적 1회

첫 행은 버그 예제, 다음 두 행은 LazyFallbackSupplier의 연속 호출입니다. 수치는 두 원문의 제어 흐름에서 도출했으며 실행 계측값이 아닙니다.

이 예의 조건

  • 값 인수 EagerFallbackEvaluationBug와 Supplier LazyFallbackSupplier는 별도 main·별도 counter입니다. 공통 관찰 단계마다 비교합니다.
  • Supplier main만 true 다음 false를 같은 공급자로 호출하며 자동 메모이제이션하지 않습니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2원 EagerFallbackEvaluationBug와 LazyFallbackSupplier는 별도 실행/별도 counter다. 왼쪽 또는 위 흐름은 값 인수가 choose 진입 전에 계산돼 true여도 1회이다. 오른쪽 또는 아래 흐름은 같은 Supplier로 true 다음 false를 호출하여 get의 누적 0→1을 보인다. 6개 노드와 4개 관계이며 mobile도 동일하고 Supplier가 자동으로 메모이제이션한다는 뜻이 아니다.
원 도표의 설명과 주석
구분내용
설명원 EagerFallbackEvaluationBug와 LazyFallbackSupplier는 별도 실행/별도 counter다. 왼쪽 또는 위 흐름은 값 인수가 choose 진입 전에 계산돼 true여도 1회이다. 오른쪽 또는 아래 흐름은 같은 Supplier로 true 다음 false를 호출하여 get의 누적 0→1을 보인다. 6개 노드와 4개 관계이며 mobile도 동일하고 Supplier가 자동으로 메모이제이션한다는 뜻이 아니다.
전체 읽기 정보값 인수와 공급자의 대체 계산 횟수 값 인수와 공급자의 대체 계산 횟수 값 인수와 공급자의 대체 계산 횟수 값 계산과 공급자 호출 값 인수와 공급자의 대체 계산 횟수 값 인수는 choose 진입 전에 계산됩니다. Supplier 본문은 선택한 분기에서 get을 호출할 때만 실행됩니다. 값 인수·주 경로 대체 계산 시점 choose 진입 전 계산 반환과 호출 횟수 primary 반환; 대체 계산 1회 공급자·첫 주 경로 대체 계산 시점 get을 호출하지 않음 반환과 호출 횟수 primary 반환; 누적 0회 공급자·다음 미적중 대체 계산 시점 choose 안에서 get 호출 반환과 호출 횟수 fallback 반환; 누적 1회 첫 행은 버그 예제, 다음 두 행은 LazyFallbackSupplier의 연속 호출입니다. 수치는 두 원문의 제어 흐름에서 도출했으며 실행 계측값이 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
선택 전에 인수 준비

값 버전은 대체 문자열을 계산하고 Supplier 버전은 실행할 참조만 만듭니다.

  • 값 버전 준비: createFallback 실행 → counter=1
  • Supplier 준비: 람다 참조 생성 → counter=0
  • 객체 소유: 두 counter는 서로 다른 객체
  • 일반 값 인수는 choose 진입 전에 실행되지만 람다 본문은 아직 실행되지 않습니다.
두 버전의 true 선택

각 choose(true)가 primary를 반환하며 Supplier는 get을 호출하지 않습니다.

  • 값 버전 결과: primary · 이미 counter=1
  • Supplier 결과: primary · counter=0
  • Supplier get: true에서는 생략
  • primary를 선택해도 값 버전의 선행 계산은 되돌리지 않습니다.
각 main의 후속 관찰

값 main은 1회에서 끝나고 Supplier main만 이어 false를 호출해 fallback을 만듭니다.

  • 값 main 종료: counter=1 유지
  • Supplier false: fallback.get() → fallback
  • Supplier 누적: counter 0→1
  • false 후속 호출은 Supplier 프로그램의 흐름이며 값 main에 추가 호출을 붙이지 않습니다.

그림은 앞의 두 프로그램을 나란히 놓고 비교합니다. 값을 넘기는 버전은 choose에 들어가기 전에 대체값을 계산하므로 true를 넘겨도 1회가 기록되고, Supplier 버전은 true에서 get()을 건너뛰었다가 false에서만 호출해 0회에서 1회가 됩니다. 두 프로그램은 각자 자기 calls 객체를 쓰므로, 두 횟수를 합쳐서 읽지 않습니다.

Supplier를 쓴다고 해서 저절로 계산이 미뤄지는 것은 아닙니다. 값을 받는 원래 choose(boolean, String, String)에 choose(true, "primary", fallback.get())처럼 get()의 결과를 넘기면, fallback.get() 자체가 인수 식이므로 호출 전에 실행됩니다. 중요한 것은 get()을 누가, 언제 부르느냐입니다. 지연 평가가 되려면 Supplier 객체 자체를 넘기고, 받는 쪽이 값이 필요한 순간에 get()을 불러야 합니다.

또 Supplier는 계산 결과를 기억하지 않습니다. 이 예제의 람다는 get()이 호출될 때마다 createFallback을 다시 실행하므로, choose(false, ...)를 두 번 호출하면 횟수도 2가 됩니다. Supplier의 Javadoc도 호출할 때마다 새 결과나 다른 결과를 돌려줘야 한다는 요구는 없다고만 적고 있어서, 결과를 저장해 두었다가 돌려줄지는 각 구현이 정합니다. 한 번 계산한 값을 재사용해야 한다면 그렇게 동작하는 래퍼를 따로 만들어야 하는데, 표준 함수형 인터페이스와 함수 조합에서 그런 메모이제이션 래퍼를 만들어 보았습니다.

람다가 캡처한 변수와 상태

앞 예제의 람다 () -> createFallback(calls)는 바깥 지역 변수 calls를 본문에서 씁니다. 익명 클래스와 람다의 차이에서 본 것처럼, 람다가 바깥의 지역 변수를 가져다 쓰는 것을 캡처(capture)라고 하고, 캡처한 지역 변수는 사실상 final이어야 합니다. 예제가 int 대신 AtomicInteger를 쓴 것도 그래서입니다. 람다 안에서 calls에 새 값을 대입할 수는 없지만, calls가 가리키는 객체 안의 값을 incrementAndGet()으로 바꾸는 것은 허용됩니다. AtomicInteger는 여러 스레드가 동시에 늘려도 값을 잃지 않는다는 장점도 있습니다.

Supplier 객체는 캡처한 값을 안에 들고 있으므로, Supplier가 살아 있는 동안에는 캡처한 객체도 메모리에서 사라지지 않습니다. 대체값을 만드는 데 키 하나만 필요한데 큰 객체 전체를 캡처하면, Supplier를 오래 보관하는 동안 그 객체도 함께 붙잡혀 있게 됩니다. 그래서 필요한 값만 지역 변수로 꺼내 그 변수를 캡처하는 편이 좋습니다.

캡처한 객체의 상태를 람다 안에서 바꾸면 신경 쓸 일이 늘어납니다. 앞 예제는 get()이 호출될 때마다 calls를 늘리므로, 프로그램의 결과가 get()을 몇 번 불렀는지에 따라 달라집니다. 여러 스레드가 같은 Supplier를 호출한다면 스레드 안전성까지 따져야 합니다. 예제에서는 호출 횟수를 보여 주려고 일부러 상태를 바꿨지만, 실제로 대체값을 만드는 함수는 바깥 상태를 바꾸지 않고 값만 만드는 편이 다루기 쉽습니다. 호출 횟수 같은 통계가 필요하다면 그 함수를 감싸는 바깥 코드에서 따로 기록합니다.

더 깊이: 캡처하지 않는 람다와 객체 동일성

JLS 15.27.4는 람다식을 계산할 때 새 객체를 만들 수도 있고, 이미 있는 객체를 가리킬 수도 있다고 정해 두었습니다. 실제로 HotSpot JVM은 아무것도 캡처하지 않는 람다라면 같은 람다식을 여러 번 계산해도 같은 객체를 돌려주는 경우가 많습니다. 하지만 이것은 구현의 선택일 뿐이므로, 람다 객체를 ==로 비교하거나 동기화의 잠금 객체로 쓰는 등 객체의 동일성에 기대는 코드는 쓰지 않습니다.

지연 평가는 비용을 없애지 않고 미룰 뿐이다

지연 평가는 계산을 없애 주지 않습니다. 대체값이 필요한 분기에서는 결국 같은 계산을 하고, 그 비용이 그 분기로 옮겨 갈 뿐입니다. 예외도 마찬가지입니다. 대체값을 만들다 예외가 나면, 그 예외는 람다를 만든 곳이 아니라 get()을 호출한 곳에서 납니다. 이 글의 예제처럼 메서드 안에서 바로 get()을 부르는 경우라면 예외는 그 메서드를 호출하는 도중에 나므로, 바깥 메서드가 정상적으로 값을 돌려준 뒤에 뒤늦게 터지지는 않습니다.

그래서 지연 평가가 언제나 나은 것은 아닙니다. 기본값이 작은 불변 상수라면 미리 만들어 두는 편이 코드도 단순하고 비용도 거의 없습니다. 설정 값처럼 프로그램을 시작할 때 검사해야 하는 것을 미루면, 잘못된 설정이 그 값을 처음 쓰는 순간에야, 어쩌면 서비스가 한창 돌아가는 중에야 드러납니다. 시작할 때 계산해 두면 그 자리에서 바로 문제를 발견할 수 있습니다. 즉시 평가와 지연 평가는 계산 비용, 부수 효과, 그리고 실패를 언제 발견하고 싶은지를 따져서 고릅니다.

반대로 지연 평가가 잘 맞는 경우도 있습니다. 반드시 있어야 하는 설정이 아니라, 없으면 기본값을 계산해 쓰는 설정을 조회하는 메서드를 보겠습니다. 기본값을 계산하는 데 비용이 든다면, 설정에 값이 있을 때는 그 계산을 건너뛰는 편이 이득입니다. 기본값을 만드는 computeDefault는 호출될 때 메시지를 출력하게 해 두었습니다.

app/LazyConfigurationLookup.java
import java.util.Map;
import java.util.function.Supplier;

public final class LazyConfigurationLookup {
    private final Map<String, String> values;

    private LazyConfigurationLookup(Map<String, String> values) {
        this.values = Map.copyOf(values);
    }

    String get(String key, Supplier<String> fallback) {
        String value = values.get(key);
        return value != null ? value : fallback.get();
    }

    private static String computeDefault(String key) {
        System.out.println("computing default for " + key);
        return "default-" + key;
    }

    public static void main(String[] args) {
        LazyConfigurationLookup config = new LazyConfigurationLookup(Map.of("mode", "safe"));
        System.out.println(config.get("mode", () -> computeDefault("mode")));
        System.out.println(config.get("region", () -> computeDefault("region")));
    }
}
실행 결과
safe
computing default for region
default-region

mode는 설정에 있으므로 safe가 바로 나오고 computeDefault는 호출되지 않습니다. region은 설정에 없어서 그때 처음 fallback.get()이 호출되고, 그래서 computing default for region이 결과보다 먼저 출력됩니다.

values.get(key)가 null이면 키가 없다고 판단할 수 있는 것은 Map.copyOf 덕분입니다. Map.copyOf는 null 키나 null 값이 들어 있는 Map을 받으면 NullPointerException을 던지므로, 이 Map에는 null 값이 있을 수 없습니다. 반면 이 get은 대체값을 만드는 함수가 null을 돌려주면 그 null을 그대로 돌려줍니다. null을 결과로 내보내면 안 된다면, 대체값의 결과도 따로 검사해야 합니다.

람다를 넘겼다고 저절로 미뤄지지는 않는다

람다를 인수로 넘기는 API는 많지만, 그 함수를 언제, 몇 번 호출하는지는 API마다 다릅니다. '람다니까 나중에 실행되겠지'라고 짐작하지 말고, 그 API가 함수를 언제 부르는지 문서로 확인하는 습관이 필요합니다.

API넘긴 함수를 호출하는 때호출 횟수
supplier.get()get()을 부를 때마다부른 만큼
Optional.orElseGet(supplier)그 Optional이 비어 있을 때, orElseGet 호출 안에서0번 또는 1번
Map.computeIfAbsent(key, f)키가 없거나 null에 연결돼 있을 때0번 또는 1번
Stream의 map, filter최종 연산이 원소를 요청할 때원소마다
CompletableFuture.supplyAsync(supplier)실행기가 작업을 실행할 때, 다른 스레드에서1번

Optional.orElseGet은 받은 Supplier를 어딘가에 저장해 두지 않고, 그 호출 안에서 값이 없을 때만 get()을 한 번 부릅니다. 지연 연산과 스트림 만들기에서 본 Stream은 여러 원소에 대한 연산 전체를 최종 연산이 실행될 때까지 미루고, 한 번 소비하면 다시 쓸 수 없습니다. 반면 Supplier는 값 하나를 만드는 일을 미루며, get()을 몇 번이든 호출할 수 있습니다. invokeAll과 CompletableFuture로 결과 모으기에서 본 CompletableFuture.supplyAsync는 여기에 스레드 풀에서의 비동기 실행까지 더해집니다. 모두 함수를 넘기지만 언제, 어디서, 몇 번 실행되는지는 이렇게 다릅니다.

표준 라이브러리에는 같은 생각을 쓴 메서드가 더 있습니다. Objects.requireNonNull(obj, messageSupplier)는 obj가 null일 때만 오류 메시지를 만들고, java.util.logging.Logger의 fine(Supplier<String>) 같은 메서드는 그 수준의 로그가 실제로 기록될 때만 메시지 문자열을 만듭니다. 대부분의 경우 쓰이지 않을 문자열을 매번 조립하지 않으려는 것입니다.

지연 계산 안에서 나는 검사 예외

Supplier.get()은 throws 절이 없으므로, 람다 본문에서 검사 예외를 그대로 던질 수 없습니다. 예를 들어 설정이 없을 때 파일에서 기본값을 읽으려고 orElseGet 안에서 Files.readString을 호출하면 컴파일 오류가 납니다. Files.readString이 검사 예외인 IOException을 던질 수 있기 때문입니다.

error: unreported exception IOException; must be caught or declared to be thrown

이럴 때는 람다 안에서 예외를 잡아 비검사 예외로 바꿔 던지거나, 검사 예외를 던질 수 있는 함수형 인터페이스를 직접 정의해 씁니다. 아래는 첫 번째 방법입니다. UncheckedIOException은 IOException을 원인(cause)으로 감싸는 비검사 예외로, RuntimeException과 비검사 예외에서 다룬 '예외를 바꿔 던지기'의 한 예입니다.

lab/FallbackFileRead.java
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;

public final class FallbackFileRead {
    public static void main(String[] args) {
        Optional<String> configured = Optional.empty();
        String text = configured.orElseGet(() -> {
            try {
                return Files.readString(Path.of("fallback.txt"));
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        });
        System.out.println(text);
    }
}

configured가 비어 있으므로 orElseGet은 람다를 실행해 파일을 읽으려 합니다. 실행하는 폴더에 fallback.txt가 없으면 예외가 나고, 출력의 앞부분은 다음과 같습니다.

실행 결과(앞부분)
Exception in thread "main" java.io.UncheckedIOException: java.nio.file.NoSuchFileException: fallback.txt
	at FallbackFileRead.lambda$main$0(FallbackFileRead.java:14)
	at java.base/java.util.Optional.orElseGet(Optional.java:364)
	at FallbackFileRead.main(FallbackFileRead.java:10)
Caused by: java.nio.file.NoSuchFileException: fallback.txt

스택 트레이스를 아래에서 위로 읽으면, main의 10번째 줄이 orElseGet을 호출했고 orElseGet 안에서 실행된 람다 본문(lambda$main$0)의 14번째 줄에서 예외가 던져졌다는 것을 알 수 있습니다(Optional.java:364 같은 JDK 내부 줄 번호는 버전마다 다를 수 있습니다). 앞 절에서 말한 대로 예외는 get()이 호출된 곳에서 납니다. 또 원래 예외 NoSuchFileException이 Caused by로 남아 있으므로, 예외를 바꿔 던질 때 원인을 함께 넘기면 무엇이 잘못됐는지를 잃지 않습니다.

여기서 하나 더 지켜야 할 것이 있습니다. 대체값을 만들다 실패한 것과 주 데이터가 없는 것은 다른 사건입니다. 대체값을 읽지 못했다고 해서 예외를 잡아 빈 Optional이나 빈 문자열로 바꿔 버리면, 파일 시스템이나 원격 서버의 장애가 단순한 '값 없음'처럼 보이게 됩니다. 앞 글에서 본 것처럼 '없음'과 '실패'는 따로 알려야 합니다.

정리

  • Java는 메서드를 호출하기 전에 인수 식을 왼쪽부터 차례로 모두 계산합니다. 메서드가 그 값을 쓰지 않더라도 인수 식 안의 메서드 호출은 이미 실행된 뒤입니다.
  • &&, ||, ?:는 결과를 정하는 데 필요한 피연산자만 계산합니다. 메서드 인수에는 이런 단락 평가가 없습니다.
  • 값 대신 Supplier를 넘기면, 람다식을 계산해도 본문은 실행되지 않고 받는 쪽이 get()을 부를 때 실행됩니다. 지연 평가는 get()을 누가 언제 부르느냐로 결정됩니다.
  • Supplier는 결과를 기억하지 않으며, get()을 부를 때마다 본문이 다시 실행될 수 있습니다. 캡처한 객체는 Supplier가 살아 있는 동안 함께 유지됩니다.
  • 지연 평가는 비용을 필요한 분기로 옮길 뿐이고, 예외도 get()을 호출한 곳에서 납니다. 값싼 상수나 시작할 때 검사해야 하는 설정은 즉시 평가가 낫습니다.

연습 문제

  1. LazyFallbackSupplier의 main을 바꿔, 같은 fallback과 calls로 choose(false, ...), choose(true, ...), choose(false, ...)를 차례로 호출합니다. 각 호출이 돌려주는 값과, 각 호출 직후의 calls.get() 값을 예측하세요.

  2. ArgumentEvaluationOrder의 mark는 그대로 두고, 아래 pick 메서드를 추가한 뒤(java.util.function.Supplier를 import해야 합니다) System.out.println("result=" + pick(false, mark("A"), () -> mark("B")));를 실행합니다. 출력되는 네 줄을 순서대로 예측하세요. 첫 인수를 true로 바꾸면 출력은 어떻게 달라질까요?

    private static String pick(boolean useFirst, String first, Supplier<String> second) {
        System.out.println("pick");
        return useFirst ? first : second.get();
    }
  3. Map<String, String> 캐시와 Supplier<String> 로더를 받아, 키가 캐시에 있으면 캐시 값을, 없으면 로더가 만든 값을 캐시에 넣고 돌려주는 메서드를 작성하세요. 로더 호출 횟수는 AtomicInteger로 세어, 캐시에 있는 키를 조회할 때는 로더가 호출되지 않는지 확인하세요.

1번 풀이

차례로 fallback과 1, primary와 1, fallback과 2입니다. 실제로 각 호출 뒤에 calls.get()을 붙여 출력하면 다음과 같습니다.

실행 결과
fallback calls=1
primary calls=1
fallback calls=2

첫 choose(false, ...)는 get()을 호출해 createFallback을 실행하므로 1이 됩니다. 두 번째 choose(true, ...)는 get()을 부르지 않으므로 1에 머뭅니다. 세 번째 choose(false, ...)는 다시 get()을 호출하는데, Supplier는 첫 번째 결과를 기억하지 않으므로 createFallback이 한 번 더 실행되어 2가 됩니다.

2번 풀이

실행 결과는 다음과 같습니다.

실행 결과
evaluated=A
pick
evaluated=B
result=B

pick이 실행되기 전에 인수가 왼쪽부터 차례로 계산됩니다. 두 번째 인수 mark("A")는 메서드 호출이므로 이때 실행되어 첫 줄이 나옵니다. 세 번째 인수인 람다식도 이때 계산되지만, 그 결과는 Supplier 객체일 뿐 본문의 mark("B")는 아직 실행되지 않습니다. 그다음 pick의 본문이 실행되어 pick이 출력되고, useFirst가 false이므로 second.get()이 호출되면서 그제야 evaluated=B가 나옵니다. 마지막으로 main이 돌려받은 B로 결과 줄을 출력합니다.

첫 인수를 true로 바꾸면 evaluated=A, pick, result=A 세 줄만 나옵니다. mark("A")는 쓰이든 말든 호출 전에 실행되지만, get()이 호출되지 않으므로 mark("B")는 끝까지 실행되지 않습니다. 같은 mark 호출이라도 값으로 넘겼는지, 람다 안에 넣어 Supplier로 넘겼는지에 따라 실행 시점이 달라지는 것입니다.

3번 풀이

캐시에서 먼저 찾아보고, 없을 때만 loader.get()을 호출합니다.

exercise/LazyCacheLookupSolution.java
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;

public final class LazyCacheLookupSolution {
    private static String get(Map<String, String> cache, String key, Supplier<String> loader) {
        String cached = cache.get(key);
        if (cached != null) {
            return cached;
        }
        String loaded = loader.get();
        cache.put(key, loaded);
        return loaded;
    }

    public static void main(String[] args) {
        Map<String, String> cache = new HashMap<>();
        cache.put("java", "cached-java");
        AtomicInteger loads = new AtomicInteger();
        Supplier<String> loader = () -> "loaded-" + loads.incrementAndGet();

        System.out.println(get(cache, "java", loader));
        System.out.println(get(cache, "spring", loader));
        System.out.println("loads=" + loads.get());
    }
}
실행 결과
cached-java
loaded-1
loads=1

java는 캐시에 있으므로 로더를 호출하지 않고 cached-java를 돌려줍니다. spring은 캐시에 없어서 로더가 처음 호출되고, 그때 loads가 1로 늘면서 loaded-1이 만들어져 캐시에 저장됩니다. 그래서 마지막 줄은 loads=1입니다. 여기서 spring을 한 번 더 조회하면 이번에는 캐시에 저장된 loaded-1을 그대로 돌려주므로, loads는 여전히 1입니다.

이 메서드는 캐시 값이 null인 경우와 키가 없는 경우를 똑같이 '없음'으로 봅니다. 그래서 로더가 null을 돌려주면, HashMap에 null 값이 저장되기는 하지만 다음 조회에서도 cache.get(key)가 null이라 로더를 다시 호출합니다. 같은 키를 두 번 조회하면 로더도 두 번 호출됩니다. null도 정상적인 캐시 값으로 취급하려면 containsKey로 키의 존재를 따로 확인해야 합니다.

같은 동작은 cache.computeIfAbsent(key, k -> loader.get()) 한 줄로도 쓸 수 있습니다. 이때 computeIfAbsent는 함수가 null을 돌려주면 아무것도 저장하지 않습니다. 또 여러 스레드가 함께 쓰는 캐시라면 위 메서드는 같은 키를 동시에 조회할 때 로더를 중복으로 호출할 수 있습니다. 이런 경우에는 ConcurrentHashMap의 computeIfAbsent를 쓰면, 한 호출 안의 확인과 계산과 저장이 원자적으로 처리되어 키가 없을 때 함수가 정확히 한 번만 호출됩니다.

다음 글

다음 글에서는 이 규칙을 Optional에 적용합니다. 값이 있는 Optional에서도 orElse의 인수가 계산되는 모습을 실행 결과로 확인하고, orElseGet, or, orElseThrow로 여러 단계의 조회를 처리합니다.