Backend/Java

[Effective Java : Item 55] 옵셔널 반환은 신중히 하라

김세진 2024. 3. 14. 01:13
반응형

 

 

 

 

개요

 

자바 8 이전, 즉 Optional이 존재하기 이전에는 메서드가 특정 조건에서 값을 반환하지 못할 때 취할 수 있는 두 가지 선택지가 있었다. 하지만 각각 가지고 있는 허점이 존재했다.

 

1. 예외를 던진다.

예외는 되도록 진짜 예외인 상황에서만 사용해야 하고, 예외가 발생할 때 스택 추적 전체를 캡쳐하므로 다소 비용이 크다.

 

2. null을 반환한다.

별도의 null 처리를 해야 한다. 그렇지 않다면 언젠가 null이 발생한 근원지와는 상관없는 곳에서 NullPointException이 발생할 것이다.

 

위같은 문제를 해결하기 위해 Optional이 등장했다.

 

 


Optional

 

Optional<T>는 null이 아닌 T 타입 참조를 하나 담거나, 아무것도 담지 않을 수 있다.

 

 

또한 Optional<T>는 위와 같이 필드가 EMPTY, value 두 가지가 존재하는데 모두 final로 선언된 것을 확인할 수 있다. 즉 Collection<T>를 구현한 것은 아니지만, 개념적으로 불변 컬렉션이다.

 

이 Optional은 보통은 T를 반환해야 하지만 특정 조건에서는 아무것도 반환하지 않아야 할 때 T 대신 Optional<T>를 반환하도록 선언하면 된다. 옵셔널을 반환하는 메서드는 예외를 던지는 메서드보다 유연하고 사용하기 쉬우며, null을 반환하는 메서드보다 오류가 발생할 가능성이 작다.

 

아래는 주어진 컬렉션에서 최댓값을 뽑아주는 메서드 예제이다.

 

public static <E extends Comparable<E>> E max(Collection<E> c) {
    if (c.isEmpty()) {
        throw new IllegalArgumentException("빈 컬렉션");
    }

    E result = null;
    for (E e : c) {
        if (result == null || e.compareTo(result) > 0) {
            result = Objects.requireNonNull(e);
        }
    }
    return result;
}

 

위 메서드에 빈 컬렉션을 전달하면 IllegalArgumentException을 반환한다. Optional을 적용한 모습은 다음과 같다.

 

public static <E extends Comparable<E>> Optional<E> max(Collection<E> c) {
    if (c.isEmpty()) {
        return Optional.empty();
    }

    E result = null;
    for (E e : c) {
        if (result == null || e.compareTo(result) > 0) {
            result = Objects.requireNonNull(e);
        }
    }
    return Optional.of(result);
}

 

어렵지 않게 Optional을 적용할 수 있다. 빈 옵셔널은 Optional.Empty(), 값이 있는 옵셔널은 Optional.of(value) 두 가지 정적 팩터리로 생성했다.

 

만약 Optional.of(value) 에 null을 전달하면 NullPointException을 던지니 주의해야 한다. 만약 value에 null이 들어올 경우도 허용하고 싶다면 Optional.ofNullable(value)를 사용하면 된다. 아래 코드에서 볼 수 있듯, value의 null 여부에 따라 적절한 값을 반환해준다.

 

public static <T> Optional<T> ofNullable(T value) {
    return value == null ? (Optional<T>) EMPTY
                         : new Optional<>(value);
}

 

혹여나 옵셔널을 반환하는 메서드에서는 절대 null을 반환해서는 안 된다. 옵셔널을 도입한 취지와 완전히 어긋나는 행위이다.

 

 


검사 예외

 

검사 예외IOException과 같이 외부 리소스에 접근하거나 입출력을 처리할 때 컴파일러에 의해 처리가 강제되는 예외를 의미한다. 반대되는 의미로 비검사 예외란 NullPointerException과 같이 클라이언트 코드 오류나 런타임 환경 문제로 주로 발생하는, 컴파일러에 의해 처리가 강제되지는 않는 예외를 의미한다.

 

왜 null을 반환하거나 예외를 던지는 대신 Optional 반환을 선택해야 하냐고 묻는다면, 위 검사 예외와 취지가 비슷하다고 할 수 있다. 반환 값이 없을 수도 있음을 API 사용자에게 명확히 전달하여, 클라이언트가 값을 받지 못했을 때 취할 행동을 선택하게끔 한다.

 

기본값 설정

String lastWordInLexicon = max(words).orElse("단어 없음");

 

orElse 메서드를 통해 빈 Optional에 대한 기본값을 설정할 수 있다. 다만, orElse는 optional이 실제 비어있든 말든 실행되어 기본값을 설정하기 때문에, 기본값 생성 비용이 다소 크다면 부담이 될 수 있다. 따라서 optional의 값이 null일 때에만 기본값을 설정하도록 하는 orElseGet 이 존재한다.

String lastWordInLexicon = max(words).orElseGet(() -> "단어 없음");

 

orElse와는 다르게 Supplier<T>를 인수로 전달받아 값이 실제로 필요해질 때 Supplier<T>를 사용해 생성하므로 초기 설정 비용을 낮출 수 있다.

 

예외 생성

Toy myToy = max(toys).orElseThrow(TemperTantrumException::new);

 

상황에 맞는 예외를 던질 수 있다. 실제 예외 발생이 아니라 팩터리를 건넨 것이므로 개요에서 언급했던 예외 생성 비용은 발생하지 않는다.

 

