Optional을 쓸 곳과 피할 곳
Optional을 메서드 반환값에 쓰는 이유와 필드·매개변수·컬렉션에서 대신 쓸 방법, isPresent·get을 대신하는 메서드와 기본형 Optional을 예제로 익힙니다.
앞의 세 글에서 Optional을 만들고, map과 flatMap으로 변환하고, 값이 없을 때 쓸 기본값을 고르는 방법을 익혔습니다. 여기까지 오면 null이 나올 만한 곳마다 Optional을 두고 싶어집니다. 그런데 필드와 매개변수, 컬렉션까지 Optional로 감싸면 코드가 오히려 복잡해지고, null 문제도 기대만큼 사라지지 않습니다.
Java API 문서는 Optional의 용도를 분명하게 적어 두었습니다. '결과 없음'을 분명히 나타내야 하고 null을 쓰면 오류가 나기 쉬운 곳에서 메서드의 반환 타입으로 쓰라고 만든 것입니다(Optional API 문서의 API Note). 이 글에서는 이 기준에 따라 Optional이 잘 맞는 자리와 맞지 않는 자리를 예제로 나눠 보고, 맞지 않는 자리에서는 무엇을 대신 쓰면 되는지 정리합니다.
반환값에 Optional이 잘 맞는 이유
Optional<User> findById(long id)를 부르는 사람은 시그니처만 보고도 사용자가 없을 수 있다는 것을 압니다. 그리고 값을 받은 바로 그 자리에서 orElse나 orElseThrow로 없을 때 어떻게 할지 정합니다. Optional 객체는 대개 메서드가 돌려준 직후에 풀려서 사라지므로, 감싸고 푸는 수고도 호출 한 번에 한 번으로 끝납니다. 값이 없을 가능성을 알리는 일과 그 처리를 정하는 일이 한곳에 모이는 자리가 바로 반환값입니다.
이 장점은 Optional 참조 자체가 null이 아닐 때만 성립합니다. 그래서 API 문서는 타입이 Optional인 변수는 절대 null이면 안 되고 언제나 Optional 객체를 가리켜야 한다고 적어 두었습니다. Optional을 반환하기로 한 메서드가 값이 없을 때 null을 돌려주면, 호출자는 null 검사와 빈 Optional 검사를 둘 다 해야 합니다. 값이 없을 때는 언제나 Optional.empty()를 돌려주세요.
목록은 Optional로 감싸지 말고 빈 목록으로
검색 결과처럼 0개 이상의 값을 돌려주는 메서드에 Optional<List<String>>을 쓰면 어떻게 되는지 보겠습니다. 아래 코드는 문서에 태그가 없다는 사실을 두 가지 값으로 나타냅니다. 하나는 빈 Optional이고, 다른 하나는 빈 목록이 든 Optional입니다. Java 9에 추가된 Optional.stream()은 값이 있으면 그 값 하나만 든 스트림을, 없으면 빈 스트림을 만듭니다. 그래서 뒤에 flatMap(List::stream)을 이으면 두 경우 모두 태그를 하나씩 셀 수 있습니다.
import java.util.List;
import java.util.Optional;
public final class OptionalCollectionStateBug {
public static void main(String[] args) {
Optional<List<String>> absent = Optional.empty();
Optional<List<String>> presentEmpty = Optional.of(List.of());
System.out.println(absent + " -> 태그 " + absent.stream().flatMap(List::stream).count() + "개");
System.out.println(presentEmpty + " -> 태그 " + presentEmpty.stream().flatMap(List::stream).count() + "개");
System.out.println("같은 값인가? " + absent.equals(presentEmpty));
}
}각 줄은 Optional 자체를 찍은 뒤, 화살표 뒤에 펼친 태그 수를 붙입니다. 두 Optional의 toString()이 어떻게 다르게 찍히는지도 함께 보세요.
Optional.empty -> 태그 0개
Optional[[]] -> 태그 0개
같은 값인가? false첫 줄의 absent.stream()은 원소가 없는 스트림이라 flatMap을 거쳐도 0개입니다. 둘째 줄의 presentEmpty.stream()은 빈 목록 하나를 원소로 가진 스트림인데, flatMap(List::stream)이 그 목록을 펼치면 남는 원소가 없어서 역시 0개가 됩니다. 즉 '태그 0개'라는 한 가지 뜻을 서로 다른 두 값이 나타내고, 셋째 줄처럼 두 값은 같지도 않습니다. 그래서 이 메서드를 부르는 쪽은 빈 Optional과 빈 목록을 둘 다 고려해야 합니다. 예를 들어 ifPresent로 값이 있을 때만 '태그 목록' 제목과 태그를 출력하는 화면은, 빈 Optional이면 아무것도 출력하지 않지만 빈 목록이 든 Optional이면 내용 없는 제목만 출력합니다. 같은 '태그 0개'가 경우에 따라 다르게 보이는 것입니다.
목록에는 '없음'을 나타내는 방법이 이미 있습니다. 바로 빈 목록입니다. 메서드가 List<String>을 돌려주고 결과가 없을 때 List.of()를 돌려주면, 호출자는 for 문이든 스트림이든 하던 대로 쓰면 되고 0개일 때 따로 처리할 것도 없습니다. Set, Map, 배열을 돌려줄 때도 마찬가지입니다.
드물게 '조회를 아예 하지 않았다'와 '조회했지만 0개였다'를 정말로 구분해야 할 때가 있습니다. 이때도 Optional<List>로 두 상태를 억지로 나누기보다, 상태마다 이름을 붙인 결과 타입을 만드는 편이 뜻을 더 잘 전합니다. 이런 결과 타입은 sealed 계층과 상태 분기에서 다룹니다.
필드는 null을 허용하고 게터에서 Optional로 감싸기
Optional<Address> address 같은 필드는 편해 보이지만 문제가 여럿 있습니다. 먼저 Optional은 Serializable을 구현하지 않습니다. 그래서 Java 직렬화를 쓰는 클래스에 Optional 필드를 두면 직렬화할 때 java.io.NotSerializableException: java.util.Optional이 납니다. JPA 같은 ORM이나 JSON 라이브러리가 Optional 필드를 원하는 대로 처리하는지도 라이브러리마다 따로 확인해야 합니다. 게다가 필드 자체에 null이 들어갈 수 있으니, 앞에서 본 'Optional 참조는 null이면 안 된다'는 규칙을 생성자와 모든 대입문에서 지켜야 합니다.
흔히 쓰는 방법은 필드는 null을 허용하는 평범한 타입으로 두고, 바깥에 값을 내보내는 메서드에서만 Optional로 감싸는 것입니다. 다음 User는 닉네임을 정하지 않았을 수도 있는 사용자입니다.
import java.util.Optional;
public final class OptionalReturnBoundary {
private static final class User {
private String nickname;
void changeNickname(String nickname) {
if (nickname == null || nickname.isBlank()) {
throw new IllegalArgumentException("nickname");
}
this.nickname = nickname;
}
void clearNickname() {
nickname = null;
}
Optional<String> nickname() {
return Optional.ofNullable(nickname);
}
}
public static void main(String[] args) {
User user = new User();
System.out.println(user.nickname().orElse("anonymous"));
user.changeNickname("min");
System.out.println(user.nickname().orElseThrow());
}
}changeNickname과 clearNickname은 필드를 바꾸기만 하고 아무것도 출력하지 않습니다. 그래서 출력은 main이 nickname()의 결과를 꺼낸 두 줄뿐입니다.
anonymous
minnickname 필드는 private이므로 null이 클래스 밖으로 새어 나가지 않습니다. 바깥에서 닉네임을 얻는 길은 nickname() 하나뿐이고, 이 메서드가 Optional.ofNullable로 감싸서 돌려줍니다. 그래서 첫 줄은 닉네임이 아직 없어 orElse의 기본값 anonymous가 나오고, changeNickname("min")을 부른 뒤의 둘째 줄은 값이 있으므로 orElseThrow()가 min을 꺼냅니다. 닉네임을 바꾸는 메서드는 null과 공백 문자열을 거부하고, 닉네임을 지우는 일은 clearNickname()이 따로 맡습니다. 그래서 닉네임이 없는 상태로 되돌리는 길도 clearNickname() 하나로 정해집니다.
반드시 있어야 하는 값이라면 Optional로 감싸지 않습니다. 생성자에서 Objects.requireNonNull 등으로 null을 거부해 두면 그 필드는 객체가 살아 있는 동안 늘 값이 있으므로, 게터도 평범한 타입을 돌려주면 됩니다. 한편 값이 없는 상태가 도메인에서 큰 의미를 가질 수도 있습니다. 예를 들어 주소를 등록한 회원과 등록하지 않은 회원이 할 수 있는 일이 다르다면, null 허용 필드 하나로 버티기보다 두 상태를 별도의 타입으로 나누는 편이 나을 수 있습니다. 어느 쪽이 맞는지는 모델과 프레임워크의 사정에 따라 정합니다.
매개변수는 Optional로 받지 않기
매개변수를 Optional로 받으면 부담이 호출하는 쪽으로 넘어갑니다. 다음 label은 제목이 없을 수도 있다는 뜻으로 Optional<String>을 받습니다.
import java.util.Optional;
public final class OptionalParameterBurden {
static String label(Optional<String> title) {
return "TITLE: " + title.orElse("untitled");
}
public static void main(String[] args) {
System.out.println(label(Optional.of("Stream")));
System.out.println(label(Optional.empty()));
System.out.println(label(null));
}
}세 호출은 try로 감싸지 않았으므로, 하나라도 예외를 던지면 그 자리에서 스택 트레이스를 남기고 프로그램이 끝납니다. 실행 결과는 다음과 같습니다.
TITLE: Stream
TITLE: untitled
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.util.Optional.orElse(Object)" because "<parameter1>" is null
at OptionalParameterBurden.label(OptionalParameterBurden.java:5)
at OptionalParameterBurden.main(OptionalParameterBurden.java:11)첫 두 줄을 얻으려면 제목이 있는 호출자는 Optional.of("Stream")으로, 제목이 없는 호출자는 Optional.empty()로 값을 감싸야 합니다. 셋째 호출은 Optional 자리에 null을 넘겼는데, 컴파일러는 이것을 막지 않습니다. 그래서 title.orElse(...)를 부르는 순간 NullPointerException이 납니다. 메시지의 <parameter1>은 첫 번째 매개변수인 title을 가리킵니다. javac가 기본 설정에서 변수 이름 정보를 클래스 파일에 남기지 않아 번호로 표시된 것으로, 클래스와 인스턴스를 다룬 글에서 본 <local1>과 같은 이유입니다.
결국 Optional 매개변수는 '값 있음', '빈 Optional', 'null'이라는 세 가지 상태를 받게 됩니다. null을 막아 주지도 못하는 감싸개를 하나 더 받은 셈이고, 이런 매개변수가 두 개면 따질 조합은 아홉 가지로 늘어납니다. 값이 있을 때와 없을 때를 메서드로 나누면 호출하는 쪽이 훨씬 단순해집니다.
public final class ParameterWithoutOptional {
static String label(String title) {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("title");
}
return "TITLE: " + title;
}
static String defaultLabel() {
return "TITLE: untitled";
}
public static void main(String[] args) {
System.out.println(label("Stream"));
System.out.println(defaultLabel());
}
}이번에는 Optional을 만들 필요 없이 문자열을 바로 넘기거나, 제목이 없을 때 쓰는 메서드를 따로 부릅니다. 출력은 앞 예제의 첫 두 줄과 같아야 합니다.
TITLE: Stream
TITLE: untitled제목이 있으면 label("Stream")을, 없으면 defaultLabel()을 부르므로 호출하는 쪽의 의도가 메서드 이름에 드러납니다. label(String)은 null이나 공백 제목을 받으면 메서드 첫머리에서 바로 IllegalArgumentException을 던지므로, 잘못된 값이 메서드 안쪽까지 들어오지 않습니다. 이름을 나누는 대신 label()과 label(String)으로 오버로딩해도 됩니다. 선택 항목이 많아서 메서드를 나누기 어렵다면 빌더나 요청 객체를 만들고, 항목마다 값이 있는지 없는지를 그 객체가 관리하게 합니다.
isPresent와 get 대신 쓰는 메서드
Optional을 받아서 isPresent()로 확인하고 get()으로 꺼내는 코드는 null 검사를 모양만 바꿔 쓴 것에 가깝습니다. 확인하는 줄과 꺼내는 줄이 떨어져 있어서, 코드를 고치다 보면 확인 없이 get()만 남기 쉽습니다. 그래서 보통은 map(...).orElse(...)처럼 값이 있을 때 할 일과 없을 때 쓸 값을 한 식에 쓰는 방식을 권합니다.
다만 이렇게 바꾼다고 언제나 결과가 같지는 않습니다. 다음 코드는 두 방식으로 사용자 이름을 꺼내고, 이름이 정상인 경우, 빈 문자열인 경우, null인 경우, 사용자 자체가 없는 경우를 차례로 비교합니다.
import java.util.Optional;
public final class IsPresentVersusMap {
private record User(String name) {}
static String withGet(Optional<User> user) {
if (user.isPresent()) {
return user.get().name();
}
return "anonymous";
}
static String withMap(Optional<User> user) {
return user.map(User::name).orElse("anonymous");
}
public static void main(String[] args) {
var inputs = new Object[][] {
{"이름 Min", Optional.of(new User("Min"))},
{"빈 이름", Optional.of(new User(""))},
{"이름 null", Optional.of(new User(null))},
{"User 없음", Optional.<User>empty()}
};
for (Object[] input : inputs) {
@SuppressWarnings("unchecked")
Optional<User> user = (Optional<User>) input[1];
System.out.println(input[0] + ": get=[" + withGet(user) + "], map=[" + withMap(user) + "]");
}
}
}각 줄은 입력 이름표 뒤에 withGet과 withMap의 결과를 나란히 찍습니다. 두 결과가 서로 다른 줄이 있는지 찾아보세요.
이름 Min: get=[Min], map=[Min]
빈 이름: get=[], map=[]
이름 null: get=[null], map=[anonymous]
User 없음: get=[anonymous], map=[anonymous]대괄호는 빈 문자열이 눈에 보이도록 붙인 것입니다. 네 경우 가운데 셋은 결과가 같고 셋째 줄만 다릅니다. withGet은 사용자가 있으니 get().name()이 돌려준 null을 그대로 반환합니다. 반면 map은 매퍼가 null을 돌려주면 빈 Optional을 만들도록 정해져 있어서, 뒤의 orElse가 기본값 anonymous를 고릅니다. 빈 문자열은 null이 아니므로 둘째 줄처럼 두 방식 모두 그대로 돌려줍니다. 아래 단계 그림은 셋째 줄의 입력을 두 식에 각각 넣었을 때 중간 상태가 어떻게 갈라지는지 보여 줍니다.
직접 반환과 map 치환이 달라지는 null 경계
같은 Optional<User>의 null name이 직접 분기에서는 null로 반환되지만 map에서는 empty가 되어 anonymous를 선택합니다. 동일 입력의 중간 상태를 비교하면 단순 치환의 의미 변경이 드러납니다.
직접 반환과 map 치환이 달라지는 null 경계
리팩터링 전후의 null 의미
직접 반환과 map 치환이 달라지는 null 경계
User는 있어도 name이 null일 수 있습니다. 직접 반환은 null을 남기지만 Optional.map은 empty로 바꿔 기본값을 선택합니다.
| 조회 상태 | isPresent 분기에서 name 반환 | map 뒤 orElse 반환 |
|---|---|---|
| User 있음·이름 있음 | 이름 | 이름 |
| User 있음·이름 null | null | defaultValue |
| User 없음 | defaultValue | defaultValue |
- User 있음·이름 있음
- isPresent 분기에서 name 반환
- 이름
- map 뒤 orElse 반환
- 이름
- User 있음·이름 null
- isPresent 분기에서 name 반환
- null
- map 뒤 orElse 반환
- defaultValue
- User 없음
- isPresent 분기에서 name 반환
- defaultValue
- map 뒤 orElse 반환
- defaultValue
같은 non-null Optional과 같은 defaultValue를 두고 비교한 API 의미입니다. 이름이 null인 행은 아래 main의 입력이 아니며, 기본값을 만드는 식에 부수 효과가 있다면 인수 평가 시점도 따로 확인해야 합니다.
이 예의 조건
- name=null인 User를 담은 동일한 non-null Optional을 두 독립 식에 적용하는 반례입니다. 원 main의 Min 출력이 아닙니다.
- 이름 빈 문자열은 null이 아니므로 map이 empty로 바꾸지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 동일한 non-null Optional에 name=null인 User가 있는 반례를 두 독립 식에 적용한다. 직접 반환한 name은 null이고 map의 null 결과는 empty여서 orElse 기본값을 반환한다. 5개 노드와 4개의 값 변환 관계이며 mobile도 동일하다. 원 main의 Min 입력을 바꾼 실제 출력이나 서로 순차적으로 실행하는 관계를 뜻하지 않는다. 이름이 빈 String이면 empty 변환 대상이 아니다. |
| 구분 | 내용 |
|---|---|
| 설명 | 동일한 non-null Optional에 name=null인 User가 있는 반례를 두 독립 식에 적용한다. 직접 반환한 name은 null이고 map의 null 결과는 empty여서 orElse 기본값을 반환한다. 5개 노드와 4개의 값 변환 관계이며 mobile도 동일하다. 원 main의 Min 입력을 바꾼 실제 출력이나 서로 순차적으로 실행하는 관계를 뜻하지 않는다. 이름이 빈 String이면 empty 변환 대상이 아니다. |
| 전체 읽기 정보 | 직접 반환과 map 치환이 달라지는 null 경계 직접 반환과 map 치환이 달라지는 null 경계 직접 반환과 map 치환이 달라지는 null 경계 리팩터링 전후의 null 의미 직접 반환과 map 치환이 달라지는 null 경계 User는 있어도 name이 null일 수 있습니다. 직접 반환은 null을 남기지만 Optional.map은 empty로 바꿔 기본값을 선택합니다. 직접 반환과 map 치환이 달라지는 null 경계 조회 상태 isPresent 분기에서 name 반환 map 뒤 orElse 반환 User 있음·이름 있음 이름 이름 User 있음·이름 null null defaultValue User 없음 defaultValue defaultValue User 있음·이름 있음 isPresent 분기에서 name 반환 이름 map 뒤 orElse 반환 이름 User 있음·이름 null isPresent 분기에서 name 반환 null map 뒤 orElse 반환 defaultValue User 없음 isPresent 분기에서 name 반환 defaultValue map 뒤 orElse 반환 defaultValue 같은 non-null Optional과 같은 defaultValue를 두고 비교한 API 의미입니다. 이름이 null인 행은 아래 main의 입력이 아니며, 기본값을 만드는 식에 부수 효과가 있다면 인수 평가 시점도 따로 확인해야 합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 Optional 입력 직접 분기와 map 식 모두 User가 있고 그 name만 null인 입력에서 출발합니다. |
|
|
| 각 필드 추출과 변환 직접 식은 get().name()을 읽고 map은 User::name의 null 결과를 empty로 바꿉니다. |
|
|
| 각 식의 반환 직접 식은 null을 반환하고 map 식은 empty의 orElse 기본값을 반환합니다. |
|
|
직접 반환과 map 치환이 달라지는 null 경계
같은 Optional<User>의 null name이 직접 분기에서는 null로 반환되지만 map에서는 empty가 되어 anonymous를 선택합니다. 동일 입력의 중간 상태를 비교하면 단순 치환의 의미 변경이 드러납니다.
직접 반환과 map 치환이 달라지는 null 경계
리팩터링 전후의 null 의미
직접 반환과 map 치환이 달라지는 null 경계
User는 있어도 name이 null일 수 있습니다. 직접 반환은 null을 남기지만 Optional.map은 empty로 바꿔 기본값을 선택합니다.
| 조회 상태 | isPresent 분기에서 name 반환 | map 뒤 orElse 반환 |
|---|---|---|
| User 있음·이름 있음 | 이름 | 이름 |
| User 있음·이름 null | null | defaultValue |
| User 없음 | defaultValue | defaultValue |
- User 있음·이름 있음
- isPresent 분기에서 name 반환
- 이름
- map 뒤 orElse 반환
- 이름
- User 있음·이름 null
- isPresent 분기에서 name 반환
- null
- map 뒤 orElse 반환
- defaultValue
- User 없음
- isPresent 분기에서 name 반환
- defaultValue
- map 뒤 orElse 반환
- defaultValue
같은 non-null Optional과 같은 defaultValue를 두고 비교한 API 의미입니다. 이름이 null인 행은 아래 main의 입력이 아니며, 기본값을 만드는 식에 부수 효과가 있다면 인수 평가 시점도 따로 확인해야 합니다.
이 예의 조건
- name=null인 User를 담은 동일한 non-null Optional을 두 독립 식에 적용하는 반례입니다. 원 main의 Min 출력이 아닙니다.
- 이름 빈 문자열은 null이 아니므로 map이 empty로 바꾸지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 동일한 non-null Optional에 name=null인 User가 있는 반례를 두 독립 식에 적용한다. 직접 반환한 name은 null이고 map의 null 결과는 empty여서 orElse 기본값을 반환한다. 5개 노드와 4개의 값 변환 관계이며 mobile도 동일하다. 원 main의 Min 입력을 바꾼 실제 출력이나 서로 순차적으로 실행하는 관계를 뜻하지 않는다. 이름이 빈 String이면 empty 변환 대상이 아니다. |
| 구분 | 내용 |
|---|---|
| 설명 | 동일한 non-null Optional에 name=null인 User가 있는 반례를 두 독립 식에 적용한다. 직접 반환한 name은 null이고 map의 null 결과는 empty여서 orElse 기본값을 반환한다. 5개 노드와 4개의 값 변환 관계이며 mobile도 동일하다. 원 main의 Min 입력을 바꾼 실제 출력이나 서로 순차적으로 실행하는 관계를 뜻하지 않는다. 이름이 빈 String이면 empty 변환 대상이 아니다. |
| 전체 읽기 정보 | 직접 반환과 map 치환이 달라지는 null 경계 직접 반환과 map 치환이 달라지는 null 경계 직접 반환과 map 치환이 달라지는 null 경계 리팩터링 전후의 null 의미 직접 반환과 map 치환이 달라지는 null 경계 User는 있어도 name이 null일 수 있습니다. 직접 반환은 null을 남기지만 Optional.map은 empty로 바꿔 기본값을 선택합니다. 직접 반환과 map 치환이 달라지는 null 경계 조회 상태 isPresent 분기에서 name 반환 map 뒤 orElse 반환 User 있음·이름 있음 이름 이름 User 있음·이름 null null defaultValue User 없음 defaultValue defaultValue User 있음·이름 있음 isPresent 분기에서 name 반환 이름 map 뒤 orElse 반환 이름 User 있음·이름 null isPresent 분기에서 name 반환 null map 뒤 orElse 반환 defaultValue User 없음 isPresent 분기에서 name 반환 defaultValue map 뒤 orElse 반환 defaultValue 같은 non-null Optional과 같은 defaultValue를 두고 비교한 API 의미입니다. 이름이 null인 행은 아래 main의 입력이 아니며, 기본값을 만드는 식에 부수 효과가 있다면 인수 평가 시점도 따로 확인해야 합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 Optional 입력 직접 분기와 map 식 모두 User가 있고 그 name만 null인 입력에서 출발합니다. |
|
|
| 각 필드 추출과 변환 직접 식은 get().name()을 읽고 map은 User::name의 null 결과를 empty로 바꿉니다. |
|
|
| 각 식의 반환 직접 식은 null을 반환하고 map 식은 empty의 orElse 기본값을 반환합니다. |
|
|
그러니 isPresent와 get을 map으로 바꿀 때는, 필드가 null인 경우의 결과가 null에서 기본값으로 바뀐다는 점을 알고 바꿔야 합니다. 대개는 이 변화가 더 바람직하지만, 호출자가 null을 받아 따로 처리하고 있었다면 동작이 달라집니다. 빈 문자열까지 기본값으로 바꾸고 싶다면 filter(name -> !name.isBlank()) 같은 조건을 따로 넣어야 합니다. 이것은 문법만 바꾼 것이 아니라 규칙을 바꾼 것이므로, 리팩터링이 아니라 기능 변경으로 다뤄야 합니다.
조회부터 최종 값까지를 한 식으로 이으면 다음과 같습니다. 활성 사용자의 이름을 소문자로 돌려주고, 사용자가 없거나 비활성이면 정해 둔 문자열을 돌려줍니다.
import java.util.Map;
import java.util.Optional;
public final class OptionalPipelineBestPractice {
private record User(long id, String name, boolean active) {}
private static Optional<User> find(Map<Long, User> users, long id) {
return Optional.ofNullable(users.get(id));
}
static String activeName(Map<Long, User> users, long id) {
return find(users, id)
.filter(User::active)
.map(User::name)
.map(String::toLowerCase)
.orElse("inactive-or-missing");
}
public static void main(String[] args) {
Map<Long, User> users =
Map.of(1L, new User(1, "Min", true), 2L, new User(2, "Seo", false));
System.out.println(activeName(users, 1));
System.out.println(activeName(users, 2));
System.out.println(activeName(users, 3));
}
}find는 Map.get의 결과를 Optional.ofNullable로 감싸므로, 없는 ID를 조회해도 예외 없이 빈 Optional을 돌려줍니다. 세 호출은 차례로 활성 사용자, 비활성 사용자, 없는 사용자를 조회합니다.
min
inactive-or-missing
inactive-or-missing1번 사용자는 filter를 통과하고 두 번의 map을 거쳐 min이 됩니다. 2번은 있지만 비활성이라 filter에서 빈 Optional이 되고, 3번은 Map에 없으므로 find에서부터 비어 있습니다. 확인과 사용이 한 식 안에 있으니 그 사이에 확인을 빠뜨리거나 값이 바뀔 여지가 없습니다. 기본값은 문자열 상수이므로 orElse로 충분합니다. 기본값을 만드는 데 조회나 객체 생성이 필요하다면, orElse와 orElseGet을 비교한 글에서 본 것처럼 값이 있을 때도 인수를 먼저 계산하는 orElse 대신 orElseGet을 씁니다.
둘째와 셋째 줄에서 보듯이 이 식은 '없는 사용자'와 '비활성 사용자'를 같은 결과로 합칩니다. 화면에 보여 줄 문구라면 이것으로 충분하지만, 두 경우를 다르게 처리해야 한다면 빈 Optional 하나로는 그 차이를 전할 수 없습니다. 연쇄를 쓰기 전에 합쳐도 되는 경우인지부터 확인하세요.
연쇄보다 다른 방법이 더 읽기 좋은 경우도 있습니다. 값이 있을 때와 없을 때 각각 여러 문장을 실행해야 한다면 ifPresentOrElse나 평범한 if 문이 낫습니다. 또 null일 수 있는 지역 변수에 기본값을 주려고 Optional.ofNullable(nickname).orElse("anonymous")처럼 Optional을 만들었다가 바로 버리는 코드는, Java 9부터 있는 Objects.requireNonNullElse(nickname, "anonymous")로 더 직접 쓸 수 있습니다. Optional은 메서드가 돌려준 결과를 다루는 도구이지, null 검사를 짧게 쓰는 문법이 아닙니다.
기본형 값에는 OptionalInt·OptionalLong·OptionalDouble
int, long, double 값 하나가 없을 수도 있다면 OptionalInt, OptionalLong, OptionalDouble을 씁니다. Optional<Integer>로도 쓸 수는 있지만 값을 Integer 객체로 박싱해야 합니다. 그래서 기본형 스트림은 처음부터 이 타입들을 돌려줍니다. 예를 들어 IntStream의 max()와 min()은 OptionalInt를, average()는 OptionalDouble을 돌려줍니다. 원소가 하나도 없으면 최댓값도 평균도 정할 수 없기 때문입니다.
import java.util.List;
import java.util.OptionalInt;
public final class PrimitiveOptionalScore {
static OptionalInt maximumPassingScore(List<Integer> scores) {
return scores.stream().mapToInt(Integer::intValue).filter(score -> score >= 60).max();
}
public static void main(String[] args) {
OptionalInt passed = maximumPassingScore(List.of(30, 80, 70));
OptionalInt failed = maximumPassingScore(List.of(20, 40));
System.out.println(passed + " -> " + passed.orElse(0));
System.out.println(failed + " -> " + failed.orElse(0));
}
}OptionalInt의 toString()은 값이 있으면 OptionalInt[값], 없으면 OptionalInt.empty를 찍습니다. 두 목록 가운데 둘째 목록에는 60점 이상인 점수가 하나도 없습니다.
OptionalInt[80] -> 80
OptionalInt.empty -> 0첫 목록에서는 60점 이상인 80과 70이 filter를 통과하고, 그중 최댓값 80이 OptionalInt[80]에 담깁니다. 둘째 목록은 통과하는 점수가 없어서 max()가 빈 OptionalInt를 돌려주고, 화살표 오른쪽의 0은 orElse(0)이 만든 값입니다. max()가 0을 계산한 것이 아닙니다. 이 예제는 60점 이상만 고르므로 0이 실제 합격 점수와 헷갈릴 일이 없습니다. 하지만 0점도 의미 있는 결과가 될 수 있는 계산이라면, orElse(0)으로 바꾸는 순간 '해당하는 점수 없음'과 '최고 점수 0점'을 구분할 수 없게 됩니다. 그럴 때는 OptionalInt를 그대로 돌려줘서 호출자가 판단하게 합니다.
기본형 Optional은 일반 Optional과 메서드 구성이 다릅니다. getAsInt(), orElse, orElseGet, orElseThrow, ifPresent, ifPresentOrElse, stream()은 있지만 map, flatMap, filter, or는 없습니다. 그래서 값을 다른 타입으로 바꾸는 연쇄가 필요하면 값을 꺼낸 다음에 처리해야 합니다.
스트림 안에서 생긴 Optional 펼치기
ID 여러 개를 한꺼번에 조회하면 Optional을 돌려주는 메서드를 스트림 안에서 부르게 됩니다. 결과를 그대로 모으면 List<Optional<String>>이 되는데, 이런 목록은 앞에서 본 Optional<List>만큼 다루기 번거롭습니다. 이때 Optional.stream()을 flatMap에 넘기면 값이 있는 것만 남길 수 있습니다.
import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class OptionalStreamFlatten {
private static final Map<Long, String> NAMES = Map.of(1L, "min", 3L, "seo");
static Optional<String> findName(long id) {
return Optional.ofNullable(NAMES.get(id));
}
public static void main(String[] args) {
List<Long> ids = List.of(1L, 2L, 3L);
List<Optional<String>> wrapped = ids.stream().map(OptionalStreamFlatten::findName).toList();
List<String> names =
ids.stream().map(OptionalStreamFlatten::findName).flatMap(Optional::stream).toList();
System.out.println(wrapped);
System.out.println(names);
}
}NAMES에는 2번 ID가 없으므로 findName(2L)은 빈 Optional을 돌려줍니다. 두 줄은 같은 조회 결과를 map만 거쳐 모았을 때와 flatMap(Optional::stream)까지 거쳐 모았을 때입니다.
[Optional[min], Optional.empty, Optional[seo]]
[min, seo]첫 줄은 map만 거친 결과라서 없는 2번도 Optional.empty로 한 자리를 차지합니다. 둘째 줄에서는 flatMap(Optional::stream)이 각 Optional을 원소가 0개 또는 1개인 스트림으로 바꾼 뒤 이어 붙이므로, 빈 Optional은 사라지고 값만 남습니다. Java 8에서는 같은 일을 filter(Optional::isPresent).map(Optional::get)으로 썼지만, Java 9부터는 이 한 단계로 충분합니다.
이처럼 스트림 안에서 잠깐 생긴 Optional은 그 자리에서 펼치고, List<Optional<T>>나 Map<K, Optional<V>>처럼 컬렉션의 원소로 저장하지 않는 것이 좋습니다. 없는 값은 컬렉션에 넣지 않으면 되므로, 원소마다 '있는지 없는지'를 따로 들고 다닐 이유가 없습니다.
Optional 대신 쓸 것 고르기
지금까지 본 내용을 상황별로 묶으면 다음과 같습니다.
| 상황 | 쓸 것 |
|---|---|
| 결과가 0개 또는 1개인 조회 | Optional<T> 반환 |
| 결과가 0개 이상인 조회 | 빈 컬렉션을 돌려주는 List<T> 등 |
| 반드시 있어야 하는 값 | 평범한 타입과 생성자·메서드 첫머리의 null 검사 |
| 없을 수도 있는 필드 | null을 허용하는 private 필드와 Optional을 돌려주는 메서드 |
| 생략할 수 있는 입력 | 오버로딩, 메서드 분리, 빌더나 요청 객체 |
| 기본형 값 하나의 결과 | OptionalInt, OptionalLong, OptionalDouble |
| 계산 자체가 실패함 | 예외 |
| 실패 원인이나 결과 상태가 여럿 | 상태마다 이름을 붙인 결과 타입 |
마지막 두 줄은 이 장의 첫 글에서 본 내용과 이어집니다. '찾았지만 없음'은 정상적인 결과이므로 빈 Optional이 어울립니다. 반면 데이터베이스 연결 실패처럼 계산 자체가 실패한 경우를 빈 Optional로 바꾸면, 장애가 평범한 '없음'처럼 보여 원인을 찾기 어려워집니다.
성능도 짚어 두겠습니다. Optional도 객체이므로 값을 감쌀 때마다 객체가 하나 생깁니다. 대부분의 업무 코드에서는 무시해도 될 비용이지만, 수백만 번 도는 기본형 반복문 안이라면 기본형 Optional이나, 실제 값과 절대 겹치지 않는 특수값을 쓰는 방법을 측정해서 비교할 수 있습니다. String.indexOf가 찾지 못하면 -1을 돌려주는 것이 특수값의 예입니다. 특수값을 고를 때는 그 값이 정말로 실제 결과와 겹치지 않는지부터 확인해야 합니다.
빈 결과가 무슨 뜻인지 문서에 적기
findById처럼 find로 시작하는 이름은 결과가 없을 수 있다는 느낌을 주지만, 그 정보는 반환 타입이 이미 알려 줍니다. 이름보다 중요한 것은 빈 결과가 정확히 어떤 상황인가입니다. 빈 Optional이 '그런 문서가 없다'인지, '있지만 볼 권한이 없다'인지, '있지만 공개 조건에 맞지 않는다'인지는 타입만 보고 알 수 없으므로 Javadoc에 적어 둡니다.
/**
* slug로 공개 문서를 찾습니다.
*
* @param slug 찾을 문서의 slug
* @param viewer 문서를 보려는 사용자
* @return 찾은 문서. 문서가 없거나, 공개되지 않았거나, viewer에게 볼 권한이 없으면 빈 Optional
*/
Optional<Document> findPublished(String slug, User viewer);이 예처럼 여러 경우를 일부러 하나의 빈 결과로 합칠 때도 있습니다. 권한이 없는 사용자에게 '문서는 있지만 볼 수 없다'고 알려 주면, 그 말만으로 문서가 존재한다는 정보가 새어 나가기 때문입니다. 반대로 호출자가 경우마다 다르게 대응해야 한다면, 빈 Optional 하나에 여러 뜻을 몰아넣지 말고 권한 오류는 예외로 던지거나 결과 타입을 나눕니다.
정리
Optional은 결과가 0개 또는 1개인 메서드의 반환 타입으로 씁니다.Optional참조 자체는null이면 안 되므로, 값이 없으면Optional.empty()를 돌려줍니다.- 0개 이상의 결과는 빈 컬렉션으로 나타냅니다.
Optional<List<T>>는 '0개'를 서로 다른 두 값으로 만들어 호출자의 일을 늘립니다. - 없을 수도 있는 필드는
null을 허용하는private필드와Optional을 돌려주는 메서드로 다룹니다. 생략할 수 있는 입력은 오버로딩이나 메서드 분리로 다루며,Optional매개변수는null까지 세 가지 상태를 받는다는 점을 기억합니다. isPresent와get대신map,filter,orElse계열을 씁니다. 다만map은 매퍼가 돌려준null을 빈Optional로 바꾸므로, 바꾸기 전후의 결과가 달라질 수 있습니다.- 기본형 값에는
OptionalInt등을 쓰되map과filter가 없다는 점을 알아 둡니다. 스트림 안에서 생긴Optional은flatMap(Optional::stream)으로 바로 펼칩니다.
연습 문제
- 문서
slug를 받아 태그 목록을 돌려주는 메서드를 만드세요. 문서가 없거나 태그가 없으면 빈 불변List를 돌려주고,Optional<List<String>>와null은 반환 타입이나 반환값으로 쓰지 마세요. - 1번의
Document가 생성자에서List.copyOf로 태그를 복사한다고 합시다. 다음 세 가지를 예상한 뒤 코드로 확인하세요. 없는 문서와 태그가 하나도 없는 문서의 반환값은 구분되나요? 문서를 만든 뒤 원본 목록에 태그를 추가하면 문서의 태그도 늘어나나요? 돌려받은 목록에add를 부르면 어떻게 되나요? - 다음 메서드의 반환 타입을 고르고 이유를 설명하세요. (가) ID로 회원 한 명 찾기, (나) 특정 태그가 붙은 글을 모두 찾기, (다) 비어 있을 수도 있는 점수 목록의 평균 구하기, (라) 실행에 꼭 필요한 설정 파일 읽기.
1번 풀이
Map.get은 키가 없으면 null을 돌려주므로 메서드 안에서는 Optional.ofNullable로 감싸서 처리하고, 바깥으로는 0개 이상을 나타내는 List 하나만 돌려줍니다.
import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class DocumentTagsSolution {
private record Document(String slug, List<String> tags) {
private Document {
tags = List.copyOf(tags);
}
}
static List<String> tags(Map<String, Document> documents, String slug) {
return Optional.ofNullable(documents.get(slug)).map(Document::tags).orElseGet(List::of);
}
public static void main(String[] args) {
Map<String, Document> documents =
Map.of("stream", new Document("stream", List.of("java", "api")));
System.out.println(tags(documents, "stream"));
System.out.println(tags(documents, "missing"));
}
}실행 결과:
[java, api]
[]stream 문서는 태그 두 개를 돌려주고, 없는 문서는 orElseGet(List::of)가 만든 빈 목록을 돌려줍니다. 여기서는 orElse(List.of())라고 써도 결과가 같습니다. 빈 불변 목록을 만드는 비용은 무시할 만하기 때문입니다. orElseGet이 확실히 나은 경우는 기본값을 만드는 데 조회나 큰 객체 생성처럼 비용이 드는 때입니다.
2번 풀이
예상을 확인하는 코드는 다음과 같습니다. tags 메서드와 Document는 1번 풀이와 같습니다.
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class DocumentTagsCopyCheck {
private record Document(String slug, List<String> tags) {
private Document {
tags = List.copyOf(tags);
}
}
static List<String> tags(Map<String, Document> documents, String slug) {
return Optional.ofNullable(documents.get(slug)).map(Document::tags).orElseGet(List::of);
}
public static void main(String[] args) {
List<String> source = new ArrayList<>(List.of("java"));
Map<String, Document> documents =
Map.of(
"live", new Document("live", source),
"empty", new Document("empty", List.of()));
System.out.println("missing=" + tags(documents, "missing") + ", empty=" + tags(documents, "empty"));
source.add("api");
System.out.println("원본=" + source + ", 문서=" + tags(documents, "live"));
try {
tags(documents, "live").add("api");
} catch (UnsupportedOperationException e) {
System.out.println("반환 목록에 add: " + e.getClass().getSimpleName());
}
}
}실행 결과:
missing=[], empty=[]
원본=[java, api], 문서=[java]
반환 목록에 add: UnsupportedOperationException첫 줄처럼 없는 문서와 태그가 없는 문서는 둘 다 빈 목록이라 구분되지 않습니다. 이 메서드는 '태그가 몇 개인가'에만 답하기로 했으므로 의도한 결과입니다. 문서가 있는지까지 알려야 한다면 문서를 찾는 메서드를 따로 두고 Optional<Document>를 돌려주게 합니다. 둘째 줄에서 원본에 api를 추가해도 문서의 태그가 그대로인 것은, List.copyOf가 생성 시점에 목록을 복사해 두었기 때문입니다. 셋째 줄의 예외는 List.copyOf가 만든 목록이 수정할 수 없는 목록이라서 납니다. 다만 List.copyOf는 목록의 구조만 복사하고 원소 객체까지 복사하지는 않습니다. 이 예제의 원소는 바꿀 수 없는 String이라 문제가 없지만, 원소가 가변 객체라면 원소 안의 변경은 문서에도 그대로 보입니다.
3번 풀이
(가)는 Optional<Member>입니다. 결과가 0개 또는 1개이고, 회원이 없는 것은 정상적인 결과이기 때문입니다.
(나)는 List<Post>입니다. 결과가 0개 이상이므로 해당하는 글이 없으면 빈 목록을 돌려주면 됩니다. Optional<List<Post>>로 감쌀 이유가 없습니다.
(다)는 OptionalDouble입니다. IntStream.average()가 그렇듯이 목록이 비어 있으면 평균을 정할 수 없습니다. 0.0을 돌려주면 실제 평균이 0점인 경우와 구분할 수 없고, Optional<Double>은 불필요한 박싱이 생깁니다.
(라)는 Config 같은 평범한 타입을 돌려주고, 파일이 없으면 예외를 던집니다. 설정 파일이 없으면 프로그램이 계속 실행될 수 없으므로 '없음'은 정상적인 결과가 아니라 실패입니다. Optional을 돌려주면 모든 호출자가 어차피 실패로 처리해야 할 상황을 각자 판단하게 됩니다.
다음 글
기본 메서드와 호환성에서는 이 장의 두 번째 주제인 인터페이스 확장으로 넘어갑니다. 이미 여러 클래스가 구현한 인터페이스에 메서드를 추가하면서도 기존 구현을 깨뜨리지 않는 default 메서드를 다룹니다.