안동민 개발노트

본문 시작

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;
    }
}
세 Optional 호출에서 실제로 평가하는 식

세 Optional 호출에서 실제로 평가하는 식

세 Optional 호출에서 실제로 평가하는 식
원문 호출대체 계산반환
첫째·값 있음orElse 인수 make 실행; eager +1value
둘째·값 있음orElseGet 공급자 미호출; lazy +0value
셋째·빈 상태orElseGet 공급자 호출; lazy +1needed
첫째·값 있음
대체 계산: 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으로 연결합니다.

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, 둘째 조회에서 문자열 unknown을 출력합니다.

이 예제는 메모리 Map을 조회하며 저장소 장애를 발생시키는 코드는 없습니다. 조회를 외부 저장소로 바꾼 경우에도 이 연쇄는 예외를 잡지 않으므로, 발생한 예외가 빈 상태로 바뀌지는 않습니다.

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로 유지합니다.

orElseGet은 T를 반환하므로 Optional을 다시 감싸는 용도가 아닙니다.

캐시 조회 뒤 데이터베이스 조회처럼 원본의 우선순위가 있을 때 사용할 수 있습니다.

보조 조회의 실패를 빈 상태로 숨기지 말고 예외 정책을 유지합니다.


orElseThrow로 필수 관계 확인

배송 생성 명령처럼 Address가 반드시 있어야 작업을 시작할 수 있다면 orElseThrow(() -> new MissingAddressException(userId))로 불변식을 명시합니다.

단순 조회 화면에 “알 수 없음”을 표시하는 대체 경로와 명령 입력 검사의 예외는 요구 사항이 다릅니다.

Supplier를 사용하면 메시지 서식 처리와 예외 객체 생성도 값이 없는 경로에서만 수행됩니다.

예외 타입에는 호출자가 복구 여부를 판단할 수 있는 의미를 담습니다.


연습 문제

User.primaryEmail이 있으면 사용하고, 없으면 Profile 저장소에서 이메일을 찾으세요.

둘 다 없으면 [email protected]을 반환합니다.

대체 프로필 경로에 진입한 횟수를 세어 기본 이메일이 있을 때는 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("[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 조회는 다르다

대체 경로 카운터와 실제 Map 조회는 다르다

대체 경로 카운터와 실제 Map 조회는 다르다
이메일 입력 상태lookups 증가 / Map.get 호출반환
기본 이메일 있음0 / 0회기본 이메일
기본 없음·프로필 101 / 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 경계 뒤에 둬야 합니다.