카테고리 : MySQL/기술노트

MySQL 트랜잭션 격리 수준: READ COMMITTED와 REPEATABLE READ의 실제 차이

InnoDB의 READ COMMITTED와 REPEATABLE READ를 Read View 수명, phantom, gap lock, 운영 선택 기준으로 비교한다.

저자: MySQL 기술 노트 작성: 2026.07.30 약 14분 8,388자
다운로드

READ COMMITTEDREPEATABLE READ의 차이를 “커밋된 값만 읽는다”와 “같은 값을 반복해서 읽는다”로만 외우면 운영 판단에 부족하다. InnoDB에서 두 격리 수준의 실제 차이는 Read View를 언제 만들고 얼마나 오래 재사용하는지, locking read와 DML이 어느 인덱스 구간을 잠그는지, 그리고 여러 SQL 문으로 구성된 업무가 어떤 일관성을 요구하는지에서 드러난다.

격리 수준을 바꾸면 조회 결과만 달라지는 것이 아니다. 장시간 트랜잭션의 undo 보존량, gap lock으로 인한 삽입 대기, deadlock 패턴, 재시도 방식, 리포트의 시점 일관성까지 함께 달라질 수 있다. 이 글에서는 두 수준을 InnoDB 내부 동작부터 재현 SQL, 운영 선택 기준까지 연결해 살펴본다. 기본 대상은 MySQL 8.0 이상이며 MySQL 8.4 LTS에도 적용할 수 있는 원칙을 중심으로 설명한다.

1. 한눈에 보는 차이

두 격리 수준 모두 일반적인 consistent read에서 다른 트랜잭션의 미커밋 변경을 읽지 않는다. 가장 큰 차이는 committed data를 어느 시점까지 볼 수 있는가이다.

비교 항목 READ COMMITTED REPEATABLE READ
일반 SELECT의 Read View 문장마다 새로 생성 보통 첫 consistent read에서 생성해 트랜잭션 동안 재사용
다른 트랜잭션의 중간 커밋 다음 일반 조회에서 보일 수 있음 기존 snapshot의 일반 조회에는 보이지 않음
non-repeatable read 발생 가능 consistent read에서는 방지
phantom 문장별 snapshot이므로 발생 가능 같은 Read View의 consistent read에서는 방지
locking read의 gap lock 일반 검색에서 대폭 줄어듦 범위 검색에서 next-key/gap lock을 사용할 수 있음
최신 커밋 데이터 가시성 문장 경계에서 빠르게 반영 트랜잭션 snapshot을 유지
대표 적합 업무 짧은 OLTP, 낮은 gap-lock 경합, 명시적 잠금·버전 검증 시점 일관성이 필요한 다중 조회, 일관 백업·리포트

이 표에서 REPEATABLE READ의 보장은 모든 SQL에 동일하게 적용된다는 뜻이 아니다. 일반 SELECT는 snapshot을 사용하는 consistent read지만, SELECT ... FOR UPDATE, SELECT ... FOR SHARE, UPDATE, DELETE는 최신 버전을 대상으로 하는 current read다. 따라서 REPEATABLE READ 트랜잭션에서도 일반 조회와 locking read가 서로 다른 값을 볼 수 있다.

2. 핵심 메커니즘: Read View의 수명

InnoDB의 MVCC는 테이블 전체 복사본을 만들어 두는 방식이 아니다. 레코드의 변경 트랜잭션 정보, undo record, Read View의 가시성 규칙을 조합해 해당 SQL이 볼 수 있는 버전을 찾는다.

flowchart TD
    A[트랜잭션에서 일반 SELECT 실행] --> I{격리 수준}
    I -->|READ COMMITTED| RC[문장 시작 시 새 Read View]
    I -->|REPEATABLE READ| RR{기존 Read View가 있는가}
    RR -->|아니요| NEW[첫 consistent read에서 생성]
    RR -->|예| REUSE[기존 Read View 재사용]
    RC --> V[가시성 판정]
    NEW --> V
    REUSE --> V
    V --> L{최신 레코드가 보이는가}
    L -->|예| R[최신 버전 반환]
    L -->|아니요| U[undo chain에서 보이는 버전 재구성]

