자바에서 Optional: 올바른 사용과 안티패턴

1. 개요

Optional은 Java 8에서 “존재하지 않을 수 있는” 값을 null보다 더 명확하게 표현하기 위해 도입되었습니다. 하지만 많은 개발자들이 잘못 사용합니다: Optional을 field, 매개변수로 남용하거나 마치 절대 비어있지 않을 것처럼 .get()을 호출하곤 합니다. 이 글은 Optional을 언제 사용해야 하는지, 올바른 패턴은 무엇인지, 그리고 피해야 할 일반적인 안티패턴들이 무엇인지 이해하는 데 도움을 줍니다.

이 글을 읽은 후 Optional을 반환하는 API를 합리적으로 설계하는 방법, map/filter/orElseGet으로 빈 값을 간결하게 처리하는 방법, 그리고 리팩토링이 필요한 “거짓 Optional” 코드를 즉시 인식하는 방법을 알게 될 것입니다.

2. Optional이란 무엇이고 언제 사용할까

Optional<T>는 T 타입의 값을 포함할 수도, 포함하지 않을 수도 있는 컨테이너입니다. 주요 목적은: 메서드 signature에서 반환 값이 존재하지 않을 수 있음을 명확히 하고, caller가 그 경우를 처리하도록 강제하는 것입니다. 대신 null 체크를 빠뜨리기 쉬워집니다.

public class CustomerRepository {

    private final Map<Long, Customer> store = new HashMap<>();

    public Optional<Customer> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }
}

findById는 Customer (null일 수 있음) 대신 Optional<Customer>를 반환합니다. Caller는 signature를 보는 순간 찾지 못한 경우를 처리해야 한다는 것을 알 수 있으며, Javadoc을 읽을 필요가 없습니다.

주의: Optional은 반환 값에 대해 설계되었습니다. 시스템의 모든 null이 될 수 있는 곳에 사용하기 위한 것은 아닙니다.

3. Optional을 만드는 여러 방법

Optional<String> present = Optional.of("Hello");       // 값이 확실히 null이 아님
Optional<String> empty = Optional.empty();              // 비어있음
Optional<String> nullable = Optional.ofNullable(null);  // null일 수 있음, 자동으로 empty로 변환
  • Optional.of(value)는 value가 null이면 즉시 NullPointerException을 던집니다 — 값이 확실히 null이 아닐 때 사용하세요.
  • Optional.ofNullable(value)는 더 안전하며, 값이 통제 불가능한 출처에서 나올 때 적합합니다 (query 결과, map.get…).
  • Optional.empty()는 빈 instance를 만들며, 사전에 값이 없다는 것을 알고 있을 때 사용합니다.

4. Optional 내부 값을 올바르게 처리하는 방법

4.1. map, filter, flatMap

public record Order(Long id, BigDecimal amount, String couponCode) {}

public class DiscountService {

    public Optional<BigDecimal> calculateDiscount(Order order) {
        return Optional.ofNullable(order.couponCode())
                .filter(code -> code.startsWith("SALE"))
                .map(code -> order.amount().multiply(BigDecimal.valueOf(0.1)));
    }
}

filter는 조건이 참일 때만 값을 유지하고, 그렇지 않으면 Optional.empty()를 반환합니다. map은 값이 존재하면 값을 변환하고, 비어있으면 아무것도 하지 않습니다. 이 체인은 중첩된 null 체크 if-else를 완전히 피할 수 있게 해줍니다.

4.2. orElse, orElseGet, orElseThrow

public class PaymentService {

    private final CustomerRepository repository;

    public PaymentService(CustomerRepository repository) {
        this.repository = repository;
    }

    public Customer resolveCustomer(Long id) {
        return repository.findById(id)
                .orElseThrow(() -> new CustomerNotFoundException(id));
    }

    public String resolveCustomerName(Long id) {
        return repository.findById(id)
                .map(Customer::name)
                .orElse("Guest");
    }
}
  • orElseThrow(supplier)은 찾지 못했을 때 의미 있는 exception을 던집니다 — 값이 필수인 곳에서 사용하세요.
  • orElse("Guest")는 기본값이 간단한 상수일 때 사용합니다. Optional에 값이 있든 없든 항상 계산됩니다.
  • orElseGet(supplier)는 기본값 계산이 비용이 드는 경우 사용합니다 — Optional이 비어있을 때만 supplier를 호출합니다.
// 잘못됨: Optional에 값이 있든 없든 항상 buildDefaultCustomer()를 호출합니다
repository.findById(id).orElse(buildDefaultCustomer());

// 올바름: 실제로 필요할 때만 호출합니다
repository.findById(id).orElseGet(this::buildDefaultCustomer);

4.3. ifPresent와 ifPresentOrElse

public class NotificationService {

    public void notifyCustomer(Optional<Customer> customer) {
        customer.ifPresentOrElse(
                c -> System.out.println(c.email() + "로 이메일을 보냅니다"),
                () -> System.out.println("보낼 customer가 없습니다")
        );
    }
}

ifPresentOrElse (Java 9부터)는 값이 있는 경우와 없는 경우를 하나의 호출로 처리하며, 긴 if (optional.isPresent()) {...} else {...} 대신 사용할 수 있습니다.

5. JUnit 5 + AssertJ로 동작 테스트하기

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>
class DiscountServiceTest {

    private final DiscountService service = new DiscountService();

    @Test
    void shouldReturnDiscountWhenCouponValid() {
        Order order = new Order(1L, BigDecimal.valueOf(100), "SALE10");

        Optional<BigDecimal> discount = service.calculateDiscount(order);

        assertThat(discount)
                .isPresent()
                .get()
                .isEqualTo(BigDecimal.valueOf(10.0));
    }

