[Optional] Optional 사용법과 프로젝트 적용 예시 (with 모던 자바 인 액션)

Damongsanga·2024년 2월 11일
0
post-thumbnail

📌 작성 배경

Spring Data JPA에서 엔티티를 찾을 때 기본적으로는 Optional로 반환하는 것을 알 수 있는데, 쿼리 메소드를 직접 만들거나 QueryDsl을 사용하여 메서드를 생성할 때 반환값을 값(DTO, Entity)으로 반환할지 Optional로 반환할지 기준이 서지 않았다. 그러다보니 NPE를 발생시키지 않으면서 Optional을 불필요하기 사용하지 않을 확실한 기준을 세우고 싶었다.

Optional<T> findById(ID id); // Spring Data JPA가 만들어준 기본 메서드

List<MemberSimpleResponseDto> getCurrentMembers(Set<String> currentMemberIdSet); // 내가 만든 Querydsl 메서드

Optional<MemberDetailResponseDto> getMemberDetail(String memberId); // 내가 만든 Querydsl 메서드

또한 반환값이 Optional이어도 이를 get 메서드로 가져와서 사용했었는데, Optional 안의 값이 null이면 에러가 발생하는 것은 기존과 동일했고 심지어 이는 NPE가 아닌 NoSuchElementException 이었다. 결국 해당 에러가 발생했을 때의 처리를 따로 해줘야했는데..

분명 Optional은 null이 될 수 있는 값을 보다 효과적으로 처리하기 위한 장치임은 알고있었기에 뭔가 잘못 사용하고 있다는 생각을 지울 수 없었다..ㅎㅎ 때문에 모던 자바 인 액션의 Optional 섹션을 정리해보고 해당 프로젝트에 적용한 내용을 작성해보았다! 🏋️🏋️🏋️

Optional 과 Null

☘️ Null

📌 null은 왜 문제인가?

  1. 에러의 근원이고 가독성을 떨어뜨린다
  2. 그 값 자체에는 아무 의미가 없다
  3. 포인터를 숨긴다는 자바 철학에 위배된다 (Null POINTER Exception)
  4. 모든 참조형식에 적용될 수 있다
  5. 할당된 null이 다른 부분으로 퍼졌을 때 어떤 의미로 사용되었는지 알 수가 없다

📌 Optional 사용 이유

  • Optional을 사용하면 null이 있을 수 있음을 알려줌
  • 사용하지 않으면 반드시 그 값이 있다는 뜻이 됨 (혹은 그 값이 꼭 있어야 한다는 뜻이 되기도!)
  • 만약 Optional이 아닌 필드에서 NPE가 발생했다는 것은 그 값이 null 일 수 있어서가 아닌 다른 부분에서 문제가 생겼다는 것을 반증하는 것임으로 null인지를 확인하는 코드는 불필요한 것이다!

프로젝트에서 사용한 Repository 메서드를 보면서 적용해보자

Optional<T> findById(ID id); // 1

List<MemberSimpleResponseDto> getCurrentMembers(Set<String> currentMemberIdSet); // 2

Optional<MemberDetailResponseDto> getMemberDetail(String memberId); // 3

findById 에서 Optional을 사용했다는 것은 해당 메서드로 엔티티(T)를 찾았을 때 그 값이 null일 수 있다는 뜻이다. 따라서 이에 대한 특정 예외를 던지거나 기본값을 넣어주는 처리 등을 해주어야 한다

getCurrentMembers 에서 Optional을 사용하지 않았다는 뜻은 해당 메서드로 조회할 때 반드시 반환값이 존재한다는 뜻이다. 따라서 null이 발생했다면 이는 로직상에 문제가 있다는 뜻으로 null이 발생되지 않도록 디버깅해야한다.
getMemberDetail도 findById와 마찬가지이다. 반환값이 null일 수 있음으로 이에 따른 처리를 해주면 된다.

☘️ Optional의 특징

스트림의 연산에서 큰 영감을 받았다. 따라서 스트림의 map 등의 메서드를 지원한다!

  • Optional.empty()
    Optional의 특별한 싱글턴 인스턴스를 반환하는 정적 팩토리 메서드!
public final class Optional<T> {
	private static final Optional<?> EMPTY = new Optional<>(null);
	
