어느 날 제 게시글을 보다가 오잉 두 querydsl을 목격했습니다. 코드잽의 태그 정책은 다음과 같습니다.
- 동일한 이름의 태그일 경우 하나만 생성이 가능하다.
- 즉, 이미 존재하는 이름의 태그일 경우 해당 태그를 공유한다.
비슷하게 메타 정보 역할을 하는 카테고리와 비교를 해보면, 태그라는 도메인 특성상 커스텀을 하기보단 중복도가 높을 것이라고 판단했기 때문입니다. 그런데 태그에서 중복된 이름이 발견되어 버렸습니다.
큰 문제이지 않을 수 있지만 만약 다른 사용자가 새로운 템플릿을 생성할 때, 동일한 "querydsl"이라는 이름의 태그와 함께 생성을 하게 되면 다음과 같이 계속해서 중복된 태그로 생성이 되어버립니다.
이 문제를 해결하기 위해 어떤 방법들이 있을지 고민해보고 적용을 통해 해결해보겠습니다.
먼저 처리 과정 이해하기
일단 문제를 해결하기 전 템플릿 생성 로직을 참고할 필요가 있습니다.
태그 생성은 별도의 API로 분리되어 있지 않고 템플릿 생성 시 함께 요청이 오게 됩니다.
템플릿 생성의 시나리오는 다음과 같습니다.
- 카테고리를 선택하고
- 제목, 설명, 소스코드를 입력한 뒤
- 태그를 입력한다.
- 마지막으로 템플릿을 저장한다.
하나의 트랜잭션이지만 태그 저장 로직에 문제가 있기 때문에 태그 로직을 자세히 봐보도록 하겠습니다.
참고로 Template은 여러 개의 Tag를 가질 수 있고 여러개의 템플릿이 하나의 Tag를 가질 수 있기 때문에 다대다 관계였습니다. 중간 테이블을 두어 다대다 관계에서의 문제를 해결했기 때문에 이점 참고해주세요.
태그 생성 로직은 템플릿에 대해 생성 요청이 들어왔을 경우, 다음과 같이 동작합니다.
처음에는 유니크 제약 조건을 걸지 않고, 애플리케이션 단에서 중복을 처리하려고 했었습니다.
유니크 제약 조건을 걸게 되면 태그가 중복될 경우, 에러가 발생하면서 템플릿 저장도 롤백이 되버리기 때문입니다.
태그 저장 때문에, 그것도 회원이 잘못 입력한 것도 아닌데, 템플릿 저장에 영향이 가면 안된다고 생각했습니다.
문제 원인 파악 - 재연하기
파악한 문제는 DB단에서 두 트랜잭션이 동시에 태그를 조회할 경우였습니다.
두 트랜잭션이 거의 동시에 태그를 조회하게 되면 위와 같이 정합성에 문제가 생기는 상황이 발생합니다.
따라서 이 문제를 처리하기 위해 다음의 방법을 고려했습니다.
- 트랜잭션의 격리 수준을 높이기
- 조회 시에 X락을 획득하기
- synchronized 키워드 사용하기
- 유니크 제약 조건 사용하기
테스트 코드를 통해 문제 상황을 재연하고 하나씩 적용해보았습니다.
1. 트랜잭션의 격리 수준을 높이기
처음 생각한 방법은 트랜잭션의 격리 수준을 높이는 것이었습니다. 기본적으로 mysql innodb의 격리 수준은 REPEATABLE READ입니다.
격리 수준을 SERIALIZABLE로 올린다면, 하나의 공유자원 즉 하나의 태그 레코드에 하나의 트랜잭션만 접근할 수 있기 때문에 해당 문제가 해결될 것이라고 생각했습니다.
🚨 데드락 발생
격리수준을 증가한 후, 데드락이 발생했습니다. 상황을 파악하기 위해 테스트를 돌리면서 innodb의 상태를 통해 락 상황을 확인했습니다.
눈에 띄는 부분은 다음과 같았습니다.
11947 트랜잭션에서 태그 레코드에 S락를 획득하고, X락을 획득하기 위해 IX락(이후에 X락을 걸겠다는 의도를 담은 테이블락)을 걸려고 시도합니다. 하지만 IX락과 S락은 공유될 수 없기 때문에 11947 트랜잭션은 대기 상태로 진입합니다.
잠깐! 단순 Select로만 조회하고 S락을 건적이 없는데 왜 S락이 걸리지?
격리 수준이 SERIALIZABLE이기 때문에 Select 쿼리일지라도, Innodb는 Select for share로 실행합니다.
다음 트랜잭션 11948이 동일하게 태그 레코드에 S락를 획득한 상태에서, X락을 획득하기 위해 IX락 걸기 위해 시도하면서, 데드락이 발생하게 되어버립니다.
그렇게 대기가 걸리면서 데드락 상태에 빠지고, innodb의 자동 데드락 탐지 과정을 통해 트랜잭션 B (11948)을 중단 처리합니다.
하나의 트랜잭션만 성공하고 다른 하나는 위에서 본 에러가 반환됩니다.
innodb는 데드락이 발생할 경우, 이를 탐지하여 언두로그의 레코드가 더 적은 트랜잭션을 롤백처리합니다.
놓친 게 있을 수 있으니 실제 쿼리를 날려서 락을 확인해보았습니다. 먼저 조회 쿼리에서 발생한 락은 트랜잭션 각각에서 S락이 걸립니다.
그다음 insert 쿼리를 수행하면 두 트랜잭션 중 20619 트랜잭션은 롤백 처리가 되며, 20616 트랜잭션은 X과 함께 저장한 레코드에 대한 S, GAP을 획득합니다.
아쉽게도 격리 수준을 높인다고 해결되지는 않았습니다.
2. 조회 시에 X락을 걸기
그러면 여기서 이런 생각이 듭니다. 태그를 조회할 때 X락을 획득하도록 하면 데드락은 발생하지 않을 것 같은데?
위와 같이 처음 겹치는 Tag 조회 시에 select for update를 통해 x락을 획득하도록 하게 하면 데드락은 발생하지 않을 것 같다는 생각이 들었습니다. 물론 성능은 떨어질 것입니다. 트랜잭션 B는 A가 끝날 때까지 대기해야 하기 때문이죠.
우선 성능을 고려하지 않고 문제가 해결이 되는지 한번 보도록 하겠습니다.
격리레벨은 다시 repeatable read로 두고 조회 쿼리를 select for update로 변경했습니다.
🚨 데드락 발생
성공할 것이라고 생각했지만 또다시 데드락이 발생해버립니다.
조회 쿼리까지만 날려보고 status를 확인해보면 다음과 같이 조회 시점에 레코드에 X락이 걸린 것을 알 수 있습니다.
그런데 중요한 것은 쓰레드 2개에서 X락을 공유하고 있다는 사실입니다.
supremum pseudo-record → 리프 노드의 상한 레코드를 두어 갭락을 걸어버림
supremum pseudo-record의 X-lock은 테이블의 인덱스에 (현재) 존재하는 가장 큰 값 이후의 모든 간격을 잠그는 잠금
그 후 데드락이 발생되기 직전, 즉 하나의 트랜잭션만 insert를 시도할 때 “INSERT Intention Gap Lock”을 걸기 위해 WATING하는 것을 알 수 있습니다.
하지만 여기서 “INSERT Intention Gap Lock”은 Gap Lock과 공유되지 않기 때문에, 서로의 Gap Lock을 기다리게 되어서 Dead Lock 상황이 발생하게 되어버립니다.
❓INSERT Intention Gap Lock이란
- 여러개의 트랜잭션들이 gap 안의 다른 위치에 INSERT를 동시 수행할 때 기다릴 필요가 없도록 하는것이 목적
- insert 위치가 겹치지 않는 경우에만 서로를 blocking하지 않는다.
https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html#innodb-insert-intention-locks%EF%BB%BF
MySQL :: MySQL 8.4 Reference Manual :: 17.7.1 InnoDB Locking
MySQL 8.4 Reference Manual / ... / The InnoDB Storage Engine / InnoDB Locking and Transaction Model / InnoDB Locking This section describes lock types used by InnoDB. Shared and Exclusive Locks InnoDB implements standard row-level locking w
dev.mysql.com
Gap락의 목적
당근마켓의 이성욱님(Real MySQL 저자분)의 블로그글을 인용하면,
gap락의 목적은 대상 간격에 insert를 막는 것이기 때문에 조회 시점에 여러 트랜잭션의 갭락은 서로 호환됩니다.
결국 for update로도 문제를 해결할 수 없었습니다.
2편에 이어서 작성하겠습니다.