모던 자바 인 액션 11장

NPE는 만악의 근원

NPE는 만악의 근원이다. 프로젝트는 커녕, 토이프로젝트나 하다못해 학교 과제라도 해 보면 이게 얼마나 자주 터지고 빡치는건지 굳이 말 안해도 될 듯.

그러니까 NPE를 예방할 수 있는 코드를 만들어 보자

if (object != null) {
  // 더 이상 자세한 설명은 생략한다
}

이게 별로 좋지 않은 방법이란 것은 뭐…굳이…

그럼 이런 방법으로 해볼까?

public String getCarInsuranceName(Person person) {
  if (person == null) {
    return "Unknown";
  }
  if (car == null) {
    return "Unknown";
  }
  Insurance insurance = car.getInsurance();
  if (insurance == null) {
    return "Unknown";
  }
  
  return insurance.getName();
}

뭔가 좀 더 느낌있어 보인다 그치..?

근데 저렇게 if를 덕지덕지 칠하고 early return이 많아지는 코드가 그렇게 좋아 보이지 않는 건 매한가지이다. 적어도 난 그렇게 느낌..

(유지보수를 하면서 클래스에 프로퍼티가 하나 더 생기면 null이 통과되는 경우를 막기 위해 ‘if문을 추가해야’ 하는 동작도 똑같이 추가되어야 하는데….음… 그걸 까먹으면?)

아무튼 NPE는 만악의 근원이고, NPE 발생을 막기 위해 수많은 시도를 해 보았으나 쉽지 않았다.

null때문에 발생하는 문제

  • 에러의 근원이다
    • null떄문에 NPE라는 에러가 뻑하면 튀나온다. 이래저래 보기싫음
  • 코드를 어지럽힌다
    • if null…덕지덕지… 이래저래 보기싫음 2
  • null이 아무 의미가 없다
    • ‘의미가 없다’ 라는 의미를 가진(…) 표현식임…
  • 자바 철학에 위배된다
    • C계열의 강점이자 단점인 포인터 개념은 없앴는데, null포인터는 두었다. 아이러니하다.
  • 형식 시스템에 구멍을 만든다
    • 강타입 언어 시스템인 자바에, null은 약타입 언어 시스템마냥 어떤 타입의 변수(참조)에 갖다 붙일 수 있음.
      특정 변수에 null을 assign하였을 시, 나중에 ‘애초에 null이 어떤 의미로 사용되었는가’ 에 대한 추적을 하기 힘듬.

그래서 어떻게 할건데?

그래서 jave.util.Optional<T>이라는 새로운 클래스를 자바8부터 제공한다.

null을 예방하기 위해, 빈 껍데기라고 생각하는 Optional 클래스로 한번 더 래핑을 해주는 개념이라고 생각하면 된다.

요런 식으로…

Car를 참조할 때, null일 수도 있는 Car를 직접 참조하는 것이 아닌, Car를 감싸고 있는 Optional을 참조하는 개념이므로 뭐 로직이 원하는대로 돌지 않을진 몰라도, NPE만큼은 예방할 수 있다!

Optional을 이용하는 코드 예제를 한번 봐보자

특기할 만한 것이 Insurance.name 프로퍼티인데, 얘는 Optional로 감싸지 않았다.

‘NPE가 나는거 아냐?’ 라고 생각할 수 있다. 물론…. NPE가 나겠지…

하지만 코드 주석으로 써져있듯 이 코드에는 숨겨진 의미가 존재한다. ‘보험회사에는 반.드.시. 이름이 있어야 한다’

그러니까 NPE가 난다면 이건 의도치 않은 NPE가 아니라, ‘당연히 나야 하는 NPE’라는 것임.

이런 코드에 익숙해지면 굳이 주석을 덕지덕지 쳐바르지 않고도, 개발자의 의도가 담긴 코드를 작성할 수 있다는 뜻이다.

Optional 사용법

/* Optional 객체 만들기 */

//빈 Optional
Optional<Car> optCar = Optional.empty();

