카테고리 : MySQL/기술노트

Isolation level 선택 기준: 정합성, 동시성, Aurora/RDS 운영 관점

MySQL 트랜잭션 격리 수준의 가시성과 잠금 차이를 이해하고 워크로드별 선택 기준을 운영 관점에서 정리한다.

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

1. 격리 수준은 정합성과 동시성의 경계를 정한다

트랜잭션 격리 수준은 단순히 “강할수록 안전한 옵션”이 아니다. 같은 트랜잭션에서 어떤 버전의 행을 볼지, 일반 SELECT가 잠금을 획득할지, 범위 검색이 동시 삽입을 막을지, 충돌을 애플리케이션이 어떤 방식으로 처리해야 할지를 함께 결정하는 실행 정책이다.

격리 수준을 너무 낮추면 한 업무 단위 안에서 읽은 값이 바뀌어 판단의 전제가 흔들릴 수 있다. 반대로 필요 이상으로 높이면 잠금 대기와 deadlock, 처리량 저하가 증가할 수 있다. 특히 SERIALIZABLE은 이름만 보고 선택하면 평범한 조회가 잠금 경합을 만드는 결과를 낳을 수 있다.

운영에서 먼저 답해야 할 질문은 다음과 같다.

  1. 한 트랜잭션 안의 여러 조회가 반드시 동일한 snapshot을 보아야 하는가?
  2. 조회한 조건 범위에 다른 트랜잭션이 행을 삽입하는 것을 막아야 하는가?
  3. 최신 커밋값이 필요하다면 일반 consistent read로 충분한가, locking read가 필요한가?
  4. 충돌 시 대기·실패·재시도를 감당할 수 있는가?
  5. Aurora reader 또는 RDS read replica의 복제 지연 문제를 트랜잭션 격리 문제와 혼동하고 있지 않은가?

이 글은 InnoDB의 네 격리 수준을 MVCC 가시성, 잠금, 애플리케이션 불변식, 관리형 서비스 운영이라는 네 축으로 비교한다.

2. InnoDB가 읽기를 처리하는 두 경로

격리 수준을 이해하려면 먼저 consistent readlocking/current read를 구분해야 한다.

  • 일반 SELECT는 보통 MVCC와 Read View를 이용하는 consistent read다.
  • SELECT ... FOR SHARE, SELECT ... FOR UPDATE, UPDATE, DELETE는 최신 버전을 확인하면서 필요한 잠금을 획득하는 locking/current read다.
  • SERIALIZABLE에서는 명시적 트랜잭션의 일반 SELECT도 잠금 읽기로 바뀔 수 있다.
flowchart TD
    A[트랜잭션의 읽기 문장] --> B{읽기 종류}
    B -->|일반 SELECT| C{Isolation level}
    C -->|READ UNCOMMITTED| D[커밋되지 않은 버전도 읽을 수 있음]
    C -->|READ COMMITTED| E[문장마다 새 Read View]
    C -->|REPEATABLE READ| F[트랜잭션 snapshot 재사용]
    C -->|SERIALIZABLE| G[공유 잠금 읽기로 직렬화 강화]
    B -->|FOR SHARE / FOR UPDATE| H[최신 버전 확인과 InnoDB 잠금]
    H --> I[record 또는 range 잠금]
    I --> J[충돌 시 대기·timeout·deadlock 가능]

READ COMMITTEDREPEATABLE READ의 가장 중요한 차이는 일반 읽기에 사용할 Read View의 수명이다. READ COMMITTED는 각 consistent read 문장마다 새 Read View를 만들므로, 다음 문장은 그 사이 커밋된 변경을 볼 수 있다. REPEATABLE READ는 첫 consistent read가 만든 snapshot을 같은 트랜잭션의 후속 consistent read가 재사용하므로 반복 조회 결과가 안정적이다.

그러나 REPEATABLE READ라고 해서 트랜잭션의 모든 문장이 과거 snapshot만 읽는 것은 아니다. UPDATE와 locking read는 현재 커밋 상태를 기준으로 동작한다. 따라서 같은 트랜잭션에서도 일반 SELECTSELECT ... FOR UPDATE가 서로 다른 버전을 볼 수 있다. 격리 수준 이름만으로 SQL의 실제 읽기 경로를 추측해서는 안 된다.

3. 네 가지 격리 수준의 의미

