데이터베이스 트리거에 의한 Deadlock 발생 이슈
문제상황
실서버에서 Deadlock이 발생했다는 리포트가 올라왔다.
Deadlock found when trying to get lock; try restarting transaction
로그를 확인해보니 1초 이내의 짧은 시간 동안 동일한 벌크성 POST 요청이 두 번 연속으로 들어왔다.
아마 우리 서버를 사용 중인 모바일 UI상에서 따닥 두 번 클릭을 한 모양이다.
원인 분석
코드단에서 먼저 분석했을 때, 데드락을 유발한 코드는 아래와 같았다.
public void updateMailboxId(String mailboxIdToUpdate, List<Long> mailNoList) {
jpaQueryFactory.update(mail)
.set(mail.mailbox.id, mailboxIdToUpdate)
.where(
mail.no.in(mailNoList)
).execute();
}
해당 코드는 in절로 하나의 업데이트 쿼리를 날리는 것으로, 단순히 해당 쿼리 자체가 데드락을 유발했다고 보긴 힘들었다.
즉, 우리의 애플리케이션 서버는 잘못이 없어보였다.
여러 용의자들을 심문하고 수색한 끝에, 원인은 해당 데이터베이스 테이블에 박혀있던 트리거 때문으로 밝혀졌다.
해당 쿼리를 통해 업데이트를 시도했던 메일 테이블에는 아래와 같은 트리거가 존재했다. (아래 쿼리는 재현한 것이지, 사실 훨씬 더 복잡하다)
CREATE TRIGGER `mailbox_stat_update` AFTER UPDATE OF mailbox_id ON mail
FOR EACH ROW
BEGIN
UPDATE `mailbox_stat` SET `mailbox_count` = `mailbox_count` - 1 WHERE id = OLD.mailbox_id;
UPDATE `mailbox_stat` SET `mailbox_count` = `mailbox_count` + 1 WHERE id = NEW.mailbox_id;
END;
메일의 메일함 정보가 변경되었을 때, 해당 메일함에 존재하는 메일의 전체 카운트 개수를 변경해주는 트리거였다.
1번 메일함에서 2번 메일함으로 이동되었을 경우, 1번 메일함의 전체 카운트 개수는 -1이 되고, 2번 메일함의 전체 카운트 개수는 +1이 된다.
그렇다면 이 쿼리가 왜 데드락을 유발하는가?
MySQL에 나와있는 트리거 관련 공식문서를 살펴보자. (https://dev.mysql.com/doc/refman/8.0/en/trigger-syntax.html)
아래는 MySQL에서 트리거 실행 중 오류가 발생한 경우, 이를 대처하는 매커니즘에 대해 서술한다.
이 중 네 번째를 보자.
An error during either a BEFORE or AFTER trigger results in failure of the entire statement that caused trigger invocation.
트리거는 종류(BEFORE, AFTER)와 무관하게 오류가 발생했을 시, 해당 트리거를 호출한 전체 statement(쿼리)를 실패처리한다.
이는 다시 말하자면 트리거와 해당 트리거를 호출한 쿼리가 내부적으로 하나의 트랜잭션 내에서 처리되고 있음을 나타낸다.
이 배경을 인지하고, 데드락이 발생한 시나리오를 그려보자.
먼저, 두 개의 요청은 거의 동일한 시간에 시작되어 각각 다른 트랜잭션을 시작하고 메일 업데이트 쿼리를 생성한다.
(예제 코드에서는 축소했지만, 실제 코드에선 스토리지에 저장된 메일 원문 파일을 먼저 이동한 후, 성공한 no만 리스트로 추출해 UPDATE 쿼리의 IN 절에 삽입한다. 두 요청이 동시에 들어온 상황에서 특정 파일은 요청 1에서, 다른 파일들은 요청 2에서 이동되어 IN절에 들어간 no값은 서로 달랐다.)
다음으로, 첫 번째 트랜잭션은 메일 업데이트 쿼리에 의해 호출된 트리거를 실행한다.
이를 통해 no가 1인 mailbox_stat 업데이트 쿼리를 만들고, 이 과정에서 해당 row에 대한 배타락을 획득한다.
이번엔 두 번째 트랜잭션이 마찬가지로 메일 업데이트 쿼리로 인해 호출된 트리거를 실행하는데, 첫 번째 트랜잭션과 달리 no가 2인 mailbox_stat 업데이트 쿼리를 먼저 만들고, 해당 row에 대한 배타락을 획득한다.
이젠 첫 번째 트랜잭션에서 no가 2인 mailbox_stat 에 대한 업데이트 쿼리를 만들려고 한다.
하지만, 두 번째 트랜잭션에서 해당 row에 대한 배타락을 점유하고 있으므로 쿼리를 만들지 못하고 대기 상태에 놓인다.
이 상황에서, 두 번째 트랜잭션이 no가 1인 mailbox_stat에 대한 업데이트 쿼리를 만들려 하지만 역시나 해당 row는 첫 번째 트랜잭션에서 배타락을 점유 중이므로 대기 상태에 빠진다.
첫 번째와 두 번째 트랜잭션이 각각 서로가 필요한 락을 점유한 채로 서로의 락을 획득하기를 기다리게 된다.
즉, 데드락이 발생하게 된 것이다.
만약 트리거의 mailbox_stat 업데이트 쿼리가 모두 각자 다른 트랜잭션에서 실행되었다면 데드락이 발생하지 않았을테지만, 앞서 서술한대로 MySQL의 트리거는 그렇지 않았다. (사실 트리거의 목적을 봤을 때 동일 트랜잭션에서 처리하는 것이 백번 옳은 동작이다)
트리거는 목적에 맞게 사용되어야한다
트리거 작동 방식을 우리가 뜯어고칠 수 없는 한, 애플리케이션 코드 단에서 이를 해결할 수 있는 방법은 사실상 없어보인다.
사실, 현재의 트리거는 이전부터 많은 문제들을 야기해왔다.
첫 번째로 테이블에 달려있는 온갖 트리거들 때문에 데이터의 생성, 변경, 삭제 작업이 하나의 커다란 블랙박스처럼 작동하게 된다.
mailbox_stat 값에 오류가 있다는 문의를 접수했을 때 원인을 확인하기 위해서 연관 코드뿐 아니라 낮은 수준의 데이터베이스 정보까지 모두 확인해봐야했다.
또한, DML 작업을 특정 서버가 아닌 데이터베이스가 직접 요청 및 처리하기 때문에 도중에 오류가 발생하더라도 이를 트래킹하고 적절히 처리하기 매우 어렵다.
두 번째로, 트리거라는 기능의 목적에 맞지 않은 트리거들이 너무 많이 달려있다는 것이다.
앞서 언급했듯 트리거는 스스로와 트리거를 유발한 코드를 모두 하나의 트랜잭션에서 처리한다.
이는 트리거가 연관된 DML 작업이 즉각적이고 강한 일관성을 유지하기 위한 목적으로 사용되길 기대하며 설계되었다고 할 수 있다.
하지만 현재 문제에서도 그렇듯 즉각적이기보단 최종적 일관성을 추구하는, 흔히 말하는 이벤트 기반으로 처리되어야 할 데이터 작업에서도 트리거가 남용되고 있다.
물론 그렇다고 트리거가 무조건 악인가? 라고 묻는다면 그렇다고 말할 순 없다.
트리거를 무작정 제거한다고 하면, 해당 데이터를 업데이트하는 모든 코드 단에 이벤트를 발행하는 로직을 추가해주어야하고 앞으로 해당 데이터를 건드는 모든 작업자들이 이 내용을 인지하고 있어야한다.
이는 큰 유지보수 작업을 필요로 하고 실수를 유발할 가능성도 커 보인다.
해결방안
어쨌든 문제는 해결되어야한다.
이를 위해 두 가지 해결방안을 놓고 고민했다.
첫 번째는 트리거를 통해 연관 데이터를 직접 수정하지 말고, 수정 내역을 데이터베이스 큐에 쌓는 형태로 수정하는 방식이었다.
대략적인 작업 방식은 다음과 같았다.
1. 기존에 mailbox_stat 데이터를 직접 UPDATE 하는 트리거 대신, 메일 수정 이전 / 이후 데이터를 별도 데이터베이스에 INSERT하도록 수정
2. 배치 서버를 통해 해당 데이터를 기반으로 일괄적으로 처리
이 방식을 통해 트리거 작업에서 데이터 간의 직접적인 연관성을 끊어버릴 수 있고, 트리거 작업의 변경 및 오류 처리 또한 배치 서버에서 유연하게 처리해줄 수 있다.
하지만, 이와 같은 방식은 수 억건의 메일 데이터의 DML 작업에 대한 씽크 데이터베이스를 구축해야하는 부담이 매우 크다.
결국 여러가지 상황을 고려해봤을 때, Debezium을 통해 CDC 환경을 구축하기로 결정했다 👏👏👏
아직 깊게 파진 못했지만 MySQL의 binary log를 읽어서 데이터 변경을 감지해 작업을 처리하는 방식으로 보인다.
당분간은 해당 기술에 대해 더 자세히 알아보도록 해야겠다!