공공부하자개발 · 영어 학습 노트
자바
고급모던 자바와 성능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정리‹ 이전다음 ›

2. 핵심 원리

2.1 NPE — 10억 달러짜리 실수

null 참조를 발명한 토니 호어(Tony Hoare)는 2009년 강연에서 이를 "10억 달러짜리 실수(billion-dollar mistake)"라고 불렀습니다. 1965년 ALGOL W 에 "구현하기 쉬워서" 넣었는데, 이후 수십 년간 셀 수 없는 버그·보안 취약점·시스템 다운을 만들었다는 것입니다.

자바에서 null 의 근본 문제는 타입 시스템의 구멍입니다. String s 라는 선언은 "s 는 String 이다"라고 말하지만 실제로는 "String 이거나 null 이다"입니다. 컴파일러는 이 차이를 검사하지 않으므로 모든 참조 변수가 잠재적 폭탄이고, 안전성은 전적으로 개발자의 기억력과 규율에 달려 있습니다.

2.2 null 의 구체적인 문제

문제 설명
의미 모호 map.get 의 null 이 키 없음인지 값이 null 인지 모름. 없음·에러·미로딩 구분 불가
방어 코드 폭증 호출 체인의 모든 단계에 if (x != null). 실제 로직보다 null 검사가 더 많아짐
늦은 실패 null 이 만들어진 곳과 터지는 곳이 멀리 떨어져 있음. 리포지토리가 준 null 이 화면 렌더링에서 터짐
계약 부재 시그니처만 보고는 null 반환 가능 여부를 알 수 없음. Javadoc 을 읽어야 하고, 읽어도 안 지켜짐
동등성 함정 s.equals("x") 는 s 가 null 이면 NPE. 그래서 "x".equals(s) 관용구

2.3 Optional 의 설계 의도 — 반환 타입 전용

Optional<T> 은 "T 타입 값이 있거나 없음"을 나타내는 컨테이너 객체입니다. 내부는 단순합니다.

java
public final class Optional<T> {
    private static final Optional<?> EMPTY = new Optional<>(null);
    private final T value;                    // null 이면 empty
    ...
}

JDK 설계자(Brian Goetz)의 공식 입장은 명확합니다.

Optional 은 "결과 없음"을 표현할 명확한 방법이 필요하고 null 반환이 오류를 유발할 가능성이 높은 라이브러리 메서드의 반환 타입을 위한 제한적 메커니즘으로 만들어졌다.

즉 Optional 은 "null 을 없애는 만능 도구"가 아니라 "이 메서드는 값을 못 돌려줄 수 있다"는 신호를 시그니처에 넣는 도구입니다. 그래서 설계상:

  • Serializable 이 아닙니다(필드에 넣지 말라는 의도).
  • 값 기반 클래스(value-based)로 == 비교, 동기화, identity 사용을 금지합니다.
  • 객체 하나를 추가로 할당하므로 성능 민감한 경로(핫 루프의 필드)에는 부적합합니다.

2.4 생성 — of / ofNullable / empty

메서드 동작 언제
Optional.of(v) v 가 null 이면 즉시 NPE 값이 절대 null 이 아님을 단언할 때
Optional.ofNullable(v) v 가 null 이면 empty 외부(레거시 API, Map.get) 에서 온 값을 감쌀 때
Optional.empty() 빈 Optional "없음"을 반환할 때

Optional.of(null) 이 NPE 를 던지는 것은 버그가 아니라 의도입니다. "여기엔 null 이 올 수 없다"는 선언이므로 null 이 오면 가장 이른 시점에 실패하게 합니다(fail-fast).

2.5 안전한 값 추출 — orElse vs orElseGet vs orElseThrow

메서드 값 있을 때 값 없을 때 인자 평가 시점
get() 값 NoSuchElementException —
orElseThrow() (10+) 값 NoSuchElementException — (get() 과 같지만 이름이 의도를 드러냄)
orElseThrow(Supplier<X>) 값 지정 예외 없을 때만
orElse(T other) 값 other 항상 (호출 전에 인자가 먼저 계산됨)
orElseGet(Supplier<T>) 값 supplier 결과 없을 때만
isPresent() / isEmpty() (11+) true / false false / true —

