JAVA

Java Optional 올바르게 쓰기: of vs ofNullable, 안티패턴, 실무 가이드라인

IT Lab 2026. 2. 23. 10:00

Java 17 기준으로 Optional.of/ofNullable 차이부터 흔한 안티패턴, API 설계·서비스 코드에서의 실무 가이드라인을 정리합니다.

 

서비스 코드에서 null 체크를 줄이려고 Optional을 도입했는데, 오히려 get()이 난무하거나 Optional<Optional<T>> 같은 코드가 생긴 적 있으실 거예요. 더 난감한 건 Optional.of() 때문에 운영에서 NPE가 터지는 경우입니다. Optional은 “null을 없애는 마법”이 아니라, 경계를 명확히 하는 도구에 가깝습니다.

핵심 개념: Java Optional을 왜/어디에 써야 할까

Optional의 핵심 가치는 “값이 없을 수 있음”을 타입으로 표현해서, 호출자가 그 가능성을 무시하기 어렵게 만드는 데 있습니다. 다만 이 장점은 적절한 경계(주로 API 반환값) 에서 쓸 때 가장 큽니다. 내부 로직 전체를 Optional로 감싸기 시작하면, 오히려 가독성과 디버깅 난이도가 올라갑니다.

Optional.of vs Optional.ofNullable 차이 (NPE의 출발점)

Optional.of는 null에서 즉시 예외, ofNullable은 empty로 변환되는 흐름도

  • Optional.of(value)value가 null이면 즉시 NPE를 던집니다. “여기는 절대 null이 오면 안 된다”는 강한 계약을 표현할 때 씁니다.
  • Optional.ofNullable(value)null이면 Optional.empty() 로 바꿉니다. 외부 입력/DB 조회 결과처럼 null 가능성이 있는 값을 감쌀 때 씁니다.

아래 표는 실무에서 헷갈리는 지점을 한 번에 정리한 선택 가이드입니다.

상황 추천 이유
“여기서 null이면 버그”를 빨리 터뜨리고 싶다 Optional.of(x) 계약 위반을 즉시 드러냄
외부 입력/DB/레거시 API 결과가 null일 수 있다 Optional.ofNullable(x) null을 안전하게 empty로 변환
Optional을 값처럼 보관/필드로 들고 가고 싶다 대체로 비추천 모델이 Optional에 오염되고 직렬화/프레임워크 연동이 불편
값이 없으면 기본값을 쓴다 orElse(...) / orElseGet(...) 표현이 명확하고 NPE 회피 가능

Optional 안티패턴 모음: “Optional을 썼는데 더 나빠지는” 코드

Optional은 잘 쓰면 if (x != null)을 줄여주지만, 다음 패턴은 오히려 품질을 떨어뜨립니다.

  1. Optional.get() 남발
    get()은 “비어있지 않다”는 확신이 있을 때만 써야 합니다. 확신이 없다면 orElseThrow, ifPresent, map/flatMap로 흐름을 이어가야 합니다.
  2. orElse(new Expensive())로 불필요한 비용 발생
    orElse(...)는 Optional이 비어있지 않아도 인자를 먼저 평가합니다. 기본값 생성이 비싸면 orElseGet(() -> ...)이 맞습니다.
  3. Optional<Optional<T>> 만들기
    map을 쓰면 Optional이 중첩될 수 있습니다. 이럴 땐 flatMap이 정답입니다.
  4. 메서드 파라미터로 Optional 받기
    공식적으로도 일반적인 권장 패턴이 아닙니다. 호출자가 Optional을 만들도록 강제하면 오히려 호출부가 복잡해집니다. 파라미터는 보통 @Nullable(문서/애너테이션) + 방어 로직이 더 현실적입니다.
  5. 필드 타입을 Optional로 두기
    JPA 엔티티, DTO, 직렬화 모델에서 Optional 필드는 프레임워크 호환성/가독성 측면에서 비용이 큽니다. “없을 수 있음”은 필드에서는 null, 반환값에서는 Optional로 경계를 두는 편이 실무적으로 안정적입니다(팀 규칙에 따라 달라질 수는 있습니다).

Optional 처리 흐름을 한눈에 보기 (map vs flatMap)

flowchart TD
  A["Optional<T>"] --> B["map: T -> U"]
  B --> C["Optional<U>"]
  A --> D["flatMap: T -> Optional<U>"]
  D --> E["Optional<U>"]
  A --> F["filter: T -> boolean"]
  F --> G["Optional<T>"]

Optional은 map/flatMap/filter로 “값이 있을 때만” 파이프라인을 이어가는 구조입니다.

코드 예제: Optional 실전 패턴 한 번에 정리 (Java 17)

아래 코드는 of/ofNullable, orElse vs orElseGet, map vs flatMap, 안티패턴 회피까지 한 파일에서 바로 실행할 수 있게 구성했습니다.

