Trouble Shooting

MySQL에서 의도치 않게 조회시 레코드에 S락을 거는 문제

olsohee 2024. 5. 21. 15:15

지난 번 프로젝트에서도 그렇고 이번 프로젝트에서도 동시성을 테스트하는 상황에서 데드락이 발생했다. 데드락 발생 원인을 알고자 InnoDB 로그를 살펴보니, 의도치 않게 조회시 레코드에 S락을 걸면서 데드락이 발생한 것이었다. 이에 대해 더 자세히 알아보자.

MySQL이 락을 거는 방식

결론부터 말하자면 MySQL은 외래키 제약이 테이블에 정의되어 있으면 레코드 삽입, 수정, 삭제시에 해당 참조 레코드에 S락을 건다. 공식 문서에서는 다음과 같이 말한다.

  • If a FOREIGN KEY constraint is defined on a table, any insert, update, or delete that requires the constraint condition to be checked sets shared record-level locks on the records that it looks at to check the constraint. InnoDB also sets these locks in the case where the constraint fails.
  • 테이블에 외래 키 제약 조건이 정의되어 있는 경우 제약 조건을 확인해야 하는 모든 삽입, 업데이트 또는 삭제는 제약 조건을 확인하기 위해 살펴보는 레코드에 공유 레코드 수준 잠금을 설정합니다. InnoDB는 제약 조건이 실패하는 경우에도 이러한 잠금을 설정합니다.

프로젝트 상황

좋아요를 누르는 경우를 예로 들어보자. 우선 게시글을 의미하는 post와 좋아요를 의미하는 like는 일대다 관계이다. 그리고 반정규화로 인해 post는 좋아요 수를 의미하는 likeCount 필드를 갖는다.

 

프로젝트에서 좋아요를 누르는 로직은 다음과 같다.

  • post 엔티티를 조회해온다. 
  • post.getLikes()를 통해 post와 연관된 like 엔티티 중 현재 좋아요를 누르려는 사용자가 이미 좋아요를 눌렀는지 아닌지를 판단한다.
    • 좋아요를 누르지 않았으면 post의 likeCount 필드 값을 증가시킨다(post update). 그리고 like 엔티티를 저장한다(like insert).
    • 반면 이미 좋아요를 눌렀으면 post의 likeCount 필드 값을 감소시키고, like 엔티티를 삭제한다.

CASE 1. post 조회 후 연관된 like 조회 -> 데드락 발생

post를 조회한 후 post.getLikes()를 통해 연관된 like를 조회한다. 그리고 현재 좋아요를 누르려는 사용자가 좋아요를 누른 기록인 like 엔티티가 있는지 확인한다.

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;
    private final LikeRepository likeRepository;

    public void likePost(long postId, String email) {
        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND));

        Optional<Like> alreadyExistLike = post.getLikes().stream()
                .filter(like -> like.getLikeUserEmail().equals(email)).findAny();

        alreadyExistLike.ifPresentOrElse(like -> {
            post.deleteLike(like);
            likeRepository.delete(like);
        }, () -> post.addLike(Like.create(email)));
    }
}

 

그러나 이 방법은 데드락이 발생했다.

 

show engine innodb status; 를 통해 MySQL InnoDB 로그를 확인해봤다. 아래 사진은 로그 중 핵심 부분만 추출한 사진이다.

 

대충 트랜잭션 1과 트랜잭션 2가 각각 post에 대해 S락을 획득했고, 두 트랜잭션이 X락을 획득하기 위해 대기하는 상태가 되며 데드락이 발생한 거 같다.

 

그런데 단순히 post를 조회할 때는 S락을 획득하지 않을 것이라고 예상했고, 실행된 쿼리를 보면 select for share 구문이 나가지 않았는데 왜 S락을 획득한 건지 의문이었다.

 

그러나 앞서 설명한 MySQL이 락을 거는 방식을 이해하면 왜 데드락이 발생했는지 알 수 있다.

  • 트랜잭션 1과 트랜잭션 2는 각각 post 를 조회한다.
  • 이후에 각 트랜잭션은 post와 연관된 like를 조회한다. 그런데 이때 like 외래키 제약 조건으로 연관된 post 레코드에 S락이 걸린다.
  • 그리고 각 트랜잭션은 post의 likeCount 값을 변경하는 update를 실행하기 위해 post 레코드에 대한 X락을 대기한다. (데드락 발생 지점!)

CASE 2. post, like 페치 조인으로 함께 조회 -> 데드락 발생

그러면 post와 like를 페치 조인으로 함께 조회하는 경우엔 어떨까?

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;
    private final LikeRepository likeRepository;

    public void likePost(long postId, String email) {
        Post post = postRepository.findByIdWithLike(postId)
                .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND));

        Optional<Like> alreadyExistLike = post.getLikes().stream()
                .filter(like -> like.getLikeUserEmail().equals(email)).findAny();

        alreadyExistLike.ifPresentOrElse(like -> {
            post.deleteLike(like);
            likeRepository.delete(like);
        }, () -> post.addLike(Like.create(email)));
    }
}
public interface PostRepository extends JpaRepository<Post, Long> {

    @Query("select p from Post p " +
            "left join fetch p.likes " +
            "where p.id = :postId")
    Optional<Post> findByIdWithLike(@Param("postId") long postId);
}

 

그러나 이 경우에도 마찬가지로 데드락이 발생했다. 아마 post와 like를 함께 조회하더라도 결국엔 like를 조회해오며 연관된 post에 S락을 걸기 때문인 거 같다.

CASE 3.  post, like 페치 조인으로 함께 조회 + 비관적 락을 걸어 조회 -> 성공

따라서 post와 like를 페치 조인으로 함께 조회할 때 배타락을 걸도록 했다. 

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;
    private final LikeRepository likeRepository;

    public void likePost(long postId, String email) {
        Post post = postRepository.findByIdWithLikeWithLock(postId)
                .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND));

        Optional<Like> alreadyExistLike = post.getLikes().stream()
                .filter(like -> like.getLikeUserEmail().equals(email)).findAny();

        alreadyExistLike.ifPresentOrElse(like -> {
            post.deleteLike(like);
            likeRepository.delete(like);
        }, () -> post.addLike(Like.create(email)));
    }
}
public interface PostRepository extends JpaRepository<Post, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select p from Post p " +
            "left join fetch p.likes " +
            "where p.id = :postId")
    Optional<Post> findByIdWithLikeWithLock(@Param("postId") long postId);
}

 

이렇게 함으로써 post와 like를 조회할 때 하나의 트랜잭션이 배타락을 걸어, post를 update하고 like insert/delete 할 때 다른 트랜잭션은 대기한다. 따라서 동시성 상황에서의 데드락 문제와 공유 자원(likeCount)에 대한 정합성 문제를 해결할 수 있었다.

정리

  1. post 조회 후 연관된 like 조회 -> 데드락 발생
    • post와 연관된 like를 조회하며 post 레코드에 S락을 건다.
    • post 레코드의 update를 위해서 X락을 대기한다. (데드락 발생)
  2. post, like 페치 조인으로 함께 조회 -> 데드락 발생
    • 1번과 마찬가지로 like를 조회할 때 post 레코드에 S락을 걸기 때문에 데드락이 발생한다.
  3. post, like 페치 조인으로 함께 조회 + 비관적 락을 걸어 조회 -> 성공
    • post와 like를 조회하며 X락을 걸어 해당 트랜잭션이 post를 update하고 like를 insert/delete하는 동안 다른 트랜잭션은 대기한다.

Reference