2.1 READ COMMITTED

READ COMMITTED에서는 각 consistent read 문장이 시작될 때 새 Read View를 사용한다. 첫 번째 SELECT가 끝난 뒤 다른 트랜잭션이 커밋하면, 같은 트랜잭션의 두 번째 SELECT는 새 커밋을 볼 수 있다. 한 SQL 문장을 실행하는 동안에는 해당 문장의 snapshot이 유지되지만, 다음 문장까지 같은 snapshot을 보장하지 않는다.

이 특성은 최신 커밋을 빨리 반영하고 오래된 Read View 수명을 줄이는 데 유리하다. 반면 “주문 헤더 합계 조회 → 주문 상세 조회 → 헤더 합계 재확인”처럼 여러 문장의 결과가 같은 논리 시점을 가리켜야 하는 업무는 중간 커밋 때문에 서로 맞지 않을 수 있다.

2.2 REPEATABLE READ

InnoDB의 기본 격리 수준인 REPEATABLE READ에서는 보통 첫 consistent read가 만든 Read View를 이후 일반 조회가 재사용한다. 다른 세션이 커밋해도 기존 snapshot에는 반영되지 않으므로 동일 조건을 다시 조회했을 때 같은 행 버전 집합을 볼 수 있다.

START TRANSACTION 자체가 항상 그 순간 Read View를 만드는 것은 아니다. 일반적으로 첫 consistent read까지 생성이 지연될 수 있다. 정확한 snapshot 시작 시점이 중요하면 START TRANSACTION WITH CONSISTENT SNAPSHOT을 사용한다. 다만 이 구문이 의미 있게 동작하는 격리 수준과 업무 목적을 확인해야 하며, 열린 snapshot을 오래 유지하면 purge가 과거 undo를 정리하지 못할 수 있다.

3. 실행 재현: 같은 트랜잭션의 두 번째 조회

다음 예제는 Event Scheduler를 별도 서버 세션으로 사용해 트랜잭션 중간의 커밋을 재현한다. 검증용 임시 인스턴스를 전제로 하며, 운영 서버에서 예제를 위해 event_scheduler를 임의로 활성화해서는 안 된다.

첫 번째 실험은 READ COMMITTED다. 잔액 100을 읽은 뒤 별도 이벤트가 130으로 바꾸고 커밋하면 두 번째 일반 조회에서 130이 보인다. 이어지는 REPEATABLE READ 실험에서는 같은 순서로 변경해도 두 번째 일반 조회가 기존 snapshot의 100을 반환한다.

DROP EVENT IF EXISTS iso_rc_change;
DROP EVENT IF EXISTS iso_rr_change;
DROP TABLE IF EXISTS iso_account;
CREATE TABLE iso_account (
    account_id BIGINT PRIMARY KEY,
    balance INT NOT NULL
) ENGINE = InnoDB;
INSERT INTO iso_account VALUES (1, 100);
SET GLOBAL event_scheduler = ON;

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
CREATE EVENT iso_rc_change
    ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 2 SECOND
    ON COMPLETION NOT PRESERVE
    DO UPDATE iso_account SET balance = 130 WHERE account_id = 1;
START TRANSACTION;
SELECT balance AS rc_first_read
FROM iso_account WHERE account_id = 1;
DO SLEEP(3);
SELECT balance AS rc_second_read
FROM iso_account WHERE account_id = 1;
COMMIT;

UPDATE iso_account SET balance = 100 WHERE account_id = 1;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
CREATE EVENT iso_rr_change
    ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 2 SECOND
    ON COMPLETION NOT PRESERVE
    DO UPDATE iso_account SET balance = 130 WHERE account_id = 1;
