[Java] List를 Optional로 처리할 때 고려해야 할 사항
Optional에 List를 담았을 때 착각할 수 있는 점이 있다.
📌 서론
개발 작업을 하던 도중 외부 api를 호출하여 받은 응답 객체(DTO) 내부의 List 필드를 가져다 사용하는 경우가 있었다. 이때 NPE가 생기지 않도록 하기 위해 Optional로 List 필드를 한번 감싸게 되는 상황이 있었다. 이게 선호되는 방법은 아니지만 NPE를 방지하고자 로직을 이렇게 작성하게 되었다.
문제는 내가 착각을 해서 Optional로 감싼 리스트가 빈 리스트(Empty List) 일 때도 ifPresentOrElse() 메서드의 else에 잡힌다고 생각했다. 근데 빈 리스트는 null이 아니기 때문에 orElse에 잡히지 않는다. 그래서 로직을 또 바꾸게 되는 일이 생겼다.
바보 같은 실수일 수도 있지만 충분히 헷갈릴 수도 있겠다 생각되어 그 내용을 작성해두고자 한다.
1. 어떤 상황일까?
상황설명
서비스 레이어에서 '외부 api 요청(Feign)'으로 데이터를 받아와서 필요한 데이터를 꺼내서 사용하는 상황이었다. 이때 외부 api에서 받아오는 데이터는 DTO로 받도록 설계했고 DTO 내부에 List로 선언된 필드가 있었다. 나는 이 DTO의 List의 값을 get으로 꺼내와서 사용해야만 했다.
문제는 내가 호출하는 이 외부 api의 응답을 잘 몰랐다는 것이다.
무슨 말이냐면 DTO 내부의 List가 emptyList를 반환하는지 null을 반환하는지 알 수가 없는 상황이었다는 것이다. 그럼 이 글을 읽어보시는 분들께서는 분명 "네가 잘 확인하면 개발했어야 하는 거 아니야?"라고 생각하실 수도 있다.
사실 나는 저 말도 맞다고 생각한다. 그러나 한 가지 더 고민할 사항이 있다. 바로 내가 '외부 api 응답'을 확실하게 알아서 개발하였다고 하더라도 외부 서버의 api를 통해 받아온 response를 사용할 때 이 값들의 유효성 체크를 제대로 하지 않고 사용한다면 언젠가는 분명히 문제가 발생할 가능성이 있다는 것이다. 그 이유는 다음과 같다.
- 기존 개발하던 분이 다른 개발자로 변경될 수 있다.
(인수인계가 100% 확실하게 가능한 경우는 있을 수가 없다고 생각된다.) - 메서드 시그니처는 동일하더라도 api 내부의 처리 로직이 변경되어 응답이 바뀔 수도 있다.
(빈 리스트에서 null을 반환하도록 로직이 바뀔 수도 있다.)
즉, 이 api의 응답(response)은 내가 컨트롤하는 부분이 아니라는 것이 중요하다.
단순히 나는 외부 서버에 api호출을 위한 request를 보내고 그 서버가 응답해 준 response를 가져다 사용할 뿐이지 내부 로직의 구성까지 하나하나 컨트롤할 수는 없다. 따라서 내가 이 외부 api의 response 변동사항을 바로바로 파악할 수는 없으며 대부분 외부 api가 response를 주는 대로 받아서 쓰거나 개발자께서 연락을 주시면 그제야 변경된 것을 알 수 있을 것이다.
최악의 상황은 나도 모르게 이 api 응답(DTO)의 필드(멤버 변수)가 null을 반환하도록 변경되는 것이다. 이렇게 되면 내부의 필드를 get 하는 로직이 null을 get 하게 되면서 예상치 못했던 NPE가 발생하면서 서버가 터질 것이다. 즉, 외부의 변경에 따라 언제 서버가 터질지 모르는 폭탄을 안고 있게 된다는 것이다. 그나마 같은 회사에서 만든 api라면 회의를 통해 같이 개발하겠지만 아예 가져다 쓰는 api일 경우라면 문제가 발생할 확률이 크다.
그래서 나는 애초에 이 api의 response를 사용할 때 확실하게 값에 대한 유효성 체크를 하면서 사용하는 것이 옳다고 생각했다. 그래야 추후 나도 모르는 상대방 서버의 내부 로직 변동으로 발생할 수도 있는 NPE를 방지할 수 있을 것이었기 때문이다. 무턱대고 get을 사용하던 개발 방식은 내가 완전 주니어 시절에 사용하던 방식이다.
참고로 이 상황은 외부 서버의 api 응답 객체 구성은 내가 함부로 변경할 수 없는 상황이다. (주는 대로만 받아서 사용해야 한다.)
2. 이때 나는 2가지 해결 방안을 생각했다.
첫 번째 방법
- DTO 내부에서 getter를 함부로 사용해서 NPE가 생기는 것을 방지하기 위해 getter는 제거하고 validAndGetList() 같은 특정 필드를 받아오는 전용 메서드를 만들어서 원하는 데이터를 받기 전에 검증을 거친 뒤 내가 필요로 하는 값을 반환받도록 하는 방식이다.
- 예를 들면 List가 empty인지 null인지 ObjectUtils.isEmpty() 메서드를 통해 확인을 하고 비었거나 null이면 new ArrayList()로 새로운 빈 리스트를 강제로 생성해서 반환해 주도록 하는 것이다.
public class MyDTO {
private List<String> list;
// 검증을 거친 후 리스트 반환
public List<String> validAndGetList() {
if (ObjectUtils.isEmpty(list)) {
return new ArrayList<>(); // null 또는 빈 리스트일 경우, 빈 리스트 반환
}
return list;
}
}
두 번째 방법
- DTO의 필요한 객체에 get을 하는 메서드 자체를 Optional.ofNullable()로 감싸주는 것이다.
- 예를 들면 getList()가 null을 반환할 때 이걸 Optional.ofNullable(getList())를 해주면 이것은 NPE가 발생하는 것을 방지할 수 있게 된다. 이후 Optional의 ifPresent()나 ifPresentOrElse() 같은 메서드를 사용하여 데이터를 처리한다.
public class MyDTO {
private List<String> list;
// Optional로 감싸서 리스트 반환
public Optional<List<String>> getList() {
return Optional.ofNullable(list);
}
}
dto.getList()
.filter(list -> !list.isEmpty())
.ifPresentOrElse(
list -> System.out.println("리스트에 데이터가 있습니다."),
() -> System.out.println("리스트가 없거나 비어 있습니다.")
);
개인적인 욕심
- 내 개인적인 욕심으로는 첫 번째 방식에서 한 것처럼 DTO 클래스 내부에 검증 비즈니스 메서드를 작성하고 싶지 않았다. 왜냐하면 DTO에 검증 로직을 추가하는 것은 편리할 수 있지만, 이는 DTO의 역할이 단순 데이터 전달에 국한되지 않고 검증 책임까지 가지게 된다고 생각하기 때문이다. 이렇게 되면 단일 책임 원칙(SRP, Single Responsibility Principle)을 위배할 수 있다고 생각했다. 그래서 두 번째 방법을 선택했고 DTO에 검증 로직을 넣는 대신 Optional로 처리해보고자 했다.
Optional 사용 도중 발견한 문제점 (사용 방법을 몰랐던 것이기에 오류는 아니다.)
- 나는 두 번째 방식으로 Optional로 List를 감싸주었다. 어떻게 보면 이렇게 사용하는 것 자체가 잘못된 방법일 것이라는 생각을 하기는 했다. 왜냐하면 List는 null 대신 빈 리스트로 초기화할 수 있으므로, 굳이 Optional을 사용할 필요가 없기 때문이다.
- 근데 지금은 다른 서버의 api를 호출해서 받아오는 상황이기 때문에 내가 남의 서버로 가서 함부로 반환하는 응답을 빈 리스트로 변경하고 커밋할 수는 없다. 심지어 다른 회사의 api라면 함부로 코드의 변경 시도조차 할 수 없다. (요청을 해서 기다려야 한다.) 그래서 이럴 때는 응답을 받는 내 서버에서 Optional을 사용해서 NPE 처리를 해두면 추후 응답에 변경이 생겨도 null관련 문제는 생기지 않을 것이라고 생각했다.
- 그래서 코드에 바로 Optional을 적용시켜서 NPE는 방지했다. 근데 ifPresentOrElse() 구문에서 생각지도 못했던 문제가 발생했다. 바로 List의 반환값이 null뿐만 아니라 단순히 빈 list를 받는 경우도 있었던 것이다. (나는 바보처럼 Null에 집중하다 보니 빈 List는 null이 아니라 존재하는 값이라는 것을 잊었던 것이다.)
아래 코드처럼 Optional을 사용할 때 getList()로 받아오는 값이 '빈 리스트'일 때 발생하는 문제 상황이다.
- 아래 코드를 보면, Optional.ofNullable(getList())로 List를 감싸고 있다. 하지만 빈 리스트([])는 null이 아니므로, Optional은 여전히 비어 있지 않다고 판단하여 첫 번째 람다(ifPresent())가 실행된다. 내가 기대했던 것은 빈 리스트도 "값이 없음"으로 간주되는 것이었으므로, 예상과 다른 동작이 발생한 것이다. (지금 생각하면 바보 같지만 이때는 생각이 잘 나지 않았다.)
Optional<List<String>> optionalList = Optional.ofNullable(getList());
// 여기서 빈 리스트일 경우도 `ifPresent`로 인식됨
optionalList
.filter(list -> !list.isEmpty()) // 비어 있지 않은 리스트만 남기도록 필터링
.ifPresentOrElse(
list -> System.out.println("리스트에 데이터가 있습니다."), // 비어 있지 않으면 실행
() -> System.out.println("리스트가 없거나 비어 있습니다.") // 빈 리스트이거나 null이면 실행
);
3. Optional 안에 빈(empty) List는 ifPresent로 간주된다
Optional과 List를 같이 사용할 때 발생할 수 있는 몇 가지 주의할 점과 위험 사항이 있다.
Optional의 의도와 목적
- Optional은 null을 안전하게 처리하기 위한 도구다. null을 반환하거나 다루는 과정에서 발생할 수 있는 NullPointerException(NPE)을 방지하고, 명시적으로 값이 있거나 없음을 나타내기 위해 사용된다. 따라서, 만약 리스트가 null이 될 가능성이 있다면, 그 리스트를 Optional로 감싸는 것은 타당한 설계일 수 있다. 특히, 리스트 자체가 null일 수 있는 경우 Optional<List>를 사용하면 null 여부를 명시적으로 처리할 수 있게 된다.
리스트의 특성
- 참고로 List는 자체적으로 빈 리스트([])라는 상태를 가질 수 있기 때문에, Optional을 사용하는 대신 빈 리스트를 반환하는 방법이 더 자연스러울 수 있다. 즉, 리스트가 비어 있는 경우에도 유효한 상태이므로, 굳이 null로 처리하지 않고 빈 리스트를 반환하는 것이 더 적절할 수 있다.
Optional 안에 빈 List는 ifPresent로 간주된다 (이 부분에서 착각을 하게 되었다.)
- 자바의 Optional은 값이 null인지 아닌지를 감싸는 컨테이너다.
- 예를 들어, Optional.of(someList)를 사용하면 someList가 null이 아닌지를 기준으로 작동한다. 하지만 List는 빈 리스트라도 null이 아닌 객체로 간주되므로, Optional 내부에 빈 리스트가 있더라도 Optional은 비어 있지 않은 상태(isPresent() == true)로 처리된다.
List<String> emptyList = new ArrayList<>();
Optional<List<String>> optionalList = Optional.of(emptyList);
optionalList.ifPresentOrElse(
list -> System.out.println("List is present and size: " + list.size()),
() -> System.out.println("List is empty or absent.")
);
- 위 코드에서는 emptyList가 비어 있더라도 Optional은 emptyList 자체를 감싸고 있기 때문에 ifPresent 부분이 실행되고, 리스트가 비어 있더라도 "List is present and size: 0"이 출력된다. 이 경우 orElse 블록은 절대로 실행되지 않는다. Optional이 비어있는 것이 아니라 리스트 자체가 "존재"하는 상태이기 때문이다.
4. 위험 사항: 리스트와 Optional을 같이 사용할 때의 문제
1) 빈 리스트와 null 리스트를 혼동할 수 있다
- 위에서 설명한 것처럼, 빈 리스트와 null 리스트는 전혀 다른 의미를 가진다. 빈 리스트는 객체가 존재하지만 아무 요소도 없는 상태이고, null 리스트는 객체 자체가 없다는 의미다. Optional을 사용할 때 빈 리스트를 감싸면, 존재하지만 비어 있는 리스트가 있기 때문에 Optional이 empty가 아닌 것으로 처리된다.
- 이 상황은 논리적 오류를 초래할 수 있다. 예를 들어, 빈 리스트와 null 리스트를 동일하게 처리하고 싶다면, 빈 리스트를 감싼 Optional을 사용할 때 추가적인 처리가 필요하다.
Optional<List<String>> optionalList = Optional.ofNullable(getList()); // getList가 null일 수 있음
optionalList
.filter(list -> !list.isEmpty()) // 리스트가 비어있다면 Optional.empty()로 만듦
.ifPresentOrElse(
list -> System.out.println("Non-empty list is present."),
() -> System.out.println("List is empty or absent.")
);
- 위 코드에서는 filter(list -> !list.isEmpty())를 사용하여 리스트가 비어 있는 경우를 Optional.empty()로 변환했다. 따라서 빈 리스트가 들어오더라도 ifPresentOrElse의 orElse 블록이 실행되게 된다. (이게 내가 원했던 방법이었다 ㅠㅠ)
2) 리스트가 null일 수 있는 경우 안전한 처리가 필요 (꼭 Optional.ofNullable()을 사용해야 한다.)
- 리스트가 null일 수 있을 때, 이를 Optional.of로 감싸면 NullPointerException이 발생할 수 있다. 반드시 Optional.ofNullable을 사용해야 한다.
List<String> nullList = null;
Optional<List<String>> optionalList = Optional.ofNullable(nullList);
optionalList.ifPresentOrElse(
list -> System.out.println("List is present."),
() -> System.out.println("List is absent.")
);
- Optional.ofNullable을 사용하면 리스트가 null일 때도 안전하게 처리할 수 있다. 이 경우, 리스트가 null이라면 ifPresentOrElse의 orElse 블록이 실행된다.
5. 예시를 통한 위험 사항 정리
1) Optional에 빈 리스트가 들어가는 경우
- 이 경우 리스트가 비어 있는 상태임에도 ifPresent 블록이 실행되고 "List is present with size: 0"이 출력된다. 이로 인해 Optional이 "비어있음"으로 간주되지 않아 orElse 블록이 실행되지 않는 문제가 생긴다. 그래서 list가 null이 아닌 경우에는 어떤 상황에도 else구문은 실행할 수가 없게 된다.
Optional<List<String>> optionalList = Optional.of(new ArrayList<>());
optionalList.ifPresentOrElse(
list -> System.out.println("List is present with size: " + list.size()),
() -> System.out.println("List is absent or null.")
);
2) Optional에 null이 들어가는 경우
- 이 경우 nullList가 null이므로 Optional.empty()가 생성되고, orElse 블록이 실행된다. (1번과는 완전히 다른 상황이다.)
List<String> nullList = null;
Optional<List<String>> optionalList = Optional.ofNullable(nullList);
optionalList.ifPresentOrElse(
list -> System.out.println("List is present."),
() -> System.out.println("List is absent.")
);
3) 리스트가 비어 있음을 명시적으로 처리하는 방법
- 빈 리스트와 null을 명시적으로 구분하고 싶다면, Optional과 filter를 같이 사용하여 빈 리스트를 처리할 수 있다.
public class OptionalFilterExample {
public static void main(String[] args) {
// 1. 리스트가 비어있지 않은 경우
Optional<List<String>> optionalList = Optional.ofNullable(getList(1));
optionalList
.filter(list -> !list.isEmpty()) // 리스트가 비어있지 않은 경우 필터 통과
.ifPresentOrElse(
list -> System.out.println("리스트에 데이터가 있습니다."), // 값이 있을 때 실행
() -> System.out.println("리스트가 없거나 비어 있습니다.") // 값이 없을 때 실행
);
// 2. 리스트가 빈 경우
Optional<List<String>> optionalEmptyList = Optional.ofNullable(getList(2));
optionalEmptyList
.filter(list -> !list.isEmpty()) // 리스트가 비어있으므로 Optional.empty()로 변환
.ifPresentOrElse(
list -> System.out.println("리스트에 데이터가 있습니다."), // 값이 있을 때 실행 (실행되지 않음)
() -> System.out.println("리스트가 없거나 비어 있습니다.") // 값이 없을 때 실행 (여기서 실행)
);
// 3. null을 반환하는 경우
Optional<List<String>> optionalNullList = Optional.ofNullable(getList(3));
optionalNullList
.filter(list -> !list.isEmpty()) // null이므로 Optional.empty()
.ifPresentOrElse(
list -> System.out.println("리스트에 데이터가 있습니다."), // 값이 있을 때 실행 (실행되지 않음)
() -> System.out.println("리스트가 없거나 비어 있습니다.") // 값이 없을 때 실행 (여기서 실행)
);
}
// 타입에 따라 리스트 반환 (1: 비어 있지 않은 리스트, 2: 빈 리스트, 3: null)
public static List<String> getList(int type) {
return switch (type) {
case 1 -> {
List<String> list = new ArrayList<>();
list.add("Item 1");
list.add("Item 2");
yield list; // 비어있지 않은 리스트
}
case 2 -> new ArrayList<>(); // 빈 리스트
default -> null; // null
};
}
}
- "리스트에 데이터가 있습니다.": 리스트에 값이 있는 경우 출력된다.
- "리스트가 없거나 비어 있습니다.": 리스트가 null이거나, 빈 리스트인 경우 출력된다.
결과는 다음과 같다.
- 이 코드에서는 filter를 통해 리스트가 비어 있을 때도 Optional.empty()로 처리하여 orElse 블록이 실행되도록 했다.
리스트에 데이터가 있습니다.
리스트가 없거나 비어 있습니다.
리스트가 없거나 비어 있습니다.
결론
- Optional<List>와 같은 구조를 사용할 때는 리스트가 비어 있는 경우와 null인 경우를 명확하게 구분해야 한다. Optional은 빈 리스트도 존재하는 값으로 간주하기 때문에, 빈 리스트와 null 리스트를 동일하게 처리하고 싶다면 filter 등의 추가적인 처리가 필요하다.