devseop08 님의 블로그

[API] 람다와 스트림: Mordern Java - 11. null 대신 Optional 클래스 본문

Language/Java

[API] 람다와 스트림: Mordern Java - 11. null 대신 Optional 클래스

devseop08 2025. 7. 21. 21:59

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

  • 전형적인 문제 상황
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 getCarInsurance(Person person) {
    return person.getCar().getInsurance().getName();
}

11.1.1 보수적인 자세로 NullPointerException 줄이기

  • 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인지 의심하므로 변수를 접근할 때마다 중첩된 if가 추가되면서 코드 들여쓰기 수준이 증가한다.
  • 따라서 이와 같은 반복 패턴 코드를 '깊은 의심'이라고 부른다.
  • 즉, 변수가 null인지 의심되어 중첩 if 블록을 추가하면 코드 들여쓰기 수준이 증가한다. => 코드의 구조가 엉망이 되고 가독성도 떨어진다.
  • null 안전 시도 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로 값이 없다는 사실을 표현하는 것은 좋은 방법이 아니다.
  • 값이 있거나 없음을 표현할 수 있는 좋은 방법이 필요하다.

11.1.2 null 때문에 발생하는 문제

  • 자바에서 null 참조를 사용하면서 발생할 수 있는 이론적, 실용적 문제
      1. 에러의 근원이다.
      1. 코드를 어지럽힌다: 중첩된 null 확인 코드
      1. 아무 의미가 없다: null은 아무 의미도 표현하지 않는다. 특히 정적 언어에서는 값이 없음을 표현하는 방법으로는 적절하지 않다.
      1. 자바 철학에 위배: 자바는 개발자로부터 모든 포인터를 숨겼다. 하지만 예외가 있는데 그것이 바로 null 포인터다.
      1. 형식 시스템에 구멍을 만든다: null은 무형식이며, 정보를 포함하고 있지 않으므로 모든 참조 형식에 null을 할당할 수 있다. 이런 식으로 null이 할당되기 시작하면서 시스템의 다른 부분으로 null이 퍼졌을 때 애초에 널이 어떤 의미로 사용되었는지 알 수 없다.

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

  • 그루비 언어의 안전 내비게이션 연산자(?.)
def carInsuranceName = person?.car?.insuracne?.name
  • 그루비 안전 네비기이션 연산자를 이용하면 null 참조 예외 걱정없이 객체에 접근할 수 있다.
  • 이 때 호출 체인에 null인 참조가 있으면 결과로 null이 반환된다.
  • 그루비의 안전 네비게이션 연산자를 이용하면 부작용을 최소화하면서 null 예외 문제를 더 근본적으로 해결할 수 있다.
  • 자바 8은 하스켈과 스칼라의 '선택형값' 개념의 영향을 받아서 java.util.Optional<T>라는 새로운 클래스를 제공한다.

11.2 Optional 클래스 소개

  • Optional은 선택형 값을 캡슐화하는 클래스다.
  • 예를 들어 어떤 사람이 차를 소유하고 있지 않다면 Person 클래스의 car 변수는 null을 가져야할 것이다.
  • 하지만 새로운 Optional을 이용할 수 있으므로 null을 할당하는 것이 아니라 변수형을 Optional<Car>로 설정할 수 있다.

  • 값이 있으면 Optional 클래스는 값을 감싼다. 반면 값이 없으면 Optional.empty 메서드로 Optional을 반환한다.

  • Optional.empty()는 Optional의 특별한 싱글턴 인스턴스를 반환하는 정적 팩토리 메서드다.

  • null 참조와 Optional.empty()는 의미상으론 둘이 비슷하지만 실제로는 차이점이 많다.

  • null을 참조하려하면 NullPointerException이 발생하지만 Optional.empty()는 이를 다양한 방식으로 활용할 수 있다.

  • null 대신 Optional을 사용하면서 Car 형식이 Optional<Car>로 바뀌었다. 이는 값이 없을 수 있음을 명시적으로 보여준다.

  • 반면 Car 형식을 사용했을 때는 Car에 null 참조가 할당될 수 있는데 이것이 올바른 값인지 아니면 잘못된 값인지 판단할 아무 정보가 없다.

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

public class Car {
    private Optional<Insurance> insurance;
    public Optioanl<Insurance> getInsurance() {
        return insurance;
    }
}