3.1 READ UNCOMMITTED

READ UNCOMMITTED는 다른 트랜잭션이 아직 커밋하지 않은 변경을 읽을 수 있다. 읽은 직후 상대 트랜잭션이 롤백하면 애플리케이션은 실제로 존재한 적 없는 값을 근거로 판단한 셈이 된다.

이 수준은 잠금을 줄이기 위한 일반적인 성능 해법이 아니다. InnoDB의 일반 consistent read는 원래 읽기와 쓰기의 충돌을 MVCC로 상당 부분 줄인다. dirty read가 허용되어도 쓰기-쓰기 충돌, 외래 키 검사, unique key 검사와 같은 잠금이 없어지는 것도 아니다. 정합성이 중요하지 않은 일회성 관측조차 Performance Schema나 검증된 집계 경로로 대체할 수 있는 경우가 많으므로, 업무 트랜잭션의 기본값으로는 거의 선택하지 않는다.

3.2 READ COMMITTED

READ COMMITTED에서는 각 문장이 실행을 시작할 때까지 커밋된 데이터를 본다. 같은 트랜잭션의 두 일반 SELECT 사이에 다른 트랜잭션이 커밋하면 두 번째 조회 결과가 달라질 수 있다.

장점은 오래된 transaction snapshot을 유지할 필요가 줄고, locking read와 변경 문장에서 불필요한 gap lock 범위가 REPEATABLE READ보다 작아질 수 있다는 점이다. 동시 쓰기가 많고 각 문장이 최신 커밋 상태를 보는 편이 자연스러운 OLTP에서는 유력한 선택이다.

다만 다음 사항을 오해하면 안 된다.

  • “항상 최신값”은 문장 시작 시점의 커밋 상태를 뜻한다. 실행 중인 긴 문장이 중간 커밋을 계속 반영한다는 뜻이 아니다.
  • gap lock이 완전히 사라지는 것은 아니다. 외래 키와 duplicate-key 검사 같은 정합성 경로에는 gap 관련 잠금이 남을 수 있다.
  • 여러 문장의 결과가 하나의 일관된 snapshot이어야 하는 보고서에는 적합하지 않을 수 있다.
  • 읽은 값에 근거해 쓸 때 발생하는 경쟁 조건은 FOR UPDATE, 조건부 UPDATE, unique constraint 같은 별도 설계로 막아야 한다.

3.3 REPEATABLE READ

REPEATABLE READ는 InnoDB의 기본 격리 수준이다. 같은 트랜잭션에서 일반 consistent read가 하나의 snapshot을 재사용하므로, 여러 조회가 동일한 논리 시점을 기준으로 계산되어야 할 때 유리하다.

또한 locking read의 범위 조건에서는 next-key lock을 이용해 검색 범위에 대한 동시 삽입을 제한할 수 있다. 이 특성은 범위의 존재 여부를 확인한 뒤 후속 변경을 수행하는 업무 규칙에 도움이 될 수 있지만, 넓은 범위나 부적절한 인덱스에서는 예상보다 많은 잠금과 대기를 만들 수 있다.

REPEATABLE READ도 애플리케이션의 모든 불변식을 자동으로 보장하지 않는다. 두 트랜잭션이 서로 다른 행을 읽고 각각 다른 행을 갱신하는 write skew 유형의 규칙은 일반 snapshot read만으로 보호되지 않을 수 있다. 보호할 업무 객체를 명시적으로 잠그거나, 불변식을 unique/check 제약과 원자적 조건부 변경으로 표현해야 한다.

3.4 SERIALIZABLE

SERIALIZABLE은 동시 트랜잭션의 결과를 어떤 직렬 실행 순서와 동등하게 만들도록 충돌을 더 적극적으로 잠금으로 바꾼다. InnoDB에서는 autocommit=0인 트랜잭션의 일반 SELECT가 사실상 FOR SHARE와 같은 잠금 읽기로 동작할 수 있다.

그 결과 읽기 트랜잭션도 writer를 대기시키고, 넓은 범위 조회는 삽입 가능 구간을 잠글 수 있다. 짧고 트래픽이 제한된 정합성 작업, 명확한 잠금 충돌을 감수하는 관리 작업에는 사용할 수 있지만, 읽기 비율이 높은 서비스의 전역 기본값으로 적용하면 처리량과 tail latency가 크게 악화될 수 있다.

