orElse와 orElseGet의 차이
값이 있어도 계산되는 orElse의 인수와 비어 있을 때만 호출되는 orElseGet의 Supplier를 실행 결과로 비교하고, map·flatMap 연쇄와 or·orElseThrow로 여러 단계의 조회를 처리합니다.
앞 글에서 메서드 인수는 호출 전에 모두 계산되고, Supplier를 넘기면 받는 쪽이 get()을 부를 때까지 계산이 미뤄진다는 것을 확인했습니다. Optional의 메서드도 평범한 Java 메서드이므로 이 규칙에서 벗어나지 않습니다. orElse는 값을 받고 orElseGet은 Supplier를 받으니, 두 메서드는 기본값을 만드는 시점이 다를 수밖에 없습니다.
이 글에서는 그 차이를 실행 결과로 확인합니다. 이어서 주소와 배송처럼 여러 단계를 거치는 조회를 map과 flatMap으로 잇고, 다른 Optional로 대체하는 or와 값이 반드시 있어야 할 때 쓰는 orElseThrow까지 살펴봅니다.
값이 있는데도 실행되는 기본값 조회
아래 코드의 Optional에는 이미 주소 Seoul이 들어 있습니다. 주소가 없을 때를 대비해 orElse에 기본 주소를 불러오는 메서드를 넘겼고, 그 메서드가 몇 번 실행되는지 queries로 셉니다.
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";
}
}address=Seoul, wrong-queries=1Optional에 값이 있으므로 orElse는 그 값을 돌려주고, 결과도 Seoul입니다. 그런데 기본 주소를 불러오는 loadDefaultAddress가 한 번 실행됐습니다. loadDefaultAddress(queries)는 orElse의 인수 식이므로 orElse가 호출되기 전에 계산되기 때문입니다. orElse는 이미 만들어진 "Default"를 받았다가, 값이 있으니 그냥 버립니다. 이 메서드가 실제로 데이터베이스를 조회했다면, 아무도 쓰지 않을 조회가 주소를 읽을 때마다 한 번씩 일어났을 것입니다. Optional의 메서드라고 해서 인수를 늦게 계산해 주지는 않습니다.
orElse와 orElseGet의 실행 과정 비교
두 메서드를 한 프로그램에서 나란히 실행해 보겠습니다. 기본값을 만드는 make는 호출될 때마다 넘겨받은 카운터를 하나 늘리고, 받은 문자열을 그대로 돌려줍니다. orElse에 쓰는 카운터 eager와 orElseGet에 쓰는 카운터 lazy는 따로 두었습니다.
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;
}
}value,value,needed
eager=1, lazy=1Optional.<String>empty()는 빈 Optional의 타입이 Optional<String>임을 직접 적어 준 것입니다. 이렇게 하지 않고 이어서 메서드를 호출하면 컴파일러가 타입을 Optional<Object>로 추론해 버려서, orElseGet의 결과를 String 변수에 담을 수 없습니다. 세 호출이 무엇을 실행했는지 단계별로 보면 다음과 같습니다.
세 Optional 호출에서 실제로 평가하는 식
값이 있는 orElse는 make를 먼저 호출하고 orElseGet은 생략하며 empty에서만 get을 호출합니다. 세 호출의 누적 eager=1/lazy=1을 실행 전후로 보여 줄 수 있습니다.
세 Optional 호출에서 실제로 평가하는 식
각 호출의 실행과 반환
세 Optional 호출에서 실제로 평가하는 식
값이 있는 orElse도 make를 실행합니다. orElseGet은 빈 상태인 세 번째 호출에서만 공급자 본문을 실행합니다.
첫째·값 있음
- 대체 계산
- orElse 인수 make 실행; eager +1
- 반환
- value
둘째·값 있음
- 대체 계산
- orElseGet 공급자 미호출; lazy +0
- 반환
- value
셋째·빈 상태
- 대체 계산
- orElseGet 공급자 호출; lazy +1
- 반환
- needed
OrElseAndOrElseGetTrace를 순서대로 읽으면 최종 카운터는 eager=1, lazy=1입니다. 이 횟수는 각 호출의 분기에서 도출한 것이며 다음 호출까지 값을 캐시한다는 뜻은 아닙니다.
이 예의 조건
- 원 main의 세 호출 순서를 유지한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 main의 세 호출을 각 입력 상태→평가 동작→반환의 9개 노드와 6개 관계로 펼쳐 보인다. desktop의 각 열/mobile의 각 3개 노드는 한 호출이고 원 실행 순서는 첫째, 둘째, 셋째다. 서로 다른 eager와 lazy counter를 합산하거나 자동 캐시로 보지 않으며 원 최종 eager 1/lazy 1을 보존한다. |
| 20 | 최종 eager 1 · lazy 1 |
| 구분 | 내용 |
|---|---|
| 설명 | 원 main의 세 호출을 각 입력 상태→평가 동작→반환의 9개 노드와 6개 관계로 펼쳐 보인다. desktop의 각 열/mobile의 각 3개 노드는 한 호출이고 원 실행 순서는 첫째, 둘째, 셋째다. 서로 다른 eager와 lazy counter를 합산하거나 자동 캐시로 보지 않으며 원 최종 eager 1/lazy 1을 보존한다. |
| 전체 읽기 정보 | 세 Optional 호출에서 실제로 평가하는 식 세 Optional 호출에서 실제로 평가하는 식 세 Optional 호출에서 실제로 평가하는 식 각 호출의 실행과 반환 세 Optional 호출에서 실제로 평가하는 식 값이 있는 orElse도 make를 실행합니다. orElseGet은 빈 상태인 세 번째 호출에서만 공급자 본문을 실행합니다. 첫째·값 있음 대체 계산 orElse 인수 make 실행; eager +1 반환 value 둘째·값 있음 대체 계산 orElseGet 공급자 미호출; lazy +0 반환 value 셋째·빈 상태 대체 계산 orElseGet 공급자 호출; lazy +1 반환 needed OrElseAndOrElseGetTrace를 순서대로 읽으면 최종 카운터는 eager=1, lazy=1입니다. 이 횟수는 각 호출의 분기에서 도출한 것이며 다음 호출까지 값을 캐시한다는 뜻은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| orElse 값 인수 값이 있어도 make(eager)를 먼저 계산합니다. |
|
|
| orElseGet 값 있음 Supplier를 실행하지 않습니다. |
|
|
| orElseGet empty Supplier.get으로 needed를 만듭니다. |
|
|
세 Optional 호출에서 실제로 평가하는 식
값이 있는 orElse는 make를 먼저 호출하고 orElseGet은 생략하며 empty에서만 get을 호출합니다. 세 호출의 누적 eager=1/lazy=1을 실행 전후로 보여 줄 수 있습니다.
세 Optional 호출에서 실제로 평가하는 식
각 호출의 실행과 반환
세 Optional 호출에서 실제로 평가하는 식
값이 있는 orElse도 make를 실행합니다. orElseGet은 빈 상태인 세 번째 호출에서만 공급자 본문을 실행합니다.
첫째·값 있음
- 대체 계산
- orElse 인수 make 실행; eager +1
- 반환
- value
둘째·값 있음
- 대체 계산
- orElseGet 공급자 미호출; lazy +0
- 반환
- value
셋째·빈 상태
- 대체 계산
- orElseGet 공급자 호출; lazy +1
- 반환
- needed
OrElseAndOrElseGetTrace를 순서대로 읽으면 최종 카운터는 eager=1, lazy=1입니다. 이 횟수는 각 호출의 분기에서 도출한 것이며 다음 호출까지 값을 캐시한다는 뜻은 아닙니다.
이 예의 조건
- 원 main의 세 호출 순서를 유지한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 main의 세 호출을 각 입력 상태→평가 동작→반환의 9개 노드와 6개 관계로 펼쳐 보인다. desktop의 각 열/mobile의 각 3개 노드는 한 호출이고 원 실행 순서는 첫째, 둘째, 셋째다. 서로 다른 eager와 lazy counter를 합산하거나 자동 캐시로 보지 않으며 원 최종 eager 1/lazy 1을 보존한다. |
| 20 | 최종 eager 1 · lazy 1 |
| 구분 | 내용 |
|---|---|
| 설명 | 원 main의 세 호출을 각 입력 상태→평가 동작→반환의 9개 노드와 6개 관계로 펼쳐 보인다. desktop의 각 열/mobile의 각 3개 노드는 한 호출이고 원 실행 순서는 첫째, 둘째, 셋째다. 서로 다른 eager와 lazy counter를 합산하거나 자동 캐시로 보지 않으며 원 최종 eager 1/lazy 1을 보존한다. |
| 전체 읽기 정보 | 세 Optional 호출에서 실제로 평가하는 식 세 Optional 호출에서 실제로 평가하는 식 세 Optional 호출에서 실제로 평가하는 식 각 호출의 실행과 반환 세 Optional 호출에서 실제로 평가하는 식 값이 있는 orElse도 make를 실행합니다. orElseGet은 빈 상태인 세 번째 호출에서만 공급자 본문을 실행합니다. 첫째·값 있음 대체 계산 orElse 인수 make 실행; eager +1 반환 value 둘째·값 있음 대체 계산 orElseGet 공급자 미호출; lazy +0 반환 value 셋째·빈 상태 대체 계산 orElseGet 공급자 호출; lazy +1 반환 needed OrElseAndOrElseGetTrace를 순서대로 읽으면 최종 카운터는 eager=1, lazy=1입니다. 이 횟수는 각 호출의 분기에서 도출한 것이며 다음 호출까지 값을 캐시한다는 뜻은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| orElse 값 인수 값이 있어도 make(eager)를 먼저 계산합니다. |
|
|
| orElseGet 값 있음 Supplier를 실행하지 않습니다. |
|
|
| orElseGet empty Supplier.get으로 needed를 만듭니다. |
|
|
| 호출 | Optional 상태 | 기본값을 만드는 식 | make 실행 | 결과 | 카운터 |
|---|---|---|---|---|---|
first | 값 있음 | orElse(make("eager", eager)) | 인수라서 호출 전에 실행 | value | eager 1 |
second | 값 있음 | orElseGet(() -> make("lazy", lazy)) | get()을 부르지 않아 실행 안 됨 | value | lazy 0 |
third | 비어 있음 | orElseGet(() -> make("needed", lazy)) | get()을 불러서 실행 | needed | lazy 1 |
결과 문자열만 보면 첫째와 둘째는 똑같이 value이고, 차이는 카운터에만 남습니다. 첫째 호출의 make("eager", eager)는 값이 있는데도 실행되어 eager를 1로 만들었고, 그렇게 만든 문자열 eager는 어디에도 쓰이지 않았습니다. 둘째 호출에서는 람다식을 계산해 Supplier 객체를 넘겼을 뿐이고, 값이 있으니 orElseGet이 get()을 부르지 않았습니다. 셋째 호출은 Optional이 비어 있어서 orElseGet이 get()을 불렀고, 그제야 make가 실행되어 lazy가 1이 됐습니다. 반환값만 검사하는 테스트로는 첫째 호출의 쓸데없는 실행을 잡아낼 수 없다는 점도 기억해 두세요.
Optional의 Javadoc도 이 차이를 그대로 설명합니다. orElse(other)는 '값이 있으면 그 값을, 없으면 other를 돌려준다'고 되어 있고, orElseGet(supplier)는 '값이 없으면 공급 함수가 만든 결과를 돌려준다'고 되어 있습니다. other는 호출하기 전에 이미 계산된 값이고, 공급 함수는 값이 없을 때만 호출됩니다.
그렇다면 언제 무엇을 쓸까요? 기본값이 리터럴, 상수, 이미 계산해 둔 지역 변수처럼 값싼 값이라면 orElse가 짧고 읽기 쉽습니다. orElse("unknown")에서 "unknown"을 미리 계산해도 드는 비용은 사실상 없습니다. 반대로 기본값을 만드는 데 데이터베이스 조회, 파일 읽기, 객체 생성, 로그 기록처럼 비용이나 부수 효과가 따른다면 orElseGet을 씁니다. 값싼 상수까지 orElseGet(() -> "unknown")으로 쓰는 것도 틀리지는 않지만, 코드만 길어질 뿐 얻는 것은 거의 없습니다. 참고로 orElse(null)은 Optional의 값을 null을 기대하는 기존 코드에 넘겨야 할 때 쓰는 표현입니다.
여러 단계의 조회를 map과 flatMap으로 잇기
실제 조회는 한 단계로 끝나지 않는 경우가 많습니다. 회원을 찾고, 그 회원의 주소 ID로 주소를 찾는 경우를 보겠습니다. 회원이 없을 수 있고, 회원은 있지만 addressId가 null일 수 있으며(주소를 입력하지 않은 채 가입한 오래된 데이터라고 합시다), 주소 저장소에 그 ID가 없을 수도 있습니다.
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회원 1은 ofNullable로 감싼 회원에서 map(User::addressId)가 100을 꺼내고, flatMap이 주소 저장소에서 Busan 주소를 찾습니다. 회원 2는 addressId가 null입니다. map은 함수가 null을 돌려주면 빈 Optional을 만들므로 여기서 값이 사라지고, flatMap의 람다는 호출되지 않은 채 마지막 orElse가 unknown을 고릅니다. 회원 자체가 없는 경우(예: findAddress(4))나 주소 저장소에 없는 ID를 가진 경우도 마찬가지로 unknown이 됩니다.
flatMap을 쓴 이유는 람다가 Optional.ofNullable(...), 즉 Optional<Address>를 돌려주기 때문입니다. 사실 이 경우에는 map(ADDRESSES::get)이라고 써도 결과가 같습니다. Map.get이 돌려주는 null을 map이 빈 Optional로 바꿔 주기 때문입니다. 하지만 주소 저장소가 처음부터 Optional<Address>를 돌려주는 메서드라면 그때는 flatMap이 꼭 필요합니다. 함수가 값을 돌려주면 map, Optional을 돌려주면 flatMap이라는 이 장 첫 글의 규칙 그대로입니다.
이 예제는 메모리의 Map을 조회하므로 저장소 장애가 생길 일이 없습니다. 조회 대상을 실제 데이터베이스로 바꾸더라도 이 연쇄는 예외를 잡지 않으므로, 장애가 빈 Optional로 바뀌어 unknown 뒤에 숨지는 않습니다. Optional 연쇄가 나타내는 것은 정상적인 '없음'뿐입니다.
null 검사와 Optional 연쇄 중 무엇을 쓸까
같은 조회를 if와 null 검사로 쓰면 다음과 같습니다. Map.get은 키가 없으면 null을 돌려주므로, 단계마다 결과가 null인지 확인하고 그때마다 null을 돌려줍니다.
static Address findAddressNullable(long userId) {
User user = USERS.get(userId);
if (user == null) {
return null;
}
Long addressId = user.addressId();
if (addressId == null) {
return null;
}
return ADDRESSES.get(addressId);
}이 방식은 단계마다 따로 손을 쓸 수 있다는 장점이 있습니다. 회원이 없을 때와 주소 ID가 없을 때 서로 다른 기본값을 쓰거나 다른 로그를 남겨야 한다면, 이렇게 명시적으로 검사하는 편이 오히려 읽기 쉽습니다. 반대로 어느 단계에서 비든 똑같이 처리하고 마지막에 한 번만 기본값을 정하면 된다면 Optional 연쇄가 간결합니다. 게다가 연쇄는 Optional을 돌려주므로, 결과가 없을 수 있다는 사실이 호출하는 쪽에도 그대로 전달됩니다.
연쇄가 너무 길어진다면 중간 단계를 이름 있는 메서드로 나눕니다. 또 Optional을 객체를 따라가며 탐색하는 만능 도구로 쓰지는 않습니다. 새로 설계하는 모델이라면 주소가 필수인지 선택인지를 생성자와 타입으로 표현할 수 있으므로, 처음부터 null이 될 수 있는 ID 필드에 기댈 이유가 없습니다. Optional이 가장 큰 효과를 내는 곳은 저장소 조회처럼 값이 없을 수 있는 결과가 프로그램 안으로 들어오는 입구입니다.
배송 조회: 비용이 드는 기본값에는 orElseGet
이번에는 주문의 배송 상태를 보여 주는 기능입니다. 주문은 있지만 배송이 아직 만들어지지 않았을 수 있습니다. 배송이 있으면 추적 번호를, 없으면 '배송 대기' 문구를 돌려줍니다. 대기 문구를 만드는 pendingLabel은 호출될 때마다 fallbackLoads를 늘립니다.
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());
}
}T-100
PENDING
fallback-loads=1주문 1은 배송 ID 10으로 배송을 찾아 추적 번호 T-100을 돌려주고, 이때 pendingLabel은 호출되지 않습니다. 주문 2는 배송이 아직 없어 deliveryId가 null이므로 첫 map에서 비고, 그제야 orElseGet이 pendingLabel을 호출해 PENDING을 만듭니다. 그래서 마지막 줄이 fallback-loads=1입니다. this::pendingLabel은 () -> pendingLabel()과 같은 뜻의 메서드 참조이며, 그 자체로 Supplier<String>이 됩니다.
여기서 pendingLabel은 문자열 하나를 돌려줄 뿐이지만, 실제 서비스라면 사용자의 언어에 맞는 안내 문구를 불러오는 것처럼 비용이 드는 작업일 수 있습니다. 그런 경우를 생각해 orElseGet을 썼고, 기본값이 정말 상수 하나라면 orElse("PENDING")으로 충분합니다. 또 이 연쇄에는 한 번 만든 PENDING을 기억해 두는 장치가 없습니다. 주문 2를 다시 조회하면 pendingLabel이 또 호출되어 횟수가 2가 되고, 주문 1을 다시 조회하면 T-100을 돌려줄 뿐 횟수는 늘지 않습니다.
정해야 할 것이 하나 더 있습니다. 지금 코드는 존재하지 않는 주문(예: 주문 3)에도 PENDING을 돌려줍니다. 없는 주문을 '배송 대기'로 보여 줘도 되는지, NOT_FOUND처럼 따로 구분해야 하는지는 코드가 아니라 서비스의 규칙으로 정할 문제입니다. 구분해야 한다면 주문 조회와 배송 조회를 하나의 연쇄로 합치지 말고, 주문이 있는지부터 따로 확인해야 합니다.
Optional.or로 다른 조회를 대체 경로로 쓰기
지금까지의 기본값은 모두 값 하나였습니다. 그런데 대체할 대상이 또 다른 조회, 즉 또 다른 Optional일 때도 있습니다. 캐시에서 먼저 찾고 없으면 데이터베이스에서 찾는 경우가 그렇습니다. 이럴 때는 Java 9에 추가된 or를 씁니다.
Optional<Profile> profile = findInCache(id).or(() -> findInDatabase(id));or는 원래 Optional에 값이 있으면 그것을 그대로 돌려주고, 공급 함수는 호출하지 않습니다. 비어 있을 때만 공급 함수를 호출해, 그 함수가 돌려준 Optional을 결과로 씁니다. 그래서 위 코드는 캐시에 있으면 데이터베이스를 조회하지 않습니다. 공급 함수는 반드시 Optional 객체를 돌려줘야 하며, null을 돌려주면 NullPointerException이 납니다.
orElseGet과 비교하면 쓰임새가 분명해집니다. orElseGet은 T 값을 돌려주므로 Optional이 거기서 끝나지만, or는 Optional<T>를 돌려주므로 뒤에 map이나 또 다른 or, 마지막의 orElse를 계속 이어 붙일 수 있습니다. 보조 조회가 실패했을 때 그 예외를 잡아 빈 Optional로 바꾸지 않는 것은 앞에서 말한 원칙 그대로입니다. 연습 문제 2에서 or를 직접 써 봅니다.
orElseThrow로 반드시 있어야 하는 값 꺼내기
배송을 시작하는 명령처럼 주소가 반드시 있어야 작업을 시작할 수 있는 곳도 있습니다. 여기서 주소가 없다는 것은 정상적인 결과가 아니라 처리할 수 없는 요청이므로, 기본값으로 덮지 말고 예외로 알려야 합니다. 조회 화면에서 주소 자리에 '알 수 없음'을 보여 주는 것과는 요구 사항이 다릅니다. 이럴 때 orElseThrow에 예외를 만드는 람다를 넘깁니다.
import java.util.Map;
import java.util.Optional;
public final class RequiredAddressCheck {
private record Address(long id, String city) {}
static final class MissingAddressException extends RuntimeException {
MissingAddressException(long userId) {
super("address required for user " + userId);
}
}
private static final Map<Long, Address> ADDRESS_BY_USER = Map.of(1L, new Address(100, "Busan"));
static Optional<Address> findAddress(long userId) {
return Optional.ofNullable(ADDRESS_BY_USER.get(userId));
}
static String startDelivery(long userId) {
Address address = findAddress(userId).orElseThrow(() -> new MissingAddressException(userId));
return "delivery to " + address.city();
}
public static void main(String[] args) {
System.out.println(startDelivery(1));
System.out.println(startDelivery(2));
}
}delivery to Busan
Exception in thread "main" RequiredAddressCheck$MissingAddressException: address required for user 2
at RequiredAddressCheck.lambda$startDelivery$0(RequiredAddressCheck.java:20)
at java.base/java.util.Optional.orElseThrow(Optional.java:403)
at RequiredAddressCheck.startDelivery(RequiredAddressCheck.java:20)
at RequiredAddressCheck.main(RequiredAddressCheck.java:26)회원 1은 주소가 있으므로 orElseThrow가 주소를 돌려주고, 람다는 호출되지 않습니다. 회원 2는 주소가 없어서 orElseThrow가 람다를 호출하고, 람다가 만든 MissingAddressException을 던집니다. 스택 트레이스에서 예외 객체가 람다 본문(lambda$startDelivery$0) 안에서 만들어졌고, 그 람다를 orElseThrow가 호출했다는 것을 확인할 수 있습니다. Optional.java:403 같은 JDK 내부 줄 번호는 버전마다 다를 수 있습니다.
orElseThrow도 예외를 만드는 일을 Supplier로 받으므로, 값이 있을 때는 예외 객체도 메시지 문자열도 만들지 않습니다. 이렇게 쓰면 '이 작업에는 주소가 반드시 있어야 한다'는 조건이 코드에 그대로 드러납니다. 예외 타입은 호출한 쪽이 무엇이 잘못됐는지, 다시 시도할 만한지를 판단할 수 있도록 상황이 드러나는 이름으로 고릅니다. 메시지에는 어떤 회원의 주소가 없었는지처럼 원인을 찾는 데 필요한 정보를 담습니다.
정리
orElse(other)의other는 평범한 인수이므로,Optional에 값이 있어도orElse를 호출하기 전에 계산됩니다.orElseGet(supplier)는 비어 있을 때만get()을 불러 기본값을 만듭니다.- 두 메서드의 반환값은 같아도 기본값을 만드는 식이 실행되는지가 다릅니다. 기본값이 값싼 상수라면
orElse, 비용이나 부수 효과가 있다면orElseGet을 씁니다. - 여러 단계의 조회는 함수가 값을 돌려주면
map,Optional을 돌려주면flatMap으로 잇습니다. 어느 단계에서 비든 나머지 단계는 건너뜁니다. - 다른 조회로 대체할 때는
Optional을 돌려주는or를, 값이 반드시 있어야 할 때는orElseThrow를 씁니다. 둘 다 공급 함수를 값이 없을 때만 호출합니다. Optional연쇄는 정상적인 '없음'만 나타냅니다. 조회 중에 난 예외는 빈 결과로 바뀌지 않고 그대로 전달됩니다.
연습 문제
OrElseAndOrElseGetTrace의make가 카운터를 늘린 다음IllegalStateException을 던지도록 바꿉니다. 이때 값이 있는Optional의orElse(make(...))와orElseGet(() -> make(...))는 각각 정상적으로 값을 돌려줄까요? 빈Optional의orElseGet(() -> make(...))는 어떨까요?- 사용자의 기본 이메일
primaryEmail이 있으면 그것을 쓰고, 없으면 프로필 저장소에서 이메일을 찾으세요. 둘 다 없으면[email protected]을 돌려줍니다. 사용자는record User(Long profileId, String primaryEmail), 프로필은record Profile(long id, String email)이고,profileId와primaryEmail은null일 수 있습니다. 프로필 저장소를 찾으러 들어간 횟수를 세어, 기본 이메일이 있을 때는 0인지 확인하세요. - 2번 풀이의
email메서드에 같은profiles와 처음 0인lookups를 쓰고, 다음 세 사용자를 차례로 조회합니다. 각 조회의 결과, 누적lookups, 그리고profiles.get이 실제로 호출된 누적 횟수를 구하세요.profileId와primaryEmail이 모두null인 사용자profileId가 99이고primaryEmail이null인 사용자profileId가null이고primaryEmail이 빈 문자열""인 사용자
1번 풀이
값이 있는 orElse(make(...))는 정상적으로 값을 돌려주지 못하고 예외로 끝납니다. 인수 make(...)를 계산하다가 예외가 났으므로, orElse는 호출조차 되지 않습니다. 값이 있는 orElseGet(() -> make(...))는 get()을 부르지 않으므로 make가 실행되지 않고, 정상적으로 value를 돌려주며 카운터도 늘지 않습니다. 빈 Optional의 orElseGet은 get()을 불러 make를 실행하므로 카운터가 늘고, 같은 예외가 orElseGet을 거쳐 호출한 쪽으로 전달됩니다. 세 호출을 각각 try로 감싸 실행하면 다음과 같습니다.
first: cannot make eager, eager=1
second=value, lazy=0
third: cannot make needed, lazy=1마지막 경우에서 예외가 빈 결과나 기본값으로 바뀌지 않는다는 점에 주목하세요. 대체값을 만드는 데 실패한 것은 '값 없음'이 아니라 실패이므로 그대로 전달되는 것이 맞습니다.
2번 풀이
기본 이메일을 Optional로 감싸고, 비어 있을 때만 프로필 저장소를 찾도록 or의 공급 함수 안에 프로필 조회를 넣습니다. 공급 함수에 들어올 때마다 lookups를 늘리면, 프로필 저장소를 찾으러 간 횟수를 셀 수 있습니다. 마지막 기본 주소는 값싼 상수이므로 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());
}
}[email protected]
[email protected]
lookups=1대체 경로 카운터와 실제 Map 조회는 다르다
primaryEmail이 있으면 공급자와 Map 조회를 건너뛰고 없을 때 lookups 증가 후 ID와 프로필을 조회합니다. 사용자별 분기와 두 카운터의 의미를 같은 역할로 추적할 수 있습니다.
대체 경로 카운터와 실제 Map 조회는 다르다
경로 진입과 실제 조회
대체 경로 카운터와 실제 Map 조회는 다르다
lookups는 or 공급자 진입 횟수입니다. profileId가 없으면 카운터는 증가해도 profiles.get은 실행하지 않습니다.
기본 이메일 있음
- lookups 증가 / Map.get 호출
- 0 / 0회
- 반환
- 기본 이메일
기본 없음·프로필 10
- lookups 증가 / Map.get 호출
- 1 / 1회
기본 없음·프로필 ID 없음
- lookups 증가 / Map.get 호출
- 1 / 0회
앞의 두 행은 main의 입력입니다. 마지막 행은 원문에 호출을 추가하지 않고 null profileId 분기를 읽은 비교입니다. lookups는 or 공급자 진입 횟수를 셉니다.
이 예의 조건
- primary의 빈 String도 값 있음이며 원 main의 다른 경로는 코드 조건입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 LazyEmailFallbackSolution의 primary 값 분기는 기본 이메일을 반환하고 부재 분기는 먼저 lookups를 증가시킨 뒤 profileId를 확인한다. ID가 없으면 Map.get을 생략하고 기본 anonymous 주소를 선택하며 ID가 있으면 저장소 조회 후 프로필 이메일 또는 미적중 시 anonymous를 반환한다. 7개 노드와 7개 관계는 mobile도 동일하다. primary의 빈 String도 값이 있는 상태이다. 원 main은 두 입력만 호출하며 다른 경로는 코드 조건에서 추론한 것이다. |
| 14 | ID 10 원 예제 |
| 구분 | 내용 |
|---|---|
| 설명 | 원 LazyEmailFallbackSolution의 primary 값 분기는 기본 이메일을 반환하고 부재 분기는 먼저 lookups를 증가시킨 뒤 profileId를 확인한다. ID가 없으면 Map.get을 생략하고 기본 anonymous 주소를 선택하며 ID가 있으면 저장소 조회 후 프로필 이메일 또는 미적중 시 anonymous를 반환한다. 7개 노드와 7개 관계는 mobile도 동일하다. primary의 빈 String도 값이 있는 상태이다. 원 main은 두 입력만 호출하며 다른 경로는 코드 조건에서 추론한 것이다. |
| 전체 읽기 정보 | 대체 경로 카운터와 실제 Map 조회는 다르다 대체 경로 카운터와 실제 Map 조회는 다르다 대체 경로 카운터와 실제 Map 조회는 다르다 경로 진입과 실제 조회 대체 경로 카운터와 실제 Map 조회는 다르다 lookups는 or 공급자 진입 횟수입니다. profileId가 없으면 카운터는 증가해도 profiles.get은 실행하지 않습니다. 기본 이메일 있음 lookups 증가 / Map.get 호출 0 / 0회 반환 기본 이메일 기본 없음·프로필 10 lookups 증가 / Map.get 호출 1 / 1회 반환 [email protected] 기본 없음·프로필 ID 없음 lookups 증가 / Map.get 호출 1 / 0회 반환 [email protected] 앞의 두 행은 main의 입력입니다. 마지막 행은 원문에 호출을 추가하지 않고 null profileId 분기를 읽은 비교입니다. lookups는 or 공급자 진입 횟수를 셉니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| primary 존재 값이 있으면 기본 이메일을 그대로 반환합니다. |
|
|
| 공급자 진입 primary 부재이면 lookups를 먼저 늘리고 ID를 확인합니다. |
|
|
| ID 조회 ID가 있으면 Map.get으로 프로필 이메일을 찾습니다. |
|
|
대체 경로 카운터와 실제 Map 조회는 다르다
primaryEmail이 있으면 공급자와 Map 조회를 건너뛰고 없을 때 lookups 증가 후 ID와 프로필을 조회합니다. 사용자별 분기와 두 카운터의 의미를 같은 역할로 추적할 수 있습니다.
대체 경로 카운터와 실제 Map 조회는 다르다
경로 진입과 실제 조회
대체 경로 카운터와 실제 Map 조회는 다르다
lookups는 or 공급자 진입 횟수입니다. profileId가 없으면 카운터는 증가해도 profiles.get은 실행하지 않습니다.
기본 이메일 있음
- lookups 증가 / Map.get 호출
- 0 / 0회
- 반환
- 기본 이메일
기본 없음·프로필 10
- lookups 증가 / Map.get 호출
- 1 / 1회
기본 없음·프로필 ID 없음
- lookups 증가 / Map.get 호출
- 1 / 0회
앞의 두 행은 main의 입력입니다. 마지막 행은 원문에 호출을 추가하지 않고 null profileId 분기를 읽은 비교입니다. lookups는 or 공급자 진입 횟수를 셉니다.
이 예의 조건
- primary의 빈 String도 값 있음이며 원 main의 다른 경로는 코드 조건입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 LazyEmailFallbackSolution의 primary 값 분기는 기본 이메일을 반환하고 부재 분기는 먼저 lookups를 증가시킨 뒤 profileId를 확인한다. ID가 없으면 Map.get을 생략하고 기본 anonymous 주소를 선택하며 ID가 있으면 저장소 조회 후 프로필 이메일 또는 미적중 시 anonymous를 반환한다. 7개 노드와 7개 관계는 mobile도 동일하다. primary의 빈 String도 값이 있는 상태이다. 원 main은 두 입력만 호출하며 다른 경로는 코드 조건에서 추론한 것이다. |
| 14 | ID 10 원 예제 |
| 구분 | 내용 |
|---|---|
| 설명 | 원 LazyEmailFallbackSolution의 primary 값 분기는 기본 이메일을 반환하고 부재 분기는 먼저 lookups를 증가시킨 뒤 profileId를 확인한다. ID가 없으면 Map.get을 생략하고 기본 anonymous 주소를 선택하며 ID가 있으면 저장소 조회 후 프로필 이메일 또는 미적중 시 anonymous를 반환한다. 7개 노드와 7개 관계는 mobile도 동일하다. primary의 빈 String도 값이 있는 상태이다. 원 main은 두 입력만 호출하며 다른 경로는 코드 조건에서 추론한 것이다. |
| 전체 읽기 정보 | 대체 경로 카운터와 실제 Map 조회는 다르다 대체 경로 카운터와 실제 Map 조회는 다르다 대체 경로 카운터와 실제 Map 조회는 다르다 경로 진입과 실제 조회 대체 경로 카운터와 실제 Map 조회는 다르다 lookups는 or 공급자 진입 횟수입니다. profileId가 없으면 카운터는 증가해도 profiles.get은 실행하지 않습니다. 기본 이메일 있음 lookups 증가 / Map.get 호출 0 / 0회 반환 기본 이메일 기본 없음·프로필 10 lookups 증가 / Map.get 호출 1 / 1회 반환 [email protected] 기본 없음·프로필 ID 없음 lookups 증가 / Map.get 호출 1 / 0회 반환 [email protected] 앞의 두 행은 main의 입력입니다. 마지막 행은 원문에 호출을 추가하지 않고 null profileId 분기를 읽은 비교입니다. lookups는 or 공급자 진입 횟수를 셉니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| primary 존재 값이 있으면 기본 이메일을 그대로 반환합니다. |
|
|
| 공급자 진입 primary 부재이면 lookups를 먼저 늘리고 ID를 확인합니다. |
|
|
| ID 조회 ID가 있으면 Map.get으로 프로필 이메일을 찾습니다. |
|
|
첫 사용자는 기본 이메일이 있으므로 or가 공급 함수를 호출하지 않고, lookups도 0에 머뭅니다. 둘째 사용자는 기본 이메일이 없어서 공급 함수에 들어가 lookups가 1이 되고, 프로필 ID 10으로 profiles.get을 호출해 [email protected]을 찾습니다. 그래서 마지막 줄이 lookups=1입니다.
lookups가 세는 것은 공급 함수에 들어간 횟수이지 profiles.get을 호출한 횟수가 아니라는 점에 주의하세요. 공급 함수에 들어가도 profileId가 null이면 ofNullable에서 이미 비므로 profiles.get은 호출되지 않습니다. 또 이 연쇄는 저장소에 없는 프로필 ID와 이메일이 null인 프로필을 구분하지 않고, 둘 다 map에서 비게 되어 기본 주소를 돌려줍니다.
3번 풀이
차례로 다음과 같습니다. profiles.get의 호출 횟수는 map(profiles::get) 대신 횟수를 세는 람다를 넣어 확인했습니다.
| 사용자 | 결과 | 누적 lookups | 누적 profiles.get 호출 |
|---|---|---|---|
profileId, primaryEmail 모두 null | [email protected] | 1 | 0 |
profileId 99, primaryEmail null | [email protected] | 2 | 1 |
profileId null, primaryEmail "" | 빈 문자열 | 2 | 1 |
첫 사용자는 기본 이메일이 없어 공급 함수에 들어가므로 lookups가 1이 되지만, profileId가 null이라 ofNullable에서 비어 profiles.get은 호출되지 않습니다. 둘째 사용자도 공급 함수에 들어가 lookups가 2가 되고, 이번에는 ID 99로 profiles.get을 한 번 호출합니다. 저장소에 99가 없어 null이 돌아오고 map이 이를 빈 Optional로 바꾸므로 결과는 기본 주소입니다.
셋째 사용자의 빈 문자열은 '값'입니다. Optional.ofNullable("")은 비어 있지 않으므로 or는 공급 함수를 호출하지 않고, 빈 문자열이 그대로 결과가 됩니다. 그래서 lookups와 profiles.get 횟수는 둘 다 늘지 않습니다. 빈 이메일도 '없음'으로 처리하려면 ofNullable 바로 뒤에 .filter(email -> !email.isBlank())를 넣어야 합니다.
다음 글
다음 글에서는 Optional을 필드, 매개변수, 컬렉션에 쓸 때 생기는 문제를 살펴보고, Optional을 어디에 쓰고 어디에 쓰지 않을지 정리합니다.