모던 자바 인 액션 - 11장 null 대신 Optional 클래스

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

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

모던 자바 인 액션 책을 보고 정리한 글입니다.

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

  • 다음 처럼 자동차와 자동차 보험을 갖고 있는 사람 객체를 중첩으로 구현했다고 가정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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;
    }
}

  • 다음 코드에서는 어떤 문제가 발생할까 ?
1
2
3
public String getCarInsuranceName(Person person) {
    return person.getCar().getInsurance().getName();
}
  • 코드상 문제는 없어 보이지만 자동차를 소유하지 않은 사람이 있을수도, 보험에 들지 않은 사람이 있을수도 있다.

보수적인 자세로 NullPointerException 줄이기

  • 예기치 않은 NPE를 피하려면 보수적인 코드를 추가해야 할것이다.
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 "Unknown";
}
  • 위 예제 에서는 모든 변수가 null 체크를 하기 때문에 코드가 지저부해지고 반복패턴이 많아진다. 이러한 코드를 Deep Doubt 라고 부른다.
  • 뭔가 다른 해결 방법이 시급해 보인다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 일 경우 바로 값을 반환 하도록 변경했다. 하지만 return 을 할수 있는 통로가 너무 많기 때문에 유지보수가 어려워지고 다른 누군가가 null일수 있다는 사실을 까먹는 경우 NPE는 여전히 발생할 것이다.

null 때문에 발생하는 문제

  • 에러의 근원이다.
    • NPE는 자바에서 가장 흔히 발생하는 에러이다.
  • 코드를 어지럽힌다.
    • Null 체크를 빈번히 하게되면 코드가 지저분해진다.
  • 아무 의미가 없다.
    • Null 은 아무 의미도 표현하지 않는다. 특히 정적 형식 언어에서 값이 없음을 표현하는 방법으로 적합하지 않다.
  • 자바 철학에 위배된다.
    • 자바는 개발자로부터 모든 포인터를 숨겼다. 하지만 예외가 Null이다.
  • 형식 시스템에 구멍을 만든다.
    • Null은 무형식이며 정보를 포함하지 않고 있기 때문에 모든 참조 형식에 Null을 할당할 수 있다.

Optional 클래스 소개

  • Java 8은 하스켈과 스칼라의 영향을 받아 Optional이라는 클래스를 도입했다.
  • Optional은 선택형 값을 캡슐화 하는 클래스이며 값이 있으면 Optional 클래스는 값을 감싸고 값이 없으면 Optional.empty 메서드로 Optional 객체를 반환한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 클래스를 활용하여 의미가 더욱 분명해졌다. 사람은 자동차를 소유했을수도, 아닐수도 있으며 자동차는 보험에 가입되어 있을수도, 없을수도 있다. 반면 보험은 반드시 이름을 가져야 한다고 나타낼수 있다.

Optional 적용 패턴

Optional 객체 만들기

  • Optional을 사용하려면 Optional 객체를 만들어야 한다. 다양한 방법으로 Optional 객체를 만들 수 있다.
  • 빈 Optional
  • 이전에도 언급했듯이 Optional.emtpy()메소드로 빈 Optional 객체를 얻을 수 있다.
    • Optiona optCar = Optional.empty();
  • null이 아닌 값으로 Optional 만들기
    • 정적 팩토리 메서드 of를 활용하여 null이 아닌 값을 포함하는 Optional을 만들 수 있다.
      • Optional optCar = Optional.of(car)
    • car가 Null 일 경우 즉시 NPE가 발생한다. (Optional을 사용하지 않았다면 car의 프로퍼티에 접근하는 순간 발생했을것이다.)
  • Null 값으로 Optional 만들기
    • 정적 팩토리 메서드 ofNullable로 null 값을 저장할 수 있는 Optional을 만들 수 있다.
      • Optional optCar = Optional.ofNullable(car)
    • car 가 null이면 빈 Optional 객체가 반환된다.

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

  • 객체의 정보를 추출할 때는 Optional을 사용할 때가 많다.
1
2
3
4
String name = null;
if (insurance != null) {
  name = insurance.getName();
}
  • 이런 유형의 패턴에서 Optional을 사용할 수 있도록 map 메서드를 지원한다.