SERIALIZABLE을 사용하더라도 deadlock과 timeout 처리는 필요하다. 직렬성이 충돌을 없애는 것이 아니라 충돌을 대기 또는 실패로 드러내는 경우가 많기 때문이다.

4. 기본값과 세션값을 먼저 확인한다

격리 수준은 전역 기본값과 현재 세션값을 구분해서 확인해야 한다. connection pool의 기존 연결은 DB parameter group이나 전역값을 변경해도 이미 가진 세션 상태를 계속 사용할 수 있다.

다음 예제는 MySQL 버전, 전역·세션 격리 수준과 autocommit을 확인하고, 세션 수준 변경이 현재 연결에 반영되는 것을 검증한다.

SELECT VERSION() AS mysql_version,
       @@GLOBAL.transaction_isolation AS global_isolation,
       @@SESSION.transaction_isolation AS session_isolation,
       @@SESSION.autocommit AS session_autocommit;

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT @@SESSION.transaction_isolation AS changed_session_isolation;

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT @@SESSION.transaction_isolation AS restored_session_isolation;

실행 결과(MySQL 8.0.x):

mysql> SELECT VERSION() AS mysql_version,
    ->        @@GLOBAL.transaction_isolation AS global_isolation,
    ->        @@SESSION.transaction_isolation AS session_isolation,
    ->        @@SESSION.autocommit AS session_autocommit;

+---------------+------------------+-------------------+--------------------+
| mysql_version | global_isolation | session_isolation | session_autocommit |
+---------------+------------------+-------------------+--------------------+
| 8.0.46        | REPEATABLE-READ  | REPEATABLE-READ   |                  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 @@SESSION.transaction_isolation AS changed_session_isolation;

+---------------------------+
| changed_session_isolation |
+---------------------------+
| READ-COMMITTED            |
+---------------------------+
1 row in set (0.00 sec)

mysql> SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

Query OK, 0 rows affected (0.00 sec)

mysql> SELECT @@SESSION.transaction_isolation AS restored_session_isolation;

+----------------------------+
| restored_session_isolation |
+----------------------------+
| REPEATABLE-READ            |
+----------------------------+
1 row in set (0.00 sec)

전역 기본값을 변경하는 작업은 연결별 업무 요구를 검토한 뒤 수행해야 한다. 애플리케이션이 연결을 빌릴 때 격리 수준을 명시한다면 pool 반환 시 원복 여부도 확인한다. 한 요청이 READ COMMITTED로 바꾼 연결을 다음 요청이 REPEATABLE READ라고 가정하고 재사용하는 상태 누수는 재현하기 어려운 정합성 장애를 만든다.

5. READ COMMITTED와 REPEATABLE READ의 가시성 비교

다음 예제는 전용 MySQL 8.0 검증 인스턴스에서 Event Scheduler를 별도 서버 세션으로 사용한다. 주 세션이 첫 값을 읽은 뒤 이벤트 세션이 값을 100에서 130으로 변경한다. REPEATABLE READ의 두 번째 일반 SELECT는 기존 snapshot의 100을 유지하지만, READ COMMITTED의 두 번째 문장은 새로 커밋된 130을 본다.

Event Scheduler는 단일 검증 컨테이너에서 동시 세션을 만들기 위한 장치다. 운영 서버에서 예제 실행을 목적으로 event_scheduler를 활성화하지 않는다.

SET GLOBAL event_scheduler = ON;
DROP EVENT IF EXISTS ev_rr_update;
DROP EVENT IF EXISTS ev_rc_update;
DROP TABLE IF EXISTS isolation_visibility_demo;

CREATE TABLE isolation_visibility_demo (
    id BIGINT PRIMARY KEY,
    balance INT NOT NULL
) ENGINE=InnoDB;

INSERT INTO isolation_visibility_demo VALUES (1, 100);

CREATE EVENT ev_rr_update
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 2 SECOND
DO UPDATE isolation_visibility_demo
      SET balance = 130
    WHERE id = 1;

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance AS rr_first_read
FROM isolation_visibility_demo WHERE id = 1;
DO SLEEP(4);
SELECT balance AS rr_second_read
FROM isolation_visibility_demo WHERE id = 1;
COMMIT;

UPDATE isolation_visibility_demo SET balance = 100 WHERE id = 1;

