MySQL에서 동시성 문제가 생기면 “잠금 문제” 또는 “격리 수준 문제”라고 말하기 쉽습니다. 하지만 그 말만으로는 원인을 충분히 설명하지 못합니다. 실제로는 어떤 트랜잭션이, 어떤 쿼리로, 어떤 인덱스 경로를 지나가며, 무엇을 얼마나 오래 잠갔는지까지 내려가야 합니다.
격리 수준은 이름으로 외우는 개념이 아니라 운영 중 대기와 정합성 문제로 드러나는 선택입니다. 그래서 이 글에서는 격리 수준 표를 다시 외우기보다, 실제 쿼리가 어떤 인덱스 경로를 지나고 어떤 잠금을 얼마나 오래 잡는지 추적하는 쪽에 초점을 맞춥니다. 같은 REPEATABLE READ라도 point lookup인지 range scan인지, 일반 SELECT인지 locking read인지에 따라 운영에서 보이는 증상은 완전히 달라집니다.
트랜잭션과 잠금은 같은 말이 아닙니다
트랜잭션은 작업의 완전성을 보장합니다. 여러 작업이 모두 반영되거나, 문제가 생기면 모두 되돌아가야 할 때 필요합니다. 반면 잠금은 여러 세션이 같은 자원을 동시에 바꾸지 않도록 접근 순서를 제어합니다.
둘은 함께 등장하지만 역할이 다릅니다.
| 구분 | 주요 목적 | 문제가 되는 경우 |
|---|---|---|
| 트랜잭션 | 작업 단위를 원자적으로 처리 | 트랜잭션이 너무 길어져 lock과 connection을 오래 붙잡음 |
| 잠금 | 동시 접근 순서 제어 | 잠금 범위가 예상보다 넓거나 대기 시간이 길어짐 |
| 격리 수준 | 동시에 실행되는 트랜잭션이 서로의 변경을 어떻게 볼지 결정 | 필요 이상으로 강하거나, 필요한 정합성을 보장하지 못함 |
트랜잭션을 사용한다고 해서 잠금 범위가 자동으로 적절해지지는 않습니다. 반대로 격리 수준을 높인다고 해서 모든 정합성 문제가 해결되는 것도 아닙니다. 어떤 현상이 발생했는지 먼저 고정해야 합니다.
MySQL에는 층위가 다른 잠금이 있습니다
MySQL에서 잠금이라고 부르는 것에는 여러 층위가 있습니다. 글로벌 락이나 메타데이터 락은 서버 또는 테이블 정의 변경과 관련된 더 큰 범위의 잠금입니다. 반면 InnoDB의 레코드 락, 갭 락, 넥스트 키 락은 인덱스와 트랜잭션 처리 과정에서 등장합니다.
| 잠금 | 범위 | 자주 보이는 상황 |
|---|---|---|
| Global Lock | 서버 수준 | 전체 백업, 전역 읽기 잠금 |
| Metadata Lock | 테이블 메타데이터 | DDL과 DML이 동시에 실행될 때 |
| Record Lock | 인덱스 레코드 | 특정 row update/delete |
| Gap Lock | 인덱스 레코드 사이의 간격 | 범위 조건에서 phantom 방지 |
| Next-Key Lock | record lock + gap lock | REPEATABLE READ의 범위 locking read |
실제 서비스에서 자주 문제가 되는 것은 “테이블이 잠겼다”보다 “생각보다 넓은 인덱스 범위가 잠겼다”에 가깝습니다.
InnoDB는 레코드보다 인덱스 경로를 기준으로 잠급니다
InnoDB는 수정할 row를 찾기 위해 사용한 인덱스 레코드에 잠금을 겁니다. 그래서 최종적으로 바뀐 row 수보다, 그 row를 찾기 위해 어떤 인덱스 경로를 지나갔는지가 먼저입니다.
예를 들어 애플리케이션은 특정 사용자 한 명의 주문 상태만 바꾼다고 생각할 수 있습니다.
UPDATE orders
SET status = 'CANCELLED'
WHERE user_id = 10
AND status = 'WAITING'
AND created_at < '2026-01-01';하지만 인덱스가 status 하나뿐이라면 InnoDB는 WAITING 상태의 넓은 구간을 훑어야 할 수 있습니다. 최종 수정 row는 적어도, 탐색 과정에서 지나가는 후보가 많아지고 잠금 범위도 넓어질 수 있습니다.
반대로 (user_id, status, created_at)처럼 조건에 맞는 복합 인덱스가 있다면 탐색 범위가 줄고, 잠금 영향도 함께 줄어들 수 있습니다.
격리 수준은 어떤 읽기를 허용할지 정합니다
격리 수준은 여러 트랜잭션이 동시에 실행될 때 서로의 변경을 어디까지 볼 수 있는지 정합니다.
| 격리 수준 | 특징 | 주의점 |
|---|---|---|
| READ UNCOMMITTED | 커밋되지 않은 변경도 볼 수 있음 | dirty read 가능 |
| READ COMMITTED | 커밋된 데이터만 읽음 | 같은 트랜잭션 안에서 다시 읽으면 값이 바뀔 수 있음 |
| REPEATABLE READ | 트랜잭션 안에서 일관된 snapshot read 제공 | locking read와 일반 read를 구분해야 함 |
| SERIALIZABLE | 가장 강한 격리 | 동시성 비용이 큼 |
높은 격리 수준이 항상 더 좋은 것은 아닙니다. 읽기 정합성은 강해질 수 있지만, 대기와 잠금 충돌 비용도 함께 커질 수 있습니다. 따라서 먼저 필요한 읽기 모델을 정해야 합니다.
일반 SELECT와 FOR UPDATE는 다르게 움직입니다
InnoDB의 REPEATABLE READ에서는 일반 SELECT가 MVCC를 통해 일관된 snapshot을 읽을 수 있습니다. 그래서 같은 트랜잭션 안에서 같은 조건을 다시 조회해도 같은 결과를 보는 것처럼 동작합니다.
START TRANSACTION;
SELECT * FROM accounts WHERE id = 1;
SELECT * FROM accounts WHERE id = 1;
COMMIT;하지만 SELECT ... FOR UPDATE는 다릅니다. 이 쿼리는 과거 snapshot을 보는 것이 아니라 현재 데이터를 기준으로 잠금을 잡는 locking read입니다.
START TRANSACTION;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
COMMIT;이 차이를 모르면 “REPEATABLE READ인데 왜 대기하지?” 또는 “SELECT인데 왜 잠금이 생기지?” 같은 질문에 답하기 어렵습니다.
| 읽기 방식 | 읽는 기준 | 잠금 |
|---|---|---|
| 일반 SELECT | MVCC snapshot | 일반적으로 row lock을 잡지 않음 |
| SELECT ... FOR UPDATE | 현재 데이터 | 수정 의도로 row 또는 범위 잠금 |
| UPDATE / DELETE | 현재 데이터 | 대상 탐색 경로에 잠금 |
격리 수준을 이해할 때는 이 두 세계를 구분해야 합니다. snapshot read에서 안정적인 것과 locking read에서 충돌이 없는 것은 다른 문제입니다.
장애는 보통 세 가지 조건이 겹칠 때 드러납니다
격리 수준과 잠금이 실제 장애로 이어지는 순간은 대체로 비슷합니다.
- 트랜잭션이 오래 열려 있습니다.
- 인덱스가 조건에 맞지 않아 잠금 범위가 넓어집니다.
- current read 또는 write가 동시에 같은 범위를 지나갑니다.
예를 들어 아래 흐름을 생각할 수 있습니다.
1. 트랜잭션 시작
2. SELECT ... FOR UPDATE로 대상 조회 및 lock 획득
3. 외부 API 호출 또는 파일 처리
4. 추가 UPDATE 수행
5. 커밋실제 DB 작업은 짧더라도 외부 API 호출이 트랜잭션 안에 있으면 lock은 그 시간만큼 유지됩니다. 이 상태에서 다른 요청이 같은 인덱스 범위를 지나가면 대기 시간이 길어집니다.
따라서 잠금 장애를 볼 때 격리 수준을 먼저 바꾸기보다, 트랜잭션 안에 DB 외 작업이 들어 있는지 먼저 확인하는 편이 안전합니다.
잠금 대기는 실행 계획과 함께 봐야 합니다
SHOW ENGINE INNODB STATUS, performance_schema, slow query log를 보면 어떤 세션이 기다렸는지는 알 수 있습니다. 하지만 왜 잠금 범위가 넓어졌는지는 실행 계획과 인덱스를 같이 봐야 합니다.
잠금 대기를 확인할 때는 다음 순서가 유용합니다.
격리 수준은 이 다음에 조정하는 편이 낫습니다. 실제로는 인덱스와 트랜잭션 길이만 정리해도 잠금 충돌이 줄어드는 경우가 많기 때문입니다.
해결책은 격리 수준 변경만이 아닙니다
동시성 문제를 만나면 READ COMMITTED로 낮추거나 SERIALIZABLE로 올리는 식의 선택을 먼저 떠올릴 수 있습니다. 하지만 격리 수준 변경은 비용과 의미가 큰 결정입니다. 먼저 더 직접적인 해결책을 검토해야 합니다.
| 문제 | 먼저 검토할 해결책 |
|---|---|
| 중복 row 생성 | unique constraint, idempotency key |
| lost update | atomic update, version column, FOR UPDATE |
| 잠금 대기 증가 | 트랜잭션 축소, 인덱스 개선, 작업 순서 조정 |
| phantom 방지가 필요함 | locking read, unique constraint, 격리 수준 검토 |
| 긴 snapshot 유지 | 긴 트랜잭션 제거, 배치 chunk 분리 |
예를 들어 중복 가입이나 중복 주문 같은 문제는 격리 수준보다 유니크 제약이 더 직접적인 방어선일 수 있습니다. count 증가 문제는 UPDATE count = count + 1 같은 원자적 update나 version 기반 낙관적 락이 더 명확할 수 있습니다.
Deadlock은 원인과 복구 경로를 함께 봐야 합니다
Deadlock은 단순히 “DB가 꼬인 상태”가 아닙니다. 두 트랜잭션이 서로가 가진 잠금을 기다리는 순환 대기 상태이고, InnoDB는 이 상황을 감지하면 보통 한쪽 트랜잭션을 희생자로 골라 rollback합니다.
문제는 애플리케이션에서 이 실패를 어떻게 다루느냐입니다. 같은 요청을 바로 재시도해도 안전한 작업인지, 이미 외부 API 호출이나 메시지 발행이 함께 일어났는지에 따라 복구 방식이 달라집니다.
| 확인할 것 | 이유 |
|---|---|
| deadlock에 참여한 두 쿼리 | 서로 어떤 순서로 어떤 인덱스를 잡았는지 확인 |
| 각 트랜잭션의 작업 순서 | 동일 자원을 서로 다른 순서로 잡는 패턴이 있는지 확인 |
| 재시도 가능 여부 | 멱등성이 없는 작업을 단순 재시도하면 중복 부작용이 생길 수 있음 |
| 외부 부작용 위치 | DB rollback과 외부 API, 메시지 발행 결과가 어긋날 수 있음 |
Deadlock 재시도는 유용한 방어선이지만, 모든 문제를 덮는 만능 처리는 아닙니다. 재시도 전에 작업이 멱등적으로 설계되어 있는지, 트랜잭션 안에 외부 부작용이 들어 있지 않은지 확인해야 합니다.
잠금 문제를 좁히는 관측 항목
잠금 문제는 재현될 때만 보이고 지나가면 사라지는 경우가 많습니다. 그래서 운영 지표와 로그가 중요합니다.
| 관측 항목 | 확인하려는 것 |
|---|---|
| lock wait time | 대기 시간이 사용자 응답 지연과 연결되는지 |
| deadlock log | 어떤 쿼리와 인덱스가 서로 기다렸는지 |
| transaction age | 오래 열린 트랜잭션이 있는지 |
| history list length | 긴 트랜잭션 때문에 undo purge가 밀리는지 |
| EXPLAIN rows | 잠금 대상 탐색 범위가 과도한지 |
| slow query log | 쿼리 실행 시간과 lock time이 함께 증가하는지 |
특히 deadlock은 단순히 “DB가 꼬였다”가 아닙니다. 어떤 순서로 어떤 인덱스를 잡았는지 알려주는 디버깅 자료입니다. deadlock log를 남기지 않으면 같은 문제가 반복되어도 원인을 좁히기 어렵습니다.
격리 수준보다 먼저 볼 것
MySQL에서 격리 수준과 잠금이 실제 장애로 이어지는 조건은 단순히 “격리 수준이 낮아서” 또는 “잠금이 있어서”가 아닙니다. 트랜잭션 길이, 인덱스 경로, 읽기 방식, 쓰기 범위가 함께 맞물릴 때 문제가 됩니다.
운영에서 남는 기준은 다음과 같습니다.
- 트랜잭션은 작업 단위의 원자성을 보장하고, 잠금은 동시 접근 순서를 제어합니다.
- InnoDB는 row 자체보다 row를 찾기 위해 사용한 인덱스 경로를 기준으로 잠금을 잡습니다.
- 일반 SELECT의 snapshot read와
FOR UPDATE같은 locking read는 다르게 동작합니다. - 높은 격리 수준이 항상 좋은 선택은 아니며, 필요한 읽기 모델에 맞춰 선택해야 합니다.
- 잠금 대기는 격리 수준보다 트랜잭션 길이와 인덱스 설계에서 먼저 줄어드는 경우가 많습니다.
- Deadlock 재시도는 작업의 멱등성과 외부 부작용 위치까지 확인한 뒤 적용해야 합니다.
- 운영에서는 lock wait, deadlock log, transaction age,
EXPLAIN을 함께 봐야 합니다.
그래서 격리 수준을 바꾸기 전에 먼저 확인할 질문은 더 작아야 합니다. 이 쿼리는 어떤 값을 읽고, 어떤 인덱스 경로로 찾고, 무엇을 얼마나 오래 잠그는가. deadlock 로그나 SHOW ENGINE INNODB STATUS를 볼 때도 같은 순서로 읽어야 합니다. 재시도 로직을 넣는 것은 그다음 문제입니다. 재시도 대상 작업이 멱등적인지, 외부 알림이나 결제처럼 되돌리기 어려운 부작용을 이미 냈는지까지 확인해야 격리 수준을 실제 해결 도구로 사용할 수 있습니다.