카테고리 : MySQL/기술노트

복제가 애플리케이션에 미치는 영향: 쓰기 후 읽기 일관성 설계

MySQL 복제 환경에서 쓰기 후 읽기 일관성을 보장하는 writer 라우팅, GTID 대기, 버전 토큰과 Aurora의 적용 범위를 정리한다.

저자: MySQL 기술 노트 작성: 2026.09.22 약 12분 7,069자
다운로드

주문 생성 API가 성공을 반환했는데 주문 상세 화면에서는 “찾을 수 없음”이 표시된다. 사용자는 저장 버튼을 다시 누르고, 애플리케이션은 중복 주문까지 처리해야 한다. 이 문제는 SQL 오류가 아니라 쓰기를 승인한 서버와 후속 읽기가 관측하는 상태가 다른 것에서 시작할 수 있다.

쓰기 후 읽기 일관성(read-after-write consistency)은 자신이 성공시킨 쓰기 또는 전달받은 선행 쓰기보다 오래된 상태를 후속 읽기에 돌려주지 않는 계약이다. 복제 지연을 줄이는 것과 이 계약을 보장하는 것은 다른 작업이다. 이 글은 Community MySQL 8.0 이상에서 계약을 설계하는 방법과 Aurora MySQL에서 달라지는 적용 경계를 다룬다.

1. 커밋 성공과 읽기 가시성 사이의 경로

비동기 binlog 복제에서는 source의 커밋, replica의 이벤트 수신, applier의 적용, 읽기 트랜잭션의 Read View 생성이 서로 다른 시점에 일어난다. source의 커밋 응답은 모든 replica에서 그 변경을 읽을 수 있다는 뜻이 아니다. 반동기 복제도 일반적으로 replica의 수신·기록 확인이지, 모든 후속 읽기의 적용 완료를 보장하는 장치가 아니다.

읽기의 결과를 결정하는 경계는 세 겹으로 나눌 수 있다.

경계 확인해야 할 조건 놓쳤을 때의 증상
복제 적용 선택한 서버에 필요한 트랜잭션이 적용되었는가 새 주문이 없거나 이전 상태가 보임
트랜잭션 가시성 읽기가 변경 이후의 Read View를 사용하는가 적용 완료인데도 오래된 값이 보임
응답 경로 캐시·검색 인덱스·응답 조립 과정도 같은 조건을 지키는가 DB는 최신인데 API 응답은 과거 값

따라서 운영 계약은 “replica lag가 작다”가 아니라 이 요청이 의존하는 변경을, 이 연결과 읽기 시점에서 관측할 수 있다여야 한다. 평균 지연이나 Seconds_Behind_Source = 0만으로 특정 커밋의 가시성을 증명할 수 없다.

flowchart TD
    A[쓰기 트랜잭션 커밋 확인] --> B[응답에 결과와 일관성 문맥 전달]
    B --> C{후속 읽기에 최신성 필요?}
    C -->|아니오| D[일반 reader와 캐시 경로]
    C -->|예| E{검증 가능한 replica 경로?}
    E -->|아니오| F[writer의 새로운 읽기]
    E -->|예| G[대상 연결에서 적용 경계 대기]
    G --> H{요청 기한 안에 성공?}
    H -->|예| I[같은 연결의 새로운 Read View로 읽기]
    H -->|아니오| J[writer 대체 또는 명시적 실패]
    I --> K[응답 버전과 캐시 조건 확인]
    F --> K

“자신의 쓰기 읽기”와 “단조 읽기”도 구별한다. 후자는 한 번 revision 8을 본 사용자가 다른 replica로 이동해 revision 7을 받지 않도록 하는 계약이다. 쓰기 없는 요청에서도 직전에 관측한 버전이나 의존성 문맥을 전파해야 할 수 있다. 어느 계약도 자동으로 모든 사용자의 모든 연산에 대한 선형화 가능성을 제공하지는 않는다.

2. 가장 단순한 해법: 중요한 후속 읽기를 writer로 보낸다

