[자바] 5. Optional : NPE와 헤어지기 (Java 8 Optional)

자바 버전 여행 5편 - NPE와 헤어지기

작성자

카테고리:

* 이 게시물은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

(자바 버전 여행 ⑤)

자바로 서비스를 만들다 가장 자주 만나는 예외를 딱 하나 꼽으면 NullPointerException(NPE)이다. 값이 없을 수 있는 걸 있다고 믿고 .을 찍는 순간 터진다. 자바 8의 Optional은 “이 값은 없을 수도 있어”를 타입으로 드러내서, 그 순간을 코드로 강제해 막는 장치다.

null 체크 지옥

사용자 → 회사 → 주소 → 도시를 타고 들어간다고 하자. 중간 어디든 null이면 터지니, 방어 코드가 계단처럼 쌓인다.

먼저 실제로 터뜨려 보자. 그대로 컴파일해 돌릴 수 있는 최소 재현 코드다 — 주소가 아직 없는 회사에서 도시명을 꺼낸다.

// NpeDemo.java
class Address { String city; Address(String c) { city = c; } String getCity() { return city; } }
class Company { Address addr; Company(Address a) { addr = a; } Address getAddress() { return addr; } }
class User { Company com; User(Company c) { com = c; } Company getCompany() { return com; } }

public class NpeDemo {
    public static void main(String[] args) {
        User user = new User(new Company(null));   // 주소가 아직 등록되지 않은 회사
        System.out.println(user.getCompany().getAddress().getCity());
    }
}

실행하면 이렇게 죽는다.

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "Address.getCity()" because the return value of "Company.getAddress()" is null
	at NpeDemo.main(NpeDemo.java:9)

자바 14부터는(JEP 358) 메시지가 무엇이 null이었는지까지 짚어 준다 — 범인은 getAddress()가 돌려준 null이다. NPE 해결의 첫걸음은 이 java.lang.NullPointerException 메시지를 끝까지 읽는 것. 다만 JVM은 범인을 알려줄 뿐 막아 주진 않아서, 이런 방어 코드가 쌓이기 시작한다.

// Before — 중첩 null 체크
String city = "UNKNOWN";
if (user != null) {
    Company c = user.getCompany();
    if (c != null && c.getAddress() != null) {
        city = c.getAddress().getCity();
    }
}

Optional로 흐름을 잇는다

Optional은 값을 상자에 담는다. 있으면 든 상자, 없으면 빈 상자다. map으로 상자를 열지 않고 안전하게 다음으로 넘긴다. 중간이 비면 그냥 빈 상자가 흘러간다.

// After — 흐름으로
String city = Optional.ofNullable(user)
    .map(User::getCompany)
    .map(Company::getAddress)
    .map(Address::getCity)
    .orElse("UNKNOWN");

위 NpeDemo의 마지막 줄을 이 체인으로 바꿔 다시 돌리면 —

UNKNOWN

같은 데이터, 같은 null인데 이번엔 예외 대신 기본값이다. 어느 단계가 비어도 빈 상자가 끝까지 흘러가 orElse가 받는다. 자바에서 NPE를 해결하는 Optional의 방식이 이거다 — null을 없애는 게 아니라, null이 지나다닐 길을 상자로 포장해 . 찍다 터질 일 자체를 없앤다.

만들고 꺼내는 법

하는 일메서드
값으로 만들기(널 아님 보장)Optional.of(v)
널일 수도 있는 값Optional.ofNullable(v)
빈 상자Optional.empty()
없으면 기본값orElse(기본), orElseGet(공급자)
없으면 예외orElseThrow(() -> …)
있을 때만 실행ifPresent(소비자)

orElse와 orElseGet은 헷갈리기 쉽다. orElse의 인자는 항상 계산되고, orElseGet은 없을 때만 실행된다. 기본값 만드는 비용이 크면 orElseGet을 써야 한다.

말로는 안 와닿으니 로그로 증명하자. 값이 이미 들어 있는 Optional에 두 메서드를 차례로 불러 본다.

static String expensive() {
    System.out.println("expensive() 호출!");   // 호출 시점 확인용 로그
    return "기본값";
}

Optional<String> opt = Optional.of("실제값");     // 값이 이미 있는 상자

System.out.println(opt.orElse(expensive()));
System.out.println(opt.orElseGet(() -> expensive()));
expensive() 호출!
실제값
실제값

기본값이 쓰일 일이 전혀 없는데도 orElse 쪽은 expensive()가 실행됐다(첫 줄 로그). 메서드를 부르기 전에 인자부터 평가하는 자바의 규칙 때문이다. 기본값이 상수면 orElse, DB 조회나 객체 생성처럼 비용이 붙으면 orElseGet이 답이다.

