[JAVA] Null과 Optional

buckshot·2024년 5월 9일

JAVA

목록 보기
4/18
post-thumbnail
모던 자바 인 액션을 읽고 해당 서적을 기반으로 일부 내용을 작성 했습니다.

Null

Null은 자바 언어에서 아무런 값이 없음을 나태내는 값이다. 이는 참조 변수가 객체를 참조하지 않을 때 사용이 된다.

자바라는 언어로 프로그램을 개발하면서 여러번의 NullPointerException 예외를 경험을 했다.

NullPointerException란?
Null값을 갖는 참조 변수를 통하여 객체의 멤버나 메소드에 접근하려고 할 때 발생한다. 다시 말해 객체가 할당되지 않은 상태에서 객체의 멤버나 메소드를 사용하려고 시도할 때 발생한다.
EX)

String str = null;
int length = str.length(); // NullPointerException 발생

간단한 예제 코드로 알아보겠다.

public class Person {
	private Car car;
    public Car getCar() { return car; }
}
public class Car {
	private Insurance insurance;
    public Insurance getInsurance() { return insurance; }
}
public class Insurance {
	private String name;
    public String getName() { return name; }
}

이렇게 사람, 차량, 보험 클래스가 있다고 하자
그러면

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

이 코드는 아무런 문제가 없을까?
보기에는 아무런 문제는 없어 보이지만 만약 해당 사람이 차량이 없을 때 getCar()메소드를 호출하면 보험 정보를 반환을 할 때 NullPointerException이 발생하는 문제가 생긴다.

이러한 문제를 해결하기 위해서는 가장 간단한 방법으로 다양한 곳에서 null 확인 코드를 추가를해서 해결을 하면 된다.

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

이렇게 조건문을 통하여 해당 객체가 Null인지 아닌지 간단하게 해결이 가능하다.

하지만 이렇게 모든 변수가 Null인지 의심하므로 변수수를 접근할 때마다 중첩된 조건문이 추가되면서 코드 들여쓰기 수준이 증가한다. 이와 같은 반복 패턴의 코드를 깊은 의심(deep doubt)라고 부른다. 이렇게 반복을 하다보면 코드의 구조가 엉망이 되고 가독성이 현저하게 떨어진다.

조금 더 좋은 해결 방법으로는

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

이렇게 중첩 조건문을 제거를하고 각 객체가 Null일 경우 즉시 'Unknown'을 반환을 한다. 하지만 이 코드 또한 좋지 못하다.
그 이유로는 getCarInsuranceName 메소드의 출구가 4개로 많아지면서 유지 보수가 어려워 졌다는 점이다. 추가적으로 Null일때 반환되는 값이 세 곳에서 반복이 되고 있다.


Null이 야기하는 문제점

  • 에러의 근원이다.
    NullPointerException은 자바에서 가장 흔하게 일어나는 예외이다.
  • 코드를 더럽게 만든다.
    때로는 중첩 조건문을 사용하여 Null 확인 코드를 추가를 하면서 가독성이 현저하게 떨어진다.
  • 아무런 의미가 있지 않다.
    정적 형식 언어에서는 값이 없음을 표현하는 방법으로는 부적합하다.
  • 자바의 철학에 위배가 된다.
    자바는 개발자로 하여금 모든 포인터를 숨겼다. 하지만 Null 만큼은 예외다.
  • 형식 시스템에 구멍을 만들어 낸다.
    Null은 무형식이며 아무런 정보가 없기 때문에 참조형식에 Nul을 할당할 수 있다. Null이 할당되기 시작하면 시스템에 다른 부분으로 퍼졌을 때 어떤 의미로 사용이 되었는지 파악할 수 없다.

다른 언어에서의 Null

구르비 언어 같은 경우에는 안전 네비게이션 연산자 (?.) 를 사용한다.

   def carnsuranceName = person?.car?.insurance?.name

이러면 호출체인에 Null인 참조가 있으면 결과로 null이 반환된다.

