Chap11. null 대신 Optional

728x90

Null Pointer Exception (이제부터는 NPE라고 부르겠다.)은 자바 개발자라면 한번쯤은(

한번만 마주쳤겠는가?

) 마주쳤을 예외이다. NPE는 모든 자바 개발자를 괴롭히는 예외긴 하지만 null이라는 표현을 사용하면서 치러야 할 당연한 대가가 아닐까?

명령형 프로그래밍 세계라면 이러한 의견이 당연한것처럼 들릴 수도 있다. 하지만 거시적인 프로그래밍 관점에서 null을 다르게 접근한다.

최초로 null을 도입한 토니 호어는 null 및 예외를 만든 결정을 가리켜 '십억 달러짜리 실수'라고 표현했다.

자바를 포함해서 최근 수십 년간 탄생한 대부분의 언어 설계에는 null 참조 개념을 포함한다. 예전 언어와 호환성을 유지하려는 목적도 있었겠지만 호어가 말한 것처럼 '구현하기 쉬웠기 때문에' null 참조 개념을 포함했을 것이다.

보수적인 자세로 NullPointerException 줄이기

대부분의 프로그래머는 필요한 곳에 다양한 null 확인 코드를 추가해서 null 예외 문제를 해결하려 할 것이다.

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

위 코드에서는 변수를 참조할 때마다 null을 확인하며 중간 과정에서 하나라도 null 참조가 있으면 "Unknown"이라는 문자열을 반환한다. 우리가 확실히 알고 있는 영역을 모델링할 때는 이런 지식을 활용해서 null 확인을 생략할 수 있지만, 데이터를 자바 클래스로 모델링할 때는 이 같은 사실을 단정하기가 어렵다.

위의 예제는 메서드에서 모든 변수가 null인지 의심하므로 변수를 접근할 때마다 중첩된 if가 추가되면서 코드 들여쓰기 수준이 증가한다. 따라서 이와 같은 반복 패턴 코드를 '깊은 의심'이라고 부른다. 즉, 변수가 null인지 의심되어 중첩 if 블록을 추가하면 코드 들여쓰기 수준이 증가한다. 이를 반복하다보면 코드의 구조가 엉망이 되고 가독성도 떨어진다. 다른 해결방법이 필요하다. 다음 예제는 다른 방법으로 이 문제를 해결하는 코드다.

public String getCarInsuranceName(Person person) {
    if (person == null) {
        return "Unknown";
    }

    Car car = person.getCar();
    if (car == null) {
        return "Unknown";
    }

    Insurance insurance = car.getInstance();
    if (insurance == null) {
        return "Unknown";
    }
    return insurance.getName();
}

위 코드는 조금 다른 방법으로 중첩 if 블록을 없앴다. 즉, null 변수가 있으면 즉시 "Unknown" 을 반환한다. 하지만 이 예제도 그렇게 좋은 코드는 아니다. 메서드에 네 개의 출구가 생겼기 때문이다. 출구 때문에 유지보수가 어려워진다. 앞의 코드는 쉽게 에러를 일으킬 수 있다. 따라서 값이 있거나 없음을 표현할 수 있는 좋은 방법이 필요하다.

null 때문에 발생하는 문제

  • 에러의 근원이다.
  • 코드를 어지럽힌다.
  • 아무 의미가 없다.
  • 자바 철학에 위배된다.
    자바는 개발자로부터 모든 포인터를 숨겼다. 하지만 예외가 있는데 그것이 바로 null 포인터이다.
  • 형식 시스템에 구멍을 만든다.
    null은 무형식이며 정보를 포함하고 있지 않으므로 모든 참조 형식에 null을 할당할 수 있다. 이런식으로 null이 할당되기 시작하면서 시스템의 다른 부분으로 null이 퍼졌을 때 애초에 null이 어떤 의미로 사용되었는지 알 수 없다.

다른 프로그래밍 언어에서는 null 참조를 어떻게 해결하는지 살펴보면서 null 참조 문제 해결 방법의 실마리를 찾아보자.

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

그루비에서는 안전 내비게이션 연산자(?.)를 도입해서 null문제를 해결했다.

