Optional
#Java/adv3
옵셔널이 필요한 이유
NPE
- 자바에서
null
은 값이 없음을 표현하는 기본적인 방법이다. - 하지만,
null
을 잘못 사용하거나null
참조에 대해 메서드를 호출하면NullPointerException
(NPE)이 발생하여 프로그램이 예기치 않게 종료될 수 있다.- 메서드 뎁스가 깊고,
null
체크를 누락하면 추척하기 어렵고, 디버깅 비용이 증가한다.
- 메서드 뎁스가 깊고,
가독성 저하
null
을 반환하거나 사용하는 경우, 조건문을 통해 객체가 null인지 계속 확인해야 한다. 이러한null
체크 로직이 누적되면 코드가 복잡해지고 가독성이 저하된다.
의도가 드러나지 않음
- 메서드 시그니처(
String findNameById(Long id)
)만 보고서는 이 메서드가null
을 반환할 수도 있다는 사실을 명확히 알기 어렵다. - 메서드를 호출하는 입장에서는 "반드시 값이 존재할 것"이라고 가정했다가, 런타임에
null
이 나와서 문제가 생길 수 있다.- ex)
String findNameById(Long id)
보고findNameById(id).toUpperCase()
를 호출했는데, 알고보니 null을 반환해서 NPE가 발생해버렸다.
- ex)
Optional의 등장
- 이러한 문제를 해결하고자 자바 8부터
Optional
클래스를 도입했다. Optional
은 "값이 있을 수도 있고 없을 수도 있음"을 명시적으로 표현해주어, 메서드의 계약(Contract)이나 호출 의도를 좀 더 분명하게 드러낸다.Optional
을 사용하면 "빈 값"을 표현할 때, 더 이상null
자체를 넘겨주지 않고Optional.empty()
처럼 의도를 드러내는 객체를 사용할 수 있다.Optional
을 사용함으로써 얻을 수 있는 이점null
체크 로직이 간결해지고, NPE가 발생할 수 있는 부분을 컴파일러, IDE, 코드 리뷰어가 쉽게 파악할 수 있다.
null을 직접 반환하는 경우
String name = findNameById(id);
// 결과 존재X 시 null 반환System.out.println("name = " + name.toUpperCase());
- NPE 발생!!
if (name != null) System.out.println(id + ": " + name.toUpperCase()); else {...}
null
체크 로직을 빠트리면 NPE가 발생한다.
Optional을 반환하는 경우
// 이름이 있으면 이름을 대문자로 출력, 없으면 "UNKNOWN"을 출력해라.
static void findAndPrint(Long id) {
Optional<String> optName = findNameById(id);
// 다른 개발자, 클라이언트 입장에서는 반환타입이 Optional임을 보고 값이 있을 지 없을 지 명시적으로 알 수 있다.
String name = optName.orElse("UNKNOWN");
System.out.println(id + ": " + name.toUpperCase());
}
static Optional<String> findNameById(Long id) {
String findName = map.get(id);
Optional<String> optName = Optional.ofNullable(findName);
return optName;
}
Optional.ofNullable(findId)
를 통해null
이 될 수도 있는 값을Optional
로 감싼다.- 메서드를 사용하는 클라이언트는 메서드 시그니처(
Optional<String> findNameById(Long id)
)만 보고도 "반환 결과가 있을 수도, 없을 수도 있겠구나"라고 명시적으로 인지할 수 있다. Optional.orElse("대체값")
: 옵셔널에 값이 있으면 해당 값을 반환하고, 값이 없다면 대체값을 반환한다.- 이 방식은 "값이 없을 수도 있다"는 점을 호출하는 측에 명확히 전달하므로, 놓치기 쉬운
null
체크를 강제하고 코드의 안정성을 높인다.
Optional 소개
java.util.Optional<T>
는 "존재할 수도 있고 존재하지 않을 수도 있는" 값을 감싸는 일종의 컨테이너 클래스null
을 내부적으로 다루는 대신,Optional
객체에 감싸서Optional.empty()
또는Optional.of(value)
형태로 다룬다.- Optional 등장 이유
- "값이 없을 수 있다"는 상황을 프로그래머가 명시적으로 처리하도록 유도
- 런타임 NPE를 사전에 예방하기 위해 도입됨.
- Optional은 언제 사용하면 좋을까?
Optional
은 "값이 없을 수도 있다"는 상황을 반환할 때 주로 사용된다.- "항상 값이 있어야 하는 상황"에서는
Optional
을 사용할 필요 없이 그냥 해당 타입을 바로 사용하거나 예외를 던지는 방식이 더 좋을 수 있다.
Optional의 생성과 값 획득
Optional 생성 : 3가지 방법
Optional.of(T value)
- 내부 값이 확실히
null
이 아닐 때 사용.null
을 전달하면NullPointerException
발생
- 내부 값이 확실히
Optional.ofNullable(T value)
- 값이
null
일 수도 있고 아닐 수도 있을 때 사용.null
이면Optional.empty()
를 반환한다. Optional<String> opt2 = Optional.ofNullable("Hello!");
Optional<String> opt3 = Optional.ofNullable(null);
- 값이
Optional.empty()
- 명시적으로 "값이 없음"을 표현할 때 사용
Optional<String> opt4 = Optional.empty();
Optional 값 획득: Optional에 들어있는 값을 꺼내는 방법
isPresent()
- 값이 있으면
true
, 없으면false
isEmpty()
: 자바 11 이상에서 사용 가능, 값이 비어있으면true
, 값이 있으면false
를 반환
- 값이 있으면
get()
- 값이 있는 경우 그 값을 반환, 값이 없으면
NoSuchElementException
발생.
- 직접 사용 시 주의해야 하며, 가급적이면
orElse
,orElseXxx
계열 메서드를 사용하는 것이 안전.
- 값이 있는 경우 그 값을 반환, 값이 없으면
orElse(T other)
- 값이 있으면 그 값을 반환, 값이 없으면
other
를 반환.
- 값이 있으면 그 값을 반환, 값이 없으면
orElseGet(Supplier<? extends T> supplier)
- 값이 있으면 그 값을 반환, 값이 없으면
supplier
호출하여 생성된 값을 반환.
- 값이 있으면 그 값을 반환, 값이 없으면
orElseThrow(...)
- 값이 있으면 그 값을 반환, 값이 없으면 지정한 예외를 던짐.
or(Supplier<? extends Optional<? extends T>> supplier)
- 값이 있으면 해당 값의
Optional
을 그대로 반환, 값이 없으면supplier
가 제공하는 다른Optional
반환 - 값 대신
Optional
을 반환한다는 특징
- 값이 있으면 해당 값의
Optional<String> optValue = Optional.of("Hello");
Optional<String> optEmpty = Optional.empty();
// get() : 값이 없으면 예외 발생. 가급적 사용을 피하자.
String getValue = optValue.get();
//String getValue2 = optEmpty.get(); // 예외 발생 -> 주석 처리
// orElse() : 값이 없으면 지정된 기본값 사용
String value1 = optValue.orElse("기본값"); // Hello
String empty1 = optEmpty.orElse("기본값"); // 기본값
// orElseGet() : 값이 없으면 람다를 실행하여 기본값 생성
String value2 = optValue.orElseGet(() -> {
System.out.println("람다 호출 - optValue"); // 호출 안됨
return "New Value";
});
String empty2 = optEmpty.orElseGet(() -> {
System.out.println("람다 호출 - optEmpty"); // 호출됨
return "New Value";
});
// orElseThrow() : 값이 없으면 예외 발생
String value3 = optValue.orElseThrow(() -> new RuntimeException("값이 없습니다!"));
try {
// optEmpty는 값이 없으므로 예외 발생
String empty3 = optEmpty.orElseThrow(() -> new RuntimeException("값이 없습니다!"));
System.out.println("empty3 = " + empty3); // 실행되지 않음
} catch (Exception e) {
System.out.println("예외 발생: " + e.getMessage());
}
// or() : Optional 반환
Optional<String> result1 = optValue.or(() -> Optional.of("Fallback"));
System.out.println(result1); // Optional[Hello], 값이 이미 존재 -> 원본 그대로
Optional<String> result2 = optEmpty.or(() -> Optional.of("Fallback"));
System.out.println(result2); // Optional[Fallback], 비어있으므로 대체 Optional 반환
Optional 값 처리:
Optional
에서는 값이 존재할 때와 존재하지 않을 때를 처리하기 위한 다양한 메서드들을 제공한다
ifPresent(Consumer<? super T> action)
- 값이 존재하면
action
실행, 값이 없으면 아무것도 안 함 ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction)
- 값이 존재하면
action
실행, 값이 없으면emptyAction
실행
- 값이 존재하면
map(Function<? super T, ? extends U> mapper)
- 값이 있으면
mapper
를 적용한 결과(Optional<U>
) 반환Optional<String>
문자열 길이로 매핑Optional<Integer>
- 값이 없으면
Optional.empty()
반환
- 값이 있으면
flatMap(Function<? super T, ? extends Optional<? extends U>> mapper)
map
과 유사하지만,Optional
을 반환할 때 중첩되지 않고 평탄화(flat
)해서 반환- Optional 안의 Optional을 하나의 Optional로 평탄화한다~
filter(Predicate<? super T> predicate)
- 값이 있고 조건을 만족하면 그대로 반환,
- 조건 불만족이거나 비어있으면
Optional.empty()
반환
stream()
- 값이 있으면 단일 요소를 담은
Stream<T>
반환, 값이 없으면 빈 스트림 반환
- 값이 있으면 단일 요소를 담은
- 값이 존재하면
Optional<String> optValue = Optional.of("Hello");
Optional<String> optEmpty = Optional.empty();
// ifPresent(): 값이 존재하면 Consumer 실행, 없으면 아무 일도 하지 않음
optValue.ifPresent(v -> System.out.println("optValue 값: " + v));
optEmpty.ifPresent(v -> System.out.println("optEmpty 값: " + v)); // 실행 X
// ifPresentOrElse():값이 있으면 Consumer 실행, 없으면 Runnable 실행
optValue.ifPresentOrElse(
v -> System.out.println("optValue 값: " + v),
() -> System.out.println("optValue는 비어있음")
);
optEmpty.ifPresentOrElse(
v -> System.out.println("optEmpty 값: " + v), // 실행 X
() -> System.out.println("optEmpty는 비어있음")
);
// map(): 값이 있으면 Function 적용 후 Optional로 반환, 없으면 Optional.empty()
Optional<Integer> lengthOpt1 = optValue.map(String::length); // Optional[5]
Optional<Integer> lengthOpt2 = optEmpty.map(String::length); // Optional.empty
// flatMap(): map()과 유사하나, 이미 Optional을 반환하는 경우 중첩을 제거
System.out.println("=== 4. flatMap() ===");
System.out.println("[map]");
// Optional[Hello] -> Optional[Optional[HELLO]] (중첩됨)
Optional<Optional<String>> nestedOpt = optValue.map(s -> Optional.of(s.toUpperCase()));
System.out.println("[flatMap]"); //flatMap을 사용하면 한 번에 평탄화
// Optional[Hello] -> Optional[HELLO]
Optional<String> flattenedOpt = optValue.flatMap(s -> Optional.of(s.toUpperCase()));
// filter(): 값이 있고 조건을 만족하면 그 값을 그대로, 불만족시 Optional.empty()
Optional<String> filtered1 = optValue.filter(s -> s.startsWith("H")); //Optional[Hello]
Optional<String> filtered2 = optValue.filter(s -> s.startsWith("X")); //Optional.empty
// stream(): 값이 있으면 단일 요소 스트림, 없으면 빈 스트림
optValue.stream().forEach(s -> System.out.println("optValue.stream() -> " + s));// 실행O
optEmpty.stream().forEach(s -> System.out.println("optEmpty.stream() -> " + s));// 실행X
Optional
의 다양한 메서드를 활용하면, 값이 존재할 때와 존재하지 않을 때의 로직을 명확하고 간결하게 구현할 수 있다.
즉시 평가와 지연 평가1
orElse()
와 orElseGet()
의 차이를 이해하기 위해서는 즉시 평가와 지연 평가를 이해해야 한다.
- 즉시 평가(eager evaluation):
- 값(혹은 객체)을 바로 생성하거나 계산해 버리는 것
- 지연 평가(lazy evaluation):
- 값이 실제로 필요할 때(즉, 사용될 때)까지 계산을 미루는 것
로거 예제: 일반적인 상황에서는 로그를 남기지 않다가, 디버깅이 필요한 경우에만 디버깅용 로그를 추가로 출력
public class Logger {
private boolean isDebug = false;
// Getter, Setter
// DEBUG로 설정한 경우만 출력 - 데이터를 받음
public void debug(Object message) {
if (isDebug) {
System.out.println("[DEBUG] " + message);
}
}
}
debug()
에 전달한 메시지는isDebug
값을true
로 설정한 경우에만 메시지를 출력한다.
public class LogMain1 {
public static void main(String[] args) {
Logger logger = new Logger();
logger.setDebug(true);
logger.debug(10 + 20); // 디버그 켜짐, 연산 결과 출력
System.out.println("=== 디버그 모드 끄기 ===");
logger.setDebug(false);
logger.debug(100 + 200); // 디버그 꺼짐, 연산 결과 출력 안됨
}
}
디버그 모드가 꺼져있는 경우
// 자바 언어의 연산자 우선순위상 메서드를 호출하기 전에 괄호 안의 내용이 먼저 계산된다.
logger.debug(100 + 200); // 1. 여기서는 100 + 200이 즉시 평가된다.
logger.debug(300); // 2. 100 + 200 연산의 평가 결과는 300이 된다.
debug(300) // 3. 메서드를 호출한다. 이때 계산된 300의 값이 인자로 전달된다.
public void debug(Object message = 300) { // 4. message에 계산된 300이 할당된다.
if (isDebug) { // 5. debug 모드가 꺼져있으므로 false이다.
System.out.println("[DEBUG] " + message); // 6. 실행되지 않는다.
}
}
앞서 계산한 100 + 200
연산은 어디에도 사용되지 않는다. 결과적으로 연산은 계산된 후에 버려진다.
즉시 평가와 지연 평가2
100 + 200이 실제로 연산되었는지 확인하기 위해 메서드로 분리하여 호출해보자.
public class LogMain2 {
public static void main(String[] args) {
Logger logger = new Logger();
logger.setDebug(true);
logger.debug(value100() + value200()); // 디버그 켜짐
System.out.println("=== 디버그 모드 끄기 ===");
logger.setDebug(false);
logger.debug(value100() + value200()); // 디버그 꺼짐
}
static int value100() {
System.out.println("value100 호출");
return 100;
}
static int value200() {
System.out.println("value200 호출");
return 200;
}
}
로그를 보면 디버그 모드를 끈 경우에도 value100()
, value200()
이 실행된 것을 확인할 수 있다. debug()
메서드를 호출하기 전에 괄호 안의 내용이 먼저 평가(계산)되는 것을 확인할 수 있다.
그렇다면 debug
모드가 켜져있을 때는 해당 연산을 처리하고, debug
모드가 꺼져있을 때는 해당 연산을 처리하지 않으려면 어떻게 해야 할까? 가장 간단한 방법은 debug
모드인 경우에만 debug()
메서드를 호출하도록 if
문을 사용하는 것이다.
if (logger.isDebug()) {
logger.debug(value100() + value200());
}
이렇게 하면 디버그 모드를 체크한 이후에 아무런 로그가 남지 않는다. debug()
메서드가 실행되지 않은 것을 확인할 수 있다.
하지만 이렇게 하려면 디버그를 출력할 때 마다 계속 if
문을 사용해야 한다. 코드 한 줄을 작성하는데 코드 2줄이 더 필요하다! (if
문을 닫는 괄호 포함)
// 1. if 문을 통한 확실한 체크, 코드는 지저분해지지만, 필요 없는 연산 수행X
if (logger.isDebug()) {
logger.debug(value100() + value200());
}
// 2. 필요없는 연산이 추가되지만 코드는 깔끔
logger.debug(value100() + value200());
두 마리 토끼를 모두 잡을 수는 없을까? (2번의 코드의 깔끔함과 1번의 필요 없는 연산은 수행하지 않는 것)
이렇게 하려면 100 + 200
, value100() + value200()
같은 연산을 정의하는 시점과 해당 연산을 실행하는 시점을 분리해야 한다. 그래서 연산의 실행을 최대한 지연해서 평가(계산)해야 한다.
즉시 평가와 지연 평가3
자바 언어에서 연산을 정의하는 시점과 해당 연산을 실행하는 시점을 분리하는 방법은 여러 가지가 있다.
- 익명 클래스를 만들고, 메서드를 나중에 호출
- 람다를 만들고, 해당 람다를 나중에 호출
람다를 사용해서 연산을 정의하는 시점과 실행하는 시점을 분리해서 문제를 해결해보자.
Logger에 람다(Supplier
)를 받는 debug
메서드를 하나 추가하자
// supplier에 람다가 전달된다. (람다는 아직 실행되지 않았다.)
public void debug(Supplier<?> supplier = lambda) {
if (isDebug) { // 4. 디버그 모드이면 if 문이 수행된다. 디버그 모드가 아니면 람다는 실행되지 않는다.
// 5. supplier.get()을 실행하는 시점에 람다에 있는 value100() + value200()이 평가(계산)된다.
// 6. 평가 결과인 300을 반환하고 출력한다.
System.out.println("[DEBUG] " + supplier.get());
}
}
public class LogMain3 {
public static void main(String[] args) {
Logger logger = new Logger();
logger.setDebug(true);
logger.debug(() -> "Operation 1: " + (value100() + value200())); // 람다 전달
System.out.println("=== 디버그 모드 끄기 ===");
logger.setDebug(false);
logger.debug(() -> "Operation 2: " + (value100() + value200())); // 람다 전달 (실행 안됨)
}
람다를 사용해서 연산을 정의하는 시점과 실행(평가)하는 시점을 분리할 수 있고, 값이 실제로 필요할 때 까지 계산을 미룰 수 있다.
orElse() vs orElseGet()
이제 orElse()
와 orElseGet()
의 차이를 이해할 수 있을 것이다.orElse()
는 보통 데이터를 받아서 인자가 즉시 평가되고, orElseGet()
은 람다를 받아서 인자가 지연 평가된다.
public class OrElseGetMain {
public static void main(String[] args) {
Optional<Integer> optValue = Optional.of(100);
Optional<Integer> optEmpty = Optional.empty();
System.out.println("단순 계산");
// 10 + 20이 이미 계산된 후 orElse()가 호출된다. (즉시 연산)
Integer i1 = optValue.orElse(10 + 20); // 10 + 20 계산 후 버림
Integer i2 = optEmpty.orElse(10 + 20); // 10 + 20 계산 후 사용
// 값이 있으면 그 값, 없으면 지정된 기본값 사용 (orElse)
System.out.println("값이 있는 경우");
Integer value1 = optValue.orElse(createData()); // createData() 즉시 호출됨
System.out.println("value1 = " + value1);
System.out.println("값이 없는 경우");
Integer empty1 = optEmpty.orElse(createData()); // createData() 즉시 호출됨
System.out.println("empty1 = " + empty1);
// 값이 있으면 그 값, 없으면 지정된 람다 사용 (orElseGet)
System.out.println("값이 있는 경우");
Integer value2 = optValue.orElseGet(() -> createData()); // createData() 호출 안됨
System.out.println("값이 없는 경우");
Integer empty2 = optEmpty.orElseGet(() -> createData()); // createData() 호출됨
}
public static int createData() {
System.out.println("데이터를 생성합니다...");
try {
Thread.sleep(1000); // 데이터 생성에 1초 걸린다고 가정
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
int createValue = new Random().nextInt(100);
System.out.println("데이터 생성이 완료되었습니다. 생성 값: " + createValue);
return createValue;
}
}
orElse(createData())
Optional
에 값이 있어도createData()
가 즉시 호출된다. 호출된 값은 버려진다.- 자바 연산 순서상
createData()
를 호출해야 그 결과를orElse()
에 인자로 전달할 수 있다.
orElseGet(() -> createData())
Optional
값이 있으면createData()
가 호출되지 않는다.orElseGet()
에 람다를 전달한다. 해당 람다는 이후에orElseGet()
안에서 실행될 수 있다.Optional
내부에 값이 있다면, 인자로 전달한 람다를 내부에서 실행하지 않는다.Optional
내부에 값이 없다면, 인자로 전달한 람다를 내부에서 실행하고, 그 결과를 반환한다.
두 메서드의 차이
orElse(T other)
는 "빈 값이면other
를 반환"하는데,other
를 "항상" 미리 계산한다.- 따라서
other
를 생성하는 비용이 큰 경우, 실제로 값이 있을 때도 쓸데없이 생성 로직이 실행될 수 있다. orElse()
에 넘기는 표현식은 호출 즉시 평가하므로 즉시 평가(eager evaluation)가 적용된다.
- 따라서
orElseGet(Supplier supplier)
은 빈 값이면supplier
를 통해 값을 생성하기 때문에, 값이 있을 때는supplier
가 호출되지 않는다.- 생성 비용이 높은 객체를 다룰 때는
orElseGet()
이 더 효율적이다. orElseGet()
에 넘기는 표현식은 필요할 때만 평가하므로 지연 평가(lazy evaluation)가 적용된다.
- 생성 비용이 높은 객체를 다룰 때는
사용 용도orElse(T other)
- 값이
이미 존재할가능성이 높거나, 혹은 orElse()에 넘기는 객체(또는 메서드)가 생성 비용이 크지 않은 경우 사용해도 괜찮다. - 연산이 없는 상수나 변수의 경우 사용해도 괜찮다.
orElseGet(Supplier supplier)
- 주로 orElse()에 넘길 값의 생성 비용이 큰 경우, 혹은 값이 들어있을 확률이 높아 굳이 매번 대체 값을 계산할 필요가 없는 경우에 사용한다.
정리하면, 단순한 대체 값을 전달하거나 코드가 매우 간단하다면 orElse()
를 사용하고, 객체 생성 비용이 큰 로직이 들어있고, Optional에 값이 이미 존재할 가능성이 높다면 orElseGet()
을 고려해볼 수 있다.
실전 활용1 - 주소 찾기
public class User {
private String name;
private Address address; // 주소가 없을 수 있음
// 생성자, getter() 생략
package optional.model;
public class Address {
private String street;
// 생성자, getter() 생략
null을 직접 다루는 방식
// 사용자의 주소(street) 출력, 없으면 "Unknown"
static void printStreet(User user) {
String userStreet = getUserStreet(user);
if (userStreet != null) {
System.out.println(userStreet);
} else {
System.out.println("Unknown");
}
}
// 사용자의 주소(street) 반환, 없으면 null 반환
static String getUserStreet(User user) {
if (user == null) {
return null;
}
Address address = user.getAddress(); // Address가 null일 수 있음
if (address == null) {
return null;
}
return address.getStreet(); // Street 반환
}
- null 체크 구문이 자주 등장하고 복잡하다.
Optional로 개선
private static void printStreet(User user) {
getUserStreet(user).ifPresentOrElse(
System.out::println, // 값이 있을 때 출력
() -> System.out.println("Unknown") // 값이 없을 때 "Unknown" 출력
);
}
static Optional<String> getUserStreet(User user) {
return Optional.ofNullable(user) // user가 null일 수 있으므로 ofNullable 사용
.map(User::getAddress) // user.getAddress() 처리
.map(Address::getStreet); // address.getStreet() 처리
// 여기서 map 체이닝 중간에 null이면 Optional.empty()를 반환
}
- null 체크 구문이 사라지고 의도가 명확해졌다.
getUserStreet()
메서드는 이제Optional<String>
을 반환하므로, 호출 측에서ifPresentOrElse()
,orElse()
,orElseGet()
등을 통해 안전하게 처리할 수 있다.- 여러
map()
체이닝을 통해 내부에서null
이 발생하면 자동으로Optional.empty()
로 전환된다. - 참고로
Optional.ofNullable(user).map(u -> u.getAddress().getStreet());
이건 불가능하다. Address가 null일 수도 있기 때문이다. (null.getStreet())
실전 활용2 - 배송
public class Order {
private Long id;
private Delivery delivery; // 배송 정보가 없을 수 있음
// 생성자, getter() 생략
public class Delivery {
private String status;
private boolean canceled; // 배송 취소 여부
// 생성자, getter() 생략
orderRepository
라는 맵에서 주문 정보를 찾은 다음, 배송 정보를 조회하여 출력하는 코드를 작성해보자.
- 배송 정보가
null
이거나,canceled == true
인 경우에는"배송X"
를 출력한다.
public class DeliveryMain {
static Map<Long, Order> orderRepository = new HashMap<>();
static {
orderRepository.put(1L, new Order(1L, new Delivery("배송완료", false)));
orderRepository.put(2L, new Order(2L, new Delivery("배송중", false)));
orderRepository.put(3L, new Order(3L, new Delivery("배송중", true))); //취소됨.배송X
orderRepository.put(4L, new Order(4L, null)); // 배송 정보 없음. 배송X
}
public static void main(String[] args) {
System.out.println("1 = " + getDeliveryStatus(1L));
System.out.println("2 = " + getDeliveryStatus(2L));
System.out.println("3 = " + getDeliveryStatus(3L)); // 취소됨 -> 배송X
System.out.println("4 = " + getDeliveryStatus(4L)); // 배송 정보 없음 -> 배송X
}
static String getDeliveryStatus(Long orderId) {
return findOrder(orderId)
.map(Order::getDelivery) // Order -> Delivery
.filter(delivery -> !delivery.isCanceled())
.map(Delivery::getStatus) // Delivery -> String
.orElse("배송X"); // 값이 없으면 "배송X"
}
static Optional<Order> findOrder(Long orderId) {
return Optional.ofNullable(orderRepository.get(orderId));
}
}
- 주문 정보가
null
이면Optional.empty()
- 배송이 없거나 취소된 경우(
filter
에서 걸러짐)에도Optional.empty()
체이닝 - 최종적으로
.orElse("배송X")
처리
옵셔널 - 베스트 프랙티스
Optional
이 좋아보여도 무분별하게 사용하면 오히려 코드 가독성과 유지보수에 도움이 되지 않을 수 있다.Optional
은 주로 메서드의 반환값에 대해 값이 없을 수도 있음을 표현하기 위해 도입되었다.- 여기서 핵심은 메서드의 반환값에
Optional
을 사용하라는 것이다.
- 여기서 핵심은 메서드의 반환값에
1. 반환 타입으로만 사용하고, 필드에는 가급적 쓰지 말기
Optional
은 주로 메서드의 반환값에 대해 "값이 없을 수도 있음"을 표현하기 위해 도입되었다.
- 클래스의 필드(멤버 변수)에
Optional
을 직접 두는 것은 권장하지 않는다.Optional
자체도 참조 타입이기 때문에, 혹시라도 개발자가 부주의로Optional
필드에null
을 할당하면, 그 자체가NullPointerException
을 발생시킬 여지를 남긴다.
- 값이 없음을 명시하기 위해 사용하는 것이
Optional
인데, 정작 필드 자체가null
이면 혼란이 가중된다. - 만약
Optional
로 필드 값을 받고 싶다면, 필드는Optional
을 사용하지 않고, 반환하는 시점에Optional
로 감싸주는 것이 일반적으로 더 나은 방법이다.
2. 메서드 매개변수로 Optional 을 사용하지 말기
- 자바 공식 문서에
Optional
은 메서드의 반환값으로 사용하기를 권장하며, 매개변수로 사용하지 말라고 명시되어 있다. - 호출하는 측에서는 단순히
null
전달 대신Optional.empty()
를 전달해야 하는 부담이 생기며, 결국null
을 사용하든Optional.empty()
를 사용하든 큰 차이가 없어 가독성만 떨어진다. - 권장 예시
- 오버로드된 메서드를 만들거나,
- 명시적으로
null
허용 여부를 문서화하는 방식을 택합니다.
3. 컬렉션(Collection)이나 배열 타입을 Optional 로 감싸지 말기
List<T>
,Set<T>
,Map<K, V>
등 컬렉션(Collection) 자체는 비어있는 상태(empty)를 표현할 수 있으므로, Optional로 컬렉션을 감싸면Optional.empty()
와 "빈 리스트"(Collections.emptyList()
) 요렇게 이중 표현이 되어 혼란을 야기한다.- 컬렉션이 비어있는 경우를 고려한다면, Optional로 반환하는게 아니라 빈 컬렉션을 반환하자.
4. isPresent()와 get() 조합을 직접 사용하지 않기
Optional
의get()
메서드는 가급적 사용하지 않아야 한다.
if (opt.isPresent()) { ... opt.get() ... } else { ... }
는 사실상null
체크와 다를 바 없으며, 깜빡하면NoSuchElementException
같은 예외가 발생할 위험이 있다.- 대신
orElse
,orElseGet
,orElseThrow
,ifPresentOrElse
,map
,filter
등의 메서드를 활용하면 간결하고 안전하게 처리할 수 있다.get()
메서드는 가급적 사용하지 말고, 예제 코드나, 간단한 테스트에서만 사용하는 것을 권장한다.
- 어쩔 수 없이
get()
메서드를 사용해야 하는 상황이라면, 이럴 때는 반드시isPresent()
와 함께 사용하자.
5. orElseGet() vs orElse()차이를 분명히 이해하기
- 비용이 크지 않은(또는 간단한 상수 정도) 대체값이라면 간단하게
orElse()
를 사용하자.
- 복잡하고 비용이 큰 객체 생성이 필요한 경우, 그리고 Optional 값이 이미 존재할 가능성이 높다면
orElseGet()
를 사용하자.
6. 무조건 Optional 이 좋은 것은 아니다
Optional
은 분명히 편의성과 안전성을 높여주지만, 모든 곳에서 "무조건" 사용하는 것은 오히려 코드 복잡성을 증가시킬 수 있다.
- 다음과 같은 경우
Optional
사용이 오히려 불필요할 수 있다.- "항상 값이 있는" 상황
- 비즈니스 로직상
null
이 될 수 없는 경우, 그냥 일반 타입을 사용하거나, 방어적 코드로 예외를 던지는 편이 낫다.
- 비즈니스 로직상
- "값이 없으면 예외를 던지는 것"이 더 자연스러운 상황
- 예를 들어, ID 기반으로 무조건 존재하는 DB 엔티티를 찾아야 하는 경우,
Optional
대신 예외를 던지는 게 API 설계상 명확할 수 있다. 물론 이런 부분은 비즈니스 상황에 따라 다를 수 있다.
- 예를 들어, ID 기반으로 무조건 존재하는 DB 엔티티를 찾아야 하는 경우,
- "흔히 비는 경우"가 아니라 "흔히 채워져 있는" 경우
Optional
을 쓰면 매번.get()
,orElse()
,orElseThrow()
등 처리가 강제되므로 오히려 코드가 장황해질 수 있다.
- "성능이 극도로 중요한" 로우레벨 코드
Optional
은 래퍼 객체를 생성하므로, 수많은 객체가 단기간에 생겨나는 영역(예: 루프 내부)에서는 성능 영향을 줄 수 있다. (일반적인 비즈니스 로직에서는 문제가 되지 않는다. 극한 최적화가 필요한 코드라면 고려 대상)
- "항상 값이 있는" 상황
클라이언트 메서드 vs 서버 메서드
사실 Optional
을 고려할 때 가장 중요한 핵심은 Optional
을 생성하고 반환하는 서버쪽 메서드가 아니라, Optional
을 반환하는 코드를 호출하는 클라이언트 메서드에 있다. 결과적으로 Optional
을 반환받는 클라이언트의 입장을 고려해서 선택하자.
- "이 로직은
null
을 반환할 수 있는가?" - "
null
이 가능하다면, 호출하는 사람 입장에서 '값이 없을 수도 있다'는 사실을 명시적으로 인지할 필요가 있는가?" - "
null
이 적절하지 않고, 예외를 던지는 게 더 맞진 않은가?"
Optional 기본형 타입 지원
다음과 같은 이유로 잘 안 쓴다.
Optional<T>
와 달리map()
,flatMap()
등의 다양한 연산 메서드를 제공하지 않는다. 그래서 범용적으로 활용하기보다는 특정 메서드(isPresent()
,getAsInt()
등)만 사용하게 되어, 일반Optional<T>
처럼 메서드 체인을 이어 가며 코드를 간결하게 작성하기 어렵다.
- 기존에 이미
Optional<T>
를 많이 사용하고 있는 코드베이스에서, 특정 상황만을 위해OptionalInt
등을 섞어 쓰면 오히려 가독성을 떨어뜨린다.
원시 타입 Optional을 고려해볼 만한 경우
- 예외적으로 미세한 성능을 극도로 추구하거나, 기본형 타입 스트림을 직접 다루면서 중간에
OptionalInt
,OptionalLong
,OptionalDouble
을 자연스럽게 얻는 상황이라면 이를 사용하는 것도 괜찮다.
'Java > Modern Java(8~)' 카테고리의 다른 글
[Java] 68. 병렬 스트림 (0) | 2025.07.04 |
---|---|
[Java] 67. 디폴트 메서드 (0) | 2025.07.04 |
[Java] 65. 스트림 API3 - 컬렉터 (0) | 2025.07.04 |
[Java] 64. 스트림 API2 - 기능 (0) | 2025.07.04 |
[Java] 63. 스트림 API1 - 기본 (0) | 2025.07.04 |