public class Insurance {
    private String name;
    public String getName() {
        return name;
    }
}
  • Optional 클래스를 사용하면서 모델의 의미가 더 명확해졌음을 확인할 수 있다.

  • Optional을 이용하면 값이 없는 상황이 데이터에 문제가 있는 것인지 아니면 알고리즘의 버그인지 명확하게 구분할 수 있다.

  • 모든 null 참조를 Optional로 대체하는 것은 바람직하지 않다.

  • Optional의 역할은 더 이해하기 쉬운 API를 설계하도록 돕는 것이다.

  • 즉 메서드의 시그니처만 보고도 선택형값인지 여부를 구별할 수 있다.

  • Optional이 등장하면 이를 언랩해서 값이 없을 수 있는 상황에 적절하게 대응하도록 강제하는 효과가 있다.

    11.3 Optional 적용 패턴

  • Optional로 감싼 값을 실제로 어떻게 사용할 수 있을까?

11.3.1 Optional 적용 패턴

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

빈 Optional

Optional<Car> optCar = Optional.empty();
null이 아닌 값으로 Optional 만들기
  • 정적 팩토리 메서드 Optional.of로 null이 아닌 값을 포함하는 Optional을 만들 수 있다.
Optional<Car> optCar = Optional.of(car);
  • Optional.of 실행 시 car가 null이라면 즉시 NullPointerException이 발생한다.
null 값으로 Optional 만들기
  • 정적 팩토리 메서드 Optional.ofNullable로 null 값을 저장할 수 있는 Optional을 만들 수 있다.
Optional<Car> optCar = Optional.ofNullable(car);
  • car가 null이면 빈 Optional 객체가 반환된다.

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

  • 보통 객체의 정보를 추출할 때는 Optional을 사용할 때가 많다.

Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
Optional<String> name = optInsurance.map(Insurance::getName);
  • Optional의 map 메서드는 스트림의 map 메서드와 개념적으로 비슷하다.
  • 스트림의 map은 스트림의 각 요소에 제공된 함수를 적용하여 요소를 변환하는 연산이다.
  • 여기서 Optional 객체를 최대 요소의 개수가 한 개 이하인 데이터 컬렉션으로 생각할 수 있다.
  • Optional이 값을 포함하면 map의 인수로 제공된 함수가 값을 바꾼다.
  • Optional이 비어있으면 아무 일도 일어나지 않는다.

11.3.3 flatMap으로 Optional 객체 연결

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

public class Car {
    private Optional<Insurance> insurance;
    public Optioanl<Insurance> getInsurance() {
        return insurance;
    }
}

public class Insurance {
    private String name;
    public String getName() {
        return name;
    }
}
Insurance insurance = new Insurance();
Car car = new Car();
Person person = new Person();

Optional<Insurance> optInsurance = Optional.ofNullable(insurance);

car.setInsurance(optInsurance);

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

person.setCar(optCar);

Optional<Person> optPerson = Optional.ofNullable(person);
  • Optional의 map 메서도로 보험명 구해보자

  • Optional 객체에 map 연산을 연쇄적으로 적용하는 코드를 구현해본다.

Optional<String> name = optPerson.map(Person::getCar)
                                 .map(Car::getInsurance)
                                 .map(Insurance::getName);
  • 위와 같은 코드는 컴파일되지 않는다.

  • Person::getCar로 반환되는 객체의 타입은 Optional<Car>이고 map 메서드의 반환 타입은 Optional이기 때문에 optPerson.map(Person: :getCar)의 반환 타입은 Optional<Optional<Car>>가 된다.

  • 이 반환 타입의 객체에 다음 map 연산을 하면 Optional<Car> 객체에 대하여 getInsurance 메서드를 호출하는데 getInsurance 메서드는 Optional 객체의 메서드가 아닌 Car의 메서드이기 때문에 컴파일이 불가하게 된다.

  • 중첩 Optional 구조에서의 map 연산은 다른 방법을 사용해야 한다.

  • flatMap은 인수로 받은 함수를 적용해서 생성된 각각의 스트림에서 콘텐츠만 남긴다.

  • 즉, 함수를 적용해서 생성된 모든 스트림이 하나의 스트림으로 병합되어 평준화된다.

  • 이와 같은 원리로 Optional을 이차원에서 일차원으로 평준화해야 한다.

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

