본문 바로가기

Reading/Effective Java

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

자바 8 전에는 메소드가 특정 조건에서 값을 반환할 수 없을 때는 예외를 던지거나, null을 반환하는 방법을 사용했다.

예외는 생성할 때 스택 추적 전체를 캡처하는 비용이 만만치않고, null 반환은 별도의 null 처리코드를 추가해야하는 단점이 있다.

 

Optional<T>


 

Optional<T>는 자바 8에서 등장한 개념으로, null이 아닌 T타입 참조를 하나 담거나 아무것도 담지 않을 수 있다.

옵셔널은 원소를 최대 1개 가질 수 있는 불변 컬렉션이고, Collection<T>를 구현하진 않았지만 원칙적으론 그렇게 말한다.

보통은 T를 반환해야 하지만 특정 조건에서는 아무것도 반환하지 않아야 할 때 T대신 Optional<T>를 반환하도록 선언하면 된다.

 

Optional을 반환하는 메소드는 예외를 던지는 메소드보다 유연하고 사용하기 쉬우며 null을 반환하는 메소드보다 오류 가능성이 작다.

자 그러면 Optional의 구성 필드와 생성자를 간단하게 알아보고 어떤식으로 생성할 수 있는지의 정적 메소드를 알아보자.

 

final class Optional&amp;lt;T&amp;gt;

 

우선 static final로 선언된 EMPTY가 있다. 빈 Optional을 매개변수가 없는 private 생성자를 통해 미리 들어가있고, 정적 메소드인 empty()를 호출하면 형변환을 비어있는 정적 필드를 반환한다. 값이 있는 생성자는 null 체크를 하고 private 생성자로 value로 넣는 것을 알 수 있다. 이 때, Objects.requireNonNull로 null을 체크하기 때문에, 매개변수로 넘어온 값이 null이면 NPE가 발생한다.

 

 

Optional에 value를 채우기 위해서, of()와 ofNullable() 메소드가 있다. 생성자에서 null이 들어가면 NPE가 터지기 때문에, of는 null이 아님이 확실한 경우에만 호출해야하고, null일 가능성이 있으면 ofNullable을 호출하면된다. 그러면 value 값이 null인지 체크하고, null 이면 비어있는 Optional을 반환하고, null이 아님이 확인된다면 of 메소드를 호출해서 값을 넣어주고 그 결과를 반환해준다.

 

여기까지 Optional을 어떻게 생성할 수 있는지를 알아봤다.

 

Optional 활용 예제1


 컬렉션에서 최댓값을 구한다고 가정해보자. Optional을 사용하지 않은 코드는 다음과 같다.

 

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을 던진다. 비어있다는 것을 굳이 예외를 생성해서 비용이 발생한다. 물론 컬렉션이 비어있지 않으면 첫 번째 원소라도 바로 반환을 하니까 null을 반환하진 않는다. 그리고 Objects.requireNonNull을 통해 null을 확인하니 첫 원소가 null이라도 예외가 터질 뿐이다. 이 코드를 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<T> 에서 설명했던 정적 팩토리를 사용해 옵셔널을 생성만 해주면 된다. 컬렉션이 비었다면 빈 옵셔널을 반환하고, 원소가 한개라도 있으면, 그게 null이면 NPE가 터질거고, 그게 아니라면 null이 아님이 보장되어 Optional.of() 메소드를 사용하면 된다. null일지도 모른다면 Optional.ofNullable() 메소드를 반환하면 되고 발이다. 상황에 맞게 사용하면 되니까

옵셔널을 반환하는 메소드에서는 절대 null을 반환하지 말자. 옵셔널 도입 취지를 완벽히 무시한 행위다.

 

스트림의 종단 연산 중 상당수는 옵셔널을 반환한다. 이 max 메소드를 스트림 버전으로 작성하면 다음과 같다.

 

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

 

Stream의 max연산이 비교자를 명시적으로 전달해야 하지만 필요한 옵셔널을 생성해준다. max() 메소드 연산자는 Comparator를 파라미터로 받는다. Comparator.naturalOrder 메소드는 다음 열거 타입의 인스턴스를 Comparator<T>로 형변환해서 사용한다.

 

 

열거 타입이 Comparator를 구현하는데, 이때 타입이 Comparable을 구현한 객체만 들어올 수 있다. 그래서 Comparable을 구현하지 않은 객체는 제네릭으로 선언한 <E extends Comparable<E>> 에 맞지않아 들어올 수 없다.

 

 

