CHAP11 - Modern-Java-in-Action/Online-Study GitHub Wiki

11장 null 대신 Optional 클래스

이 장의 내용

  • null 참조의 문제점과 null을 멀리해야 하는 이유
  • null 대신 Optional : nuII로 부터 안전한 도메인 모델 재구현하기
  • Optional 활용 : null 확인 코드 제거하기
  • Optional에 저장된 값을 확인하는 방법
  • 값이 없을 수도 있는 상황을 고려하는 프로그래밍

팩트

  • 널포인터 에러 너무 많이 겪었다.
  • null을 만든 사람도 자바에서 null 포인터를 만든 것은 실수라고 말한다.

null 때문에 어떤 문제가 발생할 수 있는지 간단한 예제로 살펴보자.

11.1 값이 없는 상황을 어떻게 처리할까?

// Person -> Car -> Insurance
public class Person {
    private Car car;
    public Car getCar() { return car; }
}
public class Car {
    private Insurance insurance;
    public Insurance getInsurance() { return insurance; }
}
public class Insurance {
    private String name;
    public String getName() { return name; }
}
// -----------------------------------
// 다음과 같이 자동차 보험 이름을 가져오려고 했을때,
// 무슨 문제가 있을까?
public String getCarInsuranceName(Person person) {
    return person.getCar().getInsurance().getName();
}

보수적인 자세로 NullPointerException 줄이기

  • deep deoubt (깊은 의심): 아래 if문 반복된 패턴
// 시도 1
// 하나라도 null 참조가 있으면 "Unknown" 리턴 
public String getCarInsuranceName(Person person) {
    if (person != null) {
        Car car = person.getCar();
        if (car != null) {
            Insurance insurance = car.getInsurance();
            if (insurance != null) {
                return insurance.getName();
            }
        }
    }
    return "Unknown";
}

// ---------------------------------------
// 시도 2
public String getCarInsuranceName(Person person) {
    if (person == null) {
        return "Unknown";
    }
    Car car = person.getCar();
    if (car == null) {
        return "Unknown";
    }
    Insurance insurance = car.getInsurance();
    if (insurance == null) {
        return "Unknown";
    }
    return insurance.getName();
}

짧게말해, null로 값이 없다는 사실을 표현하는 것은 좋은 방법이 아니다.

다른 언어는 null 대신 무얼 사용하나?

  • 다른 언어 null 문제 해결 예시
    • 하스켈: Maybe
    • 스칼라: Option[T]
    • 그루비: 안전 내비게이션 연산자(?.)
// 그루비 예시
def carInsuranceName = person?.car?.insurance?.name

11.2 Optional 클래스 소개

  • 자바에선, java.util.Optional
  • Optional 클래스가 객체가 있든 null이든 감싸준다.
// BEFORE
public class Person {
    private Car car;
    public Car getCar() { return car; }
}
public class Car {
    private Insurance insurance;
    public Insurance getInsurance() { return insurance; }
}
public class Insurance {
    private String name;
    public String getName() { return name; }
}
// ---------------------------------------------------------
// AFTER
public class Person2 {
    private Optional<Car> car; 
    public Optional<Car> getCar() { return car; }
}
public class Car2 {
    private Optional<Insurance> insurance;
    public Optional<Insurance> getInsurance() { return insurance; }
}
public class Insurance2 {
    private String name;
    public String getName() { return name; } // 보험 객체 존재시 이름이 반드시 있어야함으로, 리턴형이 Optional이 아니다.
}

모든 null 참조를 Optional로 대치하는 것 은 바람직하지 않다. Optional의 역할은 더 이해하기 쉬운 API를 설계하도록 돕는 것이다. 즉, 메서드의 시그니처만 보고도 선택형값인지 여부를 구별할 수 있다.

11.3 Optional 적용 패턴

Optional 객체 만들기

Optional로 감싼 값을 실제로 어떻게 사용할 수 있을까? 일단, Optional 객체를 만들어보자.

