본문 바로가기

함수형 자바

[JAVA] Optional을 사용한 null 처리 - Chapter 09

이번 장에서는 자바가 null 참조를 어떻게 다루는지,

그것을 Optional이나 함수형 API를 통해 어떻게 개선해야 할 지를 다룬다.

 

Null 참조의 문제점

값의 부재시,

원시 타입은 기본값으로 처리하고, 비원시타입의 경우 기본값으로 null로 처리한다.

 

null은 단순히 값이 없는 상태가 아니다.

실제 타입과 관계없이 어떠한 객체 참조와도 호환되는 타입이다.
이 때, null 참조에 접근하려고 시도하면 NullPointException이 발생한다.

 

또한 null은 타입 모호성을 갖고 있다.

구체적인 타입이 없어 모든 타입을 대표 할 수 있다.

+ null일경우 해당 타입이 무엇이든 instanceof를 사용하여 확인 시 항상 false를 반환한다.

자바에서 null을 다루는 방법 (Optional 도입 전)

1) 변수를 null로 초기화 하지 않는다.

null로 초기화 후 재할당 하는 것은 권장되지 않는다.

//비권장
String value = null;

if (condition) {
	value = "true";
} else {
	value = "false";
}

//권장
String value = condition ? "true" : "false";
String value = refactoredMethod(condtion);

 

삼항연산자로 조건을 검사하여 값을 할당해 주거나, 메서드로 값을 직접 할당해준다.

 

2) null 값을 전달하거나 수용 또는 반환하지 않아야 한다.

모든 인수나 반환값은 null을 피해야한다.

메서드나 생성자 오버로딩을 통해 필요 없는 인수가 null 값을 가지는 것을 방지할 수 있다.

위의 작업 후, 원본 메서드에서 null을 허용하지 않도록 해야 한다. (java.util.Objects 의 requireNonNull 메소드 사용)

public record User(long id, String name) {

	public User(long id) {
    	this(id, "n/a")
    }
    
    public User {
    	Object.requireNonNull(name);
    }
    
}

 

 

3) 통제할 수 없는 모든 것을 확인한다.

항상 null일 가능성이 있으므로 꼼꼼하게 검사한다.

 

4) null은 내부 구현에서는 허용된다.

메서드 결과 값을 null을 반환하지 않는 한 내부적으로는 사용 할 수 있다.

 

5) 도구를 활용해 null 검사

null 참조의 경우 변수, 인수, 메서드 반환 타입을 @Nullable or @NonNull로 어노테이션을 활용하여 표시하는 것이 좋다.

도구 기반 접근법은 컴파일 시점에서 null 검사를 제공한다.

 

6) Optional과 같은 특별한 타입 사용

해당 타입을 사용 시 런타임에서 더 안전하게 null을 처리 할 수 있다.

 

Optional

Optional은 null을 일관성 있게 처리하기 위한 전문 타입이며, 모든 함수적 기능의 혜택을 받은 유사 함수형 파이프라인이다.

다만 스트림과는 다르게, 최종 연산 추가 전까지 느긋하게 연결되지는 않고, 호출되는 즉시 수행된다.

호출 시 null 값이 있을 경우, 다음의 호출 체인에 있는 각 메서드를 계속 호출하지만, 파라미터 검증하거나 다음 단계로 넘어가기 위한 것일 뿐, 불필요한 연산은 건너 뛴다고 한다.
null값을 어느 지점에서든 만나면 비어있는 Optional을 반환한다.

 

Optional은 null이 될 수 있는 값을 담고 있는 상자이다. (값을 래핑)

또한 값이 없는 경우를 대비하여 대체할 수 있는 값을 포함하여 그 값의 생명 주기를 관리 할 수 있다. (값의 존재 여부에 따라 코드가 어떻게 행동하는지를 관리 가능)

 

1) Optional 생성

Optional 타입에는 public 생성자가 제공되지 않는다.

대신, 세 가지의 정적 팩토리 메서드가 있다.

 

Optional.ofNullable(T value)

값이 null일 가능성이 있거나 비어 있을 수 있는 경우 null값이 허용되는 새 인스턴스를 만들 때 사용한다.

 

Optional.of(T value)

값이 반드시 null이 아니어야 할 경우 (null일 경우 NullPointerException 발생) 사용한다.

 

Optional.empty()

값이 비어있다는 것을 알고 있는 경우 사용한다.

값이 비어있는 경우 Optional.ofNullable(null)을 호출 하는 대신 이 메서드를 사용하는 것이 좋다. (ofNullable의 경우 객체가 비어있는지 아닌지 검사가 이뤄지기 때문에 불필요한 메서드가 호출된다.)

 

2) 값을 확인하고 대응하기

값의 존재 유무를 확인하고 대응하기 위한 네가지 메서드가 있다.

 

확인을 위한 메서드는 is

boolean isPresent(), boolean isEnpty()

 

대응을 위한 메서드는 if 로 시작한다.

void ifPresent, void ifPresentOrElse

두 메서드는 반환 타입이 없어 사이드 이펙트만 발생하는 코드이다.

Optional<String> value = getValue(something);

value.ifPresentOrElse(System.out::println
	() -> System.out.println("No value"))

 

 

3) 필터링과 매핑

스트림과 유사하게 필터링과 매핑을 위한 세가지 연산 (filter, map, flatMap)이 제공된다.

boolean isActiveAdmin = Optional.ofNullable(permissions)
	.filter(Predicate.not(Permissions::isEmpty)
    	.map(Permissions::group)
    	.flatMap(Group::admin)
    	.map(User::isActive)
    	.orElse(Boolean.False)

 

 

4) (대체) 값 가져오기

