동시성 문제(데드락) 해결기 - X 락인데 왜 공유가 가능하지??????
이전 글에서는 동시성 문제를 INSERT IGNORE
를 사용해 해결한 경험을 공유했습니다.
INSERT IGNORE
는 unique 인덱스가 설정된 상황에서 중복된 unique 키로 삽입을 시도할 경우, 에러를 발생시키지 않고 해당 삽입을 무시합니다.
하지만 매번 INSERT
쿼리를 실행하면 성능이 저하가 우려되어 이를 테스트를 하는 도중, 다음과 같이 이상한 현상을 발견했습니다.
먼저 이전 글과 동일하게 place 테이블은 (id, name, latitude, longitude) 로 구성되어 있고, (name, latitude, longitude) 에 unique 제약이 걸려있습니다.
그리고 테이블에는 다음과 같이 ('place1', '123.456', '123.456') 레코드가 존재합니다.
이 상황에서 중복된 장소에 대해 INSERT IGNORE
를 수행하니 다음과 같이 락을 거는 것을 확인할 수 있었습니다. (트랜잭션 격리 수준은 디폴트인 REPEATABLE READ 입니다.)
이는 데이터의 일관성을 위한 락으로, 다른 트랜잭션에서 동일한 unique 키를 가지는 데이터에 대한 수정이나 삭제를 방지합니다.
(다만 unique 인덱스에 대해 단순 레코드 락이 아닌 넥스트 키 락을 거는 이유는 잘 모르겠습니다.)
삽입 시도에 따라 십입 위치에 대한 의도를 나타내는 IX 가 발생합니다.
가장 이해하기 어려운 점은 PK 인덱스에 supremum pseudo record 락이 걸린 것입니다. 이 글을 쓰게 된 이유이기도 합니다.
이 락은 PK 인덱스에서 가장 큰 레코드보다 큰 레코드의 삽입을 방지하는 역할을 합니다. 즉, AUTO_INCREMENT를 사용하는 경우 새로운 레코드는 항상 인덱스의 끝에 추가되기 때문에, 이 락으로 인해 모든 새로운 레코드 삽입이 차단되는 상황이 발생합니다.
INSERT IGNORE
는 자신이 삽입하고자 했던 ('place1', '123.456', '123.456') 에 대한 락만 걸면 될텐데, 왜 전혀 관계없는 새로운 레코드의 삽입도 막는지 이해가 가지 않았습니다.
일반적인 INSERT
는 데이터 중복이 없을 때 INSERT INTENTION LOCK 만 거는데, 레코드 충돌만 없다면 락을 공유할 수 있기 때문에 동시성이 저하되지 않습니다. 반면 INSERT IGNORE
는 중복된 데이터로 인해 supremum pseudo record 락까지 발생시키며, AUTO_INCREMENT를 사용하는 모든 삽입 시도를 차단합니다.
실제로 트랜잭션을 새로 열고 삽입을 시도하면, supremum pseudo record 락을 해제하길 기다리는 것을 확인할 수 있습니다.
INSERT IGNORE
가 어떻게 동작하길래 해당 락을 거는지 궁금했습니다. 중복 레코드가 존재하는 경우 모든 INSERT 를 잠근다는 것이 너무 비효율적이라 생각했기 때문입니다.
따라서 MySQL 의 공식 문서 내용을 확인했습니다.
If you use the IGNORE modifier, ignorable errors that occur while executing the INSERT statement are ignored. For example, without IGNORE, a row that duplicates an existing UNIQUE index or PRIMARY KEY value in the table causes a duplicate-key error and the statement is aborted. With IGNORE, the row is discarded and no error occurs. Ignored errors generate warnings instead.
Several statements in MySQL support an optional IGNORE keyword. This keyword causes the server to downgrade certain types of errors and generate warnings instead.
INSERT: With IGNORE, rows that duplicate an existing row on a unique key value are discarded.
문서 내용에 따르면 INSERT IGNORE
문은 레코드를 먼저 삽입하고 중복 에러가 발생하면 해당 행을 삭제하는 방식으로 동작합니다. 에러는 경고로 다운그레이드되며 이는 에러가 발생하지 않는 것이 아니라 에러를 핸들링 하는 것임을 의미합니다.
실제로 중복된 값에 대해 INSERT IGNORE
를 실행하고 커밋한 후, 새로운 레코드를 삽입했을 때 id 가 하나 건너뛰어 저장되는 것을 확인할 수 있었습니다. (AUTO INCREMENT인 경우)
혹시 일반 INSERT
도 중복 에러가 발생할 경우 동일한 방식으로 동작하는지 확인하고자 실험을 하였습니다.
트랜잭션을 열어둔 상태에서 중복되는 ('place1', '123.456', '123.456') 레코드를 INSERT
해 보았습니다.
이후 락 정보를 확인해보니 INSERT IGNORE
와 동일한 방시으로 락을 거는 것을 확인할 수 있었습니다.
INSERT INTENTION LOCK과 unique 레코드에 대한 S 락, PK 인덱스에 대한 supremum pseudo record 락을 동일하게 거는 것을 확인할 수 있습니다.
따라서 INSERT IGNORE
에서 걸리는 락은 단순 INSERT
쿼리에서 중복 에러가 발생할 때 걸리는 락과 동일하다는 결론을 내릴 수 있었습니다.
공식 문서에 따르면 기본적으로 MySQL의 락 메커니즘은 다음과 같습니다.
InnoDB performs row-level locking in such a way that when it searches or scans a table index, it sets shared or exclusive locks on the index records it encounters. Thus, the row-level locks are actually index-record locks. A next-key lock on an index record also affects the “gap” before that index record. That is, a next-key lock is an index-record lock plus a gap lock on the gap preceding the index record. If one session has a shared or exclusive lock on record R in an index, another session cannot insert a new index record in the gap immediately before R in the index order.
MySQL은 REPEATABLE READ 수준에서 인덱스를 스캔한 범위만큼 락을 겁니다. 이를 위해 넥스트 키 락과 갭 락을 사용하여 스캔된 범위를 잠급니다. (unique 인덱스에서 단일 레코드를 스캔하는 경우 레코드 락만 걸리지만 이 글에서는 논외로 하겠습니다.)
따라서 이 메커니즘에 따르면, 삽입 후 삭제를 하면서 PK 인덱스의 끝인 supremum record를 스캔 범위로 보는 것이 아닐까 추측됩니다.
실제로 다른 범위에 대해서도 동일하게 동작하는지 궁금해서 실험해 보았습니다.
이전처럼 PK 인덱스에 id = 1
, id = 3
인 레코드가 존재하며, id = 2
는 비어 있는 상태입니다.
이 상황에서 id = 2
이면서 다른 레코드와 중복되는 레코드를 삽입하면, id = 1
인 레코드와 id = 3
인 레코드 사이에 갭 락이 걸릴 것입니다. 즉 id 가 3인 레코드에 갭 락이 걸릴 것입니다.
해당 범위에 삽입 후 삭제를 할 것이기 때문입니다.
INSERT IGNORE place(id, name, latitude, longitude)
VALUES (2, '123.456', '123.456', 'place1')
결과적으로, 예상대로 id = 3 레코드에 갭 락이 설정되는 것을 확인할 수 있었습니다.
이전 글에서는 INSERT IGNORE
를 우아한 해결책으로 소개했지만, 실제로는 데이터 중복 시 새로운 레코드 삽입을 막아 동시성 저하를 초래할 수 있습니다.
특히 이전 글에서 언급했듯, REPEATABLE READ 격리 수준에서는 매번 INSERT IGNORE를 먼저 호출해야 했습니다. 즉 데이터가 이미 존재하는 경우에도 매번 INSERT IGNORE
를 먼저 호출해야 했습니다.
또한 비즈니스 특성상 인기 있는 장소는 데이터베이스에 이미 존재할 가능성이 높습니다. 그리고 인기 있다는 사실은 곧 경합 가능성이 더 높아질 수 있음을 의미합니다. 이러한 상황에서 매번 INSERT IGNORE
를 호출하면 supremum record lock에 의해 순차적으로 실행되며, 결과적으로 동시성이 크게 저하됩니다.
이 문제는 트랜잭션 격리 수준을 READ COMMITTED로 낮추면 해결할 수 있습니다. 하지만 이 경우 데이터 정합성이 떨어질 위험이 있습니다. 즉, 동시성은 개선되지만 데이터 일관성 문제가 발생할 가능성이 커집니다.
INSERT IGNORE
는 단순 중복 에러 뿐만 아니라 다른 에러들에 대해서도 무시하기 때문에 데이터 정합성 문제가 발생할 수 있습니다. 예를들어 NULL 제약조건, 데이터 타입 불일치 등에 대한 에러도 무시할 수 있습니다.
따라서 해당 처리에 대해 인지하고, 경고에 따라 다르게 처리할 필요가 있습니다. 아니면 어플리케이션단에서 검증에 대한 책임을 지고, DB를 믿는 것도 방법이 될 수 있을 것 같습니다.
하지만 이러한 단점들을 끌어 안으면서까지 INSERT IGNORE
를 사용하는 것은 매력적이지 않다고 생각했습니다.
최종적으로는 place 테이블을 역정규화하여 해당 문제를 해결하였습니다.
역정규화 방식은 null 저장이나 데이터 불일치 등의 문제가 발생할 수 있지만, 비즈니스적으로 이러한 상황이 발생할 가능성은 낮다고 판단했습니다.
왜냐하면, 여행기는 과거의 데이터를 기록하는 특성상 장소가 변경되거나 사라져도 이를 반영할 필요가 없다고 생각했기 때문입니다. 즉, 여행지에 대한 데이터는 고정된 상태로 유지되어야 하며, 변경 사항을 반영할 필요가 없다는 점에서 역정규화가 적합하다고 판단했습니다.
또한 place 테이블이 비즈니스적으로 검색이나 평점 등 다른 용도로 활용되지 않기 때문에, 이 방식이 더 나은 선택이라고 결론지었습니다. 물론 미래에는 그러한 요구사항이 추가될 수 있지만, 불확실한 미래 요구사항에 대비해 정규화를 진행하는 것은 시스템의 복잡성만 증가시킨다고 생각했기 때문입니다. 만약 미래에 이러한 요구사항이 생긴다면, 그때 다시 고민하고 조정하면 될 것입니다.