본문 바로가기
Dev Log/Java

Modern Java In Action 정리 - 11 null 대신 Optional 클래스

by 삽질하는큐 2020. 6. 27.
I call it my billion-dollar mistake. It was the invention of the null reference in 1965. At that time, I was designing the first comprehensive type system for references in an object oriented language(ALGOL W). My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn't resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.
- Tony Hoare

 

퀵소트로 유명한 토니 호어는 자신이 null을 개발하고 billion dollar mistake라고 말했다. 자바를 포함한 대부분의 언어 설계에서 null 참조 개념을 포함하고 있다.

 

 

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

다음과 같아 Person > Car > Insurance 형태로 다른 객체를 포함하고 있는 설계 상태일 때 person.getCar()에서 null이라면 getInsurance()를 호출하는 시점에 NullPointerException이 발생하게 된다.

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();
}

 

깊은 의심 Deep Doubt

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";
}

 

NullPointerException을 해결하기 위해서 매번 null을 체킹하는 반복 패턴이 있을 수 있는데 코드 들여쓰기 수준이 증가하고 가독성이 떨어지게 된다.

 

public String getCarInsuranceName2(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();
}

이번에는 indent의 중첩이 조금 덜한 감은 있지만 출구가 많다는 점에서 역시 좋은 코드라고 볼 수 없다.

 

 

null은 여러가지로 골칫거리다

  • 에러의 근원이면서
  • 코드를 어지럽히고
  • 딱히 그렇다고 의미하는 바도 없고
  • 자바가 포인터를 숨긴 철학에 위배되고
  • 형식 시스템에 구멍을 만든다

 

그루비의 경우에는 안전 내비게이션 연산자 "?."를 도입해서 null 문제를 해결했다. 이 체인에서 하나라도 null이라면 다음 단계로 접근하지 못하고 null이 반환된다.

def carInsuranceName = person?.car?.insurance?.name

 

Optional 클래스

자바 8에서는 하스켈의 Maybe, 스칼라의 Option[T] 등과 같은 선택형 값을 저장할 수 있는 클래스 java.util.Optional<T>를 제공한다.

값이 있으면 Optional 클래스는 값을 감싸는 형태이고 값이 없으면 Optional.empty()로 Optional을 반환한다.

왼쪽은 Car 객체를 포함하는 Optional, 오른쪽은 빈 Optional

 

객체의 값이 null이라면 NullPointerException이 발생하기 때문에 아무것도 할 수가 없지만, Optional이라면 이를 다양한 방식으로 활용할 수 있다.

 

기존의 코드에서 Optional으로 객체를 감싸보자.

public class Person {
    private Optional<Car> car;

    public Optional<Car> getCar() {
        return car;
    }
}

public class Car {
    private Optional<Insurance> insurance;

    public Optional<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();
}

 

Person이 Car를 소유할 수도 있지만 그렇지 않을 수도 있다는 의미를 선언적으로 보여주고 있다. 보험회사가 이름을 필수로 가져야 하는 것은 유지된다.

 

Optional 적용 패턴

객체 생성

Optional.of()에 null을 넣게 되면 NullPointerException이 발생한다.

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

// 값 넣기
Car car = new Car();
Optional<Car> optCarWithInstance = Optional.of(car);
Optional<Car> optWithNull = Optional.of(null); // NPE 발생!

 

 

ofNullable으로 만들면 null을 넣을수도 있다.

Optional<Car> optNullable = Optional.ofNullable(car);
Optional<Car> optNullable2 = Optional.ofNullable(null); // returns Optional.empty();

 

값 추출

값을 가져오려면 .get()을 호출하는데 empty()일 때 호출하면 또 NoSuchElementException이 발생한다.

Optional은 map 메서드를 지원하는데 스트림의 map과는 개념적으로 비슷하나 갯수가 하나라는 것이 다르다.

스트림에서는 하나씩 element를 다른 것으로 변경한 것들의 스트림을 반환한다면, Optional에서는 Optional의 껍데기는 유지한 채로 내용만 매핑되는 방식이다.

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

 

만약 이와 같은 코드를 통해서 보험회사 이름을 가져오려고 한다면

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

두번째 라인에서 인수가 Optional<Car> 객체이기 때문에 컴파일되지 않는다.

 

이럴 때는 flagMap을 이용할 수 있다.

그림에서 보듯이 flatMap 연산부분에서 사각형이 "두 개의 삼각형의 Stream"을 변환한다면, 결과적으로 삼각형이 두 개씩 있는 두 개의 Stream의 Stream인 이차원 Stream이 될 것인데, flatMap은 이를 콘텐츠만 빼서 4개의 삼각형의 일차원 Stream으로 바꾼다.

 

마찬가지로 사각형이 "삼각형을 감싸고 있는 Optional"을 변환한다면, 결과적으로 이차원의 Optional이 되겠지만, flatMap 메서드 덕분에 일차원 Optional으로 바뀌게 된다.

 

이러한 flatMap 메서드를 이용하면 다음과 같이 깔끔하게 처리할 수 있다. orElse라는 메서드는 기본값을 말그대로 Optional.empty()일 때 기본값을 반환하도록 한다.

public String getCarInsuranceName(Optional<Person> person) {
    return person.flatMap(Person::getCar)
            .flatMap(Car::getInsurance)
            .map(Insurance::getName)
            .orElse("Unknown");
}

 

Optional 스트림 조작

java 9에서는 Optional을 포함하는 스트림을 처리할 수 있도록 Optional.stream() 메서드가 추가되었다. 차가 없는 사람이나 보험이 안 되어있는 차까지 고려해서 사람들이 가입되어있는 모든 보험회사 이름들을 반환하는 함수이다.

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

 

 

필터로 특정값 거르기

만약에 Person의 나이에 따라서 값을 거르고 싶다면 filter를 쓸 수 있다. Stream이랑 개념은 다를 바 없는 것 같다.

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");
}

 

예외와 Optional 클래스

String -> Integer로 파싱할 때처럼 간단한 로직이더라도 NumberFormatException을 try/catch 블록으로 감싸는데, 이 책에서는 OptionalUtility를 만들어서 대체하는 것을 권장한다. 사실, 기존에도 Optional이 없을 뿐이지 0을 리턴하거나 하는 유틸 클래스가 있었을 텐데 드라마틱한 차이는 느껴지지 않는다.

public static Optional<Integer> stringToInt(String s) {
    try {
        return Optional.of(Integer.parseInt(s));
    } catch (NumberFormatException ex) {
        return Optional.empty();
    }
}

 

기본형 Optional을 사용하지 말자

Stream의 경우에는 IntStream등과 같은 것을 썼을 때 성능을 향상시킬 수 있는데, OptionalInt와 같은 것은 어차피 하나만 요소를 가진다. 그래서 성능적으로 이점이 없는데, 기본형을 쓰게 되면 map, flatMap, filter등을 지원하지 않는다.

 

 

 

* 도메인 모델에서는 필드 형식으로 사용할 것을 가정하지 않았기 때문에 Serializable 인터페이스를 구현하지 않는다. 따라서 도메인 모델에서 Optional을 사용하려면 필드 자체는 클래스 타입이고 Optional<클래스>를 반환받는 get메소드를 추가하는 것이 좋다.

public class Person {
    private Car car;
    public Optional<Car> getCarAsOptional() {
    	return Optional.ofNullable(car);
    }
}