본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
33장 : Optional과 인터페이스 확장

orElse·orElseGet

값이 있는 상태 `Optional`에서도 실행되는 orElse 인수를 확인하고 중첩 주소와 배송 상태를 `map`·flatMap·지연 대체 경로로 처리합니다.

orElse(value)의 인수는 일반 Java 인수라 Optional이 값이 있는 상태여도 먼저 계산됩니다. orElseGet(supplier)는 빈 상태일 때만 Supplier를 호출합니다. 결과 문자열이 같아도 비용, 부수 효과, 예외 시점이 다르므로 대체 경로 생성 방식에 맞춰 선택합니다.

값 존재 시 불필요한 대체 조회

아래 Optional에는 이미 주소가 있지만 orElse(loadDefault())의 인수가 즉시 평가됩니다. 실제 출력은 address=Seoul, wrong-queries=1입니다.

lab/OrElseEagerQueryBug.java
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 객체 생성과 캡처 비용은 발생할 수 있습니다.

src/OrElseAndOrElseGetTrace.java
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;
    }
}

즉시 평가의 호출 횟수는 1이고 지연 평가도 빈 상태 분기를 한 번 실행하므로 1입니다. 값이 있는 분기만 비교하면 지연 호출은 0입니다. 대체 경로를 여러 번 호출하지 않는다는 보장은 해당 메서드 호출 한 번의 범위에만 적용됩니다.

Optional 연쇄 탐색

User가 없을 수 있고 User.addressIdnull을 허용하는 레거시 필드라면 두 단계의 부재가 있습니다. 저장소의 Optionalmap을 적용했을 때 식별자가 null이면 Optional.map은 빈 상태로 바꿉니다. 주소 저장소가 반환한 OptionalflatMap으로 연결합니다.

src/UserAddressLookup.java
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, 둘째는 알 수 없음입니다. Address 저장소의 장애를 빈 상태로 바꾸지 않고 실제 예외는 밖으로 전달합니다. Optional 연쇄는 정상 부재만 표현합니다.

null 검사와 Optional 분기

명시적 null 검사는 각 단계별로 다른 대체 경로와 로깅이 필요할 때 읽기 좋습니다. Optional 연쇄는 부재가 같은 방식으로 전파되고 최종 처리 하나면 충분할 때 간결합니다. 연쇄가 열 단계로 길어지면 도메인 메서드를 나눕니다.

Optional을 필드 그래프 순회의 만능 해결책으로 만들지 않습니다. 새 모델에서는 Address가 필수인지 선택인지 생성자와 관계 타입으로 표현할 수 있으므로 null 허용 식별자에 의존할 필요가 없습니다. Optional은 저장소 조회 결과가 시스템에 들어오는 경계에서 가장 효과적입니다.

Order와 Delivery의 배송 조회

Order는 존재하지만 Delivery가 아직 생성되지 않을 수 있습니다. 배송이 없으면 대기 중 레이블을, 있으면 추적 번호를 반환합니다. 대기 중 값 생성이 단순 상수라면 orElse()도 안전하지만 로캘 조회 같은 비용이 있으면 orElseGet()을 선택합니다.

app/DeliveryStatusService.java
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로 유지합니다. orElseGetT를 반환하므로 Optional을 다시 감싸는 용도가 아닙니다.

캐시 조회 뒤 데이터베이스 조회처럼 원본의 우선순위가 있을 때 사용할 수 있습니다. 보조 조회의 실패를 빈 상태로 숨기지 말고 예외 정책을 유지합니다.

orElseThrow로 필수 관계 확인

배송 생성 명령처럼 Address가 반드시 있어야 작업을 시작할 수 있다면 orElseThrow(() -> new MissingAddressException(userId))로 불변식을 명시합니다. 단순 조회 화면에 “알 수 없음”을 표시하는 대체 경로와 명령 입력 검사의 예외는 요구 사항이 다릅니다.

Supplier를 사용하면 메시지 서식 처리와 예외 객체 생성도 값이 없는 경로에서만 수행됩니다. 예외 타입에는 호출자가 복구 여부를 판단할 수 있는 의미를 담습니다.

연습 문제

User.primaryEmail이 있으면 사용하고, 없으면 Profile 저장소에서 이메일을 찾으세요. 둘 다 없으면 anonymous@example.com을 반환합니다. 프로필 조회 횟수를 세어 기본 이메일이 있을 때는 0인지 확인하세요.

정답과 Optional.or

기본 Optional 뒤의 or 공급자에서만 프로필 저장소를 조회합니다. 최종 기본값은 값싼 상수이므로 orElse를 씁니다.

exercise/LazyEmailFallbackSolution.java
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("anonymous@example.com");
    }

    public static void main(String[] args) {
        Map<Long, Profile> profiles = Map.of(10L, new Profile(10, "profile@example.com"));
        AtomicInteger lookups = new AtomicInteger();
        System.out.println(email(new User(10L, "primary@example.com"), profiles, lookups));
        System.out.println(email(new User(10L, null), profiles, lookups));
        System.out.println("lookups=" + lookups.get());
    }
}

두 결과는 기본 이메일과 프로필 이메일이며 프로필 조회는 한 번뿐입니다. 프로필 Mapnull 값을 허용하지 않는다는 전제도 유지합니다.

orElseorElseGet의 차이는 반환값이 아니라 대체 경로 식의 실행 시점입니다. 주소·배송처럼 조회 단계가 여러 개인 경우 Optional 연쇄의 부재와 실제 장애를 구분하고, 비용이 큰 대체 경로는 Supplier 경계 뒤에 둬야 합니다.