Optional 제대로 쓰기 — null과 작별하는 모던 자바의 방법
user.getAddress().getCity()-- address가 null이면? 컴파일러는 아무 말도 안 하고, 런타임에NullPointerException이 터진다. null 체크 방어 코드를 쌓다 보면 비즈니스 로직이if (x != null)안에 파묻힌다.
Optional이 왜 필요한가
Optional 은 값이 있을 수도, 없을 수도 있음을 타입으로 표현하는 컨테이너예요. null의 세 가지 문제를 해결해 줍니다.
- **런타임 폭탄 **: null 참조는 컴파일러가 잡지 못하고 런타임에서야 터집니다
- ** 의미 모호 **: 메서드가 null을 반환하면 "값 없음"인지 "에러"인지 "미초기화"인지 알 수 없어요
- ** 방어 코드 오염 **: 중첩된 null 체크가 비즈니스 로직을 파묻습니다
// null 체크 지옥
if (user != null) {
Address address = user.getAddress();
if (address != null) {
String city = address.getCity();
if (city != null) return city.toUpperCase();
}
}
return "UNKNOWN";
Optional 생성 — of, ofNullable, empty
Optional을 만드는 방법은 세 가지입니다.
Optional.of(value)
// null이 아닌 값만 받는다
Optional<String> opt = Optional.of("hello");
// null을 넣으면? 바로 NullPointerException!
Optional<String> boom = Optional.of(null); // NPE 발생
"이 값은 절대 null이 아니다"라고 확신할 때 사용합니다. null이 들어오면 빠르게 실패(fail-fast)하는 게 of의 설계 의도예요.
Optional.ofNullable(value)
// null이 들어올 수 있을 때
String name = getUserName(); // null일 수 있음
Optional<String> opt = Optional.ofNullable(name);
외부 API 결과나 DB 조회 결과처럼 null 가능성이 있는 값을 감쌀 때 사용합니다. 실무에서 가장 많이 쓰게 되는 생성 메서드예요.
Optional.empty()
// 명시적으로 "값 없음"을 표현
Optional<String> empty = Optional.empty();
메서드 반환 시 "결과 없음"을 표현할 때 유용합니다. null을 반환하는 대신 Optional.empty()를 반환하면 호출자가 의도를 바로 이해할 수 있어요.
public Optional<User> findByEmail(String email) {
User user = userRepository.findByEmail(email);
if (user == null) {
return Optional.empty(); // null 대신 빈 Optional
}
return Optional.of(user);
}
// 위 코드를 한 줄로 줄이면
public Optional<User> findByEmail(String email) {
return Optional.ofNullable(userRepository.findByEmail(email));
}
값 꺼내기 — 기본 메서드들
isPresent()와 ifPresent()
Optional<String> opt = Optional.of("hello");
// isPresent — 값이 있는지 boolean으로 확인
if (opt.isPresent()) {
System.out.println(opt.get());
}
// ifPresent — 값이 있을 때만 Consumer 실행
opt.ifPresent(value -> System.out.println(value));
isPresent() + get() 조합은 null 체크와 다를 게 없습니다. 뒤에서 다시 다루겠지만, 이건 대표적인 안티패턴이에요.
isEmpty() (Java 11+)
Optional<String> opt = Optional.empty();
if (opt.isEmpty()) {
System.out.println("값이 없습니다");
}
!isPresent() 대신 가독성 좋게 쓸 수 있습니다.
체이닝 — map, flatMap, filter
Optional의 진가는 체이닝에 있습니다. null 체크 없이 변환을 이어갈 수 있어요.
map — 값 변환
Optional<String> name = Optional.of("libra");
// 값이 있으면 변환, 없으면 빈 Optional 그대로
Optional<String> upper = name.map(String::toUpperCase);
// Optional["LIBRA"]
Optional<String> empty = Optional.<String>empty().map(String::toUpperCase);
// Optional.empty — 에러 없이 빈 상태 유지
map은 Optional<T> → Optional<R> 변환입니다. 값이 없으면 함수를 실행하지 않고 빈 Optional을 반환해요.
flatMap — 중첩 Optional 방지
// getAddress()가 Optional<Address>를 반환하는 경우
public Optional<Address> getAddress() {
return Optional.ofNullable(this.address);
}
// map을 쓰면 Optional이 중첩된다
Optional<Optional<Address>> nested = user.map(User::getAddress); // 이중 래핑!
// flatMap은 중첩을 풀어준다
Optional<Address> address = user.flatMap(User::getAddress); // 깔끔
메서드 자체가 Optional을 반환할 때 flatMap을 씁니다. Stream의 flatMap과 같은 개념이에요.
filter — 조건부 필터링
Optional<Integer> age = Optional.of(25);
// 조건을 만족하면 그대로, 아니면 빈 Optional
Optional<Integer> adult = age.filter(a -> a >= 20);
// Optional[25]
Optional<Integer> minor = age.filter(a -> a < 20);
// Optional.empty
체이닝으로 null 지옥 탈출
앞에서 봤던 null 체크 코드를 Optional로 바꿔볼게요.
// Before: null 체크 지옥
if (user != null) {
Address address = user.getAddress();
if (address != null) {
String city = address.getCity();
if (city != null) {
return city.toUpperCase();
}
}
}
return "UNKNOWN";
// After: Optional 체이닝
return Optional.ofNullable(user)
.map(User::getAddress)
.map(Address::getCity)
.map(String::toUpperCase)
.orElse("UNKNOWN");
들여쓰기 없이 평탄하게 읽히죠. 이게 Optional의 존재 이유입니다.
orElse vs orElseGet vs orElseThrow
세 메서드의 차이를 정확히 알아두면 좋습니다.
orElse(T other)
// 값이 없으면 대체값 반환
String name = Optional.<String>empty().orElse("default");
// "default"
단순해 보이지만 함정이 있습니다. orElse는 값이 있든 없든 인자 표현식이 항상 평가돼요.
public String getDefault() {
System.out.println("getDefault 호출됨!"); // 비용이 큰 연산이라 가정
return "default";
}
// 값이 있는데도 getDefault()가 호출된다!
String name = Optional.of("libra").orElse(getDefault());
// 콘솔 출력: "getDefault 호출됨!"
// 결과: "libra" (반환값은 정상이지만 불필요한 연산이 실행됨)
이건 orElse가 메서드 호출이기 때문입니다. Java는 메서드 인자를 먼저 평가한 뒤 전달하거든요(eager evaluation). 단순 리터럴이면 괜찮지만, DB 조회나 API 호출 같은 비용이 큰 연산이면 문제가 됩니다.
orElseGet(Supplier)
// Supplier는 Optional이 비어있을 때만 실행된다
String name = Optional.of("libra").orElseGet(() -> getDefault());
// getDefault()가 호출되지 않는다!
String name2 = Optional.<String>empty().orElseGet(() -> getDefault());
// 이때만 getDefault()가 호출된다
** 비용이 큰 연산은 반드시 orElseGet을 사용해야 합니다.** 실무에서 이 차이를 모르고 orElse에 무거운 로직을 넣는 실수가 꽤 많아요.
orElseThrow(Supplier)
// 값이 없으면 예외를 던진다
User user = findById(id)
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다: " + id));
서비스 계층에서 "반드시 존재해야 하는" 값을 조회할 때 가장 많이 쓰는 패턴입니다.
Java 10부터는 인자 없는 orElseThrow()도 가능합니다.
// NoSuchElementException을 던진다
User user = findById(id).orElseThrow();
비교 표
| 메서드 | 대체값 실행 시점 | 용도 |
|---|---|---|
orElse(T) | ** 항상** 평가 | 단순 리터럴, 이미 계산된 값 |
orElseGet(Supplier) | 비어있을 때만 | DB 조회, API 호출 등 비용이 큰 연산 |
orElseThrow(Supplier) | 비어있을 때만 | 값이 없으면 예외를 던져야 할 때 |
or, ifPresentOrElse (Java 9+)
Java 9에서 유용한 메서드가 추가됐습니다.
or(Supplier)
// 값이 없으면 다른 Optional로 대체
Optional<String> result = findInCache(key)
.or(() -> findInDB(key))
.or(() -> findInExternalAPI(key));
orElse 계열은 언래핑된 값을 반환하지만, or는 Optional을 반환합니다. 여러 데이터 소스를 순차적으로 탐색할 때 체이닝이 깔끔해져요.
ifPresentOrElse(Consumer, Runnable)
findById(id).ifPresentOrElse(
user -> sendWelcomeEmail(user), // 값이 있을 때
() -> log.warn("사용자를 찾을 수 없습니다") // 값이 없을 때
);
ifPresent의 확장판입니다. 값이 없을 때의 동작까지 한 번에 표현할 수 있어요.
Optional과 Stream 연계
Optional.stream() (Java 9+)
List<Optional<String>> optionals = List.of(
Optional.of("a"),
Optional.empty(),
Optional.of("b"),
Optional.empty(),
Optional.of("c")
);
// Optional에서 값이 있는 것만 추출
List<String> values = optionals.stream()
.flatMap(Optional::stream) // 비어있으면 빈 Stream, 있으면 1개짜리 Stream
.toList();
// ["a", "b", "c"]
Optional.stream()은 flatMap과 함께 쓰면 Optional 리스트에서 값이 있는 것만 깔끔하게 뽑을 수 있습니다.
Stream에서 findFirst와 Optional
Optional<User> activeUser = users.stream()
.filter(User::isActive)
.findFirst(); // Optional<User> 반환
String name = activeUser
.map(User::getName)
.orElse("활성 사용자 없음");
Stream의 findFirst(), findAny(), reduce() 등은 Optional을 반환합니다. Stream과 Optional은 자연스럽게 연결되는 관계예요.
주의할 점 — 안티패턴
Optional을 어떻게 쓰면 안 되는지를 아는 것이 핵심입니다.
안티패턴 1: isPresent() + get()
// 나쁜 코드 — null 체크와 다를 게 없다
Optional<User> opt = findById(id);
if (opt.isPresent()) {
return opt.get();
}
return defaultUser;
// 좋은 코드
return findById(id).orElse(defaultUser);
isPresent() + get() 조합은 Optional을 쓰는 의미를 완전히 없앱니다.
안티패턴 2: Optional을 필드에 사용
// 하지 마세요
public class User {
private Optional<String> nickname; // 안티패턴!
}
왜 안 되는 걸까요?
- Optional은 Serializable을 구현하지 않습니다 — JPA 엔티티나 직렬화가 필요한 객체에서 문제가 돼요
- Optional은 메서드 반환 타입용으로 설계되었습니다 — Brian Goetz(Java 아키텍트)가 명시적으로 밝힌 사항이에요
- 필드마다 Optional로 감싸면 메모리 오버헤드가 쌓입니다
필드가 null일 수 있다면, getter에서 Optional을 반환하는 방식이 올바릅니다.
public class User {
private String nickname; // 필드는 그냥 String
public Optional<String> getNickname() {
return Optional.ofNullable(nickname); // getter에서 Optional 반환
}
}
안티패턴 3: Optional을 파라미터로 사용
// 하지 마세요
public void updateUser(Optional<String> nickname) {
// Optional 자체가 null로 넘어올 수도 있다!
// 호출자에게 불필요한 래핑 비용을 전가한다
}
// 대신 메서드 오버로딩이나 @Nullable 사용
public void updateUser(String nickname) { ... }
public void updateUser() { ... } // nickname 없는 버전
Optional을 파라미터로 받으면, 호출 시점에 Optional.ofNullable()로 감싸야 하는 번거로움이 생기고, Optional 파라미터 자체가 null로 넘어오는 상황까지 처리해야 합니다.
안티패턴 4: 컬렉션을 Optional로 감싸기
// 하지 마세요
public Optional<List<User>> findUsers() { ... }
// 빈 컬렉션을 반환하세요
public List<User> findUsers() {
// 결과가 없으면 빈 리스트 반환
return Collections.emptyList();
}
컬렉션은 그 자체로 "비어있음"을 표현할 수 있습니다. Optional로 한 번 더 감쌀 필요가 없어요.
올바른 사용 정리
| 용도 | 올바른가? | 이유 |
|---|---|---|
| 메서드 반환 타입 | O | Optional의 설계 목적 |
| 필드 타입 | X | Serializable 미지원, 메모리 오버헤드 |
| 메서드 파라미터 | X | 호출자에게 래핑 비용 전가, null 이중 체크 |
| 컬렉션 래핑 | X | 빈 컬렉션으로 충분 |
| Map의 value | X | Map.getOrDefault() 활용 |
실무 패턴 모음
패턴 1: 서비스 계층 — 조회 후 예외 처리
public User getUser(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
}
패턴 2: DTO 변환 시 Optional 활용
public UserResponse toResponse(User user) {
return UserResponse.builder()
.name(user.getName())
.city(Optional.ofNullable(user.getAddress())
.map(Address::getCity)
.orElse("미등록"))
.build();
}
패턴 3: 여러 소스에서 순차 탐색
public String resolveConfig(String key) {
return findInSystemProperty(key) // Optional<String>
.or(() -> findInEnvVariable(key))
.or(() -> findInConfigFile(key))
.orElse("기본값");
}
정리
| 항목 | 핵심 |
|---|---|
| Optional의 목적 | "값이 없을 수 있음"을 타입으로 표현 |
| 생성 | of(null 불가), ofNullable(null 가능), empty(명시적 비어있음) |
| 체이닝 | map(변환), flatMap(중첩 방지), filter(조건 필터) |
| orElse vs orElseGet | orElse는 항상 평가, orElseGet은 비어있을 때만. 비용 큰 연산은 반드시 orElseGet |
| 사용 범위 | 오직 메서드 반환 타입에만. 필드, 파라미터, 컬렉션 래핑 금지 |
| 대표 안티패턴 | isPresent() + get() -- null 체크와 다를 바 없다 |
| Stream 연계 | Optional.stream() (Java 9+) + flatMap으로 값 추출 |