---
title: "Failover 후 데이터 정합성 검증: GTID, checksum, 업무 불변조건"
description: "MySQL Failover 이후 GTID 이력, 데이터 checksum, 업무 불변조건을 구분하여 확인하고 서비스 재개와 불일치 대응 기준을 정리한다."
tags: [ MySQL, 고가용성, 복제, 운영 ]
image: "mysql-report-bg.png"
published: "2026-11-04"
updated: "2026-11-04"
author: "MySQL 기술 노트"
source_url: ""
---

장애 조치(Failover) 후 새 writer에 접속하여 조회가 성공했다고 해서 복구가 끝난 것은 아니다. 이전 writer에서 성공 응답을 받은 거래가 남아 있는지, 복제 과정에서 일부 변경이 제외되었는지, 응답을 받지 못한 요청을 재시도하면서 중복 결제가 발생했는지는 별도의 문제다.

데이터 정합성 검증은 **실행 이력, 저장된 내용, 업무 의미**를 서로 다른 증거로 확인하는 작업이다. GTID가 같아도 데이터가 다를 수 있고, 데이터가 같아도 두 서버가 같은 잘못된 상태를 보유할 수 있다. 이 글에서는 세 검증을 결합하되, 관측하지 못한 항목을 정상으로 취급하지 않는 운영 절차를 설명한다.

> 적용 범위: Community MySQL 8.0 이상을 기본으로 설명한다. 아래 SQL은 폐기용 MySQL에서 실행한 축소 실험이다. GTID 예제는 가정한 집합에 대한 함수 검증이고, 데이터 예제는 한 서버의 두 테이블 비교다. 실제 서버 승격, 복제 장애, 네트워크 단절 또는 Aurora Failover를 재현한 결과는 아니다.

## 1. 세 가지 질문을 분리한다

### 실행 이력: 필요한 트랜잭션을 적용했다고 기록되어 있는가

GTID는 트랜잭션의 식별자다. `gtid_executed`는 서버가 실행한 GTID 집합을 나타내지만, 각 트랜잭션이 현재 어떤 행 내용으로 남아 있는지까지 표현하지는 않는다. 뒤의 정상 거래가 앞의 거래 결과를 수정하거나 삭제했을 수도 있다.

복제 필터로 업무 변경이 제외된 경우, 빈 GTID 트랜잭션으로 오류를 건너뛴 경우, binary log에 기록되지 않는 로컬 변경이 있었던 경우에는 실행 이력만으로 데이터 동등성을 판단할 수 없다. GTID 검사는 필요한 이력의 포함 여부를 판정하는 것이지, 행 내용의 인증서가 아니다.

### 저장 내용: 같은 기준점의 데이터가 같은가

행 수, 기본 키 범위, 금액 합계, checksum, 행별 비교는 데이터 내용에 관한 증거다. 행 수와 합계는 빠른 이상 탐지에 유용하지만 서로 상쇄되는 변경을 놓칠 수 있다. checksum은 비교 범위를 요약하지만 충돌 가능성이 있으며, 불일치가 생긴 행과 그 원인까지 알려주지는 않는다.

### 업무 의미: 이 상태가 허용되는가

업무 불변조건(application invariant)은 서비스가 항상 유지해야 하는 관계다. 예를 들어 환불·부분 결제를 허용하지 않는 단순 모델에서는 결제 완료 주문의 주문액과 결제액이 같아야 한다. 실제 서비스라면 통화별 원장 합계, 예약 수량과 재고, 요청 식별자의 유일성, 주문과 결제 승인 상태의 관계를 사용한다.

모든 복제본에 같은 애플리케이션 오류가 반영되면 GTID와 checksum은 모두 정상일 수 있다. 따라서 업무 불변조건은 비교 상대가 없더라도 검사할 수 있는 독립적인 검증 축이어야 한다.

## 2. 먼저 비교 기준점을 고정한다

계획 전환에서는 업무 쓰기를 정지하거나 유입을 제어하고, 이전 writer를 차단한 뒤 최종 GTID 집합과 업무 기준점을 확보할 수 있다. 비계획 장애에서는 이전 writer가 접근 불가일 수 있으므로 마지막 모니터링 값, 보존된 binary log, 애플리케이션의 커밋 증거와 요청 기록을 사용한다. 마지막 관측 이후의 구간은 자동으로 정상 처리하지 말고 **확인 불가 구간**으로 남긴다.

운영 기록에는 최소한 다음이 필요하다.

