user.getAddress().getCity() -- address가 null이면? 컴파일러는 아무 말도 안 하고, 런타임에 NullPointerException이 터진다. null 체크 방어 코드를 쌓다 보면 비즈니스 로직이 if (x != null) 안에 파묻힌다.

Optional이 왜 필요한가

Optional 은 값이 있을 수도, 없을 수도 있음을 타입으로 표현하는 컨테이너예요. null의 세 가지 문제를 해결해 줍니다.

  1. **런타임 폭탄 **: null 참조는 컴파일러가 잡지 못하고 런타임에서야 터집니다
  2. ** 의미 모호 **: 메서드가 null을 반환하면 "값 없음"인지 "에러"인지 "미초기화"인지 알 수 없어요
  3. ** 방어 코드 오염 **: 중첩된 null 체크가 비즈니스 로직을 파묻습니다
JAVA
// 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)

JAVA
// null이 아닌 값만 받는다
Optional<String> opt = Optional.of("hello");

// null을 넣으면? 바로 NullPointerException!
Optional<String> boom = Optional.of(null); // NPE 발생

"이 값은 절대 null이 아니다"라고 확신할 때 사용합니다. null이 들어오면 빠르게 실패(fail-fast)하는 게 of의 설계 의도예요.

Optional.ofNullable(value)

JAVA
// null이 들어올 수 있을 때
String name = getUserName(); // null일 수 있음
Optional<String> opt = Optional.ofNullable(name);

외부 API 결과나 DB 조회 결과처럼 null 가능성이 있는 값을 감쌀 때 사용합니다. 실무에서 가장 많이 쓰게 되는 생성 메서드예요.

Optional.empty()

JAVA
// 명시적으로 "값 없음"을 표현
Optional<String> empty = Optional.empty();

메서드 반환 시 "결과 없음"을 표현할 때 유용합니다. null을 반환하는 대신 Optional.empty()를 반환하면 호출자가 의도를 바로 이해할 수 있어요.

JAVA
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()

JAVA
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+)

JAVA
Optional<String> opt = Optional.empty();
if (opt.isEmpty()) {
    System.out.println("값이 없습니다");
}

!isPresent() 대신 가독성 좋게 쓸 수 있습니다.

체이닝 — map, flatMap, filter

Optional의 진가는 체이닝에 있습니다. null 체크 없이 변환을 이어갈 수 있어요.

map — 값 변환

JAVA
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 방지

JAVA
// 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 — 조건부 필터링

JAVA
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로 바꿔볼게요.

JAVA
// 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)

JAVA
// 값이 없으면 대체값 반환
String name = Optional.<String>empty().orElse("default");
// "default"

단순해 보이지만 함정이 있습니다. orElse는 값이 있든 없든 인자 표현식이 항상 평가돼요.

JAVA
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)

JAVA
// Supplier는 Optional이 비어있을 때만 실행된다
String name = Optional.of("libra").orElseGet(() -> getDefault());
// getDefault()가 호출되지 않는다!

String name2 = Optional.<String>empty().orElseGet(() -> getDefault());
// 이때만 getDefault()가 호출된다

** 비용이 큰 연산은 반드시 orElseGet을 사용해야 합니다.** 실무에서 이 차이를 모르고 orElse에 무거운 로직을 넣는 실수가 꽤 많아요.

orElseThrow(Supplier)

JAVA
// 값이 없으면 예외를 던진다
User user = findById(id)
        .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다: " + id));

서비스 계층에서 "반드시 존재해야 하는" 값을 조회할 때 가장 많이 쓰는 패턴입니다.

Java 10부터는 인자 없는 orElseThrow()도 가능합니다.

JAVA
// NoSuchElementException을 던진다
User user = findById(id).orElseThrow();

비교 표

메서드대체값 실행 시점용도
orElse(T)** 항상** 평가단순 리터럴, 이미 계산된 값
orElseGet(Supplier)비어있을 때만DB 조회, API 호출 등 비용이 큰 연산
orElseThrow(Supplier)비어있을 때만값이 없으면 예외를 던져야 할 때