1
2
Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
Optional<String> name = optInsurance.map(Insurance::getName);
  • Optional의 map 메서드는 4장과 5장에서 살펴본 스트림의 map 메서드와 개념적으로 비슷하다.
  • 스트림의 map은 스트림의 각 요소에 제공된 함수를 적용하는 연산이다. 여기서 Optional 객체를 최대 요소의 개수가 한 개 이하인 데이터 컬렉션으로 생각할 수 있다.

flatMap으로 Optional객체 연결

1
2
3
4
5
Optional<Person> optionalPerson = Optional.of(person);
Optional<String> name = optionalPerson.map(Person::getCar)
                                      .map(Car::getInsurance)
                                      .map(Insurance::getName);

  • 위 코드는 컴파일 되지 않는다. 중첩된 Optional 객체는 map 연산을 사용할 경우 Optional 객체를 반환하기때문에 Optional<Optional<Car>> car와 같은 형태가 리턴된다.
  • Optional에서 지원하는 flatMap을 사용하여 평준화 해줘야 한다.
1
2
3
4
5
Optional<Person> optionalPerson = Optional.of(person);
String name = optionalPerson.flatMap(Person::getCar)
                .flatMap(Car::getInsurance)
                .map(Insurance::getName)
                .orElse("UNKNOWN");
  • Optional을 이용하여 앞선 조건문으로 가득찬 코드를 깔끔하게 처리할 수 있는 방법을 배웠다.

Optional 스트림 조작

  • Java 9 에서는 Optional을 포함하는 스트림을 쉽게 처리할 수 있도록 Optional에 stream 메서드를 추가했다.
1
2
3
4
5
6
7
8
public Set<String> getCarInsuranceNames(List<Person> persons) {
    return persons.stream()
            .map(Person::getCar) // 사람 목록을 각 사람이 보유한 자동차의 Optional<Car> 스트림으로 변환
            .map(optCar -> optCar.flatMap(Car::getInsurance)) //flatMap 연산을 통해 Optional<Car>를 해당 Optional<Insurane>로 변환
            .map(optInsurance -> optInsurance.map(Insurance::getName)) // Optional<Insuracne>를 해당 이름의 Optional<String>으로 변환
            .flatMap(Optional::stream) // Stream<Optional<String>>을 이름을 포함하는 Stream<String>으로 변환
            .collect(Collectors.toSet());
}
  • 위 연산을 거친 결과 Stream<Optional<String>>형태를 얻는데 중간 객체가 Null 인경우 값이 비어있을수 있다.
  • 다음 처럼 filter를 활용해 완전한 결과를 얻을 수 있도록 구현하자.
1
2
3
4
5
6
7
Stream<Optional<String>> optionalStream = persons.stream()
                                                .map(Person::getCar)
                                                .map(optCar -> optCar.flatMap(Car::getInsurance))
                                                .map(optInsurance -> optInsurance.map(Insurance::getName));
Set<String> collect = optionalStream.filter(Optional::isPresent)
                                    .map(Optional::get)
                                    .collect(Collectors.toSet());

디폴트 액션과 Optional 언랩

  • get()
    • get()은 값을 읽는 가장 간단한 메서드 이며 동시에 가장 안전하지 않다.
    • get()을 사용하는 경우 래핑된 값이 있으면 값을 리턴하지만 값이 없으면 NoSuchElementException을 반환 한다.
    • 따라서 Optional에 값이 반드시 있다고 가정할 수 있는 상황이 아니라면 get 메서드를 사용하지 않는 것이 바람직하다.
  • orElse(T other)
    • orElse를 사용하면 Optional이 값을 포함하지 않을 경우 기본값을 제공할 수 있다.
  • orElseGet(Supplier<? extends T> other)
    • orElse 메서드에 대응하는 lazy 버전이다. Optional에 값이 없을때만 Supplier가 실행된다.
    • 디폴트 메서드를 만드는데 시간이 걸리거나 Optional이 비어있을때만 기본값을 생성하고 싶은 경우 사용한다.
  • orElseThrow(Supplier<? extends T> other)
    • Optional이 비어 있을때 예외를 발생시킨다는 점에서 get메서드와 비스하다.
  • ifPresent(Consumer<? super T> consumer)
    • ifPresent를 이용하면 값이 존재할 때 인수로 넘겨준 동작을 실행할 수 있다.
  • ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction)
    • Java 9 버전에 추가된 이 메서드는 Optional이 비어있을때 실행할 수 있는 Runnable을 인수로 받는다는 점만 ifPresent와 다르다.