또 다른 언어 하스켈과 스칼라 같은 함수형 언어에서는 Maybe라는 형식을 제공 했고, 스칼라는 T 형식의 값을 갖거나 아무런 값도 갖지 않을 수 있는 Option[T]라는 구조를 제공했다.


Optional의 등장

앞에서 다른 언어에서는 어떻게 처리하는지에 대하여 알아보았다.
그럼 자바8 에서는 어떤 기능을 제공 했을까?

자바8은 '선택형값' 개념의 영향을 받아서 java.util.Optional<T> 라는 새로운 클래스를 제공한다.
이 클래스는 궁극적으로 좀 더 좋은 API를 설계하는 방법을 취득하게 해준다.

Optional은 선택형값을 캡슐화하는 클래스다.

Optional은 값이 있으면 해당 값을 감싸고, 값이 없으면 Optional.empty 메소드로 Optional을 반환을 한다.

여기서 의문이 드는 부분이 있다. Null을 참조하는 것과 Optional.empty은 어떤 차이점이 존재하는가????
이 부분에서 두개는 의미상으로 비슷하지만 실제로는 차이점이 있다.

Null을 참조를 하면 NullpointException이 발생을 하지만, Optional.empty()는 Optional 객체이므로 다양한 방법으로 활용이 가능하다는 부분에서 차이점이 있다.

public class Person {
	private Optional<Car> car;
    public Optional<Car> getCar() { return car; }
}
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 클래스를 사용하면서 기존 보다는 의미가 좀 더 명확해졌음을 확인할 수 있다.
Optional<>을 사용하여 해당 객체를 소유했을 수도 아닐 수도 있다.
보험의 이름 같은 경우는 Optional<String>이 아닌 String으로 선언 한 이유는 보험이 있을 경우 회사 이름이 무조건 있어야 하는 것을 보여준다.

Optional 적용

Optional을 사용하기 위해서는 Optional객체를 생성을 만들어야 한다.

  • 빈 Optional
    정적 팩토리 메소드 Optinal.empty()로 빈 Optional을 얻을 수 있다.

    Optional<Car> optCat = Optinal.empty();
  • Null이 아닌 값으로 Optional
    정적 팩토리 메소드 Optional.of를 이용하여 Null이 아닌 값을 포함하는 Optional을 만들 수 있다.

    Optional<Car> optCat = Optinal.of(car);

    이 때 car가 Null이라면 NullpointException이 발생을한다.

  • Null값으로 Optional
    정적 펙토리 메소드 Optional.ofNullable를 이용하여 Null을 저장할 수 있는 Optional을 만들 수 있다.

    Optional<Car> optCat = Optinal.ofNullable(car);

    여기서 car가 Null이면 빈 Optional이 반환이 된다.


Optional 값 추출하고 변환

사람, 사량, 보험의 예로 보험의 이름을 갖고 올 때

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

이 처럼 insurance가 Null인지 확인을 먼저 하고 정보를 갖고온다.

Optinal을 사용하여 정보를 갖고 올 때 Optional에서 Map 메소드를 지원을 한다.

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

Optional의 map메소드는 스트림의 map메소드와 개념적으로 비슷하다.

스트림에서의 map 메소드는 스트림의 각 요서에 제공된 함수를 적용하는 연산

Optional에서의 map은 Optional 객체를 최대 요소의 개수가 한 개 이하인 데이터 컬랙션으로 생각을 하면 된다.

그러면 앞에서 본 getCarInsuranceName(Person person) 메소드에서는 어떻게 적용이 가능할까?

public String getCarInsuranceName(Person person) {
	return person.getCar().getInsurance().getName();
}
Optional<Person> optPerson = Optional.of(person);
Optional<String> name = 
		optPerson.map(Person :: getCar)
        		 .map(Car :: getInsurance)
                 .map(Insurance :: getName);

