3절의 예제는 "Optional 체인으로 null 을 우회한다"에 집중했습니다. 여기서는 같은 문제를 다른 도구(중첩 if, 레코드 패턴, Map API, 기본형 Optional)로 풀어 보고, 재사용 유틸로 뽑아내고, 실제로 비용이 얼마나 드는지 재 봅니다. "Optional 이 항상 정답은 아니다"를 몸으로 익히는 것이 목표입니다.
예제 4 의 map 체인을 JDK 21 레코드 패턴(record pattern)과 전통적인 중첩 if 로 다시 씁니다. 레코드 패턴은 구조를 한 번에 분해하므로 중간 컴포넌트가 null 이면 매칭 자체가 실패해 안전하고, 필드 여러 개를 동시에 꺼낼 때 Optional 보다 짧습니다. 단계마다 변환·필터가 끼어들면 Optional 체인이, 단순 추출이면 레코드 패턴이 읽기 좋습니다.
import java.util.*;
public class ThreeWaysNested {
record Address(String city, String zip) {}
record Company(String name, Address address) {}
record Employee(String name, Company company) {}
// 방법 1: 중첩 null 검사 — 가장 원시적. 단계가 늘수록 들여쓰기가 깊어진다
static String cityByIf(Employee e) {
if (e != null) {
Company c = e.company();
if (c != null) {
Address a = c.address();
if (a != null && a.city() != null) return a.city();
}
}
return "(no city)";
}
// 방법 2: Optional.map 체인 — 어느 단계든 null 이면 empty 로 흘러감
static String cityByOptional(Employee e) {
return Optional.ofNullable(e)
.map(Employee::company)
.map(Company::address)
.map(Address::city)
.orElse("(no city)");
}
// 방법 3: JDK 21 레코드 패턴 — 구조를 한 번에 분해. 중간 컴포넌트가 null 이면 매칭 실패
static String cityByPattern(Object o) {
return o instanceof Employee(var n, Company(var cn, Address(String city, var zip))) && city != null
? city : "(no city)";
}
public static void main(String[] args) {
List<Employee> staff = Arrays.asList(
new Employee("kim", new Company("ACME", new Address("Seoul", "04524"))),
new Employee("lee", new Company("Beta", null)),
new Employee("park", null),
null,
new Employee("choi", new Company("Gamma", new Address(null, "48058"))));
System.out.printf("%-5s %-10s %-10s %s%n", "name", "if-null", "Optional", "pattern");
for (Employee e : staff) {
String name = e == null ? "null" : e.name();
System.out.printf("%-5s %-10s %-10s %s%n", name, cityByIf(e), cityByOptional(e), cityByPattern(e));
}
}
}
// 출력:
// name if-null Optional pattern
// kim Seoul Seoul Seoul
// lee (no city) (no city) (no city)
// park (no city) (no city) (no city)
// null (no city) (no city) (no city)
// choi (no city) (no city) (no city)firstPresent(Supplier<Optional<T>>...)예제 3 의 or() 체인은 소스가 세 개일 때는 좋지만, 소스 목록이 런타임에 정해지거나 여러 곳에서 같은 fallback 순서를 반복하면 유틸 메서드로 뽑는 것이 낫습니다. Supplier 로 받기 때문에 앞 소스에서 값을 찾으면 뒤 소스는 평가조차 되지 않습니다(orElseGet 과 같은 지연 원리). 주문 쿠폰 → 회원 기본 쿠폰 → 시즌 쿠폰 순으로 적용하는 예입니다.
import java.util.*;
import java.util.function.Supplier;
public class FirstPresentUtil {
// 제네릭 fallback 유틸: 순서대로 시도해 처음으로 값이 있는 Optional 을 반환. 뒤 소스는 평가하지 않음
@SafeVarargs
static <T> Optional<T> firstPresent(Supplier<Optional<T>>... sources) {
for (Supplier<Optional<T>> s : sources) {
Optional<T> o = s.get();
if (o.isPresent()) return o;
}
return Optional.empty();
}
static int calls = 0;
static Optional<String> source(String name, String value) {
calls++;
System.out.println(" " + name + " 조회");
return Optional.ofNullable(value);
}
record Order(String id, String couponCode, String memberDefaultCoupon) {}
// 주문 쿠폰 → 회원 기본 쿠폰 → 시즌 쿠폰 순으로 적용
static String resolveCoupon(Order o, Map<String, String> seasonCoupons) {
return firstPresent(
() -> source("order", o.couponCode()),
() -> source("member", o.memberDefaultCoupon()),
() -> source("season", seasonCoupons.get("2026-09"))
).orElse("NONE");
}
public static void main(String[] args) {
Map<String, String> season = Map.of("2026-09", "AUTUMN10");
List<Order> orders = List.of(
new Order("O1", "WELCOME5", "VIP20"),
new Order("O2", null, "VIP20"),
new Order("O3", null, null));
for (Order o : orders) {
calls = 0;
System.out.println(o.id() + ":");
System.out.println(" → " + resolveCoupon(o, season) + " (조회 " + calls + "회)");
}
// 시즌 쿠폰까지 없으면 empty
System.out.println(firstPresent(() -> Optional.<String>empty(), () -> Optional.<String>empty()));
}
}
// 출력:
// O1:
// order 조회
// → WELCOME5 (조회 1회)
// O2:
// order 조회
// member 조회
// → VIP20 (조회 2회)
// O3:
// order 조회
// member 조회
// season 조회
// → AUTUMN10 (조회 3회)
// Optional.emptyorElseGet vs Map.computeIfAbsent vs 네거티브 캐시"캐시에 없으면 DB 에서 읽어 넣기"는 Optional 없이 computeIfAbsent 한 줄로 끝납니다(Map API 가 이미 "없음"을 처리해 주므로). 그런데 DB 에도 없는 키는 computeIfAbsent 가 null 을 저장하지 않아 매번 DB 를 두드립니다. 이 엣지 케이스에서 Optional 을 값으로 캐시하는 네거티브 캐시가 Optional 의 드문 정당한 용도입니다.
import java.util.*;
public class CacheLookup {
static int dbLoads = 0;
static String loadFromDb(String id) { dbLoads++; return "user-" + id; }
// 방법 A: orElseGet — Optional 로 캐시 조회, 미스일 때만 DB. 캐시 저장은 직접 해야 함
static String viaOrElseGet(Map<String, String> cache, String id) {
return Optional.ofNullable(cache.get(id))
.orElseGet(() -> { String v = loadFromDb(id); cache.put(id, v); return v; });
}
// 방법 B: computeIfAbsent — Map API 가 "없으면 계산해서 넣기"를 한 번에. Optional 불필요
static String viaComputeIfAbsent(Map<String, String> cache, String id) {
return cache.computeIfAbsent(id, CacheLookup::loadFromDb);
}
// 엣지: DB 에도 없는 id. computeIfAbsent 는 null 반환 시 저장하지 않으므로 매번 DB 를 두드린다
static Optional<String> loadNullable(String id) { dbLoads++; return id.startsWith("u") ? Optional.of("user-" + id) : Optional.empty(); }
static Optional<String> viaNegativeCache(Map<String, Optional<String>> cache, String id) {
return cache.computeIfAbsent(id, CacheLookup::loadNullable); // empty 도 캐시(negative cache)
}
public static void main(String[] args) {
Map<String, String> c1 = new HashMap<>();
dbLoads = 0;
for (int i = 0; i < 3; i++) viaOrElseGet(c1, "u1");
System.out.println("orElseGet : 3회 조회, DB 로드 " + dbLoads + "회");
Map<String, String> c2 = new HashMap<>();
dbLoads = 0;
for (int i = 0; i < 3; i++) viaComputeIfAbsent(c2, "u1");
System.out.println("computeIfAbsent: 3회 조회, DB 로드 " + dbLoads + "회");
// 존재하지 않는 id 를 3번 조회
Map<String, Optional<String>> c3 = new HashMap<>();
dbLoads = 0;
for (int i = 0; i < 3; i++) viaNegativeCache(c3, "x9");
System.out.println("negative cache : 3회 조회, DB 로드 " + dbLoads + "회, 결과 " + viaNegativeCache(c3, "x9"));
Map<String, String> c4 = new HashMap<>();
dbLoads = 0;
for (int i = 0; i < 3; i++) c4.computeIfAbsent("x9", id -> loadNullable(id).orElse(null));
System.out.println("null 반환 캐시 : 3회 조회, DB 로드 " + dbLoads + "회 (null 은 저장 안 됨)");
}
}
// 출력:
// orElseGet : 3회 조회, DB 로드 1회
// computeIfAbsent: 3회 조회, DB 로드 1회
// negative cache : 3회 조회, DB 로드 1회, 결과 Optional.empty
// null 반환 캐시 : 3회 조회, DB 로드 3회 (null 은 저장 안 됨)실무 정산 파일에는 깨진 줄, 비어 있는 선택 컬럼(취소 금액, 수수료율)이 섞여 있습니다. 파서가 Optional<Settlement> 을 돌려주면 flatMap(Optional::stream) 으로 유효 행만 자연스럽게 남고, 금액 통계는 mapToLong 으로 내려가 OptionalLong/OptionalDouble 을 씁니다.
groupingBy + maxBy 가 돌려주는 Optional 은 그룹이 절대 비지 않으므로 orElseThrow() 로 바로 꺼내도 안전합니다.
import java.util.*;
import java.util.stream.*;
public class SettlementStats {
// 정산 행: 가맹점, 승인 금액, 취소 금액(없을 수 있음), 수수료율(없으면 기본)
record Settlement(String merchant, long approved, Long cancelled, Double feeRate) {
long net() { return approved - Optional.ofNullable(cancelled).orElse(0L); }
double feeRateOr(double def) { return Optional.ofNullable(feeRate).orElse(def); }
}
// 파싱: 잘못된 줄은 empty. 스트림에서 flatMap(Optional::stream) 으로 자연스럽게 제거
static Optional<Settlement> parse(String line) {
String[] f = line.split(",", -1);
if (f.length != 4 || f[0].isBlank()) return Optional.empty();
try {
return Optional.of(new Settlement(f[0], Long.parseLong(f[1]),
f[2].isBlank() ? null : Long.parseLong(f[2]),
f[3].isBlank() ? null : Double.parseDouble(f[3])));
} catch (NumberFormatException e) { return Optional.empty(); }
}
public static void main(String[] args) {
List<String> lines = List.of(
"M-01,120000,,0.03", "M-02,80000,5000,", "bad line", "M-03,abc,,0.02",
"M-01,30000,30000,0.03", ",1000,,", "M-04,50000,,0.025");
List<Settlement> rows = lines.stream().map(SettlementStats::parse).flatMap(Optional::stream).toList();
System.out.println("유효 " + rows.size() + " / 전체 " + lines.size());
// 기본형 Optional: 박싱 없이 통계. 빈 스트림이면 OptionalLong.empty
LongSummaryStatistics st = rows.stream().mapToLong(Settlement::net).summaryStatistics();
OptionalDouble avgNet = rows.stream().mapToLong(Settlement::net).average();
OptionalLong maxNet = rows.stream().mapToLong(Settlement::net).max();
System.out.printf("순매출 합계 %,d 평균 %.1f 최대 %d%n", st.getSum(), avgNet.orElse(0), maxNet.orElse(-1));
// 수수료: 없는 행은 기본 2% 적용
long fee = Math.round(rows.stream().mapToDouble(s -> s.net() * s.feeRateOr(0.02)).sum());
System.out.println("수수료 합계 " + fee);
// 가맹점별 최대 순매출 — groupingBy + maxBy 는 Optional 을 돌려주므로 orElseThrow 로 꺼냄 (그룹은 절대 비지 않음)
Map<String, Long> maxByMerchant = rows.stream().collect(Collectors.groupingBy(Settlement::merchant, TreeMap::new,
Collectors.collectingAndThen(Collectors.maxBy(Comparator.comparingLong(Settlement::net)), o -> o.orElseThrow().net())));
System.out.println("가맹점별 최대 순매출 " + maxByMerchant);
// 엣지: 빈 입력
OptionalLong none = Stream.<Settlement>empty().mapToLong(Settlement::net).max();
System.out.println("빈 입력 최대 = " + none + ", isEmpty=" + none.isEmpty());
}
}
// 출력:
// 유효 4 / 전체 7
// 순매출 합계 245,000 평균 61250.0 최대 120000
// 수수료 합계 6350
// 가맹점별 최대 순매출 {M-01=120000, M-02=75000, M-04=50000}
// 빈 입력 최대 = OptionalLong.empty, isEmpty=true2.3 절에서 "Optional 은 객체 하나를 추가로 할당한다"고 했습니다. 실제로 얼마나 차이 나는지 같은 로직을 두 방식으로 2백만 번 호출해 잽니다. JIT 의 탈출 분석(escape analysis)이 짧은 체인의 할당을 제거하기도 해서 차이는 보통 1.5~2배 안팎이며, 대부분의 서비스 코드에서는 무시해도 됩니다. 단 초당 수백만 번 도는 파싱·직렬화 루프라면 측정 후 null 검사로 돌아가는 것이 맞습니다.
import java.util.*;
public class OptionalBench {
record User(String name, String email) {}
static String domainNullCheck(User u) {
if (u == null) return "-";
String e = u.email();
if (e == null) return "-";
return e.substring(e.indexOf('@') + 1);
}
static String domainOptional(User u) {
return Optional.ofNullable(u).map(User::email).map(e -> e.substring(e.indexOf('@') + 1)).orElse("-");
}
static long runNull(User[] users, int loops) {
long h = 0;
for (int i = 0; i < loops; i++) for (User u : users) h += domainNullCheck(u).length();
return h;
}
static long runOptional(User[] users, int loops) {
long h = 0;
for (int i = 0; i < loops; i++) for (User u : users) h += domainOptional(u).length();
return h;
}
public static void main(String[] args) {
User[] users = new User[1_000];
for (int i = 0; i < users.length; i++)
users[i] = i % 10 == 0 ? null : new User("u" + i, i % 3 == 0 ? null : "u" + i + "@x.com");
int loops = 2_000; // 라운드당 2백만 호출
runNull(users, loops); runOptional(users, loops); // 워밍업(JIT 컴파일)
long bestNull = Long.MAX_VALUE, bestOpt = Long.MAX_VALUE, h1 = 0, h2 = 0;
for (int round = 0; round < 5; round++) { // 5 라운드 중 최소값 (노이즈 완화)
long t0 = System.nanoTime();
h1 = runNull(users, loops);
long t1 = System.nanoTime();
h2 = runOptional(users, loops);
long t2 = System.nanoTime();
bestNull = Math.min(bestNull, t1 - t0);
bestOpt = Math.min(bestOpt, t2 - t1);
}
System.out.println("결과 동일: " + (h1 == h2));
System.out.printf("null 검사 : %d ms%n", bestNull / 1_000_000);
System.out.printf("Optional : %d ms%n", bestOpt / 1_000_000);
System.out.printf("비율 : %.1fx%n", (double) bestOpt / bestNull);
}
}
// 출력 (시간 값은 환경·JIT 상태에 따라 크게 다름. 여러 번 실행해 경향만 볼 것):
// 결과 동일: true
// null 검사 : 145 ms
// Optional : 217 ms
// 비율 : 1.5x