11장. null 대신 Optional 클래스

Posted by yunki kim on March 27, 2022

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

1
2
3
public String getCarInsuranceName(Person person) {
    return person.getCar().getInsurance().getName();
}
cs

  위 코드의 경우 만약 getCar 메서드를 통해 가져온 객체가 null이거나 getInsurance의 반환값이 null이라면 NullPointerExceptino이 발생한다. 

보수적인 자세로 NullPointerException 줄이기

  위 코드와 같은 NullPointerException을 줄이기 위해선 다음과 같은 보수적 방식을 사용할 수 있다. 

1
2
3
4
5
6
7
8
9
10
11
12
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 "Unkown";
}
cs

  위 코드의 첫 번쨰 문제는 보험 회사의 이름이 null인지 체크하지 않는다는 것이다. 확실히 알고 있는 영역을 모델링 할 때면 null확인을 생략할 수 있다. 하지만 자바 클래스로 모델링할 때면, 모든 회사가 반드시 이름을 갖는다는 사실을 단정하기 어렵다.

  두 번째 문제는 들여쓰기 수준의 증가다. 확인을 위해 들여쓰기가 증가하는 반복 패턴(recuring pattern) 코드를 '깊은 의심(deep doubt)'이라 한다. 이는 코드 구조를 망치고 가독성을 떨어트린다.

  위 코드를 다음과 같이 수정한다면 메서드의 출구가 여러개라 유지보수가 어려워 진다는 문제가 발생한다. 또 한 같은 매직 스트링이 여러 곳에서 사용된다는 문제도 존재한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public String getCarInsuranceName(Person person) {
    if (person != null) {
        return "Unkown";
    }
    Car car = person.getCar();
    if (car != null) {
        return "Unkown";
    }
    Insurance insurance = car.getInsurance();
    if (insurance != null) {
        return "Unkown";
    }
    return insurance.getName();
}
cs

  따라서 값이 없음을 null이 아닌 다른 무언가로 표현할 방법이 필요하다.

null 때문에 발생하는 문제

  1. 에러의 근원이다: NullPointerException은 자바에서 가장 흔히 발생하는 문제다.

  2. 코드를 어지럽힌다: 중첩된 null확인은 코드 가독성을 떨어트린다.

  3. 아무 의미 없다: null은 아무 의미도 없다. 특히 정적 형식 언어에서 값이 없음을 표현하는 방법으로 적절치 않다.

  4. 자바 철학에 위배된다: 자바는 포인터를 내부에 숨겼다. 하지만 null 포인터는 감추기 못했다.

  5. 형식 시스템에 구명을 만든다: null은 무형식이고 정보를 포함하지 않는다. 따라서 모든 참조 형식에 null을 할당할 수 있다.

      그때문에 null이 여러곳에 할당되기 시작한다면, null을 애초에 어떤 의미로 사용했는지 알지 못한다.

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

  하스켈과 스칼라는 null 문제를 해결하기 위해 선택 형값(optional value)을 사용한다. 스칼라는 T 형식의 값을 갖거나 아무 값도 갖지 않을 수 있는 Option[T]라는 구조를 제공한다. Option을 사용하면 Option이 제공하는 연산을 이용해 값이 있는지를 명시적으로 확인해야 한다.

  자바 8은 이런 선택 형값에 영향을 받아 java.util.Optinal<T>라는 새로운 클래스를 제공한다. 

Optional 클래스 소개

  Optinal은 선택형값을 캡슐화하는 클래스다. Optional 클래스는 값이 있으면 값을 감싸고, 없으면 Optional.empty 메서드로 Optional을 반환한다. Optional.empty는 Optional의 특별한 싱글턴 인스턴스를 반환하는 정적 팩토리 메서드다.

1
2
3
4
5
public static<T> Optional<T> empty() {
    @SuppressWarnings("unchecked")
    Optional<T> t = (Optional<T>) EMPTY;
    return t;
}
cs

  비록 empty 메서드는 싱글톤이지만 싱글톤임을 반드시 보장하지 않는다. 따라서 '=='를 이용한 Optional의 비어있음 여부를 체크하지 말아야 한다. 대신 isPresent 메서드를 사용하자.

  의미론상으로는 Optional<T>.empty()와 null은 비슷하다. 하지만 null을 참조하려하면 NullPointerException이 발생하는 한편, Optional.empty()는 Optional 객체이므로 이를 다양한 방식으로 활용할 수 있다.

  Optional은 값이 없을 수 도 있다는 것을 명시적으로 보여주는 반면, null은 null일 올바른 값인지 아니면 잘못된 값인지 판단할 정보를 제공하지 않는다. 따라서 아래와 같이 null의 가능성이 있는 값은 Optional을 사용해 명시적으로 값이 없을 수 있음을 명시하고, 아니면 다른 타입을 명시해 값이 반드시 존재해야 함을 강제할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Car {
 
    private Optional<Insurance> insurance;
 
    public Optional<Insurance> getInsurance() {
        return insurance;
    }
}
 
