본문으로 바로가기

null 대신 Optional 클래스 사용하기

값이 없는 상황 처리하기

아래 코드는 자동차와 자동차 보험을 갖고 있는 사람 객체를 중첩 구조로 구현했다고 가정해보자.

public class Persons {  
 private Car car;  
 public Car getCar(){  
    return car;  
  }  
}  

class car{  
 private Insurance insurance;  
 public Insurance getInsurance() {  
        return insurance;  
  }  
}  

class Insurance{  
 private String name;  
 public String getName() {  
        return name;  
  }  
}
public String getCarInsuranceName(Person person) {
    return person.getCar().getInsurance().getName();
}

자동차의 보험, 이름을 리턴하는 메서드인데, 코드에는 아무 문제가 없어보이지만 person객체중에 차를 소유하지 않은 person이 있을수도 있다. 대부분의 프로그래머는 null 참조를 반환하는 방식으로 자동차를 소유하고 있지 않음을 표현할 것이다.

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

public String getCarInsuranceName(Persons 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인지 의심하므로 변수를 접근할 때마다 중첩된 if가 추가되면서 코드 들여쓰기 수준이 증가한다. 이와 같은 반복패턴 코드를 깊은 의심 이라고 부른다. 즉, 변수가 null인지 의심되어 중첩 if 블록을 추가하면 코드 들여쓰기 수준이 증가하고, 가독성이 떨어지고 코드의 구조가 엉망이 된다.

1.2 안전 시도 : 너무 많은 출구

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

    Insuracne insurance = car.getInsurance();  
     if(insurance == null) {  
        return "Unknown";  
  }  

    return insurance.getName();  
  }

이 코드는 조금 다른 방법으루 중첩 if 블록을 없앴다. 즉 , null 변수가 있으면 즉시 "Unknown"을 반환한다. 하지만 이 예제도 그렇게 좋은 코드는 아니다. 메서드에 4개의 출구가 생겼기 때문이다. 출구가 많으면 유지보수가 어려워지고, "Unknown"을 3곳에서 반복적으로 리턴하고 있는데 문자열을 반복하면서 오타 등의 실수가 생길 수 있다.

2. null 때문에 발생하는 문제

자바에서 null 참조를 사용하면서 발생할 수 있는 이론적, 실용적 문제를 확인하자.

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

  • 코드를 어지럽힌다 : 때로는 중첩된 null 확인 코드를 추가해야 하므로 null 때문에 코드 가독성이 떨어진다.

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

  • 자바 철학에 위배된다 : 자바는 개발자로부터 모든 포인터를 숨겼다. 하지만 예외가 있는데 그것이 바로 null 포인터다.

  • 형식 시스템에 구멍을 만든다 : null은 무형식이며 정보를 포함하고 있지 않으므로 모든 참조 형식에 null을 할당할 수 있다. 하지만 이런식으로 null이 할당되기 시작하면서 시스템의 다른부분으로 null이 퍼졌을 때 애초에 null이 어떤 의미로 사용되었는지 알 수 없다.

Optional< T >

이와 같은 문제를 해결하기 위해 자바8 에서는 '선택형값' 개념의 영향을 받아서 java.util.Optional< T > 라는 새로운 클래스를 제공한다.

Optional 클래스 소개

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

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

null 참조는 NullpointerException 발생시키지만 Optional.empty() 은 Optional 객체이므로 이를 다양한 방식으로 활용할 수 있다. 아래 코드와 같이 null 대신 Optional을 사용하면서 Car 형식이 아닌 Optional< Car > 로 바뀌었다. 이는 값이 없을수도 있음을 명시적으로 보여준다.

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

public class Person {  

 private Optional<Car> car;  
 private int age;  

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

 public int getAge() {  
        return age;  
  }  
}

public class Car {  

    private Optional<Insurance> insurance;  

 public Optional<Insurance> getInsurance() {  
        return insurance;  

 }
public class Insurance {  

 private String name;  

 public String getName() {  
        return name;  
  }  

}

사람(Person)은 Optional< Car > 를 참조하며, 자동차는 Optional< Insurance >를 참조하는데, 이는 사람이 자동차가 소유했을 수도 아닐 수도 있으며, 자동차 보험에 가입되어 있을 수도 아닐 수도 있음을 명확히 설명한다.
또한 Insuracne 클래스에서 보험회사 이름인 name 타입이 Optional< String > 이 아닌 String 형식으로 되어있는데, 이는 보험회사는 반드시 이름을 가져야 함을 보여준다.

따라서 보험회사의 name을 참조할 때, NullPointerException이 발생할 수도 있지만, 보험회사 이름이 null 인지 확인하는 코드는 추가할 필요도 없으며, 추가한다면 이상한 코드가 되는 것이다. 보험회사는 반드시 이름을 가져야하며, 만약 이름이 없다면 그 이유가 무엇인지 밝히는 것이 우선이다.

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

Optional 적용 패턴

Optional 객체 만들기

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

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

