[자바] Technical Article 정리 - 자바 Optional

Raoul-Gabriel Urma의 Tired of Null Pointer Exceptions? Consider Using Java SE 8’s “Optional”! 기고 내용 정리입니다.

  • Java SE 8의 Optional을 사용하여 코드를 보다 읽기 쉽게 만들고 null 포인터 예외로부터 보호할 수 있다.
  • null 참조란 값이 존재하지 않는 것을 나타내는 데 사용되므로 많은 문제의 원인이 된다.
  • Java SE 8에서 제공되는 새로운 클래스인 java.util.Optional 클래스를 사용하여 이러한 문제들을 완화할 수 있다.
  • null의 위험성
    • 의도치 않은 null 포인터 예외를 예방하기 위해서 할 수 있는 일은 먼저 방어적으로 null 참조를 막기 위한 확인을 하는 것이다.
  • 다음 예는 null 참조 확인을 위한 코드이다.
    String version = "UNKNOWN";
    if(computer != null) {
      Soundcard soundcard = computer.getSoundcard();
      if(soundcard != null) {
          USB usb = soundcard.getUSB();
          if(usb != null) {
             version = usb.getVersion();
          }
      }
    }
    
  • 그러나 이 경우 중첩된 null 참조 확인 코드로 인해 코드가 매우 더러워지게 된다.
  • 불행히도 NullPointerException가 발생하지 않도록 만들기 위해서는 많은 상용구 코드가 필요하다.
  • 또한 이러한 확인 코드들이 비즈니스 로직을 방해한다는 것은 성가신 일이며 실제로 프로그램의 전체적인 가독성을 떨어뜨린다.
  • 어떤 속성이 null이 되는 경우를 확인하는 것을 빼먹을 수 있기 때문에 이러한 null 확인 코드는 에러를 발생하기 쉽게 만든다.
  • 따라서 null을 사용하여 값이 존재하지 않는 것을 나타내는 것은 잘못된 접근이며 값의 존재 여부를 나타내기 위한 더 나은 방법이 필요하다.

Null에 대한 대안이 존재하는가

  • Groovy와 같은 언어는 잠재적인 null 참조를 안전하게 탐색하기 위해 safe navigation 연산자인 ?.를 제공하고 있으며 다음과 같이 동작한다.
    String version = computer?.getSoundcard()?.getUSB()?.getVersion();
    
    • 위 경우 변수 computernull일 경우, 또는 getSoundcard()null을 반환할 경우, 또는 getUSB()null을 반환할 경우에 변수 versionnull이 할당된다.
    • 따라서 null을 확인하기 위해 복잡한 중첩 조건문을 작성하지 않아도 된다.
  • 또한 Groovy는 Elvis 연산자인 ?:를 제공하고 있으며 이는 기본값이 필요한 간단한 상황에서 사용된다.
    String version = computer?.getSoundcard()?.getUSB()?.getVersion() ?: "UNKNOWN";
    
    • safe navigation 연산자를 사용한 표현식이 null을 반환하면 null 대신 기본값인 "UNKNOWN"이 반환되며 그렇지 않은 경우 사용 가능한 버전 값이 반환된다.
  • Haskell과 Scala와 같은 함수형 언어는 다른 방법을 사용한다.
  • Haskell은 Maybe 형을 제공하여 기본적으로 선택적인 값을 캡슐화한다.
    • Maybe 형의 값은 주어진 형의 값을 포함하거나 아무것도 포함하지 않을 수 있으며 null 참조라는 개념은 존재하지 않는다.
  • Scala 또한 Option[T]라는 유사한 구조체를 제공하여 형이 T인 값의 존재여부를 나타낼 수 있다.
    • 값이 존재하는지 그렇지 않는지 Option 형에 있는 연산을 사용하여 명시적으로 확인해야 하므로 null 확인이라는 과정을 강제한다.
    • 타입 시스템에 의해 강제되므로 더 이상 null인지 확인하지 않아도 된다.