START TRANSACTION WITH CONSISTENT SNAPSHOT;
SELECT balance AS rr_first_read
FROM iso_account WHERE account_id = 1;
DO SLEEP(3);
SELECT balance AS rr_second_read
FROM iso_account WHERE account_id = 1;
COMMIT;
SELECT balance AS value_after_commit
FROM iso_account WHERE account_id = 1;
DROP TABLE iso_account;

준비 DDL과 대기 구문의 반복 출력은 줄이고, 격리 수준별 핵심 조회 결과를 발췌했다.

실행 결과(MySQL 8.0.x):

mysql> CREATE TABLE iso_account (...);
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO iso_account VALUES (1, 100);
Query OK, 1 row affected (0.01 sec)

mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT balance AS rc_first_read FROM iso_account WHERE account_id = 1;
+---------------+
| rc_first_read |
+---------------+
|           100 |
+---------------+
1 row in set (0.00 sec)

mysql> SELECT balance AS rc_second_read FROM iso_account WHERE account_id = 1;
+----------------+
| rc_second_read |
+----------------+
|            130 |
+----------------+
1 row in set (0.00 sec)

mysql> SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Query OK, 0 rows affected (0.00 sec)

mysql> START TRANSACTION WITH CONSISTENT SNAPSHOT;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT balance AS rr_first_read FROM iso_account WHERE account_id = 1;
+---------------+
| rr_first_read |
+---------------+
|           100 |
+---------------+
1 row in set (0.00 sec)

mysql> SELECT balance AS rr_second_read FROM iso_account WHERE account_id = 1;
+----------------+
| rr_second_read |
+----------------+
|            100 |
+----------------+
1 row in set (0.00 sec)

mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT balance AS value_after_commit FROM iso_account WHERE account_id = 1;
+--------------------+
| value_after_commit |
+--------------------+
|                130 |
+--------------------+
1 row in set (0.00 sec)

mysql> DROP TABLE iso_account;
Query OK, 0 rows affected (0.01 sec)

관찰 기준은 다음과 같다.

  • rc_first_read = 100, rc_second_read = 130: 문장마다 새 Read View를 사용한다.
  • rr_first_read = 100, rr_second_read = 100: 두 일반 조회가 같은 Read View를 재사용한다.
  • value_after_commit = 130: 트랜잭션이 끝난 뒤에는 최종 커밋 값이 보인다.

이 차이가 곧 READ COMMITTED가 부정확하고 REPEATABLE READ가 항상 안전하다는 뜻은 아니다. 요구하는 일관성의 범위가 한 문장인지, 여러 문장으로 구성된 트랜잭션 전체인지가 선택 기준이다.

4. non-repeatable read와 phantom을 정확히 구분한다

4.1 non-repeatable read

같은 기본 키를 두 번 조회했는데 중간에 다른 트랜잭션이 값을 변경하고 커밋해 두 번째 값이 달라지는 현상이다. 앞의 READ COMMITTED 예제가 이에 해당한다.

REPEATABLE READ의 consistent read는 같은 Read View를 재사용하므로 이 현상을 막는다. 그러나 두 번째 문장이 SELECT ... FOR UPDATE라면 최신 커밋 버전을 읽는 current read이므로 첫 번째 일반 조회와 다른 값이 보일 수 있다. 격리 수준 이름만 보고 모든 읽기가 반복 가능하다고 해석해서는 안 된다.

4.2 phantom

같은 범위 조건을 다시 실행했을 때 중간에 커밋된 행이 추가되거나 삭제되어 결과 행 집합이 달라지는 현상이다.

  • READ COMMITTED의 일반 조회는 문장마다 새 snapshot을 사용하므로 새로 커밋된 행이 다음 조회에 나타날 수 있다.
  • REPEATABLE READ의 consistent read는 같은 snapshot을 사용하므로 새 행이 물리적으로 존재해도 기존 Read View에는 나타나지 않는다.
  • REPEATABLE READ의 locking range read는 snapshot 재사용이 아니라 최신 범위를 잠그는 current read다. phantom 삽입을 억제하기 위해 next-key lock과 gap lock을 사용할 수 있다.

