Optional 제대로 활용하기

Optional을 올바르게 사용하기 위해 공부한 내용을 정리합니다.

개요

getter에 Optional을 사용하는 것이 좋은지에 대해, Java 언어 아키텍트인 Brian Goetz가 Stackoverflow에 작성한 답변(링크)입니다.

당연히 사람들은 맘대로 할 겁니다. 하지만 우리는 명확한 의도를 가지고 이 기능(Optional)을 추가했고, 그건 많은 사람들이 바랬던 일반적인 목적의 maybe 타입은 아니었습니다. 우리 의도는 “결과 없음”을 명확히 표현할 방법이 필요한 라이브러리 메서드 반환 타입에 한정된 메커니즘을 제공하는 것이었으며, 이러한 경우에 null의 사용이 오류를 발생시킬 가능성이 압도적으로 높았기 때문입니다.

예를 들어, 결과의 배열이나 리스트를 반환하는 곳에는 사용하지 않는 것이 좋을 지도 모릅니다. 대신 빈 배열이나 리스트를 반환하세요. 필드나 메서드 파라미터로는 거의 사용해서는 안됩니다.

일상적으로 이를 게터(getter)의 반환 값으로 사용하는 것은 분명히 과용(over-use)이라고 생각합니다.

피해야 하는 Optional에는 아무 잘못이 없습니다. 그저 많은 사람들이 원했던 것이 아닐 뿐이며, 우리는 과용했을 때의 위험성에 대해서도 염려했습니다.

Java 8에 들어서 스트림과 람다 등 함수형 프로그래밍을 위한 기능들이 추가됐습니다. 이러한 기능들을 이해하는 것도 좋은 코드를 만드는 데에 필요하지만, 해당 기능의 의도를 이해하고 올바르게 사용하는 것도 중요하다는 생각이 들었습니다.

최근에 코드에 Optioanl 타입을 조금씩 적용해보고 있는데, 이러한 이해 없이 사용하려다보니 어느 부분에 사용하는 것이 적절하거나 그렇지 않은지를 명확히 구분하기 어렵다고 생각하던 중에 위와 같은 글을 찾게 됐습니다.

더불어서 Optional을 올바르게 사용하기 위한 정보도 제공하는 글이 있어 일부를 번역해서 남겨봅니다.

원문 보기

Note: 원문에서는 Optional에서 제공하는 다양한 API들을 소개하고 있는데, 이번 글에서는 JDK 8에서 사용 가능한 항목들을 다룹니다.

Optional을 올바르게 사용하자

1. Optional 변수에 절대로 null을 할당하지 말 것

나쁜 예:

Optional<Person> findById(Long id) {
    // find person from db
    if (result == 0) {
        return null;
    }
}

좋은 예:

Optional<Person> findById(Long id) {
    // find person from db
    if (result == 0) {
        return Optional.empty();
    }
}

반환 값으로 null을 사용하는 것이 위험하기 때문에 등장한 것이 Optional입니다. 당연히 Optional 객체 대신 null을 반환하는 것은 Optional의 도입 의도와 맞지 않겠죠.

Optional은 내부 값을 null로 초기화한 싱글턴 객체를 Optional.empty() 메서드를 통해 제공하고 있습니다. 위에서 인용한 답변과 같이 “결과 없음”을 표현해야 하는 경우라면 null을 반환하는 대신 Optional.empt()를 반환하면 값을 반환받은 쪽에서는 이후에 소개할 메서드들을 통해 적절하게 처리를 이어갈 수 있습니다.

2. Optional.get() 호출 전에 Optional 객체가 값을 가지고 있음을 확실히 할 것

Optional을 사용한다면 그 안에 들어있는 값은 Optional.get() 메서드를 통해 접근할 수 있습니다. 만약 빈 Optional 객체에 get() 메서드를 호출한 경우 NoSuchElementException이 발생합니다. 때문에 Optional 객체에서 값을 가져오기 전에는 이후에 소개할 API들을 통해 반드시 값이 있는지 확인해야 합니다.