CREATE EVENT ev_rc_update
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 2 SECOND
DO UPDATE isolation_visibility_demo
      SET balance = 130
    WHERE id = 1;

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance AS rc_first_read
FROM isolation_visibility_demo WHERE id = 1;
DO SLEEP(4);
SELECT balance AS rc_second_read
FROM isolation_visibility_demo WHERE id = 1;
COMMIT;

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
DROP EVENT IF EXISTS ev_rr_update;
DROP EVENT IF EXISTS ev_rc_update;
DROP TABLE isolation_visibility_demo;

실행 결과(MySQL 8.0.x):

준비·정리 문장과 대기용 SLEEP의 반복 출력은 줄이고, 두 격리 수준의 핵심 조회 결과를 발췌했다.

mysql> CREATE TABLE isolation_visibility_demo (...);

Query OK, 0 rows affected (0.01 sec)

mysql> INSERT INTO isolation_visibility_demo VALUES (1, 100);

Query OK, 1 row affected (0.00 sec)

mysql> SELECT balance AS rr_first_read
    -> FROM isolation_visibility_demo WHERE id = 1;

+---------------+
| rr_first_read |
+---------------+
|           100 |
+---------------+
1 row in set (0.00 sec)

mysql> SELECT balance AS rr_second_read
    -> FROM isolation_visibility_demo WHERE id = 1;

+----------------+
| rr_second_read |
+----------------+
|            100 |
+----------------+
1 row in set (0.00 sec)

mysql> SELECT balance AS rc_first_read
    -> FROM isolation_visibility_demo WHERE id = 1;

+---------------+
| rc_first_read |
+---------------+
|           100 |
+---------------+
1 row in set (0.00 sec)

mysql> SELECT balance AS rc_second_read
    -> FROM isolation_visibility_demo WHERE id = 1;

+----------------+
| rc_second_read |
+----------------+
|            130 |
+----------------+
1 row in set (0.00 sec)

이 차이는 어느 수준이 무조건 우수하다는 뜻이 아니다. 한 트랜잭션에서 기준 시점이 고정되어야 하는 정산 계산은 REPEATABLE READ가 자연스럽다. 반면 작업 큐가 각 문장마다 방금 커밋된 상태를 반영해야 하고 명시적 locking read로 소유권을 획득한다면 READ COMMITTED가 더 단순할 수 있다.

6. SERIALIZABLE에서 일반 SELECT가 writer를 막는 경로

다음 재현에서는 SERIALIZABLE 트랜잭션이 기본 키 한 행을 일반 SELECT로 읽는다. 이벤트 세션이 같은 행을 갱신하려고 하면 X,REC_NOT_GAP 잠금을 요청한 채, 읽기 트랜잭션이 보유한 S,REC_NOT_GAP 잠금을 기다린다.

SET GLOBAL event_scheduler = ON;
DROP EVENT IF EXISTS ev_serializable_writer;
DROP TABLE IF EXISTS serializable_lock_demo;

CREATE TABLE serializable_lock_demo (
    id BIGINT PRIMARY KEY,
    payload VARCHAR(30) NOT NULL
) ENGINE=InnoDB;

INSERT INTO serializable_lock_demo VALUES (1, 'before');

DELIMITER //
CREATE EVENT ev_serializable_writer
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 2 SECOND
DO
BEGIN
    SET SESSION innodb_lock_wait_timeout = 10;
    UPDATE serializable_lock_demo
       SET payload = 'after'
     WHERE id = 1;
END//
DELIMITER ;

SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
SELECT id, payload
FROM serializable_lock_demo
WHERE id = 1;
DO SLEEP(4);

SELECT req.OBJECT_NAME,
       req.INDEX_NAME,
       req.LOCK_MODE AS waiting_lock_mode,
       req.LOCK_STATUS AS waiting_status,
       blk.LOCK_MODE AS blocking_lock_mode,
       blk.LOCK_STATUS AS blocking_status
FROM performance_schema.data_lock_waits AS w
JOIN performance_schema.data_locks AS req
  ON req.ENGINE_LOCK_ID = w.REQUESTING_ENGINE_LOCK_ID
 AND req.ENGINE = w.ENGINE
JOIN performance_schema.data_locks AS blk
  ON blk.ENGINE_LOCK_ID = w.BLOCKING_ENGINE_LOCK_ID
 AND blk.ENGINE = w.ENGINE
