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에서 mapflatMap의 차이

Optional.mapOptional.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 안전한 매핑을 간결하게 만든다.

생각해 볼 문제

  1. Optional.of(null)Optional.ofNullable(null)의 차이를 예외 관점에서 설명하라.
  2. OptionalSerializable을 구현하지 않는 이유는 무엇인가?
  3. OptionalInt, OptionalLong, OptionalDouble이 별도로 존재하는 이유는?
  4. Stream.findFirst()Optional<T>를 반환하는 이유를 설명하라. 빈 Stream에서 무엇을 반환해야 하는가?
  5. JPA 엔티티에서 @Column 필드를 Optional로 감싸면 안 되는 이유는?

참고

'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