나쁜 예:

Optional<Person> maybePerson = findById(4);
String name = maybePerson.get().getName();

피해야 하는 예:

Optional<Person> maybePerson = findById(4);
if (myabePerson.ifPresent()) {
    return maybeperson.get();
}
return UNKNOWN_PERSON;

좋은 예:

Person person = findById(4).orElseThrow(PersonNotFoundException::new);
String name = person.getName();

피해야 하는 예의 경우는 반드시 나쁘다고만은 할 수 없지만 이후에 소개할 Optional의 API를 활용하면 동일한 로직을 더 간단하게 처리할 수 있습니다. Optioanl을 이해하고 있다면 가독성 면에서도 더 낫기 때문에 꼭 필요한 경우가 아니라면 피하는 것이 좋습니다.

3. 값이 없는 경우, Optional.orElse()를 통해 이미 생성된 기본 값(객체)를 제공할 것

결과가 없는 상황에 대해 null 대신 Optional을 사용하기로 했으니, 이전에는 null을 반환했던 “값이 없는” 상황을 처리할 방법은 크게 두 가지로 볼 수 있습니다.

  • 기본값 반환
  • 예외 던지기

Optional 객체에 값이 있는지는 Optional.isPresent() 메서드를 통해 확인할 수 있습니다.

Optional.orElse() 메서드는 “기본값 반환”에 해당하는 메서드입니다. Optional 객체의 값이 없는 경우에 orElse의 인자로 명시된 값을 대신 반환합니다.

좋은 예:

// UNKNOWN_PERSON is pre-defined object for case that no person is found that id matches
Person person = findById(4).orElse(UNKNOWN_PERSON);

주의할 점은 orElse 메서드의 인자는 Optional 객체가 비어있지 않은 경우에도 평가된다는 점입니다.

주의:

findById(4).orElse(new Person());

위의 코드는 findById가 반환한 Optional 객체가 비어있지 않은 경우에도 Person 생성자를 호출합니다. 즉, 이 방법을 생성 비용이 비싼 객체에 사용할 때는 조심해야 합니다. 이런 경우에 공통으로 사용할 수 있는 객체를 미리 생성해서 사용하는 것이 좋겠습니다. 매번 새로운 객체를 생성해야 한다면 4번 항목을 참조하세요.

4. 값이 없는 경우, Optional.orElseGet()을 통해 이를 나타내는 객체를 제공할 것

3번 항목의 경우 값이 없는 경우에 문자열 처럼 동일한 객체 참조를 반환해도 괜찮은 경우에 적합합니다. 하지만 불변 객체가 아닌 경우 이 방법은 위험할 수 있습니다. 값이 없는 경우에 매번 새로운 객체를 반환해야 하는 경우에는 Optional.orElseGet()을 사용할 수 있습니다. orElse가 기본값으로 반환할 을 인자로 받는것과 달리, orElseGet()은 값이 없는 경우 이를 대신해 반환할 값을 생성하는 람다를 인자로 받습니다.

좋은 예:

findById(4).orElseGet(() -> new Person("UNKNOWN")); // construct with named 'UNKNOWN'

3번 항목과 비교할 때의 장점은, Optional의 값이 없는 경우에만 인자로 전달된 코드가 실행된다는 점입니다. 이 방법으로 매번 새로운 객체를 생성하는 경우에는 실제로 값이 없는 경우에만 객체를 생성한다는 의미입니다. Map.computeIfXXX와 유사하다고 볼 수도 있겠습니다.

5. 값이 없는 경우, Optional.orElseThrow()를 통해 명시적으로 예외를 던질 것

값이 없는 경우, 기본값을 반환하는 대신 예외를 던져야 하는 경우도 있습니다. 이 경우에는 Optional.orElseThrow()를 사용할 수 있습니다.

