2026. 2. 21. 16:23ㆍBackend
이 글은 동시성 환경에서 자식 엔티티 생성이 단순한 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)만 보다가 동시성 환경의 락 경합/데드락 가능성을 놓칠 수 있다.
- 해결 방법은 다양하며 각 방법의 트레이드오프가 크다.
- 핫패스(댓글 생성)에서는 동기 집계를 제거하고, 비동기 + 복구 흐름까지 설계하는 것이 운영 관점에서 합리적인 선택이 될 수 있다.
'Backend' 카테고리의 다른 글
| 검증 쿼리가 꼭 필요할까? (0) | 2026.02.09 |
|---|---|
| 게시글 하나 삭제했는데 DELETE가 N번? (0) | 2026.02.02 |
| [Spring] 스프링부트 @Schedule 시간대 문제 해결 (1) | 2024.11.10 |
| [짧고 굵게 배우는 JSP 웹 프로그래밍과 스프링 프레임워크] 연습문제 5장 정답 (0) | 2024.07.07 |
| [짧고 굵게 배우는 JSP 웹 프로그래밍과 스프링 프레임워크] 연습문제 6장 정답 (0) | 2024.07.07 |