Optional란?

안녕하세요. Kernel 360 백엔드 2기 크루 송해덕입니다.

우리가 Spring JPA를 사용할 때 findByID를 하게 되면 반환값으로 항상 Optinal을 Return하게 됩니다.

그런데, 이런 Optional을 여러분은 올바르게 사용하고 계신가요?
오늘은 우리가 자주 사용하지만 올바르게 사용하지는 못하는 Optional<T>에 대해 알아보겠습니다.

어떤 기능을 알아보기 위해 가장 좋은 방법은 Documentation을 찾아보는 것입니다.

Optional은 JDK 8에서 람다식, Stream과 함께 등장하였습니다.
Oracle에서 제공하는 JDK DOCS를 보면 Optioanl을 이렇게 표현하고 있습니다.

Documentation에는 이와 같이 명시되어 있지만 해당 정의만 본다면 약간 이해하기 어려울 수 있습니다.

이해하기 쉽도록 하나의 간단한 예시를 보겠습니다.

저는 stationRepository라는 곳에서 findById를 통해 지하철역을 검색하려고 합니다.
그렇기에 반환 지하철역 entity로 받으려고 했지만 에러가 발생하게 됩니다.

왜그럴까요? 에러를 확인하기 위해 빨간줄에 마우스를 올려보았습니다.
화면을 보면 findById를 사용할 경우 반환값으로 Optional을 요구합니다.

이를 확인하기 위해 Optional class를 확인해봅시다.
클래스 내부에서 findById 메서드를 확인해보니 제네릭 T를 감싼 Optional 보이네요.

그렇다면 이를 확인했으니 아래와 같이 반환 값을 Optional로 감싸면 에러가 발생하지 않게 됩니다.
그 아랫줄 처럼 get() 메서드를 사용하여 entity를 꺼낼 수 있겠네요.

Optional을 사용하는 이유?

자, 그럼 우리는 이런 Optional을 왜 사용하는지 생각해봐야 합니다.
어차피 get() 같은 메서드로 Optional 내부에 있는 객체를 꺼내 쓸꺼면 그냥 Optional로 감싸지 않고 바로 쓰면 되는걸 이런 귀찮은 작업을 대체 왜 하는걸까요?

바로 이 보기도 싫은 NullPointerException을 방지하기 위함입니다.

NullPointerException은 무엇인가?

자 그럼 또 다시 NullPointerException은 무엇일까요?
우리가 개발을 처음 시작하면서부터 지금까지 질리도록 본 이 NullPointerException이 대체 왜 발생하게 되는걸까요?

이를 설명하기 위해 우리는 JVM의 메모리 동작을 간단하게 살펴보겠습니다.
처음 JVM의 메모리를 알아보면 일반적으로 method영역, stack영역, heap영역 이렇게 3가지의 영역을 두고 이야기를 많이 합니다.우리는 이 중 stack영역heap영역을 두고 이야기를 하겠습니다.

text라고 명명한 String에 “Hello, World!”라는 문자열을 담아 선언한다 가정해보죠.
이렇게 String 객체를 선언할 경우 메모리 영역에서는 어떻게 동작하게 될까요?

화면처럼 stack 영역에는 String text가 heap 영역에는 “Hello, World”가 저장되게 됩니다.
(사실 실제로는 String의 경우 컴파일 시 문자열 상수 풀에 저장되지만 설명을 위해 이렇게 표현했습니다.)

stack 영역에는 지역변수나 매개변수가, heap 영역에는 인스턴스 객체가 저장되기에 당연한 것이겠죠
이 상황에서 String text는 주소를 가지고 있으며 Heap 영역의 “Hello, World” 를 가르키게 되죠

자, 그럼 이런 상황(null 참조 객체)이라면 어떻게 될까요?

위와 동일하게 String empty는 stack 영역에 저장됩니다.
하지만 반대로 heap영역에는 아무것도 저장되지 않습니다.