findById(4).orElseThrow(() -> new NoSuchElementException("Person not found"));

6. 값이 있는 경우에 이를 사용하고 없는 경우에 아무 동작도 하지 않는다면, Optional.ifPresent()를 활용할 것

Optional.ifPresent()Optional 객체 안에 값이 있는 경우 실행할 람다를 인자로 받습니다. 값이 있는 경우에 실행되고 값이 없는 경우에는 실행되지 않는 로직에 ifPresent를 활용할 수 있습니다.

좋은 예:

findById(4).ifPresent((user) -> System.out.println(user.getName()));

7. ifPresent-getorElseorElseXXX 등으로 대체할 것

Optional 객체로부터 값의 유무를 확인한 뒤 값을 사용하는 패턴은 앞에서 소개한 다양한 API들로 대체할 수 있습니다.

피해야 하는 예:

Optional<Person> maybePerson = findById(4);
if (maybePerson.isPresent()) {
    Person person = maybePerson.get();
    System.out.println(person.getName());
} else {
    throw new NoPersonFoundException("No person found id maches: " + 4);
}

좋은 예:

Person person = findById(4)
    .orElseThrow(() -> new NoPersonFoundException("No person found id maches: " + 4));
System.out.println(person.getName());

8. Optional을 필드의 타입으로 사용하지 말 것

개요에서 다뤘듯이, Optional반환 타입을 위해 설계된 타입입니다. 뿐만 아니라 Serializable도 아니기 때문에 Optional을 (생성자와 세터를 포함한) 메서드의 인자로 사용하거나 클래스의 필드로 선언하는 것은 Optional의 도입 의도에 반하는 패턴입니다.

나쁜 예:

class Person {
    Optional<String> address;
}

혹은

void printUserName(Optional<Person> maybePerson) {
    // ...
}

좋은 예:

class Person {
    String address = "";
}

혹은

void printUserName(Person person) {
    // ...
}

9. Optional을 빈 컬렉션이나 배열을 반환하는 데 사용하지 말 것

컬렉션이나 배열을 통해 복수의 결과를 반환하는 메서드가 “결과 없음”을 가장 명확하게 나타내는 방법은 무엇일까요?

대부분의 경우 이런 상황에 가장 적합한 방법은 빈(empty) 컬렉션 또는 배열을 반환하는 방법일 것입니다.

이러한 상황에 빈 컬렉션이나 배열 대신 Optional을 사용해서 얻는 이점이 있는지 고민해본다면 Optional을 컬렉션이나 배열에 사용하는 것이 옳은지에 대한 답을 찾을 수 있을 것 같습니다.

나쁜 예:

Optional<List<Person>> findByLastName(String lastName) {
    // ...
}

좋은 예:

List<Person>> findByLastName(String lastName) {
    // ...
}

10. Optional의 컬렉션을 사용하지 말 것

Optional이 “결과 없음”을 나타내는 방법이라는 점에서 이를 컬렉션에 사용하는 것이 얼핏 보면 매력적으로 들릴 수도 있습니다. 특히, Map 타입에서 존재하지 않는 키에 대한 값으로 null을 반환한다는 점을 생각하면 여기에 사용해볼 수 있을 것 같습니다.

하지만 컬렉션에 Optional을 사용하는 경우는 Optional을 사용하지 않으면서 더 좋은 방법으로 개선할 수 있는 경우가 많습니다.

다음과 같이 간단한 단어 수를 세는 코드를 작성해보겠습니다.

class WordCounter {
  private Map<String, Optional<Integer>> wordCounts = new HashMap<>();

  void addCount(String word) {
    wordCounts.put(word, Optional.of(wordCounts.get(word)
      .orElse(0) + 1));
  }

  Optional<Integer> getCount(String word) {
   return wordCounts.get(word);
  }
}

