본문 바로가기
Language/Java

[Java 8] Optional

by ocwokocw 2021. 2. 11.

- 출처: https://www.oracle.com/technical-resources/articles/java/java8-optional.html

- Optional

Optional은 코드를 더 가독성있게 해주고 null pointer exception(이하 "NPE") 로 부터 보호해준다. null 참조는 값의 부재를 나타내기 때문에 많은 문제의 원인이 된다. Java SE 8 에서는 이런 문제를 완화할 수 있도록 java.util.Optional 이라 불리는 새로운 클래스를 선보였다. 아래와 같이 중첩된 객체구조를 지닌 Computer 가 있다고 가정해보자.

version을 알기 위해서 아래와 같이 코드를 작성했다고 가정해보자.

 

String version = computer.getSoundcard().getUSB().getVersion();

 

version을 알기 위해 아무 이상이 없어보이는 코드 같긴하지만 많은 컴퓨터에는 사운드 카드가 없을 경우가 있다. 이때 getSoundCard() 메소드에서 반환하는 결과는 없을것이다.

 

일반적으로는 사운드카드의 부재를 나타내기 위해서 null 참조를 반환한다. 하지만 이렇게 되면 null 참조의 USB 포트를 반환하기 위해서 getUSB() 를 호출하게 되고, 실행시점에 NPE가 일어나서 프로그램이 멈출수도 있다. 의도하지 않은 NPE를 막기 위해서 어떻게 해야할까? 아래처럼 null 참조를 체크하면서 방어적인 프로그래밍을 해야할것이다.

 

String version = "UNKNOWN";
if(computer != null) {
	SoundCard mySoundCard = computer.getSoundCard();
		
	if(mySoundCard != null) {		
		USB myUsb = mySoundCard.getUsb();
		version = myUsb.getVersion();
	}
}

 

위의 코드는 동작하는데 아무 이상없긴하지만 중첩된 if 문으로 인해서 상당히 지저분하다. NPE를 막기 위해서 상당한 boilerplate(필요하지만 관습적으로 작성해야하는 template 성 코드들)를 필요로 한다. 깔끔하게 작성해도 파악하는데 힘든 비즈니스로직에 이런 코드까지 들어가있다면 가독성은 정말 최악이다.

 

또한 오류 발생이 쉬운 프로세스이다. 만약에 1개의 속성에 대한 null 체크를 깜빡했다면? 값에 부재와 존재에 대해 더 나은 모델이 필요하다. 이런상황을 고려할 때 다른 프로그래밍 언어에서는 어떤 방안을 제공하는지 간단히 살펴볼 필요가 있다.


- Null 의 대안에는 무엇이 있는가?

Groovy 같은 언어에서는 잠재적인 null 참조를 확인하기 위해 "?." 로 표현되는 safe navigation 연산자라는게 있다. 

 

String version = computer?.getSoundcard()?.getUSB()?.getVersion();

 

version 변수는 만약 computer가 null 이거나, getSoundCard()가 null을 반환하거나, getUSB()가 null을 반환하면 null을 할당받을 것이다. null 체크를 위해 복잡한 중첩 분기문을 사용할 필요가 없는것이다.

 

또 Groovy는 default 값이 필요할 때 사용할 수 있는 Elvis 연산자 "?:" 도 포함하고 있다. 아래 코드에서 safe navigation 연산자가 null을 반환하면 default 값 "UNKNOWN"이 반환된다.

 

String version = computer?.getSoundcard()?.getUSB()?.getVersion() ?: "UNKNOWN";

 

Haskell과 Scala 같은 다른 함수형 언어에서는 다른 관점으로 처리한다. Haskell 은 optional 값을 캡슐화한 Maybe 형을 포함하고 있다. Maybe 타입의 값은 주어진 타입의 값이거나 nothing 이다. null 참조라는 개념 자체가 없다. Scala 에는 타입 T 값의 부재나 존재여부를 캡슐화한 Option[T] 라고 불리는 비슷한 생성자가 있다. Option 타입에서 이용가능한 연산자로 값이 존재하는지 아닌지 체크를 명시적으로 해야하는데, "null checking" 을 강제한다는 아이디어이다. 그래서 "깜빡했다"라고 할 수 없도록 type 시스템에 의해서 강제되고있다.


- Optional 의 기본사용

Java SE 8은 Haskell과 Scala에서 영감을 얻어 java.util.Optional<T> 라고 불리는 새로운 클래스를 발표하였다. optional 값을 캡슐화한 클래스이다. Optional을 값을 포함하거나 포함하고 있지 않은 단일 값 컨테이너라고 볼 수 있다.

Optional을 사용하면 getter 메소드를 수정해야 한다.

 

public class Computer {

	private Optional<SoundCard> soundCard;

	public void setSoundCard(SoundCard soundCard) {
		this.soundCard = Optional.of(soundCard);
	}