즉 InnoDB는 REPEATABLE READ의 phantom 문제를 두 경로에서 다르게 다룬다. 일반 조회에는 MVCC snapshot을 사용하고, 잠금 기반 범위 변경에는 인덱스 레코드와 간격을 잠근다.

5. 잠금에서 드러나는 더 중요한 차이

격리 수준 변경의 운영 효과는 조회 값보다 잠금 대기에서 더 크게 나타날 수 있다.

5.1 REPEATABLE READ의 next-key lock

REPEATABLE READ에서 범위 조건을 FOR UPDATE로 읽으면 InnoDB는 검색한 인덱스 레코드와 주변 간격에 next-key lock을 설정할 수 있다. 같은 범위에 다른 트랜잭션이 새 인덱스 값을 삽입하려 하면 기다린다. 이는 잠금 기반 범위 연산에서 phantom을 막지만, 예약 번호·시간 구간·상태 코드처럼 삽입이 집중되는 인덱스에서는 예상보다 넓은 경합을 만들 수 있다.

5.2 READ COMMITTED의 gap lock 감소

READ COMMITTED에서는 일반 검색과 인덱스 스캔에서 gap lock 사용이 크게 줄어든다. 범위 안의 기존 레코드를 잠그더라도 그 사이에 새 값을 삽입할 수 있는 경우가 많다. 외래 키 제약 검사와 중복 키 검사처럼 정합성을 위해 필요한 예외는 남는다.

다음 재현은 같은 FOR UPDATE 범위를 유지한 동안 별도 이벤트가 중간 키 15를 삽입하도록 한다. REPEATABLE READ에서는 삽입이 대기하고, READ COMMITTED에서는 완료되는 차이를 확인한다. data_lock_waits는 MySQL 8.0 이상에서 현재 대기 간선을 관찰하는 Performance Schema 테이블이다.

DROP EVENT IF EXISTS iso_rr_insert;
DROP EVENT IF EXISTS iso_rc_insert;
DROP TABLE IF EXISTS iso_slot;
CREATE TABLE iso_slot (
    slot_id BIGINT AUTO_INCREMENT PRIMARY KEY,
    score INT NOT NULL,
    KEY ix_score (score)
) ENGINE = InnoDB;
INSERT INTO iso_slot (score) VALUES (10), (20), (30);
SET GLOBAL event_scheduler = ON;

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
CREATE EVENT iso_rr_insert
    ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 2 SECOND
    ON COMPLETION NOT PRESERVE
    DO INSERT INTO iso_slot (score) VALUES (15);
START TRANSACTION;
SELECT score
FROM iso_slot
WHERE score BETWEEN 10 AND 20
ORDER BY score
FOR UPDATE;
DO SLEEP(3);
SELECT COUNT(*) AS rr_wait_edges
FROM performance_schema.data_lock_waits;
COMMIT;
DO SLEEP(1);
SELECT COUNT(*) AS rr_inserted_after_commit
FROM iso_slot WHERE score = 15;

DELETE FROM iso_slot WHERE score = 15;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
CREATE EVENT iso_rc_insert
    ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 2 SECOND
    ON COMPLETION NOT PRESERVE
    DO INSERT INTO iso_slot (score) VALUES (15);
START TRANSACTION;
SELECT score
FROM iso_slot
WHERE score BETWEEN 10 AND 20
ORDER BY score
FOR UPDATE;
DO SLEEP(3);
SELECT COUNT(*) AS rc_wait_edges
FROM performance_schema.data_lock_waits;
SELECT COUNT(*) AS rc_inserted_while_open
FROM iso_slot WHERE score = 15;
COMMIT;
DROP TABLE iso_slot;

준비·정리 구문과 SLEEP() 출력은 줄이고, 범위 잠금과 삽입 대기의 차이를 보여 주는 결과만 발췌했다.

실행 결과(MySQL 8.0.x):

mysql> CREATE TABLE iso_slot (... KEY ix_score (score)) ENGINE = InnoDB;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO iso_slot (score) VALUES (10), (20), (30);
Query OK, 3 rows affected (0.01 sec)
Records: 3  Duplicates: 0  Warnings: 0