or, ifPresentOrElse (Java 9+)

Java 9에서 유용한 메서드가 추가됐습니다.

or(Supplier)

JAVA
// 값이 없으면 다른 Optional로 대체
Optional<String> result = findInCache(key)
        .or(() -> findInDB(key))
        .or(() -> findInExternalAPI(key));

orElse 계열은 언래핑된 값을 반환하지만, or는 Optional을 반환합니다. 여러 데이터 소스를 순차적으로 탐색할 때 체이닝이 깔끔해져요.

ifPresentOrElse(Consumer, Runnable)

JAVA
findById(id).ifPresentOrElse(
        user -> sendWelcomeEmail(user),      // 값이 있을 때
        () -> log.warn("사용자를 찾을 수 없습니다") // 값이 없을 때
);

ifPresent의 확장판입니다. 값이 없을 때의 동작까지 한 번에 표현할 수 있어요.

Optional과 Stream 연계

Optional.stream() (Java 9+)

JAVA
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

JAVA
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()

JAVA
// 나쁜 코드 — null 체크와 다를 게 없다
Optional<User> opt = findById(id);
if (opt.isPresent()) {
    return opt.get();
}
return defaultUser;

// 좋은 코드
return findById(id).orElse(defaultUser);

isPresent() + get() 조합은 Optional을 쓰는 의미를 완전히 없앱니다.

안티패턴 2: Optional을 필드에 사용

JAVA
// 하지 마세요
public class User {
    private Optional<String> nickname; // 안티패턴!
}

왜 안 되는 걸까요?

  • Optional은 Serializable을 구현하지 않습니다 — JPA 엔티티나 직렬화가 필요한 객체에서 문제가 돼요
  • Optional은 메서드 반환 타입용으로 설계되었습니다 — Brian Goetz(Java 아키텍트)가 명시적으로 밝힌 사항이에요
  • 필드마다 Optional로 감싸면 메모리 오버헤드가 쌓입니다

필드가 null일 수 있다면, getter에서 Optional을 반환하는 방식이 올바릅니다.

JAVA
public class User {
    private String nickname; // 필드는 그냥 String

    public Optional<String> getNickname() {
        return Optional.ofNullable(nickname); // getter에서 Optional 반환
    }
}

안티패턴 3: Optional을 파라미터로 사용

JAVA
// 하지 마세요
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로 감싸기

JAVA
// 하지 마세요
public Optional<List<User>> findUsers() { ... }

// 빈 컬렉션을 반환하세요
public List<User> findUsers() {
    // 결과가 없으면 빈 리스트 반환
    return Collections.emptyList();
}

컬렉션은 그 자체로 "비어있음"을 표현할 수 있습니다. Optional로 한 번 더 감쌀 필요가 없어요.

올바른 사용 정리

용도올바른가?이유
메서드 반환 타입OOptional의 설계 목적
필드 타입XSerializable 미지원, 메모리 오버헤드
메서드 파라미터X호출자에게 래핑 비용 전가, null 이중 체크
컬렉션 래핑X빈 컬렉션으로 충분
Map의 valueXMap.getOrDefault() 활용

실무 패턴 모음

패턴 1: 서비스 계층 — 조회 후 예외 처리

JAVA
public User getUser(Long id) {
    return userRepository.findById(id)
            .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
}

패턴 2: DTO 변환 시 Optional 활용

JAVA
public UserResponse toResponse(User user) {
    return UserResponse.builder()
            .name(user.getName())
            .city(Optional.ofNullable(user.getAddress())
                    .map(Address::getCity)
                    .orElse("미등록"))
            .build();
}

패턴 3: 여러 소스에서 순차 탐색

JAVA
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 orElseGetorElse는 항상 평가, orElseGet은 비어있을 때만. 비용 큰 연산은 반드시 orElseGet
사용 범위오직 메서드 반환 타입에만. 필드, 파라미터, 컬렉션 래핑 금지
대표 안티패턴isPresent() + get() -- null 체크와 다를 바 없다
Stream 연계Optional.stream() (Java 9+) + flatMap으로 값 추출
댓글 로딩 중...