댓글을 많이 쓰면 데드락?

2026. 2. 21. 16:23Backend

이 글은 동시성 환경에서 자식 엔티티 생성이 단순한 INSERT처럼 보이더라도, 실제로는 부모 엔티티에 대한 FK 제약 검증과 부모 엔티티의 상태 변경(집계 업데이트)이 결합될 때 예상치 못한 교착 상태(deadlock)가 발생할 수 있음을 다룹니다.

  • MySQL InnoDB deadlock 로그를 직접 분석해 데드락 원인을 확정하고,
  • 가능한 대체안들과 트레이드오프를 비교합니다.

최종적으로 집계 업데이트를 비동기로 분리하고, 장애 시 복구(재교정) 흐름까지 정리합니다.

 


문제 상황

SNS 특성 상 댓글 작성 요청이 증가하는 상황을 가정해 K6로 부하 테스트를 수행했는데, 댓글 쓰기 실패율이 급격히 증가하는 장애가 발생했습니다.

 

시나리오는 다음과 같습니다.

사용자 100명이 1개의 게시글에 1분동안 3초 간격으로 댓글 작성.
 k.f.g.exception.GlobalExceptionHandler   : Unhandled exception
 org.springframework.dao.CannotAcquireLockException: JDBC exception executing SQL [update posts p1_0 set comment_count=(p1_0.comment_count+1) where p1_0.id=?] [ Deadlock found when trying to get lock; try restarting transaction] [n/a]; SQL [n/a]

 

서버 로그에 따르면 댓글 생성 시 게시글 필드의 댓글 수를 업데이트 하기 위해 배타 락 획득에 실패하면서 댓글 생성 작업이 롤백된 것으로 유추됩니다.

 

부하에 따른 락 대기가 아니라, 왜 데드락(교착 상태)가 발생했을까요?


로그를 까보자

명확한 원인을 알고싶어 InnoDB 상태 로그의 데드락 섹션을 직접 확인했습니다.

SHOW ENGINE INNODB STATUS

흰 것은 종이요 검은 것은 글씨라.

많은 내용의 로그가 출력되지만 문제의 원인이 데드락임을 알고있으니, LATEST DETECTED DEADLOCK 섹션을 확인하면 됩니다.

 

158669번 트랜잭션 S락 보유 및 X락 승격 대기

*** (1) TRANSACTION:
TRANSACTION 158669, 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  # 다른 트랜잭션이 가진 락 때문에 대기 중, 해당 트랜잭션이 수행한 변경 기록 수 1건 존재.
MySQL thread id 9665, OS thread handle 139995743454784, query id 7210312 localhost 127.0.0.1 katopia updating
update posts p1_0 set comment_count=(p1_0.comment_count+1) where p1_0.id=38352
# MySQL thread id 9662(세션)에서 트랜잭션 158670로 posts.id=38352의 comment_count를 증가시키는 UPDATE를 수행하던 중, 필요한 행 락을 얻지 못해 LOCK WAIT 상태로 대기 중이다.

...

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 24 page no 693 n bits 184 index PRIMARY of table `fitcheck`.`posts` trx id 158669 lock mode S locks rec but not gap
Record lock, heap no 47 PHYSICAL RECORD: n_fields 10; compact format; info bits 0
0: len 8; hex 80000000000095d0; asc         ;; # id=38352인 게시글 행의 공유락 획득

...

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 24 page no 693 n bits 184 index PRIMARY of table `fitcheck`.`posts` trx id 158669 lock_mode X locks rec but not gap waiting
Record lock, heap no 47 PHYSICAL RECORD: n_fields 10; compact format; info bits 0
0: len 8; hex 80000000000095d0; asc         ;; # # id=38352인 게시글 행의 쓰기락 획득 대기

...
  • 트랜잭션 158669가 posts.id=38352를 업데이트하려고 한다.
  • 해당 row에 대해 이미 S락을 보유하고 있다. (HOLDS ... lock mode S)
  • 그런데 같은 row에 대해 X락을 추가로 얻기 위해 대기 중이다. (WAITING ... lock_mode X ... waiting)