public String getCarInsuranceName(Optional<Person> person){
    Optional<String> name = optPerson.flatMap(Person::getCar)
                                     .flatMap(Car::getInsurance)
                                     .map(Insurance::getName)
                                     .orElse("Unknown");
}
  • 평준화 과정이란 이론적으로 두 Optional을 합치는 기능을 수행하면서 둘 중 하나라도 null이면 빈 Optional을 생성하는 연산이다.
  • flatMap을 빈 Optional에 호출하면 아무 일도 일어나지 않고 그대로 반환된다.
  • Optional의 orElse 메서드는 Optional이 비어있을 때 기본값을 제공한다.
  • Optional은 기본값을 제공하거나 Optional을 언랩하는 다양한 메서드를 제공한다.

11.3.4 Optional 스트림 조작

  • 자바 9에서는 Optional을 포함하는 스트림을 쉽게 처리할 수 있도록 Optional에 stream 메서드를 추가했다.
  • Optional 스트림을, 값을 가진 스트림으로 변환할 때 이 기능을 유용하게 활용할 수 있다.
public Set<String> getCarInsuranceNames(List<Person> persons) {
    return persons.stream()
                  .map(Person::getCar) // Optional<Car> 스트림으로 변환
                  .map(optCar -> optCar.flatMap(Car::getInsurance))
                  .map(optIns -> optIns.map(Insurance::getName))
                  .flatMap(Optional::stream)
                  .collect(toSet());
}
  • 첫 번째 map 변환을 수행하고 Stream<Optional<Car>>를 얻는다.
  • 이어지는 두 개의 map 연산을 이용해 Optional<Car>Optional<Insurance>로 변환한 다음 Optional<Insurance>Optional<String>로 변환한다.
  • 스트림이 아니라 스트림 안의 각각의 Optional 요소를 변환해줘야 한다.
  • 앞의 세 번의 변환 과정을 거친 결과 Stream<Optional<String>>을 얻는데 사람이 차를 갖고 있지 않거나 또는 차가 보험에 가입되어 있지 않아 결과가 비어있을 수 있다.
  • Optional 덕분에 이런 종류의 연산을 널 걱정없이 안전하게 처리할 수는 있지만 마지막 결과를 얻으려면 빈 Optional을 제거하고 값을 언랩해야 한다는 것이 문제다.
  • 이 문제는 Stream의 filter 메서드와 map 메서드를 순차적으로 이용해 결과를 얻어 해결할 수도 있다.
Stream<Optional<String>> stream = ...;
Set<String> result = stream.filter(Optional::isPresent)
                           .map(Optional::get)
                           .collect(toSet())
  • 하지만 Optional 클래스의 stream 메서드를 이용하면 한 번의 연산으로 같은 결과를 얻을 수 있다.

  • stream 메서드는 각 Optional이 비어있는지 아닌지에 따라 Optional을 0개 이상의 항목을 포함하는 스트림으로 변환한다.

  • 따라서 stream 메서드 참조를 스트림의 한 요소에서 다른 스트림으로 적용하는 함수로 볼 수 있으며 이를 원래 스트림에 호출하는 flatMap 메서드로 전달할 수 있다.

  • 이 기법을 이용하면 한 단계의 연산으로 값을 포함하는 Optional을 언랩하고 비어있는 Optional은 건너뛸 수 있다.

    11.3.5 디폴트 액션과 Optional 언랩

  • Optional 클래스는 Optional 인스턴스에 포함된 값을 읽는 다양한 방법을 제공한다.

      1. get()은 값을 읽는 가장 간단한 메서드면서 동시에 가장 안전하지 않은 메서드다.
    • 메서드 get은 래핑된 값이 있으면 해당 값을 반환하고 값이 없으면 NoSuchElementException을 발생시킨다. Optional에 값이 반드시 있다고 가정할 수 있는 상황이 아니라면 get 메서드를 사용하지 않는 것이 바람직하다.
      1. orElse(T other) 메서드를 이용하면 Optional이 값을 포함하지 않을 때 기본값을 제공할 수 있다.
      1. orElseGet(Supplier<? extends T> other) 메서드는 orElse 메서드에 대응하는 게으른 버전의 메서드다. Optional에 값이 없을 때만 Supplier가 실행되기 때문이다.
    • 디폴트 메서드를 만드는 데 시간이 걸리거나 Optional이 비어있을 때만 기본값을 생성하고 싶다면 orElseGet 메서드를 사용해야 한다.
      1. orElseThrow(Supplier<? extends X) exceptionSupplier)는 Optional이 비어있을 때 예외를 발생시키는데 get 메서드와 달리 발생시킬 예외의 종류를 선택할 수 있다.
      1. ifPresent(Consumer<? super T> consumer)를 이용하면 값이 존재할 때 인수로 넘겨준 동작을 실행할 수 있다. 값이 없으면 아무일도 일어나지 않는다.
      1. ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction)는 Optional이 비었을 때 실행할 수 있느 Runnable을 인수로 받는다는 점만 ifPresent(Consumer<? super T> consumer)와 다르다.

