만들어보기/TroubleShooting

[TroubleShooting] MySQL 락을 건 적이 없는데 데드락이 발생한다

다섯자두 2025. 4. 6. 14:32

배경

동시성 제어 로직을 추가하기 전에, 티켓팅 프로젝트에서 다수의 사용자가 동시에 같은 좌석을 예매하는 상황을 테스트하기 위해 테스트 코드를 짰다.

해당 테스트는 다음 목표를 가지고 있었다.

  • 동시성 제어 없이 중복 예매가 발생하는지 검증
  • 분산 락 없이 JPA 트랜잭션만으로는 안전하지 않다는 것을 검증

이에 따라 n명의 유저가 하나의 좌석을 동시에 예매하는 시나리오를 구현했다.

예상 결과

테스트의 예상 결과는 다음과 같았다.

  • 아직 락을 걸지 않았기 때문에 티켓이 여러 개 생성되어 테스트가 실패해야 한다.
  • 즉 하나의 `200 OK` 응답과 n-1개의 `400 BAD REQUEST` 응답이 반환된다.

하지만 ... 

실제 결과

실제 결과는 전혀 다르게 나왔다.

  • 실제로 생성된 티켓은 1개 뿐이었다.
  • 하나의 `200 OK` 응답과 다수의 `500 INTERNAL SERVER ERROR`, `400 BAD REQUEST` 응답이 반환되었다.

로그를 확인해보니 아래와 같은 에러가 발생하고 있었다.

락을 얻으려는 중에 데드락이 발생했단다. 

즉, 중복 생성은 발생하지 않았지만 데드락이 발생한 후 대부분의 요청이 롤백 처리되어 단 하나의 요청만 성공한 것이다.

그런데 난 아직 동시성 제어를 위한 락 처리를 하나도 하지 않은 상태였다 ... 어떻게 된걸까?


전개

🌀 난 락을 건 적 없는데 누가 락을 거는걸까?

처음에는 `@Transactional` 어노테이션을 다는 것 만으로도 해당 자원에 접근할 때 락이 걸리는 것일까? 라고 생각했지만,

실제로는 MySQL의 InnoDB 엔진이 자동으로 건 row-level 락을 건 것이었다.

찾아 본 결과 InnoDB는 개발자가 락을 명시하지 않았더라도, 내부적으로 필요한 락을 건다.

상황별 InnoDB 락 동작

상황 락 종류 설명
UPDATE, DELETE 수행 시 Row-level X Lock 해당 row에 대한 단독 접근
INSERT 수행 시 Insert Intent Lock 인덱스 충돌 방지 목적으로, 삽입 위치를 미리 선점하기 위한 목적으로 사용
SELECT FOR UPDATE 명시적 X Lock 명시적으로 거는 락

SELECT FOR UPDATE으로 개발자가 명시적으로 락을 걸지 않더라도 내부적으로 자동 락이 발생하며, 해당 락들이 교차되면 데드락이 발생할 수 있는 것이다..!!

🌀 원인을 파악해보자, 어디서 데드락이 발생하는가?

`SHOW ENGINE INNODB STATUS` 라는 명령어를 통해 로그를 명시적으로 확인할 수 있다.

테스트 코드를 돌려 다시 예외를 발생시킨 직후 터미널에서 확인해보았다.