왜그럴까요? 바로 empty가 참조하는 null이 그 이유입니다.
null은 참조 변수가 어떤 객체도 가리키지 않음을 의미합니다.
그렇기에 메모리에 저장될 것도 없는 것이죠.

이 상황에서 empty를 호출해서 길이를 알아보려 한다면 사진과 같이 NullpointerException이 발생하게 됩니다.

이와 같이 NullpointerException은 아무것도 참조하지 않는 Null 객체 및 변수를 호출할 때 발생되는 예외입니다.

Optional을 올바르게 사용하는 방법

그럼 다시 Optional로 돌아와서 여러분들이 이미 Optional을 사용하고 계신다면 어떻게 사용하고 계시나요?

혹시 내가 작성한 Optional 관련 코드가 잘못되었나 의심해본적은 없으신가요?

Optional을 올바르게 사용하기 위해서는 어떻게 해야할까요?
바로 개발자의 의도를 파악하는게 중요하겠죠. 이 기능을 만든 개발자가 어떤 생각으로 어떤 뜻을 가지고 만들었는가가 가장 중요합니다.

Oracle에서 Java Architect로 일하고 계신 Brian Goetz는 Optional을 주도해서 개발한 개발자입니다.

Brian Goetz는 Stack Overflow에서 Optional에 대한 질문에 이러한 답변을 남긴적이 있습니다.

Our intention was to provide a limited mechanism for library method return types where there needed to be a clear way to represent “no result”, and using null for such was overwhelmingly likely to cause errors.

또한, Oracle JDK 9 이후에는 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.

결국 이를 종합해보면 Optional은 제한적으로 사용해야 한다는 이야기가 됩니다.
모든 메서드가 그렇겠지만 Optional 사용시에는 제약이 붙는다고 봐야 하겠죠.

이러한 Optional의 특성때문에 한 커뮤니티에는 Optional의 안티패턴 26가지를 설명하는 글이 있습니다.

우리는 그 중 몇가지만을 확인해보도록 하겠습니다.


(1) Optional을 필드로 사용하지 말아라

// 안 좋음
class Person {
  private String name;
  private Integer age;
  private Optional<String> car;
}
 
// 좋음
class Person {
  private String name;
  private Integer age;
  private String car;
}

예시 코드를 보면 필드에 Optional이 사용된 필드가 보입니다.

사람이 차를 가지고 있을수도 있고 아닐 수도 있으니 이렇게 사용하는 것이 Optional을 잘 사용하는 것 처럼 보이지만 이는 잘못 된 사용 방법입니다.

앞서 설명드린 것 처럼 Optional은 반환 타입을 위해 설계된 타입입니다.

그런데 이처럼 필드로 선언하게 된다면 Optional은 serializable 인터페이스를 구현하지 않기에 직렬화 문제를 가지게 됩니다.


(2) 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();
  }
}

코드를 보면 Person 객체를 Optional로 감싸서 반환하는 메서드가 있습니다.

그리고 결과 값이 0일 경우 null을 return하게 됩니다.

하지만 이는 Optional의 도입 의도와 맞지 않습니다.

Optional은 null을 안전하고 일관성있게 사용하기 위함인데 Optional이 Optional 객체가 아닌 null을 참조한다는 것은 Optional을 사용함에 있어 아무런 의미가 없게 됩니다.

그렇기에 이런 경우 null이 아닌 Optional.empty()를 사용하는 것이 올바른 방법입니다.


(3) Optional 대신 빈 객체를 사용해라

// 안 좋음
public interface PersonRepository extends JpaRepository<Person, Long> {
  Optional<List<Person>> findAllByNameContaining(String keyword);
}
 
// 좋음
public interface PersonRepository extends JpaRepository<Person, Long> {
  List<Person> findAllByNameContaining(String keyword);
  // null이 반환되지 않으므로 Optional 불필요
}