주문 생성 직후 상세 조회, 결제 결과 확인, 권한 변경 검증처럼 오래된 응답의 비용이 큰 경로는 writer에서 읽는 것이 구현과 장애 대응 측면에서 가장 명확하다. 쓰기 응답 자체에 커밋된 객체의 식별자와 revision을 포함하면 불필요한 즉시 재조회도 줄일 수 있다. 단, 커밋 성공 전에 확정 응답을 보내서는 안 된다.

writer 라우팅에도 조건이 있다.

  • 기존 REPEATABLE READ 트랜잭션의 오래된 Read View를 재사용하지 않는다. writer라는 위치만으로 오래된 스냅샷이 갱신되지 않는다.
  • 연결 풀이 읽기 전용 연결과 쓰기 연결을 실제로 구분하는지 확인한다. HTTP 세션 고정과 물리 DB 연결 고정은 다른 개념이다.
  • 장애 전환 뒤에는 기존 연결의 생존 여부와 새 writer의 데이터 이력을 확인한다. 이전 writer에서 성공한 쓰기가 비동기 승격 과정에서 유실되었다면 라우팅만으로 복구되지 않는다.
  • 모든 읽기를 writer로 몰기보다, 일관성이 필요한 업무 경로를 명시한다. 장애 시 replica 대기가 모두 writer 대체로 바뀌면 writer 과부하가 추가될 수 있다.

“쓰기 후 일정 시간 writer 사용”은 유용한 부하 분산 정책이지만, 시간이 지난 뒤 replica의 적용 상태를 확인하지 않는다면 엄격한 보장은 아니다. 고정된 대기 시간이나 최근 지연 백분위는 장기 트랜잭션, worker 잠금 대기, 네트워크 단절을 상한으로 묶지 못한다.

3. GTID를 이용한 인과적 읽기 경계

GTID 기반 Community MySQL 복제에서는 필요한 트랜잭션 집합을 토큰으로 전달하고, 읽을 replica에서 WAIT_FOR_EXECUTED_GTID_SET()으로 실행 완료를 기다릴 수 있다. 대기는 시간을 추정하는 대신 필요한 실행 이력이 포함되었는지 확인한다.

안전한 처리 순서는 다음과 같다.

  1. writer에서 업무 트랜잭션의 커밋 성공을 확인한다.
  2. 그 트랜잭션을 포함하는 GTID 집합을 확보해 후속 요청에 전달한다.
  3. 읽을 물리 연결 하나를 대여하고, 이전 트랜잭션이 남아 있지 않도록 정리한다.
  4. 그 연결에서 요청 기한보다 짧은 제한 시간으로 GTID 적용을 기다린다.
  5. 반환값이 0일 때만 같은 연결에서 새로운 읽기를 수행한다.
  6. 시간 초과·연결 오류·토폴로지 변경은 writer 대체 또는 명시적 실패로 처리한다. 이를 “데이터 없음”으로 변환하지 않는다.

토큰 확보 방식은 커넥터 능력에 따라 달라진다. 서버의 session_track_gtids=OWN_GTID와 세션 추적 정보를 읽는 드라이버 기능을 이용하면 자기 세션이 커밋한 GTID를 전달할 수 있다. 변수 설정만으로 일반 SELECT 결과나 애플리케이션 객체에 GTID가 자동 추가되는 것은 아니다. 실제 드라이버와 연결 프록시가 커밋 응답의 추적 정보를 보존하는지 통합 검증해야 한다.

다른 방법은 커밋 직후 같은 writer에서 @@GLOBAL.gtid_executed를 조회해 전달하는 것이다. 이 집합은 정상적으로 binlog에 기록된 해당 커밋을 포함하지만, 다른 세션의 무관한 커밋도 포함할 수 있다. 따라서 필요한 것보다 오래 기다리거나 토큰이 커질 수 있으며, 중간 failover와 연결 교체를 탐지해야 한다. MAX(GTID) 같은 방식으로 마지막 트랜잭션 하나를 추측해서는 안 된다. GTID는 여러 UUID와 구간을 담는 집합이다.

