이슈
서비스를 운영하던 중 유저가 늘어나자 일부 API에서 데드락 문제가 발생했다.
당시 서비스는 DAU가 약 400명 정도 되는 상황이었다. 종종 서버 로그에 데드락 문제가 관찰되었고, 이에 관한 유저의 피드백도 제기되던 상황이었다.
1차 원인 추측
데드락은 서로 다른 트랜젝션이 서로가 가진 lock을 기다리면서 순환 대기 상태가 되는 경우 발생한다.
당시 나는 트랜잭션을 원자성을 보장하는 도구 정도로만 이해하고 사용하고 있었다. 여러 로직을 하나의 작업 단위로 묶기 위해 트랜잭션을 사용했고, 트랜잭션의 범위에 대해서는 깊게 고민하지 않았던 상태였다.
이 부분에서 문제가 생겼을 것이라 생각하고 관련 자료를 찾아보던 중 트랜젝션은 가능한 짧게 유지해야 한다는 조언을 보게 되었다.
당시 로깅 같은 비핵심 작업도 트랜젝션 안에 있었는데 이런 작업들로 인해 한 트랜젝션이 자원을 점유하는 상태가 길어져 데드락 확률이 높아지는것이 문제일 수 있겠다는 생각이 들었다. 이에 따라 로깅을 비롯한 중요하지 않은 작업들을 모두 트랜젝션 밖으로 이동시켰다. 하지만 여전히 문제는 해결되지 않았다.
2차 원인 추측
문제가 해결되지 않아 조금 더 조사해보니 외래키로 인해 문제가 발생할 수 있다는 내용을 발견했다.
MySQL은 데이터 무결성을 보장하기 위해 자식 테이블에서 삽입/삭제가 발생하면 FK값이 부모 테이블에 실제로 존재하는지 확인하고, 이 과정에서 사용하고자 하는 테이블 뿐만 아니라 FK로 연결된 부모 테이블까지 S-lock이 걸린다는 것을 알게 되었다.
또한 CASCADE 등의 옵션이 붙어 있으면 부모 테이블에서 작업할 때 관련된 자식 테이블들에 추가적인 락이 걸릴 수 있음을 알게 되었다.
이 때문에 현업에선 외래키 제약조건으로 무결성을 보장하는 대신 코드에서 로직으로 무결성을 유지하는 경우도 많다는 것을 알게 되었다.
대학에서 이와 같은 내용은 들은 바가 없어 망설여 졌지만 토스에 재직중인 친구에게도 같은 내용을 들은 후 디비의 모든 외래키를 제거하고, CASCADE 등의 작업을 코드에서 보장하도록 수정하였다. 이번엔 정말로 데드락 문제를 해결할 수 있을줄 알았지만 여전히 데드락 문제가 발생했다. 느낌상 빈도가 줄은 것 같긴 했지만 여전히 간헐적으로 발생했다

