본문 바로가기
Java

실무에서 반환 값이 null일 가능성이 있을 때 Optional로 안전하게 반환하는 방법

by blackjack_96 2024. 4. 3.

공부하게 된 배경

 

 웹 애플리케이션(Web Application)에서는 여러 역할을 맡는 기능이 필요하며, 이 기능들을 메서드 형태로 정의한 후 각각 관련이 있는 기능끼리 하나의 클래스(계층)로 묶어서 관리를 합니다. 하나의 웹 애플리케이션은 개념적으로 다양한 계층으로 나눠질 수 있겠지만, 주로 [프레젠테이션 계층 -  서비스 계층 - 리포지토리 계층]으로 구성이 되는데, 여기서 리포지토리(Repository) 계층은 서비스(Service) 계층으로부터 어떠한 요청을 받아, 데이터베이스와 직접적으로 커뮤니케이션을 하는 여러 기능들을 정의하는 곳입니다.

 

 데이터베이스와 직접적으로 커뮤니케이션하는 리포지토리 계층 로직을 작성할 때 Spring Data JPA라는 프레임워크를 이용하였는데, 다음과 같이 JPARepository 인터페이스를 상속하는 repository 인터페이스를 정의하게 되면, 여러 기능들을 이용할 수가 있습니다.

 

 

 

 이 JpaRepository라는 인터페이스에 어떠한 추상메서드가 정의되어 있는지 한번 살펴보기 위해 파고들어가봤습니다.

 

 이 JpaRepository는 다음과 같이 여러 인터페이스를 상속하는데, 여기서 ListCrudRepository라는 곳에 들어가게 되면, 다음과 같은 메소드가 눈에 띕니다.

 

 

 여기서 ListCrudRepository는 CrudRepository라는 인터페이스를 또 상속하고 있네요.. 아마 단순히 하나의 결과를 반환하는 CRUD작업을 위한 인터페이스와 여러 결과를 한꺼번에 반환하는 CRUD작업을 위한 인터페이스를 분리하기 위해 이렇게 구현된 것이 아닐까 합니다.

 

 

 여기 CrudRepository 인터페이스에 정의되어 있는 추상 메서드 중에서 반환형이 Optional인 것이 있습니다. 바로 findById라는 메서드입니다. 이는 인자로 전달된 id라는 기본 키(Primary Key)에 해당하는 엔티티를 데이터베이스로부터 찾아 반환하는 메서드입니다. (여담이지만, 이렇게 찾아진 엔티티는 EntityManager의 영속성 컨텍스트(Persistence Context)의 1차 캐시(1-level cache)에 저장이 되죠.)

 

 스프링 데이터 JPA를 이용하여 우리가 원하는 엔티티를 찾고, 이 엔티티를 객체 형태로 그대로 반환하는 것이 아니라, 래퍼 클래스(wrapper class)의 일종인 Optional로 감싸서 반환하는 것을 볼 수가 있습니다. 이렇게 Optional객체로 반환하는 것은 다음과 같은 이유입니다.

 

 

"인자로 전달된 id에 해당하는 엔티티가 존재하지 않을 가능성이 있다."

 

 

 인자로 전달된 id에 해당하는 엔티티가 존재하지 않을 경우 null을 반환해야 하는데, 이렇게 한다면 해당 로직을 호출한 곳에서 null과 관련된 코드를 작성하느라 상당히 복잡한 코드(if문 떡칠)를 작성하게 됩니다. 그래서 Optional로 감싸서 반환을 하게 되는 것입니다. 이렇게 감싸서 반환을 하면, 위와 같은 예외상황에 대한 처리코드를 상당히 깔끔하게 작성할 수가 있습니다.

 

"Optional이라는 것은 코드를 이쁘게 만들기 위해 쓰는 것이다.

그렇게 하면 가독성이 좋아지고,

가독성이 좋아지면 유지보수가 쉬워진다."

 

 

지금까지 내가 어떻게 프로그래밍을 했는가