- 이전 writer와 승격 후보의 식별자, 관측 시각, 승격 직전의 GTID 집합.
- 이전 writer의 쓰기 차단 증거와 새 writer의 쓰기 허용 시각.
- 비교 대상 스키마·테이블·기본 키 구간, 복제 필터와 수동 스킵 이력.
- 업무 성공 응답을 받은 요청, 결과가 불명확한 요청, 새 writer에서 새로 처리한 요청의 구분.
- 비교 시점의 쓰기 정지 여부, 스냅샷 획득 방식, 검사 범위에서 제외한 데이터.

`REPEATABLE READ`의 consistent snapshot은 한 서버 안에서 읽기 기준점을 유지하는 기능이다. 두 서버에서 비슷한 시각에 시작했다고 같은 논리 상태가 보장되지는 않는다. 또한 GTID 적용을 기다린 뒤에도 이미 생성된 오래된 Read View가 자동으로 갱신되지는 않는다. 적용 대기 이후 새 트랜잭션에서 읽거나, 별도로 정한 쓰기 정지·스냅샷 절차를 따라야 한다.

```mermaid
flowchart TD
    A[이전 writer 차단과 기준점 확보] --> B[필수 GTID 포함 여부 확인]
    B --> C{누락 또는 증거 부족}
    C -->|있음| D[쓰기 재개 보류 또는 손실 승인 절차]
    C -->|없음| E[동일 기준점의 핵심 데이터 비교]
    E --> F[업무 불변조건과 불명확한 요청 대사]
    F --> G{필수 검사 통과}
    G -->|아니요| H[증거 보존 및 불일치 구간 격리]
    G -->|예| I[제한적 업무 재개와 확대 검증]
```

전량 checksum을 끝낼 때까지 모든 서비스를 중단해야 한다는 뜻은 아니다. 재개 전에 반드시 통과해야 하는 결제·재고 등의 핵심 검사와 재개 후 수행할 배경 검사를 구분한다. 검사 비용 때문에 생략한 범위는 완료가 아니라 미검증으로 기록한다.

## 3. GTID는 양방향 차집합으로 읽는다

필요한 이전 이력을 `required_set`, 후보의 실행 이력을 `candidate_set`이라고 하면 다음처럼 해석한다.

- `GTID_SUBTRACT(required_set, candidate_set)`: 후보에 없는 필수 이력.
- `GTID_SUBTRACT(candidate_set, required_set)`: 기준 집합 바깥의 후보 이력.
- `GTID_SUBSET(required_set, candidate_set)`: 필수 이력의 포함 여부.

후보에만 있는 이력을 곧바로 errant transaction으로 단정하지 않는다. 새 writer의 정상적인 승격 후 거래, 다른 source의 정당한 이력, 초기 구성 이력일 수도 있다. 비교 시점과 출처를 확인해야 한다. 반대로 승격 전 후보에만 존재하는 출처 불명 이력을 무조건 무시해서도 안 된다.

다음은 실제 GTID를 읽은 결과가 아니라, 정상 포함·중간 누락·미관측을 구분하기 위한 입력 집합이다. 동일 UUID의 마지막 번호만 비교하면 중간 구멍을 놓칠 수 있다.

```sql
SET @required_set = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-10';
WITH candidates AS (
  SELECT 'complete' AS case_name,
         'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-10' AS candidate_set
  UNION ALL
  SELECT 'gap_and_extra',
         'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-7:9-11'
  UNION ALL
  SELECT 'unobserved', NULL
)
SELECT case_name,
       GTID_SUBSET(@required_set, candidate_set) AS required_present,
       GTID_SUBTRACT(@required_set, candidate_set) AS missing_set,
       GTID_SUBTRACT(candidate_set, @required_set) AS extra_set,
       CASE
         WHEN candidate_set IS NULL THEN 'UNKNOWN'
         WHEN GTID_SUBSET(@required_set, candidate_set) = 0 THEN 'HOLD'
         WHEN GTID_SUBTRACT(candidate_set, @required_set) <> '' THEN 'REVIEW'
         ELSE 'HISTORY_OK'
       END AS history_gate
FROM candidates
ORDER BY case_name;
```

실행 결과(MySQL 8.0.x):

아래는 긴 SQL 재출력을 줄이고 판정에 필요한 열만 발췌한 결과다.