하스켈, 스칼라 등의 함수형 언어는 아예 다른 관점에서 null 문제를 접근한다. 하스켈은 선택형값(Optional value)을 저장할 수 있는 Maybe라는 형식을 제공한다. Maybe는 주어진 형식의 값을 갖거나 아니면 아무 값도 갖지 않을 수 있다. 따라서 null 참조 개념은 자연스럽게 사라진다. 스칼라도 T 형식의 값을 갖거나 아무 값도 갖지 않을 수 있는 Option[T]라는 구조를 제공한다. 그리고 Option 형식에서 제공하는 연산을 사용해서 값이 있는지 여부를 명시적으로 확인해야 한다. 형식 시스템에서 이를 강제하므로 null과 관련한 문제가 일어날 가능성이 줄어든다.

자바 8은 '선택형 값' 개념의 영향ㅇ을 받아서 java.util.Optional<T>라는 새로운 클래스를 제공한다. 이 장에서는 java.util.Optioanl<T>를 이용해서 값이 없는 상황을 모델링하는 방법을 설명한다. 또한 null을 Optional로 바꿀 때 우리 도메인 모델에서 선택형 값에 접근하고, 새로운 기능을 효과적으로 사용하는 방법을 보여주는 몇 가지 예제를 소개한다. 궁극적으로 더 좋은 API를 설계하는 방법을 취득하게 된다. 즉, 우리 API 사용자는 메서드의 시그니처만 보고도 선택형값을 기대해야 하는지 판단할 수 있다.

Optional 클래스 소개

자바 8은 하스켈, 스칼라의 영향을 받아 java.util.Optional<T> 라는 새로운 클래스를 제공한다. Optioanl은 선택형값을 캡슐화하는 클래스다.

값이 있으면 Optioanl 클래스는 값을 감싼다 반면 값이 없으면 Optioanl.empty 메서드로 Optional을 반환한다. Optional.empty는 Optional의 특별한 싱글턴 인스턴스를 반환하는 정적 팩토리 메서드다. null 참조와 Optioanl.empty()는 서로 무엇이 다른지 궁금할 것이다. 의미상으론 둘이 비슷하지만 실제로는 차이점이 많다. null을 참조하려 하면 NPE가 발생하지만 Optional.empty()는 Optional 객체이므로 이를 다양한 방식으로 활용할 수 있다.

null 대신 Optioanl을 사용하면서 Car 형식이 Optional<Car>로 바뀌었다. 이는 값이 없을 수 있음을 명시적으로 보여준다. 반면 Car 형식을 사용하면 Car에 null 참조가 할당될 수 있는데 이것이 올바른 값인지 아니면 잘못된 값인지 판단할 아무 정보도 없다.

이제 Optioanl을 이용해서 앞의 예제 코드를 고칠 수 없다.

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

Optional 클래스를 사용하면서 모델의 의미 semantic가 더 명확해졌음을 확인할 수 있다. 사람은 자동차를 소유했을 수도 아닐 수도 있으며, 자동차는 보험에 가입되어 있을 수도 아닐 수도 있음을 명확히 설명한다. 또한 보험회사는 반드시 이름을 가져야 함을 보여준다. 따라서 보험회사 이름을 참조할 때 NPE가 발생할 수도 있다는 정보를 확인할 수 있다. 하지만 보험회사 이름이 null인지 확인하는 코드를 추가할 필요는 없다. 오히려 고쳐야 할 문제를 감추는 꼴이 되기 때문이다.

Optional을 이용하면 값이 없는 상황이 우리 데이터에 문제가 있는 것인지 아니면 알고리즘의 버그인지 명확하게 구분할 수 있다. 모든 null 참조를 Optional로 대치하는 것은 바람직하지 않다. Optional의 역할은 더 이해하기 쉬운 API를 설계하도록 돕는 것이다. 즉, 메서드의 시그니처만 보고도 선택형값인지 여부를 구별할 수 있다. Optioanl이 등장하면 이를 언랩해서 값이 없을 수 있는 상황에 적절하게 대응하도록 강제하는 효과가 있다.

Optioanl 적용 패턴

Optional 객체 만들기

Optioanl을 사용하려면 Optional 객체를 만들어야 한다. 다양한 방법으로 Optional 객체를 만들 수 있다.

빈 Optioanl

정적 팩토리 메서드 Optional.empty로 빈 Optional 객체를 얻을 수 있다.

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

이제 car가 null이라면 즉시 NullPointerException이 발생한다. Optional을 사용하지 않았다면 car의 프로퍼티에 접근하려 할 때 에러가 발생했을 것이다.

null값으로 Optional 만들기

마지막으로 정적 팩토리 메서드 Optional.ofNullable로 null값을 저장할 수 있는 Optional을 만들 수 있다.

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

