공공부하자개발 · 영어 학습 노트
자바
고급모던 자바와 성능0/10 완료
  • 01제네릭과 와일드카드
  • 02멀티스레드와 동기화
  • 03람다식과 함수형 인터페이스
  • 04Stream API와 병렬 처리
  • 05Optional로 NPE 방지
  • 06메서드 활용 패턴 (고급)
  • 07Java 21 모던 문법
  • 08어노테이션·리플렉션·동적 프록시
  • 09CompletableFuture 심화와 가상 스레드 실전
  • 10JVM 메모리·GC·OOM 진단
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 고급 › 05 / 10

Optional로 NPE 방지

섹션 7진행 0 / 10
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

4. 응용 변형 예제

3절의 예제는 "Optional 체인으로 null 을 우회한다"에 집중했습니다. 여기서는 같은 문제를 다른 도구(중첩 if, 레코드 패턴, Map API, 기본형 Optional)로 풀어 보고, 재사용 유틸로 뽑아내고, 실제로 비용이 얼마나 드는지 재 봅니다. "Optional 이 항상 정답은 아니다"를 몸으로 익히는 것이 목표입니다.

변형 1: 중첩 DTO 접근을 세 가지 방식으로 — if-null vs Optional.map vs 레코드 패턴

예제 4 의 map 체인을 JDK 21 레코드 패턴(record pattern)과 전통적인 중첩 if 로 다시 씁니다. 레코드 패턴은 구조를 한 번에 분해하므로 중간 컴포넌트가 null 이면 매칭 자체가 실패해 안전하고, 필드 여러 개를 동시에 꺼낼 때 Optional 보다 짧습니다. 단계마다 변환·필터가 끼어들면 Optional 체인이, 단순 추출이면 레코드 패턴이 읽기 좋습니다.

java
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)

변형 2: 재사용 유틸로 추출 — 제네릭 firstPresent(Supplier<Optional<T>>...)

예제 3 의 or() 체인은 소스가 세 개일 때는 좋지만, 소스 목록이 런타임에 정해지거나 여러 곳에서 같은 fallback 순서를 반복하면 유틸 메서드로 뽑는 것이 낫습니다. Supplier 로 받기 때문에 앞 소스에서 값을 찾으면 뒤 소스는 평가조차 되지 않습니다(orElseGet 과 같은 지연 원리). 주문 쿠폰 → 회원 기본 쿠폰 → 시즌 쿠폰 순으로 적용하는 예입니다.

java
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.empty

변형 3: 캐시 조회 — orElseGet vs Map.computeIfAbsent vs 네거티브 캐시

"캐시에 없으면 DB 에서 읽어 넣기"는 Optional 없이 computeIfAbsent 한 줄로 끝납니다(Map API 가 이미 "없음"을 처리해 주므로). 그런데 DB 에도 없는 키는 computeIfAbsent 가 null 을 저장하지 않아 매번 DB 를 두드립니다. 이 엣지 케이스에서 Optional 을 값으로 캐시하는 네거티브 캐시가 Optional 의 드문 정당한 용도입니다.

java
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 은 저장 안 됨)

변형 4: 정산 데이터에 적용 — 파싱 실패 걸러내기와 기본형 Optional 통계

실무 정산 파일에는 깨진 줄, 비어 있는 선택 컬럼(취소 금액, 수수료율)이 섞여 있습니다. 파서가 Optional<Settlement> 을 돌려주면 flatMap(Optional::stream) 으로 유효 행만 자연스럽게 남고, 금액 통계는 mapToLong 으로 내려가 OptionalLong/OptionalDouble 을 씁니다.

groupingBy + maxBy 가 돌려주는 Optional 은 그룹이 절대 비지 않으므로 orElseThrow() 로 바로 꺼내도 안전합니다.

java
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=true

변형 5: 성능 비교 — 핫 루프에서 null 검사 vs Optional 할당

2.3 절에서 "Optional 은 객체 하나를 추가로 할당한다"고 했습니다. 실제로 얼마나 차이 나는지 같은 로직을 두 방식으로 2백만 번 호출해 잽니다. JIT 의 탈출 분석(escape analysis)이 짧은 체인의 할당을 제거하기도 해서 차이는 보통 1.5~2배 안팎이며, 대부분의 서비스 코드에서는 무시해도 됩니다. 단 초당 수백만 번 도는 파싱·직렬화 루프라면 측정 후 null 검사로 돌아가는 것이 맞습니다.

java
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
응용 변형 예제
  • 변형 1: 중첩 DTO 접근을 세 가지 방식으로 — if-null vs Optional.map vs 레코드 패턴
  • 변형 2: 재사용 유틸로 추출 — 제네릭 firstPresent(Supplier<Optional<T>>...)
  • 변형 3: 캐시 조회 — orElseGet vs Map.computeIfAbsent vs 네거티브 캐시
  • 변형 4: 정산 데이터에 적용 — 파싱 실패 걸러내기와 기본형 Optional 통계
  • 변형 5: 성능 비교 — 핫 루프에서 null 검사 vs Optional 할당
이전 섹션3 코드 예제4 / 7다음 섹션5 자주 하는 실수 (Tip)