Optional

  • Java SE 8에서는 Haskell과 Scala의 아이디어로부터 영감을 받아 java.util.Optional<T>이라는 새로운 클래스를 도입하였다. 이 클래스는 선택적인 값을 캡슐화한다.
  • Optional을 값을 가지거나 가지지 않는 단일 값 컨테이너로 볼 수 있다.
    • 예컨대 Optional<Soundcard> 형 객체는 Soundcard 형 객체를 포함할 수 있고, 아무것도 포함하지 않는 빈 객체가 될 수도 있다.
  • Optional을 사용하여 다음과 같이 코드를 작성하여 새로운 모델을 만들 수 있다.
    public class Computer {
      private Optional<Soundcard> soundcard;
      public Optional<Soundcard> getSoundcard() { ... }
    }
    public class Soundcard {
      private Optional<USB> usb;
      public Optional<USB> getUSB() { ... }
    }
    public class USB {
      public String getVersion() { ... }
    }
    
    • Computer 형 객체는 Soundcard 형 객체를 포함할 수도 있고, 포함하지 않을 수도 있다. 즉, Soundcard는 선택적이다. 또한 Soundcard는 선택적으로 USB 포트를 가지고 있을 수 있다.
    • 위의 새로운 모델은 주어진 값이 존재하지 않는 것이 허용된다는 것을 명확하게 반영한다.
    • Guava와 같은 라이브러리에서도 유사한 개념이 적용 가능하다.
  • 사용자는 결국 USB 포트의 버전 번호를 얻고 싶은 것이다.
  • 간단히 말해서 Optional 클래스는 값이 존재하거나 존재하지 않는 상황을 명시적으로 다루는 메소드를 포함하고 있기 때문에 Optional<Soundcard> 객체를 가지고 실제적으로 할 수 있는 일은 값이 존재하지 않은 상황에 대한 처리이다.
  • 즉, null 참조 확인과 비교하여 Optional 클래스가 제공하는 장점은 값이 존재하지 않은 상황을 생각해보게 만드는 것이다.
  • 결과적으로 의도치 않은 null 포인터 예외를 예방할 수 있다.
  • Optional 클래스의 의도는 모든 null 참조를 대체하는 것이 아니라는 점 중요하다.
    • 대신, 그 목적은 메소드의 시그니처를 확인하는 것만으로 선택적인 값을 기대할 수 있는지 알 수 있도록 더 이해하기 쉬운 API를 설계하는 것이다.
    • 이는 값이 존재하지 않는 경우를 처리하기 위해 Optional을 적극적으로 언래핑하도록 강요한다.
  • 중첩된 null 확인 코드 대신 Optional을 사용해 다음과 같이 코드를 작성할 수 있다.
    String name = computer.flatMap(Computer::getSoundcard)
                        .flatMap(Soundcard::getUSB)
                        .map(USB::getVersion)
                        .orElse("UNKNOWN");
    

Optional 객체 생성 방법

  1. Optional 객체 생성
    Optional<Soundcard> sc = Optional.empty();
    
  2. null이 아닌 값을 가지고 있는 Optional 객체 생성
    SoundCard soundcard = new Soundcard();
    Optional<Soundcard> sc = Optional.of(soundcard);
    
    • 이 경우 soundcardnull이라면 즉시 NullPointerException이 발생한다. Optional을 사용하지 않았을 경우에는 soundcard의 속성에 접근하려고 할 때 발생할 것이다.
  3. null 값을 가지고 있는 Optional 객체 생성
    Optional<Soundcard> sc = Optional.ofNullable(soundcard);
    
    • 위와 같이 ofNullable 메소드를 사용하여 null 값을 가질 수 있는 Optional 객체를 생성할 수도 있다.
    • 이 경우 soundcardnull이라면 결과적으로 Optional 객체는 빈 객체가 된다.

값이 존재할 경우 어떤 일을 하기

  • Optional 객체를 생성했다면 값의 존재유무를 명시적으로 다루기 위해 사용가능한 메소드들에 접근할 수 있다.
  • 다음과 같이 null인지 확인하는 코드를 잊지 않고 작성하는 것 대신 ifPresent() 메서드를 사용할 수 있다.
    SoundCard soundcard = ...;
    if(soundcard != null) {
      System.out.println(soundcard);
    }
    
    Optional<Soundcard> soundcard = ...;
    soundcard.ifPresent(System.out::println);
    
  • 더 이상 명시적으로 null 확인을 하지 않아도 되며, 이는 타입 시스템에 의해 강제된다.
    • 만약 Optional 객체가 비어있다면 아무런 내용도 출력되지 않게 된다.
  • Optional 객체에 값이 존재하는지를 알아내기 위해 ifPresent()를 사용할 수도 있다.
  • get() 메소드는 Optional 객체에 값이 존재할 경우 그 값을 반환해주며 값이 존재하지 않는다면 NoSuchElementException 예외를 발생시킨다.
  • 다음과 같이 ifPresent()get() 메소드를 결합하여 예외를 막을 수 있다.
    if(soundcard.ifPresent()){
      System.out.println(soundcard.get());
    }
    
  • 그러나 이 방법은 중첩된 null 확인 코드와는 크게 다르지 않으므로 Optional을 사용하는 데 있어 권장된 것은 아니며 보다 이상적인 대안들이 존재한다.