//null이 아닌 값으로 만들기
Optional<Car> nonNullableOptCar = Optional.of(car); //이건 car인스턴스가 null이면 NPE발생함..

//null값으로 만들기
Optional<Car> nullableOptCar = Optional.ofNullable(car);

/* 
  Optional.map 메소드를 통한 값 추출 
    insurance 라는 인스턴스의 이름(보험회사이름)을 추출하고자 할 때
*/

//이 패턴의 코드를
String name = null;
if(insurance != null) {
  name = insurance.getName();
}

//이런 식으로 작성가능
Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
Optional<String> name = optInsurance.map(Insurance::getName);

map메소드를 이용한 값 추출 코드를 보면…. 뭔가 스트림에서 쓰는 map() 이랑 비슷하지 않나? 제대로 이해한 것이다.

  • 스트림
    • 스트림의 각 element에, 제공된 함수를 apply를 하는 결과값을 리턴
  • Optional
    • Optional의 요소에, 제공된 함수를 apply하는 결과값을 리턴

내부적인 동작을 구체적으로 모르더라도(공부하면 알겠지), 이렇게 비슷하게 동작한다는 것을 인지해 두자.

이를 그림으로 표현하면 이렇게 할 수 있다.

위는 스트림, 아래는 Optional

메소드 체인과 flatmap

그럼 다음과 같은 레거시 자바 코드 예제가 있다.

얘를 Optional을 이용하여 호출하려면 어떻게 해야 할까?

이렇게요?

앞서 구현했던 Person, Car, Insurance 클래스를 참고해보면, Person::getCar의 리턴 타입이 Car타입이 아니라 Optional<Car> 타입이므로, 3라인의 실행결과는 Optional<Optional<Car>> 타입으로 리턴됨을 알 수 있다.

물론 Car.getCar()의 리턴타입을 Car로 한다면야… 컴파일 오류가 나진 않겠지. 하지만 그건 그거대로 문제인게 getCar 메소드를 다시 Car타입으로 변경하면 NPE문제가 발생할 수 있다는 것이다.

그럼 어떻게 해결하느냐? Optional의 flatMap메소드를 이용하면 된다.

그래서 요렇게 사용하면 된다

깔끔하게 NPE가 나지 않으면서 if null 없이, 한눈에 들어오는 간단한 코드로 작성하게 되었다.


Optional 스트림 조작

자바 9서부터는 아예 Optional에 stream() 가 추가되었다. Optional 자체에 스트림을 사용할 수 있다는 뜻이다.

public Set<String> getCarInsuranceNames(List<Person> persons) {
  return persons.stream()
                .map(Person::getCar) // Optional<Car>
                .map(optCar -> optCar.flatMap(Car::getInsurance)) // flatMap을 써서 Optional<Car> --> Optional<Insurance>
                .map(optIns -> optIns.map(Insurance::getName))  // Optional<Insurance> --> Optional<String>
                .flatMap(Optional::stream) // Stream<Optional<String>> --> Stream<String>
                .collect(toSet());
}

이런 식으로 Optional자체에 Stream을 사용할 수 있다. 번거롭지 않게 곧바로 Set<String> 을 리턴할 수 있게끔….

아마 원래는 stream.filter(Optional::isPresent) 로 필터 후에 Optional::get 메소드 참조를 사용하여 최종적으로 String을 가져올 수 있을거다…

다만 이 코드를 보면…

  • 첫번째 연산에 Car가 아니라 Optional<Car>가 튀어나온다 (정확힌 Stream<Optional<Car>>)
  • 두번째 연산에 이 Optional을 언랩하기 위해 flatMap을 한번 사용한다
  • 세번째 연산에 비로소 Insurance.name 을 구한다
  • 네번째 연산에 또 Optional<String>을 언랩하기 위해 flatMap을 한번 사용한다

이런 식으로, Optional이 NPE는 막아주지만, Optional로 한번 wrapping이 되어 있으므로, 스트림 연산 사용시 매번 겁나 귀찮게(그리고 어렵게) 신경을 써야 한다는 단점이 있다.