car가 null이면 빈 Optioanl 객체가 반환된다.

그런데 Optional에서 어떻게 값을 가져오는지는 아직 살펴보지 않았다. get 메서드를 이용해서 Optional의 값을 가져올 수 있는데, 이는 곧 살펴볼 것이다. 그런데 Optional이 비어있으면 get을 호출했을 때 예외가 발생한다. 즉, Optional을 잘못 사용하면 결국 null을 사용했을 때와 같은 문제를 겪을 수 있다. 따라서 먼저 Optional로 명시적인 검사를 제거할 수 있는 방법을 살펴본다. 곧 Optional에서 제공하는 기능이 스트림 연산에서 영감을 받았음을 알게 될 것이다.

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

보통 객체의 정보를 추출할 때는 Optional을 사용할 때가 많다. 다음 코드처럼 이름 정보에 접근하기 전에 insurance가 null인지 확인해야 한다.

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

이런 유형의 패턴에 사용할 수 있도록 Optional은 map 메서드를 지원한다.

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

Optional의 map 메서드는 스트림의 map 메서드와 개념적으로 비슷하다. 스트림의 map은 스트림의 각 요소에 제공된 함수를 적용하는 연산이다. 여기서 Optional 객체를 최대 요소의 개수가 한 개 이하인 데이터 컬렉션으로 생각할 수 있다. Optional이 값을 포함하면 map의 인수로 제공된 함수가 값을 바꾼다. Optional이 값을 포함하면 map의 인수로 제공된 함수가 값을 바꾼다. Optional이 비어있으면 아무 일도 일어나지 않는다.

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

그러면 여러 메서드를 안전하게 호출하는데, 이 코드를 어떻게 활용할 수 있을까?
이제 flatMap이라는 Optional의 또 다른 메서드를 살펴보자!

flatMap으로 Optional 객체 연결

map을 사용하는 방법을 배웠으므로 다음처럼 map을 이용해서 코드를 재구현할 수 있다.

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

안타깝게도 위 코드는 컴파일되지 않는다. 변수 optPeople의 형식을 Optional<People>이므로 map 메서드를 호출할 수 있다. 하지만 getCar는 Optional<Car> 형식의 객체를 반환한다. 즉, map 연산의 결과는 Optional<Optional<Car>> 형식의 객체다. getInsurance는 또 다른 Optional 객체를 반환하므로 getInsurance 메서드를 지원하지 않는다. 이와 같은 중첩 Optional 객체 구조를 보여준다.

하지만 flatMap은 인수를 받은 함수를 적용해서 생성된 각각의 스트림에서 콘텐츠만 남긴다. 즉, 함수를 적용해서 생성된 모든 스트림이 하나의 스트림으로 병합되어 평준화된다. 우리도 이차원 Optional을 일차원 Optional로 평준화해야 한다.

map 메서드였다면 Optional 내부에 다른 Optional 그리고 그 내부에 삼각형이 저장되었겠지만 flatMap 메서드 덕분에 이차원 Optional이 하나의 삼각형을 포함하는 하나의 Optional로 바뀐다.

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

Optional의 map과 flatMap을 살펴봤으니 이제 이를 실제로 사용해보자!

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

Optional을 이용해서 값이 없는 상황을 처리하는 것이 어떤 장점을 제공하는지 확인할 수 있다. 즉, null을 확인하느라 조건 분기문을 추가해서 코드를 복잡하게 만들지 않으면서도 쉽게 이해할 수 있는 코드를 완성했다.

주어진 조건에 해당하는 사람이 없을 수 있기 때문에 따라서 Person 대신 Optional<Person>을 사용하도록 메서드 인수 형식을 바꿨다.
또한 Optional을 사용하므로 도메인 모델과 관련한 암묵적인 지식에 의존하지 않고 명시적으로 형식 시스템을 정의할 수 있었다. 정확한 정보 전달은 언어의 가장 큰 목표 중 하나다. Optional을 인수로 받거나 Optional을 반환하는 메서드를 정의한다면 결과적으로 이 메서드를 사용하는 모든 사람에게 이 메서드가 빈 값을 받거나 빈 결과를 반환할 수 있음을 잘 문서화해서 제공하는 것과 같다.

Optional을 이용한 Person / Car / Insurance 참조 체인