즉, S락 보유 + X락대기 상태입니다. 왜 X락을 얻지 못했을까요?

바로 아래에서 또 다른 트랜잭션의 상태도 확인할 수 있습니다.

 

158670번 트랜잭션 S락 보유 및 X락 승격 대기

*** (2) TRANSACTION:
...
LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MySQL thread id 9662, OS thread handle 139996542146112, query id 7210313 localhost 127.0.0.1 katopia updating
update posts p1_0 set comment_count=(p1_0.comment_count+1) where p1_0.id=38352

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 24 page no 693 n bits 184 index PRIMARY of table `fitcheck`.`posts` trx id 158670 lock mode S locks rec but not gap
Record lock, heap no 47 PHYSICAL RECORD: n_fields 10; compact format; info bits 0
0: len 8; hex 80000000000095d0; asc         ;;
...

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 24 page no 693 n bits 184 index PRIMARY of table `fitcheck`.`posts` trx id 158670 lock_mode X locks rec but not gap waiting
Record lock, heap no 47 PHYSICAL RECORD: n_fields 10; compact format; info bits 0
0: len 8; hex 80000000000095d0; asc         ;;
...

트랜잭션 (2) 역시 트랜잭션 (1)과 동일하게

  • 같은 row(posts.id=38352)에 대해
  • S락을 보유한 상태에서
  • X락 획득을 대기

하고 있었습니다.

 

 

InnoDB의 선택: 트랜잭션 (2) 희생 롤백

InnoDB는 교착 상태를 해결하기 위해 158670번 트랜잭션을 희생(victim)해 롤백했음을 알 수 있습니다.

*** WE ROLL BACK TRANSACTION (2)

 

데드락 발생 원인 정리

데드락 발생 경위 및 조치 과정을 요약하면 다음과 같습니다.

  • 다수 트랜잭션이 동일한 부모 row에 대해 공유 계열 락을 보유(HOLDS S) 한 채,
  • 동일 row에 대해 배타 락을 추가로 요구(WAITING X) 하면서,
  • 서로가 서로의 공유 락 해제를 기다리는 형태의 락 업그레이드(또는 승격) 경쟁이 발생했고,
  • 결국 교착 상태가 형성되어 InnoDB가 한 트랜잭션을 롤백해 해소했습니다.
다수 트랜잭션이 동일한 부모 row에 대한 공유락을 보유한 채, 추가적인 배타락을 요구하는 순환 대기 구조로 데드락 발생

어떻게 해결할까?

이 문제를 해결하는 방법은 여러 가지가 있습니다.

A. 부모 row를 SELECT ... FOR UPDATE로 선점

자식 생성 트랜잭션 시작 시 부모 row에 SELECT ... FOR UPDATE로 X 락을 선점한 뒤, 자식 INSERT와 집계 업데이트를 진행합니다.

  • 장점: 락 업그레이드 교착을 줄일 수 있고 정합성이 강함
  • 단점: 고트래픽 경로에서 동시성이 크게 떨어짐(사실상 직렬화), 삭제/수정 같은 작업도 지연됨

데드락은 줄더라도 병목이 더 커지는 방향이라 미채택했습니다.

 

B. 부모 row와 1:1 대응하는 “집계 테이블”로 분리

posts에서 집계 필드를 분리해 post_counters(post_id PK, comment_count ...) 테이블을 두고, 댓글 생성 시 post_counters를 업데이트합니다.

  • 장점: posts row에서 발생하던 “동일 row에 대한 락 업그레이드” 유형은 완화될 수 있음
  • 단점: 요청당 UPDATE가 발생하는 구조는 유지되며, 인기 엔티티에서는 여전히 핫 row 업데이트 경합이 남음

데드락 형태는 개선될 수 있지만 핫 row 쓰기 병목은 남는다고 판단했습니다.

 

C. DB Outbox 패턴