------------------------
LATEST DETECTED DEADLOCK
------------------------
2025-03-28 00:22:31 0x16e27f000
*** (1) TRANSACTION:
TRANSACTION 91775, ACTIVE 1 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 13 lock struct(s), heap size 1128, 6 row lock(s), undo log entries 3
MySQL thread id 1114, OS thread handle 6163836928, query id 3055400 localhost 127.0.0.1 sparta updating
/* update for com.example.ticketing.domain.concert.entity.Concert */update concerts set available_seat_count=-1,concert_date='2025-03-29 00:22:30.342449',concert_name='Test Concert',concert_type='SINGER',created_at='2025-03-28 00:22:30.345133',max_ticket_per_user=1,modified_at='2025-03-28 00:22:31.181721',ticketing_date='2025-03-28 00:12:30.342465',total_seat_count=0,user_id=1 where id=1

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 1234 page no 4 n bits 72 index PRIMARY of table `spartatest`.`concerts` trx id 91775 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 13; compact format; info bits 0
 0: len 8; hex 8000000000000001; asc         ;;
 1: len 6; hex 00000001666f; asc     fo;;
 2: len 7; hex 810000017e0110; asc     ~  ;;
 3: len 8; hex 99b638059e05442d; asc   8   D-;;
 4: len 8; hex 99b638059e05442d; asc   8   D-;;
 5: len 4; hex 80000000; asc     ;;
 6: len 8; hex 99b63a059e0539b1; asc   :   9 ;;
 7: len 12; hex 5465737420436f6e63657274; asc Test Concert;;
 8: len 1; hex 05; asc  ;;
 9: len 4; hex 80000001; asc     ;;
 10: len 8; hex 99b638031e0539c1; asc   8   9 ;;
 11: len 4; hex 80000000; asc     ;;
 12: len 8; hex 8000000000000001; asc         ;;


*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1234 page no 4 n bits 72 index PRIMARY of table `spartatest`.`concerts` trx id 91775 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 13; compact format; info bits 0
 0: len 8; hex 8000000000000001; asc         ;;
 1: len 6; hex 00000001666f; asc     fo;;
 2: len 7; hex 810000017e0110; asc     ~  ;;
 3: len 8; hex 99b638059e05442d; asc   8   D-;;
 4: len 8; hex 99b638059e05442d; asc   8   D-;;
 5: len 4; hex 80000000; asc     ;;
 6: len 8; hex 99b63a059e0539b1; asc   :   9 ;;
 7: len 12; hex 5465737420436f6e63657274; asc Test Concert;;
 8: len 1; hex 05; asc  ;;
 9: len 4; hex 80000001; asc     ;;
 10: len 8; hex 99b638031e0539c1; asc   8   9 ;;
 11: len 4; hex 80000000; asc     ;;
 12: len 8; hex 8000000000000001; asc         ;;


*** (2) TRANSACTION:
TRANSACTION 91774, ACTIVE 1 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 13 lock struct(s), heap size 1128, 6 row lock(s), undo log entries 3
MySQL thread id 1118, OS thread handle 6159380480, query id 3055401 localhost 127.0.0.1 sparta updating
/* update for com.example.ticketing.domain.concert.entity.Concert */update concerts set available_seat_count=-1,concert_date='2025-03-29 00:22:30.342449',concert_name='Test Concert',concert_type='SINGER',created_at='2025-03-28 00:22:30.345133',max_ticket_per_user=1,modified_at='2025-03-28 00:22:31.181721',ticketing_date='2025-03-28 00:12:30.342465',total_seat_count=0,user_id=1 where id=1

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 1234 page no 4 n bits 72 index PRIMARY of table `spartatest`.`concerts` trx id 91774 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 13; compact format; info bits 0
 0: len 8; hex 8000000000000001; asc         ;;
 1: len 6; hex 00000001666f; asc     fo;;
 2: len 7; hex 810000017e0110; asc     ~  ;;
 3: len 8; hex 99b638059e05442d; asc   8   D-;;
 4: len 8; hex 99b638059e05442d; asc   8   D-;;
 5: len 4; hex 80000000; asc     ;;
 6: len 8; hex 99b63a059e0539b1; asc   :   9 ;;
 7: len 12; hex 5465737420436f6e63657274; asc Test Concert;;
 8: len 1; hex 05; asc  ;;
 9: len 4; hex 80000001; asc     ;;
 10: len 8; hex 99b638031e0539c1; asc   8   9 ;;
 11: len 4; hex 80000000; asc     ;;
 12: len 8; hex 8000000000000001; asc         ;;