그럴듯한 코드가 나왔습니다. 각 단어(키)에 대한 카운트(값)가 없으면 기본값 0으로 초기화한 뒤 값에 1을 더한 값을 다시 집어 넣는 과정으로 각 단어에 대한 카운트를 저장합니다.

하지만 Map의 타입 인자를 Optional<Integer> 대신 Integer로 바꿔도 충분히 간단한 코드로 작성할 수 있습니다.

class WordCounter {
  private Map<String, Optional<Integer>> wordCounts = new HashMap<>();

  void addCount(String word) {
    wordCounts.putIfAbsent(word, 0);
    wordCounts.computeIfPresent(word, (word, cnt) -> cnt + 1);
  }

  int getCount(String word) {
    return Optional.ofNullable(wordCounts.get(word))
      .orElse(0);
  }
}

addCount 메서드의 내용은 훨씬 명료해졌습니다. 각 단어의 카운트(값)가 존재하지 않으면 0으로 초기화한 뒤 computeIfPresent를 통해 기존 값에 1씩 더한다는 점을 명확히 알 수 있습니다.

getCount 메서드도 Optional을 반환하는 대신 Map.get의 반환 값을 Optional로 만든 뒤 값이 비어있는 경우 기본값인 0을 반환하도록 했습니다.

여기서 기억해야 할 점은, Optional불변 객체라는 점입니다. 즉, 기존 구현에서는 매번 새로운 값을 감싸는 Optional 객체를 addCount 메서드가 호출될 때마다 생성하게 됩니다. 이런 코드는 컬렉션이 굉장히 많은 항목을 담는 상황이 되서야 메모리 문제를 일으키는 원인이 될 수도 있습니다(소설 책 몇 권의 단어를 센다고 생각해보세요!).

정리하면, Optional을 컬렉션의 타입 인자로 사용하는 경우는, 대부분 Optional을 사용하지 않는 더 좋은 방법이 있는 경우가 많기 때문에, Optional이 정말 필요한지 고민해보고 신중히 사용해야겠습니다.

11. 원시 타입의 Optional에는 OptionalInt, OptionalLong, OptionalDouble 사용을 고려할 것

원시 타입(primitive type)을 Optional로 사용해야 할 때는 박싱과 언박싱을 거치면서 오버헤드가 생기게 됩니다.

반드시 Optional의 제네릭 타입에 맞춰야 하는 경우가 아니라면, int, long, double 타입에는 OptionalXXX 타입 사용을 고려하는 것이 좋습니다. 이들은 내부 값을 래퍼 클래스가 아닌 원시 타입으로 갖고, 값의 존재 여부를 나타내는 isPresent 필드를 함께 갖는 구현체들입니다.

때문에 기존의 Optional 타입에 사용할 때와 비교하면 박싱과 언박싱에서 생기는 오버헤드를 줄였다는 점에서 장점이 있습니다.

좋은 예:

OptionalInt maybeInt = OptionalInt.of(2);
OptionalLong maybeLong = OptionalLong.of(3L);
OptionalDouble maybeDouble = OptionalDouble.empty();

12. 내부 값의 비교에는 Optional.equals 사용을 고려할 것

Optional.equals의 구현은 다음과 같습니다.

@Override
public boolean equals(Object obj) {
  if (this == obj) {
      return true;
  }

  if (!(obj instanceof Optional)) {
      return false;
  }

  Optional<?> other = (Optional<?>) obj;
  return Objects.equals(value, other.value);
}

기본적인 참조 확인과, 타입 확인 이후에 두 Optional의 동치성은 내부 값의 equals 구현이 결정합니다. 즉 Optional 객체 maybeAmaybeB의 두 내부 객체 ab에 대해, a.equals(b)true이면 maybeA.equals(maybeB)true이며 그 역도 성립합니다. 굳이 내부 값의 비교만을 위해 값을 꺼내올 필요는 없다는 의미입니다.

나쁜 예:

