트랜잭션과 잠금
트랜잭션이 없으면 무슨 일이 생기나
여러 쿼리가 하나의 작업을 구성할 때, 중간에 실패하면 어떻게 될지 생각해보자.
UPDATE account SET balance = balance - 10000 WHERE id = 1;
UPDATE account SET balance = balance + 10000 WHERE id = 2;첫 번째 쿼리가 성공하고 두 번째 쿼리가 실패하면 10,000원이 사라진다. 트랜잭션은 이런 문제를 막기 위해 존재한다. 두 쿼리를 하나로 묶어 전부 성공하거나 전부 실패(롤백)하게 만든다.
InnoDB는 ACID 트랜잭션을 지원한다.
| 속성 | 의미 |
|---|---|
| Atomicity | 트랜잭션 안의 쿼리는 전부 성공하거나 전부 취소된다. |
| Consistency | 트랜잭션 전후에 DB의 무결성 조건이 유지된다. |
| Isolation | 다른 트랜잭션의 진행 중인 변경이 보이지 않도록 격리한다. |
| Durability | 커밋이 완료되면 서버가 죽어도 데이터가 보존된다. |
실무에서 ACID 중 가장 자주 오해하는 것은 Isolation이다. "격리"가 무엇까지 막는지는 isolation level로 결정된다.
격리 수준: 무엇을 허용하고 무엇을 막는가
SQL 표준은 4개의 격리 수준을 정의한다. InnoDB도 모두 지원하며 기본값은 REPEATABLE READ다.
-- 현재 세션의 격리 수준 확인
SELECT @@transaction_isolation;
-- 세션별 변경
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;격리 수준마다 허용하는 현상이 다르다.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | 허용 | 허용 | 허용 |
| READ COMMITTED | 차단 | 허용 | 허용 |
| REPEATABLE READ | 차단 | 차단 | 부분 차단 |
| SERIALIZABLE | 차단 | 차단 | 차단 |
- Dirty Read: 아직 커밋되지 않은 다른 트랜잭션의 변경을 읽는 현상.
- Non-Repeatable Read: 같은 쿼리를 두 번 실행했을 때 결과가 달라지는 현상. 다른 트랜잭션이 그 사이에 커밋했기 때문이다.
- Phantom Read: 범위 조건으로 조회했을 때 없던 row가 나타나거나 있던 row가 사라지는 현상.
InnoDB의 REPEATABLE READ는 일반적인 SELECT에서 팬텀 리드를 MVCC로 막는다. 하지만 SELECT ... FOR UPDATE 같은 잠금 읽기에서는 gap lock을 사용하지 않으면 팬텀 리드가 발생할 수 있다.
MVCC: 잠금 없이 읽는 방법
InnoDB가 읽기 성능을 높이는 핵심 기법이 MVCC(Multi-Version Concurrency Control) 다. 각 row의 변경 이력을 undo log에 보관하고, 트랜잭션이 시작될 때 Read View를 생성해 "이 트랜잭션이 볼 수 있는 버전"을 결정한다.
MVCC의 핵심은 다음과 같다.
- 쓰기가 있어도 읽기를 막지 않는다. SELECT는 잠금 없이 과거 버전을 읽는다.
- 읽기가 있어도 쓰기를 막지 않는다.
- Read View는 격리 수준마다 다르게 생성된다. REPEATABLE READ는 트랜잭션 시작 후 첫 번째 SELECT 시점에 Read View를 만들고 이후 유지한다. READ COMMITTED는 SELECT마다 새로운 Read View를 만든다.
BEGIN;
-- REPEATABLE READ: 이 SELECT 시점에 Read View 확정
SELECT * FROM orders WHERE status = 'PENDING';
-- 다른 트랜잭션이 PENDING→DONE으로 변경하고 커밋
-- 같은 트랜잭션에서 다시 조회 → 이전 결과와 같다 (REPEATABLE READ)
SELECT * FROM orders WHERE status = 'PENDING';
COMMIT;잠금의 종류
MVCC는 읽기를 위한 메커니즘이다. 쓰기는 별도로 잠금을 사용한다.
공유 잠금과 배타 잠금
- 공유 잠금(Shared Lock, S): 여러 트랜잭션이 동시에 잡을 수 있다. 읽기 잠금.
SELECT ... LOCK IN SHARE MODE. - 배타 잠금(Exclusive Lock, X): 하나만 잡을 수 있다. 쓰기 잠금.
UPDATE,DELETE,SELECT ... FOR UPDATE.
S와 X는 호환되지 않는다. 한 row에 X 잠금이 있으면 다른 트랜잭션은 S도 X도 잡지 못한다.
Row Lock, Gap Lock, Next-Key Lock
InnoDB에는 세 종류의 인덱스 기반 잠금이 있다.
Record Lock: 특정 인덱스 레코드 하나를 잠근다.
UPDATE component SET version = '3.1.0' WHERE id = 101;
-- id=101 레코드에 X record lockGap Lock: 인덱스 레코드 사이의 "간격"을 잠근다. 해당 범위에 INSERT를 막는 것이 목적이다. gap lock끼리는 호환된다(여러 트랜잭션이 같은 gap에 gap lock을 동시에 잡을 수 있다).
Next-Key Lock: Record Lock + 그 앞의 Gap Lock을 합친 것. REPEATABLE READ에서 범위 조건 잠금 읽기의 기본 동작이다. 팬텀 리드를 막기 위해 사용된다.
-- id between 100 and 200 범위에 next-key lock이 걸릴 수 있다
SELECT * FROM component WHERE id BETWEEN 100 AND 200 FOR UPDATE;READ COMMITTED 격리 수준에서는 gap lock이 비활성화되어 record lock만 사용한다.
데드락
두 트랜잭션이 서로 상대방이 가진 잠금을 기다리면 데드락이 발생한다.
-- T1: A 잠금 → B 잠금 시도
BEGIN; UPDATE orders SET status='X' WHERE id=1;
UPDATE orders SET status='X' WHERE id=2; -- B 기다림
-- T2: B 잠금 → A 잠금 시도
BEGIN; UPDATE orders SET status='X' WHERE id=2;
UPDATE orders SET status='X' WHERE id=1; -- A 기다림InnoDB는 락 대기 그래프에서 사이클을 감지해 자동으로 데드락을 탐지한다. 비용이 적은 트랜잭션을 희생자(victim)로 선택해 롤백하고, 나머지 트랜잭션이 진행하도록 한다.
-- 데드락 발생 시 에러
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction
-- 직전 데드락 정보 조회
SHOW ENGINE INNODB STATUS\G데드락을 줄이는 실무 습관은 다음과 같다.
- 여러 테이블을 수정할 때 항상 같은 순서로 접근한다.
- 트랜잭션을 짧게 유지한다. 잠금을 오래 잡을수록 충돌 확률이 높다.
- 불필요한
SELECT ... FOR UPDATE를 피한다. MVCC 읽기로 충분한지 검토한다. innodb_lock_wait_timeout(기본 50초)과innodb_deadlock_detect(기본 ON)를 확인한다.
잠금 모니터링
잠금 경합을 실시간으로 확인하는 주요 쿼리다.
-- 현재 대기 중인 트랜잭션
SELECT * FROM information_schema.INNODB_TRX
WHERE trx_state = 'LOCK WAIT'\G
-- 잠금 대기 관계 (MySQL 8.x performance_schema)
SELECT
r.trx_id AS waiting_trx,
r.trx_query AS waiting_query,
b.trx_id AS blocking_trx,
b.trx_query AS blocking_query
FROM performance_schema.data_lock_waits w
JOIN information_schema.INNODB_TRX r ON r.trx_id = w.REQUESTING_ENGINE_TRANSACTION_ID
JOIN information_schema.INNODB_TRX b ON b.trx_id = w.BLOCKING_ENGINE_TRANSACTION_ID\G격리 수준 선택 기준
운영 환경에서 가장 흔히 쓰는 것은 REPEATABLE READ(기본값)과 READ COMMITTED다.
- REPEATABLE READ: 일관된 읽기가 중요한 배치 작업, 통계 쿼리에 유리하다. Gap lock이 활성화되어 데드락 위험이 조금 높을 수 있다.
- READ COMMITTED: 잠금 경합을 줄이고 싶을 때 선택한다. Gap lock이 없어 동시성이 높지만, Non-Repeatable Read를 허용한다. binlog format이 ROW여야 복제 안전성이 보장된다.
- SERIALIZABLE: 완전한 직렬화가 필요한 경우에만. 잠금 비용이 크다.
한 줄 정리
InnoDB의 트랜잭션은 MVCC로 읽기 충돌을 최소화하고, row/gap/next-key lock으로 쓰기 충돌을 제어한다. 데드락은 피하기 어렵지만 짧은 트랜잭션, 일관된 접근 순서, 잠금 모니터링으로 빈도를 낮출 수 있다.
References
- https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html
- https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html
- https://dev.mysql.com/doc/refman/8.4/en/innodb-deadlocks.html
- https://dev.mysql.com/doc/refman/8.4/en/innodb-multi-versioning.html
- https://planetscale.com/blog/mysql-isolation-levels-and-how-they-work
- https://www.percona.com/blog/innodbs-gap-locks/