이 글은 프로그래머스 - 실무 자바 개발을 위한 OOP와 핵심 디자인 패턴 강의를 정리한 내용입니다.
Optional
은 자바에서 비어있는 값을 의미하는 Null
을 처리하기위해 사용되는 문법입니다.
아마 자바로 코드를 작성하면서 NPE(NullPointerException)
을 자주 접해보았을 것입니다. NPE
를 피하려면 null 체크를 꼭 해줘야하는데, 이때 null에 대한 처리를 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을 왜 사용하는가?'입니다.
자바에서 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 인스턴스를 생성하는 방법은 크게 총 세 가지입니다.
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()
메서드를 사용하고있습니다. 이 메서드는 다음 두 가지 파라미터를 받습니다.
현재 위 코드는 존재하지 않는 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의 활용방안은 크게 두 가지 경우로 나눌 수 있습니다.
orElseThrow
같은 메서드로 사용Optional.ofNullable
로 Optional 객체 생성 후 orElseThrow
같은 메서드로 사용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을 사용한 안티 패턴으로 널리 알려지고 있는 코드입니다.
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와 굉장히 시너지가 좋기 때문에 앞으로 함께 사용할 일이 많을겁니다.