null 참조를 발명한 토니 호어(Tony Hoare)는 2009년 강연에서 이를 "10억 달러짜리 실수(billion-dollar mistake)"라고 불렀습니다. 1965년 ALGOL W 에 "구현하기 쉬워서" 넣었는데, 이후 수십 년간 셀 수 없는 버그·보안 취약점·시스템 다운을 만들었다는 것입니다.
자바에서 null 의 근본 문제는 타입 시스템의 구멍입니다. String s 라는 선언은 "s 는 String 이다"라고 말하지만 실제로는 "String 이거나 null 이다"입니다. 컴파일러는 이 차이를 검사하지 않으므로 모든 참조 변수가 잠재적 폭탄이고, 안전성은 전적으로 개발자의 기억력과 규율에 달려 있습니다.
| 문제 | 설명 |
|---|---|
| 의미 모호 | map.get 의 null 이 키 없음인지 값이 null 인지 모름. 없음·에러·미로딩 구분 불가 |
| 방어 코드 폭증 | 호출 체인의 모든 단계에 if (x != null). 실제 로직보다 null 검사가 더 많아짐 |
| 늦은 실패 | null 이 만들어진 곳과 터지는 곳이 멀리 떨어져 있음. 리포지토리가 준 null 이 화면 렌더링에서 터짐 |
| 계약 부재 | 시그니처만 보고는 null 반환 가능 여부를 알 수 없음. Javadoc 을 읽어야 하고, 읽어도 안 지켜짐 |
| 동등성 함정 | s.equals("x") 는 s 가 null 이면 NPE. 그래서 "x".equals(s) 관용구 |
Optional<T> 은 "T 타입 값이 있거나 없음"을 나타내는 컨테이너 객체입니다. 내부는 단순합니다.
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 이 아닙니다(필드에 넣지 말라는 의도).== 비교, 동기화, identity 사용을 금지합니다.| 메서드 | 동작 | 언제 |
|---|---|---|
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).
| 메서드 | 값 있을 때 | 값 없을 때 | 인자 평가 시점 |
|---|---|---|---|
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 이 비어 있을 때만 람다가 호출됩니다.
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.
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 접근 패턴의 핵심입니다.
| 메서드 | 동작 |
|---|---|
ifPresent(Consumer<T>) |
값이 있을 때만 실행 |
ifPresentOrElse(Consumer<T>, Runnable) (9+) |
있으면 첫 번째, 없으면 두 번째 |
if (opt.isPresent()) { use(opt.get()); } 는 opt.ifPresent(this::use) 로 바꿉니다. 전자는 null 체크와 형태가 동일하므로 Optional 을 쓰는 의미가 없습니다.
| 안티패턴 | 왜 나쁜가 | 대안 |
|---|---|---|
필드 타입 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 는 거의 항상 잘못 설계된 것"이라고 말합니다.
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 제거 전략입니다.
Optional 은 "값이 있음 / 없음" 두 상태만 표현합니다. "없음"의 이유(찾지 못함 / 권한 없음 / 만료됨)까지 표현하려면 sealed interface + record + switch 패턴 매칭이 더 정확합니다.
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()) |