Optional 사용 원칙
`Optional` 필드·매개변수·컬렉션·`get` 남용을 제거하고 기본형 `Optional`과 `null`·빈 상태 컬렉션·예외의 선택 기준을 정리합니다.
Optional은 “값이 없을 수 있는 메서드 결과”를 표현할 때 가장 선명합니다.
필드, 매개변수, 컬렉션 래퍼까지 퍼뜨리면 도메인 모델이 중첩되고 호출자 선택지가 늘어납니다.
Optional 사용 여부보다 부재 원소 수와 객체 불변식을 가장 단순한 타입으로 나타내는 것이 우선입니다.
Optional List의 상태 중복
목록 결과를 Optional로 감싸면 빈 Optional과 값이 있는 빈 List가 모두 “원소 0개”를 뜻하면서도 서로 다른 상태가 됩니다.
아래 실제 출력은 wrong-zero-states=2입니다.
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());
int zeroStates =
(absent.stream().flatMap(List::stream).count() == 0 ? 1 : 0)
+ (presentEmpty.stream().flatMap(List::stream).count() == 0 ? 1 : 0);
System.out.println("wrong-zero-states=" + zeroStates);
}
}검색 결과가 0개 이상이면 빈 List 하나로 충분합니다.
“질의를 수행하지 않음”과 “수행했지만 0개”가 실제로 다른 상태라면 Optional List보다 명명된 sealed 결과가 의미를 잘 전달합니다.
필드 타입의 도메인 불변식
Optional<Address> address 필드는 모든 접근자 호출자에게 Optional을 강제하고 직렬화·ORM·프레임워크 바인딩과 마찰을 만들 수 있습니다.
선택 관계라면 null을 허용하는 private 필드와 Optional 접근자를 쓸 수도 있고, 더 좋은 방법은 Address 미등록 상태를 도메인 타입이나 별도 집계로 만드는 것입니다.
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());
}
}Optional은 게터 반환에서만 만들어집니다.
내부 null 허용 선택이 항상 최선은 아니며 모델 프레임워크와 불변식에 맞춰 결정합니다.
필수 필드는 생성자에서 null 아님을 강제하고 Optional로 감싸지 않습니다.
매개변수 Optional의 부담
send(Optional<String> title)은 값이 있는 호출자도 Optional.of를 만들고 값이 없는 호출자도 빈 Optional을 만들어야 합니다.
오버로드, null 허용 어댑터, 요청 객체의 명시적 필드 상태가 더 나을 수 있습니다.
Optional 매개변수가 여러 개면 가능한 상태 조합이 급격히 늘어납니다.
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());
}
}두 메서드가 “제목 제공”과 “기본 제목” 의도를 이름으로 구분합니다.
매개변수 부재가 요청 스키마 핵심이면 sealed 명령이나 빌더로 상태를 제한합니다.
isPresent·get 대체
if (optional.isPresent()) return optional.get().name(); else return defaultValue;는 map(User::name).orElse(defaultValue)로 검사와 사용을 하나의 흐름에 묶을 수 있습니다.
복잡한 else 분기에서 여러 명령을 수행한다면 ifPresentOrElse 또는 명시적 분기가 더 읽기 좋습니다.
Optional 변수에 null을 대입하면 이중 부재가 생기므로 Optional 참조 자체는 항상 null이 아니어야 합니다.
메서드가 Optional을 반환하기로 했으면 null 대신 빈 Optional, 즉 Optional.empty()를 반환합니다.
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));
}
public static void main(String[] args) {
Map<Long, User> users = Map.of(1L, new User(1, "Min", true));
String name =
find(users, 1)
.filter(User::active)
.map(User::name)
.map(String::toLowerCase)
.orElse("inactive-or-missing");
System.out.println(name);
}
}존재 여부 검사와 값 사용 사이에 별도의 가변 상태가 없습니다.
Map 미적중과 비활성 상태를 같은 레이블로 합치는 것이 실제 요구 사항인지 확인합니다.
기본형 Optional과 스트림
OptionalInt, OptionalLong, OptionalDouble은 기본형 값 하나의 부재를 표현합니다.
IntStream.average()는 OptionalDouble, min은 OptionalInt를 반환합니다.
이 타입들은 제네릭 Optional과 API가 완전히 같지 않고 map·flatMap 같은 연산도 제한적입니다.
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) {
System.out.println(maximumPassingScore(List.of(30, 80, 70)).orElse(0));
System.out.println(maximumPassingScore(List.of(20, 40)).orElse(0));
}
}출력은 80과 0입니다.
0이 실제 점수와 혼동될 수 있다면 OptionalInt를 호출자까지 반환하거나 예외 정책을 선택합니다.
Optional을 쓰지 않을 선택 기준
컬렉션 결과에는 빈 컬렉션, 명령의 성공·실패 원인이 여러 개라면 Result 계층, 반드시 존재해야 하는 값에는 일반 타입과 입력 검사, 계산 실패에는 예외가 적합합니다.
성능에 민감한 기본형 반복문에서는 특수값이 도메인의 실제 값과 겹치지 않는지 확인한 뒤 별도 API를 사용할 수 있습니다.
엔터티 필드와 DTO에 Optional을 넣으면 라이브러리 지원을 확인합니다.
Java 직렬화 용도가 아니라도 JSON 매퍼가 원하는 표현으로 처리하는지 확인합니다.
프레임워크를 우회하려고 게터마다 null을 반환하면 타입 규칙이 다시 무너집니다.
Optional 반환 메서드 이름과 문서
find, 조회, maybe 계열 이름은 부재 가능성을 암시하지만 타입이 이미 설명합니다.
빈 상태가 “없음”, “권한 없음”, “필터 탈락” 중 무엇인지 Javadoc과 도메인 예외로 구분합니다.
정보 노출 때문에 의도적으로 같은 빈 상태를 반환할 수도 있습니다.
연습 문제
문서 slug를 받아 태그 목록을 반환하되 문서가 없거나 태그가 없으면 빈 불변 List를 반환하세요.
Optional<List<String>>와 null을 사용하지 마세요.
정답과 컬렉션 원소 수
Map.get 결과를 Optional로 내부 처리하되 public 반환은 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"));
}
}존재하는 문서는 두 태그, 미적중은 빈 List를 반환합니다.
Document 생성자가 태그 스냅샷을 만들어 외부 변경도 차단합니다.
Optional을 올바르게 사용하는 핵심은 사용 개수를 무조건 줄이는 것이 아니라 적절한 경계에 두는 것입니다.
0개 또는 1개를 반환할 때는 Optional, 0개 이상에는 컬렉션, 필수값에는 일반 타입, 여러 결과 상태에는 이름을 붙인 결과 타입을 사용합니다.