LLM WikiAccess-protected knowledge portal
← 스터디 홈
2편 · 약 13분

트랜잭션과 잠금

트랜잭션이 없으면 무슨 일이 생기나

여러 쿼리가 하나의 작업을 구성할 때, 중간에 실패하면 어떻게 될지 생각해보자.

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 ReadNon-Repeatable ReadPhantom 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을 사용하지 않으면 팬텀 리드가 발생할 수 있다.

READ UNCOMMITTED — Dirty Read 허용
READ COMMITTED — Non-Repeatable Read 허용
REPEATABLE READ (기본값) — Phantom 부분 허용
SERIALIZABLE — 모두 차단 (잠금 SELECT)
격리 수준별 현상 허용 범위: 위로 갈수록 더 많이 허용하고, 아래로 갈수록 더 엄격하게 막는다

MVCC: 잠금 없이 읽는 방법

InnoDB가 읽기 성능을 높이는 핵심 기법이 MVCC(Multi-Version Concurrency Control) 다. 각 row의 변경 이력을 undo log에 보관하고, 트랜잭션이 시작될 때 Read View를 생성해 "이 트랜잭션이 볼 수 있는 버전"을 결정한다.

Transaction T2 (SELECT)Read View 생성볼 수 있는 버전 결정
현재 rowtrx_id=10 (T1 미커밋)→ undo log 링크
Undo log 버전trx_id=8 (커밋됨)T2가 읽는 버전
MVCC 구조: 현재 row와 undo log 버전 체인으로 트랜잭션별 일관된 읽기를 제공한다

MVCC의 핵심은 다음과 같다.

  1. 쓰기가 있어도 읽기를 막지 않는다. SELECT는 잠금 없이 과거 버전을 읽는다.
  2. 읽기가 있어도 쓰기를 막지 않는다.
  3. 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 lock

Gap 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

데드락을 줄이는 실무 습관은 다음과 같다.

  1. 여러 테이블을 수정할 때 항상 같은 순서로 접근한다.
  2. 트랜잭션을 짧게 유지한다. 잠금을 오래 잡을수록 충돌 확률이 높다.
  3. 불필요한 SELECT ... FOR UPDATE를 피한다. MVCC 읽기로 충분한지 검토한다.
  4. 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/