11.3.6 두 Optional 합치기

  • Person과 Car 정보를 이용해서 가장 저렴한 보험료를 제공하는 보험회사를 찾는 복잡한 비즈니스 로직을 구현한 외부 서비스
public Insurance findCheapestInsurance(Person person, Car car) {
    // 다양한 보험회사가 제공하는 서비스 조회
    // 모든 결과 데이터 비교
    return cheapestCompany;
}
  • 두 Optional을 인수로 받아서 Optional<Insurance>를 반환하는 null 안전 버전의 메서드를 구현
  • 인수로 전달한 값 중 하나라도 비어있으면 빈 Optional<Insurance>를 반환한다.
  • Optional 클래스는 Optional이 값을 포함하고 있는지 여부를 알려주는 isPresent 메서드(언랩 메서드)를 제공한다.
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();
    }
}
  • 현재 구현 코드는 null 확인 코드와 크게 다른 점이 없다.
  • Optional 클래스에서 제공하는 기능을 이용해서 이 코드를 더 자연스럽게 개선하자.

Optional을 언랩하지 않고 두 Optional 합치기(map과 flatMap 활용)

public Optional<Insurance> nullSafeFindCheapestInsurance(
    Optional<Person> person, Optional<Car> car
){
    return person.flatMap(p -> car.map(c -> findCheapestInsurance(p, c)));
}
  • 첫 번째 Optional에 flatMap을 호출했으므로 첫 번째 Optional이 비어있다면 인수로 전달한 람다식이 실행되지 않고 그대로 빈 Optional을 반환
  • 반면 person 값이 있으면 flatMap 메서드에 필요한 Optional<Insurance>를 반환하는 Function의 입력으로 person을 사용
  • Function의 바디에서는 두 번째 Optional에 map을 호출하므로 Optional이 car 값을 포함하지 않으면 Function은 빈 Optional을 반환하므로 결국 nullSafeFindCheapestInsurance는 빈 Optional을 반환한다.
  • 마지막으로 person과 car가 모두 존재하면 map 메서드로 전달한 람다 표현식이 findCheapestInsurance 메서드를 안전하게 호출할 수 있다.

11.3.7 필터로 특정값 거르기

  • Optional의 filter 메서드는 프레디케이트를 인수로 받는다.
  • Optional 객체가 값을 가지며 프레디케이트와 일치하면 filter 메서드는 그 값을 포함하는 Optional 객체를 반환하고 그렇지 않으면 빈 Optional 객체를 반환한다.
  • Optional은 최대 한 개의 요소를 포함할 수 있는 스트림과 같으므로 이것을 적용하면 filter 연산의 결과를 쉽게 이해할 수 있다.
  • Optional이 비어있다면 filter 연산은 아무 동작도 하지 않고 Optional에 값이 있으면 그 값에 프레디케이트를 적용한다.
  • 프레디케이트 적용 결과가 true면 Optional에는 아무 변화도 일어나지 않지만 결과가 false면 값은 사라져버리고 Optional은 빈 상태가 된다.
Optional<Insurance> optInsurance = ...;
optInsurance.filter(insurance 
                    -> "CambridgeInsurance".equals(insurance.getName()))
            .ifPresent(x -> System.out.println("ok"));

Optional 필터링

  • 인수 minAge 이상의 나이를 먹은 사람만 선택하도록 프레디케이트를 설정해서 Optional의 filter메서드에 전달하는 방식으로 Optional에서 person을 필터링할 수 있다.
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 클래스 메서드