```text
mysql> SET @required_set = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-10';
Query OK, 0 rows affected (0.00 sec)

mysql> WITH candidates AS (...) SELECT ...;
+---------------+------------------+--------------+
| case_name     | required_present | history_gate |
+---------------+------------------+--------------+
| complete      |                1 | HISTORY_OK   |
| gap_and_extra |                0 | HOLD         |
| unobserved    |             NULL | UNKNOWN      |
+---------------+------------------+--------------+
3 rows in set (0.00 sec)
```

`gap_and_extra`의 실제 `missing_set`은 `aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:8`, `extra_set`은 `aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:11`이었다. 마지막 번호가 더 커도 필수 이력이 빠져 있을 수 있다. 빈 차집합은 빈 문자열로, 관측하지 못한 입력은 `NULL`로 구분한다.

`HISTORY_OK`는 실행 이력 검사만 통과했다는 뜻이다. 운영에서는 후보의 실제 `@@GLOBAL.gtid_executed`와 신뢰할 수 있는 기준 집합을 넣어야 한다. GTID를 사용하지 않는 토폴로지에 이 판정을 그대로 적용할 수는 없다.

`WAIT_FOR_EXECUTED_GTID_SET()`을 사용할 때에도 유한한 timeout을 지정하고 반환값을 확인한다. 성공은 지정 집합의 실행을 확인한 것이며, 데이터 동등성이나 기존 트랜잭션의 스냅샷 갱신을 보장하지 않는다. timeout을 0으로 지정하면 무기한 대기가 될 수 있으므로 장애 대응 자동화에서는 피한다.

## 4. 행 수와 합계가 같은데 내용이 다른 경우

아래 재현은 금액을 최소 통화 단위의 정수로 저장한 단순 주문 모델이다. `total_cents`는 주문액, `paid_cents`는 확정 결제액이다. 모두 결제 완료 상태이며 환불·부분 결제는 없다고 가정한다. 운영 테이블이 아닌 비어 있는 실습 DB에서 아래 두 절의 SQL을 순서대로 실행한다.

처음에는 기준 테이블과 후보 테이블의 내용을 같게 만든다. 이후 후보에서 두 주문액을 반대 방향으로 변경하여 행 수와 전체 합계는 유지하되 행 내용은 다르게 만든다.

```sql
CREATE TABLE verify_ref (
  order_id BIGINT PRIMARY KEY,
  total_cents BIGINT NOT NULL,
  paid_cents BIGINT NOT NULL
) ENGINE=InnoDB;
CREATE TABLE verify_candidate LIKE verify_ref;
INSERT INTO verify_ref VALUES (1, 10000, 10000), (2, 20000, 20000);
INSERT INTO verify_candidate SELECT * FROM verify_ref;
CHECKSUM TABLE verify_ref, verify_candidate EXTENDED;

UPDATE verify_candidate
SET total_cents = CASE order_id
  WHEN 1 THEN total_cents + 1000
  WHEN 2 THEN total_cents - 1000
END
WHERE order_id IN (1, 2);
SELECT 'reference' AS side_name, COUNT(*) AS row_count,
       SUM(total_cents) AS total_sum FROM verify_ref
UNION ALL
SELECT 'candidate', COUNT(*), SUM(total_cents) FROM verify_candidate;
CHECKSUM TABLE verify_ref, verify_candidate EXTENDED;

SELECT r.order_id, 'missing_or_changed' AS difference_kind
FROM verify_ref r
LEFT JOIN verify_candidate c ON c.order_id = r.order_id
WHERE c.order_id IS NULL
   OR NOT (r.total_cents <=> c.total_cents)
   OR NOT (r.paid_cents <=> c.paid_cents)
UNION ALL
SELECT c.order_id, 'candidate_only'
FROM verify_candidate c
LEFT JOIN verify_ref r ON r.order_id = c.order_id
WHERE r.order_id IS NULL
ORDER BY order_id;
```

실행 결과(MySQL 8.0.x):

SQL 재출력과 준비 단계의 반복 메시지는 줄였다. 변경 전에는 두 테이블의 checksum이 모두 `3912374476`이었다. 다음은 입력 성공과 변경 이후의 핵심 결과다. checksum 숫자 자체는 운영 정상값이 아니라 이번 실험에서 얻은 값이다.