사실 빈 컬렉션이나 빈 배열을 반환하는 메서드가 “값이 없음”을 보여주기 위해서 가장 옳은 방법은 그냥 빈 객체 그 자체를 넘겨주는 것입니다.

이미 빈 객체를 할당받은 변수의 경우 null을 참조하고 있는것이 아니니 Optional의 등장 배경과는 조금 동떨어진 것이 되겠죠?


(4) Optional.get() 전에는 값을 가지고 있는지 확인해야한다

// 안 좋음
Optional<Person> optionalPerson = personRepository.findById(regNo);

String name = optionalPerson.get().getName();
 
// 좋음(?)
Optional<Person> optionalPerson = personRepository.findById(regNo);

if (optionalPerson.isPresent()) {
  return optionalPerson.get().getName();
}

return ""
 

Optional.get()을 하기 전에는 Optional 내부에 값이 있는지 없는지 확인이 필요합니다.

그렇지 않고 위와 같이 바로 .get()을 하게 될 경우 NoSuchElementException이 발생하게 됩니다.
NullPointerException을 피하겠다고 Optional을 사용했는데 또 다른 에러를 마주하게 되는 것이죠

그렇기에 Optional.get()을 사용하기 위해서는 이전에 isPresent()와 같은 확인 과정이 필요합니다.

하지만, 이 방법이 좋다…? 라고는 이야기 할 수 없습니다.
그 이유는 뒤에 이어지는 내용에서 설명해드리겠습니다.

(5) isPresent() - get()orElse()orElseXXX으로 처리해라

Optional<Person> optionalPerson = personRepository.findById(regNo);

