즉시 평가와 지연 평가
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은 함수 객체를 보관했다가 조건에 따라 실행합니다.
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로 전달하는 계산 절차
Supplier<T>의 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";
}
}첫 번째 선택 뒤 calls는 0, 두 번째 선택 뒤에는 1입니다.
Supplier는 계산 결과를 자동으로 저장하지 않으므로 get을 여러 번 호출하면 그만큼 다시 계산합니다.
한 번 계산한 값을 재사용해야 한다면 별도의 지연 초기화 객체를 설계합니다.
lambda capture 비용과 상태
캡처가 없는 람다는 실행 환경에 따라 인스턴스를 재사용할 수 있지만, 인스턴스 동일성을 기능 규칙으로 삼아서는 안 됩니다.
큰 객체 그래프를 캡처하면 Supplier가 살아 있는 동안 해당 참조도 유지됩니다.
필요한 키만 캡처하고 자원 자체를 오래 붙들지 않습니다.
가변 상태를 Supplier 안에서 변경하면 호출 횟수와 스레드 안전성이 중요해집니다.
대체 경로 팩터리는 가능하면 결정적인 순수 함수로 두고 지표는 외부 데코레이터에서 관찰합니다.
지연 평가는 비용을 없애지 않고 조건부로 지연
필요한 분기에서는 결국 계산을 수행합니다. 지연 평가를 사용하면 최대 지연 시간이 대체 경로에 집중될 수 있고 예외가 발생하는 시점도 API 호출 뒤로 이동합니다. 시작할 때 검증해야 하는 설정까지 지연하면 오류를 늦게 발견할 수 있습니다.
즉시 평가가 더 나은 경우도 있습니다. 작고 불변인 기본값을 미리 만들면 코드가 단순해지고 초기화 시점에 실패를 발견할 수 있습니다. 비용, 부수 효과, 실패 발견 시점을 기준으로 선택합니다.
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일 수 있는지 API가 정해야 합니다.
Stream·Supplier 지연 평가
Stream은 최종 연산이 원소를 요구할 때까지 여러 원소의 연산 파이프라인을 미룹니다.
Supplier는 값 하나를 생성하는 호출을 미룹니다.
둘 다 함수를 보관하지만 생명 주기와 원소 수가 다릅니다.
Stream은 한 번 소비되고 Supplier는 get을 여러 번 호출할 수 있습니다.
Optional.orElseGet은 빈 상태일 때 Supplier를 한 번 호출해 값 하나를 받습니다.
CompletableFuture의 공급자는 실행기 스케줄링과 비동기 생명 주기를 추가합니다.
“람다이므로 지연된다”가 아니라 어떤 API가 언제 함수를 호출하는지 확인합니다.
지연 계산 예외의 처리 위치
Supplier.get()은 검사 예외를 선언하지 않습니다.
I/O 대체 경로가 검사 예외를 낸다면 래퍼 인터페이스를 만들거나 서비스 경계에서 예외를 변환합니다.
무조건 RuntimeException으로 감싸 원인과 재시도 가능성을 잃어서는 안 됩니다.
대체 경로 실패와 주요 미적중을 별도 결과로 기록합니다.
빈 상태 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());
}
}첫 조회는 로더를 건너뛰고 둘째만 호출해 loads=1입니다.
다중 스레드 캐시에서는 이 구현이 중복 적재를 막지 않으므로 ConcurrentHashMap 정책을 검토합니다.
즉시 평가와 지연 평가를 구분하는 기준은 함수 문법이 아니라 호출 주체와 시점입니다.
값 인수는 메서드 진입 전에 계산되고 Supplier 본문은 get에서 실행된다는 규칙을 비용·부수 효과·예외 정책에 연결해야 합니다.