Java 8 Optional 살펴보기

Updated:

1. Intro

Java 를 이용하여 개발하면서 가장 많이 발생하는 예외중 하나는 NPE(NullPointerException) 입니다. NPE 예외를 피하기 위해서는 null 체크를 필수로 해줘야 하는데, 확인해야 하는 변수가 많아질수록 코드가 복잡해집니다.

그러므로 null 대신 초깃값으로 사용하기도 권장합니다.

위와 같은 이유로 Optional 클래스가 Java 8 에서 최초로 도입되었습니다. 간단한 예제를 통해서 알아보겠습니다. 아래와 같은 Person 클래스가 있다고 가정하겠습니다.

class Person {
	  String name ;
	  Integer age ;
}

Service 로직에서 Person 정보를 아래와 같이 가져온다고 가정하겠습니다.

Person person = personService.getPerson() ;
System.out.println("name : " + person.getName()) ;
System.out.println("age : " + person.getAge()) ;

person 객체가 null 이 아니면 정상적으로 출력이 되지만 null 일 경우 NPE 예외가 발생합니다. 위 예외 처리를 해결하기 위해 if 문을 활용하면 아래와 같이 작성해야 합니다.

Person person = personService.getPerson() ;
if (person != null) {
    System.out.println("name : " + person.getName()) ;
    System.out.println("age : " + person.getAge()) ;
}

간단하게 해결할 수 있습니다. 하지만 여기서 아래와 같이 새로운 요구사항이 발생합니다.

  • 이름과 나이의 값이 null 일 경우에는 노출해주지 마세요.
Person person = personService.getPerson() ;
if (person != null) {
    if (person.getName() != null) {
        System.out.println("name : " + person.getName()) ;
    }
    if (person.getAge() != null) {
        System.out.println("age : " + person.getAge()) ;
    }
}

if 문으로 위 요구사항을 해결했지만 점점 코드가 복잡해지고 너저분해지는것을 알 수 있습니다. 위 사항을 Optional 클래스로 간단히 해결할 수 있습니다. 아래 예시를 통해 알아보겠습니다.

Person person = personService.getPerson() ;
Optional<Person> personOptional = Optional.ofNullable(person);
personOptional.map(Person::getName).ifPresent(name -> System.out.println("name : " + name));
personOptional.map(Person::getAge).ifPresent(age -> System.out.println("age : " + age));

위처럼 if 분기문으로 나누지 않고 함수형으로 코드를 작성할 수 있어 가동성을 높일 수 있습니다.

하지만 Optional 을 사용할 때 중요한 사항이 하나 있습니다. Optional 은 반환할 값이 없을 때 null 대신 Wrapper 로 감싸는 타입입니다. 즉, Optional 을 반환하는 메소드를 만들 때 절대 null 을 반환하는 Optional 을 리턴하면 안됩니다.

왜냐하면, Optional 값을 Wrapping 하고 다시 풀고, null 일 경우에는 대체함수를 호출해야 하기 때문에 오버헤드가 발생하여 성능저하가 발생합니다. Optional 을 사용하는 가장 좋은 상황은 메소드의 결과를 알 수 없으며, 클라이언트가 이 상황을 특별하게 처리해야 할 때 Optional<T> 를 반환하면 됩니다.

간단한 예제를 알아봤으니 Optional 클래스에서 제공하는 메소드들을 알아보겠습니다.

2. Creating Optional Objects

먼저 Optional 객체를 생성하는 메소드들을 알아보겠습니다.

2.1. Optional.of

of 메소드는 값이 null 인 경우 NullPointerException 예외가 발생합니다. 반드시 값이 있어야 하는 객체인 경우 해당 메서드를 사용하면 됩니다.

Optional<String> optional = Optional.of("result");

2.2. Optional.ofNullable

값이 null 인 경우 비어있는 Optional 을 반환합니다. of 메소드와는 다르게 값이 null 일수도 있는 것은 해당 메서드를 사용하면 됩니다.

Optional<String> optional1 = Optional.ofNullable("result");
Optional<String> optional2 = Optional.ofNullable(null);

2.3. Optional.empty

empty 메소드를 이용하면 null 값을 전달 할 때 예외가 발생하지 않으며 비어있는 Optional 객체를 반환할 수 있습니다.

Optional<String> optional = Optional.empty();

3. Optional Object Intermediate Processing