값 반환

Element lastNobleGas = max(Elements.NOBLE_GASES).get();

 

Optional의 값이 항상 채워져 있다고 가정했을 때 get 메서드를 통해 Optional 내부의 값을 얻을 수 있다. 만약 값이 없는 경우 NoSuchElementException이 발생하므로 주의해야 한다.

 

값 존재여부

Optional<ProcessHandle> parentProcess = ph.parent();
System.out.println("부모 PID: " + (parentProcess.isPresent() ?
    String.valueOf(parentProcess.get().pid()) : "N/A"));

 

isPresent()는 Optional에 값이 존재하면 true, 없으면 false를 반환한다. 이 메서드를 통해 대부분의 원하는 작업을 수행할 수 있지만, 이를 대체할 더 간결하고 명확하며 용법에 맞는 메서드들이 있으니 반드시 필요한 경우에만 사용해야 한다. 위의 코드는 map과 orElse를 사용하여 다음과 같이 대체될 수 있다.

 

System.out.println("부모 PID: " + 
    ph.parent().map(h -> String.valueOf(h.pid())).orElse("N/A"));

 

값 존재 시 수행

optional.ifPresent(value -> System.out.println("Value is present: " + value));

 

ifPresent는 Optional이 비어 있지 않을 때(값을 가질 때) 실행할 작업을 지정할 수 있는 메서드이다.

 

그 외

추가적인 메서드로 filter, map, flatMap이 존재한다. 해당 메서드들은 stream에서 사용하는 메서드들과 이름 및 기능이 동일하다.

 


스트림

 

한편 스트림의 종단 연산은 대부분 Optional을 반환한다. 따라서 위의 최댓값 메서드에 Stream을 적용하면 다음과 같이 작성할 수도 있다.

 

public static <E extends Comparable<E>> Optional<E> max(Collection<E> c) {
    return c.stream().max(Comparator.naturalOrder());
}

 

스트림을 사용한다면 Optional들을 Stream<Optional<T>>로 받아서, 그 중 채워진 값들을 뽑아 Stream<T>에 건네 담아 처리하는 경우가 흔하다. 예를 들어 다음과 같이 구현할 수 있다.

 

streamOfOptionals
    .filter(Optional::isPresent) // 값이 채워진 optional만 필터
    .map(Optional::get) // 그 값을 꺼내 스트림에 매핑

 

자바 9에서는 Optional에 Optional을 스트림으로 변환시켜주는 stream() 메서드가 추가되었다. 옵셔널에 값이 존재한다면 그 값을 담은 스트림으로, 값이 없다면 빈 스트림으로 반환해준다. 이를 위의 코드에 적용하면 다음과 같이 명료하게 표현할 수 있다.

 

streamOfOptionals
    .flatMap(Optional::stream)

 

 


주의/고려할 점

 

컨테이너 타입

컬렉션, 스트림, 배열, 옵셔널 같은 컨테이너 타입은 옵셔널로 감싸면 안 된다. 빈 Optional<List<T>> 보다는 빈 List<T>를 반환하는 것이 낫다. 그냥 빈 List<T>를 반환한다면 Optional을 처리할 추가 코드를 작성하지 않아도 되고 훨씬 간결해진다.

 

성능

그리고 Optional도 엄연히 한 단계를 더 거치는 작업이기 때문에 성능에 민감한 상황에서는 맞지 않을 수 있다. 성능이 중요한 메서드라면 Optional을 사용할 때의 성능 검증을 반드시 해야 한다.

 

기본 타입

또한 박싱된 기본 타입을 담는 옵셔널을 반환하면 안 된다(ex: Optional<Integer>). 아무래도 기본 타입을 담는 것보다 무거울 수밖에 없다. 이를 위해 전용 옵셔널 클래스로 OptionalInt, OptionalLong, OptionalDouble이 존재한다. 이 옵셔널들도 Optional<T>에 존재하는 메서드들을 대부분 제공한다.

 

Boolean, Byte, Character, Short, Float의 경우 박싱된 기본 타입이긴 하지만 기본 타입의 크기가 작아서 성능상 큰 부담은 아니기 때문에 예외일 수 있다.

 

컬렉션의 원소

옵셔널을 Map의 값으로 사용하면 절대 안 된다. 만약 그렇게 한다면 키 자체가 없는 경우, 키는 존재하지만 value로 들어 있는 옵셔널이 빈 경우 두 가지를 모두 검사해야 할 것이다. 쓸데없이 혼란과 오류 가능성만 키우게 되는 꼴이 되므로 일반적으로 옵셔널을 컬렉션의 키, 값, 원소나 배열의 원소로 사용하는 것은 대부분의 경우 적절하지 않다. 

 

인스턴스 필드

인스턴스의 필드로 필수가 아니면서 기본(primitive) 타입인 필드가 있다면, 값이 없음을 나타낼 마땅한 방법이 존재하지 않다. 따라서 이러한 경우에는 필드 자체를 옵셔널로 선언하는 것도 좋은 방법이라고 할 수 있다.

 

 


 

정리

 

1. 값을 반환하지 못할 가능성이 존재한다면 null이나 예외를 던지기 보다 옵셔널을 사용하는 것을 고려하자.

2. 성능에 민감한 상황이라면 철저한 성능 검증을 통해 옵셔널의 도입을 고려하도록 하자.

3. 옵셔널을 반환값에 대한 처리 이외의 용도로는 되도록 사용하지 말자.

 

 

 

 

 

반응형