//바람직하지 않은 코딩 방식
Member member1 = memberRepository.findById(1L).get();

 

 

 만약 키에 해당하는 엔티티가 존재하여, 결과로 반환된 Optional객체에 get()을 통해서 꺼내면 그 결과로 엔티티가 반환됩니다. 하지만 인자로 전달된 키에 해당하는 엔티티가 존재하지 않는다면, null이 Optional객체에 감싸져서 반환이 되고, 이 Optional객체의 get()메서드를 통해 본체를 꺼내려고 하면 예외(NoSuchElementException)가 발생하게 됩니다. 그렇기 때문에 이렇게 Optional객체에 바로 get()을 통해 데이터를 꺼내려는 것을 시도하는 것은 바람직하지 않으며, 먼저 Optional객체가 감싸고 있는 데이터 본체가 null인지 아닌지 확인하는 작업이 필요합니다.

 

"null을 감싸고 있는 Optional객체의 get() 메소드를 호출하면

NoSuchElementException이라는 예외가 발생한다

그래서, Optional로 감싸진 데이터가 null인지 확인하는 작업이 필요하다"

 

Optional을 사용하는 올바른 방법

 저는 주로 다음과 같은 방법들을 선호합니다.

// 방식 1 : 권장
Member member1 = memberRepository.findById(15L).orElse(UNKNOWN_MEMBER);

// 방식 2 : 그닥 권장되지 않음(필요에 따라 사용할 수도 있음)
Member member1 = memberRepository.findById(15L).orElseGet(() -> new Member("UNKNOWN"));

// 방식 3 : 권장
memberRepository.findById(15L).ifPresent((member) -> System.out.println(member.getName()));

// 방식 4 : 권장
memberRepository.findById(15L).orElseThrow(() -> new NoSuchElementException("Member not found"));

 

우선 첫 번째 방식은 orElse()구문을 활용하는 방식입니다.

반환된 Optional객체에 null이 아닌 데이터가 담겨져 있다면 해당 데이터를 꺼내서 반환하고,

null이 담겨져 있다면 인자로 전달된 객체(이러한 목적을 위해 사용하기로 이미 정의되어 있는 객체여야 합니다)를 반환하게 됩니다.

 

두 번째 방식은 orElseGet()구문을 사용하는 방식으로써,

optional객체에 null이 아닌 데이터가 담겨져 있다면 해당 데이터를 꺼내서 반환하고,

null이 담겨져 있다면 인자로 전달된 람다식이 실행됩니다.

첫 번째 방식과 비슷하지만, 이미 정의되어 있는 객체를 꺼내는 것이 아니라 그 객체를 직접 생성해 내도록 하고 있는데, 객체를 생성하는 작업은 비쌀 수 있으므로(리소스가 많이 들어갈 수 있으므로) 되도록이면 첫 번째 방식처럼, 미리 본 목적을 위해 사용하기로 약속되어 정의된 객체를 반환하도록 하는 것이 안전하고 후에 로직을 좀 더 일관적으로 처리할 수가 있습니다.

 

세 번째 방식은 ifpresent()구문을 사용하는 방식으로써,

optional객체에 null이 아닌 데이터가 담겨져 있을 때에만, 이 데이터를 인자로 하는 함수를 실행합니다. 

 

 

 오라클에서 제공하는 Java8 문서를 참조하였습니다. 이 ifPresent메소드는 입력인자로 Consumer라는 Functional Interface의 구현 객체를 인자로 받고 있습니다. 간단히 말씀드리자면, ifPresent에는 위의 예시와 같이 인자를 필요로 하지만 반환값은 존재하지 않는 함수를 람다식(Lambda Expression)기반으로 정의하면 됩니다.

 

 어떠한 데이터를 찾은 후 바로 그 데이터를 이용한 행위가 필요하다면 상당히 고려해볼만한 가치가 있는 방식입니다.

 

 

네 번째 방식은 orElseThrow()구문을 사용하는 방식으로써,

 optional객체에 null인 데이터가 담겨져 있을 때에 인자로 전달된 예외(Exception)을 발생시키는 방법입니다. 만약 탐색결과 데이터가 존재하지 않을 경우 사용자 정의 예외 혹은 이미 정의된 예외를 발생시키고, 프로그래머가 이를 다양하게 처리하는 방식으로 구현을 하고싶다면 네 번째 방식을 고려해볼 수 있을 것 같습니다.

 

 

정리

 이상 Optional을 현명하게 활용하는 방법에 대하여 살펴보았습니다. 역시 좋은 코드를 작성하기 위해서는 그만큼 알아야하는 것이 많네요..