메서드 설명
empty 빈 Optional 인스턴스 반환
of 값이 존재하면 값을 감싸는 Optional을 반환하고 값이 null이면 NullPointerException
ofNullable 값이 존재하면 값을 감싸는 Optional을 반환하고 값이 null이면 빈 Optional 반환
or 값이 존재하면 같은 Optional을 반환, 값이 없으면 Supplier에서 만든 Optional 반환
filter 값이 존재하며 프레디케이트와 일치하면 값을 포함하는 Optional을 반환하고, 값이 없거나 프레디케이트와 일치하지 않으면 빈 Optional을 반환
map 값이 존재하면 제공된 매핑 함수를 적용
flatMap 값이 존재하면 인수로 제공된 함수를 적용한 결과 Optional을 반화하고, 값이 없으면 빈 Optional을 반환함
stream 값이 존재하면 존재하는 값만 포함하는 스트림을 반환하고, 값이 없으면 빈 스트림을 반환
ifPresent 값이 존재하면 지정된 Consumer를 실행하고 값이 없으면 아무 일도 일어나지 않음
ifPresentOrElse 값이 존재하면 지정된 Consumer를 실행하고 값이 없으면 Runnable을 실행
isPresent 값이 존재하면 true를 반환하고 값이 없으면 false를 반환
get 값이 존재하면 Optional이 감싸고 있는 값을 반환하고 값이 없으며 NoSuchElementException 발생
orElse 값이 존재하면 값을 반환하고 값이 없으면 제공된 기본값을 반환
orElseGet 값이 존재하면 값을 반환하고 값이 없으면 제공된 Supplier에서 제공하는 값을 반환
orElseThrow 값이 존재하면 값을 반환하고 값이 없으면 제공된 Supplier에서 생성한 예외를 발생

11.4 Optional을 사용한 실용 예제

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

  • Map의 get 메서드는 요청한 키에 대응하는 값을 찾지 못했을 때 null을 반환한다.
  • null을 반환하는 것보다는 Optional을 반환하는 것이 더 바람직하다.
  • get 메서드의 시그니처는 고칠 수 없지만 get 메서드의 반환값은 Optional로 감쌀 수 있다.
Optional<Object> value = Optional.ofNullable(map.get("key"));

11.4.2 예외와 Optional 클래스

  • 자바 API는 어떤 이유에서 값을 제공할 수 없을 때 null을 반환하는 대신 예외를 발생시킬 때도 있다.
  • 문자열을 정수로 변환하는 정적 메서드 Integer.parseInt(String), 이 메서드는 문자열을 정수로 바꾸지 못할 때 NumberFormatException을 발생시킨다.
  • 정수로 변환할 수 없는 문자열 문제를 빈 Optional로 해결할 수 있다.
  • parseInt가 Optional을 반환하도록 모델링할 수 있는 것이다.
  • 기초 자바 메서드 parseInt 자체를 고칠 수는 없지만 OptionalUtility 클래스에 parseInt를 감싸는 작은 유틸리티 메서드를 구현해서 Optional을 반환할 수 있다.
public class OptionalUtility {
    public static Optional<Integer> stringToInt(String s) {
        try {
            return Optional.of(Integer.parseInt(s));
        } catch (NumberFormatException e) {
            return Optional.empty();
        }
    }
}

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

  • 스트림처럼 Optional도 기본형으로 특화된 OptionalInt, OptionalLong, OptionalDouble 등의 클래스를 제공한다.
  • 스트림이 많은 요소를 가질 때는 기본형 특화 스트림을 이용해서 성능을 향상시킬 수 있지만 Optional의 최대 요소 수는 한 개이므로 Optional에서는 기본형 특화 클래스로 성능을 개선할 수 없다.
  • 기본형 특화 Optional은 Optional 클래스의 map, flatMap, filter 등을 지원하지 않으므로 기본형 특화 Optional을 사용할 것을 권장하지 않는다.
  • 스트림과 마찬가지로 기본형 특화 Optional로 생성한 결과는 다른 일반 Optional과 혼용할 수 없다. => OptionalInt를 다른 Optional의 flatMap에 메서드 참조로 전달할 수 없다.

    11.4.4 응용

Properties props = new Properties();
props.setProperty("a", "5");
props.setProperty("b", true);
props.setProperty("c", "-3");
public in readDuration(Properties props, String name) {
    return Optional.ofNullable(props.getProperties(name))
                   .flatMap(OptionalUtility::stringToInt)
                   .filter(i -> i>0)
                   .orElse(0);
}