다음은 GTID 집합 판정만 확인하는 독립 실행 예제다. UUID와 구간은 집합 연산을 설명하기 위한 예시 값이며 실제 복제 서버의 실행 이력을 뜻하지 않는다.

SET @required_gtids = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-5';
SET @behind_gtids = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-4';
SET @ready_gtids = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-7';
SELECT GTID_SUBSET(@required_gtids, @behind_gtids) AS behind_ready,
       GTID_SUBSET(@required_gtids, @ready_gtids) AS ready,
       GTID_SUBTRACT(@required_gtids, @behind_gtids) AS missing_gtids;

실행 결과(MySQL 8.0.x):

mysql> SET @required_gtids = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-5';

Query OK, 0 rows affected (0.00 sec)

mysql> SET @behind_gtids = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-4';

Query OK, 0 rows affected (0.00 sec)

mysql> SET @ready_gtids = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-7';

Query OK, 0 rows affected (0.00 sec)

mysql> SELECT GTID_SUBSET(@required_gtids, @behind_gtids) AS behind_ready,
    ->        GTID_SUBSET(@required_gtids, @ready_gtids) AS ready,
    ->        GTID_SUBTRACT(@required_gtids, @behind_gtids) AS missing_gtids;

+--------------+-------+----------------------------------------+
| behind_ready | ready | missing_gtids                          |
+--------------+-------+----------------------------------------+
|            0 |     1 | aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:5 |
+--------------+-------+----------------------------------------+
1 row in set (0.00 sec)

실제 요청 처리의 구조는 아래와 같다. required_gtids는 바인딩 값이며, 특정 언어나 커넥터의 실행 코드가 아니라 설계용 의사 코드다.

connection = reader_pool.acquire()
try:
    ensure_no_open_transaction(connection)
    result = execute_scalar(connection,
        "SELECT WAIT_FOR_EXECUTED_GTID_SET(?, ?)",
        [required_gtids, remaining_wait_budget_seconds])
    if result != 0:
        return read_from_writer_or_fail_explicitly()
    return read_with_fresh_view(connection)
finally:
    reset_and_release(connection)

WAIT_FOR_EXECUTED_GTID_SET()은 적용 완료 시 0, 시간 초과 시 1을 반환한다. 오류도 별도로 처리한다. 제한 시간 0 또는 인자 생략은 즉시 검사 모드가 아니라 무기한 대기다. 잔여 예산이 0 이하이면 함수를 호출하지 말고 대체 정책을 실행한다. 함수의 대기 제한 외에 연결 획득, 네트워크, 실제 SELECT까지 포함한 전체 요청 기한이 필요하다.

실행 이력과 데이터 일치는 같은 조건이 아니다

GTID 대기는 해당 서버의 실행 집합을 관측한다. 특정 채널의 수신 상태만 확인하는 기능이 아니다. 또한 replication filter로 변경이 제외되었거나 빈 GTID 트랜잭션으로 장애를 건너뛴 서버는 GTID가 포함되어도 업무 데이터가 없을 수 있다. 이 경계는 동일한 업무 데이터를 충실히 복제하며 임의 데이터 변경이 없는 replica라는 운영 전제를 요구한다.

성공한 대기 뒤 연결을 풀에 반환하고 다른 연결에서 읽는 것도 잘못이다. 다른 서버를 받을 수 있으며, 같은 서버라도 이전 트랜잭션을 물려받을 수 있다. 요청에 연결을 고정하는 범위는 “적용 확인부터 실제 읽기 종료까지”다.

4. 적용 완료 후에도 오래된 스냅샷은 남는다

InnoDB의 REPEATABLE READ consistent read는 트랜잭션의 Read View를 유지한다. replica에서 GTID 대기가 성공해도 이미 생성된 Read View를 새것으로 교체하지 않는다. 따라서 순서는 기존 트랜잭션 종료 → 적용 대기 → 새로운 읽기여야 한다. 여러 SELECT가 동일한 스냅샷을 공유해야 한다면 대기 성공 뒤에 새 읽기 트랜잭션과 Read View를 시작한다.

