[Ch11] null 대신 Optional 클래스

NullPointerException 을 유발하는 null 객체를 보완하기 위한 Optional 에 대해서 알아본다.

값이 없는 상황을 처리하는 방법

보수적인 방법 : 중첩된 If 문

가장 흔하고 쉽게 null을 피하는 방법은 여러 if문을 통해 null 체크를 직접하는 것이다. (지겹다 :cry:)

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

이 메서드는 모든 변수가 null인지 의심하므로 “깊은의심 (deep doubt)” 라고도 부른다. 이를 반복하다보면 코드의 구조가 엉망이 되고 가독성도 떨어진다.

자바 8의 선택 : java.util.Optional

자바8에서는 ‘선택형값’ 개념으로 Optional 라는 새로운 클래스를 제공하며, 개발자는 메서드의 시그니처만 보고도 Optional 값을 기대해야 하는지 판단할 수 있다.

null 일 수 있는 값을 Optional 객체로 감싼다.

@Getter
public class Person {
    private Optional<Car> car;

    @Getter
    public static class Car {
        private Optional<Insurance> insurance;
    }
    @Getter
    public static class Insurance {
        private String name; // 필수로 값이 있을 경우에는 Optional 로 감싸지 않는다.
    }
}

이처럼 Optional 의 역할은, 값이 없을 수 있는 상황에 적절하게 대응하도록 강제하는 효과가 있다.

Optional 적용 패턴

Optional 객체 만들기

Optional.of() 또는 Optional.ofNullble(), Optional.empty() 로 Optional 객체를 만들 수 있다.

맵으로 Optional의 값을 추출하고 변환하기

Optional 로 랩한 객체더라도, 값이 없는데 get() 메서드를 통해 값을 가져오려고 하면 에러가 난다. 이럴 경우 map 메서드를 활용하면 된다.

Optional이 값을 포함하면 map의 인수로 제공된 함수가 값을 바꾼다. 비어있다면 아무일도 일어나지 않는다.

Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
Optional<String> name = optInsurance.map(Insurance::getName);

다시 예제로 돌아와서, 아래 코드를 어떻게 Optional 을 활용해서 바꿀 수 있는지 알아보자

public static getCarInsuranceName(Person person) {
    return person.getCar().getInsurance().getName();
}

Person 객체의 내부에 있는 name 을 가져오려면 아래의 값에 접근해야 하며, NPE를 피해야한다.

Person -> Optional -> Optional -> getName

map 메서드를 사용하면 Optional<Optional> 와 같이 한번 더 Optional 객체로 래핑되기 때문에 `flatMap`을 활용해서 평준화를 시킬 것이다.

만약 map 메서드를 이용했다면 아래와 같은 에러가 뜬다.

Optional<String> name = person.map(Person::getCar)
                              .map(Person.Car::getInsurance)
                              .map(Person.Insurance::getName);

reason: no instance(s) of type variable(s) exist so that Optional conforms to Car

스트림의 flatMap은 함수를 인수로 받아서 다른 스트림으로 반환하는 메서드. 값이 존재하면 인수로 제공된 함수를 적용한 결과 Optional을 반환하고, 값이 없으면 빈 Optional을 반환한다.

Optional<String> name = person.flatMap(Person::getCar)
                              .flatMap(Person.Car::getInsurance)
                              .map(Person.Insurance::getName);

이렇게 NPE를 피하기 위해 if-else 문이 아닌, Optional 의 스트림을 활용해서 값을 도출하는 방법을 알아봤다. Optional을 인수로 받거나, 반환하는 메서드를 정의한다면 개발자들은 “아 이 값은 null 일 수 있구나!” 라고 생각할 수 있어야 되겠다 :)

flatMap 에 대해서

flatMap 에 대해 조금 더 노트하고 싶은 내용이 있다. flatMap 은 두 Optional 을 합치는 기능을 수행하면서, 둘 중 하나라도 null 이면 빈 Optional을 생성하는 연산이다.

flatMap 을 빈 Optional에 호출하면 아무 일도 일어나지 않고 그대로 반환된다. 반면 Optional이 Person을 감싸고 있다면, flatMap에 전달된 Function이 Person에 적용된다. Person.Car::getInsurance 라는 Function을 적용한 결과가 이미 Optional 이므로 flatMap 메서드는 결과를 그대로 반환할 수 있다. 반면 마지막에 map 메서드를 쓴 이유는 name 은 이미 String 이기 때문에 flatMap을 쓸 필요가 없어서이다.

Optional 스트림 조작

자바 9에서는 Optional을 포함하는 스트림을 쉽게 처리할 수 있도록 Optional 에 stream() 메서드를 추가했다.

public static Set<String> getCarInsuranceNames(List<Person> persons) {
    return persons.stream()
                  .map(Person::getCar)
                  .map(car -> car.flatMap(Person.Car::getInsurance))
                  .map(ins -> ins.map(Person.Insurance::getName))
                  .flatMap(Optional::stream) // Stream<Optional<String>> dmf Stream<String> 으로 평준화
                  .collect(Collectors.toSet());
}

각각의 name 을 담은 Stream 을 생성하고, Set 으로 변환했다. 하지만 요소들 중에서 빈 Optional 값이 포함되어 있으므로, 이를 필터하기 위해 filter 메서드를 추가해준다.

return persons.stream()
              .map(Person::getCar)
              .map(car -> car.flatMap(Person.Car::getInsurance))
              .map(ins -> ins.map(Person.Insurance::getName))
              .filter(Optional::isPresent) // 값이 있는것만 filter 해준다
              .map(Optional::get) // 값이 있는것만 위에서 필터했으므로, 바로 get으로 가져와도 된다.
              .collect(Collectors.toSet());

두 Optional 합치기

Person 과 Car 정보를 이용해서 가장 저렴한 보험료를 제공하는 보험회사를 찾는 메서드를 작성해야 한다고 가정하자

여기서 Person, Car 둘 중에 하나라도 비어 있으면 빈 Optional 를 반환한다. 또 if 문으로 땜빵할 수 없다.

앞서 배운 flatMap 메서드를 이용하면 언랩하지 않고 구현할 수 있다. 왜냐하면 첫번째 Optional 에 flatMap 을 호출했으므로, 만약 person이 비어 있다면 인수로 전달한 람다 표현식이 실행되지 않고 그대로 빈 Optional 을 반환한다.

두 번째 Optional 인 car에 map을 호출하므로 Optional 이 car 값을 포함하지 않으면 Function은 빈 Optional을 반환한다.

마지막으로 두 Optional 이 모두 값이 존재하면 map 메서드로 전달한 람다 표현식이 findcheapestInsurance 메서드를 안전하게 호출할 수 있다.

public static Optional<Person.Insurance> nullSafeFindCheapestInsurance(Optional<Person> person, Optional<Person.Car> car) {
    return person.flatMap(p -> car.map(c -> findcheapestInsurance(p, c)));
}

public static Person.Insurance findcheapestInsurance(Person p, Person.Car c) {
    return null;
}

filter 로 특정값 거르기

Optoinal 자체에서도 filter 메서드로 값을 필터링할 수 있다.

Optional<Insurance> optInsurance = ...;
optInsurance.filter(insurance -> "CambridgeInsurance".equals(insurance.getName()))
            .ifPresent(x -> System.out.println("ok"));

댓글남기기