Optional 객체를 여러 메서드를 사용하여 값을 필터링하거나 다른 값으로 변환할 수 있습니다. Java 8 에서 제공하는 Stream 클래스처럼 Optional 도 메소드 체이닝을 통해 여러 개의 중간 연산을 처리하고 최종적으로 종단 처리 메서드를 통해 최종 결과를 반환할 수 있습니다.

Optional 객체를 중간처리할 수 있는 메소드에 대해 알아보겠습니다.

3.1. Optional.filter

filter 메서드를 활용하면 원하는 값을 갖는 Optional 객체만 필터링할 수 있습니다. 연도를 비교하는 간단한 예시를 통해 알아보겠습니다.

isPresent 메소드는 4절에서 알아보겠습니다.

Integer year = 2021;
Optional<Integer> yearOptional = Optional.of(year);
// True
boolean is2021 = yearOptional.filter(y -> y == 2021).isPresent();
// False
boolean is2022 = yearOptional.filter(y -> y == 2022).isPresent();

다음으로 복잡한 예시를 알아보겠습니다. Person 객체 들의 나이가 범위 내에 있는지 확인하는 코드를 Optional 사용 여부에 따라 비교해보겠습니다.

먼저 Optional 이 없는 코드를 살펴보겠습니다.

public boolean isCheck1(Person person) {
    boolean isInRange = false;
    
    if (person != null && person.getAge() != null && 
            (person.getAge() >= 10  && person.getAge() <= 30)) {
        isInRange = true;
    }
    
    return isInRange; 
}

Optional 을 사용해서 코드를 작성해보겠습니다.

public boolean isCheck2(Person person) {
    return Optional.ofNullable(person)
            .map(Person::getAge)
            .filter(p -> p >= 10)
            .filter(p -> p <= 30)
            .isPresent();
}

3.2. Optional.map

map 메서드로 Optional 값을 변환할 수 있습니다.

List<String> list = Arrays.asList(
        "Alice", "Bob", "Charlie", "David");
Optional<List<String>> listOptional = Optional.of(list);
// 4
int size = listOptional
        .map(List::size)
        .orElse(0);

위 예제는 Optional 객체 안에 문자열 목록을 래핑하고 map 메소드를 사용하여 포함된 목록에 대한 작업을 수행합니다. 위에서 수행하는 작업은 리스트 크기를 반환하는 것입니다.

mapfilter 메소드를 함께 사용하면 더 유용한 작업을 진행할 수 있습니다. 사용자가 입력한 비밀번호의 정확성을 확인하는 예제를 통해 알아보겠습니다.

String password = " password ";
Optional<String> passOpt = Optional.of(password);

// false
boolean correctPassword = passOpt
        .filter(p -> p.equals("password"))
        .isPresent();

// true
correctPassword = passOpt
        .map(String::trim)
        .filter(p-> p.equals("password"))
        .isPresent();

3.3. Optional.flatMap

map 메소드와 마찬가지로 값을 변환하는 대안으로 flatMap 메서드도 있습니다. 차이점은 map 은 래핑되지 않은 경우에만 값을 변환하는 반면 flatMap 은 래핑 된 값을 가져와 변환하기 전에 래핑 해제한다는 것입니다.

이전 예제에서는 Optional 인스턴스에서 래핑하기 위해 간단한 StringInteger 개체를 만들었습니다. 하지만 대부분 상황에서 복잡한 객체의 접근자로부터 객체를 반환받습니다. 예제를 통해 알아보겠습니다.

class Person {
	  String name ;
	  Integer age ;
	  
	  public Person() {}
	  
	  public Person(String name, Integer age) {
		  this.name = name ;
		  this.age = age ;
	  }

	  public Optional<String> getName() {
		  return Optional.ofNullable(name);
	  }

	  public Optional<Integer> getAge() {
		  return Optional.ofNullable(age);
	  }
}

위와 같이 래핑 된 Person 클래스를 이용하여 아래 예제를 사용하면 됩니다.

Person person = new Person("Alice", 26);
Optional<Person> personOptional = Optional.of(person);

Optional<Optional<String>> nameOptionalWrapper  
    = personOptional.map(Person::getName);
Optional<String> nameOptional 
    = nameOptionalWrapper.orElseThrow(IllegalArgumentException::new);

// Alice
String name1 = nameOptional.orElse("");
System.out.println(name1) ;

// Alice
String name = personOptional
        .flatMap(Person::getName)
        .orElse("");
System.out.println(name);

위 코드에서는 Person 개체의 이름을 검색하는 예제입니다.

