Part 1. DeadLock 현상 발생
단일 품목 주문 API 부하테스트를 진행하면서 아래와 같이 응답이 실패함을 확인할 수 있었다

실패의 원인을 찾는데는 APM 툴로 Pinpoint를 사용하고 있었기에 크게 어렵지 않았다.

모니터링한 결과, DeadLock이 빈번하게 발생하고 있음을 확인했다. 위의 사진에서 보다시피 CannotAcquireLockException이 반복적으로 나타나고 있으며, 특히 HikariCP 데이터베이스 연결 풀에서 2,443ms 동안 대기하는 것을 볼 수 있다.

지금부터 DeadLock이 왜 발생했는지 찾아보자!
Part 2. 문제 원인 분석
현재 주문 처리의 핵심 플로우는 다음과 같다.

Orders와 Order_items 테이블에 순차적으로 INSERT를 수행한 후 Products 테이블의 재고를 UPDATE하는 과정에서 DeadLock이 발생했다. 특이점으로는 Order_items 테이블의 경우 Orders와 Products를 Foreign Key(외래키)로 참조하고 있었다는 점이다. 이에 따라 MySQL의 InnoDB Storage Engine에서 FK 제약조건을 적용할 경우 어떤 방식으로 동작하는지 조사했다. Real MySQL에 의하면 FK를 사용할 때 내부적으로 데이터 무결성 보장을 위해서 Products에 S-Lock(공유 락)을 걸고 있음을 알 수 있었다. 이로 인해 다음와 같은 상황에서 DeadLock이 발생했던 것이다.

데드락 발생 메커니즘을 구체적으로 살펴보면 다음과 같다. 첫 번째 트랜잭션(T1)이 Orders와 Order_items를 INSERT할 때, Order_items 테이블의 product_id 외래키 제약조건으로 인해 Products 테이블의 해당 레코드에 자동으로 S-Lock이 걸리게 된다. 동시에 두 번째 트랜잭션(T2)도 동일한 과정을 거쳐 Products 레코드에 S-Lock을 획득한다. S-Lock의 경우 서로 호환 가능하여 Lock이 해제되지 않더라도 실행 가능하다.
그러나 문제는 이후에 발생한다. T1이 자신이 참조한 Products의 재고를 UPDATE하려 할 때 X-Lock(배타적 락)이 필요한데, T2가 이미 해당 Products에 S-Lock을 보유하고 있다. 이 경우 X-Lock과 S-Lock은 충돌하기 때문에 T1은 X-Lock을 얻기 위해서 T2의 S-Lock이 해제될 때까지 대기 상태에 빠진다. 마찬가지로 T2도 자신의 Product를 UPDATE하려 할 때 T1의 S-Lock 때문에 대기하게 되어 순환 대기 상태, 즉 데드락이 발생하는 구조였다.
이를 해결하기 위해 아래의 DeadLock 발생 조건 4가지 중 하나만 제거하면 DeadLock을 해결할 수 있게 된다
1. 상호 배제 (Mutual Exclusion) : 한 번에 하나의 프로세스만 자원 사용 가능
2. 점유와 대기 (Hold and Wait) : 자원을 점유한 채로 다른 자원을 기다림
3. 비선점 (No Preemption) : 다른 프로세스의 자원을 강제로 빼앗을 수 없음
4. 순환 대기 (Circular Wait) : 프로세스들이 서로의 자원을 기다리는 순환 구조
Part 3. 여러 해결 방법 검토
1. 작업 순서 변경 (순환 대기 제거)
Order와 Order_items INSERT 전에 Product UPDATE를 먼저 수행하는 방식으로 순서를 변경하는 방안을 검토했다. 이 방법은 데드락의 네 가지 필요조건 중 '순환 대기(Circular Wait)' 조건을 제거하는 접근이다.

