즉시 평가와 지연 평가
Java 인수가 호출 전에 계산되는 규칙을 확인하고 값 대신 Supplier를 전달해 필요한 시점에만 대체 경로를 실행합니다.
Java 메서드 인수는 호출에 들어가기 전에 계산됩니다.
값이 필요 없더라도 인수 식에 메서드 호출이 있으면 이미 실행됩니다.
지연 평가가 필요하면 값 자체가 아니라 “나중에 값을 만드는 동작”인 Supplier를 전달하고 소비자가 조건에 따라 호출해야 합니다.
선택되지 않은 대체 경로의 실행
choose(true, primary, createFallback())에서 대체 경로 인수는 choose 호출 전에 평가됩니다.
원문을 따라가면 주 경로가 선택돼도 fallback-created와 wrong-fallback-calls=1이 출력됩니다.
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";
}
}대체 경로가 단순한 리터럴이라면 불필요한 계산 문제가 작지만, 데이터베이스 조회나 객체 그래프 생성, 지표 기록처럼 부수 효과가 있다면 비용과 동작이 달라집니다.
조건 연산자는 선택한 분기만 평가하지만 일반 메서드의 인수는 호출 전에 평가된다는 차이를 구분합니다.
인수 식 평가 순서
Java는 인수를 왼쪽에서 오른쪽으로 평가한 뒤 메서드를 호출합니다.
boolean 단락 연산자 &&, ||와 조건 연산자는 필요한 분기만 평가합니다.
Stream 중간 연산은 최종 평가를 위한 연산을 구성합니다. Optional.orElseGet은 공급자를 장기 보관하지 않고, 해당 호출 안에서 값이 없을 때 get()을 호출합니다.
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");
}
}first, second가 출력된 뒤 combine 결과가 나옵니다.
expensiveBoolean은 false &&의 오른쪽 피연산자라 실행되지 않습니다.
여기서는 mark(first)→mark(second)→combine의 순서입니다. 첫 mark가 예외로 끝나면 둘째 인수와 combine에는 도달하지 않습니다. choose 내부에서 주 경로를 골랐다는 사실은 진입 전 인수 계산을 되돌리지 않습니다. JLS의 왼쪽부터 인수 평가 규칙은 이 순서를 정합니다.
기능이 평가 순서와 부수 효과에 의존하면 리팩터링이 어려우므로 가능하면 순수한 인수를 사용합니다.
Supplier로 전달하는 계산 절차
() -> createFallback(calls) 같은 람다 식을 인수로 평가하면 공급자 참조를 얻고 캡처할 참조를 정합니다. 람다 본문은 소비자가 get()을 호출할 때 수행됩니다. 인수 식에 supplier.get()을 직접 쓰면 그 호출은 즉시 평가됩니다.
대체 경로가 필요 없는 분기에서는 get을 부르지 않습니다.
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";
}
}값 인수와 공급자의 대체 계산 횟수
값 인수 버전은 choose 진입 전에 대체값을 계산하고 Supplier 버전은 true에서 생략 후 false에서만 호출합니다. 실제 calls 카운터의 1과 0→1 전환이 핵심입니다.
값 인수와 공급자의 대체 계산 횟수
값 계산과 공급자 호출
값 인수와 공급자의 대체 계산 횟수
값 인수는 choose 진입 전에 계산됩니다. Supplier 본문은 선택한 분기에서 get을 호출할 때만 실행됩니다.
값 인수·주 경로
- 대체 계산 시점
- 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 버전은 실행할 참조만 만듭니다. |
|
|
| 두 버전의 true 선택 각 choose(true)가 primary를 반환하며 Supplier는 get을 호출하지 않습니다. |
|
|
| 각 main의 후속 관찰 값 main은 1회에서 끝나고 Supplier main만 이어 false를 호출해 fallback을 만듭니다. |
|
|
값 인수와 공급자의 대체 계산 횟수
값 인수 버전은 choose 진입 전에 대체값을 계산하고 Supplier 버전은 true에서 생략 후 false에서만 호출합니다. 실제 calls 카운터의 1과 0→1 전환이 핵심입니다.
값 인수와 공급자의 대체 계산 횟수
값 계산과 공급자 호출
값 인수와 공급자의 대체 계산 횟수
값 인수는 choose 진입 전에 계산됩니다. Supplier 본문은 선택한 분기에서 get을 호출할 때만 실행됩니다.
값 인수·주 경로
- 대체 계산 시점
- 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 버전은 실행할 참조만 만듭니다. |
|
|
| 두 버전의 true 선택 각 choose(true)가 primary를 반환하며 Supplier는 get을 호출하지 않습니다. |
|
|
| 각 main의 후속 관찰 값 main은 1회에서 끝나고 Supplier main만 이어 false를 호출해 fallback을 만듭니다. |
|
|
예시 같은 createFallback이라도 값 인수에서는 호출 결과 문자열을 먼저 만들고 choose에 넘깁니다. Supplier 인수에서는 나중에 실행할 참조만 넘기고 true 분기는 get을 생략합니다. 예제의 LazyFallbackSupplier의 false 호출에서만 실제 팩터리를 실행하므로 누적 횟수는 0→1입니다. 두 예제의 main의 AtomicInteger는 서로 다른 객체이며 세 행을 하나의 누적 계측처럼 더하지 않습니다.
독립 확인: LazyFallbackSupplier의 같은 fallback과 calls로 choose(false),choose(true),choose(false)를 순서대로 호출합니다. 각 반환 값과 누적 횟수를 구하세요. 첫 인수 true는 유지하고 세 번째 인수를 fallback.get()으로 직접 넘기는 값 버전이면 호출을 건너뛰나요?
풀이 확인
순서대로 fallback/1,primary/1,fallback/2입니다. 같은 Supplier를 두 번 get해서 계산도 두 번 실행했으며 자동 캐시가 없습니다. fallback.get()을 값 인수로 쓰면 choose 진입 전에 실행되므로 true여도 추가 1회가 발생합니다. 값 메서드와 Supplier 메서드는 인수 타입부터 다르므로 이 비교는 각 기존 버전의 시그니처에 맞춘 별도 호출입니다.
Supplier 타입은 결과 캐시 여부를 정하지 않습니다. 이 예제의 람다는 get()마다 createFallback을 실행하지만, 다른 구현은 저장된 값을 반환할 수도 있습니다.
한 번 계산한 값을 재사용해야 한다면 별도의 지연 초기화 객체를 설계합니다.
람다 캡처 비용과 상태
캡처가 없는 람다는 실행 환경에 따라 인스턴스를 재사용할 수 있지만, 인스턴스 동일성을 기능 규칙으로 삼아서는 안 됩니다.
큰 객체 그래프를 캡처하면 Supplier가 살아 있는 동안 해당 참조도 유지됩니다.
필요한 키만 캡처하고 자원 자체를 오래 붙들지 않습니다.
가변 상태를 Supplier 안에서 변경하면 호출 횟수와 스레드 안전성이 중요해집니다.
대체 경로 팩터리는 가능하면 결정적인 순수 함수로 두고 지표는 외부 데코레이터에서 관찰합니다.
지연 평가는 비용을 없애지 않고 조건부로 미룹니다
필요한 분기에서는 결국 계산을 수행합니다.
지연 평가는 필요한 분기에 계산 비용을 모읍니다. 이 동기식 API에서 대체 경로의 예외는 get()을 호출하는 도중 발생하므로 바깥 메서드가 정상 반환한 뒤로 넘어가는 것은 아닙니다.
시작할 때 검증해야 하는 설정까지 지연하면 오류를 늦게 발견할 수 있습니다.
즉시 평가가 더 나은 경우도 있습니다.
작고 불변인 기본값을 미리 만들면 코드가 단순해지고 초기화 시점에 실패를 발견할 수 있습니다.
비용, 부수 효과, 실패 발견 시점을 기준으로 선택합니다.
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();
}
public static void main(String[] args) {
LazyConfigurationLookup config = new LazyConfigurationLookup(Map.of("mode", "safe"));
System.out.println(config.get("mode", () -> "computed-mode"));
System.out.println(config.get("region", () -> System.getProperty("user.country", "KR")));
}
}Map에 null을 저장하지 않는 불변식이 있으므로 null은 미적중을 뜻합니다.
대체 경로 Supplier는 미적중 경로에서만 실행됩니다.
이 구현은 대체 경로가 반환하는 null도 그대로 반환합니다. null을 금지하려면 결과 검증을 별도로 정해야 합니다.
Stream·Supplier 지연 평가
Stream은 최종 연산이 원소를 요구할 때까지 여러 원소의 연산 파이프라인을 미룹니다.
Supplier는 값 하나를 생성하는 호출을 미룹니다.
둘 다 함수를 보관하지만 생명 주기와 원소 수가 다릅니다.
Stream은 한 번 소비되고 Supplier는 get을 여러 번 호출할 수 있습니다.
Optional.orElseGet은 빈 상태일 때 Supplier를 한 번 호출해 값 하나를 받습니다.
CompletableFuture의 공급자는 실행기 스케줄링과 비동기 생명 주기를 추가합니다.
“람다이므로 지연된다”가 아니라 어떤 API가 언제 함수를 호출하는지 확인합니다.
지연 계산 예외의 처리 위치
Supplier.get()은 검사 예외를 선언하지 않습니다.
I/O 대체 경로가 검사 예외를 낸다면 래퍼 인터페이스를 만들거나 서비스 경계에서 예외를 변환합니다.
검사 예외를 변환할 때는 원인 예외와 재시도 판단에 필요한 정보를 보존합니다.
대체 경로 실패와 주 경로의 미적중을 별도 결과로 기록합니다.
빈 상태 Optional로 합치면 원격 장애를 단순 부재로 오판합니다.
연습 문제
Map 캐시와 Supplier<String> 로더를 받아 키가 있으면 캐시 값을, 없으면 로더 결과를 반환하는 메서드를 작성하세요.
로더 호출 횟수는 AtomicInteger로 검증하세요.
정답과 호출 횟수
Map의 computeIfAbsent는 매퍼가 필요할 때만 호출하지만, null 결과와 동시성 Map의 규칙을 확인해야 합니다.
여기서는 명시적 분기로 호출 시점을 보여 줍니다.
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());
}
}원문 main의 첫 조회는 로더를 건너뛰고 둘째만 호출하므로 loads=1입니다. 이 메서드는 캐시의 null 값과 키 부재를 같은 미적중으로 취급하며, 로더가 null을 저장하면 다음 조회에서도 다시 적재합니다.
다중 스레드 캐시에서는 이 구현이 중복 적재를 막지 않으므로 ConcurrentHashMap 정책을 검토합니다.
독립 확인: 마지막 HashMap 캐시 연습에서 먼저 java를 조회하고, 같은 spring을 두 번 연속 조회합니다. 각 반환 값과 최종 loads를 구하세요. 별도로 loader가 null을 반환하고 호출 횟수만 늘린다면 같은 absent 키의 두 조회는 몇 번 적재하나요?
풀이 확인
첫 조회는 cached-java이고 spring의 첫 조회가 loaded-1을 저장합니다. 다음 spring 조회는 같은 loaded-1을 읽어 최종 loads=1입니다. 별도 null 로더는 HashMap에 null을 저장할 수 있지만 다음 cache.get도 null이라 미적중 분기로 들어가 두 조회에서 2회 적재합니다. 키 존재 판별을 containsKey로 바꾸는 요구와 현재 null 값=미적중 정책은 구분해야 합니다.
즉시 평가와 지연 평가를 구분하는 기준은 함수 문법이 아니라 호출 주체와 시점입니다.
값 인수는 메서드 진입 전에 계산되고 Supplier 본문은 get에서 실행된다는 규칙을 비용·부수 효과·예외 정책에 연결해야 합니다.