'강제 추출'부터 대체값 제공에 이르기 까지 가져오는 방법이 다양하다.

 

T get()

안전 검사를 수행하지 않으며, 미리 값을 확인해야 한다.

값이 없을 때는 NoSuchElementException이 발생한다.

 

T orElse(T other)

T orElseGet(Supplier<T> supplier)

값이 없는 경우 대체값을 제공한다.

Supplier기반 방식은 느긋하게 가져오는 것을 허용한다. (orElse는 값이 비어있던지 있던지 간에 인수로 전달받은 메서드를 실행 하는데에 비해, orElseGet은 값이 없는 경우에만 supplier가 연산된다.)

 

T orElseThrow(Supplier<X> exceptionSupplier)

T orElseThrow()

값이 없는 경우에 특정 예외를 던질 수 있다.

 

Optional<T> or(Supplier<Optional<T>> supplier)

값이 없는 경우 다른 Optional을 느긋하게 반환한다.

호출 체인에 or메서드 전의 메서드에 의해 null값이 반환된 경우더라도, Optional 호출 체인을 반환하게 된다.

 

Stream<T> stream()

값이 있는 경우 해당 값을 유일한 요소로 가진 스트림을 반환,

없는 경우 비어있는 스트림을 반환한다.

아래의 스트림과의 호환성에서 사용된다.

 

Optional과 스트림

1) Optional을 스트림 요소로 활용하기

스트림 내 filter연산에 Optional메서드를 사용할 수 있지만, 결과값으로 Stream<Optional<T>>는 원하는 방식이 아닐 것이다.

이를 Stream<T>로 변환하기 위한 방법은 두가지가 있다.

//filter후 map처리
List<User> users = permissions.stream()
	.filter(Predicate.not(Permissions::isEmpty))
    	.map(Permissions::group)
        .map(Group::admin)
        .filter(Optional::isPresent)
        .map(Optional::orElseThrow)
        .toList()
        
//flatMap 사용
List<User> users = permissions.stream()
	.filter(Predicate.not(Permissions::isEmpty))
    	.map(Permissions::group)
        .map(Group::admin)
        .flatMap(Optional::stream)
        .toList()

 

2) 스트림의 최종 연산

스트림의 최종연산의 반환값에 비어있는 값을 적절히 표현하기 위해 Optiona이 반환 될 수 있다.

특정 요소를 반환하고 싶으면 find메서드를, 특정 요소의 존재만 알고싶다면 match메서드 (anyMatch, noneMatch)를 이용하면 된다.

 

Optional<T> findFirst()

스트림의 첫번째 요소가 Optional로 반환되며, 스트림이 비어있는 경우 빈 Optional을 반환한다.

 

Optional<T> findAny()

임의의 요소가 Optional로 반환되며, 스트림이 비어있는 경우 빈 Optional을 반환한다.

 

단일 값으로 줄일 때에도 Optional을 반환 할 수 있다. (reduce, min, max)

 

원시 타입용 Optional

기본 값이 존재하는 원시 타입 변수도 Optional을 사용하고 싶을 수 있다. (실제로 존재하지 않는다는 것을 표현하고 싶어서)

다만 Optional<T> 제네릭 타입과 원시 타입을 함께 사용하는 것은 불가능 하다.

다만 오토박싱과 특수화된 타입을 사용하는 두가지 방법이 있다.

 

다만 특수화된 타입들은 Optional로부터 상속받지 않아 공통 인터페이스도 공유하지 않으며, 기능면에서도 동일하지 않고, filter, map, flatMap과 같은 연산을 제공하지 않는다.

 

Optional 주의사항

1) 반환 값을 Optional로 하고자 하였으면 어떤 경우에도 null을 반환하지 않는 것이 좋다. (비어있는 Optional 또는 그에 해당하는 원시타입을 반환한다.)

 

2) 식별자에 민감한 메서드 사용시 예상과 다르게 동작 할 수 있다.

public class OptionalComparison {
    public static void main(String[] args) {
        Optional<String> opt1 = Optional.of("test");
        Optional<String> opt2 = Optional.of("test");
        Optional<String> opt3 = Optional.empty();

        // == 비교
        System.out.println("== 비교:");
        System.out.println(opt1 == opt2); // false

        // equals 비교
        System.out.println("equals 비교:");
        System.out.println(opt1.equals(opt2)); // true

        // hashCode 비교
        System.out.println("hashCode 비교:");
        System.out.println(opt1.hashCode() == opt2.hashCode()); // true

        // equals 및 hashCode 비교 시 null 처리
        System.out.println("null 처리 비교:");
        System.out.println(opt1.equals(opt3)); // false
        System.out.println(opt1.hashCode() == opt3.hashCode()); // false
    }
}

 

3) 성능 오버헤드가 발생 할 수 있다.

값의 존재 여부를 확인하거나 대체값을 제공하는 것 외에 추가적인 연산 없이 Optional을 만드는 것은 그다지 의미가 없다.

 

4) 컬렉션 타입의 경우에는 Optional로 매핑하는 것 대신 빈 컬렉션 타입을 반환하는 방식으로 대체한다.

이때도 마찬가지로 컬렉션에 대해 null을 허용하는 것은 좋지 않다.

 

5) 직렬화가 가능한 타입의 private 필드로 사용하기에는 적합하지 않다. (java.io.Serializable 인터페이스 미지원)

getter메서드에서 반환 타입을 Optional로 감싸서 반환하는 형식으로 대체한다.