Java: Null 안정성에 좋은 Optional 클래스, 이제 이렇게 써야 깔끔합니다.

Java: Null 안정성에 좋은 Optional 클래스, 이제 이렇게 써야 깔끔합니다.

이제랄 게 있겠어요. 사실 원래 아키텍처에 이렇게 녹여야 깔끔했습니다.

Optional은 무엇인가요?

Optional 때문에 코드가 난잡하게 된다면 어떻게 해야 할까요?

글의 발단

무분별한 옵셔널 사용으로 코드가 깔끔하지 않다고 느낄 때가 있다는 사연을 예전에 들은 적이 있습니다.

그래서 저는 어떤 이유로 어떻게 관리하고 있는지 설명을 했고, 이를 결국 글로도 남기기로 결정했습니다.

Java Optional이란?

자바의 NULL 안정성을 위한 표준 API 클래스입니다.

null일 수도 있는 값을 감싸서(박싱 해서) 사용한다는 점에서 마치 래퍼 클래스(wrapper class)와 닮았습니다. 래퍼 클래스보다는 박싱된 클래스라고 표현하는 것이 더 알맞습니다. 래퍼 클래스는 이미 기본 타입에 대응하는 참조 타입을 가리키는 표현으로 사용되기 때문입니다.

다른 언어들은 젊다 보니, null 안정성을 보장하기 위한 여러 신택스가 있습니다. 연산자 수준에서 이미 지원하고 있다는 말이죠.

// javascript

// 1) 객체가 nullish인 경우
const result = someObject?.field;

// 2) 갖고 오려는 값이 nullish일 때 대체값
const result = someNullableData ?? 'NOT_FOUND';

// 3) 객체 또는 필드 중 하나라도 nullish일 때 대체값
const result = someObject?.nullableField ?? 'NOT_FOUND';

코틀린도 null-safety 연산자들을 지원합니다.

// kotlin
val result = someNullableData ?: 'NOT_FOUND'

someObject?.field = 'abc'

item?.let { println(it) }

그런데 자바는 버전을 꾸준히 올리고 있음에도 불구하고, 정말 중요한 기능들은 어느 주기로 특정 버전에 담는 식으로 발전의 여지를 남겨 가며 업그레이드되고 있습니다.

그나마 null 처리를 편하게 수행할 수 있도록, 데이터를 박싱해서 null 여부에 관해 관리해 주는 옵셔널 클래스가 존재합니다. (JDK 1.8 이상)

Optional 인스턴스 생성

Optional 인스턴스는 다른 값을 '감싸며' 생성합니다. 박싱이라고 표현합니다.

래퍼 클래스의 주요 개념인 박싱/언박싱과 거의 같은 관점입니다.

생성(박싱) 방식 분류

우리가 직접 생성하는 경우도 있을 수 있습니다. 이 경우 다음 세 경우에 따른 방식이 기본입니다.

  • null이 아니라는 것을 알 때

  • null이라는 것을 알 때

  • null인지 아닌지 긴가민가할 때

// null이 아닌 것으로 보장된 객체를 박싱
Optional<Item> optionalItem = Optional.of(nonNullItem);

// null이 분명한 경우, null을 넣지 말고 Optional.empty() 사용
Optional<Item> optionalItem = Optional.empty();

// null일 수도 있고 아닐 수도 있는 경우
Optional<Item> optionalItem = Optional.ofNullable(item);

언박싱

누구나 설레는 언박싱 시간입니다. 평범하게 get()도 있지만, 그런 식으로 뻔한 언박싱보다는 대체값, 예외 등 null일 때의 조치를 포함하는 언박싱 함수들이 더 좋습니다.

// Optional<Item> optionalItem에 대하여

// non-null임을 분명하게 알 때
Item item = optionalItem.get();

// nullable일 때, null 대체 값
Item item = optionalItem.orElse(대체값);

// nullable일 때, null 대체 값 생성
Item item = optionalItem.orElseGet(대체값을 반환하는 함수);

// nullable일 때, null인 경우의 예외 발생
Item item = optionalItem.orElseThrow(예외를 반환하는 함수 또는 그 생성자);

이중에서 저는 .orElseThrow를 사용하는 일이 많았습니다. 특히 에러 코드 관리에서 상성이 좋게 되어 있다면 이렇게 활용할 수 있습니다.

Item item = optionalItem
        .orElseThrow(MyErrorCode.EXAMPLE::defaultException);
  • 제 경우는 ErrorCode를 인터페이스로 만듭니다.

  • 그리고 리소스나 피처 단위에서 세부적인 enumErrorCode를 만들어 인터페이스를 구현합니다.

  • ErrorCode 인터페이스에는 defaultException 메서드가 있으며, RuntimeException 또는 그 하위 예외 클래스를 반환합니다.

일반 아키텍처에서 깔끔하게 관리하기

다시 글의 배경으로 돌아와서, 무분별한 옵셔널 사용으로 코드가 깔끔하지 않다고 느낀다는 사연에 대해 어떤 솔루션을 제시할 수 있을까요?

거창하게 솔루션이라고 할 것도 아닙니다만, 간단히 우리의 여러 아키텍처를 생각해 봅시다.

제가 제안했던 것은,

  1. 서비스 레이어는 기능의 구체적인 부분을 구현하는 영역이기 때문에, Exception 등 가용한 수단이 많이 있다는 전제를 두고 이야기를 했습니다.

  2. 레포지터리는 우리가 구현하려는 구체적인 기능에 결정권을 가지면 안 되기 때문에, 옵셔널 객체로 반환하여 선택권을 상위 레이어에 넘기지만, 서비스에서부터는 선택권을 갖는다는 것이죠.

  3. 그래서 레포지터리에서는 반환에 옵셔널을 사용하되, 서비스에서 반환 시 옵셔널을 언박싱하는 것이 일반적인 코드 작성 규칙으로 편해 보인다는 것이었습니다.

특히 저희는 ErrorCode를 인터페이스로 두고, 구체적인 ErrorCode enum을 적절한 리소스나 피처 단위로 따로 관리하기 때문에 앞서 소개한 ErrorCode를 통한 .orElseThrow(...) 구현이 매우 쉬운 상태이기도 합니다.

그리고 GlobalExceptionHandler 등에서 이 예외를 캐치하여 반환할 거니까, 정상 응답 로직과 예외 응답 로직을 적절한 수준에서 구분해 둘 수 있었습니다.