Person을 Optional로 감싼 다음에 flatMap(Person::getCar)를 호출했다. 이미 설명한 것처럼 이 호출을 두 단계의 논리적 과정으로 생각할 수 있다. 첫 번째 단계에서는 Optional 내부의 Person에 Function을 적용한다. 여기서는 Persondml getCar 메서드가 Function이다. getCar 메서드는 Optional<Car>를 반환하므로 Optional 내부의 Person이 Optional<Car>로 변환되면서 중첩 Optional이 생성된다. 따라서 flatMap 연산으로 Optional을 평준화한다. 평준화 과정이란 이론적으로 두 Optional을 합치는 기능을 수행하면서 둘 중 하나라도 null이면 빈 Optional을 생성하는 연산이다. flatMap을 빈 Optional에 호출하면 아무 일도 일어나지 않고 그대로 반환된다. 반면 Optional이 Person을 감싸고 있다면 flatMap에 전달된 Function이 Person에 적용된다. Function을 적용한 결과가 이미 Optional이므로 flatMap 메서드는 그대로 반환할 수 있다.

이어서 Optional<Car>를 Optional<Car>를 Optional<Insurance>로 변환한다. Optional<Insurance>를 Optional<String>으로 변환한다. 세 번째 단계에서 Insurance.getName()은 String을 반환하므로 flatMap을 사용할 필요가 없다. 호출 체인 중 어떤 메서드가 빈 Optional을 반환한다면 전체 결과로 빈 Optional을 반환하고 아니면 관련 보험회사의 이름을 포함하는 Optional을 반환한다. 이제 반환된 Optional의 값을 어떻게 읽을 수 있을까? 호출 체인의 결과로 Optional<String>이 반환되는데 여기에 회사 이름이 저장되어 없을 수도 있다. Optional이 비어있을 때 기본값(default value)을 제공하는 orElse라는 메서드를 사용했다. Optional은 기본값을 제공하거나 Optional을 언랩(unwrap)하는 다양한 메서드를 제공한다. 이제 Optional에서 제공하는 다양한 기능을 자세히 살펴보자.

Optioanl 스트림 조작

자바 9에서는 Optional을 포함하는 스트림을 쉽게 처리할 수 있도록 Optional에 stream() 메서드를 추가했다. Optional 스트림을 값을 가진 스트림으로 변환할 때 이 기능을 유용하게 활용할 수 있다.

아래의 예제는 List<Person>을 인수로 받아 자동차를 소유한 사람들이 가입한 보험 회사의 이름을 포함하는 Set<String>을 반환하도록 메서드를 구현해야 한다.

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

보통 스트림 요소를 조작하려면 변화, 필터 등의 일련의 여러 긴 체인이 필요한데 이 예제는 Optional로 값이 감싸져있으므로 이 과정이 조금 더 복잡해졌다.

Optional 덕분에 이런 종류의 연산을 널 걱정없이 안전하게 처리할 수 있지만 마지막 결과를 얻으려면 빈 Optional을 제거하고 값을 언랩해야 한다는 것이 문제다.

Stream<Optional<String>> stream = ...
Set<String> result = stream.filter(Optional::isPresent)
                           .map(Optional::get)
                           .collect(toSet());

Optional 클래스의 stream() 메서드를 이용하면 한 번의 연산으로 같은 결과를 얻을 수 있다. 이 메서드는 각 Optional이 비어있는지 아닌지에 따라 Optional을 0개 이상의 항목을 포함하는 스트림으로 변환한다. 따라서 이 메서드의 참조를 스트림의 한 요소에서 다른 스트림으로 적용하는 함수로 볼 수 있으며 이를 원래 스트림에 호출하는 flatMap 메서드로 전달할 수 있다. 지금까지 배운것처럼 이런 방법으로 스트림의 요소를 두 수준인 스트림의 스트림으로 변환하고 다시 한 수준인 평면 스트림으로 바꿀 수 있다. 이 기법을 이용하면 한 단계의 연산으로 값을 포함하는 Optional을 언랩하고 비어있는 Optional을 건너뛸 수 있다.

디폴트 액션과 Optional 언랩

