한 달 동안 안 잡히던 데드락의 진짜 원인
한동안 데드락 문제에 시달리던 때가 있었다.
거의 한달 내내 데드락과 씨름을 했는데 그 과정에 대한 기록을 남긴다
이슈
서비스 운영 중 유저 수가 증가하면서 일부 API에서 데드락 문제가 발생했다.
당시 DAU는 약 400명 수준이었고, 서버 로그에서 간헐적으로 데드락이 관찰되었다.
이와 관련된 유저 피드백도 점차 증가하는 상황이었다.
1차 원인 추측
데드락은 서로 다른 트랜잭션이 서로가 점유한 lock을 기다리며 순환 대기 상태에 빠질 때 발생한다.
당시에는 트랜잭션을 단순히 원자성을 보장하는 도구로 이해하고 있었고,
여러 로직을 하나의 작업 단위로 묶는 용도로만 사용하고 있었다.
트랜잭션의 범위나 lock 점유 시간에 대해서는 깊게 고려하지 않은 상태였다.
이 과정에서 문제가 발생했을 것이라 판단하고 관련 자료를 조사하던 중,
트랜잭션은 가능한 한 짧게 유지해야 한다는 점을 확인했다.
당시 로깅과 같은 비핵심 작업도 트랜잭션 내부에 포함되어 있었는데,
이로 인해 lock 점유 시간이 길어지고 데드락 발생 확률이 높아질 수 있다고 판단했다.
이에 따라 로깅 등 중요하지 않은 작업들을 트랜잭션 외부로 분리했다.
하지만 문제는 여전히 해결되지 않았다.
2차 원인 추측
추가로 조사하던 중 외래키(Foreign Key)가 데드락의 원인이 될 수 있다는 내용을 확인했다.
MySQL에서는 데이터 무결성을 보장하기 위해
자식 테이블에서 삽입/삭제가 발생할 때 부모 테이블의 존재 여부를 검사한다.
이 과정에서 단순히 대상 테이블뿐 아니라 FK로 연결된 부모 테이블에도 S-lock이 발생한다.
또한 CASCADE 옵션이 설정된 경우,
부모 테이블 작업 시 자식 테이블에도 추가적인 lock이 발생할 수 있다.
이러한 이유로 일부 서비스에서는 외래키 제약조건 대신
애플리케이션 레벨에서 무결성을 관리하기도 한다는 점을 알게 되었다.
이에 따라 모든 외래키를 제거하고,
CASCADE와 같은 동작을 코드로 직접 보장하도록 수정했다.
데드락 발생 빈도가 줄어든 것처럼 보였지만,
여전히 간헐적으로 문제는 발생했다.
3차 원인 추측
데드락 해결 방법을 더 조사하던 중,
자원 접근 순서를 통일하면 데드락을 예방할 수 있다는 내용을 확인했다.
예를 들어, 다음과 같이 자원 접근 순서를 정의할 수 있다:
User → Cat → Shop → Inventory그리고 필요한 자원을 반드시 이 순서대로 획득하도록 보장하면 순환 대기 상황을 방지할 수 있다.
하지만 여기서 한 가지 의문이 생겼다.
예를 들어: 1. User 조회 2. User를 기반으로 Cat 조회 3. Cat 정보를 기반으로 User 수정
이 경우 자원 접근 순서가 완전히 일관된다고 보기 어려웠고, 서로 다른 트랜잭션이 동시에 실행될 경우 문제가 발생할 수 있다고 판단했다.
또한 실제 서비스 코드 전반에서 자원 획득 순서를 완벽히 통일하는 것이 현실적으로 가능한지도 의문이었다.
이 문제를 고민하던 중, 어느 블로그의 한 이미지에서 단서를 찾을 수 있었다.
해당 블로그는 데드락을 해결하기 위해 자원 순서를 통일하는 예시를 보여주는 코드가 작성되어 있었는데
트랜젝션 처음 부분에 이번 트랜젝션에서 수정이 일어나는 테이블에 시작하자마자 X-LOCK을 거는 것을 발견했다.
이 방식을 적용하면:
-
한 트랜잭션이 먼저 X-lock을 획득하고
-
다른 트랜잭션은 lock을 획득하지 못한 채 대기하게 된다
결과적으로 자원 획득 순서가 자연스럽게 보장되고
데드락 발생 가능성을 줄일 수 있다는 점을 이해하게 되었다.
이에 따라:
-
자원 접근 순서를 통일하고
-
트랜잭션 시작 시 필요한 테이블에 대해 X-lock을 선점하도록 수정했다
상당한 코드 수정이 이루어졌고,
문제가 해결되었다고 생각했지만 여전히 데드락은 여전히 발생했다.
해결 과정
나는 더 이상 내 코드 내에서 데드락이 발생하는 유의미한 원인을 발견할 수 있었다.
내가 코드를 아무리 못 짰어도 이 정도로 데드락이 많이 생길 수가 없었다.
인터넷을 아무리 찾아봐도, 더 이상 새로운 내용은 찾을 수 없었다.
그래서 나는 코드 수정을 그만 두고 데드락이 발생하는 상황을 면밀히 분석해 보았다.
모니터링 도구를 이용해 데드락 데이터를 유심히 보던 중 특이한 점을 발견했다.
데드락 발생 시점 전후로 서버의 평균 응답 속도가 평소에 비해 느려지는 현상이 있었다.
따라서 서버의 응답 속도가 평소에 비해 느렸던 로그들만 검색해 보았고,
이 요청들을 모아보니 단번에 이상함을 눈치챌 수 있었다.
응답 속도가 평소보다 느렸던 요청들은 매시 정각부터 15분 사이에 몰려 있었다.
이 중 데드락이 발생한 경우는 극소수였지만 거의 항상 정각부터 15분 동안 서버의 응답 속도가 평소보다 느렸다.
이 패턴을 보고 문득 과거에 했던 작업이 떠올랐다.
서비스를 스토어에도 올리기 전에 테스트 플라이트를 이용해 서비스 하던 시절, 맥 미니를 구매해 배포 전 테스트하기 위한 테스트 서버를 구축했었다.
맥 미니는 용량도 크고 자원도 남기 때문에 이걸로 무엇을 할 수 있을지 고민하다 배포 서버의 로그와 디비를 백업하기 위한 크론잡을 설정해두고, 주기적으로 정리하는 기능을 만들어 뒀었다.
이 중 디비를 백업하기 위한 크론잡을 매시 정각에 설정해 뒀다는 사실이 떠올랐고, 응답 속도가 느려지기 시작하는 시점과 일치한다는 것을 깨달았다.
확인을 위해 해당 크론잡을 중지했고, 더 이상 데드락은 발생하지 않았다.
결과적으로 문제의 원인은 코드가 아니라 DB의 백업 작업이었다.
DB dump 작업이 수행되면서 대량의 read와 lock이 발생했고, 그 상태에서 서비스 트랜잭션이 동시에 실행되면서 Deadlock이 발생했던 것이다.
해당 백업 작업은 AWS에서 이미 자동 백업이 수행되고 있었기 때문에 필요하지 않은 작업이었다.
초기에는 유저 수가 적어 Deadlock이 확률적으로 잘 발생하지 않았고, 내가 재현해보려 테스트할 때는 정각 근처 시간대가 아니었기 때문에 문제를 재현하기 어려웠던 것이다.
광고를 집행하며 유저가 늘어나면서 언제든 트래픽이 발생하게 되었고, 그 결과 문제가 드러난 것이었다.
문제가 드러날 때 쯤엔 먼 옛날 큰 고민 없이 해두면 좋겠지 생각하고 걸어둔 크론잡은 이미 잊어버린 상태였다.
결과적으로 들이 노력에 비해 조금 허무하게 해결된 문제였다.
새로 알게 된 것
이번 사건이 허무하게 끝나긴 했지만 데드락에 대해 많이 공부하는 계기가 되었다.
맥 미니의 자원이 남는 것이 아까워 이미 AWS에서 잘 백업되고 있는 디비를
추가적으로 백업하도록 한 것은 과한 시스템 설계였다.
당시에 큰 고민 없이 해두면 좋겠지 생각했던 것이 오히려 자충수가 되었다.
정말 필요한 기능이 아니라면 고민 없이 추가한 기능은 오버 엔지니어링이 되고, 유지 보수를 어렵게 한다는 것을 배웠다.
그래도 데이터를 이용해 원인을 찾아낸 것은 좋은 경험이었다.
덕분에 데이터를 더 자주 들여다보게 되었고, 개발 관점 뿐 아니라 서비스 관점에서도 유의미한 아이디어를 도출하는 경험을
이후로도 간간히 할 수 있었다