이렇게 작성이 가능하다. 하지만 이 처럼 작성된 코드는 컴파일이 불가능 하다.
왜냐하면 optPerson의 형식은 Optional<Person>이므로 map 메소드 호출이 가능하다. 하지만 getCar 메소드는 Optional<Car>을 반환을 하기 때문에 연산 결과로 Optional<Optional<Car>> 형태(중첩 Optional)가 된다.


이러한 문제를 해결하기 위해서 flatMap을 사용을 한다.

스트림에서의 flatMap은 함수를 인수로 받아 다른 스트림을 반환을 해주는 메소드이다. 보통 인수로 받은 함수를 스트림의 각 요소에 적용하면 스트림 내부에 스트림이 만들어진다. 하지만 flatMap은 인수로 받은 함수를 적용해서 생성된 각각의 스트림에서 콘텐츠만 남긴다. 다시 말해, 함수를 적용해서 생성된 모든 스트림이 하나의 스트림으로 병합되어 평준화한다.

public String getCarInsuranceName(Optional<Person> person) {
	return person.flatMap(Person :: getCar)
    			 .flatMap(Car :: getInsurance)
                 .map(Insurance :: getName)
                 .orElse("Unknown");
}


Optional에서 Stream()

자바9에서 Optional을 포함하는 스트림을 쉽게 가공할 수 있도록 Optional에 Stream() 메소드를 추가했다.
Optional 스트림을 값을 갖는 스트림으로 변환을 할 때 이 기능을 유용하게 활용이 가능하다.

public Set<String> getCarInsuranceNames(List<Person> persons) {
	return persons.stream()
    			  .map(Person :: getCar)
                  .map(optCar -> optCar.flatMap(Car :: getInsurance))
                  .map(optInsurance -> optInsurance(Insurance::getName))
                  .flatMap(Optional::stream)
                  .collect(toSet());
}
Stream<Optional<String>> stream = ...;
        Set<String> result = stream.filter(Optional::isPresent)
                                    .map(Optional::get)
                                    .collect(toSet());

Optional의 stream을 이용하여 한 번의 연산으로 같은 결과를 얻을 수 있다.


Default 액션과 Optional 언랩

Optional 클래스는 인스턴스에 포함된 값을 읽는 다양한 방법을 제공한다.

  • get()
    값이 있으면 반환하고 없으면 NoSuchElementException을 발생시킨다.

  • orElse(T other)
    Optional이 값을 포함하지 않을 때 기본 값을 제공할 수 있다.

  • orElseGet(Supplier<? extends T> other)
    Optional 값이 없을 때만 Supplier가 실행된다.
    default 메서드를 만드는데 시간이 걸리거나 Optional이 비어있을 때만 기본값을 생성하고 싶을 때, 사용한다.

  • orElseThrow(Supplier<? extends X> exceptionSupplier)
    Optional이 비어있을 때 예외를 발생시킨다.

  • ifPresent(Consumer<? super T> consumer)
    값이 존재할 때 인수로 넘겨준 동작을 실행할 수 있다.

  • ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction)
    Optional이 비어있을 때 실행할 수 있는 Runnable을 인수로 받는다.


특정값 거르기

보험 회사 중 'ABC'라는 이름의 회사가 있는지 확인을 해야한다. 라고 가정을 했을 때

Insurance insurance = ...;
if(insurance != null && "ABC".equals(insurance.getName())) {
System.out.println("ok");
}

와 같이 조건문을 사용하여 특정 이름의 보험이 있을 경우 동작이 가능하도록 구현을 할 수 있다.

Optional객체의 Filter를 사용하여 구현도 가능하다

Optional<Insurance> optInsurance = ...;
optInsurance.filter(insurance -> 
						"ABC".equals(insurance.getName()))
            .ifPresent(x -> System.out.println("ok"));

처럼 특정 조건과 일피하면 값을 반환하고 아니면 빈 Optional객체를 반환한다.


추가적으로 Optional 클래스 메소드에 대한 정리한 내용이다.

profile
let's go insane

0개의 댓글