값을 강제로 꺼낼 땐 get() 대신 자바 10+의 인자 없는 orElseThrow()를 쓰자. 하는 일은 같지만 “없으면 던진다”는 각오가 이름에 드러난다.

이렇게 쓰면 안 된다

Optional은 잘못 쓰면 오히려 독이다. 내가 리뷰에서 가장 자주 돌려보내는 것들이다.

// 안티패턴 1 — get()으로 그냥 까기 (NPE와 다를 게 없다)
String s = opt.get();          // 비어 있으면 예외

// 안티패턴 2 — 있는지 물어보고 까기 (Optional 쓴 의미가 없음)
if (opt.isPresent()) { use(opt.get()); }   // → opt.ifPresent(this::use);

// 안티패턴 3 — 필드/파라미터를 Optional로 (권장 X, 반환 타입에만)

왜 나쁜지 하나씩 짚으면 — 1번은 비어 있으면 NoSuchElementException, NPE에서 예외 이름만 갈아 끼운 셈이다. 2번은 뜯어보면 if (x != null)과 같은 구조라 상자에 담은 보람이 없다. 3번은 Optional이 Serializable이 아니라 직렬화가 필요한 필드(JPA 엔티티 등)에서 말썽이고, 필드마다 상자 오버헤드도 얹힌다.

핵심 원칙은 하나다. Optional은 메서드 반환값으로 “없을 수 있음”을 알리는 용도지, 필드나 파라미터에 두르는 물건이 아니다.

함정이 하나 더 있다. NPE를 피하려고 Optional을 꺼냈는데, 상자를 만드는 순간 NPE가 나는 경우다.

// 안티패턴 4 — Optional.of()에 null일 수 있는 값 넣기
String nickname = fetchNicknameFromApi();      // 외부 응답 — null일 수 있다
Optional<String> opt = Optional.of(nickname); // null이면 여기서 바로 터진다

nickname이 null이면 Optional.of 내부의 Objects.requireNonNull에서 java.lang.NullPointerException이 그대로 난다. null이 들어올 여지가 조금이라도 있으면 ofNullable이 맞고, of는 “여기서 null이면 그건 버그”라고 못 박고 싶을 때만 쓴다.

직접 해보기 — 중첩 null 체크를 Optional 체인으로

2단계 이상 중첩된 객체에서 값을 꺼낼 때, if 중첩과 Optional을 비교하자.

  1. User → Company → Address → city 구조를 record로 만든다(중간이 null일 수 있게).
  2. Optional.ofNullable(...).map(...).orElse(...) 체인으로 도시명을 꺼낸다.
  3. 중간을 null로/값으로 바꿔 가며 결과가 어떻게 달라지는지 본다.
record Address(String city) {}
record Company(Address address) {}
record User(Company company) {}

User user = new User(new Company(null));   // 주소가 없는 경우

String city = Optional.ofNullable(user)
    .map(User::company)
    .map(Company::address)
    .map(Address::city)
    .orElse("UNKNOWN");

System.out.println(city);

실행해 보면 이렇게 나온다.

UNKNOWN

여기서 조금씩 바꿔 보자.

  • new User(new Company(new Address("서울")))로 바꿔 “서울”이 나오는지 본다.
  • orElse(expensive()) vs orElseGet(() -> expensive())에 로그를 넣어, expensive가 언제 호출되는지 비교한다.

한 걸음 더 — 빈 Optional에 .get()을 호출해 NoSuchElementException을 직접 만나 보고, 왜 get 남용이 나쁜지 체감하자.

스포일러 — 만나게 될 메시지는 java.util.NoSuchElementException: No value present다. NPE를 피했더니 예외가 이름만 바꿔 돌아온 셈이라는 걸 몸으로 확인하는 순간이다.

✅ 확인 — null 체크 중첩을 Optional 체인으로 바꾸고, orElse와 orElseGet의 차이를 설명할 수 있으면 통과다.

체이닝 — map·flatMap·filter

Optional의 진짜 힘은 연결에 있다. map으로 값이 있으면 변환하고, flatMap으로 Optional을 반환하는 메서드를 이어붙이고, filter로 조건을 건다. 값이 없으면 자동으로 건너뛴다 — 중첩 null 체크가 한 줄 파이프가 된다.