public class Insurance {
 
    private String name;
 
    public String getNames() {
        return name;
    }
}
 
cs

  그럼에도 모든 null 참조를 Optional로 대체하는 것은 적절치 않다. Optional의 역할은 더 이해하기 쉬운 API를 설계하게 하는 것이다. 즉, 메서드의 시그니처만 보고도 선택형값인지 여부를 구별할 수 있다. Optional의 등장 덕분에 이를 언랩해 값이 없을 수 있는 상황에 적절히 대응하게 강제하는 효과를 얻었다.

Optional 적용 패턴

Optinal 객체 만들기

  다음과 같은 다양한 방식으로 Optional 객체를 만들 수 있다.

빈 Optional

 

  Optional.empty 메서드로 빈 Optional 객체를 얻을 수 있다.

1
2
Optional<Car> optCar = Optional.empty();
 
cs

null이 아닌 값으로 Optional 만들기

  정적 팩토리 메서드인 Optional.of로 null이 아닌 값을 포함하는 Optional을 만들 수 있다. 이 때, of의 인자로 넘긴 값이 null이면 NullPointerException이 발생한다.

 

1
2
Optional<Car> optCar = Optional.of(car);
 
cs

  Optional.of 메서드는 다음과 같이 동작한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static <T> Optional<T> of(T value) {
    return new Optional<>(value);
}
 
private Optional(T value) {
    this.value = Objects.requireNonNull(value);
}
 
public final class Objects {
    ...
 
    public static <T> T requireNonNull(T obj) {
        if (obj == null)
            throw new NullPointerException();
        return obj;
    }
 
    ...
}
 
cs

null 값으로 Optional 만들기

  정적 팩토리 메서드인 Optional.ofNullable를 사용하면 null 값을 저장할 수 있는 Optional을 만들 수 있다. 만약 ofNullable의 인자가 null이면 빈 Optional 객체를 반환한다.

1
2
Optional<Car> optCar = Optional.ofNullable(car);
 
cs

  Optional.ofNullable 메서드 내부는 다음과 같다.

1
2
3
public static <T> Optional<T> ofNullable(T value) {
    return value == null ? empty() : of(value);
}
cs

  이제 Optional의 값을 가져오는 방법을 살펴보자

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

  Optional은 map 메서드를 제공한다. 이 map은 스트림에서의 map과 같은 기능을 한다. 따라서 다음과 같이 Insurance 내의 이름을 가져올 수 있다.

1
2
3
4
Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
Optional<String> name = optInsurance.map(Insurance::getName);
 
 
cs

  Optional의 map은 map의 인수로 제공된 함수가 값을 바꾼다. 만약 비어있다면 아무 일도 발생하지 않는다.

flatMap으로 Optional 객체 연결

  다음과 같은 코드가 있다고 해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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 getNames() {
        return name;
    }
}
 
Optional<Person> optPerson = Optional.of(person);
Optional<String> name = optPerson.map(Person::getCar)
    .map(Car::getInsurance)
    .map(Insurance::getName);
cs

  이 코드는 컴파일 되지 않는다. 그 이유는 getCar 메서드가 Optional<Car>를 반환하기 때문에 첫 번째 map의 연산이 Optional<Optional<Car>>가 되기 때문이다. 이를 해결하기 위해선 flatMap을 사용하면 된다. Optional의 flatMap 역시 stream에서의 flatMap과 비슷한 역할을 한다. 즉, Optional의 flatMap은 이차원 Optional을 일차원 Optional로 변환해준다.

1
2
3
4
Optional<String> name = optPerson.flatMap(Person::getCar)
    .flatMap(Car::getInsurance)
    .map(Insurance::getName)
    .orElse("Unknown");
cs

  위 예제에서 볼 수 있듯이 Optional을 사용해 null 확인을 위한 조건 분기를 없앴다. 그때문에 간결하면서 이해하기 쉬운 코드가 완성되었다.

  Optional을 사용하면 도메인 모델과 관련한 암묵적인 지식에 의존하지 않고 명시적으로 형식 시스템을 정의할 수 있다. Optional을 메서드에 사용한다면, 이 메서드가 빈 값을 받거나 빈 결과를 반환할 수 있다는 것을 문서화해 제공할 수 있다.

도메인 모델에 Optional을 사용했을 때 데이터를 직렬화할 수 없는 이유

  위 예제에서 볼 수 있듯이 Optional을 사용해 도메일 모델에서 값이 꼭 존재하는지의 여부를 구체적으로 표현할 수 있다. 하지만 Optional의 본래 목적은 선택형 반환값의 지원이였다. 따라서 필드 형식의 사용 목적이 아니였으므로 Serializeable 인터페이스를 구현하지 않는다. 그럼에도 객체 그래프 에서 객체가 null일 수 있다면, 사용하는 것이 바람직하다. 만약 직렬화 모델이 필요하면 다음과 같이 Optional로 값을 반환받을 쑤 있는 메서드를 추가하는 것이 좋다.