WHERE req.OBJECT_SCHEMA = DATABASE()
  AND req.OBJECT_NAME = 'serializable_lock_demo';

COMMIT;
DO SLEEP(2);
SELECT id, payload FROM serializable_lock_demo;

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
DROP EVENT IF EXISTS ev_serializable_writer;
DROP TABLE serializable_lock_demo;

실행 결과(MySQL 8.0.x):

이벤트 생성과 대기용 문장의 반복 출력은 줄이고, 최초 읽기, 실제 잠금 대기 관계, 커밋 뒤 변경값을 발췌했다.

mysql> CREATE TABLE serializable_lock_demo (...);

Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO serializable_lock_demo VALUES (1, 'before');

Query OK, 1 row affected (0.00 sec)

mysql> SELECT id, payload
    -> FROM serializable_lock_demo
    -> WHERE id = 1;

+----+---------+
| id | payload |
+----+---------+
|  1 | before  |
+----+---------+
1 row in set (0.00 sec)

mysql> SELECT req.OBJECT_NAME,
    ->        req.INDEX_NAME,
    ->        req.LOCK_MODE AS waiting_lock_mode,
    ->        req.LOCK_STATUS AS waiting_status,
    ->        blk.LOCK_MODE AS blocking_lock_mode,
    ->        blk.LOCK_STATUS AS blocking_status
    -> FROM performance_schema.data_lock_waits AS w
    -> ...
    -> WHERE req.OBJECT_NAME = 'serializable_lock_demo';

+------------------------+------------+-------------------+----------------+--------------------+-----------------+
| OBJECT_NAME            | INDEX_NAME | waiting_lock_mode | waiting_status | blocking_lock_mode | blocking_status |
+------------------------+------------+-------------------+----------------+--------------------+-----------------+
| serializable_lock_demo | PRIMARY    | X,REC_NOT_GAP     | WAITING        | S,REC_NOT_GAP      | GRANTED         |
+------------------------+------------+-------------------+----------------+--------------------+-----------------+
1 row in set (0.01 sec)

mysql> SELECT id, payload FROM serializable_lock_demo;

+----+---------+
| id | payload |
+----+---------+
|  1 | after   |
+----+---------+
1 row in set (0.00 sec)

이 결과는 SERIALIZABLE의 비용이 추상적인 이론이 아니라 실제 writer 대기로 나타난다는 점을 보여 준다. 범위 조회라면 잠금 범위가 더 넓어질 수 있으며, access path와 인덱스 구성에 따라 관찰되는 세부 lock mode도 달라질 수 있다. 운영에서는 고정된 lock 문자열보다 요청자·차단자 관계, 대상 table/index, 트랜잭션 수명과 조회 범위를 함께 해석한다.

7. 워크로드별 선택 기준

워크로드 또는 요구 우선 검토할 수준 이유와 보완책
짧은 OLTP, 최신 커밋 상태를 문장마다 반영 READ COMMITTED snapshot 수명이 짧고 일부 gap locking을 줄일 수 있다. 업무 객체 획득에는 FOR UPDATE, 조건부 UPDATE, unique constraint를 사용한다.
여러 조회가 동일 기준 시점이어야 하는 계산·보고 REPEATABLE READ 트랜잭션 snapshot이 안정적이다. 긴 snapshot이 undo purge와 이력 보존에 주는 영향을 제한한다.
존재하지 않는 범위까지 보호한 뒤 후속 변경 REPEATABLE READ + 적절한 locking read next-key lock을 의도적으로 사용하되, 인덱스로 잠금 범위를 좁힌다.
짧고 통제된 직렬 실행 요구 SERIALIZABLE 읽기도 writer를 막을 수 있다. 별도 연결·짧은 트랜잭션·timeout·재시도 정책을 둔다.
정합성이 필요 없는 것처럼 보이는 임시 관측 보통 READ COMMITTED 또는 관측 전용 도구 READ UNCOMMITTED의 dirty read가 실제로 허용 가능한지 먼저 검증한다.

7.1 기본값보다 업무 불변식에서 출발한다

격리 수준을 선택할 때 anomaly 이름만 나열하는 방식은 부족하다. 업무 불변식을 SQL 실행 경로로 바꾸어 검토해야 한다.

