동시성과 데드락

개나뇽·2025년 1월 6일

문제 상황

  • 인스타그램과 같은 SNS 서비스에서 제공하는 기능에는 댓글 달기, 좋아요 등 과 같은 기능이 존재.
  • 두 기능의 공통점은 공유된 리소스에 여러 스레드가 동시에 접근해 요청을 수행.
  • 요청의 결과로 좋아요_수, 댓글_수 증감 진행
  • 증감의 결과가 올바르게 처리되지 않는 문제 발생

테이블 관계

  • comment 테이블은 user_id, feed_id를 FK 로 가짐.
  • feed 테이블은 정수값의 comment_count를 가짐
   @Transactional
    public void comment(Long userId, Long feedId, CommentRequest request) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND));
        Feed feed = feedRepository.findById(feedId)
            .orElseThrow(() -> new GlobalException(ErrorCode.FEED_NOT_FOUND));

        commentRepository.save(
            Comment.builder()
                .user(user)
                .feed(feed)
                .content(request.getContent())
                .build()
        );
        feed.addComment();
}
public void addComment() {
    this.commentCount += 1;
}
  • user 객체와 feed 객체를 통해 comment 객체를 생성.
  • feed의 commentConut를 하나 증가 시킴.

테스트 코드

    @Test
    @Transactional
    void commentCurrencyTest() throws InterruptedException {
        CommentRequest commentRequest = new CommentRequest("하이");
        Long feedId = 7L;
        int count = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch countDownLatch = new CountDownLatch(count);
        for (int i = 1; i <= count; i++) {
            Long userId = (i % 2 == 0) ? 2L : 3L;
            executorService.execute(() -> {
                try {
                    commentService.comment(userId, feedId,commentRequest);
                } catch (Exception e) {
                    e.printStackTrace();
                    System.out.println(e.getMessage());
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();

        Feed feed = feedRepository.findById(feedId).orElseThrow(RuntimeException::new);
        Assertions.assertThat(feed.getCommentCount()).isEqualTo(100);
}
  • 위 테스트 코드 결과로 데드락(교착 상태) 발생
  • 100개의 요청은 보내 18개의 commentCount 증가 → 82개의 요청이 사라짐

데드락(Deadlock)?

  • 둘 이상의 트랜잭션이 자원을 점유(Lock 획득한 상태)한 상태에서 서로 다른 트랜잭션이 획득한 자원(Lock)을 요구하며 기다리는 상황. → Lock 획득을 무한 대기
💡

락 이란?
데이터베이스에서 특정 자원(예: 테이블, 행, 페이지 등)에 대한 접근을 제어하기 위해 사용되는 메커니즘 → 즉, 쉽게 생각하면 특정 자원(DB row 등)에 접근할 수 있는 권한

Deadlock History

  • SHOW ENGINE INNODB STATUS\g 확인
------------------------
LATEST DETECTED DEADLOCK
------------------------
2025-01-04 22:52:03 0x16c097000
*** (1) TRANSACTION:
TRANSACTION 573770, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MySQL thread id 332, OS thread handle 6136180736, query id 152592 localhost 127.0.0.1 root updating
update feed set comment_count=18,content='안녕하세요',last_modified_at='2025-01-04 22:52:03.477048',like_count=0,user_id=2 where feed_id=7

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 460 page no 4 n bits 80 index PRIMARY of table `devhub`.`feed` trx id 573770 lock mode S locks rec but not gap
Record lock, heap no 9 PHYSICAL RECORD: n_fields 9; compact format; info bits 64
 0: len 8; hex 8000000000000007; asc         ;;
 1: len 6; hex 00000008c145; asc      E;;
 2: len 7; hex 010000018c0276; asc       v;;
 3: len 8; hex 99b5396de607882f; asc   9m   /;;
 4: len 8; hex 99b5896d0307224b; asc    m  "K;;
 5: len 15; hex ec9588eb8595ed9598ec84b8ec9a94; asc                ;;
 6: len 8; hex 8000000000000002; asc         ;;
 7: len 4; hex 80000012; asc     ;;
 8: len 4; hex 80000000; asc     ;;

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 460 page no 4 n bits 80 index PRIMARY of table `devhub`.`feed` trx id 573770 lock_mode X locks rec but not gap waiting
Record lock, heap no 9 PHYSICAL RECORD: n_fields 9; compact format; info bits 64
 0: len 8; hex 8000000000000007; asc         ;;
 1: len 6; hex 00000008c145; asc      E;;
 2: len 7; hex 010000018c0276; asc       v;;
 3: len 8; hex 99b5396de607882f; asc   9m   /;;
 4: len 8; hex 99b5896d0307224b; asc    m  "K;;
 5: len 15; hex ec9588eb8595ed9598ec84b8ec9a94; asc                ;;
 6: len 8; hex 8000000000000002; asc         ;;
 7: len 4; hex 80000012; asc     ;;
 8: len 4; hex 80000000; asc     ;;

*** (2) TRANSACTION:
TRANSACTION 573768, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MySQL thread id 331, OS thread handle 6135066624, query id 152594 localhost 127.0.0.1 root updating
update feed set comment_count=18,content='안녕하세요',last_modified_at='2025-01-04 22:52:03.478154',like_count=0,user_id=2 where feed_id=7

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 460 page no 4 n bits 80 index PRIMARY of table `devhub`.`feed` trx id 573768 lock mode S locks rec but not gap
Record lock, heap no 9 PHYSICAL RECORD: n_fields 9; compact format; info bits 64
 0: len 8; hex 8000000000000007; asc         ;;
 1: len 6; hex 00000008c145; asc      E;;
 2: len 7; hex 010000018c0276; asc       v;;
 3: len 8; hex 99b5396de607882f; asc   9m   /;;
 4: len 8; hex 99b5896d0307224b; asc    m  "K;;
 5: len 15; hex ec9588eb8595ed9598ec84b8ec9a94; asc                ;;
 6: len 8; hex 8000000000000002; asc         ;;
 7: len 4; hex 80000012; asc     ;;
 8: len 4; hex 80000000; asc     ;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 460 page no 4 n bits 80 index PRIMARY of table `devhub`.`feed` trx id 573768 lock_mode X locks rec but not gap waiting
Record lock, heap no 9 PHYSICAL RECORD: n_fields 9; compact format; info bits 64
 0: len 8; hex 8000000000000007; asc         ;;
 1: len 6; hex 00000008c145; asc      E;;
 2: len 7; hex 010000018c0276; asc       v;;
 3: len 8; hex 99b5396de607882f; asc   9m   /;;
 4: len 8; hex 99b5896d0307224b; asc    m  "K;;
 5: len 15; hex ec9588eb8595ed9598ec84b8ec9a94; asc                ;;
 6: len 8; hex 8000000000000002; asc         ;;
 7: len 4; hex 80000012; asc     ;;
 8: len 4; hex 80000000; asc     ;;

*** WE ROLL BACK TRANSACTION (2)
  • HOLDS THE LOCK(S) : s-Lock(Shared Lock) 획득
  • WAITING FOR THIS LOCK TO BE GRANTED : Lock 획득을 대기
    • 대기하는 Lock을 x-Lock(Exclusive Lock)

Lock 미사용시 데드락 발생 원인

외래키 잠금전파

  • 외래키는 변경시(INSERT, UPDATE, DELETE) 부모 테이블이나 자식 테이블에 데이터가 존재하는지 체크
  • Lock이 연관관계를 맺고 있는 여러 테이블로 전파
  • 변경 작업을 위해 외래키컬럼에 S-Lock 걸리게 되면서 데드락이 발생 가능

Locking
MySQL extends metadata locks, as necessary, to tables that are related by a foreign key constraint. Extending metadata locks prevents conflicting DML and DDL operations from executing concurrently on related tables. This feature also enables updates to foreign key metadata when a parent table is modified. In earlier MySQL releases, foreign key metadata, which is owned by the child table, could not be updated safely.

If a table is locked explicitly with LOCK TABLES, any tables related by a foreign key constraint are opened and locked implicitly. For foreign key checks, a shared read-only lock (LOCK TABLES READ) is taken on related tables. For cascading updates, a shared-nothing write lock (LOCK TABLES WRITE) is taken on related tables that are involved in the operation.

출처 https://dev.mysql.com/doc/refman/8.4/en/create-table-foreign-keys.html

S-Lock (Shared-Lock)

공유, 읽기 잠금 등으로 데이터를 읽을 때 사용되는 Lock
른 s-lock과 한 리소스에 두개 이상의 Lock을 동시에 설정할 수 있으나, x-Lock은 불가능
여러 트랜잭션에서 동시에 하나의 데이터를 읽을 수 있다. 그러나 변경중인 리소스를 동시에 읽을 수는 없다.

  • FK가 있는 테이블에서 FK를 포함한 데이터를 insert, update, delete 쿼리는 제약 조건 확인을 위해 s-Lock 설정

→ comment 테이블에 데이터 insert 동작중 fk인 Feed에 s-lock

X-Lock (Exclusive-Lock)

데이터 변경시 사용
다른 Lock 들과 호환되지 않기 때문에, 한 리소스에 하나의 x-Lock만 설정 가능
동시에 여러 트랜잭션이 한 리소스에 엑세스할 수 없게 된다.(읽기도 불가)
→ 오직 하나의 트랜잭션만 해당 리소스 점유 가능

  • 레코드에 update 쿼리 동작시 사용되는 모든 레코드에 x-Lock 설정

→ Feed에 commentCount를 update 하면서 x-lock 설정

데드락 발생 과정

  1. 트랜잭션 A가 데이터를 insert하면서 FK가 걸려있는 레코드에 s-Lock 설정
  2. 트랜잭션 B가 데이터를 insert하면서 FK가 걸려있는 레코드에 s-Lock 설정
  3. 트랜잭션 B가 Feed의 commentCount를 update하기 위해 x-Lock 설정 대기하려고 하나 이미 s-Lock이 걸려 대기
  4. 트랜잭션 B가 Feed의 commentCount를 update하기 위해 x-Lock 설정 대기하려고 하나 이미 s-Lock이 걸려 대기

→ 서로 다른 트랜잭션이 같은 자원에 대해 Lock를 가져 서로 Lock를 해제할때까지 대기하면서 데드락 발생

접근 과정

데드락에 대해 조사하다 보니 A트랜잭션에서 이미 Lock를 점유한 상태에서 B트랜잭션이 동일한 리소스에 대한 Lock 요구하는 상황이라면 이런 상황을 처음부터 막는다면 데드락이 발생하지 않을 거라는 생각이 듬.

이런 방법을 비관적 락이라고 한다.

해결 방법

낙관적 락 (Optimistic Lock)

  • 실제 Lock 을 이용하지 않고 Version 이라는 개념을 도입해 정합성을 맞추는 방법
  • 데이터를 읽은 후 update 동작시 내가 읽은 Version이 맞는지 확인후 업데이트
  • 리소스에 락을 걸어 점유하지 않고, 동시성 이슈 발생시 그때 처리하는 방식
  • 읽은 버전에서 수정사항이 발생할 경우 application에서 다시 읽은 후 작업을 하는 롤백 작업 수행

적용 방법

  • 엔티티에 version 컬럼을 추가하고 @Version 애노테이이션 사용
  • Repository에서 Feed를 가져오는 쿼리 메서드에 @Lock 애노테이션 사용
    • NONE: 락을 적용하지 않아도 엔티티에 이 적용된 필드가 있다면 낙관적 락이 적용
    • OPTIMISTIC(READ): 읽기시에도 락을 사용. 버전을 체크하고, 트랜잭션이 종료될 때까지 다른 트랜잭션에서 변경하지 않음을 보장
    • OPTIMISTIC_FORCE_INCREMENT(WRITE): 낙관적 락을 사용하면서 버전 정보를 강제로 증가
@Entity
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Feed extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long feedId;

    @JoinColumn(name = "user_id")
    @ManyToOne(fetch = FetchType.LAZY)
    private User user;
    @Version
    private Long version;
    
}
---------------------------
public interface FeedRepository extends JpaRepository<Feed, Long> {
    @Lock(LockModeType.OPTIMISTIC)
    Optional<Feed> findByFeedId(Long feedId);
}

주의 사항

  • 버전은 JPA에서 관리하므로 개발자가 수정하면 안됨
    • 벌크 연산시에는 JPA가 관리하지 않으므로 개발자가 직접 버전 관리
  • 최초 커밋만 인정

예를 들어 선착순 100명에게 발급해주는 쿠폰 이벤트에 동시에 100명이 요청했다면 100명 모두 동시에 받는게 아닌 최초의 커밋 1명만 받고 나머지 99명은 다시 이벤트에 신청해야 한다. → 이에 따른 후속 조치를 해줘야 한다.
(Retry 등…)

비관적 락 (Pessimistic Lock)

  • 실제로 데이터에 Lock을 걸어 정합성을 맞추는 방식
  • 트랜잭션 시작시 S-Lock 또는 X-Lock를 걸고 시작
  • 데이터에는 Lock을 가진 스레드만 접근이 가능하도록 제어하는 방법

적용 방법

  • Repository에서 Feed를 가져오는 쿼리 메서드에 @Lock 애노테이션 사용
    • PESSIMISTIC_READ : S-Lock을 획득하고 데이터가 update, delete 방지
    • PESSIMISTIC_WRITE : X-Lock을 획득하고 데이터를 다른 트랜잭션에서 read,update,delete 방지
    • PESSIMISTIC_FORCE_INCREMENT : PESSIMISTIC_WRITE와 유사하지만 @Version이 지정된 Entity와 협력하기 위해 도입되어 PESSIMISTIC_FORCE_INCREMENT 락을 획득할 시 버전이 업데이트
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select f from Feed f where f.feedId = :feedId")
Optional<Feed> findByFeedId(Long feedId);

주의 사항

  • 여러 테이블에 Lock을 걸면서 데드락이 발생하는 경우 비관적 락으로는 해결이 불가능.

예를 들어

  • A 트랜잭션이 테이블 1의 1번 데이터에 Lock을 획득
  • B 트랜잭션이 테이블 2의 1번 데이터에 Lock을 획득
  • A 트랜잭션이 테이블 2의 1번 데이터에 Lock을 획득 시도(이미 B가 락을 점유중으로 실패 - 대기)
  • B 트랜잭션이 테이블 1의 1번 데이터에 Lock을 획득 시도(이미 A가 락을 점유중으로 실패 - 대기)

선택

  • 낙관적 락은 충돌이 발생하지 않을 것을 상정
  • 비관적 락은 충돌이 반드시 발생 한다는것을 상정

→ 댓글과 좋아요라는 기능의 경우 충돌이 일어날수 밖에 없는 기능이라 생각되어 비관적 락을 선택

결과

@Test
void commentCurrencyTest() throws InterruptedException {
    CommentRequest commentRequest = new CommentRequest("하이");
    Long feedId = 10L;
    int count = 100;
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch countDownLatch = new CountDownLatch(count);
    for (int i = 1; i <= count; i++) {
        Long userId = (i % 2 == 0) ? 2L : 3L;
        executorService.execute(() -> {
            try {
                commentService.comment(userId, feedId,commentRequest);
            } catch (Exception e) {
                e.printStackTrace();
                System.out.println(e.getMessage());
            }
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();

    Feed feed = feedRepository.findById(feedId).orElseThrow(RuntimeException::new);
    System.out.println(feed.getCommentCount());
    Assertions.assertThat(feed.getCommentCount()).isEqualTo(100);
}
----------------------------------------------------------------
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select f from Feed f where f.feedId = :feedId")
Optional<Feed> findByFeedId(Long feedId);
----------------------------------------------------------------
@Transactional
public void comment(Long userId, Long feedId, CommentRequest request) {
    User user = userRepository.findById(userId)
        .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND));
    Feed feed = feedRepository.findByFeedId(feedId)
        .orElseThrow(() -> new GlobalException(ErrorCode.FEED_NOT_FOUND));

    commentRepository.save(
        Comment.builder()
            .user(user)
            .feed(feed)
            .content(request.getContent())
            .build()
    );
    feed.addComment();
}

  • 동시성 문제가 자주 일어난다면 롤백의 횟수가 적어 낙관적 락 보다 성능이 좋을 수 있음
  • Lock이 필요치 않은 상황에서도 Lock을 걸어 성능상 문제가 될 수 있음(읽기가 많이 이뤄지는 경우)
  • 선착순 이벤트처럼 트래픽이 몰리거나, 여러 테이플에 Lock을 걸면서 자원이 필요한 겨우 데드락이 발생할 수 있으며 비관적 락으로 해결 불가

참고 글

참고1
참고2
참고3
참고4
참고5

profile
정신차려 이 각박한 세상속에서!!!

0개의 댓글