// 1 빈 Optional 객체 만들기
Optional<Car2> optCar1 = Optional.empty();

// 2 null이 아닌 값으로 Optional 만들기
// null 이 아님을 확실할 경우 
Optional<Car2> optCar2 = Optional.of(car);

// 3 null값으로 Optional 만들기
Optional<Ca2r> optCar3 = Optional.ofNullable(car);

주의: get() 을 이용하여 Optional의 값을 가져올 수 있는데, Optional이 비어있으면 호출시 예외가 발생한다. 즉, Optional 사용시 결국 null을 사용했을 때 와 같은 문제를 겪을 수 있다.

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

// BEFORE
String name = null;
if(insurance != null){
    name = insurance.getName();
}

// AFTER
// 보험 객체를 Optional로 만들고, 
// Optional로 감싸진 객체의 getName을 실행한다. 
// 보험 객체가 널이면 name에는 Optional empty가 들어가고 
// 값이 있을 경우 name에는 값이 들어간다. 
Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
Optional<String> name = optInsurance.map(Insurance::getName);
// ------------------------------------
// 보험 이름: aaaa로
Insurance insurance1 = new Insurance("aaaa"); 
Optional<Insurance> optInsurance1 = Optional.ofNullable(insurance1);
Optional<String> 보험1 = optInsurance1.map(Insurance::getName);

// // 보험 이름: 지정 x
Insurance insurance2 = new Insurance();
Optional<Insurance> optInsurance2 = Optional.ofNullable(insurance2);
Optional<String> 보험2 = optInsurance2.map(Insurance::getName);

System.out.println(보험1); // 결과: Optional[aaaa]
System.out.println(보험2); // 결과: Optional.empty

flatMap으로 Optional 객체 연결

  • 스트림의 flatMap 메서드와 Optional의 flatMap 메서드는 유사하다.
  • map과 flatmap 시그니처 차이

![image-20220123111801353](D:\0 Google Drive\03 스터디 모임\모던자바\5주차\11장.assets\image-20220123111801353.png)

![image-20220123111743582](D:\0 Google Drive\03 스터디 모임\모던자바\5주차\11장.assets\image-20220123111743582.png)

// 컴파일 에러 예시
// map -> map -> map 을 호출하는데, Optional<Optional<Optional<Car>>> 이렇게 중첩된다
Optional<Person2> optPerson = Optional.of(person2);
Optional<String> name =
    optPerson.map(Person2::getCar)
    .map(Car2::getInsurance)
    .map(Insurance2::getName);

// 스트림의 flatMap과 비슷하게, 옵셔널에서 flatMap 사용하면 중첩이 풀리면서 일차원으로 만들어진다. 

Optional로 자동차의 보험회사 이름 찾기

// null 체크하는 if문 없이 코드 간결
public String getCarInsuranceName(Optional<Person> person) {
    return person.flatMap(Person::getCar) //  Optional을 이용한 참조 체인
        .flatMap(Car::getInsurance)
        .map(Insurance::getName)
        .orElse("Unknown"); //  값이 없다면
}
// ------------------------------------------
// 실행 예시
// 차 없는 사람 생성 
Person2 person = new Person2(Optional.empty()); 
Optional<Person2> optPerson = Optional.of(person);
System.out.println(getCarInsuranceName3(optPerson)); 
// 결과: Unknown
  • 호출의 논리적 과정
    • 1단계: Person에 Function을 적용 (getCar 메서드가 펑션)
      • flatmap 사용 이유: Optional이 계속 중첩됨으로 평준화 한다.
        • 평준화 과정: 두 Optional을 합치는 기능을 수행하면서 둘 중 하나라도 null이면 빈 Optional을 생성
    • 2단계: 1단계와 동일하게 작동하고, Optional 리턴
    • 3단계: 스트링 반환하므로, flatmap 사용 필요 x

도메인 모델에 Optional을 사용했을 때 데이터를 직렬화할 수 없지만, 이런 단점에도 저자 Optional을 사용해서 도메인 모델을 구성하는 것이 바람직하다고 말한다.