```text
mysql> INSERT INTO verify_ref VALUES (1, 10000, 10000), (2, 20000, 20000);
Query OK, 2 rows affected (0.00 sec)
Records: 2  Duplicates: 0  Warnings: 0

mysql> UPDATE verify_candidate SET ... WHERE order_id IN (1, 2);
Query OK, 2 rows affected (0.00 sec)
Rows matched: 2  Changed: 2  Warnings: 0

mysql> SELECT ... UNION ALL SELECT ...;
+-----------+-----------+-----------+
| side_name | row_count | total_sum |
+-----------+-----------+-----------+
| reference |         2 |     30000 |
| candidate |         2 |     30000 |
+-----------+-----------+-----------+
2 rows in set (0.00 sec)

mysql> CHECKSUM TABLE verify_ref, verify_candidate EXTENDED;
+---------------------------------+------------+
| Table                           | Checksum   |
+---------------------------------+------------+
| mysql_tech_note.verify_ref       | 3912374476 |
| mysql_tech_note.verify_candidate |  194597422 |
+---------------------------------+------------+
2 rows in set (0.00 sec)

mysql> SELECT ... LEFT JOIN ... UNION ALL SELECT ...;
+----------+--------------------+
| order_id | difference_kind    |
+----------+--------------------+
|        1 | missing_or_changed |
|        2 | missing_or_changed |
+----------+--------------------+
2 rows in set (0.01 sec)
```

이 예제에서는 행 수와 합계가 같더라도 checksum과 행 비교로 차이를 검출할 수 있다.

이 예제의 행 비교는 모든 열을 대상으로 하며, 기본 키의 누락·추가도 양방향으로 조사한다. `<=>`는 NULL 안전 비교이므로, NULL을 허용하는 실제 데이터에 응용할 때도 일반 `=` 비교의 미확정 결과 때문에 불일치를 놓치는 일을 피할 수 있다.

### checksum을 운영에 적용할 때의 조건

`CHECKSUM TABLE ... EXTENDED`는 테이블을 행 단위로 읽으며, InnoDB에서는 읽기 잠금을 동반한다. 대규모 테이블에서 장애 직후 일제히 실행하면 I/O 부하나 쓰기 대기 때문에 복구가 늦어질 수 있다. InnoDB에서 `QUICK`으로 빠르게 유효한 값을 얻을 수 있다고 가정하지 않는다. `NULL` checksum은 일치를 뜻하는 값이 아니다.

또한 checksum은 행 형식에 의존하며 충돌하지 않는다는 보장도 없다. 비교 전에 테이블 정의, 대상 열, 문자셋, collation, 버전 차이를 확인한다. `CHECKSUM TABLE`에서 같은 값이 나왔다는 사실을 암호학적인 완전 일치 증명으로 표현해서는 안 된다.

대규모 대상에서는 고정된 기본 키 경계로 청크를 나누고, 건수와 정규화한 행 내용의 해시 요약값을 기록한다. 차이가 나온 구간만 다시 분할하여 마지막에는 행별 차이를 확인한다. OFFSET 페이지 처리는 중간 삽입·삭제에 취약하므로 검증 범위를 나타내는 고정 키 경계를 사용한다.

애플리케이션에서 해시를 생성한다면 NULL과 빈 문자열, 구분자, 바이너리 값, 시각의 시간대, 소수의 표현, 행 순서의 직렬화 규칙을 고정한다. `GROUP_CONCAT`으로 모든 행을 연결한 문자열은 길이 제한으로 잘릴 위험이 있다. 단순한 CRC 값의 합이나 XOR만으로 전체 행 일치를 결론 내리지 않는다.

## 5. checksum이 같아도 업무 조건이 깨져 있는 경우

다음은 실습을 위해 기준 쪽에도 후보와 같은 잘못된 주문액을 반영한다. **이는 검사가 놓칠 수 있는 상태를 만드는 조작이며, 운영 복구 절차가 아니다.** 양쪽이 같은 오류를 가지면 checksum은 다시 일치한다. 그러나 결제 완료 주문의 주문액과 결제액은 일치하지 않는다.

```sql
UPDATE verify_ref r
JOIN verify_candidate c ON c.order_id = r.order_id
SET r.total_cents = c.total_cents;
CHECKSUM TABLE verify_ref, verify_candidate EXTENDED;
SELECT 'reference' AS side_name,
       SUM(total_cents <> paid_cents) AS invariant_violations
FROM verify_ref
UNION ALL
SELECT 'candidate', SUM(total_cents <> paid_cents)
FROM verify_candidate;
SELECT order_id, total_cents, paid_cents
FROM verify_candidate
WHERE total_cents <> paid_cents
ORDER BY order_id;
DROP TABLE verify_candidate, verify_ref;
```