String city = findUser(id)
    .map(User::getCompany)         // 값 있으면 변환
    .flatMap(Company::getAddress)  // Optional 반환 메서드 이어붙이기
    .map(Address::getCity)
    .orElse("미상");               // 없으면 기본값

flatMap이 낯설 수 있는데, Company::getAddress가 Optional을 반환하기 때문에 쓴 것이다. map으로 이으면 Optional<Optional<Address>>처럼 상자가 겹치는데 flatMap이 한 겹으로 펴 준다 — 일반 값 변환은 map, Optional을 돌려주는 메서드는 flatMap.

실전 모양으로 한 번에 돌려 보자. 저장소에서 사용자를 찾고, 나이 조건을 걸고, 있을 수도 없을 수도 있는 닉네임을 잇는다 — 실무 Optional 사용법의 핵심이 이 안에 다 있다.

import java.util.Map;
import java.util.Optional;

public class ChainDemo {
    record User(String name, int age) {}

    static final Map<String, User> USERS =
            Map.of("kim", new User("김코즈", 17), "lee", new User("이랩", 25));
    static final Map<String, String> NICKNAMES = Map.of("이랩", "랩장");

    static Optional<User> findUser(String id) { return Optional.ofNullable(USERS.get(id)); }
    static Optional<String> findNickname(User u) { return Optional.ofNullable(NICKNAMES.get(u.name())); }

    public static void main(String[] args) {
        System.out.println(findUser("kim")
                .filter(u -> u.age() >= 19)              // 조건에 안 맞으면 빈 상자
                .map(u -> u.name() + "님, 환영합니다")
                .orElse("성인 인증이 필요합니다"));

        System.out.println(findUser("lee")
                .flatMap(ChainDemo::findNickname)        // Optional 반환 메서드 잇기
                .orElse("닉네임 없음"));
    }
}
성인 인증이 필요합니다
랩장

어디에도 null 검사가 없다. 김코즈는 17살이라 filter에서 빈 상자가 됐고, 이랩은 닉네임까지 닿았다. 값이 빠지는 순간 이후 단계는 전부 건너뛰고 orElse만 남는다 — 중첩 null 체크 지옥이 파이프 하나로 줄어드는 이유다.

한 걸음 더 — 자바 9+가 채운 것들

Optional은 8 이후로도 조금씩 다듬어졌다. 실무에서 바로 쓰이는 셋만 —

// ifPresentOrElse (9) — 있으면/없으면을 한 번에
findUser(id).ifPresentOrElse(
    user -> render(user),
    ()   -> renderGuest());

// or (9) — 비어 있으면 다른 Optional로 폴백
findInCache(id).or(() -> findInDb(id));

// stream (9) — Optional 목록에서 값 있는 것만 흘리기
ids.stream().map(this::findUser)
   .flatMap(Optional::stream)     // 빈 상자는 자연히 걸러진다
   .toList();

특히 Optional::stream은 ‘검색 결과 중 존재하는 것만 모으기’를 isPresent 검사 없이 파이프라인 그대로 잇게 해준다 — 스트림 편과 Optional이 만나는 지점이다.

언제 Optional, 언제 아닌가

Optional은 ‘메서드 반환값이 없을 수 있음‘을 드러내는 데 최적이다 — findById가 대표적이다. 반대로 필드·생성자 파라미터·컬렉션 원소엔 부적합하다(오버헤드·직렬화 문제). ‘없을 수 있는 걸 반환한다’가 신호다. 규칙 하나 더 — Optional 자체를 null로 반환하지 마라. 빈 Optional(Optional.empty())을 돌려주는 게 이 타입을 쓰는 이유다.

이제 값의 ‘없음’을 다루는 법까지 왔다. 다음 편은 자바 8이 손본 또 하나의 골칫거리, 낡은 Date와 Calendar를 대체하는 java.time이다.

자주 묻는 질문 (FAQ)

Optional을 필드에 써도 되나요?

권장하지 않습니다. Optional은 메서드 ‘반환값’으로 없음을 알리는 용도이며, 필드·파라미터에 두르는 건 안티패턴입니다.

orElse와 orElseGet 중 뭘 쓰나요?

기본값 계산이 비싸면 orElseGet(없을 때만 실행)을, 단순 상수면 orElse를 씁니다. orElse의 인자는 항상 계산됩니다.

Optional만 쓰면 NullPointerException이 사라지나요?

아니요. Optional은 반환값의 ‘없음’을 드러내는 도구일 뿐입니다. 파라미터 검증(Objects.requireNonNull), null 대신 빈 컬렉션 반환 같은 관례와 함께 써야 NPE가 실제로 줄어듭니다.

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다