[요즘카페 트러블슈팅] orElse + JPA 콜라보로 DB가 날라감;;
우테코 레벨3 프로젝트로 진행하고 있는 요즘 카페를 진행하며 경험한 트러블 슈팅입니다.
MVP까지 기능 개발을 끝낸 뒤, release를 위해 팀 내부에서 자체적으로 QA를 진행했다.
몇 가지의 테스트를 진행하던 도중 정말 이상한 버그를 발견했다.
특정 상황에서 사용자의 좋아요 목록이 초기화 되는 해괴한 버그였다.
몇번의 테스트를 통해서 좋아요 목록이 초기화 되는 플로우를 찾아냈다.
- 회원가입을 한다.
- 카페 정보에 좋아요 를 누른다.
- 로그아웃 후 다시 로그인 한다.
- 좋아요 목록이 뿅하고 사라진다...?
좋아요 목록이 사라지는 원인은 결국 DB에 있던 사용자의 좋아요 목록이 모두 다 삭제 되는 것이 원인이었는데,
왜 때문에 이런일이 일어나는지 이유를 찾기위해 위의 워크 플로우와 동일한 테스트 코드를 작성했다.
@Test
@DisplayName("재로그인 시 좋아요 목록이 남아있는다.")
void reLoginLikedListTest() {
final Cafe cafe = cafeRepository.save(Fixture.getCafe("카페1", "주소1", 10));
// 1. 회원가입
final Member member = memberRepository.save(
new Member("memberId", "폴로", "폴로사진"));
given(jwtTokenProvider.getMemberId(anyString())).willReturn(member.getId());
doReturn(new MemberInfo(member.getId(), member.getName(), member.getImage())).when(kakaoOAuthClient)
.getUserInfo(anyString());
context.accessToken = "accessToken";
// 2. 좋아요 등록
context.invokeHttpPostWithToken("/cafes/" + cafe.getId() + "/likes?isLiked=true");
// 3. 재로그인
relogin();
// 좋아요 목록 조회
context.invokeHttpGet("/members/{memberId}/liked-cafes?size=1&page=1", member.getId());
final List<LikedCafeResponse> result = context.response.jsonPath().getList("");
//then
assertThat(result).hasSize(1);
}
private Response relogin() {
return RestAssured.given()
.log().all()
.queryParam("code", "googleCode")
.when()
.post("/auth/{providerName}", "kakao");
}
위와 문제가 발생하는 워크플로우와 동일한 테스트 코드를 작성했을 때,
현재의 문제상황과 동일하게 result(회원의 좋아요 목록)의 size가 0이 되어 테스트가 실패했다.
결국 문제는 재로그인 하는 경우 일어나는 것이기 때문에 재로그인의 플로우를 하나하나 중단점을 찍어가며 디버깅을 해보았다.
그러다보니 생각보다 쉽게 문제점을 발견할 수 있었다.
현재의 로직이 OAuth 로그인 시도 시,
이미 가입된 회원이라면, 바로 Access Token
을 생성해주고,
가입되지 않은 사용자라면, 회원가입 후, Access Token
을 생성하는 로직인데,
해당 분기를 orElse
를 통해 처리했다.
그런데 문제는 이미 가입된 회원의 경우에도 orElse
의 메서드가 실행된다.
👆tmp를 보면 null이 아니지만,
👇saveNewMemberWithAllCafes
가 실행됨
orElse
를 오용해서 생긴일이었다.
아래의 코드를 예시로 들어보자
@Test
public void testOrElse() {
String example = "폴로";
Optional<String> exampleOptional = Optional.ofNullable(example);
final String name1 = exampleOptional.orElse("익명");
final String name2 = exampleOptional.orElseGet(()-> "익명");
System.out.println(name1); // 폴로
System.out.println(name2); // 폴로
}
위의 테스트코드를 실행해보면, 둘다 당연하게도 폴로가 출력된다.
다음은 example
을 null
로 변경해서 실행해보자.
@Test
void orElseTest() {
String example = null;
Optional<String> exampleOptional = Optional.ofNullable(example);
final String name1 = exampleOptional.orElse("익명");
final String name2 = exampleOptional.orElseGet(() -> "익명");
System.out.println(name1); // 익명
System.out.println(name2); // 익명
}
정상적으로 잘 실행 되는 것 같은데..?
그럼 다음의 케이스를 한번 확인해보자.
@Test
void orElseTest() {
String example = "폴로";
Optional<String> exampleOptional = Optional.ofNullable(example);
final String name1 = exampleOptional.orElse(getAnonymousName());
final String name2 = exampleOptional.orElseGet(this::getAnonymousName);
System.out.println(name1);
System.out.println(name2);
}
private String getAnonymousName() {
System.out.println("실행됩니다!!!");
return "익명";
}
이전의 코드와 달라진 거라면, 익명을 바로 orElse
와 orElseGet
에 박아놓은게 아니라,
메서드를 통해 값을 받아 전달한다.
현재 example이 null이 아니므로, 폴로가 두번 출력될거라 예상했지만,
getAnonymousName()
이 호출된 것을 확인 할 수 있다.
이게 무슨 일인가 싶지만, 매우 당연한 현상이다;;
@Test
void rowTest() {
print(getA());
}
private String getA() {
System.out.println("getA 실행!!!");
return "A";
}
private void print(String s) {
System.out.println(s + " From Print!!");
}
위의 코드에서 rowTest()
를 돌렸을때 출력 순서를 예상해보면 위의 케이스도 이해가 된다.
당연하게도 getA()
가 먼저 실행되고 print
가 실행된다.
위의 rowTest
를 리팩토링? 해보면 다음과 같다.
@Test
void rowTest() {
final String a = getA();
print(a);
}
여기까지 이해하고 다시 orElse
를 살펴보면, 뭐가 잘못된건지 바로 보인다.
@Test
void orElseTest() {
String example = "폴로";
Optional<String> exampleOptional = Optional.ofNullable(example);
final String name1 = exampleOptional.orElse(getAnonymousName());
final String name2 = exampleOptional.orElseGet(this::getAnonymousName);
System.out.println(name1);
System.out.println(name2);
}
private String getAnonymousName() {
System.out.println("실행됩니다!!!");
return "익명";
}
orElse
의 경우는 값을 인자로 받기 때문에 메서드를 인자로 넣게 되면 값을 구하기 위해,
인자에 들어있는 메서드가 먼저 실행된다!
그렇기 때문에
위의 코드를 실행 시키면 다음과 같은 결과가 나왔던 것이다.
이제 문제의 코드를 살펴보자.
createAccessToken()
에서 orElse
의 인자로 method를 넣어주었다.
그렇기 때문에 값을 구하기 위해 saveNewMemberWithAllCafes()
가 우선 실행이 된다.
saveNewMemberWithAllCafes()
내부에서memberInfo.toMember()
를 통해 member 인스턴스를 생성한다.
(이때 생성된 member 인스턴스의 likedCafes와 UnViewedCafes 는 빈리스트로 초기화 된다.)
memberRepository
를 통해 member를 save
한다.
그런데, member의 id가 이미 존재 하다보니 JPA merge를 해버린다.
(member의 id는 OAuth Provider로 부터 받는 OICD를 이용한다.)
그리고 트랜잭션이 끝나는 순간 JPA가 flush를 호출하며,
해당 멤버의 좋아요 리스트가 비워진 상태로 DB에 쓰여진다!
문제의 원인을 찾는 것은 힘들었지만, 해결은 간단했다.orElse
를 orElseGet
으로 변경해주기만 하면된다.
public T orElseGet(Supplier<? extends T> supplier) {
return value != null ? value : supplier.get();
}
orElseGet
은 orElse
와 달리, 값이 아닌 supplier를 인자로 받기 때문에
method가 우선 실행되지 않는다.
orElse를 orElseGet으로 변경해주니 테스트코드도 잘 통과하는것을 확인할 수 있었다.
orElse와 JPA의 변경감지가 화려하게 콜라보하여 생긴 오류였는데,
덕분에 많은 공부가 되었다.