orElse 와 orElseGet 의 평가 시점 차이가 가장 중요한 원리입니다. 자바는 메서드를 호출하기 전에 인자를 먼저 평가합니다.

opt.orElse(loadDefault()) 는 loadDefault() 를 먼저 실행하고 그 결과를 orElse 에 넘깁니다 — Optional 에 값이 있든 없든. 반면 orElseGet(() -> loadDefault()) 는 람다를 넘기므로 Optional 이 비어 있을 때만 람다가 호출됩니다.

java
Optional<String> name = Optional.of("kim");
String a = name.orElse(expensive("A"));           // expensive("A") 실행됨! (값이 있어도)
String b = name.orElseGet(() -> expensive("B"));  // expensive 실행 안 됨

orElse 의 인자가 상수나 이미 있는 변수면 차이가 없지만, DB 조회·객체 생성·로깅이면 성능 낭비이자 부수효과 버그입니다. 규칙: 인자가 리터럴/변수면 orElse, 계산이 필요하면 orElseGet.

2.6 변환 — map / flatMap / filter

Optional 은 스트림처럼 체이닝합니다. 값이 없으면 체인 전체가 empty 로 흘러가고, 중간에 NPE 가 나지 않습니다.

메서드 시그니처 동작
map(Function<T, U>) Optional<U> 값이 있으면 변환. 결과가 null 이면 empty
flatMap(Function<T, Optional<U>>) Optional<U> 변환 함수가 Optional 을 돌려줄 때 (중첩 방지)
filter(Predicate<T>) Optional<T> 조건 불만족 시 empty
or(Supplier<Optional<T>>) (9+) Optional<T> 비어 있으면 다른 Optional 로 대체 (fallback 체인)
stream() (9+) Stream<T> 0 개 또는 1 개 요소 스트림. flatMap(Optional::stream) 으로 스트림에서 empty 제거

map vs flatMap: user.map(User::address) 에서 address() 가 Address 를 돌려주면 Optional<Address>. 만약 address() 가 이미 Optional<Address> 를 돌려준다면 map 은 Optional<Optional<Address>> 를 만들므로 flatMap 을 씁니다. 스트림의 map/flatMap 과 같은 관계입니다.

map 의 null 처리: map 의 함수가 null 을 돌려주면 자동으로 empty 가 됩니다. 그래서 Optional.ofNullable(user).map(User::getAddress).map(Address::getCity) 는 어느 단계에서 null 이 나와도 안전합니다 — 이것이 중첩 DTO 접근 패턴의 핵심입니다.

2.7 소비 — ifPresent / ifPresentOrElse

메서드 동작
ifPresent(Consumer<T>) 값이 있을 때만 실행
ifPresentOrElse(Consumer<T>, Runnable) (9+) 있으면 첫 번째, 없으면 두 번째

if (opt.isPresent()) { use(opt.get()); } 는 opt.ifPresent(this::use) 로 바꿉니다. 전자는 null 체크와 형태가 동일하므로 Optional 을 쓰는 의미가 없습니다.

2.8 안티패턴 — 쓰면 안 되는 곳

안티패턴 왜 나쁜가 대안
필드 타입 private Optional<String> nick; 직렬화 불가, 메모리 낭비, 필드 자체가 null 일 수 있어 이중 검사 필요 필드는 nullable 로 두고 getter 가 Optional 반환
메서드 파라미터 void f(Optional<String> s) 호출자가 Optional.empty() 를 만들어 넘겨야 함. 오버로드가 더 명확 f(String s) + f(), 또는 @Nullable
컬렉션 요소 List<Optional<T>> 빈 요소를 넣을 이유가 없음. 그냥 빼면 됨 List<T> (없는 건 빼기)
Optional 컬렉션 Optional<List<T>> 빈 리스트가 이미 "없음"을 표현함 List<T> (빈 리스트 반환)
isPresent() + get() null 체크와 동일. Optional 의 이점 0 map / orElse / ifPresent
Optional.of(null) NPE ofNullable
opt.get() 무방비 NoSuchElementException = NPE 의 다른 이름 orElseThrow(() -> ...)
orElse(null) 남발 다시 null 세계로 정말 null 이 필요한 경계에서만
생성자/세터 파라미터 위 파라미터와 동일
기본형 박싱 Optional<Integer> 박싱 비용 OptionalInt, OptionalLong, OptionalDouble
Optional == Optional 값 기반 클래스. identity 비교 금지 equals