집계 업데이트 대신 outbox 테이블에 “자식 생성 이벤트”를 트랜잭션 내에 기록하고, 워커가 이를 소비해 집계를 수행합니다.

  • 장점: 원자성에 강하고, 이벤트 유실에 강함
  • 단점: 고트래픽 경로에서 outbox 쓰기가 추가되며(쓰기 경로 증가), 운영 구성(폴링/전파)이 필요

집계 카운트만을 위해 outbox를 도입하기엔 구조가 무겁다고 판단했습니다.

 

D. 카운터 샤딩

counter_shards(parent_id, shard_id, cnt) 형태로 1:N 샤딩 row로 분산 저장합니다.

  • 장점: 핫 로우를 분산해 높은 동시성에 강함
  • 단점: 조회 시 합산 비용(SUM) 또는 별도 캐시/합산 전략이 필요해 운영 복잡도가 증가

현재 단계에서는 복잡도 대비 과한 설계라고 판단했습니다.

 

E. 메시지 큐 기반 집계 (Kafka / RabbitMQ 등)

자식 생성 이벤트를 발행하고, 컨슈머가 집계를 수행합니다.

  • 장점: 비동기 처리의 정석, 확장성/재처리/관측성에 강함
  • 단점: 인프라 도입/운영 비용이 큼

집계 카운트 하나 때문에 메시지 큐를 도입하는 것은 현재 규모에서는 오버엔지니어링이라고 판단했습니다.


최종 선택: Redis 원자적 누적 + 배치 반영 + 재교정(복구)

최종적으로 동기 트랜잭션에서 집계 업데이트를 제거하고, Redis에 집계 증분(delta)을 원자적으로 누적한 뒤 배치로 DB에 반영하는 방식을 선택했습니다.

 

기대 효과

  • DB의 핫 row 업데이트를 요청당 1회 → 배치당 1회로 감소
  • 댓글 생성 응답 경로에서 집계 UPDATE를 제거해 지연/교착 가능성을 완화
  • 장애를 고려해 복구(재교정) 경로를 명시적으로 설계

실행 플로우

 

 

장애 대응 / 복구(재교정) 전략

Redis는 메모리 기반 구성에 따라 장애 시 누적 delta가 유실될 수 있습니다.

유실이 발생하면 실제 자식 row 수와 집계값이 어긋날 수 있으므로, 복구 경로가 필요합니다.

모든 부모에 대해 전체 재계산을 수행할 수도 있지만, 데이터가 많아질수록 비용이 커집니다.

그래서 “마지막 배치 반영 시점(checkpoint)”을 DB에 남기고, 그 이후 구간만 재집계하도록 설계했습니다.


개선 결과

구조 개선 이후 동일 환경에서 부하 테스트를 수행한 결과 다음과 같은 개선 효과를 확인했습니다.

항목개선 전개선 후

사용자 100명 200명
부하 시간 1분 5분
댓글 쓰기 지연 3초 3초
댓글 쓰기 실패율 42.45% 0%
처리 부하 기준 약 10배 증가
시스템 안정성 불안정 안정적으로 동작

특히 기존 대비 약 10배 이상의 높은 부하 환경에서도 댓글 쓰기 실패율이 0% 수준으로 감소하여, 구조 개선이 데드락 문제 해결 및 시스템 안정성 확보에 효과적임을 확인하였습니다.


정리

이번 트러블슈팅을 통해 “생성”이라는 도메인 로직만 보고 구현하면, 동시성 환경에서 집계 필드가 핫 row 쓰기 병목과 교착의 원인이 될 수 있음을 확인했습니다.

또한 비동기 처리는 공짜가 아니며(복구/운영 고려 필요), 상황에 따라 여러 대안을 조합한 하이브리드 전략이 실무적으로 더 적합할 수 있음을 배웠습니다.

  • 생성 로직(INSERT)만 보다가 동시성 환경의 락 경합/데드락 가능성을 놓칠 수 있다.
  • 해결 방법은 다양하며 각 방법의 트레이드오프가 크다.
  • 핫패스(댓글 생성)에서는 동기 집계를 제거하고, 비동기 + 복구 흐름까지 설계하는 것이 운영 관점에서 합리적인 선택이 될 수 있다.