mysql> SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT score FROM iso_slot
    -> WHERE score BETWEEN 10 AND 20 ORDER BY score FOR UPDATE;
+-------+
| score |
+-------+
|    10 |
|    20 |
+-------+
2 rows in set (0.00 sec)

mysql> SELECT COUNT(*) AS rr_wait_edges
    -> FROM performance_schema.data_lock_waits;
+---------------+
| rr_wait_edges |
+---------------+
|             1 |
+---------------+
1 row in set (0.00 sec)

mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT COUNT(*) AS rr_inserted_after_commit
    -> FROM iso_slot WHERE score = 15;
+--------------------------+
| rr_inserted_after_commit |
+--------------------------+
|                        1 |
+--------------------------+
1 row in set (0.00 sec)

mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT score FROM iso_slot
    -> WHERE score BETWEEN 10 AND 20 ORDER BY score FOR UPDATE;
+-------+
| score |
+-------+
|    10 |
|    20 |
+-------+
2 rows in set (0.00 sec)

mysql> SELECT COUNT(*) AS rc_wait_edges
    -> FROM performance_schema.data_lock_waits;
+---------------+
| rc_wait_edges |
+---------------+
|             0 |
+---------------+
1 row in set (0.00 sec)

mysql> SELECT COUNT(*) AS rc_inserted_while_open
    -> FROM iso_slot WHERE score = 15;
+------------------------+
| rc_inserted_while_open |
+------------------------+
|                      1 |
+------------------------+
1 row in set (0.00 sec)

mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)

mysql> DROP TABLE iso_slot;
Query OK, 0 rows affected (0.00 sec)

이 예제에서 검증된 결과는 rr_wait_edges = 1, rr_inserted_after_commit = 1, rc_wait_edges = 0, rc_inserted_while_open = 1이다. 실제 운영 서버에서는 동시에 발생한 다른 잠금 대기가 섞일 수 있으므로 전체 대기 건수만 보지 말고 요청·차단 트랜잭션 ID, object name, index name을 좁혀 확인해야 한다.

READ COMMITTED로 바꾸면 deadlock이 모두 사라지는 것도 아니다. 기존 레코드에 대한 record lock, unique key 검사, 외래 키 검사, 서로 다른 순서의 갱신은 여전히 대기와 deadlock을 만들 수 있다. 바뀌는 것은 잠금 문제의 존재 여부가 아니라 일부 검색에서 사용하는 간격 잠금의 범위와 수명이다.

6. 애플리케이션 정합성: 격리 수준보다 트랜잭션 패턴이 중요하다

6.1 읽고 계산한 뒤 덮어쓰기

다음 패턴은 두 격리 수준 모두에서 lost update 위험을 만들 수 있다.

START TRANSACTION;
SELECT quantity FROM inventory WHERE product_id = :id;
-- 애플리케이션에서 새 수량 계산
UPDATE inventory SET quantity = :new_quantity WHERE product_id = :id;
COMMIT;

두 요청이 같은 수량을 읽고 각자 계산한 결과를 차례로 덮어쓰면 먼저 커밋한 변경을 잃을 수 있다. REPEATABLE READ snapshot이 애플리케이션의 오래된 계산 결과를 자동으로 거부하는 것은 아니다.

가능하면 조건과 계산을 하나의 원자적 DML로 표현한다.

UPDATE inventory
   SET quantity = quantity - :requested_quantity
 WHERE product_id = :product_id
   AND quantity >= :requested_quantity;

영향받은 행 수가 1이면 성공으로 처리하고, 0이면 재고 부족 또는 대상 없음으로 해석한다. 여러 행과 외부 판단이 필요하면 SELECT ... FOR UPDATE로 최신 행을 잠근 뒤 같은 트랜잭션에서 검증과 변경을 끝낸다.

6.2 낙관적 버전 검사

긴 사용자 흐름에서 DB 잠금을 계속 유지하는 대신 version column을 사용할 수 있다.