*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1234 page no 4 n bits 72 index PRIMARY of table `spartatest`.`concerts` trx id 91774 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 13; compact format; info bits 0
 0: len 8; hex 8000000000000001; asc         ;;
 1: len 6; hex 00000001666f; asc     fo;;
 2: len 7; hex 810000017e0110; asc     ~  ;;
 3: len 8; hex 99b638059e05442d; asc   8   D-;;
 4: len 8; hex 99b638059e05442d; asc   8   D-;;
 5: len 4; hex 80000000; asc     ;;
 6: len 8; hex 99b63a059e0539b1; asc   :   9 ;;
 7: len 12; hex 5465737420436f6e63657274; asc Test Concert;;
 8: len 1; hex 05; asc  ;;
 9: len 4; hex 80000001; asc     ;;
 10: len 8; hex 99b638031e0539c1; asc   8   9 ;;
 11: len 4; hex 80000000; asc     ;;
 12: len 8; hex 8000000000000001; asc         ;;

*** WE ROLL BACK TRANSACTION (2)

▶ 로그 분석

update concerts set available_seat_count=... where id=1
  • 서로 다른 트랜잭션이 같은 콘서트 row(id=1)에 대해 S lock을 가지고 있으면서, 동시에 X lock을 요청하고 있다.
  • 이로 인해 서로가 서로의 락 해제를 기다리며 데드락이 발생했다.
    • 서로 X Lock을 얻기 위해 상대가 S Lock을 해제하기를 기다림
  • 결국 하나의 트랜잭션만 성공하고 나머지는 자동 롤백되었다.
🔽 X Lock과 S Lock 잠깐 살펴보기
  X Lock (Exclusive Lock) S Lock (Shared Lock)
설명 데이터를 읽기+쓰기 할 수 있는 락 데이터를 읽기만 할 수 있는 락
다른 트랜잭션의 S Lock 요청 시 허용 여부 허용 안 됨 허용 됨
다른 트랜잭션의 X Lock 요청 시 허용 여부 허용 안 됨 허용 안 됨

현재 Concert와 SeatDetail에 `availableSeatCount` 라는 통계성 필드가 존재하여서 티켓을 발행할 때마다 해당 필드를 업데이트하고 있기 때문에 데드락이 발생하고 있던 것이었다.

아래 메서드가 코드 레벨에서 해당하는 부분이다.

private Ticket createTicket(User user, Concert concert, Order order, Seat seat) {
         concert.decreaseAvailableSeatCount(1);
         seat.markAsUnavailable();
         seat.getSeatDetail().decreaseAvailableSeatCount(1);
         return Ticket.builder()
                 .user(user)
                 .order(order)
                 .concert(concert)
                 .seat(seat)
                 .price(seat.getSeatDetail().getPrice())
                 .ticketStatus(TicketStatus.AVAILABLE)
                 .build();
     }

💡 서로 다른 좌석을 예매해도 데드락은 발생할 수 있다

현재 테스트 코드는 동일 좌석에 대한 동시 요청을 가정했지만, 동일 좌석이 아니라 하더라도 데드락이 발생할 수 있음을 깨닫게 되었다.

  • 여러 Seat 객체가 동일한 SeatDetail 또는 Concert를 공유한다.
  • 따라서 하나의 콘서트에 대해서 특정 시각에 예매가 시작되면, 같은 Concert에 대한 좌석 예매 요청이 몰리게 되고
  • 특정 Concert row에 대한 update 쿼리 요청이 동시에 발생하여 데드락이 발생할 수 있다.

따라서 이에 대한 추가 대응 방안이 필요함을 알게 되었다.

  • 단순히 같은 좌석 예매를 락으로 막는 것뿐만 아니라
  • 같은 SeatDetail / Concert를 공유하는 좌석군 전체에 대한 보호 전략이 필요하다.

번외 : 왜 항상 Concert 업데이트에서 데드락이 발생할까?

테스트를 여러 번 수행하고 로그를 분석해본 결과, 데드락이 발생할 때마다 항상 로그의 원인은 concert 엔티티의 업데이트 쿼리였다.
사실 seatDetail도 concert와 마찬가지로 통계성 필드를 update하는 로직이 존재하는데, 왜 유독 concert에서만 데드락이 발생했을까? 하는 의문이 들었다.

