[Java] Optional

양성욱·2023년 9월 21일
0
post-thumbnail

이 글은 프로그래머스 - 실무 자바 개발을 위한 OOP와 핵심 디자인 패턴 강의를 정리한 내용입니다.

Optional은 자바에서 비어있는 값을 의미하는 Null을 처리하기위해 사용되는 문법입니다.

아마 자바로 코드를 작성하면서 NPE(NullPointerException)을 자주 접해보았을 것입니다. NPE를 피하려면 null 체크를 꼭 해줘야하는데, 이때 null에 대한 처리를 Optional을 활용하면 좀 더 가독성 높은 코드를 작성할 수 있습니다.

NPE가 발생되는 코드를 Optional로 개선해보기

Main

public static void main(String[] args) {
		String string = getNullString();

		System.out.println("string=" + string);

		System.out.println(string.toUpperCase());
}

private static String getNullString() {
		return null;
}

NPE를 일으킬 수 있는 코드가 있습니다. 여기서 어떤 로직이 NPE를 발생시킬 수 있을까요? (이거 못 맞추면 Java 다시 공부하셔야해요 진심)

System.out.println(string.toUpperCase());
위 로직입니다. NPE는 null이 들어있는 레퍼런스 변수를 대상으로 필드 참조나 메서드 호출을 할 때 발생합니다.

그럼 NPE를 방어하기 위한 코드를 어떻게 작성해볼 수 있을까요?

public static void main(String[] args) {
		String string = getNullString();

		System.out.println("string=" + string);

		if (string != null) {
				System.out.println(string.toUpperCase());
		}
}

private static String getNullString() {
		return null;
}

if문을 활용해서 Null Check를 해주는 방식으로 코드를 작성할 수 있습니다.

그러나 이렇게 if문을 사용해 Null Check를 해주는 방식은 코드의 가독성을 저하시킵니다.

이번에는 좀 더 현실적인 예시를 들어보겠습니다.

MapRepositoryV1

public class MapRepository {

    private Map<String, String> map = new HashMap<>();

    MapRepository() {
        map.put("EXIST_KEY", "value");
    }

    public String getValue(String key) {
        return map.get(key);
    }
}

Main

public class WithoutOptionalExampleMain {
    public static void main(String[] args) {
        MapRepository mapRepository = new MapRepository();
        String string = mapRepository.getValue("NOT_EXIST_KEY");

        System.out.println("string=" + string);

				// NPE 발생!!!
        // System.out.println(string.toUpperCase());

        // NullPointerException을 피하려면 이렇게 Null 체크를 해야함.
        if(string != null)
            System.out.println(string.toUpperCase());
    }
}

Main에서 MapRepository에 존재하지 않는 key로 데이터 조회를 시도하므로 NPE가 발생하는 코드입니다.

역시 if문을 활용해서 Null Check를 진행하고 있습니다. 이 코드를 Optional을 사용해서 개선해보겠습니다.

MapRepositoryV2

public class MapRepository {

    private Map<String, String> map = new HashMap<>();

    MapRepository() {
        map.put("EXIST_KEY", "value");
    }

    public Optional<String> getOptionalValue(String key) {
        return Optional.ofNullable(map.get(key));  // 정적 팩토리 메서드
    }

    public String getValue(String key) {
        return map.get(key);
    }
}

Optional은 위와 같이 Optional.ofNullable()로 생성할 수 있습니다. 이렇게 하면 map.get()의 반환값이 저장된 Optional 객체가 생성됩니다.

Optional의 문법적인 내용보다 더 중요한건 'Optional을 왜 사용하는가?'입니다.

Why Optional?

자바에서 Optional을 도입하기 위해 진행했던 토론 중 일부에 다음과 같은 내용이 있습니다.

😎 Having Optional enables me to do fluent API thingies like:
stream.getFirst().orElseThrow(() -> new MyFancyException())

대충 '나에게 옵셔널 사용을 허용하면, API 호출을 우아하게 작성할 수 있다.'는 의미입니다. 예시로 든 코드를 살펴보면 람다식을 활용해 예외 처리도 한 번에 하고 있습니다.

// Case01
stream.getFirst().orElseThrow(() -> new MyFancyException())
// Case02
Object obj = stream.getFirst();

if (obj == null)
		new MyFancyException();