예를 들어 “재고가 음수가 되면 안 된다”는 요구는 다음처럼 원자적 조건부 변경으로 표현할 수 있다.

UPDATE inventory
   SET quantity = quantity - :requested
 WHERE item_id = :item_id
   AND quantity >= :requested;

-- affected rows가 1이면 확보 성공, 0이면 재고 부족 또는 경쟁에서 패배

이 구조는 먼저 수량을 읽고 나중에 무조건 갱신하는 read-modify-write보다 경쟁 조건을 줄인다. 실제 테이블명과 bind placeholder가 필요한 설계 예시이므로 실행 검증용 SQL fence가 아니라 구조 예시로 제시했다.

7.2 긴 트랜잭션을 격리 수준으로 정당화하지 않는다

안정된 snapshot이 필요하다는 이유로 수십 분 동안 트랜잭션을 열어 두면 undo history가 길어지고 purge가 지연되며, connection pool과 failover 복구에도 부담을 준다. 큰 보고서는 read replica, 데이터 웨어하우스, 검증된 export snapshot, 배치용 별도 자원 같은 아키텍처 선택과 함께 검토해야 한다.

7.3 locking read는 인덱스와 함께 설계한다

FOR UPDATE가 잠그는 범위는 SQL의 의도만이 아니라 optimizer가 선택한 access path의 영향을 받는다. 조건 인덱스가 없으면 많은 레코드를 검사하고 잠글 수 있다. 격리 수준 변경 전후에는 EXPLAIN, 실제 lock wait, transaction duration, deadlock 비율을 함께 비교해야 한다.

8. Aurora MySQL과 RDS MySQL 운영 관점

Aurora MySQL과 RDS for MySQL도 호환 엔진 버전에서 InnoDB 격리 수준의 기본 의미를 따른다. 그러나 관리형 서비스에서는 격리 수준, 복제 가시성, failover를 서로 다른 문제로 분리해야 한다.

8.1 writer의 트랜잭션 정합성과 reader의 최신성은 다르다

writer 인스턴스에서 REPEATABLE READ를 사용한다고 해서 reader endpoint가 writer의 최신 커밋을 즉시 반환하는 것은 아니다. reader는 복제 적용 시점과 endpoint 라우팅의 영향을 받는다. “방금 쓴 값을 반드시 읽어야 한다”면 격리 수준만 바꾸지 말고, 해당 요청의 writer routing 또는 애플리케이션 read-after-write 정책을 검토해야 한다.

반대로 reader에서 오래된 값이 보였다는 이유만으로 writer 트랜잭션의 isolation anomaly라고 결론 내리면 안 된다. 먼저 접속한 endpoint, 인스턴스 역할, replica lag와 세션의 transaction snapshot 수명을 확인한다.

8.2 parameter group과 connection pool을 함께 확인한다

DB parameter group에서 기본 격리 수준을 관리하더라도 실제 적용 범위와 적용 시점은 엔진 버전과 파라미터 속성을 확인해야 한다. 동적 변경 가능 여부, 새 연결에만 반영되는지, 재부팅이 필요한지를 콘솔 표시와 엔진 메타데이터로 검증한다.

애플리케이션이 세션 시작 시 SET SESSION TRANSACTION ISOLATION LEVEL ...을 실행한다면 parameter group보다 애플리케이션 초기화 코드가 실효값을 결정할 수 있다. 운영 점검은 설정 저장소가 아니라 실제 pool 연결에서 @@SESSION.transaction_isolation을 수집하는 방식으로 수행한다.

8.3 failover와 재시도는 별도 설계다

격리 수준은 연결 단절이나 커밋 응답 불확실성을 해결하지 않는다. Aurora writer failover 또는 RDS 장애 조치 중 연결이 끊기면 애플리케이션은 새 writer에 재연결해야 하며, COMMIT 성공 여부가 불확실한 요청에는 멱등성 키와 중복 방지 제약이 필요하다.

잠금 timeout과 deadlock도 계속 발생할 수 있다. 관리형 스토리지 구조가 동일 row 또는 range의 논리적 잠금 경합을 제거하지는 않는다. Database Insights/Performance Insights의 wait 차원, 애플리케이션 오류 1205·1213, transaction latency를 같은 시간축으로 관찰해야 한다.

9. 흔한 오해와 실패 패턴

9.1 REPEATABLE READ면 모든 읽기가 같은 버전이다

