프로젝트를 Junit5를 이용해 댓글 삭제 테스트 코드를 작성하던 중 Postman으로 테스트 할 땐 정상적으로 동작하던 코드가 테스트 코드를 이용하면 원하는 결과값이 도출되지 않았다.
@Test
void deleteCommentsByPostIdTest() {
// given
Member member = Member.builder()
.username("id")
.password("1234")
.nickname("nick")
.role("ROLE_USER")
.build();
memberRepository.save(member);
Post post = Post.builder()
.title("title")
.content("content")
.member(member)
.independentPostType(IndependentPostType.COOK)
.build();
postRepository.save(post);
Comment parentComment = Comment.builder()
.content("parent")
.member(member)
.post(post)
.build();
Comment savedParent = commentRepository.save(parentComment);
// when
commentRepository.deleteCommentsByPostId(savedParent.getPost().getId());
// then
assertThatThrownBy(() -> commentRepository.findById(savedParent.getId()).get())
.isInstanceOf(NoSuchElementException.class);
}
@Override
public int deleteCommentsByPostId(Long postId) {
return em.createQuery("delete from Comment c where c.post.id = :postId")
.setParameter("postId", postId)
.executeUpdate();
}
문제의 코드는 다음과 같았다. 댓글 작성을 위해 Member Post 객체를 먼저 저장한 뒤 Comment 객체를 생성해 저장하고 게시글 PK를 이용해 댓글을 삭제하는 테스트를 원했다. 코드 흐름 상 전혀 문제가 없을 것 같아 당연히 통과할 수 있을줄 알았다. 그러나..
java.lang.AssertionError:
Expecting code to raise a throwable.
at community.independe.repository.CommentRepositoryTest.deleteCommentsByPostIdTest(CommentRepositoryTest.java:233)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:568)
at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:727)
at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60)
at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:131)
at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:156)
at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:147)
at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:86)
at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(InterceptingExecutableInvoker.java:103)
at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.lambda$invoke$0(InterceptingExecutableInvoker.java:93)
at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:106)
at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:64)
at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:45)
at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:37)
at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:92)
at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:86)
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$7(TestMethodTestDescriptor.java:217)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:213)
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:138)
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:68)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:151)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141)
at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
테스트 수행 결과 예외가 발생되길 예상했지만 예외가 발생하지 않았다. 이유가 뭐지?

로그에 delete 쿼리가 나간 것 까지 보인다. 쿼리에 문제가 있나 싶어 정확한 파라미터 값을 보기위해 p6spy 라이브러리를 추가해보았다
// p6spy 추가
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'
코드에 System.out을 이용해 값을 추가해 비교해 보았다. (보통의 경우 System.out보단 Log를 사용하는 것이 더 좋다. 난 잠시 확인해 본 뒤 System.out을 제거할 계획이다.)
// == 생략 ==//
Comment parentComment = Comment.builder()
.content("parent")
.member(member)
.post(post)
.build();
Comment savedParent = commentRepository.save(parentComment);
System.out.println("post Id" + savedParent.getPost().getId());
// when
commentRepository.deleteCommentsByPostId(savedParent.getPost().getId());
// == 생략 ==//


postId 값이 일치한다. 그렇다면 쿼리상 postId가 3번인 댓글이 지워져야 한다. 하지만 댓글이 지워지지 않아 예외를 발생시키지 못 했다. 왜 이런 현상이 발생하는지 도저히 이해할 수 없었다. 다른 테스트들을 하나씩 돌려보며 다른점을 찾아봤다. 그러다 JPA에서 제공하는 deleteById를 이용해 제거해도 같은 현상이 발생하는지 궁금했다.
@Test
void deleteByIdTest() {
// given
Member member = Member.builder()
.username("id")
.password("1234")
.nickname("nick")
.role("ROLE_USER")
.build();
memberRepository.save(member);
Post post = Post.builder()
.title("title")
.content("content")
.member(member)
.independentPostType(IndependentPostType.COOK)
.build();
postRepository.save(post);
Comment parentComment = Comment.builder()
.content("parent")
.member(member)
.post(post)
.build();
Comment savedComment = commentRepository.save(parentComment);
// when
commentRepository.deleteById(savedComment.getId());
// then
assertThatThrownBy(() -> commentRepository.findById(savedComment.getId()).get())
.isInstanceOf(NoSuchElementException.class);
}