// returns false if both person object is absent
boolean comparePersonById(long id1, long id2) {
  Optional<Person> maybePersonA = findById(id1);
  Optional<Person> maybePersonB = findById(id2);
  if (!maybePersonA.isPresent() && !maybePersonB.isPresent()) { return false; }
  if (maybePersonA.isPresent() && maybePersonB.isPresent()) {
      return maybePersonA.get().equals(maybePersonB.get());
  }
  return false;
}

좋은 예:

// returns false if both person objects are absent
boolean comparePersonById(long id1, long id2) {
  Optional<Person> maybePersonA = findById(id1);
  Optional<Person> maybePersonB = findById(id2);
  if (!maybePersonA.isPresent() && !maybePersonB.isPresent()) { return false; }
  return findById(id1).equals(findById(id2));
}

13. 변환에 mapflatMap 사용을 고려할 것

Optional에도 mapflatMap 메서드가 있습니다. 이를 활용하면 스트림처럼 함수형 스타일로 코드를 작성할 수 있습니다.

map의 경우 스트림의 map과 동일한 형태로 다른 값으로 변환하는 과정입니다. 물론 매핑은 Optional의 값이 있는 경우에만 거칩니다.

사용자의 주소 문자열로부터 Address 객체를 생성하는 경우를 생각해보겠습니다.

class Address {
    public static Address of(String text) { /* ... */ }
}

Address getUserAddress(long id) {
  findById(id)
      .map(Person::getAddress)
      .map(Address::of)
      .orElseGet(Address::emptyAddress());
}

map 메서드는 매퍼 함수의 반환값을 Optional.ofNullable에 인자로 전달하여 Optional 객체로 만듭니다.

flatMapmap과 비슷하지만 인자로 전달되는 매퍼 함수의 반환 타입이 Optional이어야 한다는 점이 다릅니다. 다음과 같은 경우에 사용할 수 있겠습니다.

class Address {
    // 반환 타입이 Optional
    public static Optional<Address> of(String text) { /* ... */ }
}

Address getUserAddress(long id) {
  findById(id)
      .map(Person::getAddress)
      .flatMap(Address::of)
      .orElseGet(Address::emptyAddress());
}

Address.of의 반환 타입이 Optional일 때 map 메서드를 사용하면 만들어지는 타입은 Optional<Optional<Address>>가 됩니다.

살펴본 것과 같이 매퍼 함수가 Optional 객체를 생성할 책임을 갖는 경우에는 flatMap을, 그렇지 않은 경우에는 map을 활용합니다.

14. 값에 대해 미리 정의된 규칙(제약사항)이 있는 경우에는 filter 사용을 고려할 것

Optional.filter도 스트림처럼 값을 필터링하는 역할을 합니다. 인자로 전달된 predicate이 참인 경우에는 기존의 내부 값을 유지한 Optional이 반환되고, 그렇지 않은 경우에는 비어 있는 Optional을 반환합니다.

유저네임에 대한 몇 가지 제약 사항을 검증하는 기능을 아래 메서드를 활용하여 다음과 같이 구현해볼 수 있습니다.

boolean isIncludeSpace(String str) { /* ... */ } // check if string includes white space

boolean isOverLength(String str) { /* ... */ } // check if length of string is over limit

boolean isDuplicate(String str) { /* ... */ } // check if string is duplicates with already registered

기존 방식:

boolean isValidName(String username) {
  return isIncludeSpace(username) &&
    isOverLength(username) &&
    isDuplicate(username);
}

Optional을 활용한 방식:

boolean isValidName(String username) {
  return Optional.ofNullable(username)
    .filter(this::isIncludeSpace)
    .filter(this::isOverLength)
    .filter(this::isDuplicate)
    .isPresent();
}

여기에는 어느 방법이 맞다고 단정하기 어렵기 때문에 (가독성 등을 고려하여)상황에 따라 최선이라고 생각되는 방법을 찾는 게 중요할 것 같습니다.

목록으로