UPDATE document
   SET body = :new_body,
       version_no = version_no + 1
 WHERE document_id = :id
   AND version_no = :previous_version;

영향받은 행이 0이면 다른 요청이 먼저 변경한 것이므로 최신 상태를 다시 읽어 충돌을 사용자나 업무 로직에 전달한다. 이 패턴은 isolation level을 대체한다기보다, 트랜잭션 밖까지 이어지는 논리적 동시성을 명시적으로 검증한다.

6.3 쓰기 편향과 여러 행 제약

“근무 중인 담당자가 최소 한 명이어야 한다”처럼 여러 행을 함께 읽고 서로 다른 행을 변경하는 제약은 단일 행 lost update와 다르다. 두 트랜잭션이 같은 snapshot에서 조건을 만족한다고 판단한 뒤 각기 다른 행을 변경하면 최종 상태가 업무 제약을 위반할 수 있다.

이런 문제는 REPEATABLE READ라는 이름만으로 해결되지 않는다. 제약을 단일 unique key나 집계 행으로 모델링하거나, 충돌 지점이 되는 행을 명시적으로 잠그거나, 필요하면 SERIALIZABLE을 제한된 업무에 검토해야 한다. 잠금 순서와 deadlock 재시도 정책도 함께 설계해야 한다.

7. 격리 수준을 선택하는 운영 기준

7.1 READ COMMITTED가 유리한 경우

  • 각 SQL 문장 내부의 일관성은 필요하지만 트랜잭션 전체 snapshot은 필요하지 않다.
  • 짧은 OLTP가 많고, 범위 locking read의 gap-lock 경합이 주요 병목이다.
  • 애플리케이션이 잠금 조회, 원자적 DML, version column으로 충돌을 명시적으로 제어한다.
  • 같은 트랜잭션의 후속 조회에서 최신 커밋을 보는 것이 자연스럽다.
  • 변경 후 영향받은 행 수와 업무 조건을 철저히 검증한다.

7.2 REPEATABLE READ가 유리한 경우

  • 여러 조회가 하나의 논리 시점을 기준으로 일관돼야 한다.
  • 일관된 백업, 리포트, 페이지 순회 등에서 transaction snapshot이 필요하다.
  • 기존 애플리케이션이 같은 트랜잭션 안의 반복 조회 결과가 변하지 않는다는 전제에 의존한다.
  • next-key locking을 포함한 현재 잠금 동작을 이해하고 검증했다.
  • 장시간 snapshot으로 인한 undo 보존과 purge 지연을 모니터링한다.

7.3 서비스 전체를 한 번에 바꾸지 않는다

격리 수준 변경은 부하 테스트에서 평균 응답 시간만 비교해서 결정할 수 없다. 실제 쿼리 혼합과 동시성을 재현해 다음을 비교해야 한다.

  1. 동일 업무 시나리오의 결과 정합성
  2. lock wait와 deadlock의 수, 잠금 대상 인덱스 범위
  3. 트랜잭션 지속 시간과 열린 snapshot 수명
  4. undo history 증가와 purge 회복 속도
  5. 재시도 시 중복 처리 여부와 멱등성
  6. connection pool에서 세션 설정이 다음 요청에 누출되는지 여부
  7. binary logging과 복제 구성에서 statement 안전성 경고가 없는지 여부

MySQL은 statement의 결과가 실행 순서에 따라 달라질 수 있으면 statement-based logging에 안전하지 않다고 판단할 수 있다. 최신 운영 구성에서는 row-based binary logging이 일반적이지만, 격리 수준만 바꾸고 binlog_format, 복제 토폴로지, CDC 도구의 기대 동작을 확인하지 않는 것은 위험하다.

8. 설정과 진단 방법

현재 세션과 글로벌 기본값을 먼저 구분한다. 글로벌 값을 바꿔도 이미 연결된 세션의 동작이 즉시 동일하게 바뀐다고 가정해서는 안 된다. connection pool에서는 빌린 연결의 세션 상태를 요청마다 초기화하거나, pool 초기화 SQL과 반환 절차를 명확히 해야 한다.

