자바로 프로그램을 개발하면서 한 번이라도 NullPointerException을 껶지 않은 사람은 없을 것이다.
NullPointerException은 모든 자바 개발자를 괴롭히는 예외다.
1965년 토니 호어(Tony Hoare)라는 영국 컴퓨터과학자가 힙에 할당되는 레코드를 사용하며 형식을 갖는 최초의 프로그래밍 언어 중 하나인 알골(ALGOL)을 설계하면서 처음 null 참조가 등장했다.
그는 구현하기가 쉬웠기 때무엔 null을 도입했다고 하였다. 하지만 여러 해가 지난 후 그는 null 및 예외를 만든 결정을 가리켜 십억 달러짜리 실수라고 표현했다.
모든 자바 프로그래머라면 NullPointerException이라는 귀차는 예외가 발생하는 상황을 몸소 겪었을 것이다.
값이 없는 상황을 어떻게 처리할까?
몇몇의 프로그래머들은 값이 있는지 없는지를 알아보려고 null 참조를 허용하고, if 분기문을 활용해 null 값인지 확인하곤 한다. 하지만 이는 많은 문제점을 일으킨다.
null 때문에 발생하는 문제
≫ 에러의 근원이다.
NullPointerException은 자바에서 가장 흔히 발생하는 에러다.
≫ 코드를 어지럽힌다.
때로는 중첩된 null 확인 코드를 추가해야 하므로 가독성이 떨어진다.
≫ 아무 의미가 없다.
null은 아무 의미도 표현하지 않는다. 특히 정적 형식 언어에서는 값이 없음을 표현하는 방법으로는 적절치 않다.
≫ 자바 철학에 위배된다.
자바는 개발자로부터 모든 포인터를 숨겼다. 하지만 null만 포인터가 존재한다.
≫ 형식 시스템에 구멍을 만든다.
null은 어떠한 정보도 포함하고 있지 않으므로 모든 참조 형식에 할당할 수 있기 때문에 null의 할당이 널리 퍼졌을때 어디서 발생하는지 찾기 어렵다.
예를 들어 자동차를 가지고 있는 사람 객체를 중첩 구조로 구현했다고 해보자. 보통에 모든 자동차는 브랜드가 있을 것이다.
@Getter
public class Person {
private Car car;
public String getCarBrandName() {
return car.getBrand().getName();
}
}
@Getter
public class Car {
private String name;
private Brand brand;
}
@Getter
public class Brand {
private String name;
}
만약 Person 클래스에 getCarBrandName 메서드를 사용하게 되면 어떤 문제가 발생할까?
코드에는 아무 문제가 없어 보이지만 자동차가 없는 사람의 경우에는 런타임 시 NullPointerException이 발생하면서 프로그램 실행이 중단될 것이다. 그리고 Person이 null이라면, Brand가 null이라면 어떻게 될까?
대부분의 프로그래머는 필요한 곳에 다양한 null 확인 코드를 추가해서 null 예외 문제를 해결하려 할 것이다.
public String getCarBrandName(Person person) {
if (person != null) {
Car car = person.getCar();
if (car != null) {
Brand brand = car.getBrand();
if (brand != null) {
return brand.getName();
}
}
}
return "Unknown";
}
위 코드에서는 변수를 참조할때마다 null을 확인하여 중간 과정 하나라도 null 참조가 있으면 "Unknown"이라는 문자열을 반환한다.
이와 같은 반복 패턴 코드를 깊은 의심(deep doubt)이라고 부른다.
우리는 모든 자동차에 브랜드가 존재하는 것을 알고 있어서 null 확인을 생략할 수 있지만, 다른 모델링인 경우에는 개발자가 한눈에 판단하기 어렵다. 그리고 변수에 접근할 때마다 if가 추가되면서 코드 들여쓰기 수준이 증가한다.
이를 반복하다 보면 코드의 구조가 엉망이 되고 가독성도 떨어진다.
다른 방법을 사용해보자.
public String getCarBrandName(Person person) {
if (person == null) {
return "Unknown";
}
Car car = person.getCar();
if (car == null) {
return "Unknown";
}
Brand brand = car.getBrand();
if (brand == null) {
return "Unknown";
}
return brand.getName();
}
위 코드는 null 변수가 있으면 즉시 "Unknown"을 반환하는 방법으로 중첩 if 블록을 없앴다.
하지만 이 예제도 좋은 코드는 아니다. 지금 예제에서는 메서드에 4개의 출구가 생겼지만 참조하는 변수가 많아질수록 출구가 더욱 많아질 것이기 때문에 가독성과 유지보수에 좋지 않다.
그리고 만약 누군가가 null 일수도 있는 사실을 깜박하고 한곳이라도 빼먹었다면 말짱 도루묵인 것이다.
위에서 보듯이 null로 값이 없다는 사실을 표현하는 것은 좋은 방법이 아니다.
Optional 클래스 소개
Java8은 선택형값 개념의 영향을 받아서 java.util.Optional라는 새로운 클래스를 제공한다.
Optional은 선택형 값을 캡슐화하는 클래스이다.
값이 있으면 Optional 클래스는 값을 감싼다 반면 값이 없으면 Optional.empty 메서드로 Optional을 반환한다.
null 참조와 Optional.empty()는 차이점이 많다.
null을 참조하려 하면 NullPointerException이 발생하지만 Optional.empty()는 Optional 객체이므로 다양한 방식으로 활용할 수 있다.
위에 예제로 대략 Optional을 어떻게 사용하는지 살펴보자.
@Getter
public class Person {
private Optional<Car> car;
}
@Getter
public class Car {
private String name;
private Brand brand;
}
@Getter
public class Brand {
private String name;
}
null 참조 대신 Optional을 사용해 이 값이 없을 수 있음을 명시적으로 보여준다.
자동차가 없는 사람도 있을 수 있으므로 Car 참조에는 Optional을 사용했지만, 자동차에는 무조건 브랜드가 있으므로 Brand 참조에는 Optional을 사용하지 않았다.
이로인해 내가 아닌 다른 개발자가 코드를 봐도 Person 클래스에는 car값이 있을 수도 있고, 없을 수도 있다는 걸 한눈에 파악할 수 있다.
즉, Optional을 이용해 더 이해하기 쉬운 모델링을 만들 수도 있다.
Optional 객체 만들기
다양한 방법으로 Optional 객체를 만들 수 있다.
빈 Optional
Optional 클래스의 정적 팩토리 메서드 Optional.empty로 빈 Optional 객체를 얻을 수 있다.
Optional<Car> optCar = Optional.empty();
null이 아닌 값으로 Optional 만들기
Optional 클래스의 정적 팩토리 메서드 Optional.of로 null이 아닌 값을 포함하는 Optional을 만들 수 있다.
Optional<Car> optCar = Optional.of(car);
만약 car가 null이라면 즉시 NullPointerException이 발생할 것이다.
Optional을 사용하지 않았다면 car 프로피터에 접근하려 할 때 에러가 발생할 것이다.
null값으로 Optional 만들기
Optional 클래스의 정적 팩토리 메서드 Optional.ofNullable로 null 값을 저장할 수 있는 Optional을 만들 수 있다.
Optional<Car> optCar = Optional.ofNullable(car);
car가 null이면 빈 Optional 객체가 반환된다.(Optional.empty)
Optional의 값을 추출하고 변환하기
Optional 클래스는 Optional 인스턴스에 포함된 값을 추출하는 다양한 방법을 제공한다.
≫ get()
값을 읽는 가장 간단한 메서드면서 동시에 가장 안정하지 않은 메서드다.
만약 빈 Optional 객체에서 get() 메서드를 사용한다면 NoSuchElementException을 발생시킨다.
따라서 값이 반드시 있다고 가정할 수 있는 상황에서만 사용해야 한다.
≫ orElse(T other)
Optional이 값을 포함하지 않을 때 기본값을 제공할 수 있다.
≫ orElseGet(Supplier<? extends T> other)
Optional에 값이 없을때만 Supplier가 실행된다.
효율성 때문에 Optional이 비어있을 때만 기본값을 생성하고 싶다면(기본값이 반드시 필요한 상황) orElseGet을 사용해야 한다.
≫ orElseThrow(Supplier<? extends X> exceptionSupplier)
Optional이 비어있을 때 원하는 예외를 발생시킨다.
≫ ifPresent(Consumer<? super T> consumer)
값이 존재할 때 인수로 넘겨준 동작을 실행할 수 있다. 값이 없으면 아무 동작도 하지 않는다.
≫ ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction)
이 메서드는 Optional이 비어있을 때 실행할 수 있는 Runnable을 인수로 받는다.
map으로 값 추출하기
위에서 예제로 사용한 자동차의 브랜드명을 추출하는 메서드가 있다고 가정해보자.
String brandName = null;
Car car = person.getCar();
if (car != null) {
brandName = car.getBrand().getName();
}
return brandName;
이런 유형의 패턴에 사용할 수 있도록 Optional은 map 메서드를 지원한다. Stream의 map 메서드와 개념적으로 비슷하다.
Optional<Brand> optBrand = Optional.ofNullable(brand);
Optional<String> name = optCar.map(Brand::getName);
map은 Optional이 값을 포함하면 map의 인수로 제공된 함수가 값을 바꾸고, Optional이 비어있으면 아무 일도 일어나지 않는다.
flatMap으로 값 추출하기
map에서 사용하는 방법을 배웠으므로 다음처럼 map을 이용해서 코드를 재구현 할 수 있다.
Optional<Person> optPerson = Optional.ofNullable(person);
Optional<String> brandName = optPerson.map(Person::getCar)
.map(Car::getBrand)
.map(Brand::getName);
하지만 위 코드는 컴파일되지 않는다.
Person 객체에서 Car 참조에 Optional을 사용했기 때문에 Person::getCar는 Optional<Car> 클래스를 반환한다.
Optional<Car> 형식에서 바로 getBrand 메서드를 사용할 수 없기 때문에 컴파일 에러가 발생하게 된다.
이럴때 사용하는 것이 flatMap을 사용할 수 있다. flatMap 역시 Stream의 flatMap와 개념적으로 비슷하다.
flatMap을 사용해 이차원 Optional에서 일차원 Optional로 평준화 해야 한다.
Optional<Person> optPerson = Optional.ofNullable(person);
Optional<String> brandName = optPerson.flatMap(Person::getCar)
.map(Car::getBrand)
.map(Brand::getName);
Car 클래스에 Brand 객체 참조는 Optional을 사용하지 않았으므로 flatMap을 사용할 필요는 없다.
Optional 스트림 조작
java9에서는 Optional을 포함하는 스트림을 쉽게 처리할 수 있도록 Optional에 stream() 메서드를 추가했다.
Optional 스트림을 값을 가진 스트림으로 변환할 때 이 기능을 유용하게 활용할 수 있다.
여러명의 사람들이 가진 자동차의 브랜드명을 조회하는 메서드를 구현한다고 가정해보자.
List<Person> people = List.of(
new Person(Optional.of(new Car("아반테", new Brand("현대")))),
new Person(Optional.of(new Car("K5", new Brand("기아")))),
new Person(),
new Person(Optional.of(new Car("티볼리", new Brand("쌍용"))))
);
Set<String> brandNames = people.stream()
.map(Person::getCar) // Stream<Optional<Car>>으로 변환
.map(car -> car.map(Car::getBrand)) // Stream<Optional<Brand>>으로 변환
.map(brand -> brand.map(Brand::getName)) // Stream<Optional<String>>으로 변환
.flatMap(Optional::stream) // Stream<String>으로 변환
.collect(Collectors.toSet()); // Set으로 값 도출
Optional.stream() 메서드는 각 Optional이 비어있는지 아닌지에 따라 Optional을 0개 이상의 항목을 포함하는 스트림으로 변환한다.
따라서 Optional 값을 스트림으로 변환해 원래 스트림에서 호출하는 flatMap 메서드로 전달할 수 있다.
이 기법을 이용하면 한 단계의 연산으로 값을 포함하는 Optional을 언랩하고 빈 Optional은 건너뛸 수 있다.
두 Optional 합치기
만약 두 Optional 클래스를 인수로 받아 Optional 클래스로 반환해야 한다고 해보자.
예를 들어 어떠한 두 브랜드(Optional<Brand>) 중 가장 저렴한 차(Optional<Car>)를 조회하는 메서드 같은 경우라고 할 수 있다.
인수로 전달한 두 Optional 중 하나라도 비어이있으면 빈 Optional<Car>을 반환하도록 하겠다.
public class Brand {
private String name;
private List<Car> cars;
}
public class Car {
private String name;
private int price;
}
public Optional<Car> getMostCheapCar(Optional<Brand> brand1, Optional<Brand> brand2) {
if (brand1.isPresent() && brand2.isPresent()) {
List<Car> allCars = Stream.of(brand1.get().getCars(), brand2.get().getCars())
.flatMap(Collection::stream)
.collect(Collectors.toList());
Optional<Car> mostCheapCar = allCars.stream()
.min(Comparator.comparingInt(Car::getPrice));
return mostCheapCar;
} else {
return Optional.empty();
}
}
이 메서드에 장점은 시그니처 만으로 둘 다 아무 값도 반환하지 않을 수 있다는 정보를 명시적으로 보여준다는 것이다.
하지만 null 확인 코드와 크게 다른 점이 없다. 이럴때 위에서 확인한 flatMap으로 이를 해결할 수 있다.
public Optional<Car> getMostCheapCar(Optional<Brand> brand1, Optional<Brand> brand2) {
Optional<Car> mostCheapCar = brand1.flatMap(
b1 -> brand2.flatMap(b2 ->
Stream.of(b1.getCars(), b2.getCars())
.flatMap(Collection::stream)
.min(Comparator.comparingInt(Car::getPrice))
)
);
return mostCheapCar;
}
flatMap을 사용해 빈값을 제외하고 가장 저렴한 차를 찾는 로직을 실행할 수 있다. 그 결과 훨씬 더 간단한 코드가 완성되었다.
필터로 특정값 거르기
종종 객체의 메서드를 호출해서 어떤 프로퍼티를 확인해야 할 때가 있다.
만약 자동차를 가지고 있는 사람인지 확인하고 싶다면 Optional 객체에 filter 메서드를 이용할 수 있다.
Optional<Person> optPerson = Optional.of(new Person(Optional.of(new Car("아반테", 23_000_000))));
optPerson.filter(person -> person.getCar().isPresent())
.ifPresent(p -> System.out.println("I have car"));
filter 메서드는 Predicate를 인수로 받는다.
Optional 객체(Optional<Person>)가 값을 가지며 Predicate(person.getCar().isPresent())와 일치한다면 filter 메서드는 그 값을 반환하고 그렇지 않으면 빈 Optional 객체를 반환한다.
'Java' 카테고리의 다른 글
[모던 자바] 디폴트 메서드(default method)란? (0) | 2022.03.18 |
---|---|
[모던 자바] 새로운 날짜와 시간 API(LocalDate, LocalTime, LocalDateTime) (0) | 2022.03.17 |
[모던 자바] DSL(도메인 전용 언어)이란? (2) | 2022.03.15 |
[모던 자바] 람다 표현식과 스트림 테스팅과 디버깅 (0) | 2022.03.14 |
[모던 자바] 디자인 패턴 소개 및 람다를 이용해 리팩터링 하기 (1) | 2022.03.11 |