null 대신 Optional 반환하기
값이 없을 수 있는 메서드가 null 대신 Optional을 반환하게 하고, Optional을 만들고 값을 꺼내고 map·flatMap·filter로 변환하는 방법을 익힙니다.
회원 ID로 회원을 찾는 메서드를 만든다고 해 봅시다. 찾는 회원이 없을 때 지금까지는 대개 null을 돌려주었습니다. null과 NullPointerException에서 만든 findByEmail도 그랬습니다. 이 방식은 간단하지만, 메서드 선언만 봐서는 null이 나올 수 있다는 사실을 알 수 없다는 약점이 있습니다. 호출하는 쪽이 이 사실을 잊고 결과를 바로 쓰면 NullPointerException이 납니다.
이 글에서는 '값이 없을 수도 있다'는 사실을 반환 타입에 드러내는 Optional을 배웁니다. Optional을 만드는 방법, 안에 든 값을 안전하게 꺼내는 방법, 값이 있을 때만 변환을 이어 가는 map·flatMap·filter를 차례로 살펴봅니다. 마지막으로 Optional로 나타내면 안 되는 상황과 Optional을 쓰지 않는 편이 나은 경우를 정리합니다.
null을 돌려주는 메서드의 약점
아래 코드는 같은 조회를 두 가지 방식으로 만듭니다. findNullable은 찾지 못하면 null을 돌려주고, find는 Optional<User>를 돌려줍니다. Optional.of(user)는 값 하나를 담은 Optional을 만들고, Optional.empty()는 아무것도 담지 않은 Optional을 만듭니다.
import java.util.List;
import java.util.Optional;
public final class NullableAndOptionalLookup {
private record User(long id, String name) {}
private static final List<User> USERS = List.of(new User(1, "min"), new User(2, "seo"));
static User findNullable(long id) {
for (User user : USERS) {
if (user.id() == id) {
return user;
}
}
return null;
}
static Optional<User> find(long id) {
for (User user : USERS) {
if (user.id() == id) {
return Optional.of(user);
}
}
return Optional.empty();
}
public static void main(String[] args) {
System.out.println(find(1));
System.out.println(find(9));
System.out.println(findNullable(1).name());
System.out.println(findNullable(9).name());
}
}Optional[User[id=1, name=min]]
Optional.empty
min
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "NullableAndOptionalLookup$User.name()" because the return value of "NullableAndOptionalLookup.findNullable(long)" is null
at NullableAndOptionalLookup.main(NullableAndOptionalLookup.java:31)Optional을 출력하면 값이 있을 때는 Optional[값], 없을 때는 Optional.empty로 나타납니다. 첫 두 줄이 그 모습입니다. 셋째 줄은 findNullable(1)이 찾은 회원의 이름입니다. 넷째 줄에서는 findNullable(9)가 null을 돌려주었고, 여기에 .name()을 호출하는 순간 NullPointerException이 났습니다. 오류 메시지가 findNullable(long)의 반환값이 null이었다고 알려 주기는 하지만, 이것은 프로그램이 이미 멈춘 뒤에 얻는 정보입니다.
문제는 findNullable의 선언 static User findNullable(long id)가 null을 돌려줄 수 있다는 사실을 전혀 드러내지 않는다는 점입니다. 호출하는 쪽은 문서나 구현을 읽어야만 이 사실을 알 수 있고, 한 번이라도 확인을 빠뜨리면 위와 같은 예외가 납니다. 실제 프로그램에서는 null이 여러 메서드를 거쳐 전달된 뒤에야 예외가 나는 경우가 많아서, 예외가 난 줄과 null이 생긴 곳이 한참 떨어져 있기도 합니다.
반면 find의 반환 타입 Optional<User>는 '회원이 없을 수도 있다'는 사실을 선언에 적어 둡니다. 게다가 Optional<User>는 User가 아니므로, 확인 없이 회원의 메서드를 바로 호출하는 코드는 아예 컴파일되지 않습니다. find(9).name()이라고 쓰면 다음과 같은 컴파일 오류가 납니다(앞부분의 파일 이름과 줄 번호는 생략했습니다).
error: cannot find symbol
String name = find(9).name();
^
symbol: method name()
location: class Optional<User>이름을 쓰려면 회원이 없을 때 어떻게 할지를 함께 적어야 합니다. 예를 들어 find(9).map(User::name).orElse("guest")는 회원이 없으면 guest를 돌려줍니다. 확인을 잊는 실수가 실행 중의 예외 대신 컴파일 오류로 드러난다는 것, 이것이 Optional을 반환하는 가장 큰 이유입니다. 물론 Optional에도 확인 없이 값을 꺼내는 get() 같은 메서드가 있어서 완벽한 장치는 아닌데, 이 이야기는 뒤에서 다시 합니다.
Optional은 값이 0개 또는 1개인 상자
Optional<T>는 T 타입의 값을 하나 담고 있거나 아무것도 담고 있지 않은 객체입니다. 값이 들어 있으면 '값이 있다(present)'고 하고, 아무것도 없으면 '비어 있다(empty)'고 합니다. 원소를 많아야 하나만 담을 수 있는 작은 컬렉션이라고 생각하면 이해하기 쉽습니다. 다만 Optional에는 null을 담을 수 없으므로, 값이 있다면 그 값은 언제나 null이 아닙니다.
Optional의 Javadoc은 Optional을 주로 메서드의 반환 타입으로 쓰라고 안내합니다. 그중에서도 '결과 없음'을 나타낼 필요가 분명하고, 그 자리에 null을 쓰면 오류가 생기기 쉬운 메서드가 대상입니다. 같은 문서는 Optional 타입의 변수 자체는 절대 null이어서는 안 되고 언제나 Optional 객체를 가리켜야 한다고도 적고 있습니다. null 대신 쓰려고 도입한 Optional 변수에 다시 null을 넣으면, 확인해야 할 경우가 줄기는커녕 하나 더 늘어나기 때문입니다.
Optional 만들기: empty, of, ofNullable
Optional을 만드는 정적 메서드는 세 가지입니다. 무엇을 고를지는 손에 든 값이 null일 수 있는지에 따라 정해집니다.
| 메서드 | 결과 | 주로 쓰는 곳 |
|---|---|---|
Optional.empty() | 빈 Optional | 찾지 못했다는 결과를 직접 돌려줄 때 |
Optional.of(value) | value를 담은 Optional. value가 null이면 NullPointerException | 값이 반드시 있다고 확신할 때 |
Optional.ofNullable(value) | value가 null이면 빈 Optional, 아니면 value를 담은 Optional | Map.get처럼 null을 돌려주는 기존 API의 결과를 감쌀 때 |
of는 null을 받으면 그 자리에서 예외를 던집니다. 불편해 보이지만 일부러 그렇게 만든 것입니다. 아래 코드는 null을 돌려주는 옛 메서드의 결과를 of로 감쌉니다.
import java.util.Optional;
public final class OptionalOfNullFailure {
public static void main(String[] args) {
String nullable = findLegacy("missing");
Optional<String> result = Optional.of(nullable);
System.out.println(result);
}
private static String findLegacy(String key) {
return null;
}
}Exception in thread "main" java.lang.NullPointerException
at java.base/java.util.Objects.requireNonNull(Objects.java:233)
at java.base/java.util.Optional.of(Optional.java:113)
at OptionalOfNullFailure.main(OptionalOfNullFailure.java:6)Optional.of는 안에서 Objects.requireNonNull로 인수를 검사하므로, 스택 트레이스의 맨 아래 줄이 가리키는 곳이 바로 of를 호출한 6번째 줄입니다. 괄호 안의 Objects.java:233 같은 JDK 내부 줄 번호는 JDK 버전마다 다를 수 있습니다(위 결과는 JDK 21에서 실행한 것입니다). 중요한 것은 null이 들어온 바로 그 자리에서 예외가 났다는 점입니다. 만약 of가 null을 조용히 빈 Optional로 바꿔 주었다면, '여기에는 값이 반드시 있다'는 가정이 깨졌다는 사실을 아무도 모른 채 프로그램이 계속 실행됐을 것입니다.
반대로 null이 정상적인 결과인 API라면 ofNullable을 씁니다. Map.get은 키가 없으면 null을 돌려주고, System.getenv는 그런 이름의 환경 변수가 없으면 null을 돌려줍니다. 이런 메서드의 결과를 받자마자 ofNullable로 감싸 두면, 그 뒤의 코드는 null을 신경 쓰지 않고 Optional만 다루면 됩니다. 새로 만드는 코드라면 처음부터 null을 만들지 말고, 저장소 메서드가 Optional을 직접 돌려주게 하는 편이 좋습니다.
import java.util.Optional;
public final class OptionalCreationCatalog {
public static void main(String[] args) {
Optional<String> empty = Optional.empty();
Optional<String> required = Optional.of("stream");
Optional<String> adapted = Optional.ofNullable(System.getenv("MISSING_OPTIONAL_DEMO"));
Optional<String> blank = Optional.ofNullable("");
System.out.println("empty=" + empty);
System.out.println("required=" + required);
System.out.println("adapted=" + adapted);
System.out.println("blank=" + blank + ", present=" + blank.isPresent());
}
}empty=Optional.empty
required=Optional[stream]
adapted=Optional.empty
blank=Optional[], present=true셋째 줄의 adapted는 MISSING_OPTIONAL_DEMO라는 환경 변수가 없어서 비어 있습니다. 누군가 이 환경 변수를 설정해 두었다면 Optional[설정한 값]이 출력될 것입니다. 넷째 줄은 눈여겨볼 만합니다. 길이가 0인 문자열도 엄연한 값이므로, Optional.ofNullable("")은 비어 있지 않고 빈 문자열 하나를 담은 Optional[]이 됩니다. Optional이 구분하는 것은 '값이 있는가 없는가'뿐이고, 그 값이 쓸 만한지는 알려 주지 않습니다. 이름이 비어 있으면 안 된다는 규칙이 있다면 그 규칙은 Optional과 별개로 검사해야 합니다.
참고로 Optional.ofNullable(null)과 Optional.empty()는 둘 다 빈 Optional이고, equals로 비교하면 같다고 나옵니다. 하지만 비어 있는지를 Optional.empty()와의 == 비교로 확인해서는 안 됩니다. Javadoc은 empty()가 언제나 같은 객체를 돌려준다고 약속하지 않으므로, 비어 있는지는 isEmpty()나 isPresent()로 확인합니다.
값 꺼내기: get 대신 orElse·orElseThrow·ifPresent
Optional에 든 값을 꺼내는 가장 직접적인 메서드는 get()입니다. 하지만 비어 있는 Optional에서 get()을 호출하면 NoSuchElementException: No value present가 납니다. 최종 연산과 기본형 스트림에서 빈 findFirst() 결과에 get()을 호출했을 때 본 바로 그 예외입니다. 그래서 if (result.isPresent())로 먼저 확인한 뒤 result.get()으로 꺼내는 코드를 쓰게 되는데, 이것은 null 검사를 모양만 바꾼 것과 다르지 않습니다. 확인을 빠뜨리면 예외가 나는 것도 똑같습니다.
그래서 Optional은 비어 있을 때 할 일을 인수로 함께 받는 메서드를 따로 제공합니다. 값이 있는지 확인하는 일과 값을 꺼내 쓰는 일이 한 번의 호출로 묶이므로, 확인을 빠뜨릴 틈이 없습니다.
| 메서드 | 값이 있을 때 | 비어 있을 때 |
|---|---|---|
orElse(other) | 그 값을 돌려줌 | other를 돌려줌 |
orElseGet(supplier) | 그 값을 돌려줌 | supplier.get()의 결과를 돌려줌 |
orElseThrow() | 그 값을 돌려줌 | NoSuchElementException을 던짐 |
orElseThrow(exceptionSupplier) | 그 값을 돌려줌 | 공급자가 만든 예외를 던짐 |
ifPresent(action) | 값을 action에 넘겨 실행 | 아무것도 하지 않음 |
ifPresentOrElse(action, emptyAction) | 값을 action에 넘겨 실행 | emptyAction을 실행 |
기본값이 있으면 orElse나 orElseGet을 쓰고, 값이 없는 것이 오류라면 orElseThrow를 씁니다. 값이 있을 때만 할 일이 있다면 ifPresent나 ifPresentOrElse가 알맞습니다. 인수 없는 orElseThrow()(Java 10부터)는 get()과 똑같이 동작하지만 이름에 '없으면 예외'라는 뜻이 드러납니다. 그래서 Javadoc도 get() 대신 이 메서드를 쓰라고 권하며, 이 교재도 값이 반드시 있어야 하는 자리에서는 orElseThrow()를 씁니다.
import java.util.Optional;
public final class OptionalValueHandling {
public static void main(String[] args) {
Optional<String> present = Optional.of("collector");
Optional<String> absent = Optional.empty();
System.out.println(present.orElse("default"));
System.out.println(absent.orElseGet(() -> "computed-default"));
present.ifPresent(value -> System.out.println("present=" + value));
absent.ifPresentOrElse(
value -> System.out.println("value=" + value), () -> System.out.println("missing"));
}
}collector
computed-default
present=collector
missingpresent에는 값이 있으므로 orElse("default")는 기본값을 쓰지 않고 collector를 돌려줍니다. absent는 비어 있어서, 둘째 줄에는 orElseGet이 람다를 호출해 만든 computed-default가 나옵니다. 셋째 줄은 값이 있을 때만 실행되는 ifPresent의 출력입니다. 넷째 줄은 비어 있는 absent에서 ifPresentOrElse가 두 번째 인수, 즉 빈 경우의 동작을 실행한 결과입니다.
orElse와 orElseGet은 결과만 보면 같아 보이지만 기본값을 언제 계산하느냐가 다릅니다. 다음 글에서 메서드 인수가 계산되는 시점을 먼저 살펴보고, 그다음 글에서 이 차이를 실행 결과로 확인합니다. 또 하나, 분기가 복잡하고 해야 할 일이 여러 가지라면 이런 메서드를 억지로 이어 붙이기보다 평범한 if 문으로 쓰는 편이 읽기 쉽습니다. Optional의 메서드는 짧은 처리를 깔끔하게 쓰기 위한 도구이지, 모든 분기를 대신하려는 것이 아닙니다.
map·flatMap·filter로 값 변환하기
Optional에서 값을 꺼내지 않은 채 변환을 이어 갈 수도 있습니다. 다음 세 메서드는 모두 값이 있을 때만 함수를 실행하고, 비어 있으면 함수를 호출하지 않은 채 빈 Optional을 그대로 돌려줍니다. 그래서 중간 어느 단계에서든 값이 사라지면 나머지 단계는 모두 건너뛰고 빈 결과가 끝까지 전달됩니다.
map(f)는 값에f를 적용하고 그 결과를 다시Optional로 감쌉니다.f가null을 돌려주면 결과는 빈Optional입니다. 결과를ofNullable로 감싸는 것과 같습니다.flatMap(f)는f가 이미Optional을 돌려줄 때 씁니다. 결과를 한 번 더 감싸지 않고f가 돌려준Optional을 그대로 씁니다.f가null을 돌려주면NullPointerException이 납니다.filter(p)는 값이 조건p를 만족하면 그대로 두고, 만족하지 않으면 빈Optional로 바꿉니다.
map과 flatMap의 차이는 함수가 돌려주는 타입에서 나옵니다. 프로필을 찾는 메서드 findProfile이 Optional<Profile>을 돌려준다고 합시다. 프로필 ID를 담은 Optional<Long>의 map에 findProfile을 넘기면, map이 그 결과를 한 번 더 감싸서 Optional<Optional<Profile>>이 됩니다. 출력해 보면 Optional[Optional[...]]처럼 상자 안에 상자가 든 모양입니다. flatMap은 이렇게 겹친 상자를 하나로 펴 줍니다. filter·map·flatMap과 상태 보유 중간 연산에서 Stream의 flatMap이 스트림 안의 스트림을 한 줄로 편 것과 같은 생각입니다.
다음 예제는 사용자의 프로필 ID로 프로필을 찾고, 활성 상태인 프로필의 표시 이름을 소문자로 바꿉니다. 사용자의 profileId는 null일 수 있는 Long이고, 프로필 저장소에는 그 ID의 프로필이 없을 수 있으며, 찾은 프로필이 비활성 상태일 수도 있습니다. 값이 사라질 수 있는 곳이 세 군데인 셈입니다.
import java.util.Map;
import java.util.Optional;
public final class OptionalProfileLookup {
private record User(long id, Long profileId) {}
private record Profile(long id, String displayName, boolean active) {}
private static final Map<Long, Profile> PROFILES =
Map.of(10L, new Profile(10, "MIN", true), 20L, new Profile(20, "OLD", false));
private static Optional<Profile> findProfile(long id) {
return Optional.ofNullable(PROFILES.get(id));
}
private static Optional<String> activeDisplayName(User user) {
return Optional.ofNullable(user.profileId())
.flatMap(OptionalProfileLookup::findProfile)
.filter(Profile::active)
.map(Profile::displayName)
.map(String::toLowerCase);
}
public static void main(String[] args) {
System.out.println(activeDisplayName(new User(1, 10L)).orElse("anonymous"));
System.out.println(activeDisplayName(new User(2, null)).orElse("anonymous"));
}
}min
anonymousmain은 프로필 ID가 10인 사용자와 null인 사용자, 두 경우만 실행합니다. ID 10은 ofNullable에서 Optional[10]이 되고, flatMap이 findProfile(10)을 호출해 MIN 프로필을 얻습니다. 이 프로필은 활성 상태이므로 filter를 통과하고, 두 번의 map을 거쳐 "MIN"이 "min"이 됩니다. 반면 ID가 null이면 첫 단계부터 비어 있습니다. 그래서 flatMap은 findProfile을 아예 호출하지 않고, 뒤의 단계도 모두 건너뛴 채 orElse의 anonymous가 출력됩니다.
같은 빈 결과라도 부재가 생기는 단계는 다르다
null ID·미등록 ID·비활성 프로필이 각기 다른 단계에서 empty가 되고 ID 10만 MIN에서 min으로 변환됩니다. 조회와 active 필터를 통과하는 값의 흐름을 분리할 수 있습니다.
OptionalProfileLookup의 profileId와 저장된 프로필 상태를 분기별로 읽습니다.
부재가 생긴 위치
같은 빈 결과라도 부재가 생기는 단계는 다르다
ID가 없거나 저장소에 값이 없거나 비활성 상태여도 최종 결과는 anonymous입니다. 어떤 단계에서 값이 비었는지 구분합니다.
10: 활성 프로필
- 값이 사라지는 지점
- 부재 없음
- 최종 처리
- 표시 이름 MIN을 기본 로케일로 소문자 변환
null: ID 없음
- 값이 사라지는 지점
- ofNullable에서 empty
- 최종 처리
- anonymous
20: 비활성 프로필
- 값이 사라지는 지점
- filter(active)에서 empty
- 최종 처리
- anonymous
저장소에 없는 ID
- 값이 사라지는 지점
- findProfile에서 empty
- 최종 처리
- anonymous
main은 ID 10과 null만 호출합니다. 나머지 행은 저장된 데이터와 코드에 근거한 미실행 경로이며, Optional은 발생한 예외를 자동으로 empty로 바꾸지 않습니다.
이 예의 조건
- main은 ID 10/null만 호출하며 20/99는 미실행 조건입니다.
- 인수 없는 toLowerCase는 기본 로케일을 사용합니다. MIN→min은 한국어·영어 로케일 조건이며 터키어에서는 mın입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 OptionalProfileLookup의 값 전파를 8개 노드와 7개 관계로 나타낸다. 각 단계의 오른쪽 desktop·아래 mobile 부재 가지가 anonymous를 선택한다. desktop 아래·mobile 왼쪽 우회 가지는 값이 남아 다음 단계로 진행한다. 원 main은 10/null만 호출하며 20/99는 별도 미실행 경로다. 예외를 empty로 숨기지 않고 로케일 변환 정책은 원문을 따른다. |
| 9 | ID 99 미등록 |
| 13 | ID 20 비활성 |
| 16 | ID 10: MIN 변환 |
| 구분 | 내용 |
|---|---|
| 설명 | 원 OptionalProfileLookup의 값 전파를 8개 노드와 7개 관계로 나타낸다. 각 단계의 오른쪽 desktop·아래 mobile 부재 가지가 anonymous를 선택한다. desktop 아래·mobile 왼쪽 우회 가지는 값이 남아 다음 단계로 진행한다. 원 main은 10/null만 호출하며 20/99는 별도 미실행 경로다. 예외를 empty로 숨기지 않고 로케일 변환 정책은 원문을 따른다. |
| 전체 읽기 정보 | 같은 빈 결과라도 부재가 생기는 단계는 다르다 같은 빈 결과라도 부재가 생기는 단계는 다르다 OptionalProfileLookup의 profileId와 저장된 프로필 상태를 분기별로 읽습니다. 부재가 생긴 위치 같은 빈 결과라도 부재가 생기는 단계는 다르다 ID가 없거나 저장소에 값이 없거나 비활성 상태여도 최종 결과는 anonymous입니다. 어떤 단계에서 값이 비었는지 구분합니다. 10: 활성 프로필 값이 사라지는 지점 부재 없음 최종 처리 표시 이름 MIN을 기본 로케일로 소문자 변환 null: ID 없음 값이 사라지는 지점 ofNullable에서 empty 최종 처리 anonymous 20: 비활성 프로필 값이 사라지는 지점 filter(active)에서 empty 최종 처리 anonymous 저장소에 없는 ID 값이 사라지는 지점 findProfile에서 empty 최종 처리 anonymous main은 ID 10과 null만 호출합니다. 나머지 행은 저장된 데이터와 코드에 근거한 미실행 경로이며, Optional은 발생한 예외를 자동으로 empty로 바꾸지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| ID 존재 profileId가 null이면 조회 없이 anonymous입니다. |
|
|
| 조회와 활성 미등록 99 또는 비활성 20도 empty로 갑니다. |
|
|
| 값 변환 활성 ID 10의 MIN을 기본 로케일로 변환하며 한국어·영어 조건에서는 min입니다. |
|
|
같은 빈 결과라도 부재가 생기는 단계는 다르다
null ID·미등록 ID·비활성 프로필이 각기 다른 단계에서 empty가 되고 ID 10만 MIN에서 min으로 변환됩니다. 조회와 active 필터를 통과하는 값의 흐름을 분리할 수 있습니다.
OptionalProfileLookup의 profileId와 저장된 프로필 상태를 분기별로 읽습니다.
부재가 생긴 위치
같은 빈 결과라도 부재가 생기는 단계는 다르다
ID가 없거나 저장소에 값이 없거나 비활성 상태여도 최종 결과는 anonymous입니다. 어떤 단계에서 값이 비었는지 구분합니다.
10: 활성 프로필
- 값이 사라지는 지점
- 부재 없음
- 최종 처리
- 표시 이름 MIN을 기본 로케일로 소문자 변환
null: ID 없음
- 값이 사라지는 지점
- ofNullable에서 empty
- 최종 처리
- anonymous
20: 비활성 프로필
- 값이 사라지는 지점
- filter(active)에서 empty
- 최종 처리
- anonymous
저장소에 없는 ID
- 값이 사라지는 지점
- findProfile에서 empty
- 최종 처리
- anonymous
main은 ID 10과 null만 호출합니다. 나머지 행은 저장된 데이터와 코드에 근거한 미실행 경로이며, Optional은 발생한 예외를 자동으로 empty로 바꾸지 않습니다.
이 예의 조건
- main은 ID 10/null만 호출하며 20/99는 미실행 조건입니다.
- 인수 없는 toLowerCase는 기본 로케일을 사용합니다. MIN→min은 한국어·영어 로케일 조건이며 터키어에서는 mın입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 OptionalProfileLookup의 값 전파를 8개 노드와 7개 관계로 나타낸다. 각 단계의 오른쪽 desktop·아래 mobile 부재 가지가 anonymous를 선택한다. desktop 아래·mobile 왼쪽 우회 가지는 값이 남아 다음 단계로 진행한다. 원 main은 10/null만 호출하며 20/99는 별도 미실행 경로다. 예외를 empty로 숨기지 않고 로케일 변환 정책은 원문을 따른다. |
| 9 | ID 99 미등록 |
| 13 | ID 20 비활성 |
| 16 | ID 10: MIN 변환 |
| 구분 | 내용 |
|---|---|
| 설명 | 원 OptionalProfileLookup의 값 전파를 8개 노드와 7개 관계로 나타낸다. 각 단계의 오른쪽 desktop·아래 mobile 부재 가지가 anonymous를 선택한다. desktop 아래·mobile 왼쪽 우회 가지는 값이 남아 다음 단계로 진행한다. 원 main은 10/null만 호출하며 20/99는 별도 미실행 경로다. 예외를 empty로 숨기지 않고 로케일 변환 정책은 원문을 따른다. |
| 전체 읽기 정보 | 같은 빈 결과라도 부재가 생기는 단계는 다르다 같은 빈 결과라도 부재가 생기는 단계는 다르다 OptionalProfileLookup의 profileId와 저장된 프로필 상태를 분기별로 읽습니다. 부재가 생긴 위치 같은 빈 결과라도 부재가 생기는 단계는 다르다 ID가 없거나 저장소에 값이 없거나 비활성 상태여도 최종 결과는 anonymous입니다. 어떤 단계에서 값이 비었는지 구분합니다. 10: 활성 프로필 값이 사라지는 지점 부재 없음 최종 처리 표시 이름 MIN을 기본 로케일로 소문자 변환 null: ID 없음 값이 사라지는 지점 ofNullable에서 empty 최종 처리 anonymous 20: 비활성 프로필 값이 사라지는 지점 filter(active)에서 empty 최종 처리 anonymous 저장소에 없는 ID 값이 사라지는 지점 findProfile에서 empty 최종 처리 anonymous main은 ID 10과 null만 호출합니다. 나머지 행은 저장된 데이터와 코드에 근거한 미실행 경로이며, Optional은 발생한 예외를 자동으로 empty로 바꾸지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| ID 존재 profileId가 null이면 조회 없이 anonymous입니다. |
|
|
| 조회와 활성 미등록 99 또는 비활성 20도 empty로 갑니다. |
|
|
| 값 변환 활성 ID 10의 MIN을 기본 로케일로 변환하며 한국어·영어 조건에서는 min입니다. |
|
|
저장소에 든 데이터를 보면 main에서 실행하지 않은 다른 ID가 어떻게 될지도 알 수 있습니다. 아래 표는 네 경우를 단계별로 나란히 놓은 것이고, 99와 20의 경우는 연습 문제 3에서 직접 실행해 확인합니다.
| 프로필 ID | ofNullable | flatMap(프로필 조회) | filter(활성 여부) | map 두 번 | 결과 |
|---|---|---|---|---|---|
| 10 | Optional[10] | MIN 프로필 | 통과 | MIN → min | min |
null | 비어 있음 | 호출하지 않음 | 건너뜀 | 건너뜀 | anonymous |
| 99 | Optional[99] | 비어 있음(저장소에 없음) | 건너뜀 | 건너뜀 | anonymous |
| 20 | Optional[20] | OLD 프로필 | 비활성이라 비어 있음 | 건너뜀 | anonymous |
표의 아래 세 줄은 결과가 모두 anonymous이지만, 값이 사라진 단계는 서로 다릅니다. ID가 없어서인지, 프로필이 없어서인지, 비활성이어서인지는 최종 결과만 봐서는 구분되지 않습니다. 이 구분이 필요하다면 Optional 하나로는 부족하다는 뜻인데, 이 문제는 아래 '없음과 실패를 구분하기'에서 다시 다룹니다.
한 가지 더 볼 것은 activeDisplayName의 매개변수입니다. 사용자 자체가 없을 수도 있다고 해서 매개변수를 Optional<User>로 바꾸는 것은 권하지 않습니다. 사용자가 있는지는 호출하는 쪽에서 먼저 확인하고 있는 사용자만 넘기는 편이, 메서드도 단순하고 호출하기도 쉽습니다.
더 깊이: 인수 없는 toLowerCase와 기본 로케일
예제의 String::toLowerCase처럼 인수 없는 toLowerCase()는 프로그램이 실행되는 환경의 기본 로케일(locale) 규칙을 따릅니다. 한국어나 영어 로케일에서는 MIN이 min이 되지만, 터키어 로케일에서는 대문자 I가 점 없는 소문자 ı로 바뀌어 mın이 됩니다. 실제로 "MIN".toLowerCase(Locale.forLanguageTag("tr"))를 실행하면 mın이 나옵니다. 화면에 보여 줄 문장이라면 사용자의 로케일을 따르는 것이 맞습니다. 하지만 식별자나 키처럼 어느 환경에서든 같은 결과가 나와야 하는 문자열이라면 toLowerCase(Locale.ROOT)처럼 로케일을 직접 지정합니다.
없음과 실패를 구분하기
비어 있는 Optional은 '찾아봤는데 없었다'는 정상적인 결과입니다. 회원 ID로 조회했는데 그런 회원이 없는 것은 오류가 아니므로 Optional로 알리기에 알맞습니다. 하지만 데이터베이스에 연결하지 못했거나 권한이 없어서 조회 자체를 하지 못했다면 이야기가 다릅니다. 이것은 찾지 못한 것이 아니라 찾는 일에 실패한 것이므로 예외로 알려야 합니다.
두 경우를 같은 빈 Optional로 합치면 어떤 일이 생길까요? 데이터베이스가 멈춘 동안 모든 조회가 '회원 없음'으로 처리되어, 사용자는 가입한 계정이 없다는 엉뚱한 안내를 받습니다. 장애가 정상적인 '없음'처럼 보이니 운영자가 문제를 알아차리는 것도 늦어집니다. 다행히 Optional은 예외를 삼키지 않습니다. 앞 예제의 findProfile이 예외를 던진다면, 그 예외는 빈 Optional로 바뀌지 않고 activeDisplayName을 호출한 쪽까지 그대로 전달됩니다. Optional이 다루는 것은 값이 없는 경우뿐입니다.
반대로 호출하는 쪽에서 값이 반드시 있어야 하는 경우도 있습니다. 예를 들어 주문을 처리하려면 주문한 회원이 꼭 있어야 합니다. 이때는 orElseThrow에 예외를 만드는 람다를 넘겨, 비어 있는 결과를 그 상황에 맞는 예외로 바꿉니다. 람다는 값이 없을 때만 호출되므로, 값이 있으면 예외 객체를 만드는 비용도 들지 않습니다.
User user = find(userId)
.orElseThrow(() -> new IllegalArgumentException("unknown user id: " + userId));예외 메시지에는 무엇을 찾다가 실패했는지 알 수 있도록 키를 넣되, 비밀번호나 인증 토큰 같은 민감한 정보는 넣지 않습니다. 또 '없음'의 이유를 여러 가지로 나눠 처리해야 한다면 Optional로는 부족합니다. 예를 들어 '가입한 적 없는 회원'과 '탈퇴한 회원'을 다르게 안내해야 한다면, 값이 있거나 없는 두 상태만 표현하는 Optional로는 그 차이를 전달할 수 없습니다. 이럴 때는 가능한 결과의 종류를 직접 정의한 타입을 쓰는데, 그런 타입을 만드는 방법은 sealed 계층을 다루는 글에서 배웁니다.
Optional을 반환하지 않는 편이 나은 경우
Optional은 '결과가 하나 있거나 없다'를 나타낼 때 가장 잘 맞습니다. 이 모양에 맞지 않는 곳에 쓰면 오히려 코드가 복잡해집니다.
가장 흔한 예는 컬렉션입니다. 결과가 여러 개일 수 있는 검색 메서드는 Optional<List<User>>가 아니라 List<User>를 돌려주고, 결과가 없으면 빈 리스트를 돌려주면 됩니다. 빈 리스트만으로 '0개'를 충분히 나타낼 수 있으므로, 여기에 Optional을 씌우면 '빈 Optional'과 '빈 리스트를 담은 Optional'이라는 같은 뜻의 상태가 둘이나 생깁니다.
기본형 값에는 Optional<Integer> 대신 최종 연산과 기본형 스트림에서 본 OptionalInt, OptionalLong, OptionalDouble을 쓸 수 있습니다. 또 private 메서드처럼 호출하는 곳이 한두 군데이고 결과를 바로 확인하는 좁은 범위라면, null을 돌려줘도 실수할 여지가 적습니다. Optional도 객체이므로 만드는 비용이 조금 들지만, 대부분의 조회 코드에서는 그 비용보다 읽기 쉬운 코드가 주는 이득이 훨씬 큽니다. 성능이 정말 문제가 되는 곳이라면 추측하지 말고 측정해 본 뒤에 결정합니다.
Optional을 필드나 메서드 매개변수에 쓰지 않는 이유를 포함해, 어디에 쓰고 어디에 쓰지 않을지는 이 장의 네 번째 글에서 자세히 정리합니다.
정리
null을 돌려주는 메서드는 선언만 봐서는 결과가 없을 수 있다는 사실을 알 수 없습니다.Optional<T>를 돌려주면 그 사실이 반환 타입에 드러나고, 확인 없이 값을 쓰는 코드는 컴파일되지 않습니다.Optional.empty()는 빈 결과를,of(value)는 반드시 있는 값을,ofNullable(value)는null일 수 있는 값을 감쌉니다.of(null)은 그 자리에서NullPointerException을 던집니다.- 값은
get()대신orElse,orElseGet,orElseThrow,ifPresent,ifPresentOrElse로 꺼냅니다. 비어 있을 때 할 일을 함께 적게 되므로 확인을 빠뜨리지 않습니다. map,flatMap,filter는 값이 있을 때만 함수를 실행하고, 비어 있으면 빈 결과를 끝까지 전달합니다. 함수가Optional을 돌려주면flatMap을 씁니다.- 빈
Optional은 '찾았지만 없음'이라는 정상 결과입니다. 조회 자체가 실패했다면 예외로 알리고, 결과가 여러 개일 수 있다면 빈 컬렉션으로 나타냅니다.
연습 문제
- 문서 목록에서
slug가stream이고 공개 상태인 문서를 찾아 제목을 소문자로 돌려주세요. 그런 문서가 없으면untitled를 돌려주되,null과get()은 쓰지 마세요. 문서는record Document(String slug, String title, boolean published)이고, 목록에는("stream", "Stream API", true)와("draft", "Draft", false)두 문서가 들어 있습니다. - 1번 풀이에서 두 가지를 각각 바꿔 봅니다. (가) 첫 문서의
published만false로 바꾸면 결과는 무엇이고, 제목을 꺼내는map은 실행될까요? (나) 첫 문서는 공개 상태로 두고 제목만 빈 문자열""로 바꾸면 결과는 무엇일까요? - 본문의
OptionalProfileLookup에서activeDisplayName(new User(3, 20L))과activeDisplayName(new User(4, 99L))에 각각orElse("anonymous")를 붙여 출력하면 무엇이 나올까요? 두 경우에findProfile은 호출될까요? 또findProfile이 예외를 던지도록 바꾸면 결과가anonymous가 될까요?
1번 풀이
스트림에서 조건에 맞는 첫 문서를 찾는 findFirst()가 이미 Optional<Document>를 돌려주므로, 여기에 map을 이어 붙이고 마지막에 orElse로 기본값을 정하면 됩니다.
import java.util.List;
public final class PublishedTitleLookupSolution {
private record Document(String slug, String title, boolean published) {}
public static void main(String[] args) {
List<Document> documents =
List.of(
new Document("stream", "Stream API", true),
new Document("draft", "Draft", false));
String title =
documents.stream()
.filter(Document::published)
.filter(document -> document.slug().equals("stream"))
.findFirst()
.map(Document::title)
.map(String::toLowerCase)
.orElse("untitled");
System.out.println(title);
}
}stream api두 filter를 통과하는 문서는 첫 문서 하나뿐이므로 findFirst()는 그 문서를 담은 Optional을 돌려줍니다. 이어서 첫 map이 제목 Stream API를 꺼내고, 둘째 map이 소문자로 바꿔 stream api가 됩니다. 값이 있으므로 orElse("untitled")의 기본값은 쓰이지 않습니다. 본문의 프로필 예제와 마찬가지로 인수 없는 toLowerCase는 기본 로케일을 따르므로, 위 결과는 한국어나 영어 로케일에서 실행한 것입니다.
2번 풀이
(가) 결과는 untitled이고, 제목을 꺼내는 map은 실행되지 않습니다. 첫 문서는 공개 상태가 아니어서 첫 filter에서 빠지고, draft도 공개 상태가 아니어서 빠집니다. 남는 문서가 없으니 findFirst()는 빈 Optional을 돌려주고, 뒤의 두 map은 함수를 호출하지 않은 채 빈 결과를 넘기며, 마지막 orElse가 untitled를 고릅니다.
(나) 결과는 빈 문자열이므로 아무것도 보이지 않는 줄이 출력됩니다. 빈 문자열도 값이므로 findFirst()의 결과는 비어 있지 않고, 두 map을 모두 거칩니다. "".toLowerCase()도 빈 문자열이고, 값이 있으니 orElse는 untitled를 고르지 않습니다. Optional은 값이 있는지만 구분할 뿐 제목이 비어 있으면 안 된다는 규칙까지 검사해 주지는 않는다는 점을 보여 주는 예입니다. 빈 제목도 '없음'으로 처리하려면 .filter(title -> !title.isBlank()) 같은 단계를 직접 넣어야 합니다.
3번 풀이
두 경우 모두 anonymous가 출력되고, 두 경우 모두 findProfile이 호출됩니다. findProfile의 첫 줄에 System.out.println("findProfile(" + id + ")");을 넣고 두 호출을 실행하면 다음과 같이 나옵니다.
findProfile(20)
anonymous
findProfile(99)
anonymous프로필 ID가 null이 아니므로 두 경우 모두 flatMap까지는 값이 있고, 그래서 findProfile이 실행됩니다. 20은 OLD 프로필을 찾지만 비활성이라 filter에서 비고, 99는 PROFILES.get(99L)이 null이어서 findProfile 안의 ofNullable에서 이미 비어 있습니다. 어느 쪽이든 이름을 바꾸는 두 map은 건너뜁니다. ID가 null인 사용자는 findProfile조차 호출하지 않았다는 점과 비교해 보세요.
findProfile이 예외를 던지도록 바꾸면 결과는 anonymous가 아닙니다. flatMap은 함수에서 난 예외를 잡지 않으므로 예외가 activeDisplayName과 main까지 그대로 전달되고, orElse는 실행될 기회조차 없습니다. 실패가 '없음'으로 바뀌지 않으니, 저장소 장애가 anonymous 뒤에 숨는 일도 생기지 않습니다.
다음 글
다음 글에서는 orElse와 orElseGet의 차이를 이해하기 위한 바탕으로, 메서드 인수가 언제 계산되는지와 Supplier로 계산을 필요한 순간까지 미루는 방법을 살펴봅니다.