소름 돋는 OrElse, OrElseGet

·2023년 8월 9일

프로젝트-요즘카페

목록 보기
3/12

문제의 시나리오

요즘카페 서비스에 가입한 유저가 존재한다.
원하는 카페들을 좋아요를 눌러서 자신의 좋아요 목록에 저장된 것을 확인했다.

다음 번에 다시 들어와서 로그인을 하니…
좋아요 목록이 초기화됐다ㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠ

예시

간단한 예시를 가져왔다.
MemberService#findById는 ID값으로 DB에서 조회한 뒤 존재하면 그대로 반환, 존재하지 않으면 새로 저장해서 반환하는 로직이다.

테스트를 하기 위해서 아래의 코드를 짰다.

@Test
void findById_exist() {
    //given
    memberRepository.save(new Member("연어"));

    //when
    final Member found = memberService.findById(1L);

    //then
	assertSoftly(softAssertions -> {
    	assertThat(found.getName()).isEqualTo("연어");
	    assertThat(memberRepository.findAll()).hasSize(1);
	});
}
  • Member를 저장해두고, MemberService#findById로 조회를 한다.
  • 조회한 Member의 이름은 연어이다.
  • 저장된 Member의 총 갯수는 1개이다.

정상적인 시나리오라면 통과해야 한다.

하지만 실제로 저장된 Member의 총 갯수는 2이다.

이유는 서비스 로직에 있다.

@Transactional
public Member findById(final long memberId) {
    return memberRepository.findById(memberId)
            .orElse(saveNewMember());
}

private Member saveNewMember() {
    return memberRepository.save(new Member("참치"));
}

왜…?

자바의 함수형 인터페이스를 아는가? 람다식을 아는가? 지연 연산을 아는가?
위의 문장이 문제의 원인이다.

public class Optional<T>{
	public T orElse(T other){...}
	public T orElseGet(Supplier<? extends T> supplier){...}
}

Optional 클래스에서 값을 꺼내려고 할 때, 내부가 null일 경우 위의 두 메서드를 사용할 수 있다.
솔직히 메서드 네이밍을 보면 똑같아 보인다.

하지만 파라미터를 보면 orElseGetSupplier를 받고 있고, orElseT를 받고 있다.
하나는 지연 연산이 가능하고, 다른 하나는 불가능하다는 소리이다.

@Transactional
public Member findById(final long memberId) {
    return memberRepository.findById(memberId)
            .orElse(saveNewMember());
}

private Member saveNewMember() {
    return memberRepository.save(new Member("참치"));
}

다시 돌아와서 MemberService#findById를 살펴보면,
orElse를 사용하고 있기 때문에 saveNewMember()메서드를 즉시 실행해서 리턴된 값을 바인딩하게 된다.
따라서 해당 memberId엔티티의 존재와 상관없이 무조건 saveNewMember() 메서드가 동작하는 것이다.

없을 때만 저장해서 반환하고 싶다면 orElseGet을 썼어야 한다.

(orElseGet을 사용했을 때는 정상적으로 테스트가 통과한다)

실제 서비스에서 발생했던 시나리오

  • 로그인을 할 때 해당 ID의 멤버가 존재하지 않으면 새로 저장하게 된다.
  • 이때 orElse를 사용했고 의도치 않은 save()가 호출됐다.
  • 멤버의 ID는 Auto Increment가 아닌 OAuth2의 Open ID이다.
  • 따라서 merge()가 호출되고, 변경감지가 flush() 되면서 기존 데이터가 모두 사라졌다.
profile
渽晛

0개의 댓글