	public static<T> Optional<T> empty() {
	        @SuppressWarnings("unchecked")
	        Optional<T> t = (Optional<T>) EMPTY;
	        return t;
	    }
}
  • Optional.of()
    값이 null 일 수 없다. 만약 null이면 바로 NPE 발생
  • Optional.ofNullable()
    값이 null일 수 있다. 만약 null이면 빈 Optional 객체 반환

📌 map & flatMap

Optional은 map 함수를 지원한다!
마찬가지로 flatMap도 지원한다

  • 스트림에서 map과 flatMap의 차이
    • map
      • 인수로 받은 함수의 스트림을 적용하면 스트림의 스트림이 생성됨
    • flatMap
      • 인수로 받은 함수를 적용해서 생성된 각각의 스트림에서 컨텐츠만 남음
      • 따라서 차원이 늘어나지 않고 유지가 된다

map과 flatMap의 차이를 예시에서 더 자세히 살펴보자
첫 코드는 컴파일 되지 않고, 아래 코드는 컴파일에 성공한다. 이유는 무엇일까??

Optional<String> name = 
		Optional.of(person).map(Person::getCar) //Optional<Optional<Car>>
                        .map(Car::getInsurance) // 컴파일 X
                        .map(Insurance::getName);

Optional<String> name = 
		Optional.of(person).flatMap(Person::getCar) //Optional<Car>
                        .flatMap(Car::getInsurance) //Optional<Insurance>
                        .map(Insurance::getName) //Optional<String>
                        .orElse("UnKnown"); // 컴파일 O
  

getCar는 원래 Optional 객체를 반환한다
따라서 map(Person::getCar)에서 Optional<<Optional>를 반환하게 되어, getInsurance는 반환값을 받을 수 없다

이와 달리 flatMap은 2차원 Optional을 1차원으로 평준화해준다

  • 평준화란?
    이론적으로 두 Optional을 합치는 기능을 수행하면서 둘 중 하나라도 null이면 빈 Optional을 생성하는 연산

flatMap을 빈 Optional에 호출하면 아무 일도 일어나지 않고 그대로 반환되나 값을 감싸고 있다면 그 값에 주어진 함수가 적용된다

☘️ 메서드 설명

대표적으로 사용하는 Optional 처리 메서드이다. 간단히 메서드를 설명하고 이를 활용한 예시를 기술하겠다.

  • get( T )
    • 가장 간단하지만 NoSuchElementException 발생 가능하다
    • 따라서 반드시 Optional에 값이 있는게 아니라면 사용하지 말것
  • orElse( T )
    • 값이 없을 때 기본값 제공 가능
  • orElseGet( supplier )
    • Lazy 버전으로 값이 실제로 없을 때만 supplier가 실행
    • 디폴트 메서드를 만드는데 비용이 클 때 사용
  • orElseThrow( supplier )
    • get 메서드와 비슷한데 값이 없을 때 발생하는 예외를 지정할 수 있다
  • ifPresent( consumer )
    • 값이 존재할 때 동작을 하고 없으면 아무일도 일어나지 않는다

그렇다면 지금부터는 프로젝트에서 사용한 Service단 메서드를 보면서 어떻게 활용했는지 함께 보자!

💡 1. orElseThrow를 사용하면 사용자 지정 Exception을 던지기 좋다.

멤버를 삭제하는 서비스 메서드가 있다고 생각해보자. 사용자는 존재하지 않는 멤버를 삭제하라는 명령을 보낼 수도 있다.

이 때 삭제하려는 멤버가 존재하는지 확인을 하고 삭제하는 것은 쿼리를 2번 발생시킴으로 매우 비효율적이다.

그렇다면 findById에서 null을 가져올 수도 있다는 뜻인데, 만약 findById를 그냥 get으로 가져오게 되면 NoSuchElementException 에러가 발생하는데, 이는 다른 엔티티들을 찾지 못했을 때도 모두 동일한 에러가 발생함으로 우리는 에러만으로 어떠한 이유로 에러가 발생하였는지 알기 어렵다.

따라서 orElseThrow 사용자 지정 Exception (RestApiException) 을 던지도록 하여 간결하고 명확하게 오작동한 이유를 기술할 수 있다.