1
2
3
4
5
6
7
public class Person {
    private Car car;
    
    pubilc Optional<Car> getCarAsOptional() {
        return Optional.ofNullable(car);
    }
}
cs

Optinal 스트림 조작

  자바 9부터 Optional을 포함하는 스트림을 쉽게 처리할 수 있게 Optional에 stream 메서드가 추가되었다. 이는 Optional 스트림을 값을 가진 스트림으로 변환할 때 사용된다. Optional에 정의된 stream 메서드는 다음과 같다.

1
2
3
4
5
6
7
public Stream<T> stream() {
    if (!isPresent()) {
        return Stream.empty();
    } else {
        return Stream.of(value);
    }
}
cs

  이는 다음과 같이 사용할 수 있다.

1
2
3
4
5
6
7
8
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)) // Stream<Optional<String>>
        .flatMap(Optional::stream) // Stream<String>
        .collect(toSet());
}
cs

디폴트 액션과 Optional 언랩

  Optional 클래스는 다음과 같은, 인스턴스에 포함된 값을 읽는 다양한 방법을 제공한다.

get()

  값을 읽는 가장 간단한 메서드면서 가장 안전하지 않는 메서드이다. get은 값이 없으면 NoSuchElementException을 발생시킨다. 따라서 get의 사용은 중첩된 null 확인 코드를 사용하는 것과 크게 다르지 않다.

orElse(T other)

  Optional이 값을 포함하지 않을 때 기본값을 제공한다. 기본값은 인자로 넘긴다.

orElseGet(Supplier<? extends T> other)

  orElse 메서드에 대응하는 게으른 버전의 메서드다. Optional에 값이 없을 때만 Supplier가 실행된다. 디폴트 메서드를 만드는 데 시간이 걸리거나 Optional이 비어 있을 때만 기본값을 생성하고 싶다면 이 메서드를 사용해야 한다.

orElseThrow(Supplier<? extends X> exceptionSupplier)

  Optional이 비어있을 때 예외를 발생시킨다. 하지만 이 예외는 get 메서드 처럼 정해진 것이 아닌, 원하는 종류를 선택할 수 있다.

ifPresent(Consumere<? super T> consumer)

  값이 존재할 때 인수로 넘긴 동작을 실행하고, 없으면 아무 동작도 수행하지 않는다.

ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction)

  Optional이 비었을 떄 실행할 수 있는 Runnable을 인수로 받는다. 나머지 기능은 ifPresent 메서드와 같아.

두 Optional 합치기

필터로 특정값 거르기

    객체의 메서드를 호출해 어떤 프로퍼티를 확인해야 한다고 해보자. 이 때 isPresent 메서드를 활용해 분기 처리를 하면 null 사용과 크게 다르지 않다. 따라서 Optional에서 제공하는 filter 메서드를 사용하는 것이 적절하다. filter 메서드는 stream에서의 filter 처럼 특정 조건에 만족하는 지를 검사한다. 단, Optional이 비어 있으면 아무런 동작도 하지 않는다. Optional에 값이 있고 프리디케이트를 적용 시켰을 때, 적용 결과가 true면 Optional에 변화가 발생하지 않는다, false면 값은 사라지고 빈 Optional이 된다.

Optional을 사용한 실용 예제

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

  기존 자바 API에서는 null을 반환해 요청한 값이 없거나 어떤 문제로 계산에 실패했음을 알린다. Map의 get 메서드가 그 예시다. 이런 상황에서 Optional을 사용하면 조건 분기를 할 필요가 없어진다.

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

예외와 Optional 클래스

  일부 자바 API는 값을 제공할 수 없을 때 null 대신 예외를 발생시킨다. 이 때 예외를 발생시키는 메서드를 try/catch로 감싸고 catch에서 빈 Optional을 반환하는 메서드를 만들면 기존처럼 거추장 스럽게 try/catch를 사용하지 않아도 된다.

1
2
3
4
5
6
7
public static Optional<Integer> stringToInt(String s) {
    try {
        return Optional.of(Integer.parseInt(s));
    } catch (NumberFormatException e) {
        return Optional.empty();
    }
}
cs

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

  스트림과 같이 Optional도 OptionalInt 같은 기본형 특화 Optional을 제공한다. 하지만 Optional은 최대 요소 수가 하나 이므로 기본 특화 사용을 통해 성능을 개선할 수 없다. 또 한, 기본형 특화 Optional은 map, filter 등을 지원하지 않는다. 게다가 기본형 특과 Optional은 일반 Optional과 혼용할 수 없다. 따라서 기본형 특화 Optional의 사용을 권장하지 않는다.

 

출처 - 모던 자바 인 액션