세 번째 명령문에서 map 메소드와 flatMap 메소드로 동일한 작업을 수행하는 방법의 차이를 주의하셔서 확인하면 됩니다.

map 함수를 이용하여 반환하면 추가로 래핑 된 객체를 반환하기 위해 추가 호출을 해야 하는데 flatMap 을 사용하면 암시적으로 래핑을 해제시키는 것을 알 수 있습니다.

4. Optional Object Result Processing

마지막으로 종단 처리에 대해 알아보겠습니다. 종단 처리는 Optional 객체의 메소드 체이닝을 끝낸다는 의미입니다.

4.1. Optional.isPresent

최종적으로 연산을 끝낸 후 생성된 Optional 객체가 존재하는지를 확인합니다.

// true
Optional.ofNullable("result")
    .isPresent();
// false
Optional.ofNullable("result")
    .filter(val -> "false".equals(val))
    .isPresent(); 

4.2. Optional.ifPresent

ifPresent 메소드를 사용하면 값이 null이 아닌 것으로 확인되면 래핑 된 값에 대해 일부 코드를 실행할 수 있습니다.

Optional<String> opt = Optional.of("result");
opt.ifPresent(name -> System.out.println(name.length()));

4.3. Optional.get

get 메서드는 Optional 객체에서 값을 꺼낼 수 있는 가장 간단한 메소드입니다.

하지만 값이 null 인 경우에 NullPointerException 예외가 발생합니다.

Optional<String> opt = Optional.of("result");
String name = opt.get();

4.4. Optional.orElse

orElse 메서드는 Optional 객체 내부에 값을 조회하는 데 사용합니다. 이 메서드는 래핑 된 값이 있으면 반환하고, 그렇지 않으면 인수를 반환합니다.

String nullName = null;
String name = Optional.ofNullable(nullName).orElse("result");

4.5. Optional.orElseGet

orElseGet 메소드는 orElse와 유사합니다. 차이점은 Optional 값이 없으면 반환할 값을 가져오는 대신 공급자 함수(Supplier Function)를 사용하여 호출 값을 반환합니다.

String nullName = null;
String name = Optional.ofNullable(nullName).orElseGet(() -> "result");

4.6. Optional.orElseThrow

orElseThrow 메소드는 orElseorElseGet 의 뒤를 이으며 최종적으로 연산을 끝낸후 Optional 객체가 비어있으면 예외가 발생합니다.

String nullName = null;
String name = Optional.ofNullable(nullName).orElseThrow(
    IllegalArgumentException::new);

4.7. Difference Between orElse and orElseGet

Optional 개념을 처음 접하면 orElseorElseGet 의 차이점을 모르는 경우가 많습니다.

하지만 차이점을 명확히 모르면 코드의 성능에 크게 영향을 줄 수 있습니다. 이를 예시를 통해 알아보겠습니다. 먼저 인수를 사용하지 않고 기본값을 반환하는 메소드를 만들겠습니다.

public String getDefaultValue() {
    System.out.println("getDefaultValue is Call");
    return "Default Value";
}

이 함수를 orElse, orElseGet 으로 접근하여 래핑 된 값을 가져와보겠습니다.

String text = null;

String defaultText = Optional.ofNullable(text).orElseGet(this::getDefaultValue);
defaultText = Optional.ofNullable(text).orElse(getDefaultValue());

실행 결과는 아래와 같습니다. 두 메소드 모두 래핑 된 값이 없을 때는 똑같은 방식으로 작동합니다.

getDefaultValue is Call
getDefaultValue is Call

하지만 값이 존재할 때는 어떻게 동작하는지 알아보겠습니다.

String text = "text";

System.out.println("orElseGet is Call");
String defaultText 
  = Optional.ofNullable(text).orElseGet(this::getDefaultValue);

System.out.println("orElse is Call");
defaultText = Optional.ofNullable(text).orElse(getDefaultValue());

실행 결과는 아래와 같습니다. 가장 큰 차이점은 orElse 메소드는 값이 null 여부에 상관없이 항상 함수가 호출됩니다.

orElseGet is Call
orElse is Call
getDefaultValue is Call

여기서 큰 문제가 발생할 수 있습니다. 앞서 정의한 getDefaultValue() 함수 내부에서 새로운 객체를 생성해서 초기화를 시켜주면, Optional 내부 값에 관계없이 호출이 되므로 값이 변경될 수 있기 때문입니다.

버그는 사소한 것에서부터 올 수 있으니 모두 조심합시다.

참고자료

Leave a comment