실행 결과(MySQL 8.0.x):

```text
mysql> UPDATE verify_ref r
    -> JOIN verify_candidate c ON c.order_id = r.order_id
    -> SET r.total_cents = c.total_cents;

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

mysql> CHECKSUM TABLE verify_ref, verify_candidate EXTENDED;

+----------------------------------+-----------+
| Table                            | Checksum  |
+----------------------------------+-----------+
| mysql_tech_note.verify_ref       | 194597422 |
| mysql_tech_note.verify_candidate | 194597422 |
+----------------------------------+-----------+
2 rows in set (0.00 sec)

mysql> SELECT 'reference' AS side_name,
    ->        SUM(total_cents <> paid_cents) AS invariant_violations
    -> FROM verify_ref
    -> UNION ALL
    -> SELECT 'candidate', SUM(total_cents <> paid_cents)
    -> FROM verify_candidate;

+-----------+----------------------+
| side_name | invariant_violations |
+-----------+----------------------+
| reference |                    2 |
| candidate |                    2 |
+-----------+----------------------+
2 rows in set (0.00 sec)

mysql> SELECT order_id, total_cents, paid_cents
    -> FROM verify_candidate
    -> WHERE total_cents <> paid_cents
    -> ORDER BY order_id;

+----------+-------------+------------+
| order_id | total_cents | paid_cents |
+----------+-------------+------------+
|        1 |       11000 |      10000 |
|        2 |       19000 |      20000 |
+----------+-------------+------------+
2 rows in set (0.00 sec)

mysql> DROP TABLE verify_candidate, verify_ref;

Query OK, 0 rows affected (0.00 sec)
```

이 결과는 비교 상대와의 동일성과 업무적으로 올바른 상태가 서로 다른 성질임을 보여준다.

실무의 불변조건은 업무 명세에 맞게 정의한다. 부분 결제나 환불을 허용하는 시스템에서 무조건 주문액과 결제액의 일치를 요구하면 정상 데이터를 장애로 판정하게 된다. 여러 통화의 금액도 통화별로 집계한다.

실제 주문·결제 테이블을 검사할 때는 결제를 주문 단위로 먼저 집계한 뒤 주문과 비교한다. 주문·결제·상세를 그대로 다대다로 조인하면 합계가 중복된다. 조인에 성공한 행만 검사하지 말고 주문이 없는 결제나 결제가 없는 완료 주문 같은 고아 행도 별도로 검사한다.

업무 요청 ID의 중복, 외부 결제 승인과 DB 상태의 불일치, outbox의 미처리 이벤트도 중요하다. 외부 서비스와 DB가 하나의 트랜잭션에 참여하지 않는다면 DB만의 checksum으로는 판정할 수 없다. 성공 응답을 반환한 업무 ID와 결과 불명의 업무 ID를 나누고, 멱등 키를 기준으로 조회·재처리한다.

## 6. 불일치를 발견했을 때의 판단 순서

1. **증거를 먼저 보존한다.** 이전 writer를 바로 다시 연결하거나 복제 대상으로 초기화하지 않는다. 접근이 복구되면 쓰기를 차단한 상태에서 로그·GTID·업무 데이터를 보존한다.
2. **비교 시점이 같은지 확인한다.** 정상적인 승격 후 거래 때문에 달라진 것인지, 장애 전 필수 이력이 없는 것인지 분리한다. 차이가 있다는 사실만으로 손상이라고 단정하지 않는다.
3. **누락·추가·값 변경을 구분한다.** GTID 차집합, 키 구간, 행별 변경, 업무 ID를 연결한다. 한 트랜잭션에 주문·결제·감사 행이 함께 포함되었을 수 있으므로 한 테이블만 수정하고 종료하지 않는다.
4. **기준 데이터를 업무적으로 결정한다.** 이전 writer가 항상 정답이거나 새 writer가 항상 정답인 것은 아니다. 이미 처리된 외부 결제나 출고 기록을 포함해 대사한다.
5. **멱등 보정과 재검사를 수행한다.** 승인된 보정은 적용 대상과 기대 전후 값을 남기고, 같은 세 검증을 다시 실행한다. 수동으로 checksum만 맞추거나 GTID를 스킵해 경고를 지우지 않는다.