NotImplComp는 Comparable을 구현하지 않은 클래스다.

max 제네릭 메소드를 Comparable을 구현하지 않은 매개변수가 사용하려하면 Comparable을 구현하지 않았다는 컴파일 에러가 뜬다.

 

 

마찬가지로, Comparable을 구현하지 않으면 Compartor.naturalOrder를 사용할 수 없다. 내부를 보면 알겠지만 단순히 Comparable을 구현한 객체에 한해서 Comparator 객체를 직접 생성하지않고 정적 메소드를 호출하게 편리성을 제공해주는 메소드다.

 

그렇다면 null을 반환하거나 예외를 던지는 대신 옵셔널 반환을 선택해야 하는 기준은 뭘까? 옵셔널은 검사 예외와 취지가 비슷하다.

즉 반환 값이 없을 수도 있음을 API 사용자에게 명확히 알려준다. 비검사 예외를 던지거나 null을 반환하면 API 사용자가 인지하지 못해 끔찍한 결과가 발생할 수 있는데, 검사 예외를 던지면 클라이언트에서는 반드시 이에 대처하는 코드를 작성해넣어야 한다.

 

비슷하게 메소드가 옵셔널을 반환하면 클라이언트는 값을 받지 못했을 때 취할 수 있는 행동을 알아보자.

 

(1) orElse

 

 

옵셔널이 비어있을 경우 파라미터로 전달한 값을 반환하고, 비어있지 않다면 가진 값을 반환한다.

 

(2)orElseThrow

 

 

매개변수가 없는 orElseThrow는 옵셔널이 비었다면 NoSuchElementException을 던지고, 값이 있다면 반환한다.

매개변수가 있는 orElseThrow는 옵셔널이 비었다면 파라미터로 넘어온 예외를 던지고, 값이 있다면 반환한다.

이렇게 사용하면 실제 예외가 아니라 예외 팩토리를 건네서 예외가 실제로 발생하지 않는 한 예외 생성 비용은 들지 않는다.

 

(3) get

 

 

옵셔널에 값이 있다는 가정하에 get() 메소드를 통해 곧바로 값을 꺼내 사용할 수 있다. 하지만 잘못 판단한 거라면 내부적으로 NoSuchElementException이 발생한다.

 

(4) orElseGet

 

 

기본값 설정 비용이 크다면, Supplier<T>를 사용해 값이 처음 필요할 때 생성하므로 초기 설정 비용을 낮출 수 있다. 내부 코딩에서 보이듯이, value가 null일 경우에만 Supplier를 실행해서 값을 가져옴으로 orElse가 초기값이 항상 설정되어 들어가는 것과 달리 큰 장점이다.

 

//초기 옵셔널 설정
Optional<NotImplComp> optionalWord = Optional.of(new NotImplComp(25, "kkt"));
// 옵셔널이 비어있다면 기본 값 설정할 수 있는 메소드
optionalWord.orElse(new NotImplComp(24,"kkt!"));
// 옵셔널이 비어있다면 예외를 처리할 수 있는 메소드
optionalWord.orElseThrow(NullPointerException::new);
// 옵셔널에 값이 있다는 가정하에 값을 가져올 수 있는 메소드
optionalWord.get();
// orElse와 달리 객체 생성을 옵셔널이 비어있을때만 실행되도록 미룰 수 있음
optionalWord.orElseGet(()-> new NotImplComp(24,"kkt!"));

 

 

 

Optional 활용 예제2


여전히 적합한 메소드를 찾지 못했다면 더 특별한 쓰임에 대비한 메소드도 있다. 

 

(1) isPresent / isEmpty

 

 

안전 벨브 역할의 메소드로 isPresent는 옵셔널이 채워져있으면 true, 비었으면 false를 반환한다. isEmpty는 반대일 뿐이다.

하지만 이 메소드를 쓰는 코드 상당수가 앞서 언급한 메소드들로 대체할 수 있고 더 짧고 명확하고 용법에 맞는 코드가 된다.

 

다음 코드는 부모 프로세스의 프로세스 ID를 출력하거나 부모가 없다면 "N/A"를 출력하는 코드다.

이 코드는 Optional의 map 메소드를 사용해 다음처럼 바꿀 수 있다.

 