일반 consistent read는 snapshot을 재사용하지만 UPDATE와 locking read는 현재 버전을 확인한다. 두 경로를 섞으면 같은 트랜잭션에서도 가시성이 달라질 수 있다.

9.2 READ COMMITTED면 잠금 문제가 사라진다

쓰기-쓰기 충돌, record lock, 외래 키·unique 검사 잠금은 남는다. 범위 잠금이 줄 수는 있어도 deadlock과 timeout 대응은 계속 필요하다.

9.3 SERIALIZABLE이면 애플리케이션 재시도가 필요 없다

오히려 충돌이 잠금 대기와 deadlock으로 더 자주 드러날 수 있다. 트랜잭션 전체 ROLLBACK, 제한된 exponential backoff와 jitter, 멱등성 설계가 필요하다.

9.4 격리 수준을 낮추면 느린 쿼리가 해결된다

부적절한 인덱스, 과도한 스캔, 긴 트랜잭션, hot key가 원인이라면 isolation 변경은 증상만 바꾸거나 정합성을 훼손할 수 있다. 실행 계획과 잠금 범위를 먼저 측정한다.

9.5 Aurora reader의 오래된 값은 isolation level 문제다

reader endpoint의 replica 적용 지연과 writer 트랜잭션의 MVCC 가시성은 별개다. 접속 대상과 복제 상태를 확인하지 않고 writer의 격리 수준을 변경하면 원인을 해결하지 못한다.

9.6 전역값을 바꾸면 기존 연결도 즉시 바뀐다

기존 connection pool 세션은 이전 상태를 유지할 수 있다. 배포 후 새 연결과 재사용 연결 모두에서 세션값을 확인하고, pool 초기화·반환 시 상태 초기화 정책을 검증한다.

10. 변경 전 검증 절차

격리 수준 변경은 기능 플래그처럼 단계적으로 수행해야 한다.

  1. 업무 트랜잭션별 불변식과 허용할 수 없는 anomaly를 문서화한다.
  2. 일반 read, locking read, 변경 문장을 구분해 실제 SQL 흐름을 그린다.
  3. 재현 데이터와 두 개 이상의 동시 세션으로 가시성·잠금·실패를 시험한다.
  4. 조건 인덱스와 EXPLAIN으로 lock 대상 범위를 검토한다.
  5. canary 인스턴스 또는 제한된 애플리케이션 연결에 세션 수준으로 적용한다.
  6. transaction latency, lock wait, deadlock, 오류 1205·1213, undo history 증가를 비교한다.
  7. connection pool의 세션 상태 초기화와 rollback 동작을 검증한다.
  8. Aurora/RDS에서는 writer·reader routing과 failover 재시도를 별도 시험한다.
  9. 문제가 생기면 세션 기본값을 원복할 수 있는 절차와 배포 기준을 준비한다.

11. 운영 체크리스트

정합성 요구

  • read-modify-write를 조건부 UPDATE

동시성과 잠금

  • FOR SHARE·FOR UPDATE
  • SERIALIZABLE

설정과 배포

Aurora/RDS

12. 결론

Isolation level 선택의 출발점은 제품 기본값이나 “가장 강한 수준”이 아니라 업무 불변식과 SQL의 실제 읽기 경로다. READ COMMITTED는 문장마다 최신 커밋 snapshot을 사용해 동시성을 높일 수 있고, REPEATABLE READ는 여러 consistent read의 기준 시점을 안정시킨다. SERIALIZABLE은 읽기까지 잠금 충돌로 전환해 직렬성을 강화하지만 운영 비용이 크며, READ UNCOMMITTED는 dirty read를 허용할 명확한 근거가 있을 때만 검토해야 한다.

격리 수준만으로 정합성 설계가 완성되지는 않는다. 적절한 인덱스, locking read, 원자적 조건부 변경, unique constraint, 짧은 트랜잭션, 전체 트랜잭션 재시도와 멱등성 정책이 함께 있어야 한다. Aurora MySQL과 RDS MySQL에서는 여기에 endpoint routing, replica lag, parameter group, failover까지 별도 축으로 검증해야 한다.

다음 기술노트에서는 isolation level 변경을 실제 서비스에 적용할 때 어떤 지표와 동시성 시나리오로 회귀 시험을 구성해야 하는지 더 구체적으로 다룬다.