if (optionalPerson.isPresent()) {
  Person person = optionalPerson.get();
} else {
  throw new PersonNotFoundException(Person Not Found");
}

화면에 있는 코드처럼 isPresent()를 통해 Optional 내부를 확인하고 값이 없는 경우 Exception을 발생시킨다고 가정해봅시다.

겉으로 보기에는 코드의 내용이 명확하고 깔끔해 보입니다.

만약, 이 코드에서 else 문을 사용하지 않고 리팩토링을 해야한다면 어떻게 하실건가요?

if문의 조건에서 맨 앞에 !를 붙이고 해당하는 경우에 exception을 throw하게 하고 optionalPerson.get()메서드 부분을 밖으로 빼실 건가요?

// 안 좋음
Optional<Person> optionalPerson = personRepository.findById(regNo);

if (optionalPerson.isPresent()) {
  Person person = optionalPerson.get();
} else {
  throw new PersonNotFoundException("Person Not Found");
}

// 좋음
Optional<Person> optionalPerson = personRepository.findById(regNo);

Person person = personRepository.findById(regNo)
    .orElseThrow(() -> new PersonNotFoundException(Person Not Found"));
}

Optional은 이런 복잡한 방법이 아닌 간단한 방법을 제공하고 있습니다.
바로 orElseThrow() 메서드입니다.

위의 코드는 아래와 같이 orElseThrow()를 통해 간단하게 변경할 수 있습니다.

이런 예외 처리가 아니라 비어 있는 경우 다른 액션을 취하고 싶다면 orElse()orElseGet() 같은 메서드들이 제공되고 있죠

그렇기에 isPresent() - get()을 사용하기보다는 아래와 같은 orElse 계열의 메서드를 사용하는 것이 좋습니다.

물론, 매번 이 방법이 좋다고만은 할 수 없지만 Optional에서 지향하고 있는 방법이라는 것만 알아두시면 좋을 것 같습니다.

번외 (orElse()와 orElseGet()의 차이)

여러분은 orElse()orElseGet()의 차이가 있다는 것을 알고 계시나요?

Optional 클래스에서 orElse()와 orElseGet()을 살펴보겠습니다.

살펴보니 orElseGet()에서 매개변수로 Supplier 객체가 보이는 차이점이 있고 메서드 내부에는 삼항연산자를 동일하게 사용하는 것 처럼 보이네요.
별반 차이가 없어보입니다.

이떤 차이가 있는지 잘 모르시겠다면 다음 예시를 보시면 이해하실 수 있을 겁니다.

// orElse()를 사용
@Test
@DisplayName("orElse 테스트")
void orElseTestMethod() {
  String str = Optional.of("not empty")
    .orElse(emptyReturn());

  System.out.println(str);
}

// orElseGet()을 사용
@Test
@DisplayName("orElse 테스트")
void orElseTestMethod() {
  String str = Optional.of("not empty")
    .orElseGet(() -> emptyReturn());

  System.out.println(str);
}

저는 테스트 코드를 두개 작성해봤습니다.
두개의 차이는 orElse()를 사용하냐 orElseGet()을 사용하냐의 차이밖에 없습니다.
두 코드 모두 Optional 객체 내부가 null이면 emptyReturn이라는 메서드를 호출하게 됩니다.

하지만, 저는 Optional.of()를 통해 “not empty”라는 string 문자열을 넣어줬으니 두 코드는 모두 emptyReturn() 메서드를 실행하지 않고 “not empty”만 콘솔에 출력하게 되겠네요.

과연 두 코드 모두 결과가 동일할까요?

// orElse()를 사용한 결과
empty object
not empty

// orElseGet()을 사용한 결과
not empty

결과를 확인해보면 두 코드는 다른 결과를 보여주고 있습니다.

orElse()를 사용한 경우 Optional 내부가 null이 아님에도 emptyReturn 메서드가 실행되고 있는 모습이네요.

왜그럴까요?

다시 위에 있는 Optional 클래스로 돌아가서 두 메서드를 확인해봅시다.

아까 전에 말씀드린 매개변수쪽을 다시 봐보면 orElse()의 경우 메서드가 아닌 값을 인수로 받고 있습니다.

하지만 이와 다르게 orElseGet()의 경우 Supplier라는 함수형 인터페이스를 인수로 받고 있네요.

따라서 orElse()의 경우 메서드 인수를 할당하기 위해 그 안에 있는 메서드를 실행하게 됩니다.
반면에 orElseGet()의 경우 Optional의 값이 null일 때만 supplier.get()이 실행되게 됩니다.


다시 앞의 코드로 돌아가보죠.

// orElse()를 사용
@Test
@DisplayName("orElse 테스트")
void orElseTestMethod() {
  String str = Optional.of("not empty")
    .orElse(emptyReturn());

  System.out.println(str);
}

// orElseGet()을 사용
@Test
@DisplayName("orElse 테스트")
void orElseTestMethod() {
  String str = Optional.of("not empty")
    .orElseGet(() -> emptyReturn());

  System.out.println(str);
}

orElse()인수를 할당받기 위해 Optional의 값이 null이던 null이 아니던 emptyReturn()이 호출되게 됩니다.
그렇기에 콘솔에 empty object와 not empty가 출력이 되는 것이죠.

반대로 orElseGet()의 경우 Optional이 null이 아니기 때문에 emptyReturn이 호출되지 않는 것이죠.

결론

지금은 Optional을 올바르게 사용하는 방법들 중 일부만을 설명해드렸지만 이외에도 여러가지 다양한 내용들이 있습니다.

Optional에 대한 올바른 사용방법이 궁금하신 분들은 26가지 안티패턴을 보시면 도움이 될 것 같습니다.

이번 게시글에 작성된 내용처럼 우리가 어떠한 기능을 사용할 때 올바른 방법을 알고 사용하는것은 정말 중요하다고 생각됩니다.

그렇기에 좋다는 이야기만 듣고 혹은 눈에 보인다고 아무렇게나 사용하는 것이 아닌, 어떤 것인지를 정확하게 알아보고 올바른 사용법을 파악하는 것은 개발자에게 정말 중요한 소양이라고 생각하게 되었습니다.

이상으로 마치며 긴 포스트를 읽어주셔서 감사합니다.