ProcessHandle ph = ProcessHandle.current();
// isPresent를 적절치 못하게 사용했다.
Optional<ProcessHandle> parentProcess = ph.parent();
System.out.println("부모 PID: " + (parentProcess.isPresent() ?
        String.valueOf(parentProcess.get().pid()) : "N/A"));

// 같은 기능을 Optional의 map를 이용해 개선한 코드
System.out.println("부모 PID: " +
        ph.parent().map(h -> String.valueOf(h.pid())).orElse("N/A"));

 

 

(2) Map

 

 

빈 옵셔널이면 빈 옵셔널을 반환하고, 값이 있다면 mapper Function에 value를 파라미터로 넣은 결과를 Optional로 반환한다.

위에서 사용한 예시가 아이디를 출력하기 위한 것이기 때문에, ProcessHandle을 그대로 반환하지 않고 pid를 String으로 매핑해 Optional로 반환하고 거기에 값이 빌 경우를 대비해 orElse를 통해서 기본 String을 잡아준 형태다.

 

그리고 Optional에는 map 뿐만아니라, flatMap과 filter가 존재한다.

 

(3) filter

 

 

값이 없다면 현재 Optional 반환하고, 값이 있다면 predicate value 파라미터로 줘서 참이라면 값이 있는 현재 Optional 반환하고, predicate 충족하지 못하면 옵셔널을 반환한다.

 

(4) flatMap

 

 

map과 유사하게, 비었다면 빈 옵셔널을 보내고, 값이 있다면 value에 mapper Function을 적용시켜서 결과값을 Optional로 반환한다.

map과의 차이는 Function에서 return 값을 Optional로 받기 때문에, 추가적으로 Optional를 래핑하지 않는다. 그리고 그 Optional이 null만 아니라면 그 값을 반환해준다. 테스트 코드를 보면 무슨 말인지 알거다.

 

@Test
public void map() {
    Optional<Optional<String>> s1 = Optional.of(Optional.of("STRING"));
    // map은 만약 Optional을 리턴할 경우 Optional이 한 번 더 래핑된다.
    Optional<Optional<String>> s2 = Optional.of("string").map(s -> Optional.of("STRING"));
    assertEquals(s1,s2);
}
@Test
public void flatMap() {
    Optional<String> s1 = Optional.of("STRING");
    // FlatMap은 Optional을 리턴해야한다. 결과는 Optional을 추가로 래핑하지 않는다.
    Optional<String> s2 = Optional.of("string").flatMap(s -> Optional.of("STRING"));
    assertEquals(s1,s2);
}

 

테스트는 다음 레퍼런스를 참조하였다. [ 참고 ]

 

 

(5) stream

 

그리고 자바 8에서는 스트림을 사용해서 채워진 옵셔널 값을 뽑아 Stream에 담아 처리할 수도 있다.

 

Stream<Optional<ProcessHandle>> processHandleStream = ProcessHandle.allProcesses()
        .map(Optional::ofNullable);
// (1) filter를 사용해 값이 있다면 그 값을 꺼내 스트림에 매핑
Stream<ProcessHandle> existValues = processHandleStream
        .filter(Optional::isPresent)
        .map(Optional::get);
// (2) Optional에 자바 9부터 추가된 stream() 메소드를 이용해 변환
Stream<ProcessHandle> existValuesFlat = processHandleStream
        .flatMap(Optional::stream);

 

ProcessHandle.allProcess() 자체가 이미 값이 존재하는 Stream으로 구성되어있지만, 예제를 위해서 Optional을 매핑했다.

(1)의 방법은 말 그대로 filter로 값이 존재하는 Optional만 골라서 값을 get으로 꺼내 매핑하면 된다.

(2)는 자바 9부터 Optional에 추가된 stream 메소드를 이용하면 옵셔널에 값이 있으면 그 값을 원소로 담은 스트림으로, 값이 없다면 빈 스트림으로 변환한다. 이를 Stream의 flatMap 메소드와 조합하여 사용한 결과다.

 

 

Optional의 stream 메소드 내부고, 말 그대로 값이 있으면 원소를 담은 Stream을 반환하고 없다면 빈 Stream을 반환하는 것이다. 

 

(6) Or

 

 

자바 9부터 추가된 or 메소드는 값이 있으면 Optional을 그대로 반환하고 값이 없다면 supplier를 통해서 받은 객체를 다시 Optional로 래핑해서 Optional 자체가 null인지만 확인하고 그 옵셔널을 반환해준다. orElseGet과의 차이점은 supplier를 실행한 반환값이 null일 수 있으니 Optional로 한 번 감싸준다.

 

