이번 장은 Optional에 대해서 다룬다. Optional은 null을 대체하기 위해 등장한 클래스이다.
null의 등장 배경은 컴파일러의 자동확인 기능으로 참조를 안전하게 사용할 수 있을 것이란 생각에서 시작되었다고 한다.
하지만, null을 만든 토니 호어는 이를 십억 달러짜리 실수라고 이야기했다. null에서 발생하는 문제는 뭐가 있을까
값이 없는 상황
차, 사람, 보험에 대한 클래스가 존재할때 다음 코드를 실행한다고 해보자.
public String getInsuranceName(Person person){
return person.getCar().getInsurance().getName();
}
아무 문제 없이 실행될 것 같지만, 차를 소유하지 않은 사람이 있다면? getInsurance()를 실행했을때 차가 존재하지 않으니, null을 참조하게 되고 이때 NullPointerException이 발생하게 된다.
이를 방지하기 위해 null을 확인하는 코드를 추가해보자. 아래와 같다.
public String getInsuranceName(Person person){
if(person!=null){
Car car = person.getCar();
if(car!=null){
Insurance insurance = car.getInsurance();
if(insurance!=null){
return insurance.getName();
}
}
}
return "Unknown";
}
이런 경우, 중첩해서 찾는 값들이 많아질때마다 if문을 추가해야하고, 이때마다 코드 들여쓰기 수준이 증가한다. 이러한 반복패턴을 깊은 의심이라고 하는데, 코드의 구조가 엉망이 되고 가독성이 떨어진다는 단점이 있다.
들여쓰기 수준을 낮추기 위해 아래와 같이 수정하면 null을 확인할때마다 return하는 출구가 생겨 유지보수가 어려워진다. 또한, 어떤 값이 null이 될 경우를 생각하지 않는다면 에러가 발생한다. 따라서 이도 좋은 방법은 아니다.
public String getInsuranceName(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때문에 발생하는 문제
- 에러의 근원: NullPointerException이 흔하게 발생한다.
- 코드 가독성이 떨어진다: 중첩된 null을 확인하는 코드를 추가해야하므로 코드 가독성이 떨어진다.
- 아무 의미가 없다: null은 아무 의미를 표현하지 않기에 값이 없음을 표현하는 방법으로 부적절하다.
- 자바 철학 위배: 자바는 개발자로부터 모든 포인터를 숨기는데 null포인터는 예외다.
- 형식 시스템에 구멍을 만든다: null은 아무 형식이 없고 정보를 포함하지 않으므로 모든 참조에 null을 할당할 수 있다. 하지만 이 할당된 null이 다른곳으로 퍼졌을때 어떤의미로 사용되었는지 알 수 없다.
다른 언어는 null 대신 무엇을 활용하는가?
그루비: 안전 내비게션 연산자 (?.)을 도입해 null문제 해결했다.
def carInsurance = person?.Car?.insurance?.name
딱봐도 직관적으로 이해할 수 있다. 있으면? 진행시켜 이런느낌으로 호출하는 체인에 null 이 있으면 결과로 null을 반환한다.
자바7에서도 비슷한 제안이 존재했으나 null예외 문제를 해결할 수 있는 근본적인 방법이 아닌 추후에 코드를 사용하는 사람이 문제를 더 해결하기 어렵다는 생각이었다.
하스켈
Maybe: 선택형 값을 저장할 수 있는 형시으로 주어진 형식의 값을 갖거나 아무것도 갖지 않을 수 있다.
스칼라
Option[T]: T형식의 값을 갖거나 아무것도 갖지 않거나이다.
자바에서는 Optional을 등장시키며 이 문제를 해결했다.
Optional이란?
Java8에선 하스켈과 스칼라의 영향을 받아 `Optional<T>`라는 클래스가 등장했다. Optional이란, 선택형 값을 캡슐화한 클래스로 아래 그림과 같이 Car라는 객체가 있으면 이를 Optional<Car>로 한번 감싼 것이다. 이는 명시적으로 Car라는 객체가 값이 없을수도 있음을 의미한다.
따라서, 값이 있으면 실제 Car 객체를 반환하지만 실제 Car가 존재하지 않는다면, 아래 오른쪽 그림과 같이 null대신 `Optional.empty`를 반환한다.
`Optional.empty`는 Optinal의 싱글턴 인스턴스를 반환하는 정적 팩토리 메서드이다. null을 참조하면 NPE가 발생하지만, Optional.empty()는 Optional 객체이므로 참조가 가능하다.
public class Person{
private Optional<Car> car;
public Optional<Car> getCar(){return car;}
public Person(){
}
}
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<>로 감싸주면 명시적으로, 해당 값이 없을수도 있음을 의미한다. 반대로 Insurance의 String name은 Optional로 감싸지 않았는데, 이는 name은 반드시 존재해야한다는 의미를 뜻하게 된다.
Optional 적용 패턴
Optional 객체만들기
빈 optional
Optional<Car> optCar = Optional.empty();
null이 아닌 값으로 Optional 만들기: Optional.of() 활용
Optional<Car> optCar = Optional.of(car);
null이 아닌 값을 포함하는 Optional클래스로 car가 null이라면 NPE가 발생한다.
null값으로 Optional 만들기
Optional<Car> optCar = Optional.ofNullable(car);
null값을 저장할 수 있는 Optional이다. 이때 car가 null이면 NPE가 아닌 비어있는 Optinal객체가 반환된다.
맵으로 Optional에서 값 추출 후 변환하기
객체의 정보를 추출할때 Optional을 활용할 수 있다. 보험이 null인지 확인하고 null이 아니면 name을 반환하는 코드를 아래와 같이 짤 수 있다.
String name = null;
if(insurance!=null){
name = insurance.getName();
}
이렇게 직접 null체크를 하지 않고 Optional.map을 활용해서 name을 받을 수도 있다.
Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
Optional<String> name = optInsurance.map(Insurance::getName);
map은 스트림의 map과 유사한다. Optional객체를 값이 최대 1개 이하인 데이터 컬렉션으로 생각하면 map에 대한 이해가 도움이 된다.
결국 Optional을 활용하면 아래와 같이 여러 메서드를 안전하게 호출할 수 있다.
public String getInsuranceName(Person person){
return person.getCar().getInsurance().getName();
}
flatMap으로 Optional 객체 연결
스트림처럼 map을 여러번 사용해서 객체를 변환하는 건 어떨까? 일반적으론 아래 코드처럼 생각할 수 있다.
Optional<String> name = optPerson.map(Person::getCar)
.map(Car::getInsurance)
.map(Insurance::getName);
하지만 이 코드는 컴파일 되지 않는다.
optPerson은 Optional<Person> 이므로, map을 호출하고 Optional<Car>가 아니라 두 번 감싸진 Optinal<Optional<Car>>가 반환된다. (스트림 flatMap찾을때랑 비슷한듯하다)
이 문제를 해겱하기 위해 스트림의 flatMap을 떠올려보자. 스트림의 flatMap은 Function<T,R>을 인수로 받아 다른 스트림을 반환하는 메서드인데 이때 스트림의 스트림이 아닌 스트림의 컨텐츠 (안에 있는 값)만 남기고 반환한다.
Optional에서도 이차원을 일차원으로 평준화하는 과정이 필요하다.
이를 적용해 Person -> 보험회사 이름까지 찾는 코드를 작성하면 아래와 같다.
이렇게 Optional을 활용하면 null을 확인하느라 조건 분기문을 작성하지 않아도 되고 해당 필드가 비어있어도 되는지, 아닌지를 명시적으로 표현할 수 있다.
이 과정에 대한 Reference Chain을 살펴보자. 그림으로 보면 아래와 같이 4단계를 거친다.
- 1단계: Person에 Person::getCar를 적용한다. 이때 반환하는 값은 Optional<Car>인데 평준화 과정을 통해 중첩된 Optional을 풀어준다.
도메인 모델에 Optional 사용 시 직렬화가 불가능한 이유
위에서 우리는 도메인 필드에 Optional을 써서 값이 있어야하는지 여부를 판단했다.
private Optional<Car> car;
그러나, 자바 언어 아키텍트인 브라이언 고츠는 Optional을 설계의도인 선택형 반환값(Optional return?)을 지원하는 것이라 명확하게 이야기했다. 위와 같이 쓰는 것은 설계의도에 맞지 않다는 것이다.
따라서, 필드에 사용될 것이 가정되지 않았으므로 Serializable 인터페이스를 구현하지 않는다.
그러나, 객체 그래프에서 null일 수도 있는 상황이 있다면, Optional을 사용해 도메인 모델을 구성하는것이 필요한데, 이때 직렬화를 사용하고 싶다면 아래와 같이 사용하기를 권장한다.
public class Person{
private Car car;
public Optional<Car> getCarAsOptional(){
return Optional.ofNUllable(car);
}
}
Optional에 스트림 조작
사람 목록을 이용해 가입한 보험사명의 Set을 구해보자. 이때 스트림을 사용하는데 반환하는 값이 Optional이므로 보다 세부적으로 구현을 해줄 필요가 있다.
우선 `Person::getCar`의 반환형이 Optional<Car>이므로, 여기서 Optional<Insurance>를 얻기 위해선, flatMap을 사용해야한다.
3번의 map이후에 Stream<Optional<String>>을 반환하는데, 여기서 이제 할일은 값이 존재하면, String으로 반환하고 Set으로 바꾸는 과정이 필요하다.
Optional을 제거하고 언랩한 값의 스트림이 필요한 것이다. Stream<String>이 필요한 것이다.
아래 코드를 통해 filter와 map을 활용해 결과를 얻을 수 있다.
Stream<Optinal<String>> stream = ...
Set<String> result = stream.filter(Optinal::isPresent)
.map(Optional::get)
.collect(toSet());
하지만 이 과정은 Optional::Stream을 통해 한번에 해결할 수 있다.
아래는 Optional::Stream의 구현부이다. 존재하면 값의 스트림을, 아니면 비어있는 스트림을 반환한다.
Optional::Stream의 결과 Stream이 반환되고 Stream<Stream<String>>이 되는것을 방지하기 위해 flatMap을 사용한다.
디폴트 액션과 Optinal 언랩
Optional에서 값을 얻는 방법을 살펴보자.
- get() : 가장 간단하지만 안전하지 않은 방법이다. 값이 존재하면 값을 반환하지만 없으면 `NoSuchElementException`을 반환한다. 결국 null을 사용하는 것과 다른게 없기 때문이다.
- orElse(): 값이 존재하지 않는 경우 기본 값을 제공한다.
- orElseGet(Supplier<? extends T> other): orElse와 유사한 게으른 버전의 메서드다. Optional에서 값이 없을때만 Supplier를 실행하기에 디폴트 메서드를 만들기 오래걸리거나, Optinal이 비어있을때만 기본값을 생성하고 싶다면 이것을 사용하자.
- orElseThrow(Supplier<? extens X> exceptionSupplier): Optional이 비어있을때 예외를 발생시킨다는 점에서 get과 비슷하지만 어떤 예외를 발생시킬지 예외의 종류를 선택할 수 있다.
- ifPresent(Consumer<? super T> consumer): 값이 존재할때 인수로 넘겨준 동작을 실행할 수 있다.
- ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction): Optional이 비어있을때 실생할 수 있는 메서드를 Runnable로 받는다.
두 Optinal 합치기
Person과 Car를 이용해 저렴한 보험료를 제공하는 보험회사를 찾는 코드가 아래와 같이 있다고 가정하자.
public Insurance findCheapestInsurance(Person person, Car car){
//로직
return cheapestInsurance;
}
여기서 두 Optional 객체 Optional<Car>, Optional<Person>을 인수로 받아 Optional<Insurance>를 반환하는 null safe한 메서드를 만든다고 생각해보자.
map과 filter를 사용해 코드를 작성할 수 있다.
public Optinal<Insurance> nullSafeFindCheapestInsurance(Optional<Person> person, Optional<Car> car){
return person.flatMap(p->car.map(c->findCheapestInsurance(p,c)));
}
처음에 flatMap을 호출할때 값이 비어있으면 empty를 아니면 flatMap 내부의 메서드를 실행하고 이때 car역시 비어있지 않다면, map을 실행해 가장 보험료가 적은 보험을 안전하게 찾을 수 있다.
필터로 특정값 거르기
어떤 보험이름이 특정값과 같은지를 확인하기 위해선 null 체크를 하고, 이름 비교를 해야한다. 이는 아래와 같이 filter와 ifPresent를 ㅌ통ㅇ해 구현할 수 있다.
Optional<Insurance> optInsurance = ...;
optInsurance.filter(insurance -> "CambridgeInsurance".equals(insurance.getName()))
.ifPresent(x->System.out.println("ok"));
filter는 스트림에서와 같이 프레디케이트를 인수로 받는다.Optional은 컬렉션의 크기가 최대 1인 스트림과 활용이 같다.
Optional을 사용한 실용 예제
잠재적으로 null이 될 수 있는 값을 합치기
Map에서는 get메서드의 반환값이 존재하지 않을 경우 null을 반환한다. 값이 존재하지 않는다면 null이 아닌, Optional을 반환하는게 더 적절하다.
Object value = map.get("key");
"key"에 해당하는 value가 없다면 null이 반환될 것이다. 이를 Optional.ofNullable을 사용해 개선할 수 있다.
Optional<Object> value = Optional.ofNullable(map.get("key"));
예외와 Optional 클래스
자바 API에선 값을 제공할 수 없을때 null이 아닌 예외를 반환하기도 한다. 문자열을 정수로 변환하는 `Integer.parseInt(String)`이 그 예인데 NumberFormatException이 발생한다.
이를 정수가 아닌 형태가 있을때 비어있는 Optional을 반환하도록 바꿀 수 있다.
public static Optional<Integer> stringToInt(String s){
try{
return Optional.of(Integer.parseInt(s));
}catch(NumberFormatException e){
return Optinal.empty();
}
}
이러한 유틸리티 클래스를
기본형 Optional을 사용하지 말아야하는 이유
Optional도 기본형 특화 Optional인 OptionalInt, OptionalLong, OptionalDouble 등이 존재한다. 스트림에서는 기본형 특화 스트림으로 성능을 향상할 수 있었지만, Optional은 최대 1개의 요소를 가지고 있기에 성능이 개선되지 않는다. 또한, 기본형 특화 Optional은 map,filter,flatMap등을 제공하지 않고, 기본형 특화 Optional을 사용하면 다른 일반 Optional 과 혼합해서 사용하기 어렵기에 사용을 권장하지 않는다.
'PL > 모던 자바 인 액션' 카테고리의 다른 글
모던 자바 인 액션 - 회고록 (1) | 2023.10.27 |
---|---|
모던 자바 인 액션 - Chap12 (0) | 2023.10.09 |
자바 병렬 프로그래밍 5.2 병렬 컬렉션 (0) | 2023.09.30 |
모던 자바 인 액션 7주차 - Chap 08 (0) | 2023.09.30 |
모던 자바 인 액션 6주차 - Chap07 (0) | 2023.09.21 |