두 Optional 합치기

  • 두 Optional인수를 활용하여 연산을 해야하는 경우를 살펴보자.
1
2
3
4
5
6
public Optional<Insurance> nullSafeFindCheapestInsurance(Optional<Person> person, Optional<Car> car) {
    if (person.isPresent() && car.isPresent()) {
        return Optional.of(new Insurance());
    }
    return Optional.empty();
}
  • isPresent 메서드를 활용해서 Optional이 비엇는지 확인후 필요한 연산을 진행했다.
  • 이 코드는 null 검사 코드와 크게 다를 것 이 없다. 조금더 개선해보자.

필터로 특정 값 거르기

  • 객체의 프로퍼티를 확인해야 하는 경우 객체가 Null인지 확인한 다음 메서드를 실행해야 한다.
1
2
3
4
Insurance insurance = ....;
if (insurance != null && "XXX".equals(insurance.getName())) {
  ...
}
  • Optional객체에 filter 메서드를 이용해 다음과 같이 코드를 재구현 할 수 있다.
1
2
3
Optional<Insurance> optInsurance = ...;
optInsurance.filter(insurance -> "XXX".equals(insurance.getName()))
            .ifPresent(x -> ...);
  • Optional에 값이 있으면 filter연산을 실행할 것이고 값이 없으면 아무 연산도 실행하지 않는다.

Optional을 사용한 실용 예제

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

  • 기존 자바 API에서는 null을 반환하며 요청한 값이 없거나 계산이 실패했음을 알렸다.
  • map.get("key") 는 키에 해당하는 값이 map에 없으면 null을 반환하게 되었다.
  • 기존처럼 if - then - else를 추가하거나 Optional.ofNullable을 사용할 수 있다.

예외와 Optional 클래스

  • 자바 API 에서는 어떤 이유에서 값을 제공할 수 없을때 예외를 발생했다.
  • Integer.parseInt와 같은 예를 보면 문자열을 정수로 반환할수 없는 경우 NumberFormatException을 반환 한다.
  • 이러한 경우를 Optional을 활용해 개선해보자
1
2
3
4
5
6
7
public static Optional<Integer> stringToInt(String s) {
  try {
    return Optional.of(Integer.parseInt(s));
  } catch (NumberForamtException e) {
    return Optional.empty();
  }
}
  • 위와 같은 메서드를 포함하는 유틸리티 클래스를 만들면 거추장 스러운 try/catch로직을 벗어날 수 있을것이다.

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

  • Optional 도 스트림과 마찬가지로 OptionalInt, OptionalLong, OptionalDouble등의 클래스를 제공한다.
  • 스트림ㅇ이 많은 요소를 가질때는 기본형 특화 스트림을 이용해 불필요한 boxing / unboxing을 줄여 성능향상을 할 수 있지만 Optional의 최대요소는 한개이므로 기본형 특화 클래스로 개선할 이유가 없다.
  • 기본형 특화 Optional은 map, flatMap, filter등을 지원하지 않고 일반 Optional과 호환 되지 않는다.

정리

  • 역사적으로 프로그래밍 언어에서는 null 참조로 값이 없는 상황을 표현했다.
  • 자바 8에서는 값이 있거나 없음을 표현할 수 있는 클래스 Optional을 제공한다.
  • 팩토리 메서드 empty, of, ofNullable등을 이용해 Optional 객체를 만들 수 있다.
  • Optional 클래스는 스트림과 비슷한 연산을 수행하는 map, flatMap, filter등의 메소드를 제공한다.
  • Optional로 값이 없는 상황을 적절하게 처리하도록 강제할 수 있다. 즉 Optional로 예상치 못한 NPE를 방지할 수 있다.
  • Optional을 활용하면 더 좋은 API를 설계할 수 있다. 즉 사용자는 메서드의 시그니처만 보고 Optional값이 사용되거나 반환되는지 예측할 수 있다.