Optional 반환 계약
`null` 반환의 누락 위험을 `Optional`의 0개·1개 모델로 바꾸고 생성·조회·변환·대체 경로 API를 목적별로 선택합니다.
메서드가 null을 반환할 수 있는데 시그니처가 User뿐이면 호출자는 문서나 구현을 읽어야 부재를 압니다.
Optional<User>는 값이 없을 수 있다는 사실을 타입에 올려 호출자가 처리하도록 만듭니다.
Optional은 모든 null을 감싸는 컨테이너가 아니라 계산 결과가 0개 또는 1개라는 반환 규칙입니다.
Optional.of와 null
Optional.of는 null이 아닌 값만 받습니다.
아래 코드는 저장소 결과가 null인데 of로 감싸므로 실제 NullPointerException이 발생합니다.
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;
}
}null 허용 경계를 바로 바꾸기 어렵다면 Optional.ofNullable(nullable)을 사용합니다.
새 내부 코드에서는 가능한 한 null을 만들지 않고 저장소가 Optional을 직접 반환하게 합니다.
Optional.ofNullable(null)과 Optional.empty()는 같은 빈 결과입니다.
null 반환과 호출자별 실패
한 호출자는 null 검사를 하고, 다른 호출자는 메서드를 바로 호출해 NullPointerException을 냅니다.
또 다른 호출자는 null을 “빈 문자열”로 바꿔 도메인 부재와 잘못된 값을 섞을 수 있습니다.
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) {
return USERS.stream().filter(user -> user.id() == id).findFirst().orElse(null);
}
static Optional<User> find(long id) {
return USERS.stream().filter(user -> user.id() == id).findFirst();
}
public static void main(String[] args) {
User nullable = findNullable(9);
System.out.println("nullable=" + (nullable == null));
System.out.println("optional=" + find(9));
}
}Optional을 받은 호출자는 부재를 orElse, map, ifPresent, orElseThrow 가운데 하나로 처리해야 값을 꺼낼 수 있습니다.
다만 Optional에서도 부재 가능성을 무시하고 get()만 호출할 수 있으므로 API 사용 원칙이 필요합니다.
Optional 생성 API를 입력 신뢰도에서 선택
Optional.empty()는 명시적 부재를 나타냅니다.
of(value)에 null을 넘기면 프로그래밍 오류이고, ofNullable(value)는 레거시 null 허용 코드와 연결할 때 사용합니다.
이미 Optional인 값을 다시 Optional.of로 감싸면 Optional<Optional<T>>가 생기므로 반환 타입의 중첩 여부를 확인합니다.
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"));
System.out.println("empty=" + empty.isEmpty());
System.out.println("required=" + required.orElseThrow());
System.out.println("adapted-present=" + adapted.isPresent());
}
}환경 변수처럼 실제로 null을 반환할 수 있는 외부 API에는 어댑터에서만 ofNullable을 씁니다.
도메인 내부에는 Optional이나 검증한 값을 넘겨 null의 확산을 막습니다.
값 처리 API 우선 원칙
get()은 빈 상태에서 예외가 나고 부재 정책이 호출 지점에 보이지 않습니다.
기본값이 필요하면 orElse·orElseGet, 부재가 오류라면 orElseThrow, 값이 있을 때만 동작해야 한다면 ifPresent·ifPresentOrElse를 씁니다.
다음 문서에서는 대체 경로의 즉시 평가와 지연 평가 차이를 자세히 다룹니다.
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"));
}
}Optional을 boolean처럼 isPresent로 검사한 뒤 get하는 것보다 처리 동작을 컨테이너에 전달하면 확인과 사용이 한 흐름에 묶입니다.
복잡한 분기와 여러 부수 효과가 필요하면 메서드를 억지로 연쇄하지 말고 명시적인 서비스 흐름을 씁니다.
Optional의 변환 연산
map은 값이 있을 때만 함수를 적용하고 빈 상태는 그대로 전파합니다.
매퍼가 null을 반환하면 빈 상태가 됩니다.
함수가 Optional을 반환한다면 flatMap으로 중첩을 피합니다.
filter는 값이 조건식을 통과하지 못하면 빈 상태로 바꿉니다.
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, 둘째는 anonymous입니다.
사용자 자체가 null일 수 있게 Optional 인수로 넘기기보다 메서드 경계에서 User의 존재 여부를 먼저 정합니다.
Optional과 예외의 경계
“검색했지만 없음”이 정상 결과면 Optional이 적합합니다.
데이터베이스 연결 실패, 권한 오류처럼 계산 자체가 실패했다면 빈 상태로 숨기지 않고 예외나 Result 타입을 사용합니다.
부재와 실패를 같은 빈 상태로 만들면 장애가 정상 미적중 지표에 섞입니다.
orElseThrow 공급자에서는 도메인 예외를 생성할 수 있습니다.
예외 메시지에 키와 연산을 넣되 비밀번호나 토큰 같은 민감 정보는 노출하지 않습니다.
여러 부재 원인을 구분해야 한다면 sealed 결과 계층이 Optional보다 적합합니다.
Optional을 반환하지 않아도 되는 경우
private 메서드에서 호출자와 null 규칙이 명백하고 즉시 처리되는 매우 좁은 범위, 성능에 민감한 기본형 경로, 값이 반드시 존재하는 불변식에는 Optional이 불필요할 수 있습니다.
컬렉션 결과는 빈 컬렉션만으로도 원소가 0개인 상태를 표현합니다.
Optional을 쓰면 래퍼 할당과 메서드 연쇄 비용이 생길 수 있으나 대부분의 업무 조회에서는 명확성이 우선입니다.
성능이 실제 병목이라면 기본형 Optional이나 다른 API와 비교해 측정합니다.
연습 문제
slug가 같고 공개 상태인 Document의 제목을 소문자로 반환하세요.
없으면 untitled를 반환하되 null과 get을 쓰지 마세요.
정답과 부재 전파
findFirst 결과에 map을 연결하면 빈 상태에서는 제목 변환을 실행하지 않습니다.
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);
}
}결과는 스트림 api입니다.
미적중과 저장소 실패가 모두 untitled가 되지 않도록 실제 데이터 접근 예외는 별도로 전달합니다.
Optional의 가치는 null을 감추는 데 있지 않고 부재를 반환 타입에 나타내는 데 있습니다.
생성 지점의 null 신뢰도, 빈 상태 처리 정책, map·flatMap의 원소 수, 정상 부재와 실패의 구분을 함께 설계해야 합니다.