새 writer에서 업무 쓰기를 시작한 뒤에는 이전 writer로 주소만 돌리는 것이 안전한 복귀가 아니다. 양쪽의 이력이 갈라졌다면 기준 이력을 정하고 재구축·대사해야 한다. 데이터 손실을 허용하는 비상 재개도 가능하지만, 그것은 정합성 검증 통과가 아니라 명시적인 손실 범위와 책임자의 승인을 가진 별도 결정이다.

## 7. Aurora MySQL에서는 무엇이 달라지는가

같은 Aurora DB 클러스터의 writer와 reader는 하나의 논리적인 cluster volume을 공유한다. 따라서 Community MySQL의 독립된 datadir 사이 binlog 복제와 동일한 방식으로 내부 복제를 해석해서는 안 된다. 클러스터 내부 reader 승격의 건전성을 Community 복제 채널의 GTID 집합만으로 판정하는 절차는 적절하지 않다.

그렇더라도 업무 검증이 사라지지는 않는다. 새 writer endpoint에 재접속했는지, 오래된 연결·트랜잭션을 유지하고 있지 않은지, 결과 불명의 요청이 중복 처리되지 않았는지 확인해야 한다. reader 조회의 지연·스냅샷과 writer의 실제 커밋 상태도 구분한다. 공유 스토리지는 잘못된 업무 변경을 자동으로 올바르게 바꾸지 않는다.

Aurora Global Database의 리전 간 비계획 전환은 또 다른 범위다. 비동기 복제 지연으로 손실 가능 구간이 생길 수 있으므로 리전 간 복제 상태와 애플리케이션 거래 증거를 함께 보존한다. 같은 리전 클러스터 내부 승격과 동일한 무손실 조건으로 취급하지 않는다. 외부 MySQL과의 binlog 복제를 사용하는 경우에만 해당 채널의 GTID 구성·필터·버전 지원 범위에 맞추어 이력 검사를 적용한다.

이 글의 로컬 SQL 결과는 Aurora의 내부 복제 상태나 리전 간 손실 범위를 측정한 것이 아니다. 실제 환경에서는 AWS의 전환 이벤트와 클러스터 상태, 애플리케이션 요청 기록을 결합한 별도 리허설이 필요하다.

## 8. 서비스 재개 체크리스트

- [ ] 이전 writer의 쓰기가 차단되었고, 요청 경로가 새 writer로 향하는지 확인했다.
- [ ] 필수 GTID의 출처·기준 시각을 기록하고 누락과 출처 불명 추가 이력을 구분했다.
- [ ] 기준 이력을 확보하지 못한 구간을 정상이나 0건으로 처리하지 않았다.
- [ ] 데이터 비교는 같은 논리 기준점·키 구간·열 집합을 사용했다.
- [ ] 행 수와 합계 외에 내용 비교를 수행하고, 불일치 구간은 행별로 확인했다.
- [ ] 주문·결제·재고 등 핵심 업무 불변조건과 고아 행을 검사했다.
- [ ] 성공 응답 요청과 결과 불명 요청을 분리하고 재시도에 멱등성을 적용했다.
- [ ] 검사 도구 오류·시간 초과·NULL 결과는 미검증으로 분류했다.
- [ ] 필수 재개 조건과 배경 검사 범위, 손실 승인 여부, 보정 책임자를 기록했다.
- [ ] 보정 뒤 동일한 검사를 다시 실행하고 이전 writer의 재가입 방식을 정했다.

## 결론

Failover의 완료 조건은 새 서버의 접속 성공이 아니라, 필요한 이력과 데이터 내용, 업무 관계에 관한 증거가 갖추어졌는가이다. GTID는 이력을, checksum과 행 비교는 내용을, application invariant는 업무 의미를 검증한다. 세 결과를 서로 대체하지 않고 조합해야 데이터 손실과 중복 처리를 구분할 수 있다.

이 검증 구조는 향후 split-brain 대응과 장애 조치 자동화의 승인 조건에도 그대로 연결된다. 자동화해야 할 것은 무조건적인 승격과 성공 판정이 아니라, 증거가 부족할 때 멈추고 불일치 범위를 남기는 절차다.

## 참고 문서

- [MySQL 8.4: GTID 함수](https://dev.mysql.com/doc/refman/8.4/en/gtid-functions.html)
- [MySQL 8.4: CHECKSUM TABLE](https://dev.mysql.com/doc/refman/8.4/en/checksum-table.html)
- [AWS: Aurora 복제 구조](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Replication.html)
- [AWS: Aurora Global Database의 switchover와 failover](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-global-database-disaster-recovery.html)