가설: Concert 업데이트가 가장 먼저 락을 요청하는 로직이라서?

  • 소스 코드를 하나씩 확인해보니, `concert.decreaseAvailableSeatCount()`가 트랜잭션 안에서 가장 먼저 수행되는 update 로직이었다.
  • 그 전에는 insert 혹은 select 위주 작업만 있었고, 실제 row-level 락이 발생할 만한 update는 concert가 처음이었다.
  • 그래서 "락을 가장 먼저 획득하려는 대상이 concert이기 때문이 아닐까?" 하는 가설을 세웠다.

실험: 업데이트 순서를 바꿔보면?

가설을 검증하기 위해 concert와 seatDetail의 업데이트 순서를 바꿔보았다.

private Ticket createTicket(...) {
     seat.getSeatDetail().decreaseAvailableSeatCount(1); // 먼저 수행
     concert.decreaseAvailableSeatCount(1);
     seat.markAsUnavailable();
    
     ...
}

하지만 결과는 동일했다.
여전히 데드락은 concert 업데이트에서 발생했다.

추가 실험: Concert 업데이트를 아예 제거하면?

이번엔 concert 업데이트 로직을 완전히 제거하고 테스트를 수행해보았다.
그 결과, 이번에는 seatDetail 업데이트에서 데드락이 발생하는 것을 확인할 수 있었다.

결론: 먼저 update된 자원이 데드락의 첫 희생자가 되는 경향이 있다

  • 결국 어떤 자원이 데드락의 트리거 지점이 되는지는 실제로 실행되는 updatea 쿼리의 순서와 관련이 있다.
  • 하지만 그 순서는 개발자가 작성한 코드 순서와는 무관할 수 있다.
    • 왜냐하면 JPA는 변경 사항을 모아서 flush 시점에 일괄 반영하기 때문
  • 따라서 어떤 자원이 먼저 락을 획득할지는 개발자가 직접 제어할 수 없다.
명시적 락을 사용하지 않는 한, DB에서 어떤 자원이 먼저 락을 획득할지는 통제할 수 없으며
따라서 데드락 방지를 위해서는 자원 접근 순서나 트랜잭션 설계에 대한 고려가 반드시 필요하다 !! 

최종 결론

락을 명시하지 않아도 데드락은 발생할 수 있다

`@Transactional`은 트랜잭션 범위를 정의하지만, 락을 명시적으로 거는 것은 아니다.

하지만 MySQL의 InnoDB는 UPDATE, INSERT 등의 쿼리 수행 시 자동으로 row-level 락을 건다.
이 때 서로 다른 트랜잭션 간 락 경합이 발생할 수 있으며, InnoDB는 덜 중요한 트랜잭션을 자동 rollback 시켜 해결한다.

어떤 트랜잭션이 rollback될지는 내부 알고리즘으로 결정되어 개발자가 이를 제어하거나 예측할 수 없다.

JPA는 쿼리 실행 순서를 보장하지 않는다

JPA는 변경된 엔티티들을 flush 시점에 일괄 반영하므로, 코드에서 먼저 호출된 update가 실제로 먼저 실행된다는 보장이 없다.

따라서 명시적으로 제어하지 않는 이상 락의 획득 순서는 예측 불가능하다.

서로 다른 좌석을 예매해도 데드락은 발생할 수 있겠구나

여러 좌석이 동일한 SeatDetail 또는 Concert를 참조하고 있다면, 각기 다른 좌석에 대한 예매 요청이라도 공통 자원에 대한 update 경합이 발생할 수 있다.

즉, 단순히 동일 좌석 접근만 막는 것만으로는 불충분하다.

테스트 코드의 중요성

이래서 다들 테스트 코드가 중요하다고 하는건가 ......

코드는 언제든 예상과 다르게 동작할 수 있고 내가 차마 생각하지 못 한 다양한 상황들이 존재할 수 있음을 다시 한 번 제대로 깨달을 수 있었다 ㅎㅎ

앞으로는 동시성 같은 민감한 부분 뿐만 아니라 다른 로직에서도 테스트 코드를 열심히 작성해야겠다는 다짐도 함께 얻게 되었다