비관적 락이 걸리지 않는 문제

Rookedsysc·2024년 7월 5일
2

이번 포스팅은 락별 성능테스트 중에 발생했던 락이 걸리지 않는 문제에 대해서 얘기를 해보겠다.

비관적 락 코드

Post : Commnt == 1 : N의 연관관계인 상황.
Comment를 Post 테이블의 comment_count 컬럼에도 값을 업데이트를 해야한다.
병렬처리로 10건 입력시 정확히 10건이 입력이 될 수 있게 비관락을 구현했다.

  • findByIdForUpdate (Lock 메서드)
    @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);
  • Transacional 걸린 전체 메서드
    @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()) 함수가 호출되고 있었다.

  • DB 의존성이 있는 코드인지 commentConverter.requestToEntity()라는 클래스 & 함수명에서 알 수 없음
    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();
    }

의견

위 문제를 두고

  • 트랜잭션의 특성이다 (트랜잭션 특성상 트랜잭션이 시작될 때의 데이터를 그대로 가지고 있음)
  • 1차 캐싱의 문제다

라는 두 가지의 의견이 나왔다.

동작과정 추측

  • 트랜잭션 A와 트랜잭션 B 시작:

    • 두 트랜잭션이 동시에 save 메서드를 호출함
  • 트랜잭션 A가 requestToEntity 메서드 호출:

    • 트랜잭션 A는 requestToEntity 메서드를 호출하여 Post 엔티티를 읽음
    • 이 시점에서 Post 엔티티의 commentCount는 0이라고 가정
  • 트랜잭션 B가 requestToEntity 메서드 호출:

    • 트랜잭션 B도 requestToEntity 메서드를 호출하여 Post 엔티티를 읽음
    • 트랜잭션 B도 Post 엔티티의 commentCount가 0인 상태를 읽음
  • 트랜잭션 A와 B가 각각 Comment 엔티티 생성:

    • 트랜잭션 A와 B는 각각 읽어온 Post 엔티티를 기반으로 Comment 엔티티를 생성함
  • 트랜잭션 A가 Post 엔티티 잠금 및 업데이트:

    • 트랜잭션 A는 findByIdForUpdate를 호출하여 Post 엔티티에 비관적 잠금을 설정함
    • commentCount 값을 1로 증가시키고 Post 엔티티를 저장함
  • 트랜잭션 B가 Post 엔티티 잠금 대기:

    • 트랜잭션 B는 findByIdForUpdate를 호출하여 잠금을 시도하지만, 트랜잭션 A가 잠금을 보유하고 있으므로 대기함
    • 트랜잭션 A가 커밋하고 잠금을 해제하면, 트랜잭션 B는 잠금을 획득하고 Post 엔티티를 읽음
    • 하지만 트랜잭션 B는 이미 commentCount 값을 0으로 읽었고, commentCount를 1로 증가시키는 동작을 수행함
  • 결과적으로 commentCount가 잘못된 값으로 설정됨:

    • 트랜잭션 A와 B는 모두 초기 commentCount 값을 0으로 읽었기 때문에, 최종적으로 commentCount 값이 2가 아니라 1로 설정될 수 있음

em.clear()를 하면 어떨까?

requestToEntity 메서드와 findByIdForUpdate 메서드 사이에 em.clear()를 넣었더니 동시성 문제가 해결되었다.
이게 해결되는 원인을 나는 다음과 같이 판단했다.

  • REPEATABLE READ(MySQL 기본 격리수준)에서는 트랜잭션이 시작할 때 트랜잭션 고유번호를 부여 받음
  • REPEATABLE READ는 읽기를 할 때 내 트랜잭션 고유 번호보다 작은 커밋된 데이터를 읽음
  • 따라서 A 트랜잭션에서 findByIdForUpdate 메서드 시작과 동시에 걸린 락은 save() 메서드가 끝나면서 락이 풀림
  • B 트랜잭션에서 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 메서드의 순서를 바꾸면 아래와 같은 동작이 일어난다.

  • A 트랜잭션에서 findByIdForUpdate 메서드로 락을 얻음
  • B 트랜잭션에서는 findByIdForUpdate 메서드에서 대기 발생
  • A 트랜잭션에서 commentRepository.save()를 하며 락이 해제됨
  • 락이 풀리며 B 트랜잭션에서 findByIdForUpdate 메서드로 가장 최근에 커밋된 데이터인 A 트랜잭션의 변경 결과를 얻어옴
  • 결과적으로 B 트랜잭션의 1차적으로 캐싱되는 데이터가 락이 걸리면서 쓰고 난 이후의 데이터가 됨
@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);
}

Isolation

트랜잭션이 쓰기 작업을 할 때 이전에 커밋됐던 기록을 언두로그에 올리고 메인 데이터 파일에 쓰기를 시작한다.

  • READ UNCOMMITED : 메인 데이터 파일에서 읽어온다. (Commit 되지 않은 데이터 파일을 읽는 Dirty Read 발생)
  • READ COMMITED : 커밋이 되지 않았다면 언두로그의 최신 데이터를, 커밋이 됐다면 메인 데이터 파일에서 읽어온다.
  • REPEATABLE READ : MVCC를 보장하기 위해 모든 트랜잭션은 고유한 트랜잭션 번호가 있는데 언두 영역의 데이터 중에서 지금 시작되는 트랜잭션의 번호보다 작은 트랜잭션 번호를 가진 언두로그를 참조한다.
  • SERIALIZABLE : 읽기 작업도 테이블 수준의 공유 잠금을 획득해야지 사용할 수 있게 된다.

배운점

  • converter에서 DB 의존이 있는 코드인지 명확하지 않다. 하나의 역할만 하는 메서드를 작성하자!
  • 락 거는 쿼리는 Transaction이 시작될 때 오는게 좋다.

Reference

0개의 댓글