* 이 게시물은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
(자바 버전 여행 ⑤)
자바로 서비스를 만들다 가장 자주 만나는 예외를 딱 하나 꼽으면 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을 비교하자.
User → Company → Address → city구조를 record로 만든다(중간이 null일 수 있게).Optional.ofNullable(...).map(...).orElse(...)체인으로 도시명을 꺼낸다.- 중간을 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())vsorElseGet(() -> 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가 실제로 줄어듭니다.

답글 남기기