다음 쿼리는 대상 버전, 세션·글로벌 격리 수준, autocommit, binary log format, 현재 InnoDB 트랜잭션과 잠금 대기를 확인한다. 새 검증 인스턴스에서는 열린 트랜잭션과 잠금 대기가 Empty set인 것이 정상이다.

SELECT VERSION() AS mysql_version;
SELECT @@session.transaction_isolation AS session_isolation,
       @@global.transaction_isolation AS global_isolation,
       @@session.autocommit AS autocommit;
SHOW GLOBAL VARIABLES LIKE 'binlog_format';
SELECT trx_id,
       trx_state,
       trx_started,
       trx_mysql_thread_id,
       trx_isolation_level,
       trx_rows_modified
FROM information_schema.INNODB_TRX
ORDER BY trx_started
LIMIT 20;
SELECT requesting_engine_transaction_id,
       blocking_engine_transaction_id,
       requesting_thread_id,
       blocking_thread_id
FROM performance_schema.data_lock_waits
LIMIT 20;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

실행 결과(MySQL 8.0.x):

mysql> SELECT VERSION() AS mysql_version;

+---------------+
| mysql_version |
+---------------+
| 8.0.46        |
+---------------+
1 row in set (0.00 sec)

mysql> SELECT @@session.transaction_isolation AS session_isolation,
    ->        @@global.transaction_isolation AS global_isolation,
    ->        @@session.autocommit AS autocommit;

+-------------------+------------------+------------+
| session_isolation | global_isolation | autocommit |
+-------------------+------------------+------------+
| REPEATABLE-READ   | REPEATABLE-READ  |          1 |
+-------------------+------------------+------------+
1 row in set (0.00 sec)

mysql> SHOW GLOBAL VARIABLES LIKE 'binlog_format';

+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| binlog_format | ROW   |
+---------------+-------+
1 row in set (0.00 sec)

mysql> SELECT trx_id,
    ->        trx_state,
    ->        trx_started,
    ->        trx_mysql_thread_id,
    ->        trx_isolation_level,
    ->        trx_rows_modified
    -> FROM information_schema.INNODB_TRX
    -> ORDER BY trx_started
    -> LIMIT 20;

Empty set (0.00 sec)

mysql> SELECT requesting_engine_transaction_id,
    ->        blocking_engine_transaction_id,
    ->        requesting_thread_id,
    ->        blocking_thread_id
    -> FROM performance_schema.data_lock_waits
    -> LIMIT 20;

Empty set (0.00 sec)

mysql> SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

Query OK, 0 rows affected (0.00 sec)

운영 장애에서는 INNODB_TRX만 보고 세션을 종료하지 않는다. 오래 열린 읽기 전용 트랜잭션은 잠금 blocker가 아니어도 오래된 Read View로 purge를 지연시킬 수 있고, 짧은 쓰기 트랜잭션은 반대로 중요한 레코드 잠금을 잡고 다수 요청을 막을 수 있다. data_lock_waits, data_locks, performance_schema.threads, 현재 statement 정보를 결합해 blocker와 업무 주체를 확인한 뒤 조치해야 한다.

9. Aurora MySQL에서의 해석

Aurora MySQL에서도 트랜잭션 격리 수준과 InnoDB 호환 계층의 Read View 의미를 이해해야 한다. 다만 endpoint와 복제 가시성이 추가 변수다.

  • writer endpoint의 한 트랜잭션 안에서 값이 달라졌다면 먼저 격리 수준과 consistent/current read를 확인한다.
  • reader endpoint에서 최신 커밋이 늦게 보이는 현상은 transaction snapshot뿐 아니라 replica 가시성 지연도 분리해서 조사한다.
  • writer에서 쓴 뒤 reader endpoint로 이동하는 read-after-write 흐름은 단일 세션의 isolation level만으로 최신 읽기를 보장하지 않는다.
  • Aurora Replica에서 장시간 읽기 트랜잭션을 수행할 때는 failover, 재연결, 쿼리 취소 정책을 함께 고려한다.
  • DB cluster parameter group 또는 session 초기화 SQL로 기본 격리 수준을 바꾸기 전에 기존 connection pool과 재연결된 세션의 실제 값을 확인한다.
  • 장애 조치가 발생하면 진행 중 트랜잭션이 새 writer에서 이어지는 것이 아니다. 애플리케이션은 트랜잭션 전체를 멱등하게 재시도해야 한다.

