
만약 자바로 작업한 적이 있다면 NullPointerException이라는 악몽을 겪어본 적이 있을 겁니다. 모든 런타임 에러의 왕이지요.
당신이 주니어 개발자든 10년 이상 경력의 개발자든 상관없습니다. 어느 순간 당신은 모든 곳에 if != null 검사를 작성해놓거나 디버깅 프로덕션 로그가 NullPointerException으로 비명 지르는 걸 목도하게 될 겁니다.
바로 이것이 자바가 자바8에서 Optional을 도입한 이유입니다. 그리고 말씀드리건대 만약 당신이 Optional을 올바르게 사용한다면 단순히 null 관련 오류를 막는데 그치지 않을 겁니다. 그것은 코드를 훨씬 깔끔하고, 읽기 좋고, 의도가 드러나도록 만들어줍니다.
이 글에서 저는 예제, 함정, 모범 사례를 단계별로 거쳐 Optional을 분석해볼 것입니다. 당신이 Optional을 오용하는 일이 없도록 만들기 위해 말이지요 (네, 많은 사람들은 그것을 오용하고 있습니다.)
Optional이 도입되기 전에는 메서드가 값을 찾지 못했을 때 두 가지 선택지 중 하나를 택해야 했습니다:
null 반환 → 코드 곳곳에 지저분한 null 검사가 들어가게 됩니다.
예외 던지기 → 대부분의 경우 과잉 대응이면서 성능적인 비용도 큽니다.
예를 들어보죠:
public String findUserEmailById(Long userId) {
User user = userRepository.findById(userId);
if (user != null) {
return user.getEmail();
} else {
return null;
}
}
이제 누군가 이 메서드를 호출할 때마다 아래와 같이 확인해봐야 됩니다:
String email = service.findUserEmailById(42L);
if (email != null) {
// use email
}
이것은 반복적이고 지루하며 실수하길 유발합니다.
그 대신 Optional은 값을 가질 수도 있고 가지지 않을 수도 있는 컨테이너 객체를 제공합니다. 이를 통해 "값이 없는" 상황을 null에 맡기지 않으면서 한층 명백하게 만들어줍니다.
Optional을 사용해 같은 메서드를 다시 작성해봅시다:
public Optional<String> findUserEmailById(Long userId) {
return userRepository.findById(userId)
.map(User::getEmail);
}
이제 이 메서드를 사용할 때 아래와 같이 작성하면 됩니다:
service.findUserEmailById(42L)
.ifPresent(email -> System.out.println("Email: " + email));
null 체크도 없고 if (something != null이 야기하는 스파게티 코드도 없습니다.
오직 깔끔하고 선명한 자바 코드뿐입니다.
Optional을 사용하는 가장 유용한 방법으로는 다음과 같은 것들이 있습니다:
isPresent()와 ifPresent()Optional<String> email = service.findUserEmailById(42L);
// Old way
if (email.isPresent()) {
System.out.println(email.get());
}
// Cleaner way
email.ifPresent(System.out::println);
ifPresent()는 값이 존재할 때만 처리하도록 강제하여 명쾌합니다.
orElse() 와 orElseGet()String email = service.findUserEmailById(42L)
.orElse("default@example.com");
사용자에게 이메일이 없으면 단아하게 물러납니다.
orElse() → 항상 기본 값을 평가합니다.orElseGet() → 필요할 때만 지연 평가(Evaluates lazily)합니다.String email = service.findUserEmailById(42L)
.orElseGet(() -> expensiveDefaultEmailCalculation());
orElseThrow()null을 반환하는 대신 필요할 때만 예외를 throw할 수도 있습니다:
String email = service.findUserEmailById(42L)
.orElseThrow(() -> new RuntimeException("Email not found"));
여기저기 throw new를 흩뿌리는 것보다 훨씬 낫습니다.
map()과 flatMap()을 이용한 체이닝Optional의 가장 훌륭한 것 중 하나로 메서드 체이닝을 들 수 있겠습니다.
String domain = service.findUserEmailById(42L)
.map(email -> email.split("@")[1])
.orElse("unknown.com");
단계별로 null을 체크하는 대신, 코드가 자연스럽게 흘러갑니다.
매핑 자체가 또 다른 Optional을 반환한다면 flatMap()을 사용하세요.
다른 도구와 마찬가지로 Optional도 오용될 수 있습니다. 하지 말아야 할 것들로는 다음과 같은 것들이 있습니다.
// BAD
private Optional<String> name;
왜냐고요? 왜냐하면 Optional은 클래스 필드가 아니라 메서드 반환 타입을 위해 설계되었기 때문입니다. 불필요한 래핑을 추가하게 되지요.
// BAD
public void updateEmail(Optional<String> email) { ... }
호출자의 코드를 난처하게 만듭니다. 대신 실제 값을 전달하거나 메서드를 오버로드하세요.
JSON이나 JPA를 다룰 때 필드로 Optional을 사용하면 문제가 생깁니다. 반환값으로만 Optional을 사용하세요.
비교해봅시다:
Optional이 없을 때:
User user = userRepository.findById(42L);
if (user != null) {
Address address = user.getAddress();
if (address != null) {
City city = address.getCity();
if (city != null) {
System.out.println(city.getName());
}
}
}
Opitonal이 있을 때:
userRepository.findById(42L)
.map(User::getAddress)
.map(Address::getCity)
.map(City::getName)
.ifPresent(System.out::println);
차이가 천지차이만큼 납니다.
하나는 뭔가 잘못된 방어적 프로그래밍처럼 보입니다.
다른 하나는 유연하고 읽기 좋은 데이터 흐름처럼 보입니다
당신이 전자상거래 시스템을 구축하고 있다고 가정해봅시다.
public Optional<Discount> findDiscountByUserId(Long userId) {
return userRepository.findById(userId)
.flatMap(User::getDiscount);
}
이제 할인을 적용할 때입니다:
BigDecimal finalPrice = discountService.findDiscountByUserId(userId)
.map(discount -> price.subtract(discount.getAmount()))
.orElse(price);
null은 없습니다. 혼란도 없습니다. 깔끔한 의도만 있을 뿐입니다.
Java의 Optional은 단순히 NullPointerException을 피하기 위한 래퍼가 아닙니다.
이것은 null 체크가 가득한 방어적 코드를 작성하는 것에서 선명하고, 깔끔하며, 의도가 뚜렷히 보이는 코드를 작성하는 것으로의 철학적 전환입니다.
하지만 기억하세요. 다른 좋은 도구들처럼 Optional도 현명하게 사용해야 됩니다. 필드나 파라미터에 Optional를 넣지 마세요. 값이 있을 수도 있고 없을 수도 있는 값의 반환 타입으로만 Optional을 사용하세요.
한번 제대로 사용하기 시작하면, 다음과 같은 점을 알게 될 것이다:
NullPointerException을 던지지 않는 당신에게 감사할 것입니다.그러니 다음에 메서드에서 null을 반환하려고 할 때… 멈추세요.
Optional을 반환하세요. 미래의 당신이 고마워할 것입니다.