자바에서 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을 다시 감싼 것일까?” — 그 답이 유지할지 제거할지를 결정해줄 것입니다.