이번 포스팅은 락별 성능테스트 중에 발생했던 락이 걸리지 않는 문제에 대해서 얘기를 해보겠다.
Post : Commnt == 1 : N의 연관관계인 상황.
Comment를 Post 테이블의 comment_count 컬럼에도 값을 업데이트를 해야한다.
병렬처리로 10건 입력시 정확히 10건이 입력이 될 수 있게 비관락을 구현했다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({
@QueryHint(name = "javax.persistence.lock.timeout", value = "5000"), // 5초 타임아웃
@QueryHint(name = "org.hibernate.cacheable", value = "false") // 캐시 사용 안함
})
@Query("SELECT p FROM Post p WHERE p.id = :postId")
Optional<Post> findByIdForUpdate(@Param("postId") Long postId);
@Transactional
public Comment save(CommentSaveDto request, Member member) {
validatePostNotReplied(request);
Comment comment = commentConverter.requestToEntity(member, request);
Post post = postRepository.findByIdForUpdate(request.postId())
.orElseThrow(() -> new NotFoundException(PostStatus.POST_NOT_FOUND));
log.info("[CUSTOM LOG] PESSIMISTIC_WRITE 락 획득: commentCount = {}", post.getCommentCount());
long count = post.getCommentCount();
count++;
post.setCommentCount(count);
postRepository.save(post);
log.info("[CUSTOM LOG] post 저장 : commentCount = {}", post.getCommentCount());
return commentRepository.save(comment);
}
위 상황에서 k6 부하테스트 도구를 활용해서 10명의 인원이 동시에 1건의 데이터를 입력하는 상황을 가정하고 테스트를 했다.
결과는 아래와 같이 실제로 입력된 Commet의 개수는 10개, 그러나 Post에 입력된 CommentCount의 개수는 2개였다.
고수님들이 즐비한 카카오톡 오픈채팅방에 해당 문제를 올려봤다.
문제는 Comment comment = commentConverter.requestToEntity(member, request);
코드에서 단순히 변환만 하는게 아니었다.
해당 코드 내부에서는 postRepository.findById(request.postId())
함수가 호출되고 있었다.
public Comment requestToEntity(Member member, CommentSaveDto request) {
Post post = postRepository.findById(request.postId())
.orElseThrow(
() -> {
log.error(PostStatus.POST_NOT_FOUND.getMessage());
return new NotFoundException(PostStatus.POST_NOT_FOUND);
}
);
return Comment.builder()
.parentId(request.parentId())
.member(member)
.post(post)
.content(request.content())
.build();
}
위 문제를 두고
라는 두 가지의 의견이 나왔다.
트랜잭션 A와 트랜잭션 B 시작:
save
메서드를 호출함트랜잭션 A가 requestToEntity
메서드 호출:
requestToEntity
메서드를 호출하여 Post 엔티티를 읽음commentCount
는 0이라고 가정트랜잭션 B가 requestToEntity
메서드 호출:
requestToEntity
메서드를 호출하여 Post 엔티티를 읽음commentCount
가 0인 상태를 읽음트랜잭션 A와 B가 각각 Comment 엔티티 생성:
트랜잭션 A가 Post 엔티티 잠금 및 업데이트:
findByIdForUpdate
를 호출하여 Post 엔티티에 비관적 잠금을 설정함commentCount
값을 1로 증가시키고 Post 엔티티를 저장함트랜잭션 B가 Post 엔티티 잠금 대기:
findByIdForUpdate
를 호출하여 잠금을 시도하지만, 트랜잭션 A가 잠금을 보유하고 있으므로 대기함commentCount
값을 0으로 읽었고, commentCount
를 1로 증가시키는 동작을 수행함결과적으로 commentCount
가 잘못된 값으로 설정됨:
commentCount
값을 0으로 읽었기 때문에, 최종적으로 commentCount
값이 2가 아니라 1로 설정될 수 있음requestToEntity
메서드와 findByIdForUpdate
메서드 사이에 em.clear()를 넣었더니 동시성 문제가 해결되었다.
이게 해결되는 원인을 나는 다음과 같이 판단했다.
findByIdForUpdate
메서드 시작과 동시에 걸린 락은 save() 메서드가 끝나면서 락이 풀림findByIdForUpdate
메서드를 호출했을 때 락으로 인한 대기가 발생하고 락이 풀렸을 때 캐시가 없으므로 커밋된 가장 최근의 데이터인 A 트랜잭션 실행결과를 읽음@Transactional
public Comment save(CommentSaveDto request, Member member) {
validatePostNotReplied(request);
Comment comment = commentConverter.requestToEntity(member, request);
em.clear();
Post post = postRepository.findByIdForUpdate(request.postId())
.orElseThrow(() -> new NotFoundException(PostStatus.POST_NOT_FOUND));
log.info("[CUSTOM LOG] PESSIMISTIC_WRITE 락 획득: commentCount = {}", post.getCommentCount());
long count = post.getCommentCount();
count++;
post.setCommentCount(count);
postRepository.save(post);
log.info("[CUSTOM LOG] post 저장 : commentCount = {}", post.getCommentCount());
return commentRepository.save(comment);
}
requestToEntity
메서드와 findByIdForUpdate
메서드의 순서를 바꾸면 아래와 같은 동작이 일어난다.
findByIdForUpdate
메서드로 락을 얻음 findByIdForUpdate
메서드에서 대기 발생commentRepository.save()
를 하며 락이 해제됨findByIdForUpdate
메서드로 가장 최근에 커밋된 데이터인 A 트랜잭션의 변경 결과를 얻어옴@Transactional
public Comment save(CommentSaveDto request, Member member) {
validatePostNotReplied(request);
Post post = postRepository.findByIdForUpdate(request.postId())
.orElseThrow(() -> new NotFoundException(PostStatus.POST_NOT_FOUND));
Comment comment = commentConverter.requestToEntity(member, request);
em.clear();
log.info("[CUSTOM LOG] PESSIMISTIC_WRITE 락 획득: commentCount = {}", post.getCommentCount());
long count = post.getCommentCount();
count++;
post.setCommentCount(count);
postRepository.save(post);
log.info("[CUSTOM LOG] post 저장 : commentCount = {}", post.getCommentCount());
return commentRepository.save(comment);
}
트랜잭션이 쓰기 작업을 할 때 이전에 커밋됐던 기록을 언두로그
에 올리고 메인 데이터 파일에 쓰기를 시작한다.
언두로그
의 최신 데이터를, 커밋이 됐다면 메인 데이터 파일에서 읽어온다.지금 시작되는 트랜잭션의 번호보다 작은 트랜잭션 번호를 가진 언두로그를 참조
한다.