빈 Optional인 상황에서 기본값을 반환하도록 orElse로 Optional을 읽었다. Optional 클래스는 이 외에도 Optional 인스턴스에 포함된 값을 읽는 다양한 방법을 제공한다.

  • get()은 값을 읽는 가장 간단한 메서드면서 동시에 가장 안전하지 않은 메서드다.
  • orElse 메서드를 이용하면 Optional이 값을 포함하지 않을 때 기본값을 제공할 수 있다.
  • orElseGet(Supplier<? extends T> other)는 orElse 메서드에 대응하는 게으른 버전의 메서드다. Optional에 값이 없을 때만 Supplier가 실행되기 때문이다.
  • orElseThrow(Suppler<? extends X> exceptionSupplier)는 Optional이 비어있을 때 예외를 발생시킨다.
  • ifPresent(Consumer<? super T> consumer)를 이용하면 값이 존재할 때 인수로 넘겨준 동작을 실행할 수 있다.
  • ifPresnetOrElse(Consumer<? super T> action, Runnable emptyAction) 이 메서드는 Optional이 비었을 때 실행할 수 있는 Runnable을 인수로 받는다는 점만 ifPresnet와 다르다.

두 Optional 합치기

이제 Person과 Car 정보를 이용해서 가장 저렴한 보험료를 제공하는 보험회사를 찾는 몇몇 복잡한 비즈니스 로직을 구현한 외부서비스가 있다고 가정하자.

이제 두 Optional을 인수로 받아서 Optional<Insurance>를 반환하는 null 안전 버전의 메서드를 구현해야 한다고 가정하자. 인수로 전달한 값 중 하나라도 비어있으면 빈 Optional<Insurance>를 반환한다. Optional 클래스는 Optional이 값을 포함하는지 여부를 알려주는 isPresent라는 메서드도 제공한다. 따라서 isPresent를 이용해서 다음처럼 코드를 구현할 수 있다.

public Optional<Insurance> nullSafeFindCheapestInsurance {
        Optional<Person> person, Optional<Car> car) {
    if (person.isPresent() && car.isPresent()) {
        return Optional.of(findCheapesInsurance(person.get(), car.get()));
    } else {
        return Optional.empty();
    }
}

이 메서드의 장점은 person과 car의 시그니처만으로 둘 다 아무값도 변환하지 않을 수 있다는 정보를 명시적으로 보여준다는 것이다. 안타깝게도 구현 코드는 null 확인 코드와 크게 다른점이 없다. Optional 클래스에서 제공하는 기능을 이용해서 이 코드를 더 자연스럽게 개선해볼 수 없을까?

map과 flatMap 메서드를 이용해서 기존의 nullSafeFindCheapestInsurance() 메서드를 한 줄의 코드로 변경할 수 있다.

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

필터로 특정값 거르기

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

이는 stream과 유사하게 filter 메서드를 이용해서 필터를 할 수 있고, filter 메서드는 프레디케이트를 인수로 받는다. Optional 객체가 값을 가지며 프레디케이트와 일치하면 filter 메서드는 그 값을 반환하고 그렇지 않으면 빈 Optional 객체를 반환한다. Optional은 최대 한 개의 요소를 포함할 수 있는 스트림과 같다는 관점에서 쉽게 이해할 수 있을 것이다.

Optional을 사용한 실용 예제

새 Optional 클래스를 효과적으로 이용하려면 잠재적으로 존재하지 않는 값의 처리 방법을 바꿔야 한다. 즉, 네이티브 자바 API의 상호작용하는 방식도 바꿔야 한다. 기존 자바 API는 Optional을 적절하게 활용하지 못하고 있다. 그렇다고 기존 API에서 Optional을 사용할 수 없는 것은 아니다. Optioanl 기능을 활용할 수 있도록 우리 코드에 작은 유틸리티 메서드를 추가하는 방식으로 이 문제를 해결할 수 있다. 지금부터 몇 가지 실용 예제를 살펴보자.

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

기존의 자바 API에서는 null을 반환하면서 요청한 값이 없거나 어떤 문제로 계산에 실패했음을 알린다. 지금까지 살펴본 것처럼 null을 반환하는 것보다는 Optional을 반환하는 것이 더 바람직하다. get 메서드의 시그니처는 우리가 고칠 수 없지만 get 메서드의 반환값은 Optional로 감쌀 수 있다. Map<String, Object> 형식의 맵이 있는데, 다음처럼 key로 값에 접근한다고 가정하자.

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

문자열 'key'에 해당하는 값이 없으면 null이 반환될 것이다. map에서 반환하는 값을 Optional로 감싸서 이를 개선할 수 있다. 코드가 복잡하기는 하지만 기존처럼 if-then-else를 추가하거나, 아니면 아래와 같이 깔끔하게 Optional.ofNullable을 이용하는 두 가지 방법이 있다.

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