	public Optional<SoundCard> getSoundCard() {
		return soundCard;
	}
}

......

public class SoundCard {

	private Optional<USB> usb;

	public void setUsb(USB usb) {
		this.usb = Optional.of(usb);
	}

	public Optional<USB> getUsb() {
		return usb;
	}
}

 

위의 코드에서 Computer 는 사운드 카드를 가지고있을 수도 아닐 수도 있다는걸 분명하게 보여준다. 게다가 사운드 카드는 USB 포트를 선택적으로 가질 수 있게 된다. 이렇게 Optional 이라는 새로운 모델을 도입함으로써 지정된 값이 없을수도 있다는 것을 분명하게 알 수 있게 되었다.

 

실제로 Optional<SoundCard> 객체를 이용하여 무엇을 할 수 있는가? 결국에는 USB 포트의 version을 알아야 한다. 간단히 말해서 Optional 클래스는 값의 존재유무를 명확하게 다루는 메소드들을 포함하고 있다. 사실 이런 메소드를 포함한다는것보다도, Optional 클래스가 코드를 작성하거나 보는사람에게 "값이 없을수도 있다"라고 생각하게 강제한다는것이다. 결론적으로 의도되지 않은 NPE를 방지할 수 있게 된다.

 

Optional 클래스의 의도가 모든 단일 null 참조를 대체하는것이 아니라는것을 명심해야 한다. 메소드의 시그니처를 단지 읽기만 했을 때, optional 한 값이 있을 수 있는지 이해할 수 있는 API 설계를 도와주는것에 목적이 있다. 


- Optional 패턴

설명은 충분히 하였고 코드를 살펴보자. 우선 Optional을 사용하여 어떻게 일반적인 null-check 패턴이 다시 작성될 수 있는지 살펴보자. 최종적으로 Optional을 어떻게 사용하는지 이해하게 될 것이다. 아래 코드는 중첩 null 체크를 Optional을 이용하여 작성한 코드이다.

 

String name = computer.flatMap(Computer::getSoundcard)
                          .flatMap(Soundcard::getUSB)
                          .map(USB::getVersion)
                          .orElse("UNKNOWN");

- Optional 객체 생성

Optional 객체를 만드는데에는 몇 가지 방법이 있다. 아래 코드는 empty Optional 을 생성하는 코드이다.

 

Optional<SoundCard> soundCard = Optional.empty();

 

아래는 null이 아닌 값을 가진 Optional 객체이다.

 

SoundCard soundCard = new SoundCard();	
Optional<SoundCard> optionalSoundCard = Optional.of(soundCard);

 

만약 soundCard 가 null 이면 NPE 가 발생한다.

또 ofNullable을 사용하면 null 값을 가질 가능성이 있는 Optional 객체를 만들 수 있다.

 

Optional<SoundCard> optionalSoundCard = Optional.ofNullable(soundCard);

- 값이 존재할 경우

Optional 객체를 가지고 있다면, 값의 존재 유무를 명시적으로 다룰 수 있는 메소드를 사용할 수 있다.

ifPresent() 메소드를 사용하면 if 문으로 nulll check하는 구문을 변경하여 사용할 수 있다.

 

if(soundCard != null) {
	System.out.println(soundCard);
}

Optional.ofNullable(soundCard)
	.ifPresent(System.out::println);

 

더이상 명시적으로 null check를 할 필요가 없다. 만약 Optional 객체가 비어있다면 아무것도 출력되지 않는다. 또한 isPresent() 메소드로 Optional 객체안에 값이 존재하는지를 알 수 있다. 게다가 Optional 객체안에 포함된 값이 존재하면 반환하는 get() 메소드도 있다. 만약 없다면 NoSuchElementException이 발생한다. 두 메소드를 결합하면 exception을 예방하기 위해서 아래처럼 작성해야 한다.

 

if(soundcard.isPresent()){
    System.out.println(soundcard.get());
}

 

사실 위와 같은 코드는 중첩된 null-check 예제의 패턴보다 더 나은점이 없기 때문에 권고하는 방법은 아니다.


- Default 값과 동작

일반적으로 연산의 결과가 null 이라면 default 값을 반환하는식의 코드를 많이 작성할것이다. 이렇게 하기 위해 3항 연산자를 사용하곤 한다.

 

Soundcard soundcard = 
  maybeSoundcard != null ? maybeSoundcard 
            : new Soundcard("basic_sound_card");

SoundCard soundcard = maybeSoundCard == null ? new SoundCard() 
			: maybeSoundCard;

 

Optional 객체를 이용하면 orElse() 메소드를 이용하여 Optional 이 비었을 때 default 값을 제공할 수 있다.

 

SoundCard soundcardWithDefaultCard = Optional.ofNullable(maybeSoundCard)
			.orElse(new SoundCard());

 

유사한 방식으로 만약 Optional이 비어있다면 default 값을 제공하는 대신에 exception을 발생시키는 orElseThrow() 메소드를 사용할 수 있다.

 