Optional 스트림 조작

  • 자바9, Optional에 stream() 메서드를 추가
  • flatMap(Optional::stream): Stream<Optional>을 현재 이름을 포함하는 Stream으로 변환
// 사람들이 가입한 보험회사 이름들을 리턴
 public static Set<String> getCarInsuranceNames(List<Person2> persons) {
     return persons.stream()
         .map(Person2::getCar) // 어떤 사람은 자동차를 가지지 않을 수도 있다.
         .map(optCar -> optCar.flatMap(Car2::getInsurance))
         .map(optIns -> optIns.map(Insurance2::getName))
         .flatMap(Optional::stream)
         .collect(Collectors.toSet()); // 중복 제거 기능도
 }

// ------------------------------------------------
System.out.println(getCarInsuranceNames(personList)); // [a, b, c]

// 총 7명 중에, 차 있는 4명 중 1명만 빼고 보험을 들었고 (보험명: a,b,c), 다른 3명은 차가 없다.
 static List<Person2> personList = List.of(
     new Person2(Optional.of(new Car2(Optional.of(new Insurance2("a"))))),
     new Person2(Optional.of(new Car2(Optional.of(new Insurance2("b"))))),
     new Person2(Optional.of(new Car2(Optional.of(new Insurance2("c"))))),
     new Person2(Optional.of(new Car2(Optional.empty())))),
     new Person2(Optional.empty()),
     new Person2(Optional.empty()),
     new Person2(Optional.empty())
 );

Optional에서 stream 메서드를 제공하지 않았다면, Optional의 isPresent 메서드를 사용해 가며, 직접 처리해줘야하는 번거로움이 있었을것이다. (옵셔널에 스트림 생겨서 좋다!)

디폴트 액션과 Optional 언랩

  • Optional unwrap 방법들
    • orElse("디폴트값"): 값이 없을때 기본값 제공.
    • get(): 값 바로 제공. 값이 없다면, NoSuchElementException. 그러니 조심 사용.
    • orElseGet(): 값이 없을때만, Supplier가 실행. Optional이 비어있을 때만 기본값을 생성하고 싶을때 사용.
    • orElseThrow(): get과 비슷
    • ifPresent(): 값이 존재할 때 인수로 넘겨준 동작을 실행. 없으면 실행 x.
    • ifPresentOrElse(): Optional이 비었을 때 실행할 수 있는 Runnable을 인수로 받는다는 점만 ifPresent와 다르다.
// ifPresentOrElse 예시
Optional<Integer> op = Optional.empty();
try {
    op.ifPresentOrElse(
        (value) -> {
            System.out.println( "Value is present, its: " + value);
        },
        () -> {
            System.out.println("Value is empty");
        });
} catch (Exception e) {
    System.out.println(e);
}
// 결과: Value is empty

두 Optional 합치기

복잡한 비즈니스 로직 가정: 가장 보험료 저렴한 회사 찾기

// 사람에 따라 보험료 다를테니,
// 가장 저렴한 보험료 찾기
public Insurance findCheapestInsurance(Person person, Car car) {
    // 로직: 다양한 보험회사가 제공하는 서비스 조회 후 모든 결과 데이터 비교
    return cheapestCompany;
}

Optional를 반환하는 버전을 구현해야 할때

// 아래 구현 코드는 null 확인 코드와 크게 다른 점이 없다.
// 퀴즈: 개선해 보자
public Optional<Insurance> nullSafeFindCheapestInsurance (
 	   Optional<Person> person, Optional<Car> car) {
    if (person.isPresent() && car.isPresent()) {
        return Optional.of(findCheapestInsurance(person.get(), car.get()));
    } else {
        return Optional.empty();
    }
}

퀴즈

Optional 언랩하지 않고 두 Optional 합치기

정답

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

필터링

객체의 메서드 호출해서 어떤 프로퍼티를 확인해야 할 때가 있다.