3차 원인 추측
데드락 문제에 대해 더 조사하던 중 데드락을 막고 싶다면 자원의 접근 순서를 통일해야 한다는 것을 알게 되었다.
예를 들어 모든 코드에서 유저 정보 - 고양이 정보 - 상점 정보 - 인벤토리 정보 등의 자원에 순서를 정해두고 이 중 획득해야 하는 것이 있다면 순서대로 획득하도록 보장하면 데드락을 막을 수 있다는 것이었다.
하지만 여기서 한 가지 의문이 들었다.
예를 들어
User -> Cat -> Shop -> Inventory 와 같이 자원 접근 순서를 정해둔 상황에서
1. User 정보를 조회
2. User 정보를 이용해 Cat 조회
3. Cat 정보를 이용해 User 정보를 수정
이 경우엔 자원 획득 순서를 통일했다고 보기도 어렵고, 서로 다른 트랜젝션이 각각 2번까지 진행한 상태에서 한 트랜젝션이 3번을 수행하면 2번까지 진행한 트랜젝션이 가지고 있는 User의 정보가 락을 걸었음에도 수정된 상황이어서 문제가 되지 않나 하는 것이었다.
애초에 코드 전반에서 자원의 획득 순서를 통일하는 것이 일반적인 방식인가에 대해 의문도 들었다.
한참을 고민하던 끝에 어느 블로그의 한 이미지에서 단서를 찾을 수 있었다.
해당 블로그는 데드락을 해결하기 위해 자원 순서를 통일하는 예시를 보여주는 코드가 작성되어 있었는데 트랜젝션 처음 부분에 이번 트랜젝션에서 수정이 일어나는 테이블에 시작하자마자 X-LOCK을 거는 것을 발견했다.
그 이유를 고민하던 중 이것이 내가 처음 가졌던 의문에 대한 답임을 깨달았다.
앞의 예시에서 트랜젝션 A가 유저 정보 테이블을 검색할때 S-LOCK이 아닌 X-LOCK을 걸고 시작하면 트랜젝션 B는 아무 자원도 점유하지 않고 대기할테고, 트랜젝션 A가 완료된 후 유저정보를 조회할 것이기 때문에 문제가 발생하지 않음을 알게 되었다.
이에 따라 모든 코드에서 자원 획득 순서를 통일하고, 동시에 가능한 트랜젝션 시작 부분에서 필요한 테이블에 대해 X-LOCK을 걸도록 수정했다.
상당히 큰 코드 수정이었지만 그래도 이번에야말로 데드락을 해결했다 생각했다. 하지만 거짓말처럼 또다시 데드락 문제가 발생했다.
해결 과정
더 이상 내 코드에서 데드락이 발생하는 유의미한 원인을 찾을 수 없었다.
우리는 데이터독을 이용하고 있었기 때문에 데이터 분석을 손쉽게 할 수 있었다.
데이터독을 이용해 데드락 발생 로그를 유심히 살펴보던 중 이상한 점을 발견했다.
데드락 발생 시점 전 후로 서버의 평균 응답 속도가 평소에 비해 느려지는 현상이 있었다.
따라서 큰 생각 없이 데드락이 발생하는 상황을 분석하기 위해 서버의 응답 속도가 평소보다 느렸던 요청들을 검색했고, 이 요청들을 모아보니 곧바로 이상함을 느낄 수 있었다.
응답 속도가 평소보다 느렸던 요청들은 매시 정각부터 15분 사이에 몰려 있었다.
이 중 데드락이 발생한 경우는 극소수였지만 거의 항상 정각부터 15분 동안 서버의 응답 속도가 평소보다 느렸다.
이 패턴을 보고 문득 과거에 했던 작업이 떠올랐다.
서비스를 스토어에도 올리기 전 테스트 플라이트를 이용해 서비스 하던 시절, 맥 미니를 구매해 배포 전 테스트하기 위한 테스트 서버를 구축했었다.
맥 미니는 용량도 크고 자원도 남기 때문에 이걸로 무엇을 할 수 있을지 고민하다 배포 서버의 로그와 디비를 백업하기 위한 크론잡을 설정해두고, 주기적으로 정리하는 기능을 만들어 뒀었다.
이 중 디비를 백업하기 위한 크론잡을 매시 정각에 설정해 뒀다는 사실이 떠올랐고, 응답 속도가 느려지기 시작하는 시점과 일치한다는 것을 깨달았다.
확인을 위해 해당 크론잡을 중지했고, 더 이상 데드락은 발생하지 않았다.
결과적으로 문제의 원인은 코드가 아니라 DB의 백업 작업이었다.
DB dump 작업이 수행되면서 대량의 read와 lock이 발생했고, 그 상태에서 서비스 트랜잭션이 동시에 실행되면서 Deadlock이 발생했던 것이다.
해당 백업 작업은 AWS에서 이미 자동 백업이 수행되고 있었기 때문에 필요하지 않은 작업이었다.
초기에는 유저 수가 적어 Deadlock이 확률적으로 잘 발생하지 않았고, 내가 재현해보려 테스트할 때는 정각 근처 시간대가 아니었기 때문에 문제를 재현하기 어려웠던 것이다.
광고를 집행하며 유저가 늘어나면서 언제든 트래픽이 발생하게 되었고, 그 결과 문제가 드러난 것이었다.
결과적으로 들이 노력에 비해 조금 허무하게 해결된 문제였다.
새로 알게된 것
1. Deadlock의 원인은 코드만이 아니다
애플리케이션 코드뿐 아니라 백업 작업, 배치 작업, 크론 작업 등 인프라 레벨 작업도 Deadlock의 원인이 될 수 있다.
2. 트랜잭션은 가능한 한 짧게 유지해야 한다
비핵심 작업을 트랜잭션 밖으로 분리하는 것만으로도 lock 점유 시간을 크게 줄일 수 있다.
3. 외래키는 성능과 무결성 사이의 트레이드오프다
FK는 데이터 무결성을 보장하지만 추가적인 lock을 유발할 수 있다. 실제 서비스에서는 성능 문제로 FK를 제거하고 코드에서 무결성을 관리하는 경우도 많다.
4. 자원 획득 순서를 통일하면 Deadlock 확률을 크게 줄일 수 있다
5. 오래된 스크립트와 작업도 반드시 관리하거나 제거해야 한다
몇 달 전에 만들어 둔 cron job이 문제의 원인이었지만 그 존재를 완전히 잊고 있었기 때문에 문제를 쉽게 해결하지 못했다.
'back-end > 🐾 삼냥이즈 트러블슈팅' 카테고리의 다른 글
| [삼냥이즈 트러블슈팅] Typeorm Findone() (0) | 2025.04.24 |
|---|