같은 역할을 수행하는 코드를 Optional을 활용했을때와 하지 않았을때의 예시입니다.

역시 첫 번째 코드가 더 가독성이 높습니다. 물론 Optional이 익숙하지 않으신 분들은 두 번째 코드가 더 익숙해서 가독성이 높다고 생각하실 수 있지만, 익숙함의 차이가 아닙니다. 결정적인것은 무언가를 가져온다는 행위와, 그것이 null인지 검사하는 행위가 묶여있느냐, 아니면 동떨어져있느냐입니다.

첫 번째 코드는 통칭 메서드 체이닝을 통해 한 줄로 깔끔하게 표현이 가능하지만, 두 번째 코드는 로직이 행위별로 나눠질 수 밖에 없습니다.

Object obj = stream.getFirst();

if (obj == null)
		new MyFancyException();

심지어 두 행위 사이에 다른 행위의 코드가 들어갈 수 있는 여지까지 있습니다. 이렇게 되면 두 코드간의 연관성은 희석되고, null 체크는 더 어려워집니다.

우리가 로직을 작성할 때 메서드 호출 결과가 null이 올 수 있는 경우, 우리가 할 수 있는일은 크게 두 가지 입니다.

  • 해당 클래스의 인스턴스를 새로 생성해주고 로직을 마저 실행
  • 에외를 던짐

둘 중 일반적으로는 두 번째 경우가 더 많습니다. 메서드를 호출하여 인스턴스를 조회해오는 경우는 보통 특정 인스턴스를 조회하려는 의도이거나, 특정 인스턴스의 값을 변경하려는 경우가 많기 때문입니다.

Optional 생성하기

Optional 인스턴스를 생성하는 방법은 크게 총 세 가지입니다.

MapRepository mapRepository = new MapRepository();
Optional<String> string = mapRepository.getOptionalValue("NOT_EXIST_KEY");

// 첫 번째 방식
Optional.of(string);

// 두 번째 방식
Optional.ofNullable(string);

// 세 번째 방식
Optional.empty();

세 방법 모두 정적 팩토리 메서드를 활용하는 방법입니다.

Optional.of는 파라미터로 넘어온 값이 null일 경우 NPE를 던집니다.

😵‍💫 ??? NPE를 피하려고 Optionale을 사용하는건데, 되려 NPE를 던진다고요...?

여기서 NPE를 던지는 시점은 레퍼런스 변수를 참조하는 시점이 아니라, Optional 객체를 생성하는 시점입니다. 따라서 개발자가 의도적으로 예외를 발생시키는 것에 더 가깝다고 볼 수 있습니다. (아무튼 null 체크 로직을 작성할 필요가 없어집니다.)

Optional.ofNullable은 파라미터가 null일 경우 비어있는 Optional 객체를, null이 아니라면 해당 값을 포함한 Optional 객체를 생성해서 반환해줍니다. 보통 이 ofNullable을 많이 사용합니다.

Optional.empty는 고의로 비어있는 Optional 객체를 생성해줍니다. null을 반환하는 것과 같은 효과를 주고 싶을 때 사용합니다.

이제 다시 예제 코드를 살펴보겠습니다.

MapRepository

public class MapRepository {

    private Map<String, String> map = new HashMap<>();

    MapRepository() {
        map.put("EXIST_KEY", "value");
    }

    public Optional<String> getOptionalValue(String key) {
        return Optional.ofNullable(map.get(key));
    }

    public String getValue(String key) {
        return map.get(key);
    }
}

MainV1

public class WithOptionalExampleMain {
    public static void main(String[] args) {
        MapRepository mapRepository = new MapRepository();
        Optional<String> string = mapRepository.getOptionalValue("NOT_EXIST_KEY");

        string.ifPresentOrElse(
                str -> System.out.println(str.toUpperCase()),  // Optional이 Empty가 아닐 때 실행
                () -> {
                    throw new RuntimeException("키가 존재하지 않습니다.");  // Optional이 Empty일 때 실행
                }
        );
    }
}

main로직을 보면 null 체크 대신 ifPresentOrElse() 메서드를 사용하고있습니다. 이 메서드는 다음 두 가지 파라미터를 받습니다.

  • Optional 객체에 들어있는 값이 empty가 아닐 경우, Optional 객체에 들어있는 값을 파라미터로 받아 실행하는 람다식
  • empty일 경우 실행할 람다식