// 보험회사 이름 CambridgeInsurance인지 확인 
Insurance insurance = ...;
if(insurance != null && "CambridgeInsurance".equals(insurance.getName())){
    System.out.println("ok");
}

filter 사용: 조건에 맞으면 값을 반환하고 그렇지 않으면 빈 Optional 객체를 반환

// Optional으로 재구현 
Optional<Insurance> optInsurance = ...;
optInsurance.filter(insurance ->
                    "CambridgeInsurance".equals(insurance.getName()))
    .ifPresent(x -> System.out.println("ok"));]

퀴즈

Optional filtering

특정 나이 이상 되는 사람들이 가입한 보험을 찾아와 보자. (minAge 이상 일때만)

public String getCarInsuranceName(Optional<Person> person, int minAge)

정답

public String getCarInsuranceName(Optional<Person> person, int minAge) {
    return person.filter(p -> p.getAge() >= minAge)
        .flatMap(Person::getCar)
        .flatMap(Car::getInsurance)
        .map(Insurance::getName)
        .orElse("Unknown");
}

11.4 Optional을 사용한 실용 예제

기존의 코드를 재구현하는데 도움이 되는 다양한 기법을 확인해보자.

잠재적으로 null이 될 수 있는 대상을 Optional로 감싸기

Object value = map.get("key");
// ------------------------------------
// get의 시그니처를 고칠 순 없지만, 받은값을 감쌀 수 있다. 
Optional<Object> value = Optional.ofNullable(map.get("key"));

예외와 Optional 클래스

  • Integer.parseInt()는 값을 제공할 수 없을 때 null을 반환하는 대신 예외를 발생시킨다.
    • e.g. Integer.parseInt("hello") -> NumberFormatException 에러
// 이와 같은 유틸리티 클래스르 많이 만들자!
public static Optional<Integer> stringToInt(String s) {
    try {
        return Optional.of(Integer.parseInt(s));
    } catch (NumberFormatException e) {
        return Optional.empty();
    }
}

// stringToInt("hello") -> 에러 나지 않는다. 옵셔널의 empty를 받는다.

기본형 Optional을 사용하지 말아야 하는 이유

  • Optionallnt, OptionalLong, OptionalDouble은 사용 자제하기.
    • map, flatMap,filter 등을 지원하지 않는다.
    • 기본형은 기존 Optional 혼용 사용 불가
      • 예, OptionalInt를 반환한다면 이를 다른 Optional 의 flatMap에 메서드 참조로 전달할 수 없다.

응용

실제 업무에서 어떻게 사용하는지 보자.

// 프로그램 설정 인수로 프로퍼티 전달 예제 
// 키를 주고 안에 값이 양수인것만 값을 가져오자. otherwise 0
@Test
public void testMap() {
    Properties props = new Properties();
    props.setProperty("a", "5");
    props.setProperty("b", "true");
    props.setProperty("c", "-3");

    assertEquals(5, readDurationImperative(props, "a"));
    assertEquals(0, readDurationImperative(props, "b"));
    assertEquals(0, readDurationImperative(props, "c"));
    assertEquals(0, readDurationImperative(props, "d"));
}

public static int readDurationImperative(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;
}

퀴즈

유틸리티 메서드를 이용해, 위 코드를 개선해보자.

Optional로 프로퍼티에서 지속 시간 읽기

@Test
public void testMap() {
    Properties props = new Properties();
    props.setProperty("a", "5");
    props.setProperty("b", "true");
    props.setProperty("c", "-3");

    assertEquals(5, readDurationWithOptional(props, "a"));
    assertEquals(0, readDurationWithOptional(props, "b"));
    assertEquals(0, readDurationWithOptional(props, "c"));
    assertEquals(0, readDurationWithOptional(props, "d"));
}

public static int readDurationWithOptional(Properties props, String name) {
    return Optional.ofNullable(props.getProperty(name))
        .flatMap(OptionalUtility::stringToInt)
        .filter(i -> i > 0)
        .orElse(0);
}
⚠️ **GitHub.com Fallback** ⚠️