기본값과 조치

  • 전형적인 패턴은 코드 실행 결과가 null인지 결정할 때 기본값을 반환하는 것이다.
  • 이를 위해 일반적으로 다음과 같이 삼항(ternary) 연산자를 사용할 수 있다.
    Soundcard soundcard = maybeSoundcard != null ? maybeSoundcard : new Soundcard("basic_sound_card");
    
  • Optional 객체를 사용하고, Optional이 비어 있을 때 기본값을 제공하는 orElse() 메소드를 사용하면 다음과 같이 코드를 작성할 수 있다.
    Soundcard soundcard = maybeSoundcard.orElse(new Soundcard("default"));
    
  • 유사하게 Optional이 비어 있을 때 기본값을 제공하는 것 대신 예외를 발생시키는 orElseThrow() 메소드를 사용할 수도 있다.
    Soundcard soundcard = maybeSoundCard.orElseThrow(IllegalStateException::new);
    

filter 메서드를 사용하여 특정 값을 제외하기

  • 객체를 대상으로 메소드를 호출하고 어떤 특성을 확인하는 것이 자주 필요하다.
  • USB 객체가 특정 버전인지 확인이 필요하다면 이를 위한 안정적인 방법 중 하나는 USB 객체를 가르키는 참조가 null인지 아닌지 확인한 다음 getVersion() 메소드를 호출하는 것이다.
    USB usb = ...;
    if(usb != null && "3.0".equals(usb.getVersion())) {
      System.out.println("ok");
    }
    
  • 이 패턴은 Optional 객체의 filter() 메소드를 사용하여 다음과 같이 다시 작성할 수 있다.
    Optional<USB> maybeUSB = ...;
    maybeUSB.filter(usb -> "3.0".equals(usb.getVersion())
          .ifPresent(() -> System.out.println("ok"));
    
  • filter() 메소드는 술어(predicate; 불리언을 반환하는 함수)를 인자로 받는다. Optional 객체에 값이 존재하고 값이 일치한다면 그 값을 반환하며, 그렇지 않다면 빈 Optional 객체를 반환한다.
  • 이러한 패턴은 Stream 인터페이스에서 사용된 filter() 메소드와 유사하다.

map 메서드를 사용하여 값을 추출 및 변환시키기

  • 또다른 흔한 패턴은 객체로부터 정보를 추출하는 것이다.
  • Soundcard 객체에서 USB 객체를 추출하고 버전이 올바른지 확인하기 위한 전형적인 코드는 다음과 같다.
    if(soundcard != null) {
      USB usb = soundcard.getUSB();
      if(usb != null && "3.0".equals(usb.getVersion()) {
          System.out.println("ok");
      }
    }
    
  • Optional 객체(Soundcard 객체)의 map() 메소드를 사용하면 “null 값 확인과 값 추출” 코드를 다음과 같이 작성할 수 있다.
    Optional<USB> usb = maybeSoundcard.map(Soundcard::getUSB);
    
  • 이는 스트림과 함께 사용되는 map() 메소드와 직접적으로 유사한데, 스트림에서는 map() 메소드에 함수를 전달할 수 있으며 그 함수는 스트림의 각각의 원소들에 대해 동작하지만 스트림이 비어있는 경우 아무런 일도 일어나지 않는다.
  • Optional 객체의 map() 메소드도 이와 정확히 동일하다. Optional 객체 내부에 포함된 값은 map() 메소드의 인자로 전달된 함수에 의해 “변환되며” Optional 객체가 비어있다면 아무런 일도 일어나지 않는다.
  • 결과적으로 map() 메서드와 filter() 메서드를 결합하여 버전이 3.0이 아닌 USB 객체를 제외할 수 있다.
    maybeSoundcard.map(Soundcard::getUSB)
                .filter(usb -> "3.0".equals(usb.getVersion())
                .ifPresent(() -> System.out.println("ok"));
    

flatMap 메서드를 사용하여 Optional 객체를 연쇄적으로 처리하기

  • Option을 사용하기 위해 리팩토링 할 수 있는 몇가지 패턴을 보았다.
    String version = computer.getSoundcard().getUSB().getVersion();
    
  • 위 코드가 수행하는 작업은 다른 객체에서 하나의 객체를 추출하는 것이며 이것이 바로 map() 메서드의 목적이다.
  • 앞서 ComputerOptional<Soundcard>가 있고 SoundcardOptional<USB>가 있도록 모델을 변경했으므로 다음과 같이 작성할 수 있다.
    String version = computer.map(Computer::getSoundcard)
                           .map(Soundcard::getUSB)
                           .map(USB::getVersion)
                           .orElse("UNKNOWN");
    
  • 불행히도 이 코드는 컴파일 되지 않는다.
  • 변수 computerOptional<Computer> 유형이므로 map() 메서드를 호출할 수 있다.
  • 하지만 getSoundcard() 메서드는 Optional<Soundcard> 형 객체를 반환한다. 따라서 map() 메서드의 실행 결과는 Optional<Optional<Soundcard>> 형 객체가 된다.
  • 그 결과 바깥쪽 Optional 객체가 다른 Optional 객체를 값으로 가지며 이 Optional<Optional<Soundcard>> 형 객체는 getUSB() 메서드를 지원하지 않기 때문에 getUSB() 메서드 호출은 유효하지 않다.
  • 스트림에서 사용한 패턴인 flatMap() 메서드를 살펴보면, 스트림의 flatMap() 메서드는 함수를 인자로 전달받아서 다른 스트림을 반환한다.
    • 이 함수는 스트림의 각 요소에 적용되어 스트림의 스트림을 생성한다.
    • 그러나 flatMap() 메서드는 생성된 각각의 스트림을 그 스트림의 내용으로 교체하는 효과가 있다.
    • 다시 말해 함수에 의해 생성된 모든 개별 스트림들은 하나의 단일 스트림으로 통합되거나 “평평하게” 된다.
  • 우리가 원하는 것도 이와 유사하며 중첩된 두 단계 Optional을 하나로 단조롭게 만드는 것이 목적이다.
  • Optional 또한 flatMap() 메서드를 지원하며 목적은 Optional의 값에 변환 함수(transformation function)를 적용하고(맵 연산과 같이) 중첩된 Optional을 단일 Optional로 단조롭게 만드는 것이다.
  • map(Computer::getSoundcard) 메서드는 중첩된 Optional 객체(Optional<Optional<Soundcard>>)를 반환하는 반면, flatMap(Computer::getSoundcard) 메소드는 단일 Optional 객체를 반환한다.
  • 이전 코드를 올바르게 만들기 위해서는 flatMap() 메서드를 사용하여 다음과 같이 다시 작성해야 한다.
    String version = computer.flatMap(Computer::getSoundcard)
                           .flatMap(Soundcard::getUSB)
                           .map(USB::getVersion)
                           .orElse("UNKNOWN");
    
  • flapMap() 메서드는 Optional<Optional<Soundcard>> 형 객체 대신 Optional<Soundcard> 형 객체를 반환하고, 두 번째 flatMap() 메서드도 Optional<USB> 형 객체를 반환하는 동일한 목적을 달성한다.
  • getVersion() 메서드는 Optional 객체가 아닌 String 객체를 반환하기 때문에 세 번째 호출은 map() 메소드이어야 한다.
  • 이로써 중첩된 null 확인 코드 대신 더 읽기 쉬운 코드를 작성할 수 있게 되었으며, 또한 null 포인터 예외로부터 더 보호받을 수 있게 되었다.

결론

  • Optional의 목적은 모든 null 참조를 대체하는 것이 아니라 메서드의 시그니처를 확인하는 것만으로 선택적인 값을 기대할 수 있는지 알 수 있도록 더 이해하기 쉬운 API를 설계하는 것이다.
  • 또한 값이 존재하지 않는 경우를 처리하기 위해 Optional을 적극적으로 언래핑하도록 강요한다.
  • 결과적으로 의도하지 않은 null 포인터 예외로부터 코드를 보호할 수 있다.

Comments