Optional 을 파라미터로 받지 말라는 이유를 조금 더: 메서드 내부에서 어차피 isPresent 분기를 해야 하고, 호출자는 foo(Optional.ofNullable(x)) 처럼 불필요한 래핑을 합니다. 자바 언어 설계자들은 "Optional 을 파라미터로 받는 API 는 거의 항상 잘못 설계된 것"이라고 말합니다.

2.9 Objects 유틸 — requireNonNull / requireNonNullElse

Optional 이 아닌 곳(생성자 인자 검증, 파라미터 기본값)에는 java.util.Objects 가 적합합니다.

메서드 동작
Objects.requireNonNull(obj) null 이면 NPE. 생성자·세터 초입에서 fail-fast
Objects.requireNonNull(obj, "message") 메시지 포함 NPE
Objects.requireNonNullElse(obj, default) (9+) null 이면 default
Objects.requireNonNullElseGet(obj, supplier) (9+) null 이면 supplier 결과
Objects.isNull(obj), nonNull(obj) Predicate 로 쓰기 좋음: filter(Objects::nonNull)
Objects.equals(a, b) 둘 다 null 이면 true, 한쪽만 null 이면 false, NPE 없음
Objects.toString(obj, "N/A") null 안전 문자열

record 의 컴팩트 생성자에서 Objects.requireNonNull(name, "name") 로 불변식을 지키는 것이 JDK 21 관용구입니다. 생성 시점에 null 을 막으면 그 객체를 쓰는 모든 코드에서 null 검사가 사라집니다 — Optional 보다 훨씬 강력한 null 제거 전략입니다.

2.10 JDK 21 기준 대안 — 패턴 매칭과 sealed

Optional 은 "값이 있음 / 없음" 두 상태만 표현합니다. "없음"의 이유(찾지 못함 / 권한 없음 / 만료됨)까지 표현하려면 sealed interface + record + switch 패턴 매칭이 더 정확합니다.

java
sealed interface Lookup<T> permits Found, NotFound, Forbidden {}
record Found<T>(T value) implements Lookup<T> {}
record NotFound<T>(String id) implements Lookup<T> {}
record Forbidden<T>(String reason) implements Lookup<T> {}

String msg = switch (lookup) {                 // 컴파일러가 세 경우 모두 처리했는지 검사(exhaustiveness)
    case Found<User> f -> "hello " + f.value().name();
    case NotFound<User> n -> "no user " + n.id();
    case Forbidden<User> x -> "denied: " + x.reason();
};

instanceof 패턴 매칭(JDK 16+)은 nullable 값을 다룰 때 캐스팅과 null 검사를 합칩니다: if (obj instanceof String s && !s.isBlank()) — obj 가 null 이면 instanceof 가 false 이므로 안전합니다. switch 에 case null -> (JDK 21) 을 두면 null 을 명시적으로 분기할 수 있습니다.

상황 도구
조회 결과 있음/없음 Optional<T>
없음의 이유가 여러 가지 sealed 결과 타입 + switch
생성 시 null 금지 Objects.requireNonNull in 생성자
외부에서 온 nullable 값 분기 instanceof 패턴, switch case null
컬렉션 없음 빈 컬렉션 (List.of())
핵심 원리
  • 2.1 NPE — 10억 달러짜리 실수
  • 2.2 null 의 구체적인 문제
  • 2.3 Optional 의 설계 의도 — 반환 타입 전용
  • 2.4 생성 — of / ofNullable / empty
  • 2.5 안전한 값 추출 — orElse vs orElseGet vs orElseThrow
  • 2.6 변환 — map / flatMap / filter
  • 2.7 소비 — ifPresent / ifPresentOrElse
  • 2.8 안티패턴 — 쓰면 안 되는 곳
  • 2.9 Objects 유틸 — requireNonNull / requireNonNullElse
  • 2.10 JDK 21 기준 대안 — 패턴 매칭과 sealed
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제