    Optional< Car > optCar = Optional.empty();
  2. null이 아닌 값으로 Optional 만들기
    정적 팩토리 메서드 Optional.of로 null이 아닌 값을 포함하는 Optional을 만들 수 있다.

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

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

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

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

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

다음 내용으로는 Optional에서 어떻게 값을 가져오는지 살펴 볼 것이다. 결론부터 말하자면 get() 메서드를 이용해서 Optional의 값을 가져올 수 있는데, 만약 Optional이 비어있다면 get()을 호출했을 때 예외가 발생한다.
즉, Optional을 잘못 사용하면 결국 null을 사용했을 때와 같은 문제를 겪을 수 있다.
따라서 Optional로 명시적인 검사를 제거할 수 있는 방법을 살펴본다.

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

보통 객체의 정보를 추출할 때는 Optional을 사용할 때가 많다. 예를들어 보험회사의 이름을 추출한다면, 다음 코드처럼 이름 정보에 접근하기 전에 insuracne(보험 가입 여부)가 null인지 확인해야 한다.

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

이러하 유형의 패턴에 사용할 수 있도록 Optional 은 map 메서드를 지원한다. 다음 코드를 살펴보자.

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

Optional의 map 메서드는 스트림의 map 메서드와 개념적으로 비슷하다. 스트림의 map은 스트림의 각 요소에 제공된 함수를 적용하는 연산이다. 여기서 Optional 객체를 최대 요소의 개수가 한 개 이하인 데이터 컬렉션으로 생각할 수 있다.

flatMap으로 Optional 객체 연결

어떤 사람이, 자동차를 보유하고 있으며, 자동차 보험도 가입되어있고, 그 자동차 보험의 이름을 출력하는 메소드는 아래와 같다.

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

물론 위 코드는 null 이라는 예외가 발생하므로, map을 이용해서 코드를 재구현한다면 아래 코드와 같이 재구현 할 것이다.

//person이 null이 아니면 Optional을 만든다.
Optional<Person> optPerson = Optional.of(person);
Optional<String> name = optPerson.map(Person::getCar)
                .                .map(Car::getInsurance)
                                .map(Insurance::getName);

안타깝게도 위 코드는 컴파일 되지 않는다. 그 이유는 다음과 같다.

  1. optPerson은 Optional형식이므로, 첫번째 map(Person::getCar)는 정상적으로 호출할 수 있다.
  2. 하지만 getCar는 Optional< Car > 형식인데, 두번째 map의 연산 결과는 Optional< Optional < Car > > 형식의 객체다.
  3. 세 번째 map 연산 또한 마찬가지다.

이 문제를 해결하는 방법은 바로 flatMap의 사용이다.
스트림에서 flatpMap은 함수를 인수로 받아서 다른 스트림을 반환하는 메서드다.

보통 인수로 받은 함수를 스트림의 각 요소에 적용하면 스트림의 스트림이 만들어 지는 구조인데, flatMap은 인수로 받은 함수를 적용해서 생성된 각각의 스트림만을 콘텐츠에 남긴다.
즉, 함수를 적용해서 생성된 모든 스트림이 하나의 스트림으로 병합되어 평준화 된다.

Optional도 마찬가지로Optional< Optional < Car > > 과 같은 2차원 Optional을 1차원 Optional로 평준화해야 한다.

이제 flatMap을 이용해서 자동차의 보험회사 이름을 찾아보자.