    @Test
    void shouldReturnEmptyWhenNoCoupon() {
        Order order = new Order(2L, BigDecimal.valueOf(100), null);

        Optional<BigDecimal> discount = service.calculateDiscount(order);

        assertThat(discount).isEmpty();
    }
}

AssertJ는 Optional을 위한 특화된 assertion을 제공합니다: isPresent(), isEmpty(), hasValue(...)입니다. 이들을 사용하여 .get()을 호출한 후 비교하는 것 대신 하면, 테스트 코드가 더 자연스럽게 읽힙니다.

6. 자주 만나는 안티패턴들

6.1. 검사 없이 .get() 호출하기

// 잘못됨: Optional이 비어있으면 NoSuchElementException을 던집니다.
// Optional을 사용하는 이유를 완전히 무효화합니다.
Customer customer = repository.findById(id).get();

직접 .get()을 호출하면 Optional은 단지 null을 한 층 더 감싼 것일 뿐입니다 — 여전히 runtime crash가 발생할 수 있지만, 추가 코드 줄이 낭비됩니다. orElseThrow() (명확한 exception 포함) 또는 map/ifPresent를 통해 처리하세요.

6.2. entity/DTO의 field로 Optional 사용하기

// 잘못됨: Optional은 Serializable을 구현하지 않으며,
// JPA entity의 field로 나타나서는 안 됩니다
@Entity
public class Customer {
    private Optional<String> middleName; // 피하세요
}

Optional은 Serializable을 구현하지 않으며, field나 constructor 매개변수로 사용하도록 설계되지 않았습니다. middleName이 null일 수 있으면, 그냥 String으로 두고 null을 허용하세요 — 필요하면 getter에서 반환할 때만 Optional로 감싸세요:

// 올바름: field는 여전히 String이며,
// 필요하면 getter에서만 Optional로 감싸세요
public Optional<String> getMiddleName() {
    return Optional.ofNullable(middleName);
}

6.3. Optional을 메서드 매개변수로 사용하기

// 잘못됨: caller가 호출하기 전에 자신이 Optional.of/ofNullable로 감싸야 합니다
public void updateAddress(Optional<String> newAddress) { ... }

이 방식은 복잡성을 caller로 미루며, overload나 @Nullable에 비해 장점이 없습니다. 대신 overload를 사용하거나 String을 전달하고 일반적인 null 체크를 하세요:

// 올바름
public void updateAddress(String newAddress) {
    this.address = newAddress; // caller가 null을 전달할지 결정합니다
}

6.4. isPresent()를 확인한 후 get()을 호출하기 (구형 null 체크처럼)

// 잘못됨: 구형 null 체크를 다시 작성한 것이며, Optional을 활용하지 못합니다
if (repository.findById(id).isPresent()) {
    Customer c = repository.findById(id).get(); // query를 2번 호출합니다!
    System.out.println(c.name());
}

이 코드는 복잡할 뿐만 아니라, findById가 실제 query라면 두 번 호출합니다. 대신 사용하세요:

// 올바름
repository.findById(id).ifPresent(c -> System.out.println(c.name()));

6.5. collection에 Optional 사용하기

// 잘못됨: collection은 이미 "비어있음"이라는 개념이 있으므로 (empty list),
// Optional이 필요하지 않습니다
public Optional<List<Order>> findOrdersByCustomer(Long customerId) { ... }

Optional<List<Order>>를 반환하면 caller가 두 개의 “비어있음” 상태를 처리해야 합니다: Optional이 비어있거나 list가 비어있거나 — 중복됩니다. 데이터가 없을 때는 항상 비어있는 List를 반환하세요:

// 올바름
public List<Order> findOrdersByCustomer(Long customerId) {
    return orders.getOrDefault(customerId, List.of());
}

6.6. 높은 성능이 필요한 hot path에서 Optional 사용하기

Optional은 추가 object wrapper를 만듭니다. 대부분의 애플리케이션에서는 큰 비용이 아니지만, 초당 수백만 번 실행되는 코드 (예: 대량 데이터 stream 처리 루프)에서는 지속적인 boxing이 GC에 영향을 미칠 수 있습니다. 이런 경우, null을 직접 체크하거나 sentinel value 사용을 고려하세요.

주의: 너무 일찍 최적화하지 마세요 — profile을 한 후 Optional이 실제 병목이 될 때만 이를 고려하세요.

7. Optional을 사용해야 하는 경우/하지 말아야 하는 경우 요약 표

상황 Optional 사용?
메서드의 반환 값이 존재하지 않을 수 있는 경우 ✅ 사용하세요
entity/DTO의 field ❌ 사용하지 마세요
constructor/메서드의 매개변수 ❌ 사용하지 마세요
collection (List, Set, Map) 반환 ❌ 사용하지 마세요 — 빈 collection을 반환하세요
null 체크 반복을 피하기 위한 메서드 내 local variable ⚠️ 경우에 따라 — 고려해보세요, 때로는 if/else가 더 명확합니다

8. 결론

Optional은 API 계약을 명확하게 하고 호출 레벨의 NullPointerException을 피하는 좋은 도구입니다. 하지만 올바른 위치에 사용할 때만 가치를 발휘합니다: 메서드의 반환 값입니다. field, 매개변수, 또는 collection의 wrapper로 변환하지 마세요 — 그런 곳에서는 Optional이 코드를 더 복잡하게 만들 뿐 문제를 해결하지 못합니다. 새로운 코드를 쓸 때, 자신에게 물어보세요: “이 Optional이 caller가 더 명확하게 처리하는 데 도움이 될까, 아니면 단지 null을 다시 감싼 것일까?” — 그 답이 유지할지 제거할지를 결정해줄 것입니다.

javaoptionalbest-practicesclean-code