(7) ifPresent / ifPresentOrElse

 

 

간단하게 ifPresent는 value가 null이 아니면 Consumer로 넘어온 함수형 인터페이스를 실행하는 것이고, ifPresentOrElse는 value가 null이 아니면 Consumer 함수형 인터페이스를 실행하지만, null일 경우에 Runnable로 넘어온 함수를 실행해준다.

 

이렇게 Optional 클래스에서 사용할 수 있는 것들을 전반적으로 알아보았다.

 

 

Optional 사용 시점


1. 컬렉션, 스트림, 배열, 옵셔널 같은 컨테이너 타입은 옵셔널로 감싸지말자.

빈 Optional<List<T>>를 반환하기보다 빈 List<T>를 반환하는게 좋다. 그러면 클라이언트가 옵셔널 처리코드를 작성하지 않아도 된다.

 

ProcessHandle.Info 인터페이스의 arguments 메소드는 Optional&lt;String[]&gt;을 반환하는데 예외적인 경우니 따라하지 말자.

 

 

2. 결과가 없을 수 있으며, 클라이언트가 이 상황을 특별하게 처리해야 한다면 Optional<T>를 반환하자

이렇게 하더라도 Optional<T>를 반환하는 데는 대가가 따른다. Optional도 새로 할당하고 초기화해야 하는 객체고, 그 안에서 값을 꺼내려면 메소드를 호출해야 하니 한 단계를 더 거친다. 그래서 성능이 중요한 상황에선 옵셔널이 맞지않을 수 있고 어떤 메소드가 이 상황에 처하는지 알려면 세심히 측정해보는 수 밖에 없다.(아이템67)

 

3. 박싱된 기본 타입을 담은 옵셔널을 반환하는 일은 없게하자

박싱된 기본 타입을 담는 옵셔널은 래핑되서 기존보다 무거울 수 밖에 없고, 기본 타입을 사용하기 위한 옵셔널 클래스들도 따로 있다.

바로 OptionalInt, OptionalLong, OptionalDouble 클래스다. 이 옵셔널들도 Optional<T>가 제공하는 메소드를 거의 다 제공한다.

단, 덜 중요한 기본 타입용인 Boolean, Byte, Character, Short, Float은 없기 때문에 상황과 성능에 맞게 선택해야한다.

 

4. 옵셔널을 컬렉션의 키, 값, 원소나 배열의 원소로 사용하지 말자

옵셔널을 만약 맵의 값으로 사용하면 맵 안에 키가 없다는 사실을 나타내는 방법은 2가지가 된다.

  • 키 자체가 없는 경우
  • 키는 있지만 그 키가 속이 빈 옵셔널인 경우

이렇게 컬렉션이나 배열의 원소로 사용하게되면 쓸데없이 복잡성만 높여 혼란과 오류 가능성을 키울 뿐이다.

적절한 상황이 있을지는 모르겠지만, 대부분의 상황에서는 적절하지 않다.

 

5. 인스턴스 필드에 Optional을 선언하는 것은 상황에 맞게 사용하자

대부분은 필수 필드를 갖는 클래스와 이를 확장해 선택적 필드를 추가한 하위 클래스를 따로 만들어야 함을 암시하기 때문에 좋지않다.

하지만 인스턴스의 필드 중 상당수가 필수가 아닌데,[아이템 2 NutritionFacts class처럼] 기본 타입들(int,long...)인 경우 값이 없음을 나타낼 방법이 마땅치 않다. 이런 상황에서는 선택적 필드들의 getter에 옵셔널을 반환하게 하면 더 나은 선택지일 수도 있다. 이런 경우에는 필드 자체를 옵셔널로 선언하는 것도 방법이 될 수 있다.

 

 

 

정리


값을 반환하지 못하거나 반환값이 없을 가능성을 염두에 둬야한다면 옵셔널을 반환해야할 상황일 수 있다.

하지만 옵셔널 반환에는 성능 저하가 뒤따르니, 성능에 민감한 메소드라면 null을 반환하거나 예외를 던지는게 나을 수 있다.

이 외에, Optional을 올바르게, 조금이라도 좋게 사용하기 위해서 참고할 수 있는 레퍼런스도 있다. [ 참고1 ] / [ 참고2 ]

 

 

* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.