 public String getCarInsuranceName(Optional< Person > person) {
     return person.flatMap(Person::getCar)
                 .flatMap(Car::getInsurance)
                 .map(Insuracne::getName) //이 단계에서는 String을 반환하므로 flatMap을 사용할 필요가 없다.
                 .orElse("Unknown"); // Optional이 비어있으면 Unknown 출력

처음 작성했던 코드와 비교해보자. 훨씬 더 명확하고, 오류의 가능성도 현저히 적어졌다.

Optional 인스턴스에 포함된 값을 읽는 다양한 방법

  • 가장 많이 사용하는 get()은 값을 읽는 가장 간단한 메서드면서 동시에 가장 안전하지 않는 메서드다. 래핑된 값이 있으면 해당 값을 반환하지만 값이 없으면 NoSuchElementException을 발생시킨다. 따라서, Optional에 값이 반드시 있다고 가정할 수 있는 상황이 아니면 get 메서드를 사용하지 않아야 한다.

  • orElse 메서드를 이용하면 Optional이 값을 포함하지 않을 때 기본값을 제공할 수 있다.

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

  • orElseThrow 는 Optional이 비어있을 때 예외를 발생시킨다는 점에서 get과 비슷하지만 , 이 메서드는 발생시킬 예외의 종류를 선택할 수 있다.

  • ifPresent()를 이용하면 값이 존재할 때 인수로 넘겨준 동작을 실행할 수 있다. 값이 없으면 아무일도 일어나지 않는다. + Optional이 값을 포함하고 있는지 여부를 알려준다.

예를들어 보험회사가 제공하는 서비스와 모든 결과 데이터를 비교하는 메서드가 아래와 같이 존재한다고 가정해보자.

public Insuracne findCheapestInsurance(Person person, Car car) [
    return cheapestCompany;

이제, 두 Optional (Person과 Car) 를 인수로 받아서 Optional< Insuracne >를 반환하는 null 안전 버전의 메서드를 구현해야 한다. 인수로 전달된 Optional형 Person과 Car가 하나라도 비어있다면 Optional< Insurance >를 반환하도록 구현한다.

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

위 코드를 flatMap을 사용해서 구현할 수 있다.

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

필터로 특정값 거르기

마지막으로, filter 메서드를 이용해서 특정 값을 거르는 방법을 알아보자.

예를 들어 보험회사 이름이 'CambridgeInsurance' 인지 확인해야 한다고 가정하자. 이 작업을 안전하게 수행하려면 다음 코드에서 보여주는 것처럼 Insurance 객체가 null인지 여부부터 확인한 다음에 getName 메서드를 호출해야 한다.

Insurance insurance = ...;
if(insurance != null && insurance.getName().equlas("CambridgeInsuracne") {
    System.out.println("oK");
}

Optional 객체에 filter 메서드를 이용해서 다음과 같이 코드를 재구현 할 수 있다.

Optional<Insuracne> optInsurance = ...;
optInsuracne.filter(insurance -> "CambridgeInsuracne".equals(insuracne.getName()).ifPresent(x->System.out.println("ok));

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

기존의 자바 API 에서는 null을 반환하면서 요청한 값이 없거나 어떤 문제로 계산에 실패했음을 알린다. 예를 들어 Map의 get 메서드는 요청한 키에 대응하는 값을 찾지 못했을 때 null을 반환한다. 지금까지 살펴본 것처럼 null을 반환하는 것보다는 Optional을 반환하는 것이 더 바람직하다.
예를들어, Map에서 get 메서드의 시그니처는 우리가 고칠 수 없지만, get 메서드의 반환값은 Optional로 감쌀 수 있다.

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

문자열 "key"에 해당하는 key값이 없으면 null이 반환된다. map에서 반환하는 값을 Optional로 감싸서 이를 개선할 수 있다.

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

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

기본형Optional을 사용하지 말아야 한다.

스트림처럼 Optional도 기본형으로 특화된 OptionalInt , OptionalLong, OptionalDouble 등의 클래스를 제공한다. 예를들어 Optional< Integer > 대신 , OptionalInt를 반환할 수 있다. 하지만 기본형 Optional은 Optional에서 유용한 메서드인 mapflatMap , filter 등을 제공하지 않기때문에 기본형 특화 Optional 사용을 권장하지 않는다.

Optional 클래스의 메서드 정리

  1. empty : 빈 Optional 인스턴스 반환
  2. filter : 값이 존재하며 프레디케이트와 일치하면 값을 포함하는 Optional을 반환하고, 값이 없거나 프레디케이트와 일치하지 않으면 빈 Optional을 반환
  3. flatMap : 값이 존재하면 인수로 제공된 함수를 적용한 결과 Optional을 반환하고, 값이 없으면 빈 Optional을 반환함
  4. ifPresent : 값이 존재하면 지정된 Consumer를 실행하고, 값이 없으면 아무 일 도 일어나지 않음
  5. map : 값이 존재하면 제공된 매핑 함수를 적용함
  6. of : 값이 존재하면 값을 감싸는 Optional을 반환하고, 값이 null이면 NullPointException을 발생시킴
  7. ofNullable : 값이 존재하면 값을 감싸는 Optional을 반환하고 값이 null이면 빈 Optional을 발환함
  8. orElse : 값이 존재하면 값을 반환하고 , 존재하지 않으면 기본값을 반환함
  9. orElseGet : 값이 존재하면 값을 반환하고 값이 없으면 Supplier에서 제공하는 값을 반한함
  10. orElseThrow : 값이 존재하면 값을 반환하고, 값이 없으면 Supplier에서 생성한 예외를 발생시킴
  11. stream 값이 존재하면 존재하는 값만 포함하는 스트림을 반환하고, 값이 없으면 빈 스트림을 반환

마치며..

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