Java8 - Optional 클래스
이 포스트에서는 아래 내용에 대해 알아본다.
- null 레퍼런스의 문제점
- null 대신 Optional: null 로부터 안전한 도메인 모델 재구현
- Optional 활용: null 확인 코드 제거
- Optional 에 저장된 값 확인
- 값이 없을 수도 있는 상황으로 고려하는 프로그래밍
소스는 github 에 있습니다.
목차
- 1. null 에서의 기존 처리
- 2. Optional 클래스
- 3. Optional 적용 패턴
- 4. 기본형 특화 Optional
- 5. 정리하며..
- 참고 사이트 & 함께 보면 좋은 사이트
1. null 에서의 기존 처리
public class Insurance {
private String name;
public String getName() {
return name;
}
}
public class Car {
private Insurance insurance;
public Insurance getInsurance() {
return insurance;
}
}
public class Person {
private Car car;
public Car getCar() {
return car;
}
}
public static String getCarInsuranceName(Person person) {
return person.getCar().getInsurance().getName();
}
위 코드 실행 시 아래와 같이 NullPointerException 이 발생한다.
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "Car.getInsurance()" because the return value of "Person.getCar()" is null
at PersonMain.getCarInsuranceName(PersonMain.java:10)
at PersonMain.main(PersonMain.java:6)
null 을 방어하기 위한 시도 1: 깊은 의심
public static String getCarInsuranceName2(Person person) {
if (person != null) {
Car car = person.getCar();
if (car != null) {
Insurance insurance = car.getInsurance();
if (insurance != null) {
return insurance.getName();
}
}
}
return "Null";
}
위 코드를 보면 변수의 null 을 체크하느라 코드 들여쓰기 수준이 증가한다. 이러한 반복 패턴 코드를 깊은 의심(deep doubt) 라고 한다.
null 을 방어하기 위한 시도 2: 너무 많은 출구
public static String getCarInsuranceName3(Person person) {
if (person == null) {
return "Null";
}
Car car = person.getCar();
if (car == null) {
return "Null";
}
Insurance insurance = car.getInsurance();
if (insurance == null) {
return "Null";
}
return insurance.getName();
}
중첩 if 문은 제거했지만 4개의 출구가 생겼기 때문에 좋은 코드는 아니다. 반복되는 “Null” 문구 또한 복붙을 하면서 오타 등의 실수가 발생할 수 있다.
2. Optional 클래스
Java8 은 java.util.Optional<T> 클래스를 제공한다.
Optional 은 선택형 값을 캡술화하는 클래스로, 값이 있으면 Optional 클래스는 값을 감싸고 값이 없으면 Optional.empty() 로 Optional 을 반환한다.
Optional.empty() 는 Optional 의 싱글턴 인스턴스를 반환하는 정적 팩토리 메서드이다.
이제 위 코드를 Optional 을 이용하여 개선해보자.
public class Insurance {
// 보험 회사는 반드시 이름이 있음
private String name;
public String getName() {
return name;
}
}
public class Car {
// 보험이 있을 수도 있고 없을 수도 있다.
private Optional<Insurance> insurance;
public Optional<Insurance> getInsurance() {
return insurance;
}
}
public class Person {
// 차가 있을 수도 있고 없을 수도 있다.
private Optional<Car> car;
public Optional<Car> getCar() {
return car;
}
}
보험의 경우 Optional
Optional 을 이용하면 값이 없는 상황이 데이터에 문제가 있는 것인지 아니면 알고리즘의 버그인지 명확하게 구분할 수 있다.
따라서 모든 null 레퍼런스를 Optional 로 대치하는 것은 바람직하지 않다. Optional 을 통해 메서드의 시그니처만 보고도 선택형 값인지 여부를 구별할 수 있다.
3. Optional 적용 패턴
3.1. Optional 객체 생성
3.1.1. 빈 Optional 생성: Optional.empty()
static factory 메서드인 Optional.empty()
로 빈 Optional 객체 생성이 가능하다.
// 빈 Optional 생성
Optional<Car> optCar = Optional.empty();
3.1.2. null 값을 포함하지 않는 Optional 생성: Optional.of()
static factory 메서드인 Optional.of()
로 null 을 포함하지 않는 Optional 객체 생성이 가능하다.
// null 이 아닌 값으로 Optional 생성
Car car = null;
Optional<Car> optCar2 = Optional.of(car);
위 코드를 컴파일하면 NPE 가 발생한다. (= 컴파일러 에러)
만일 Optional 을 사용하지 않았다면 car 프로퍼티에 접근할 때 에러가 발생한다. (= 런타임 에러)
3.1.3. null 값으로 Optional 생성: Optional.ofNullable()
static factory 메서드인 Optional.ofNullable()
로 null 값을 저장할 수 있는 Optional 객체 생성이 가능하다.
// null 값으로 Optional 생성
Optional<Car> optCar3 = Optional.ofNullable(car);
만일 car 가 null 이면 빈 객체인 Optional.empty 를 반환한다.
Optional 객체에서 값을 꺼낼 때는 get()
을 이용해서 값을 꺼낼 수 있다.
하지만 Optional 이 비어있으면 get() 호출 시 예외가 발생한다. (= Optional 을 잘못 사용하면 결국 null 을 사용했을 때와 같은 문제를 겪을 수 있음)
따라서 Optional 로 명시적인 검사를 제거할 수 있는 방법에 대해 살펴보자.
3.2. map() 으로 Optional 값 추출 후 변환
String name = null;
if (insurance != null) {
name = insurance.getName();
}
이런 유형의 패턴에 사용할 수 있도록 Optional 은 map() 메서드를 지원한다.
Insurance insurance = new Insurance();
Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
Optional<String> name = optInsurance.map(Insurance::getName);
// Optional.empty
System.out.println(name);
Optional 의 map() 은 Stream 의 map() 과 개념적으로 비슷하다.
Stream 의 map() 은 스트림 각 요소에 제공된 함수를 적용하는 연산이다. 여기서 Optional 객체를 최대 요소의 개수가 한 개 이하인 데이터 컬렉션으로 생각할 수 있다.
만일 Optional 이 값을 포함하면 map() 의 인수로 제공된 함수가 값을 바꾸고, Optional 이 비어있으면 아무 일도 일어나지 않는다.
3.3. flatMap() 으로 Optional 객체 연결
이제 아래와 같은 여러 메서드를 안전하게 호출하는 법에 대해 알아보자.
public String getCarInsuranceName(Person person) {
return person.getCar().getInsurance().getName();
}
위 내용은 map() 으로 재구현해보자.
Person person = new Person();
Optional<Person> optPerson = Optional.of(person);
Optional<String> name = optPerson
.map(Person::getCar) // Optional<Optional<Car>> 반환
.map(Car::getInsurance) // Optional<U> 반환
.map(Insurance::getName);
위 코드는 아래와 같은 에러가 뜨면서 컴파일되지 않는다.
reason: no instance(s) of type variable(s) exist so that Optional<Car> conforms to Car
optPerson 의 형식은 Optional
스트림의 flatMap() 은 함수를 인수로 받아서 다른 스트림을 반환하는 메서드이다. 즉, 함수를 적용해서 생성된 모든 스트림이 하나의 스트림으로 병합되어 평준화된다.
Optional 도 이차원 Optional 을 일차원 Optional 로 평준화할 때 flatMap() 을 사용할 수 있다.
public static String getCarInsuranceName(Optional<Person> person) {
return person.flatMap(Person::getCar) // Optional<Car> 반환
.flatMap(Car::getInsurance) // Optional<Insurance> 반환
.map(Insurance::getName) // Optional<String> 반환
.orElse("Unknown");
}
orElse()
는 Optional 이 비어있을 때 디폴트값을 제공한다.
Person person = new Person();
Optional<Person> optPerson = Optional.of(person);
String name = getCarInsuranceName(optPerson);
System.out.println(name);
도메인 모델에 Optional 사용 시 데이터 직렬화 불가능
2. Optional 클래스 에서 본 것처럼 Optional 로 도메인 모델에서 값이 꼭 있어야 하는 경우인지 여부를 구체적으로 표현할 수 있었다.
Optional 클래스는 필드 형식으로 사용할 것을 가정하지 않았기 때문에 Serializable 인터페이스를 구현하지 않는다.
따라서 도메인 모델에 Optional 을 사용한다면 직렬화 모델을 사용하는 도구나 프레임워크에서 문제가 생길 수 있다.
하지만 이와 같은 단점에도 객체가 null 일 수 있는 상황이라면 Optional 을 사용해서 도메인 모델을 구성하는 것이 바람직하다.
직렬화 모델이 필요하다면 아래처럼 Optional 로 값을 반환받을 수 있는 메서드를 추가하는 방식을 권장한다.
기존
public Optional<Car> getCar() {
return car;
}
직렬화 지원
public Optional<Car> getCarAsOptional() {
return Optional.ofNullable(car);
}
3.4. 디폴트 액션과 Optional 언랩
Optional 클래스는 Optional 인스턴스에서 값을 읽을 수 있는 다양한 인스턴스 메서드를 제공한다.
get()
- 값을 읽는 가장 간단한 메서드이면서 동시에 가장 안전하지 않은 메서드
- 래핑된 값이 있으면 해당 값을 반환하고, 값이 없으면 NoSuchElementException 발생
- 따라서 Optional 에 반드시 값이 있다고 가정할 수 있는 상황에서만 사용이 가능한데 이건 결국 null 확인 코드를 넣는 상황과 크게 다르지 않음
orElse(T other)
- Optional 이 값을 포함하지 않을 때 디폴트값 제공
orElseGet(Supplier<? extends T> other)
- orElse() 에 대응하는 게으른 버전의 메서드
- Optional 에 값이 없을 때만 Supplier 가 실행됨
- 디폴트 메서드를 만드는 데 시간이 걸리거나 Optional 이 비어있을 때만 디폴트 값을 생성하고 싶다면(디폴트 값이 반드시 필요한 상황) orElseGet() 사용
orElseThrow(Supplier<? extends X> exceptionSupplier)
- Optional 이 비어있을 때 예외를 발생시킨다는 점에서 get() 과 비슷
- 다른 점은 발생시킬 예외의 종류를 선택할 수 있다는 점이 다름
ifPresent(Consumer<? super T> consumer)
- 값이 존재할 때 인수로 넘겨준 동작을 실행
- 값이 없으면 아무 일도 일어나지 않음
3.5. 두 Optional 합치기
예를 들어 Person 과 Car 를 이용하여 가장 저렴한 보험료를 제공하는 보험회사를 찾는 기능을 구현한다고 해보자.
public Insurance findCheapestInsurance(Person person, Car car) {
...
return cheapestCompany;
}
두 Optional 을 인수로 받아서 Optional<Insurance> 를 반환하는 null-safe 한 메서드를 구현해보자.
인수들 중 하나라도 비어있으면 비어있는 Optional<Insurance> 를 반환한다.
public Optional<Insurance> nullSafeCheapestInsurance(Optional<Person> person, Optional<Car> car) {
if (person.isPresent() && car.isPresent()) {
return Optional.of(findCheapestInsurance(person.get(), car.get()));
} else {
return Optional.empty();
}
}
Optional.isPresent()
는 Optional 이 값을 포함하는지 여부를 알려줌
위 메서드는 person 과 car 의 시그니처만으로 둘 다 아무값도 반환하지 않을 수 있다는 정보를 명시적으로 보여준다.
하지만 구현 코드는 null 확인 코드와 크게 다른 점이 없다.
위 코드를 아래와 같이 개선 가능하다.
public Optional<Insurance> nullSafeCheapestInsurance2(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 을 사용한다.
두 번째 Optional 에 map() 을 호출하므로 Optional 이 car 값을 포함하지 않으면 Function 은 빈 Optional 을 반환하므로 결국 nullSafeCheapestInsurance2() 는 빈 Optional 을 반환한다.
마지막으로 person 과 car 가 모두 존재하면 map() 메서드로 전달한 람다 표현식이 findCheapestInsurance() 메서드를 안전하게 호출한다.
3.6. 필터로 특정값 거르기
보험회사 이름이 ‘testCompany’ 인지 확인해야 한다고 하면 먼저 Insurance 객체의 null 여부를 체크한 후 getName() 를 호출할 것이다.
Insurance insurance = ..;
if (insurance != null && "testCompany".equals(insurance.getName())) {
System.out.println("OK");
}
이 부분을 filter() 를 사용하여 재구현할 수 있다.
Optional<Insurance> optInsurance = ..;
optInsurance.filter(insurance -> "testCompany".equals(insurance.getName()))
.ifPresent(x -> System.out.println("OK"));
filter()
메서드는 Predicate 를 인수로 받는다.
Optional 객체가 값을 가지면서 Predicate 와 일치하면 그 값을 반환하고, 그렇지 않으면 빈 Optional 객체를 반환한다.
Optional 이 비어있다면 filter() 는 아무 동작도 하지 않고, Optional 에 값이 있으면 그 값에 Predicate 를 적용한다.
3.7. Optional 클래스의 메서드
메서드 | |
---|---|
empty() | - 빈 Optional 인스턴스 반환 |
filter() | - 값이 존재하며 Predicate 와 일치하면 값을 포함하는 Optional 반환 - 값이 없거나 Predicate 와 일치하지 않으면 Optional 반환 |
flatMap() | - 값이 존재하면 인수로 제공된 함수를 적용한 결과 Optional 반환 - 값이 없으면 빈 Optional 반환 |
get() | - 값이 존재하면 Optional 이 감싸고 있는 값 반환 - 값이 없으면 NoSuchElementException 발생 |
ifPresent() | - 값이 존재하면 지정된 Consumer 실행 - 값이 없으면 아무 일도 일어나지 않음 |
isPresent() | - 값이 존재하면 true, 값이 없으면 false 반환 |
map() | - 값이 존재하면 제공된 매핑 함수 적용 |
of() | - 값이 존재하면 값을 감싸는 Optional 반환 - 값이 null 이면 NPE 발생 |
ofNullable() | - 값이 존재하면 값을 감싸는 Optional 반환 - 값이 null 이면 빈 Optional 반환 |
orElse() | - 값이 존재하면 값 반환 - 값이 없으면 디폴트값 반환 |
orElseGet() | - 값이 존재하면 값 반환 - 값이 없으면 Supplier 에서 제공하는 값 반환 |
orElseThrow() | - 값이 존재하면 값 반환 - 값이 없으면 Supplier 에서 생성한 예외 발생 |
4. 기본형 특화 Optional
스트림처럼 Optional 도 기본형으로 특화된 OptionalInt
, OptionalLong
, OptionalDouble
이 있다.
스트림의 경우 스트림이 많은 요소를 가질 때 기본형 특화 스트림을 이용해서 성능을 향상시킬 수 있지만 Optional 의 최대 요소 수는 한 개이므로 Optional 에서는 기본형 특화 Optional 로 성능을 개선할 수 없다.
또한 기본형 특화 Optional 은 Optional 클래스의 유용한 메서드인 map(), flatMap(), filter() 등을 지원하지 않으므로 기본형 특화 Optional 은 사용하지 않을 것을 권장한다.
5. 정리하며..
- 팩토리 메서드 Optional.empty(), Optional.of(), Optional.ofNullable() 등을 이용해서 Optional 객체 생성 가능
- Optional 클래스는 스트림과 비슷한 연산을 수행하는 map(), flatMap(), filter() 등의 메서드 제공
- Optional 을 활용하면 더 좋은 API 설계 가능
- 사용자는 메서드의 시그니처만 보고도 Optional 값이 사용되거나 반환되는지 예측 가능
참고 사이트 & 함께 보면 좋은 사이트
본 포스트는 라울-게이브리얼 우르마, 마리오 푸스코, 앨런 마이크로프트 저자의 Java 8 in Action을 기반으로 스터디하며 정리한 내용들입니다.