즉시 평가와 지연 평가
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 &&의 오른쪽 피연산자라 실행되지 않습니다.
기능이 평가 순서와 부수 효과에 의존하면 리팩터링이 어려우므로 가능하면 순수한 인수를 사용합니다.
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 진입 전 계산 | primary 반환; 대체 계산 1회 |
| 공급자·첫 주 경로 | get을 호출하지 않음 | primary 반환; 누적 0회 |
| 공급자·다음 미적중 | choose 안에서 get 호출 | fallback 반환; 누적 1회 |
- 값 인수·주 경로
- 대체 계산 시점: choose 진입 전 계산반환과 호출 횟수: primary 반환; 대체 계산 1회
- 공급자·첫 주 경로
- 대체 계산 시점: get을 호출하지 않음반환과 호출 횟수: primary 반환; 누적 0회
- 공급자·다음 미적중
- 대체 계산 시점: choose 안에서 get 호출반환과 호출 횟수: fallback 반환; 누적 1회
첫 행은 버그 예제, 다음 두 행은 LazyFallbackSupplier의 연속 호출입니다. 수치는 두 원문의 제어 흐름에서 도출했으며 실행 계측값이 아닙니다.
Supplier 타입은 결과 캐시 여부를 정하지 않습니다. 이 예제의 람다는 get()마다 createFallback을 실행하지만, 다른 구현은 저장된 값을 반환할 수도 있습니다.
한 번 계산한 값을 재사용해야 한다면 별도의 지연 초기화 객체를 설계합니다.
lambda capture 비용과 상태
캡처가 없는 람다는 실행 환경에 따라 인스턴스를 재사용할 수 있지만, 인스턴스 동일성을 기능 규칙으로 삼아서는 안 됩니다.
큰 객체 그래프를 캡처하면 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 정책을 검토합니다.
즉시 평가와 지연 평가를 구분하는 기준은 함수 문법이 아니라 호출 주체와 시점입니다.
값 인수는 메서드 진입 전에 계산되고 Supplier 본문은 get에서 실행된다는 규칙을 비용·부수 효과·예외 정책에 연결해야 합니다.