orElse·orElseGet
값이 있는 Optional에서도 실행되는 orElse 인수를 확인하고 중첩 주소와 배송 상태를 map·flatMap·지연 대체 경로로 처리합니다.
orElse(value)의 인수는 일반 Java 인수라 Optional이 값이 있는 상태여도 먼저 계산됩니다.
orElseGet(supplier)는 빈 상태일 때만 Supplier를 호출합니다.
결과 문자열이 같아도 비용, 부수 효과, 예외 시점이 다르므로 대체 경로 생성 방식에 맞춰 선택합니다.
값 존재 시 불필요한 대체 조회
아래 Optional에는 이미 주소가 있지만 orElse(loadDefault())의 인수가 즉시 평가됩니다.
원문에서 도출되는 출력은 address=Seoul, wrong-queries=1입니다.
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
public final class OrElseEagerQueryBug {
public static void main(String[] args) {
AtomicInteger queries = new AtomicInteger();
String address = Optional.of("Seoul").orElse(loadDefaultAddress(queries));
System.out.println("address=" + address + ", wrong-queries=" + queries.get());
}
private static String loadDefaultAddress(AtomicInteger queries) {
queries.incrementAndGet();
return "Default";
}
}대체 경로가 리터럴·상수·이미 계산된 지역 변수처럼 값싼 값이면 orElse가 간결합니다.
데이터베이스 조회, 객체 생성, 로깅처럼 불필요한 실행을 피해야 한다면 orElseGet을 씁니다.
“Optional API이므로 자동으로 지연된다”고 생각해서는 안 됩니다.
두 대체 경로 API의 평가 경로
값이 있는 상태에서 orElse는 인수를 계산한 뒤 결과를 버리지만 orElseGet은 공급자 본문을 호출하지 않습니다.
빈 상태에서는 둘 다 대체 경로의 값을 반환합니다.
다만 Supplier 객체 생성과 캡처 비용은 발생할 수 있습니다.
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
public final class OrElseAndOrElseGetTrace {
public static void main(String[] args) {
AtomicInteger eager = new AtomicInteger();
AtomicInteger lazy = new AtomicInteger();
String first = Optional.of("value").orElse(make("eager", eager));
String second = Optional.of("value").orElseGet(() -> make("lazy", lazy));
String third = Optional.<String>empty().orElseGet(() -> make("needed", lazy));
System.out.println(first + "," + second + "," + third);
System.out.println("eager=" + eager.get() + ", lazy=" + lazy.get());
}
private static String make(String value, AtomicInteger calls) {
calls.incrementAndGet();
return value;
}
}세 Optional 호출에서 실제로 평가하는 식
| 원문 호출 | 대체 계산 | 반환 |
|---|---|---|
| 첫째·값 있음 | orElse 인수 make 실행; eager +1 | value |
| 둘째·값 있음 | orElseGet 공급자 미호출; lazy +0 | value |
| 셋째·빈 상태 | orElseGet 공급자 호출; lazy +1 | needed |
- 첫째·값 있음
- 대체 계산: orElse 인수 make 실행; eager +1반환: value
- 둘째·값 있음
- 대체 계산: orElseGet 공급자 미호출; lazy +0반환: value
- 셋째·빈 상태
- 대체 계산: orElseGet 공급자 호출; lazy +1반환: needed
OrElseAndOrElseGetTrace를 순서대로 읽으면 최종 카운터는 eager=1, lazy=1입니다. 이 횟수는 각 호출의 분기에서 도출한 것이며 다음 호출까지 값을 캐시한다는 뜻은 아닙니다.
Optional 연쇄 탐색
User가 없을 수 있고 User.addressId도 null을 허용하는 레거시 필드라면 두 단계의 부재가 있습니다.
저장소의 Optional에 map을 적용했을 때 식별자가 null이면 Optional.map은 빈 상태로 바꿉니다.
주소 저장소가 반환한 Optional은 flatMap으로 연결합니다.
import java.util.Map;
import java.util.Optional;
public final class UserAddressLookup {
private record User(long id, Long addressId) {}
private record Address(long id, String city) {}
private static final Map<Long, User> USERS =
Map.of(
1L, new User(1, 100L),
2L, new User(2, null));
private static final Map<Long, Address> ADDRESSES = Map.of(100L, new Address(100, "Busan"));
static Optional<Address> findAddress(long userId) {
return Optional.ofNullable(USERS.get(userId))
.map(User::addressId)
.flatMap(addressId -> Optional.ofNullable(ADDRESSES.get(addressId)));
}
public static void main(String[] args) {
System.out.println(findAddress(1).map(Address::city).orElse("unknown"));
System.out.println(findAddress(2).map(Address::city).orElse("unknown"));
}
}원문은 첫 조회에서 Busan, 둘째 조회에서 문자열 unknown을 출력합니다.
이 예제는 메모리 Map을 조회하며 저장소 장애를 발생시키는 코드는 없습니다. 조회를 외부 저장소로 바꾼 경우에도 이 연쇄는 예외를 잡지 않으므로, 발생한 예외가 빈 상태로 바뀌지는 않습니다.
Optional 연쇄는 정상 부재만 표현합니다.
null 검사와 Optional 분기
명시적 null 검사는 각 단계별로 다른 대체 경로와 로깅이 필요할 때 읽기 좋습니다.
Optional 연쇄는 부재가 같은 방식으로 전파되고 최종 처리 하나면 충분할 때 간결합니다.
연쇄가 열 단계로 길어지면 도메인 메서드를 나눕니다.
Optional을 필드 그래프 순회의 만능 해결책으로 만들지 않습니다.
새 모델에서는 Address가 필수인지 선택인지 생성자와 관계 타입으로 표현할 수 있으므로 null 허용 식별자에 의존할 필요가 없습니다.
Optional은 저장소 조회 결과가 시스템에 들어오는 경계에서 가장 효과적입니다.
Order와 Delivery의 배송 조회
Order는 존재하지만 Delivery가 아직 생성되지 않을 수 있습니다.
배송이 없으면 대기 중 레이블을, 있으면 추적 번호를 반환합니다.
대기 중 값 생성이 단순 상수라면 orElse()도 안전하지만 로캘 조회 같은 비용이 있으면 orElseGet()을 선택합니다.
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
public final class DeliveryStatusService {
private record Order(long id, Long deliveryId) {}
private record Delivery(long id, String trackingNumber) {}
private final Map<Long, Order> orders = Map.of(1L, new Order(1, 10L), 2L, new Order(2, null));
private final Map<Long, Delivery> deliveries = Map.of(10L, new Delivery(10, "T-100"));
private final AtomicInteger fallbackLoads = new AtomicInteger();
String trackingLabel(long orderId) {
return Optional.ofNullable(orders.get(orderId))
.map(Order::deliveryId)
.flatMap(id -> Optional.ofNullable(deliveries.get(id)))
.map(Delivery::trackingNumber)
.orElseGet(this::pendingLabel);
}
private String pendingLabel() {
fallbackLoads.incrementAndGet();
return "PENDING";
}
public static void main(String[] args) {
DeliveryStatusService service = new DeliveryStatusService();
System.out.println(service.trackingLabel(1));
System.out.println(service.trackingLabel(2));
System.out.println("fallback-loads=" + service.fallbackLoads.get());
}
}추적 번호가 있는 첫 호출은 대체 경로를 건너뛰고 둘째 호출만 PENDING을 만들어 fallback-loads=1입니다.
주문 자체가 없을 때도 PENDING으로 보일지 NOT_FOUND로 구분할지 제품 규칙을 정해야 합니다.
Optional.or의 지연 대체
값이 아니라 다른 Optional 조회를 대체 경로로 사용하려면 primary.or(() -> secondary())가 유용합니다.
기본 값이 있으면 secondary 공급자를 호출하지 않고 결과도 Optional로 유지합니다.
orElseGet은 T를 반환하므로 Optional을 다시 감싸는 용도가 아닙니다.
캐시 조회 뒤 데이터베이스 조회처럼 원본의 우선순위가 있을 때 사용할 수 있습니다.
보조 조회의 실패를 빈 상태로 숨기지 말고 예외 정책을 유지합니다.
orElseThrow로 필수 관계 확인
배송 생성 명령처럼 Address가 반드시 있어야 작업을 시작할 수 있다면 orElseThrow(() -> new MissingAddressException(userId))로 불변식을 명시합니다.
단순 조회 화면에 “알 수 없음”을 표시하는 대체 경로와 명령 입력 검사의 예외는 요구 사항이 다릅니다.
Supplier를 사용하면 메시지 서식 처리와 예외 객체 생성도 값이 없는 경로에서만 수행됩니다.
예외 타입에는 호출자가 복구 여부를 판단할 수 있는 의미를 담습니다.
연습 문제
User.primaryEmail이 있으면 사용하고, 없으면 Profile 저장소에서 이메일을 찾으세요.
둘 다 없으면 [email protected]을 반환합니다.
대체 프로필 경로에 진입한 횟수를 세어 기본 이메일이 있을 때는 0인지 확인하세요.
정답과 Optional.or
기본 Optional 뒤의 or 공급자에서만 프로필 저장소를 조회합니다.
최종 기본값은 값싼 상수이므로 orElse를 씁니다.
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
public final class LazyEmailFallbackSolution {
private record User(Long profileId, String primaryEmail) {}
private record Profile(long id, String email) {}
private static String email(User user, Map<Long, Profile> profiles, AtomicInteger lookups) {
return Optional.ofNullable(user.primaryEmail())
.or(
() -> {
lookups.incrementAndGet();
return Optional.ofNullable(user.profileId())
.map(profiles::get)
.map(Profile::email);
})
.orElse("[email protected]");
}
public static void main(String[] args) {
Map<Long, Profile> profiles = Map.of(10L, new Profile(10, "[email protected]"));
AtomicInteger lookups = new AtomicInteger();
System.out.println(email(new User(10L, "[email protected]"), profiles, lookups));
System.out.println(email(new User(10L, null), profiles, lookups));
System.out.println("lookups=" + lookups.get());
}
}대체 경로 카운터와 실제 Map 조회는 다르다
| 이메일 입력 상태 | lookups 증가 / Map.get 호출 | 반환 |
|---|---|---|
| 기본 이메일 있음 | 0 / 0회 | 기본 이메일 |
| 기본 없음·프로필 10 | 1 / 1회 | [email protected] |
| 기본 없음·프로필 ID 없음 | 1 / 0회 | [email protected] |
- 기본 이메일 있음
- lookups 증가 / Map.get 호출: 0 / 0회반환: 기본 이메일
- 기본 없음·프로필 10
- lookups 증가 / Map.get 호출: 1 / 1회
- 기본 없음·프로필 ID 없음
- lookups 증가 / Map.get 호출: 1 / 0회
앞의 두 행은 main의 입력입니다. 마지막 행은 원문에 호출을 추가하지 않고 null profileId 분기를 읽은 비교입니다. lookups는 or 공급자 진입 횟수를 셉니다.
main의 Map.of는 null을 허용하지 않습니다. 다른 Map이 null 값을 반환하더라도 이 연쇄의 map은 이를 빈 상태로 처리하므로 미등록 프로필과 구분하지 않습니다.
orElse와 orElseGet의 차이는 반환값이 아니라 대체 경로 식의 실행 시점입니다.
주소·배송처럼 조회 단계가 여러 개인 경우 Optional 연쇄의 부재와 실제 장애를 구분하고, 비용이 큰 대체 경로는 Supplier 경계 뒤에 둬야 합니다.