해당 코드는 정상적으로 동작했다. 그런데 뭔가 이상한 점을 발견했다 로그에 delete 쿼리가 없었다. 로그를 다 뒤져봐도 delete 쿼리는 보이지 않았다 심지어 이제 다시보니 insert 쿼리도 없었다. Member, Post Comment를 저장하려면 insert 쿼리가 있었어야 하는데 이제보니 없다. 해당 이유를 구글링을 통해 찾아보았다.
@DataJpaTest
public class CommentRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@Autowired
private PostRepository postRepository;
@Autowired
private CommentRepository commentRepository;
//== 생략 ==//
원인은 테스트 클래스에 붙은 @DataJpaTest 때문이었다. @DataJpaTest 내부에는 @Transactional이 붙어있는데 @Test는 테스트 이후 데이터를 롤백시키기 때문에 데이터를 저장한다 해서 DB에 반영하는 것이 아닌 영속성 컨텍스트에서만 관리되는 것이었다. 그렇다면 여기서 또 다른 고민이 생긴다. '왜 내 처음 코드는 delete 쿼리가 로그에 적힌거지?' 다시한번 내 코드를 살펴보았다.
public class CommentRepositoryCustomImpl implements CommentRepositoryCustom{
private final JPAQueryFactory queryFactory;
private final EntityManager em;
public CommentRepositoryCustomImpl(EntityManager em) {
this.em = em;
this.queryFactory = new JPAQueryFactory(em);
}
@Override
public int deleteCommentsByPostId(Long postId) {
return em.createQuery("delete from Comment c where c.post.id = :postId")
.setParameter("postId", postId)
.executeUpdate();
}
@Override
public int deleteCommentByParentId(Long commentId) {
return em.createQuery("delete from Comment c where c.parent.id = :parentId")
.setParameter("parentId", commentId)
.executeUpdate();
}
}
public interface CommentRepositoryCustom {
int deleteCommentsByPostId(Long postId);
int deleteCommentByParentId(Long commentId);
}
내 쿼리는 쿼리 한번에 여러 테이블의 로우를 변경하는 벌크 연산이다. 이제야 예전 JPA를 공부했던 기억이 떠올랐다. 벌크연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리를 보낸다. 즉, @Test에 붙은 @Transactional로 인해 영속성 컨텍스트에만 관리되던 댓글을 데이터베이스에 직접 delete 쿼리를 날려 삭제하려 했기 때문에 테스트가 실패한 것이었다. 그럼 어떻게 해결하지? EntityManager가 있구나!
@Test
void deleteCommentsByPostIdTest() {
// given
Member member = Member.builder()
.username("id")
.password("1234")
.nickname("nick")
.role("ROLE_USER")
.build();
memberRepository.save(member);
Post post = Post.builder()
.title("title")
.content("content")
.member(member)
.independentPostType(IndependentPostType.COOK)
.build();
postRepository.save(post);
Comment parentComment = Comment.builder()
.content("parent")
.member(member)
.post(post)
.build();
Comment savedParent = commentRepository.save(parentComment);
em.flush();
em.clear();
// when
commentRepository.deleteCommentsByPostId(savedParent.getPost().getId());
// then
assertThatThrownBy(() -> commentRepository.findById(savedParent.getId()).get())
.isInstanceOf(NoSuchElementException.class);
}
EntityManager를 이용해 영속성 컨텍스트를 강제로 DB에 flush한 뒤 영속성컨텍스트를 삭제했다.

그 결과 정상적으로 테스트가 작동했다. 내 머리를 많이 아프게 했지만 JPA에 대해 좀 더 자세히 공부하고 알게된 순간이었다.
'Project > Trouble Shooting' 카테고리의 다른 글
| [Independe] 소셜 로그인 도입 시 동일 호스트 문제 해결 (2) | 2024.01.28 |
|---|---|
| [Independe] Junit5에서 JWT를 이용한 로그인 처리 (0) | 2023.10.09 |