Optional.get()를 호출하면 NPE가 없어지는가 — Optional의 설계 의도와 함정
Optional<T>를 도입하면 NullPointerException이 사라질까? — 아니다. Optional.get()을 빈 Optional에 호출하면 NoSuchElementException이 발생한다. Optional은 null을 없애는 도구가 아니라, "값이 없을 수 있다"는 사실을 타입 시스템으로 표현하는 도구다. (Optional API)
"값이 없을 수도 있다"는 사실을 타입으로 표현하면 어떻게 될까? 이 글은 Optional이 NPE를 어떻게 예방하는지, 언제 쓰고 언제 쓰지 말아야 하는지를 풀어간다.
Optional이 해결하는 문제 — "이 메서드는 null을 반환할 수 있습니다"
Java 8 이전에는 메서드가 null을 반환할 수 있다는 것을 문서나 관습으로만 전달할 수 있었다:
// Java 7 — null 반환의 함정
String name = user.getName(); // null일 수도 있음 — 호출자가 어떻게 아는가?
// name.length(); // NPE!
Optional은 이것을 타입 시스템으로 명시한다:
// Java 25
Optional<String> name = user.getName(); // 명시적으로 "값이 없을 수 있음"
name.length(); // 컴파일 에러! Optional에는 length()가 없음
name.ifPresent(n -> System.out.println(n.length())); // 안전
Optional 생성과 소비
// Java 25 — 생성
Optional<String> present = Optional.of("Hello"); // null 불가 (NPE)
Optional<String> empty = Optional.empty(); // 빈 Optional
Optional<String> maybe = Optional.ofNullable(getName()); // null 허용 → 빈 Optional
// Java 25 — 안전한 소비 (4가지 패턴)
// 1. ifPresent — 값이 있을 때만 실행
maybe.ifPresent(name -> System.out.println(name));
// 2. orElse — 값이 없으면 기본값
String name2 = maybe.orElse("Unknown");
// 3. orElseGet — 값이 없으면 Supplier로 생성 (지연 평가)
String name3 = maybe.orElseGet(() -> expensiveDefault());
// 4. orElseThrow — 값이 없으면 예외
String name4 = maybe.orElseThrow(() -> new RuntimeException("이름 없음"));
orElse(defaultValue)와orElseGet(() -> defaultValue)의 차이:orElse는 값이 있든 없든defaultValue를 항상 평가한다.orElseGet은 빈 경우에만 Supplier를 호출한다. 기본값 생성이 비싸면orElseGet을 쓴다.
Optional 메서드 체이닝
// Java 25
Optional<Integer> length = user.getName() // Optional<String>
.filter(name -> name.length() > 2) // Optional<String> (조건 불만족 시 empty)
.map(String::length) // Optional<Integer>
.filter(len -> len < 50); // Optional<Integer>
// Stream처럼 파이프라인으로 연결 — 중간에 empty가 되면 나머지 무시
Optional 안티패턴 — 하지 말아야 할 것
// Java 25 — 안티패턴 1: 필드에 Optional 사용
class User {
// private Optional<String> name; // X — 직렬화 불가, 메모리 오버헤드
private String name; // O — 필드는 그냥 참조 타입
}
// Java 25 — 안티패턴 2: 매개변수에 Optional
// void setName(Optional<String> name) { ... } // X — 의미 없음
void setName(String name) { ... } // O — null 검사는 메서드 안에서
// Java 25 — 안티패턴 3: get() 직접 호출
Optional<String> opt = findName();
// String name = opt.get(); // X — 빈 경우 NoSuchElementException
String name = opt.orElse("default"); // O
// Java 25 — 안티패턴 4: isPresent + get (Optional을 null 검사처럼 쓰기)
if (opt.isPresent()) {
use(opt.get()); // X — Optional의 의미 없음
}
// 차라리:
opt.ifPresent(this::use); // O
Optional은 반환 타입으로만 쓰도록 설계됐다. (Optional JavaDoc) 필드, 매개변수, 컬렉션 요소에는 쓰지 않는다. 컬렉션이 비었을 때는
Optional<List<T>>가 아니라 빈List<T>를 반환한다.
Optional과 Stream의 관계
Optional은 0 또는 1개의 요소를 가진 Stream과 유사하다:
// Java 25 — stream()으로 Optional을 Stream으로 변환
List<String> allNames = users.stream()
.map(User::getName) // Stream<Optional<String>>
.flatMap(Optional::stream) // Stream<String> (빈 Optional은 제거)
.toList();
Optional::stream(Java 9+)은 빈 Optional을 빈 Stream으로, 값 있는 Optional을 1요소 Stream으로 변환한다. flatMap과 결합하면 null 처리가 우아해진다.
NPE의 역사 — null 참조가 "10억 달러 실수"인 이유
Tony Hoare(Quick Sort 발명자, Turing상 수상자)는 1965년 ALGOL W에서 null 참조를 도입한 것을 자신의 "10억 달러 실수(billion-dollar mistake)"라고 부른다. null은 모든 참조 타입의 값이 될 수 있으므로, 컴파일러는 "이 값이 null일 수 있는가?"를 알 수 없다.
Java에서 null이 만드는 문제:
NullPointerException은 런타임에 발생 — 컴파일 타임에 잡을 수 없음@Nullable어노테이션(Checker Framework, JetBrains, Spring 등)은 정적 분석 도구에 의존 — 언어 수준 보장 아님- null 검사 코드(
if (x != null) { ... })는 반복적이고 실수하기 쉬움
Optional은 "값이 없을 수 있음"을 언어 수준에서 표현하여, 컴파일러와 IDE가 null 처리를 강제할 수 있게 한다.
null vs Optional 비교
// Java 25 — null 방식 (전통적)
public class UserRepository {
public User findById(String id) {
// null을 반환할 수 있음 — 호출자가 문서를 읽어야 앎
return db.get(id); // 없으면 null?
}
}
User user = repo.findById("123");
// user.getName(); // NPE 가능성
// Java 25 — Optional 방식
public class UserRepository {
public Optional<User> findById(String id) {
return Optional.ofNullable(db.get(id));
}
}
Optional<User> user = repo.findById("123");
user.ifPresent(u -> System.out.println(u.getName()));
// 안전 — 컴파일러가 Optional 처리를 강제
Optional.or() (Java 9+) — 대안 제공
// Java 25 — or로 빈 경우 대안 Optional 제공
Optional<User> user = repo.findById("primary")
.or(() -> repo.findById("fallback"))
.or(() -> Optional.of(User.DEFAULT));
// user는 체인에서 발견된 첫 번째 값
or(Supplier<Optional<T>>)는 빈 Optional일 때만 Supplier를 평가한다 — orElseGet과 비슷하지만 Optional을 반환한다.
Optional과 예외 처리 — try-catch 대체
// Java 25 — 예외를 Optional로 변환
public static Optional<Integer> parseInt(String s) {
try {
return Optional.of(Integer.parseInt(s));
} catch (NumberFormatException e) {
return Optional.empty();
}
}
Optional<Integer> num = parseInt("42");
System.out.println(num.orElse(-1)); // 42
Optional<Integer> invalid = parseInt("abc");
System.out.println(invalid.orElse(-1)); // -1
예외를 Optional로 변환하면, "실패할 수 있는 연산"의 결과를 예외가 아닌 값으로 다룰 수 있다.
Stream.map(Optional::method)와 결합하면 예외 처리를 파이프라인에 녹일 수 있다.
실습 — Optional로 안전한 체인 만들기
// Java 25 — OptionalDemo.java
import java.util.Optional;
public class OptionalDemo {
record User(String name, Optional<String> nickname) {
User(String name) { this(name, Optional.empty()); }
}
static User findUser(String id) {
return id.equals("1") ? new User("Alice", Optional.of("Al")) :
id.equals("2") ? new User("Bob") :
null;
}
public static void main(String[] args) {
// null-safe 체인 — nickname의 길이
Optional.ofNullable(findUser("1"))
.map(User::nickname)
.flatMap(n -> n) // Optional<Optional<String>> → Optional<String>
.map(String::toUpperCase)
.ifPresent(System.out::println); // AL
Optional.ofNullable(findUser("2"))
.map(User::nickname)
.flatMap(n -> n)
.map(String::toUpperCase)
.ifPresent(System.out::println); // (출력 없음 — nickname이 비어 있음)
Optional.ofNullable(findUser("3"))
.map(User::nickname)
.flatMap(n -> n)
.or(() -> Optional.of("GUEST"))
.ifPresent(System.out::println); // GUEST
}
}
java OptionalDemo.java
AL
GUEST
확인할 것: 사용자가 없어도 NPE가 발생하지 않는다. Optional.ofNullable, flatMap, or를 결합하여 null-safe한 체인을 구성한다. null 검사 if문 없이 안전하게 처리된다.
@Nullable 어노테이션 vs Optional
| 접근 | 메커니즘 | 장점 | 단점 |
|---|---|---|---|
Optional<T> 반환 |
타입 시스템 | 컴파일러 강제, API 명확 | 객체 할당 오버헤드, 직렬화 불가 |
@Nullable T |
정적 분석 (Checker, IDE) | 오버헤드 없음, 필드에 사용 가능 | 도구 의존, 런타임 보장 없음 |
plain T (관습) |
문서 + 코드 리뷰 | 가장 단순 | NPE 위험, 발견 지연 |
핵심 원칙: 반환 타입에는
Optional<T>를, 메서드 내부 null 검사에는@Nullable+Objects.requireNonNull()를 쓴다. 둘을 혼용하지 않는다.
Optional에서 map과 flatMap의 차이
Optional.map과 Optional.flatMap은 Stream의 그것과 같은 원리로 동작한다:
// Java 25 — map: 값을 변환
Optional<String> name = Optional.of("Java");
Optional<Integer> length = name.map(String::length); // Optional[4]
// flatMap: 중첩 Optional 평탄화
record User(String name, Optional<String> email) {}
Optional<User> user = Optional.of(new User("Alice", Optional.of("alice@example.com")));
// map을 쓰면 중첩됨
Optional<Optional<String>> nested = user.map(User::email); // Optional<Optional<String>>
// flatMap을 쓰면 평탄화됨
Optional<String> flat = user.flatMap(User::email); // Optional[String]
flatMap은 변환 결과가 Optional일 때 사용한다. Optional<Optional<T>>를 Optional<T>로 평탄화한다. 메서드가 Optional을 반환하면 flatMap을, 일반 값을 반환하면 map을 쓴다.
실전 패턴: Repository + Service + Optional 체인
// Java 25 — 실전 Optional 체인
public class UserService {
private final UserRepository repo;
// Optional 체인으로 안전한 접근
public String getEmailDomain(String userId) {
return repo.findById(userId) // Optional<User>
.map(User::email) // Optional<String>
.filter(email -> email.contains("@")) // Optional<String> (검증)
.map(email -> email.substring(email.indexOf("@") + 1)) // Optional<String>
.orElse("unknown");
}
// Optional을 빈 컬렉션으로 변환
public List<Order> getOrders(String userId) {
return repo.findById(userId)
.map(User::orders)
.orElse(Collections.emptyList()); // null 대신 빈 리스트
}
}
핵심 원칙: 빈 컬렉션을 null 대신 반환한다.
Optional<List<T>>가 아니라 빈List<T>를 반환하는 것이 더 자연스럽다 — 빈 컬렉션은 "데이터 없음"을 나타내는 정상적인 값이며, null이나 Optional로 감쌀 필요가 없다. (Effective Java Item 54)
요약 — 이 글의 결론
- Optional은 null을 없애는 도구가 아니다. "값이 없을 수 있다"는 사실을 타입으로 표현하는 도구다.
Optional.get()을 무심코 호출하면 여전히 예외가 발생한다. - 반환 타입으로만 쓴다. 필드, 매개변수, 컬렉션 요소에는 쓰지 않는다.
Optional<List<T>>가 아니라 빈List<T>를 반환한다. orElse,orElseGet,ifPresent,orElseThrow로 소비한다.isPresent()+get()은 null 검사와 다를 게 없다.orElse(default)는 항상 default를 평가한다. 비싼 기본값은orElseGet(() -> expensive())으로 지연시킨다.Optional::stream(Java 9+)으로 Optional을 Stream과 결합한다.flatMap(Optional::stream)이 null 안전한 매핑을 간결하게 만든다.
생각해 볼 문제
Optional.of(null)과Optional.ofNullable(null)의 차이를 예외 관점에서 설명하라.Optional이Serializable을 구현하지 않는 이유는 무엇인가?OptionalInt,OptionalLong,OptionalDouble이 별도로 존재하는 이유는?Stream.findFirst()가Optional<T>를 반환하는 이유를 설명하라. 빈 Stream에서 무엇을 반환해야 하는가?- JPA 엔티티에서
@Column필드를Optional로 감싸면 안 되는 이유는?
참고
- Optional API (Java 25) - 접근 2026-07-10
- JEP-126: Optional - 접근 2026-07-10
'Develop Artifacts > Java' 카테고리의 다른 글
| Java - 16. io nio (0) | 2026.07.12 |
|---|---|
| Java - 15. datetime (0) | 2026.07.12 |
| Java - 13. stream (0) | 2026.07.12 |
| Java - 12. lambda functional (0) | 2026.07.12 |
| Java - 11. generics (0) | 2026.07.12 |