별도 폐기용 MySQL source/replica 두 대에서 applier만 일시 정지시켜 이를 확인했다. 표의 값은 실제 실행 결과이며, 짧은 제한 시간은 실험 목적이다. 운영 대기 시간을 권장하는 값이 아니다.

실험은 먼저 revision 1의 복제를 완료하고 replica의 SQL thread만 멈춘 다음, writer에서 revision 2를 커밋하는 순서로 진행했다. 토큰은 writer에서 커밋 뒤 조회한 전체 GTID 실행 집합을 사용했다. 오래된 스냅샷을 만든 연결과 applier를 재시작한 관리 연결은 서로 분리했다.

실행 순서와 관측 대상 실제 반환값 해석
applier 정지 후 revision 2의 GTID를 0.2초 대기 wait_result = 1 아직 적용되지 않아 시간 초과
같은 replica에서 일반 SELECT before_apply = 1 이전 revision이 남아 있음
별도 reader 연결에서 RR consistent snapshot 시작 후 SELECT snapshot_start = 1 revision 1을 보는 Read View 생성
관리 연결에서 applier 재시작 후 reader 연결에서 GTID 대기 wait_result = 0 revision 2의 트랜잭션 적용 완료
reader의 기존 트랜잭션에서 일반 SELECT old_snapshot = 1 적용 완료와 스냅샷 가시성은 별개
reader에서 COMMIT 후 autocommit SELECT fresh_read = 2 새로운 Read View로 변경 확인
writer에서 revision 3 커밋 후 열린 트랜잭션 없이 GTID 대기 wait_result = 0 다음 의존 트랜잭션 적용 완료
대기에 성공한 같은 연결에서 autocommit SELECT after_barrier = 3 적용 경계와 읽기 시점을 모두 충족

두 서버의 버전은 MySQL 8.0.46이다. 각 단계의 반환값을 자동 단언으로 검사했으며, 실험 후 두 인스턴스를 삭제했다.

이 실험은 두 가지를 분리한다. GTID 대기는 실제로 적용 경계를 통과시킨다. 그러나 오래된 스냅샷에서 읽으면 그 이후에도 이전 revision이 보인다. READ COMMITTED에서는 일반 consistent read의 Read View가 문장마다 생성되지만, 풀 상태와 라우팅 검증이 불필요해지는 것은 아니다.

SELECT ... FOR UPDATE를 reader 신선도 보장의 대체 수단으로 쓰지 않는다. locking read는 적용되지 않은 변경을 가져오는 기능이 아니며, 잠금과 읽기 전용 제약 등 별도의 의미를 갖는다.

5. 업무 버전 토큰으로 결과의 최소 상태를 표현한다

애플리케이션에 GTID를 직접 노출하기 어렵다면 객체의 증가형 revision을 일관성 문맥으로 전달할 수 있다. 쓰기 응답이 order_id=101, revision=2를 반환했다면 후속 읽기는 revision 2 이상을 요구한다. reader 응답이 없거나 더 낮으면 writer에서 다시 확인하거나, 기한 안에서 재시도하고 실패를 명시한다.

다음 SQL은 revision 증가와 오래된 수정 요청의 차단을 검증한다. 빈 실습 DB에서 실행하는 자체 정리 예제이며, 실제 주문 서비스에 그대로 적용하는 스키마는 아니다.

CREATE TABLE consistency_demo (
    id BIGINT PRIMARY KEY,
    status VARCHAR(20) NOT NULL,
    revision BIGINT NOT NULL
) ENGINE=InnoDB;
INSERT INTO consistency_demo VALUES (101, 'pending', 1);
START TRANSACTION;
UPDATE consistency_demo
SET status = 'paid', revision = revision + 1
WHERE id = 101 AND revision = 1;
COMMIT;
SELECT id, status, revision, revision >= 2 AS meets_minimum
FROM consistency_demo WHERE id = 101;
UPDATE consistency_demo
SET status = 'cancelled', revision = revision + 1
WHERE id = 101 AND revision = 1;
SELECT id, status, revision
FROM consistency_demo WHERE id = 101;
DROP TABLE consistency_demo;

실행 결과(MySQL 8.0.x):