Optional 언랩

Optional 인스턴스에 포함된 값을 읽는 다양한 방법에 대해 알아본다

  • get()
    • 말그대로 값을 리턴해 주는 메소드. 근데, 값이 비어있는 경우엔 NoSuchElementException이 발생한다. get()을 뻔질나게 사용하면 결국 구조적으로 NPE체크 if문이랑 다를게 없을테니 지양하자
  • orElse()
    • Optional에 값이 없을때 디폴트값을 리턴받을 수 있다.
  • orElseGet(Supplier<? extends T>)
    • orElse() 랑 같은 개념이다. orElse()대비 좀 더 축약된 버전
  • orElseThrow(Supplier<? extends X> exceptionSupplier)
    • orElseGet() 이 디폴트값을 리턴한다면 얘는 예외를 던진다. 여러모로 던질 수 있는 예외를 정할 수 있다는 점만 제외하면 get()과 다를 바가 없음
  • ifPresent(Consumer<? super T> comsumer)
    • 값이 없다면 consumer가 아무일도 하지 않고, 값이 있다면 consumer를 실행한다.
  • ifPersentOrElse(Consumer<? super T> actoin, Runnable emptyAction)
    • orElse <-> orElseGet 과 동일한 수준의 관계. Optional이 비어있을 때 실행할 수 있는 Runnable을 arg로 받는다

두개의 Optional을 이용하여 연산하기

Person 과 Car를 이용해서, 가장 저렴한 보험료를 제공하는 메소드를 만든다고 가정해 보자

그리고 이 두 요소가 Optional로 래핑된, Optional<Person> 과 Optional<Car> 를 arg로 받는 메소드를 만든다고 해 보자

뭐 되는 코드이긴 하지만, if 체크가 뭔가 좀….세련되지 못한 느낌이다

편-안

이런 식으로 flatMap을 이용해서 Optional<Person> 을 Person으로 언랩을 하여 한 줄의 코드로 처리할 수 있다

필터로 특정 값 거르기는 너무너무너무너무 익숙해서 슈퍼패스

Optional 클래스에서 다양한 메소드를 제공해 준다. 기존에 stream을 많이 사용하던 사람들에겐 친숙한 내용일 것이다

Optional 실제 예제

nullable한 변수를 Optional로 감싸기

Object value = map.get("key");

Optional<Object> value = Optional.ofNullable(map.get("key"));

예외처리를 Optional로

try{
  return Optional.of(Integer.parseInt(s));
} catch (NumberFormatException e) {
  return Optional.empty();
}

응용

Properties값을 테스트하는 테스트코드를 만든다고 하자. 다음과 같이 Properties를 읽어서 값이 제대로 들어가있는지를 테스트하는 JUnit assertion 코드를 예제로…… readDuration메소드를 구현해 본다

Properties props = new Properties();
props.setProperty("a","5");
props.setProperty("b","true");
props.setProperty("c","-3");

assertEquals(5, readDuration(param,"a"));
assertEquals(0, readDuration(param,"b"));
assertEquals(0, readDuration(param,"c"));
assertEquals(0, readDuration(param,"d"));

위 코드가 참이 되려는 readDuratoin을 구현한다 치면… Optional 없이는 이렇게 구현될 수 있을 것이다

public int readDuration(Properties props, String name) {
  String value = props.getProperty(name);
  if (value != null) {
    try {
      int i = Integer.parseInt(value);
      if (i>0) return i;
    } catch(NumberFormatException nfe) {}
  }
  return 0;
}

흠….. if에 try/catch라….

좀 더 깔끔하게 Optional을 이용하여 코드를 리팩토링 해 보자

public int readDuration(Properties props, String name) {
  return Optional.ofNullable(props.getProperty(name))
                 .flatMap(OptionalUtility::stringToInt)
                 .filter(i -> i>0)
                 .orElse(0);
}

편-안

Leave a Comment