백엔드 개발을 하면서 Optional이 자주 쓰인다. 기능들에 대해 잘 아는 것도 좋지만, Optional에 대해 어느 정도의 이해도를 가지고 용도에 맞게 사용하는지도 중요하다는 생각이 들어서 정리해보겠다.
Optional이란?
Optional은 Java8 버전부터 도입되었으며, '값이 없는 경우'를 표현하기 위한 용도로 사용되는 클래스이다.
Optional 클래스는 Java 제네릭을 사용하여 만들어져 있으므로, 어떤 타입의 객체라도 값이 없을 수 있는 겨우에 Optional을 사용하여 표현할 수 있다.
그러면, Optional이 왜 만들어졌고, 어떨 때 사용하면 되는지 알아보자.
Optional 사용 목적
API 공식 문서에 보면 Optional을 만든 의도가 다음과 같이 적혀있다.
API Note :
Optional is primarily intended for use as a method return type where there is a clear need to represent "no result," and where using null is likely to cause errors. A variable whose type is Optional should never itself be null; it should always point to an Optional instance.
메소드가 반환할 결과 값이 '없음'을 명백하게 표현할 필요가 있고, null 을 반환하면 에러가 발생할 가능성이 높은 상황에서 메소드의 반환 타입으로 Optional 을 사용하자는 것이 Optional 을 만든 주된 목적이다.
Optional 타입의 변수의 값은 절대 null 이어서는 안 되며, 항상 Optional 인스턴스를 가리켜야 한다.
위와 같이 Optional은 메서드의 리턴 타입으로 '결과 없음'을 명확히 표현하는 용도로 제한적으로 사용하기 위해 제공되는 것이다.
Java8 이전에는 '결과 없음'을 표시하기 위한 용도로 null이 사용되었다. 그러나 객체 값이 null인 경우에, null 체크 없이 부주의하게 사용되는 경우 'NPE(NullPointerException)'이 발생하면서 프로그램이 죽는 등의 상황이 발생했다.
이런 상황을 방지하고 안전한 코딩을 위해 Optional이 만들어지게 되었다.
사용의 이점
Optional은 '리턴 타입'의 용도로 제한적으로 사용하기 위해 만들어졌기 때문에, null 체크의 문제를 없애고 null-safe 한 코드를 제공할 수 있다.
빈 객체를 반환해야 할 경우 null이나 예외를 던지는 형태로 처리해야하지만, Optional을 사용하면 다음과 같은 이점이 있다.
- 1) 빈 결과를 명확히 반환한다.
- 2) null을 반환하는 메서드보다 오류 가능성이 적다.
- 3) null일 때 예외를 던지는 메서드보다 더 사용이 용이한 코드를 만들 수 있다.
그러면 이제 Optional을 사용하는 방법에 대해 알아보자.
Optional 사용법
Optional을 알맞게 사용하기 위한 26가지의 방법을 제공하는 글이 있어서, 일부만 정리해보겠다.
1. Optional 변수에 절대로 Null을 할당하지 마라.
안좋은 예)
public Optional<Member> fetchMember() {
Optional<Member> emptyMember = null;
...
}
좋은 예)
public Optional<Member> fetchMember() {
Optional<Member> emptyMember = Optional.empty();
...
}
Optional을 초기화하려면 null 값 대신 Optional.empty()를 넣어라.
Optional은 단지 컨테이너일 뿐이다.
Optional에 null을 넣게 되면 Optional의 도입 의도와도 맞지 않다.
2. Optional.get()을 호출하기 전에 Optional에 값이 있는지 확인해라.
어떤 이유로든 Optional.get()을 사용하기로 결정했다면, 호출 전에 Optional 값이 존재한다는 것을 증명해야 한다는 것을 잊으면 안된다.
일반적으로 Optional.isPresent() 메서드를 기반한 검사(조건)를 추가하여 이를 수행한다.
안좋은 예)
Optional<Member> member = findById(id) ;
Member myMember = member.get();
좋은 예)
if (member.isPresent()) {
Member myMember = member.get();
...
} else {
...
}
만약 빈 객체에 get() 메서드를 호출한 경우 NoSuchElementException 이 발생하기 때문에 반드시 값이 있는지 확인을 해야한다.
그러나, isPresent()-get() 쌍은 좋지않은 방법이기 때문에 아래에 대안과 함께 다른 로직을 설명하겠다.
3. 값이 없는 경우, Optional.orElse() 메서드를 통해 이미 생성된 기본 값(객체)을 설정/반환한다.
Optional.orElse() 메서드를 사용하는 것은 값을 설정/반환하기 위한 isPresent()-get() 쌍에 대한 대안이다.
안좋은 예)
public static final String USER_STATUS = "UNKNOWN";
...
public String findUserStatus(long id) {
Optional<String> status = findUserStatusById(id) ;
if (status.isPresent()) {
return status.get();
} else {
return USER_STATUS;
}
}
좋은 예)
public static final String USER_STATUS = "UNKNOWN";
...
public String findUserStatus(long id) {
Optional<String> status = findUserStatusById(id);
return status.orElse(USER_STATUS);
}
여기서 중요한 것은 orElse()의 매개변수가 비어 있지 않은 Optional이 있는 경우에도 실행되는 것이다. 즉, 사용되지 않더라도 실행되므로 성능 저하가 발생한다.
(Optional에 값이 있으면 orElse()의 인자로 실행된 값은 무시됨.)
따라서, 매개변수(기본 객체)가 new 연산자 등 새로운 객체 생성이나 새로운 연산을 유발하지 않고, 이미 구성된 경우에만 사용해야 한다.
다른 상황에서는 4번을 참고하자.
4. 값이 없는 경우 Optional.orElseGet() 메서드를 통해 존재하지 않는 기본 객체를 설정/반환
Optional.orElseGet() 메서드를 사용하는 것은 isPresent() - get() 쌍에 대한 또 다른 대안이다.
안좋은 예)
public String computeStatus() {
...
}
public String findUserStatus(long id) {
Optional<String> status = findUserStatusById(id);
if (status.isPresent()) {
return status.get();
} else {
return computeStatus();
}
}
또 다른 안좋은 예)
public String computeStatus() {
...
}
public String findUserStatus(long id) {
Optional<String> status = findUserStatusById(id);
return status.orElse(computeStatus());
}
좋은 예)
public String computeStatus() {
...
}
public String findUserStatus(long id) {
Optional<String> status = findUserStatusById(id);
return status.orElseGet(this::computeStatus);
}
여기서 중요한 것은 orElseGet()의 매개변수가 Supplier라는 것이다. 즉, 인수로 전달된 Supplier 메서드는 Optional 값이 없는 경우에만 실행된다.
따라서, Optional에 값이 없을 때만 새 객체를 생성하거나 새 연산을 수행하므로 불필요한 오버헤드가 없으므로, orElse() 성능 페널티를 피하는데 유용하다.
5. 값이 없는 경우, orElseThrow()를 통해 NoSuchElementException 예외 발생
Optional.orElseThrow() 메서드를 사용하는 것은 isPresent()-get() 쌍에 대한 또 다른 대안이다.
때로는 Optional 값이 없는 경우 NoSuchElementException 예외를 발생시키기만 하면 된다.
안좋은 예)
public String findUserStatus(long id) {
Optional<String> status = findUserStatusById(id);
if (status.isPresent()) {
return status.get();
} else {
throw new NoSuchElementException();
}
}
좋은 예)
public String findUserStatus(long id) {
Optional<String> status = findUserStatusById(id);
return status.orElseThrow();
}
더 좋은 예)
public String findUserStatus(long id) {
Optional<String> status = findUserStatusById(id);
return status.orElseThrow(NoSuchElementException::new);
}
위와 같이 예외를 명시적으로 표기하는 것이 더 바람직하다.
Java 10 이후로는 예외가 NoSuchElementException인 경우 인수 없이 orElseThrow() 메서드만 적어줘도 되지만, Java8, 9는 명시적으로 작성해줘야 한다.
orElseThrow 메서드만 적는 것보다는 명시적으로 작성하는 것이 가독성과 유지보수 측면에서 좋을 것이라 생각된다.
6. Optional이 있으면 사용하고, 없으면 아무것도 하지 마라. 이것은 Optional.ifPresent()의 작업이다.
Optional.ifPresent()는 값을 사용하기만 하면 되는 isPresent()-get() 쌍에 좋은 대안이다.
값이 없으면 아무것도 동작하지 않는다.
안좋은 예)
Optional<String> status = findUserStatusById(id);
...
if (status.isPresent()) {
System.out.println("Status: " + status.get());
}
좋은 예)
Optional<String> status = findUserStatusById(id);
...
status.ifPresent(System.out::println);
7. Optional이 있으면 사용하고, 없으면 빈 기반 작업을 실행해라. 이것은 Optional.ifPresentOrElse()의 작업이다.
Optional이 비어 있을 때 Optional을 반환하는 다른 작업을 실행하고 싶다. orElse(), orElseGet() 메서드는 둘다 래핑되지 않은 값을 반환하기 때문에 이를 수행할 수 없다.
이 메서드는 값을 설명하는 Optional을 반환할 수 있다. 그렇지 않으면 제공 함수에서 생성된 Optional을 반환한다.
안좋은 예)
public Optional<String> fetchStatus() {
Optional<String> status = findUserStatusById(id) ;
Optional<String> defaultStatus = Optional.of("PENDING");
if (status.isPresent()) {
return status;
} else {
return defaultStatus;
}
}
또다른 안좋은 예)
public Optional<String> fetchStatus() {
Optional<String> status = findUserStatusById(id) ;
return status.orElseGet(() -> Optional.<String>of("PENDING"));
}
좋은 예)
public Optional<String> fetchStatus() {
Optional<String> status = findUserStatusById(id) ;
Optional<String> defaultStatus = Optional.of("PENDING");
return status.or(() -> defaultStatus);
// or, without defining "defaultStatus"
return status.or(() -> Optional.of("PENDING"));
}
8. Optional.orElse/orElseXXX는 람다에서 isPresent()-get() 쌍을 완벽하게 대체한다.
람다에 특정한 일부 연산은 Optional(e.g., findFirst(), findAny(), reduce(), ...)을 반환한다. isPresent()-get() 쌍을 통해 이 Optional을 사용하려면 if문으로 코드를 오염시킬수 있다. 이러한 경우 orElse()와 orElseXXX()는 매우 편리하다.
예제1
안좋은 예)
List<Product> products = ... ;
Optional<Product> product = products.stream()
.filter(p -> p.getPrice() < price)
.findFirst();
if (product.isPresent()) {
return product.get().getName();
} else {
return "NOT FOUND";
}
또다른 안좋은 예)
List<Product> products = ... ;
Optional<Product> product = products.stream()
.filter(p -> p.getPrice() < price)
.findFirst();
return product.map(Product::getName)
.orElse("NOT FOUND");
좋은 예)
List<Product> products = ... ;
return products.stream()
.filter(p -> p.getPrice() < price)
.findFirst()
.map(Product::getName)
.orElse("NOT FOUND");
예제2
안좋은 예)
Optional<Member> member = ... ;
Product product = ... ;
...
if(!member.isPresent() ||
!member.get().getItems().contains(product)) {
throw new NoSuchElementException();
}
좋은 예)
Optional<Member> member = ... ;
Product product = ... ;
...
member.filter(m -> m.getItems().contains(product)).orElseThrow();
9. 값을 얻는 단일 목적에 Optional 사용은 바람직하지 않다.
때때로 우리는 사물을 '과도하게 사용'하는 경향이 있다. 즉, Optional과 같은 사물이 있고, 모든 곳에서 사용하려고 한다.
단일 목적을 위해 Optional을 사용하는 이러한 관행은 피하고, 간단하고 직관적인 코드에 의존해라.
안좋은 예)
public String fetchStatus() {
String status = ... ;
return Optional.ofNullable(status).orElse("PENDING");
}
좋은 예)
public String fetchStatus() {
String status = ... ;
return status == null ? "PENDING" : status;
}
10. Optional 유형의 필드, 메서드 또는 생성자 인수에서 선언하지 마라.
Optional을 필드 또는 메서드(세터 포함) 인수에서 사용하지 마라. 이는 Optional의 의도에 반하는 또 다른 사용법이다.
안좋은 예)
public class Customer {
private final String name;
private final Optional<String> postcode;
public Customer(String name, Optional<String> postcode) {
this.name = Objects.requireNonNull(name, () -> "Name cannot be null");
this.postcode = postcode;
}
public Optional<String> getPostcode() {
return postcode;
}
...
}
좋은 예)
public class Customer {
private final String name;
private final String postcode;
public Customer(String name, String postcode) {
this.name = Objects.requireNonNull(name, () -> "Name cannot be null");
this.postcode = postcode;
}
public Optional<String> getPostcode() {
return Optional.ofNullable(postcode);
}
...
}
보다시피 이 예제의 getter는 Optional을 반환한다. 하지만 모든 getter를 이와 같이 변환하기 위한 규칙으로 Optional을 사용하지 마라. 대부분의 경우 getter는 컬렉션이나 배열을 반환하며, 이 경우 Optional 대신 빈 컬렉션/배열을 반환하는 것을 선호한다. Brian Goetz가 한 말을 명심하자.
I think routinely using it as a return value for getters would definitely be over-use.
(나는 Optional을 getter에 대한 반환 값으로 일상적으로 사용하는 것은 확실히 남용이라고 생각한다.)
11. 빈 컬렉션이나 배열을 반환하기 위해 Optional을 사용하지 마라.
컬렉션이나 배열로 복수의 결과를 반환하는 메서드가 "결과 없음"을 가장 명확하게 나타내는 방법은 대부분의 경우 빈 컬렉션 또는 배열을 반환하는 방법이다.
이러한 상황에서 빈 컬렉션이나 배열 대신 Optional 을 사용해서 얻는 이점이 있는지 고민해본다면 Optional을 컬렉션이나 배열에 사용하는 것이 옳은지에 대한 답을 찾을 수 있을 것이다.
안좋은 예)
public Optional<List<String>> fetchMemberItems(long id) {
Member member = ... ;
List<String> items = member.getItems();
return Optional.ofNullable(items);
}
좋은 예)
public List<String> fetchMemberItems(long id) {
Member member = ... ;
List<String> items = member.getItems();
return items == null ? Collections.emptyList() : items;
}
마찬가지 이유로 Spring Data JPA Repository 메서드 선언시 다음과 같이 컬렉션을 Optional로 감싸서 반환하는 것은 좋지 않다.
컬렉션을 반환하는 Spring Data JPA Repository 메서드는 null을 반환하지 않고 비어있는 컬렉션을 반환해주므로 Optional로 감싸서 반환할 필요가 없다.
12. 컬렉션에서 원소로 Optional 사용을 피해라.
안좋은 예)
Map<String, Optional<String>> items = new HashMap<>();
items.put("I1", Optional.ofNullable(...));
items.put("I2", Optional.ofNullable(...));
...
Optional<String> item = items.get("I1");
if (item == null) {
System.out.println("This key cannot be found");
} else {
String unwrappedItem = item.orElse("NOT FOUND");
System.out.println("Key found, Item: " + unwrappedItem);
}
좋은 예)
Map<String, String> items = new HashMap<>();
items.put("I1", "Shoes");
items.put("I2", null);
...
// get an item
String item = get(items, "I1"); // Shoes
String item = get(items, "I2"); // null
String item = get(items, "I3"); // NOT FOUND
private static String get(Map<String, String> map, String key) {
return map.getOrDefault(key, "NOT FOUND");
}
컬렉션에 Optional 을 원소로 사용하지 말고 원소를 꺼낼 때나 사용할 때 null 체크 하는 것이 좋다.
특히 Map 은 getOrDefault() , putIfAbsent() , computeIfAbsent() , computeIfPresent() 처럼 null 체크가 포함된 메소드를 제공하므로, Map 의 원소로 Optional 을 사용하지 말고 Map 이 제공하는 메소드를 활용하는 것이 좋다.
13. Optional.of()와 Optional.ofNullable()을 혼동하지 마라.
of() 메서드는 null 이 아님이 확실할 때만 사용해야 하며, null 이면 NPE(NullPointerException)이 발생 한다.
ofNullable() 메서드는 null 일 가능성이 있을 때 사용해야 하며, null이 아님이 확실하면 of()를 사용해야 한다.
안좋은 예)
public Optional<String> fetchItemName(long id) {
String itemName = ... ; // itemName이 null이 된다면 NPE 발생
...
return Optional.of(itemName);
}
좋은 예)
public Optional<String> fetchItemName(long id) {
String itemName = ... ;
...
return Optional.ofNullable(itemName);
}
14. 원시 타입의 Optional 에는 OptionalInt , OptionalLong , OptionalDouble 사용을 고려할 것
원시 타입(primitive type)을 Optional 로 사용하면 Boxing 과 UnBoxing 을 거치면서 오버헤드가 생기게 된다.
반드시 Optional 의 제네릭 타입에 맞춰야 하는 경우가 아니라면 int , long , double 타입에는 OptionalXXX 타입 사용을 고려하는 것이 좋다. 이들은 내부 값을 래퍼 클래스가 아닌 원시 타입으로 갖고, 값의 존재 여부를 나타내는 isPresent 필드를 함께 갖는 구현체들이다.
안좋은 예)
Optional<Integer> cnt = Optional.of(10); // boxing 발생
for(int i = 0; i < cnt.get(); i++) { ... } // unboxing 발생
좋은 예)
OptionalInt cnt = OptionalInt.of(10); // boxing 발생 안 함
for(int i = 0; i < cnt.getAsInt(); i++) { ... } // unboxing 발생 안 함
15. Optional의 값을 비교하기 위해 언래핑할 필요가 없다.
Optional.equals 의 구현은 다음과 같다.
/**
* Indicates whether some other object is "equal to" this {@code Optional}.
* The other object is considered equal if:
* <ul>
* <li>it is also an {@code Optional} and;
* <li>both instances have no value present or;
* <li>the present values are "equal to" each other via {@code equals()}.
* </ul>
*
* @param obj an object to be tested for equality
* @return {@code true} if the other object is "equal to" this object
* otherwise {@code false}
*/
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
return obj instanceof Optional<?> other
&& Objects.equals(value, other.value);
}
anassertEquals()에 두 개의 Optional이 있는 경우 언래핑된 값이 필요하지 않다.
이는 Optional#equals()가 Optional 객체가 아닌 래핑된 값을 비교하기 때문이다.
안좋은 예)
Optional<String> actualItem = Optional.of("Shoes");
Optional<String> expectedItem = Optional.of("Shoes");
assertEquals(expectedItem.get(), actualItem.get());
좋은 예)
Optional<String> actualItem = Optional.of("Shoes");
Optional<String> expectedItem = Optional.of("Shoes");
assertEquals(expectedItem, actualItem);
16. Optional의 Stream API를 사용하자.
Java 9부터 Optional.stream() 메서드를 적용하여 Optional 인스턴스를 Stream으로 처리할 수 있다.
이는 Optional API를 Stream API와 연결해야 할 때 유용하다.
이 메서드는 한 요소의 Stream 또는 빈 Stream(Optional이 없는 경우)을 만듭니다.
또한 Stream API에서 사용할 수 있는 모든 메서드를 사용할 수 있습니다.
안좋은 예)
public List<Product> getProductList(List<String> productId) {
return productId.stream()
.map(this::fetchProductById)
.filter(Optional::isPresent)
.map(Optional::get)
.collect(toList());
}
public Optional<Product> fetchProductById(String id) {
return Optional.ofNullable(...);
}
좋은 예)
public List<Product> getProductList(List<String> productId) {
return productId.stream()
.map(this::fetchProductById)
.flatMap(Optional::stream)
.collect(toList());
}
public Optional<Product> fetchProductById(String id) {
return Optional.ofNullable(...);
}
실제로 Optional.stream()을 사용하면 filter()와 map()을 flatMap()으로 바꿀 수 있다.
또한 Optional을 List로 변환할 수 있다.
public static <T> List<T> convertOptionalToList(Optional<T> optional) {
return optional.stream().collect(toList());
}
'학습 > Java' 카테고리의 다른 글
Stack & Queue (0) | 2023.02.28 |
---|---|
Array & List (0) | 2023.02.22 |
자바 개발환경 구축 (JDK 17 설치) Window_2023.02.15 업데이트 (0) | 2021.05.31 |