import java.util.Optional;

public class OptionalGuideDemo {

    // 레거시/외부 시스템처럼 null을 반환할 수 있는 메서드
    static String legacyFindNickname(Long userId) {
        if (userId == null || userId <= 0) return null;
        return (userId % 2 == 0) ? "neo" : null; // 일부는 닉네임이 없다고 가정
    }

    static Optional<String> findEmail(Long userId) {
        if (userId == null || userId <= 0) return Optional.empty();
        return Optional.of("user" + userId + "@example.com");
    }

    static String expensiveDefault() {
        // 비싼 초기화/IO/복잡한 계산이라고 가정
        System.out.println("expensiveDefault() called");
        return "DEFAULT";
    }

    public static void main(String[] args) {
        Long userId = 2L;

        // 1) of vs ofNullable
        String maybeNull = legacyFindNickname(userId);

        Optional<String> ok1 = Optional.ofNullable(maybeNull); // null이면 empty
        System.out.println("nickname(ofNullable): " + ok1);

        // Optional<String> boom = Optional.of(maybeNull); // maybeNull이 null이면 NPE (주의)

        // 2) get() 안티패턴 대신 orElseThrow
        String email = findEmail(userId)
                .orElseThrow(() -> new IllegalStateException("email must exist for userId=" + userId));
        System.out.println("email: " + email);

        // 3) orElse vs orElseGet 차이 (평가 시점)
        String v1 = Optional.of("VALUE").orElse(expensiveDefault());   // 기본값이 불필요하게 계산됨
        System.out.println("v1: " + v1);

        String v2 = Optional.of("VALUE").orElseGet(OptionalGuideDemo::expensiveDefault); // 필요할 때만 호출
        System.out.println("v2: " + v2);

        // 4) map vs flatMap (Optional 중첩 방지)
        Optional<Optional<String>> nested = Optional.of(userId).map(OptionalGuideDemo::findEmail);
        System.out.println("nested: " + nested);

        Optional<String> flattened = Optional.of(userId).flatMap(OptionalGuideDemo::findEmail);
        System.out.println("flattened: " + flattened);

        // 5) filter + map으로 조건부 변환
        String nicknameUpper = Optional.ofNullable(legacyFindNickname(userId))
                .filter(n -> n.length() >= 3)
                .map(String::toUpperCase)
                .orElse("NO_NICKNAME");
        System.out.println("nicknameUpper: " + nicknameUpper);

        // 6) ifPresentOrElse로 분기 (Java 9+)
        Optional.ofNullable(legacyFindNickname(3L))
                .ifPresentOrElse(
                        n -> System.out.println("nickname exists: " + n),
                        () -> System.out.println("nickname missing")
                );

        // 7) Optional을 파라미터로 받기보다, 호출부에서 Optional로 감싸는 편이 단순한 경우가 많음
        // (여기서는 데모이므로 호출부에서 ofNullable 사용)
        printNickname(Optional.ofNullable(legacyFindNickname(userId)));
    }

    // 데모용: 파라미터 Optional은 일반적으로 권장되지 않지만, 피치 못할 때라면 내부에서 get()을 피하세요.
    static void printNickname(Optional<String> nicknameOpt) {
        String nickname = nicknameOpt.orElse("anonymous");
        System.out.println("printNickname: " + nickname);
    }
}

실무 팁

💡 실무에서는: “Optional은 반환 타입에만” 규칙을 먼저 정해보세요

  • 레이어 경계(Repository/Client → Service)에서 null 가능성을 Optional로 승격하면 호출부가 안전해집니다.
  • 반대로 Service 내부 변수까지 Optional로 감싸기 시작하면, 디버깅 시 값 추적이 어려워지고 map/flatMap이 과해져 가독성이 떨어질 수 있습니다.
  • 팀 컨벤션으로 “DTO/Entity 필드에는 Optional 금지, 반환값에는 허용” 같은 룰을 두면 분쟁이 줄어듭니다.

💡 실무에서는: orElseorElseGet을 성능/부작용 관점에서 구분해 사용하세요

  • 기본값 생성이 비싸거나 로그/메트릭 같은 부작용이 있다면 orElseGet이 안전합니다.
  • orElseThrow()는 “없으면 예외”를 가장 명확히 표현합니다. (예외 메시지에 식별자(userId 등)를 넣으면 운영 대응이 빨라집니다.)

핵심 요약: of는 null이면 즉시 NPE, ofNullable은 empty로 변환합니다.
핵심 요약: get() 대신 orElseThrow, 중첩 Optional은 flatMap, 비싼 기본값은 orElseGet을 쓰세요.
핵심 요약: Optional은 주로 “반환 타입 경계”에서 효과가 크고, 필드/파라미터 남용은 피하는 편이 좋습니다.

다음 글: #23 java.time 완전 가이드