실제로 73882건의 요청 결과 데드락은 발생하지 않았다. Product 테이블을 먼저 UPDATE하여 X-Lock을 획득한 후 해제하고, 이후에 Order와 Order_items를 INSERT하면 외래키 검증 시 S-Lock만 필요하게 되어 락의 획득 순서가 일관되게 유지되기 때문이다.
2. 외래키 제약조건 제거 (점유와 대기 제거)
Order_items 테이블의 product_id 외래키 제약조건을 제거하는 방안을 고려했다. 이는 데드락의 필요조건 중 '점유와 대기(Hold and Wait)' 조건을 제거하는 방식이다. 외래키가 없으면 Order_items INSERT 시 Product 테이블에 대한 S-Lock이 자동으로 걸리지 않으므로, 트랜잭션이 Product 테이블에 대한 Lock을 미리 점유하지 않게 된다. 따라서 Product UPDATE 시 다른 트랜잭션의 S-Lock을 기다릴 필요가 없어져 데드락이 근본적으로 해결된다.
Part 4. FK를 제거한다면?
외래키 제약조건은 데이터 무결성을 보장하는 중요한 기능이지만, 다음과 같은 성능상 단점들이 존재한다
- 자동 S-Lock 획득: 참조 무결성 검증 과정에서 참조되는 레코드에 자동으로 S-Lock이 걸리게 되어, 동시성 처리에 제약이 발생한다
- 트랜잭션 Lock 보유 시간 증가: 외래키 검증으로 인한 추가적인 락 획득으로 전체 트랜잭션의 Lock 보유 시간이 길어진다
- INSERT/UPDATE 시 참조 테이블 조회: 외래키가 있는 테이블에 INSERT나 UPDATE를 수행할 때마다 참조 테이블의 존재 여부를 확인하기 위한 추가적인 조회가 발생한다
- DELETE/UPDATE 시 연쇄 확인: 부모 테이블의 레코드를 삭제하거나 키를 변경할 때, 자식 테이블의 모든 관련 레코드를 확인해야 하는 오버헤드가 발생한다
FK를 제거한다면 참조 무결성을 해결해야한다!
일단 Product의 경우 쇼핑몰 도메인 특성상 STORE_OWNER 혹은 ADMIN이 직접 관리하는 마스터 데이터이며, Product가 갑작스럽게 삭제되는 경우는 발생 가능성이 낮다고 판단했다. 또한 정책상으로 한번 판매되었던 상품은 브랜드 측에서 판매하지 않더라도 삭제하는 것이 아니라 품절로 처리하고 검색에서 조회되지 않도록 한다면, 사용자는 과거의 주문 내역에서 판매되지 않는 상품이더라도 상세정보를 확인할 수 있을 것이다.
무결성 문제는 ORDER_ITEMS INSERT 이전에 별도의 SELECT 조회를 추가하여 Product 존재 여부를 판단하기로 했다. 이 방식의 장점은 SELECT 조회 시에는 S-Lock이 걸리지 않아 다른 트랜잭션의 Product UPDATE 작업을 방해하지 않는다는 점이다. 따라서 데이터 무결성은 Application 레벨에서 보장하면서도 성능상 이점을 가질 수 있다
Part 5. 최종 결정
첫 번째 방안인 작업 순서 변경도 데드락을 해결할 수 있었지만, FK 제약조건으로 인한 Lock Contention 발생 증가를 가장 크게 고려하여 최종적으로는 FK 제거를 선택했다. 이는 다음과 같은 이유에서다
- 성능 최적화 : 외래키 제약조건 제거로 INSERT 시 자동 S-Lock 획득을 방지하여 동시성 성능을 개선
- 도메인 특성 : 쇼핑몰의 Product는 관리자 관리 하에 있어 예기치 못한 삭제 가능성이 낮다. 즉, 존재하지 않는 Product와 같은 무결성 문제가 발생할 가능성이 낮다
- Application 레벨 무결성 보장 가능 : 사전 SELECT 조회를 통한 존재 여부 확인으로 무결성 문제 해결
- 추후 MSA 분리 관점 : Product와 Order는 서로 다른 Bounded Context에 존재하는 도메인으로, DDD(Domain-Driven Design) 관점에서 각각 분리되어야 할 독립적인 비즈니스 영역이다. 즉 FK를 분리해 Application Layer에서 다른 도메인을 조회해서 사용하는 방식은 추후 MSA 분리를 쉽게 만든다
실제로 이를 도입한 결과, 성능 측면에서 개선된 결과를 확인할 수 있었다.
💻 테스트 환경
WAS(SpringBoot) 1대 : AWS t4g.micro(2 vCPU, 1GIB Memory)
부하테스트 툴 : nGrinder
APM 툴 : Pinpoint
Metric 수집 및 시각화 툴 : Prometheus + Grafana

📈 FK 제거 전 후 비교 (Before vs After)
| Metric | Before (제거 전) | After (제거 후) | 개선 효과 |
| TPS (Transactions/sec) | 179.5 | 187.4 | 4.4% ↑ |
| p99 latency | 1.63s | 1.44s | 11.66% ↓ |
| Average response time | 0.939s | 0.792s | 15.66% ↓ |
- TPS : 초당 처리 가능한 트랜잭션 수가 179.5 → 187.4로 약 4.4% 증가
- p99 latency : 응답 지연 상위 1% 구간이 1.63s → 1.44s로 약 11.66% 단축
- Average response time : 0.939s → 0.792s로 약 15.66% 단축
이를 통해 DeadLock 해결과 성능 최적화를 동시에 달성할 수 있었다
'프로젝트' 카테고리의 다른 글
| [바로] Redis의 Lua Script는 Atomic 하지 않다..? (0) | 2025.09.04 |
|---|---|
| [바로] 일괄 주문 기능 개선 Vol.1 (Ft. Eventual Consistency, Lua Script) (0) | 2025.08.27 |
| [바로] 단일 주문 성능 개선 삽질기 (Ft. JPA save, FK) (0) | 2025.08.20 |
| [바로] 반복되는 인증,인가 처리 없애버리기(Ft. AOP & ArgumentResolver) (0) | 2025.08.08 |
| [바로] 분산 시스템에서 ID가 유일하려면?(Ft. Snowflake VS TSID 성능테스트) (3) | 2025.07.28 |