Aurora의 분산 스토리지 구조가 애플리케이션의 논리적 경쟁 조건을 제거하지는 않는다. 원자적 DML, 명시적 잠금, version 검사, 짧은 트랜잭션이라는 기본 원칙은 그대로 적용된다.

10. 흔한 오해와 실패 패턴

10.1 “READ COMMITTED는 dirty read를 허용한다”

허용하지 않는다. 다른 트랜잭션이 커밋한 뒤의 값을 다음 문장에서 볼 수 있다는 의미다. 미커밋 값을 읽는 READ UNCOMMITTED와 구분해야 한다.

10.2 “REPEATABLE READ는 항상 최신 값을 읽는다”

일반 조회는 기존 snapshot의 과거 버전을 읽을 수 있다. 최신 버전을 잠그고 판단해야 하면 locking read나 원자적 DML을 사용해야 한다.

10.3 “REPEATABLE READ에는 phantom이 절대 없다”

consistent read는 같은 snapshot에서 행 집합을 유지하지만, current read는 최신 상태를 대상으로 한다. 일반 조회와 locking read를 섞으면 새로 커밋된 행이 current read에 나타날 수 있다. 문장 종류를 제외하고 격리 수준 이름만으로 결과를 예측하면 안 된다.

10.4 “READ COMMITTED로 바꾸면 모든 잠금 대기가 사라진다”

일부 gap lock이 줄어들 뿐 record lock, 외래 키 검사, 중복 키 검사와 DDL의 metadata lock은 남는다. 잘못된 인덱스와 긴 트랜잭션도 그대로라면 병목의 형태만 바뀔 수 있다.

10.5 “오래된 snapshot은 읽기 전용이므로 비용이 없다”

오래된 Read View는 purge가 필요한 undo 버전을 제거하지 못하게 할 수 있다. 직접 잠금을 거의 잡지 않아도 undo tablespace, history list, buffer 효율과 복구 시간에 간접 부담을 줄 수 있다.

10.6 “세션 격리 수준을 한 번 바꾸면 해당 요청에만 적용된다”

SET SESSION TRANSACTION ISOLATION LEVEL ...은 연결에 남는다. pool로 반환된 연결을 다음 요청이 재사용하면 의도하지 않은 수준으로 실행될 수 있다. 트랜잭션 단위 설정과 세션 초기화 정책을 명확히 구분해야 한다.

11. 변경 전 점검표

업무 정합성

잠금과 성능

  • 핵심 FOR UPDATE
  • data_locksdata_lock_waits

배포와 운영

  • binlog_format

12. 정리

READ COMMITTEDREPEATABLE READ의 본질적인 차이는 격리 수준 이름이 아니라 Read View의 수명과 잠금 범위다. READ COMMITTED는 문장마다 최신 committed snapshot으로 이동하고 일반 검색의 gap lock을 줄여 동시성에 유리할 수 있다. REPEATABLE READ는 일반 조회가 트랜잭션 snapshot을 재사용하고 범위 locking read에서 next-key locking을 사용해 더 강한 시점·범위 안정성을 제공한다.

어느 한쪽이 모든 시스템에 우월하지는 않다. 업무가 요구하는 일관성 범위, 원자적 갱신 가능성, range lock 경합, 장시간 snapshot, 재시도와 멱등성까지 함께 평가해야 한다. 다음 단계에서는 record lock, gap lock, next-key lock을 실제 인덱스 탐색 경로와 연결해 보면 두 격리 수준의 동시성 차이를 더 정확히 예측할 수 있다.