@Transactional
public void delete(String memberId) {
  Member member = memberRepository.findById(memberId).orElseThrow(() -> new RestApiException(CustomErrorCode.NO_MEMBER));
  member.delete();
  roomMemberRepository.deleteAll(member.getRoomMembers());
}

💡 2. ifPresent를 사용하여 null이 아닐 때만 동작하게 할 수 있다

멤버의 팔로잉 수, 팔로워 수를 가져오는 쿼리 2개를 응답 dto에 넣어주는 private 메서드이다.

Querydsl에서 fetchOne한 값을 Optional.ofNullable로 감싸주어 NPE를 방지해주었다.

만약 팔로워, 팔로잉이 존재할 때만 값을 설정해주기 위해 ifPresent를 사용하였다.

private void setFollowCount(MemberDetailResponseDto res, String memberId) {
        Optional<Long> followerCount = Optional.ofNullable(jpaQueryFactory.select(follow.to.count())
                .from(follow)
                .where(follow.to.memberId.eq(memberId))
                .fetchOne());

        Optional<Long> followingCount = Optional.ofNullable(jpaQueryFactory.select(follow.from.count())
                .from(follow)
                .where(follow.from.memberId.eq(memberId))
                .fetchOne());

        followerCount.ifPresent(count -> res.setFollowerCount(count.intValue()));
        followingCount.ifPresent(count -> res.setFollowingCount(count.intValue()));
    }

📌 Optional 합치기 without unwrap

모던 자바 인 액션에 들어있는 예시였는데 흥미로워서 정리해보았다. person과 car가 둘다 null이 아닐 때만 myMethod라는 메서드가 실행되게 하려면 어떻게 해야할까? 물론 if(person != null && car != null)으로 처리할 수도 있겠지만 Optional을 사용해보는건 어떤가! Optional의 Stream한 연산을 사용해보면 그렇게 어렵지 않다. 모던 자바 인 액션에서 제시하는 방법은 flatMap & map 활용하는 것이다.

Optional<Person> person;
Optional<Car> car;
person.flatMap(p -> car.map(c -> myMethod(p,c)));
  • 첫번째 flatMap에서 만약 optional에 값이 없다면 람다 표현식 실행안되고 바로 빈 optional 반환
    • 값이 있다면 평준화되어 이어지겠지?
  • 두번째 map에서 값이 없다면 역시 빈 optional을 반환함으로 빈 optional 반환
    • 값이 있다면 내부 메서드 실행!

📌 필터링

Optional은 매우 Stream하다! stream 처럼 filter를 사용할 수 있음으로 활용해보자.
optional에 값이 없다면 filter는 아예 작동하지 않는다
filter를 거치고 남은 값을 활용할 수 있을 것이다!
만약 이름이 John인 사람이 있다면 그 이름을 출력한다고 하면?

  Optional.of(person).filter(person -> "John".equals(person.getName()))
		.ifPresent(System.out::println);

📌 실용 예제

null이 발생할 수 있는 값을 if-else-then 대신 ofNullable을 이용하여 깔끔하게 처리하자

  Optional<Object> value = Optional.ofNullable(map.get("key"));

어떤 값이 null일 수 있고, null이거나, 값을 int로 변환하는데 실패하거나, 값이 없으면 0을 반환해야 한다면?
아래와 같이 함수형 프로그래밍으로 할 수도 있다!

  return Optional.ofNullable(props.getProperty(name))
		.flatMap(OptionalUtility::stringToInt) // Optional 값이 int로 바뀔 수 있으면 파싱, 안되면 empty 반환하는 "사용자" 메서드
		.filter(i -> i > 0)
		.orElse(0);

📌 주의할 점

  • Optional은 직렬화할 수 없다
    • 필드 형식으로 사용할 것을 가정하지 않았음으로 Serializable 인터페이스를 구현하지 않았기 때문
    • 따라서 직렬화 모델이 필요하다면 Optional으로 값을 받을 수 있는 메서드를 추가하는 것이 좋다
  • 기본형 Optional은 사용하지 말자
    • map, flatMap, filter 등을 지원하지 않는다

참고자료
모던 자바 인 액션

profile
향유하는 개발자

0개의 댓글

관련 채용 정보