Optional을 올바르게 사용하기 위해 공부한 내용을 정리합니다.
getter에 Optional
을 사용하는 것이 좋은지에 대해, Java 언어 아키텍트인 Brian Goetz가 Stackoverflow에 작성한 답변(링크)입니다.
당연히 사람들은 맘대로 할 겁니다. 하지만 우리는 명확한 의도를 가지고 이 기능(
Optional
)을 추가했고, 그건 많은 사람들이 바랬던 일반적인 목적의 maybe 타입은 아니었습니다. 우리 의도는 “결과 없음”을 명확히 표현할 방법이 필요한 라이브러리 메서드 반환 타입에 한정된 메커니즘을 제공하는 것이었으며, 이러한 경우에null
의 사용이 오류를 발생시킬 가능성이 압도적으로 높았기 때문입니다.예를 들어, 결과의 배열이나 리스트를 반환하는 곳에는 사용하지 않는 것이 좋을 지도 모릅니다. 대신 빈 배열이나 리스트를 반환하세요. 필드나 메서드 파라미터로는 거의 사용해서는 안됩니다.
일상적으로 이를 게터(getter)의 반환 값으로 사용하는 것은 분명히 과용(over-use)이라고 생각합니다.
피해야 하는
Optional
에는 아무 잘못이 없습니다. 그저 많은 사람들이 원했던 것이 아닐 뿐이며, 우리는 과용했을 때의 위험성에 대해서도 염려했습니다.
Java 8에 들어서 스트림과 람다 등 함수형 프로그래밍을 위한 기능들이 추가됐습니다. 이러한 기능들을 이해하는 것도 좋은 코드를 만드는 데에 필요하지만, 해당 기능의 의도를 이해하고 올바르게 사용하는 것도 중요하다는 생각이 들었습니다.
최근에 코드에 Optioanl
타입을 조금씩 적용해보고 있는데, 이러한 이해 없이 사용하려다보니 어느 부분에 사용하는 것이 적절하거나 그렇지 않은지를 명확히 구분하기 어렵다고 생각하던 중에 위와 같은 글을 찾게 됐습니다.
더불어서 Optional을 올바르게 사용하기 위한 정보도 제공하는 글이 있어 일부를 번역해서 남겨봅니다.
Note: 원문에서는
Optional
에서 제공하는 다양한 API들을 소개하고 있는데, 이번 글에서는 JDK 8에서 사용 가능한 항목들을 다룹니다.
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()
를 반환하면 값을 반환받은 쪽에서는 이후에 소개할 메서드들을 통해 적절하게 처리를 이어갈 수 있습니다.
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
을 이해하고 있다면 가독성 면에서도 더 낫기 때문에 꼭 필요한 경우가 아니라면 피하는 것이 좋습니다.
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번 항목을 참조하세요.
Optional.orElseGet()
을 통해 이를 나타내는 객체를 제공할 것3번 항목의 경우 값이 없는 경우에 문자열 처럼 동일한 객체 참조를 반환해도 괜찮은 경우에 적합합니다. 하지만 불변 객체가 아닌 경우 이 방법은 위험할 수 있습니다. 값이 없는 경우에 매번 새로운 객체를 반환해야 하는 경우에는 Optional.orElseGet()
을 사용할 수 있습니다. orElse
가 기본값으로 반환할 값을 인자로 받는것과 달리, orElseGet()
은 값이 없는 경우 이를 대신해 반환할 값을 생성하는 람다를 인자로 받습니다.
좋은 예:
findById(4).orElseGet(() -> new Person("UNKNOWN")); // construct with named 'UNKNOWN'
3번 항목과 비교할 때의 장점은, Optional
의 값이 없는 경우에만 인자로 전달된 코드가 실행된다는 점입니다. 이 방법으로 매번 새로운 객체를 생성하는 경우에는 실제로 값이 없는 경우에만 객체를 생성한다는 의미입니다. Map.computeIfXXX
와 유사하다고 볼 수도 있겠습니다.
Optional.orElseThrow()
를 통해 명시적으로 예외를 던질 것값이 없는 경우, 기본값을 반환하는 대신 예외를 던져야 하는 경우도 있습니다. 이 경우에는 Optional.orElseThrow()
를 사용할 수 있습니다.
findById(4).orElseThrow(() -> new NoSuchElementException("Person not found"));
Optional.ifPresent()
를 활용할 것Optional.ifPresent()
는 Optional
객체 안에 값이 있는 경우 실행할 람다를 인자로 받습니다. 값이 있는 경우에 실행되고 값이 없는 경우에는 실행되지 않는 로직에 ifPresent
를 활용할 수 있습니다.
좋은 예:
findById(4).ifPresent((user) -> System.out.println(user.getName()));
ifPresent
-get
은 orElse
나 orElseXXX
등으로 대체할 것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());
Optional
을 필드의 타입으로 사용하지 말 것개요에서 다뤘듯이, Optional
은 반환 타입을 위해 설계된 타입입니다. 뿐만 아니라 Serializable
도 아니기 때문에 Optional
을 (생성자와 세터를 포함한) 메서드의 인자로 사용하거나 클래스의 필드로 선언하는 것은 Optional
의 도입 의도에 반하는 패턴입니다.
나쁜 예:
class Person {
Optional<String> address;
}
혹은
void printUserName(Optional<Person> maybePerson) {
// ...
}
좋은 예:
class Person {
String address = "";
}
혹은
void printUserName(Person person) {
// ...
}
Optional
을 빈 컬렉션이나 배열을 반환하는 데 사용하지 말 것컬렉션이나 배열을 통해 복수의 결과를 반환하는 메서드가 “결과 없음”을 가장 명확하게 나타내는 방법은 무엇일까요?
대부분의 경우 이런 상황에 가장 적합한 방법은 빈(empty) 컬렉션 또는 배열을 반환하는 방법일 것입니다.
이러한 상황에 빈 컬렉션이나 배열 대신 Optional
을 사용해서 얻는 이점이 있는지 고민해본다면 Optional
을 컬렉션이나 배열에 사용하는 것이 옳은지에 대한 답을 찾을 수 있을 것 같습니다.
나쁜 예:
Optional<List<Person>> findByLastName(String lastName) {
// ...
}
좋은 예:
List<Person>> findByLastName(String lastName) {
// ...
}
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
이 정말 필요한지 고민해보고 신중히 사용해야겠습니다.
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();
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
객체 maybeA
와 maybeB
의 두 내부 객체 a
와 b
에 대해, 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));
}
map
과 flatMap
사용을 고려할 것Optional
에도 map
과 flatMap
메서드가 있습니다. 이를 활용하면 스트림처럼 함수형 스타일로 코드를 작성할 수 있습니다.
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
객체로 만듭니다.
flatMap
은 map
과 비슷하지만 인자로 전달되는 매퍼 함수의 반환 타입이 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
을 활용합니다.
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();
}
여기에는 어느 방법이 맞다고 단정하기 어렵기 때문에 (가독성 등을 고려하여)상황에 따라 최선이라고 생각되는 방법을 찾는 게 중요할 것 같습니다.