mysql> CREATE TABLE consistency_demo (
    ->     id BIGINT PRIMARY KEY,
    ->     status VARCHAR(20) NOT NULL,
    ->     revision BIGINT NOT NULL
    -> ) ENGINE=InnoDB;

Query OK, 0 rows affected (0.01 sec)

mysql> INSERT INTO consistency_demo VALUES (101, 'pending', 1);

Query OK, 1 row affected (0.00 sec)

mysql> START TRANSACTION;

Query OK, 0 rows affected (0.00 sec)

mysql> UPDATE consistency_demo
    -> SET status = 'paid', revision = revision + 1
    -> WHERE id = 101 AND revision = 1;

Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> COMMIT;

Query OK, 0 rows affected (0.00 sec)

mysql> SELECT id, status, revision, revision >= 2 AS meets_minimum
    -> FROM consistency_demo WHERE id = 101;

+-----+--------+----------+---------------+
| id  | status | revision | meets_minimum |
+-----+--------+----------+---------------+
| 101 | paid   |        2 |             1 |
+-----+--------+----------+---------------+
1 row in set (0.00 sec)

mysql> UPDATE consistency_demo
    -> SET status = 'cancelled', revision = revision + 1
    -> WHERE id = 101 AND revision = 1;

Query OK, 0 rows affected (0.00 sec)
Rows matched: 0  Changed: 0  Warnings: 0

mysql> SELECT id, status, revision
    -> FROM consistency_demo WHERE id = 101;

+-----+--------+----------+
| id  | status | revision |
+-----+--------+----------+
| 101 | paid   |        2 |
+-----+--------+----------+
1 row in set (0.00 sec)

mysql> DROP TABLE consistency_demo;

Query OK, 0 rows affected (0.00 sec)

두 번째 UPDATE는 이미 오래된 revision 1을 전제로 하므로 변경 행이 없다. 이는 낙관적 동시성 제어를 검증한 것이며, 그 SQL 자체가 replica의 적용을 기다리거나 읽기 일관성을 보장한 것은 아니다. 애플리케이션은 별도로 최소 revision을 검사하고 대체 경로를 구현해야 한다.

버전 토큰에는 다음 한계가 있다.

  • 행의 revision이 증가해도 다른 테이블이나 검색 결과 집합의 완전성까지 증명하지는 않는다. 주문과 주문 항목처럼 함께 확인할 대상의 범위를 정의해야 한다.
  • 삭제 뒤 행이 없다는 것만으로 삭제 적용 여부를 알 수 없다. tombstone, 별도 변경 이력, GTID 또는 writer 확인이 필요하다.
  • 객체를 삭제·재생성하면서 revision을 초기화하면 과거 토큰과 충돌할 수 있다. 식별자 세대나 별도 버전 공간을 설계한다.
  • revision 2 이상이라는 조건은 정확히 revision 2의 상태를 재현한다는 뜻이 아니다. 후속 합법적 변경으로 revision 3이 반환될 수 있다.

GTID 토큰과 업무 버전 토큰 모두 권한 검사를 대신하지 않는다. 클라이언트가 보낸 임의의 GTID 집합을 무제한으로 기다리게 하면 자원 고갈 경로가 될 수 있다. 토큰의 서버 측 발급·검증, 크기·유효기간·클러스터 범위와 요청별 대기 상한을 함께 관리한다.

6. 캐시·연결 풀·재시도까지 포함해야 한다

DB 읽기를 정확하게 설계해도 그 앞의 캐시가 revision 1을 반환하면 계약은 깨진다. 최신성이 필요한 요청은 캐시를 우회하거나 캐시 값에 revision을 함께 저장하고 최소 버전과 비교해야 한다. 캐시 무효화 실패도 감시 대상이다. replica의 오래된 값을 다시 캐시에 저장하여 신선한 값을 덮어쓰지 않도록 버전 기반 갱신 조건을 검토한다.

다음 실패는 복제와 함께 자주 나타난다.