현재 위 코드는 존재하지 않는 key를 사용해서 데이터를 조회하고 있기 때문에 당연히 값이 비어있는 Optional 객체를 가지고 있습니다. 따라서 두 번째 람다식이 실행됩니다.

Optional<String> string = mapRepository.getOptionalValue("EXIST_KEY");

만약 위와 같이 존재하는 key를 사용해 데이터를 조회하면 당연히 어떤 값을 포함한 Optional 객체가 반환되므로 첫 번째 람다식이 실행될 것입니다.

MainV2

public class WithOptionalExampleMain2 {
    public static void main(String[] args) {
        // 이전 코드보다 간결한 코드
        
        MapRepository mapRepository = new MapRepository();
        String string = mapRepository.getOptionalValue("NOT_EXIST_KEY").orElseThrow(
                () -> {throw new RuntimeException("키가 존재하지 않습니다.");}
        );

        System.out.println(string.toUpperCase());
    }
}

MainV1과 똑같은 기능을 하지만 더 간결한 코드를 작성해보았습니다.

orElseThrow는 Optional 객체가 비어있을 경우에만 람다식을 실행하고, 비어있지 않으면 Optional 객체에 포함된 값을 반환합니다.

MainV3

public class WithOptionalExampleMain3 {
    public static void main(String[] args) {
        // Optional의 정적 팩토리 메서드 사용
        
        MapRepository mapRepository = new MapRepository();
        String string = Optional.ofNullable(mapRepository.getValue("NOT_EXIST_KEY"))
                .orElseThrow(RuntimeException::new);

        System.out.println(string.toUpperCase());
    }
}

Optional을 직접 생성해서 사용할 수도 있습니다. Null이 반환될 수 있는 가능성이 있는 메서드를 실행할 때 Optional을 감싸서 사용할 수 있습니다.

Optional의 실제 활용

Optional의 활용방안은 크게 두 가지 경우로 나눌 수 있습니다.

  • 내가 쓰는 라이브러리의 메서드 반환 타입이 Optional인 경우 -> 그대로 orElseThrow같은 메서드로 사용
  • 내가 쓰는 라이브러리의 메서드 반환 타입이 Optional이 아닌 경우 -> Optional.ofNullable로 Optional 객체 생성 후 orElseThrow같은 메서드로 사용

Optional을 잘못 사용하고 있는 예시

public class OptionalAntiPatternExampleMain {
    public static void main(String[] args) {
        MapRepository mapRepository = new MapRepository();
        Optional<String> string = mapRepository.getOptionalValue("NOT_EXIST_KEY");

        if(string.isPresent())
            System.out.println(string.get().toUpperCase());
        else
            throw new RuntimeException("키가 존재하지 않습니다.");
    }
}

위와 같은 코드는 Optional을 잘못 활용하고 있는 대표적인 사례입니다.

Optional<> 타입의 인스턴스를 생성하는거 까지는 좋았는데, 기존 NPE 체크를 하던 로직과 별반 다를거 없는 분기 코드가 포함되어있습니다.

이러면 가독성을 높일 수 있다는 이점이 크게 반감됩니다. Optional을 사용한 안티 패턴으로 널리 알려지고 있는 코드입니다.

Optional을 잘 사용하고 있는 예시

public class ListOptionalExampleMain {
    public static void main(String[] args) {
        List<Integer> list = new ArrayList<>();

        list.add(1);
        list.add(2);
        list.add(3);
        list.add(4);
        list.add(5);

        Integer filteredInteger = list.stream()
                .filter(value -> value.equals(100))
                .findFirst()
                .orElseThrow(() -> {
                    throw new RuntimeException("100에 해당하는 요소가 없습니다.");
                });

        System.out.println(filteredInteger);
    }
}

Optional을 잘 활용하는 방법으로 Stream API와 연계해서 사용하는 방식이 있습니다. 특히 위 코드처럼 조건을 통해 filtering을 하는 경우, 조건을 충족하는 값이 있거나 없을 수 있는 경우를 Optional 객체를 통해 잘 처리한 예시라고 볼 수 있겠습니다.

Optional 문법은 Stream API와 굉장히 시너지가 좋기 때문에 앞으로 함께 사용할 일이 많을겁니다.

profile
개발의 신이시여... 제게 집중할 수 있는 ㅎ... 네? 맥주요?

0개의 댓글