SoundCard soundcardWithException = Optional.ofNullable(maybeSoundCard)
			.orElseThrow(IllegalStateException::new);

- filter 메소드

때로는 어떤 속성을 체크하여 객체의 메소드를 호출해야 하는 경우가 있다. 예를 들어 USB 포트의 특정 버전인지 아닌지 체크하고 싶을 수도 있다. 이렇게 하기 위해서 USB 객체에 대한 참조가 null 인지를 체크하고 getVersion() 메소드를 호출해야 한다.

 

USB usb = new USB();
	
if(usb != null && "3.0".equals(usb.getVersion())) {
	System.out.println("3.0 Version");
}

 

Optional 과 filter 메소드를 이용하면 아래처럼 작성할 수 있다.

 

Optional.ofNullable(usb)
		.filter(pUsb -> "3.0".equals(pUsb.getVersion()))
		.ifPresent(pUsb -> System.out.println("3.0"));

- Map 메소드를 이용한 값의 추출화 변환

또 하나의 일반적인 패턴은 객체에서 정보를 추출하는것이다. 예를 들어 Soundcard 객체로부터 USB 객체를 추출하거나 더 나아가 해당 버전이 정확한지를 체크하고 싶을 수도 있다. 아래 코드를 보자.

 

if(soundcard != null){
  USB usb = soundcard.getUSB();
  if(usb != null && "3.0".equals(usb.getVersion()){
    System.out.println("ok");
  }
}

Optional<USB> usb = maybeSoundcard.map(Soundcard::getUSB);

 

여기서 stream에 사용된 map 메소드를 사용하였다. stream 에서는 map 메소드가 stream의 각 원소에 적용되었다. stream이 비어있다면 아무일도 일어나지 않는다.

 

Optional 클래스에서 map 메소드가 하는일은 stream과 똑같다. Optional 내부에 포함된 값이 인자로 전달된 함수에 의해 변환되며 Optional 이 비어있다면 아무일도 일어나지 않는다. map과 filter 메소드를 이용하면 USB버전이 3.0이 아닌지를 아래처럼 깔끔한 코드로 알 수 있다.

 

maybeSoundcard.map(Soundcard::getUSB)
      .filter(usb -> "3.0".equals(usb.getVersion())
      .ifPresent(() -> System.out.println("ok"));

 

코드가 점점 null 체크에 집중된 것이 아닌 문제 상태를 표현하는 코드로 가까워 지고 있다.

 


- flatMap 메소드

Optional을 사용하며 리팩토링된 패턴들을 살펴보았다. 아래 코드를 어떻게 안전한 방법으로 재작성 할 수 있는가?

 

String version = computer.getSoundcard().getUSB().getVersion();

 

위의 코드는 정확히 1:1 맵핑이 된다는 가정하에 작성된것이다. 글 이전부분에서 모델을 Computer가 SoundCard를 속성으로 가지고 있던 것을 Optional<SoundCard>로 변경한적이 있다. map을 이용하여 아래처럼 작성하면 되는것일까?

 

String version = computer.map(Computer::getSoundcard)
                  .map(Soundcard::getUSB)
                  .map(USB::getVersion)
                  .orElse("UNKNOWN");

 

아쉽게도 위의 코드는 컴파일되지 않는다. map 으로 Computer 에서 SoundCard로 변환하고 USB 도 똑같은 방식으로 변환했는데 왜 안되는것일까? computer 변수의 타입은 Optional<Computer>이고, 정확히 map 메소드를 호출하였다. 그러나 getSoundcard()는 Optional<Soundcard>를 반환한다. 이렇게 되면 map 연산자의 결과는 Optional<Optional<Soundcard>> 타입의 객체가 되는것이다. 아래그림처럼 중첩된 Optional 객체가 되는것이다.

이 문제를 어떻게 해결할까? 눈치가 빠르다면 stream에서 보았던 flatMap을 떠올릴것이다. stream의 flatMap과 비슷하게 2 레벨의 Optional을 1개로 "평탄화" 시키길 원할것이다.

 

Optional도 flatMap을 제공한다. 이 함수의 목적은 Optional의 값에 변환함수를 적용하고 2 레벨의 Optional을 1 레벨로 "평탄화" 하는 것이다.  그래서 최종적으로는 아래처럼 코드가 작성되어야 한다.

 

String usbVersion = computer.flatMap(Computer::getSoundCard)
		.flatMap(SoundCard::getUsb)
		.map(USB::getVersion)
		.orElse("UNKNOWN");

 

처음의 flatMap은 Optional<Optional<SoundCard>> 대신에 Optional<SoundCard>가 반환되게 해주었고, 2번째 flatMap은 같은 목적으로 Optional<USB>를 반환하였다. 3번째에서 getVersion은 Optional 객체가 아닌 String을 반환하였기 때문에 flatMap 대신 map을 사용하였다.

댓글