상황 잘못된 대응 권장 처리
replica 대기 시간 초과 주문이 없다는 404 반환 writer 확인 또는 최신성 확보 실패 응답
커밋 응답 유실 같은 결제를 새 요청으로 재실행 멱등 키로 writer에서 처리 결과 확인
여러 reader 사이 이동 최근 지연만 비교해 임의 선택 요청의 토큰을 대상 연결마다 확인
대체 읽기 급증 모든 요청을 무제한 writer로 전환 동시 실행 제한, 중요도별 정책, 과부하 방어
장애 전환 후 GTID 불충족 토큰을 버리고 정상 응답 데이터 유실·이력 차이와 잘못된 토큰을 구별

토큰은 요청 간 의존성을 전달할 수 있어야 한다. 한 프로세스의 메모리에만 저장하면 다음 HTTP 요청이 다른 인스턴스에 도착할 때 사라진다. 반대로 사용자 전체에 장시간 writer 고정을 적용하면 독립적인 조회까지 불필요하게 집중된다. 업무 객체·요청 체인별로 필요한 최소 범위를 정한다.

7. Aurora MySQL의 적용 범위

Aurora의 같은 리전 reader는 Community MySQL의 binlog replica와 같은 복제 경로가 아니다. 같은 클러스터의 writer와 reader가 스토리지를 공유한다는 사실만으로 모든 reader의 모든 SELECT가 즉시 최신 상태를 보장하는 것은 아니다. Community용 GTID 대기를 Aurora 내부 reader 동기화의 일반 해법으로 옮겨서는 안 된다.

기본적으로 중요한 후속 읽기는 writer endpoint에서 새 읽기로 처리하는 설계가 명확하다. reader endpoint는 쿼리 단위가 아니라 연결 단위로 연결을 분산한다. 연결 풀에 남은 세션은 매 SELECT마다 다른 reader로 재분배되지 않는다. reader가 없는 클러스터에서는 reader endpoint가 writer에 연결될 수 있으므로 endpoint 이름을 접근 권한 경계로 사용하지 않는다.

write forwarding을 사용하는 지원 구성에서는 aurora_replica_read_consistency의 의미를 별도로 검토한다.

  • EVENTUAL: 변경의 가시성을 기다리지 않으므로 이전 값이 보일 수 있다.
  • SESSION: 같은 세션에서 write forwarding으로 수행한 변경의 가시성을 확보하도록 기다린다. 다른 writer 연결에서 수행한 임의 쓰기를 자동으로 추적한다는 뜻이 아니다.
  • GLOBAL: 쿼리 시작 시점까지 writer에서 커밋된 변경이 반영되도록 기다리는 범위를 제공한다. 이에 따른 읽기 지연과 지원 구성의 제약을 고려한다.

이 변수는 write forwarding 활성화와 엔진 버전·지원 문장·트랜잭션 제약을 전제로 한다. 일반 reader 연결에 설정만 추가하면 모든 읽기/쓰기 분리 구성이 같은 보장을 얻는다고 해석하지 않는다. Global Database의 리전 간 forwarding과 같은 리전 local forwarding도 구성을 구별해 확인해야 한다.

검증 범위: 이 글의 SQL 예제와 복제·스냅샷 실험은 Community MySQL 8.0에서 실행했다. Aurora의 endpoint, write forwarding, 실제 장애 전환, 애플리케이션 드라이버의 GTID 추적은 실행 실험하지 않았으며 아래 공식 문서를 근거로 적용 조건을 설명했다.

8. 도입 전 점검표

결론

쓰기 후 읽기 일관성은 복제 서버의 상태 하나로 결정되지 않는다. 필요한 변경을 표현하는 토큰, 그 변경을 확인한 연결, 새로운 Read View, 캐시와 응답 정책이 같은 계약을 따라야 한다. 우선 중요한 경로를 writer에서 읽도록 단순화하고, 실제로 읽기 확장이 필요한 경로에 검증 가능한 replica 대기를 추가하는 편이 안전하다.

다음 운영 과제는 일관성의 대가를 측정하는 것이다. 대기 지연과 writer 대체율을 서비스 응답 기한·장애 전환 정책과 연결해야 복제 확장이 사용자 관점의 안정성으로 이어진다.

참고 문서