NullPointerException
NullPointerException은 이름 그대로 pointer가 null인 경우에 발생하는데, Java에서는 pointer보다 reference라는 말이 더 어울릴지도 모르겠다. 이렇게 어떤 reference가 null일 때, 해당 reference를 통해 필드나 메소드 등에 접근하면 NullPointerException이 발생한다. 예를 들면 아래와 같은 경우다.
Car car = null;
car.drive();
위의 두 줄의 코드가 NullPointerException의 모든 것이라고 보면 된다. 첫 번째로 reference가 null을 참조하고 있어야 하며, 두 번째로 그 reference를 통해 필드나 메소드에 접근하는 것이 바로 NullPointerException이다.
그렇다면 primitive 타입에서는 어떨까? 일단 primitive 타입은 선언하는 순간 0에 대응하는 값(boolean은 false, 숫자타입은 0 등)으로 초기화되어 null이 될 수가 없다. 게다가 primitive 타입은 필드도, 메소드도 없기 때문에 절대로 NullPointerException이 발생하지 않는다.
기존의 Java에서는 직접 null 체크를 하는 방식으로 NullPointerException을 피했다. 예를 들어 아래와 같은 코드를 생각해보자.
person.getCar().getNavigation().search("서울역");
null이 없다면 정말 아름다운 코드다. 그러나 모든 사람이 자동차가 있는 것이 아니고, 내비게이션 옵션이 없는 차도 존재할 것이다. 따라서 null 체크 코드를 추가해보자.
if (person == null) {
return null;
}
Car car = person.getCar();
if (car == null) {
return null;
}
Navigation navi = car.getNavigation();
if (navi == null) {
return null;
}
return navi.search("서울역");
그냥 NullPointerException을 무시하고 내버려두고 싶을 정도로 쓸데없는 코드가 많아졌다. 그래도 null인 경우에 다른 값을 넣어주는 등 다른 로직을 추가해야 하는 경우에는 어쩔 수 없이 위와같이 코드를 추가해야한다.
다른 언어들도 비슷한 문제를 겪었고, NullPointerException을 피하기 위한 방법들이 제시되었다.Groovy에서는 객체의 필드를 참조할 때 '안전 내비게이션 연산자'라는 것을 제공한다. 필드가 null이 아니면 값을 반환하고, null이면 null을 반환하고 끝낸다. 위 예시는 Groovy에서 아래와 같이 바뀐다.
person?.car?.navigation.search("서울역")
만약 car가 null이면 navigation을 탐색하지 않고 null을 반환하고 끝낸다. 메소드 호출이 100번이든 1000번이든 사용자는 최종 결과만 null 체크 해주면 된다. Java 8에서도 이와 비슷한 기능을 제공하는데, 그것이 바로 Optional 클래스다.
Optional
Optional은 Java 8부터 제공되는 기능으로, 값을 캡슐화하기 위한 wrapper 클래스다. Optional 클래스는 단지 값을 감싸주는 것 이외에 여러 기능들을 제공한다. 일단 먼저 위의 예시를 Java 8 버전으로 바꿔보자.
opPerson.flatMap(Person::getCar)
.flatMap(Car::getNavigation)
.map(n -> n.search("서울역"));
위 코드에 나와있지 않지만 opPerson의 타입은 Optional<Person>이고, Person 클래스의 getCar의 리턴 타입은 Optional<Car>, Car 클래스의 getNavigation의 리턴 타입은 Optional<Navigation>로 바뀌었다고 가정한다.
첫째 줄부터 보면, opPerson에서 flatMap을 호출해서 Person 클래스의 getCar 메소드를 메소드 참조로 넘겨주었다. 이것은 opPerson이 감싸고 있는 Person타입의 인스턴스가 null이 아니면 flatMap의 파라미터로 넘어온 메소드를 실행하고, null이면 빈 Optional을 반환한다. 첫째 줄의 결과는 Optional<Car> 타입이 된다.
둘째 줄 역시 마찬가지로 null이 아닌 경우 getNavigation을 실행하고, null인 경우 빈 Optional을 반환하여 결과는 Optional<Navigation> 타입이다.
마지막 셋째 줄은 map으로 람다 표현식을 파라미터로 넘겨주었다. 이것도 flatMap과 비슷하게 Optional 내부의 값이 null이 아닌 경우에 메소드를 실행하고, null이면 빈 Optional을 반환한다. Navigation의 search 메소드의 리턴 타입이 List<SearchResult>라고 하면, 최종 결과의 타입은 Optional<List<SearchResult>>가 된다.
위 코드의 결과는 opPerson과 중간 결과들 중 어느 하나라도 빈 Optional이면 최종 결과는 빈 Optional이 되는 것이고, 모두 비어있지 않으면 최종 결과도 비어있지 않게 된다. Groovy의 예시와 비슷하다고 볼 수 있다.
그렇다면 위 코드에서 flatMap과 map의 차이는 무엇일까? 그것은 파라미터로 전해진 메소드를 보면 알 수 있다. getCar, getNavigation은 리턴 타입이 Optional이고, search의 리턴 타입은 Optional이 아니다. 즉, map을 사용하면 결과가 Optional<Optional<Car>>타입이 되는데, 이런 경우에 map 대신 flatMap을 사용하면 Optional<Car>를 반환한다.
Optional은 스트림 API와 매우 닮아있다. Stream이나 Optional 클래스로 실제 객체를 감싸주는 것도 그렇고, 메소드 체이닝으로 여러 메소드를 연결해서 사용하는 것도, 람다 표현식이나 메소드 참조로 동작 파라미터화를 이용하는 것도 비슷하다. 이것은 아마도 스트림에서 for, if 등을 내부적으로 숨겼던 것처럼 null 체크코드를 내부로 숨기기 위해 고민한 결과가 아닐까 싶다.
Optional 생성
위에서 이미 만들어진 Optional들을 사용하는 것을 살펴봤다면, 이번에는 Optional을 직접 생성하는 방법을 알아보자. 경우의 수는 3가지이다.
첫 번째는 비어있는 Optional이다.
Optional<Car> opt = Optional.empty();
Optional은 static 메소드로 empty를 제공한다. 내부에 값을 저장하지 않고 있기 때문에 꺼낼 수 없다. null과 비슷하지만, 위의 예시처럼 파이프라인을 구성했을 때 NullPointerException이 발생하지 않고, empty를 반환한다.
두 번째는 null이 아닌 값을 가진 Optional이다.
Optional<Car> opt = Optional.of(car);
of 메소드는 null이 아닌 값만 가질 수 있다. 만약 위 코드에서 car가 null이라면 NullPointerException이 일어난다. 일반적으로 NullPointerException은 reference를 사용할 때 뒤늦게 발생하지만, Optional.of를 사용하면 생성할 때 미리 알 수 있게되어 사용할 때 null이 아님을 보장할 수 있다.
마지막은 of와 비슷하지만 null을 허용하는 Optional이다.
Optional<Car> opt = Optional.ofNullable(car);
우리가 어떤 객체가 null인지 아닌지 항상 판단할 수 있다면 NullPointerException이 절대 일어나지 않을 것이다. 실제로는 null일 수도 있고, 아닐 수도 있는 상황이 많이 발생한다. 예를 들어 외부 라이브러리를 사용할 때 어떤 메소드가 null을 허용하는지 확신할 수 없다. 따라서 이런 경우를 대비해 null일 수도, 아닐 수도 있는 ofNullable을 제공한다.
Optional 값 꺼내기
Optional은 결국 wrapper 클래스이기 때문에 중요한 것은 내부의 값이다. Optional은 여러 가지 경우에 사용할 수 있는 메소드를 제공한다. 가장 간단한 것은 get이다.
Car car = opt.get();
get은 정말 단순히 내부의 값을 꺼내는 것이다. Optional은 내부의 값이 있을 수도, 없을 수도 있는데, 딱 봐도 위험해보인다. 만약 값이 없다면 NoSuchElementException이 발생한다. 내부의 값이 있다는 것을 확신할 때만 사용해야 한다.
값이 없는 경우에 NoSuchElementException 말고 다른 예외를 던지고 싶을 수도 있다.
opt.orElseThrow(() -> new RuntimeException());
이 두가지 방법은 opt가 값을 가져야 한다는 것을 강제하고 싶을 때 사용할 수 있다.
만약 값이 없는 경우에 다른 값을 사용하고 싶다면, 기본값을 제공하는 메소드가 있다.
Car car = opt.orElse(new Truck());
만약 opt의 내부에 값이 있으면 get과 같이 동작하고, 없다면 새로운 트럭을 반환한다. 그러나 이 코드는 opt의 값이 있든 없든 상관없이 Truck 객체를 만든다. orElse의 파라미터는 Truck 객체이기 때문이다. 만약 Truck을 만드는 데 시간이 오래걸린다면 opt의 값이 있는 경우에는 큰 손해다. 따라서 opt의 값이 없는 경우에만 코드를 실행하도록 최적화된 메소드가 있다.
Car car = opt.orElseGet(() -> new Truck());
위와 같이 Supplier를 사용하면 파라미터로 넘겨준 것은 단순히 코드일 뿐, 그것을 실행한 것이 아니다. 따라서 동작 파라미터화로 코드의 실행을 필요할 때까지 미룰 수 있다.
Optional의 값으로 결과를 반환하는 것이 아니라 단순히 소비하는 경우에는 Consumer를 이용할 수 있다.
opt.ifPresent(Car::drive);
opt값이 있으면 메소드를 실행하고, 없으면 아무 일도 일어나지 않는다. opt의 값이 있든 없든 반환값은 없다. Java 9에서는 값이 없는 경우에 Runnable을 실행할 수 있는 메소드를 제공한다.
opt.ifPresentOrElse(Car::drive, () -> System.out.println("No Car"));
참고로 Optional에는 or라는 메소드도 있는데, orElse, orElseThrow, orElseGet과 완전히 다른 역할이다. orElse, orElseThrow, orElseGet은 내부의 값을 꺼내기 위한 용도이고, or는 Optional이 비어있는 경우 다른 Optional을 반환하기 위해서 사용한다.
Optional<Car> newOpt = opt.or(() -> Optional.of(new Car()));
주의할 점
기본형 특화 Optional은 사용할 이유가 없다
스트림과 비슷하게 Optional도 OptionalInt, OptionalLong 등 기본형 특화 클래스를 제공한다. 그러나 실제로 사용했을 때 얻을 수 있는 이점이 거의 없다. 스트림에서는 하나의 스트림 안에 수십만개의 요소가 있을 수 있다. 따라서 기본형 특화 클래스를 사용했을 때, 박싱-언박싱을 없애 성능향상이 가능했다. 그러나 Optional은 값이 하나 뿐이기 때문에 성능에는 영향이 미미하다. 게다가 map, filter같은 메소드를 지원하지 않기 때문에 약간의 제약이 있다. 따라서 기본형 특화 Optional은 사용할 이유가 없다.
직렬화 불가
Optional은 Serializable 인터페이스를 구현하지 않는다. 따라서 Optional을 필드로 사용하는 클래스는 직렬화할 수 없다. 따라서 직렬화가 필요하다면 필드는 null을 허용하고, getter에서 ofNullable로 감싸서 반환하는 등의 방법을 사용해야 한다.
3줄 요약
- NullPointerException은 null을 가리키는 reference에 메소드 호출이나 필드 접근시에 일어난다
- Optional은 객체를 포장하는 wrapper 클래스로, null 체크 코드를 내부로 숨긴다
- Optional을 파이프라인으로 연결하여 스트림처럼 사용할 수 있다
'Java > 모던 자바 인 액션' 카테고리의 다른 글
[Java] 모던 자바 인 액션(7) - default 메소드 (0) | 2023.03.04 |
---|---|
[Java] 모던 자바 인 액션(6) - 새로운 날짜, 시간 API (0) | 2023.03.03 |
[Java] 모던 자바 인 액션(4) - 스트림 API (0) | 2023.02.28 |
[Java] 모던 자바 인 액션(3) - 함수형 인터페이스와 람다 표현식 (1) | 2023.02.26 |
[Java] 모던 자바 인 액션(2) - 동작 파라미터화 (0) | 2023.02.25 |