이와 같은 코드를 이용해서 null일 수 있는 값을 Optional로 안전하게 변환할 수 있다.

예외와 Optional 클래스

자바 API는 어떤 이유에서 값을 제공할 수 없을 때 null을 반환하는 대신 예외를 발생시킬 때도 있다. 이것에 대한 전형적인 예가 문자열을 정수로 변환하는 정적 메서드 Integer.parseInt(String)다. 이 메서드는 문자열을 정수로 바꾸지 못할 때 NumberFormatException을 발생시킨다. 즉, 문자열이 숫자가 아니라는 사실을 예외로 알리는 것이다. 기존에 값이 null일 수 있을떄는 if문으로 null 여부를 확인했지만 예외를 발생시키는 메서드에서는 try/catch 블록을 사용해야 한다는 점이 다르다.

정수로 변환할 수 없는 문자열 문제를 빈 Optional로 해결할 수 있다. 즉, parseInt가 Optional을 반환하도록 모델링할 수 있다. 물론 기존 자바 메서드 parseInt를 직접 고칠 수 없지만 다음 코드처럼 parseInt를 감싸는 작은 유틸리티 메서드를 구현해서 Optional을 반환할 수 있다.

public static Optional<Integer> stringToInt(String s) {
    try {
        return Optional.of(Integer.parseInt(s)); // 문자열을 정수로 변환할 수 있으면 정수로 변환된 값을 포함하는 Optioanl을 반환한다. 
    } catch (NumberFormatException e) {
        return Optional.empty(); // 그렇지 않으면 빈 Optional을 반환한다. 
    }
}

위와 같은 메서드를 포함하는 유틸리티 클래스 OptionalUtility를 만들기를 바란다. 그러면 필요할 때 OptionalUtility.stringToInt를 이용해서 문자열을 Optional<Integer>로 변환할 수 있다. 기존처럼 거추장스러운 try/catch 로직을 사용할 필요가 없다.

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

스트림처럼 Optional도 기본형으로 특화된 OptionalInt, OptionalLong, OptionalDouble 등의 클래스를 제공한다. 스트림을 배울때에는 스트림이 많은 요소를 가질 때는 기본형 특화 스트림을 이용해서 성능을 향상시킬 수 있다고 설명했다. 하지만 Optional의 최대 요소 수는 한 개이므로 Optional에서는 기본형 특화 클래스로 성능을 개선할 수 없다.

기본형 특화 Optional은 map, flatMap, filter 등을 지원하지 않으므로 기본형 특화 Optional을 사용할 것을 권장하지 않는다. 게다가 스트림과 마찬가지로 기본형 특봐 Optional로 생성한 결과는 다른 일반 Optional과 혼용할 수 없다.

응용

Optional 클래스의 메서드를 실제 업무에서 어떻게 활용할 수 있는지 살펴보자.
예를 들어 프로그램의 설정 인수로 Properties를 전달하고자 가정하자. 그리고 다음과 같은 Properties로 우리가 만든 코드를 테스트할 것이다.

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

이제 프로그램에서는 Properties를 읽어서 값을 초 단위의 지속 시간으로 해석한다. 다음과 같은 메서드 시그니처로 지속 시간을 읽는다.

public int readDuration(Properties props, String name)

지속 시간은 양수여야 하므로 문자열이 양의 정수를 가리키면 해당 정수를 반환하지만 그 외에는 0을 반환한다. 이를 다음과 같이 테스팅할 수 있다.

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

이들 어설션은 다음과 같은 의미를 갖는다. 프로퍼티 'a'는 양수로 변환할 수 있는 문자열을 포함하므로 readDuration 메서드는 5를 반환하다. 프로퍼티 'b'는 숫자로 변환할 수 없는 문자열을 포함하므로 0을 반환한다. 프로퍼티 'c'는 음수 문자열을 포함하므로 0을 반환한다. 'd'라는 이름의 프로퍼티는 없으므로 0을 반환한다.

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; //하나의 조건이라도 실패하면 0을 반환한다.  
}

예상대로 if문과 try/catch 블록이 중첩되면서 구현 코드가 복잡해졌고 가독성도 나빠졌다.

이는 아래와 같이 유틸리티 메서드를 이용해서 유연한 코드로 구현할 수 있다.

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

Optional과 스트림에서 사용한 방식은 여러 연산이 서로 연결되는 데이터베이스 질의문과 비슷한 형식을 갖는다.

728x90