카테고리 : MySQL/기술노트

복구 검증 자동화: restore test, checksum, row count, application smoke test

MySQL 복원본의 행 수, 체크섬, 업무 정합성과 애플리케이션 동작을 단계별로 검증하고 실패를 자동 판정하는 절차를 정리한다.

저자: MySQL 기술 노트 작성: 2026.10.01 약 11분 6,196자
다운로드

백업 프로세스의 종료 코드가 0이라는 사실은 파일을 생성했다는 뜻이지, 서비스가 다시 동작한다는 뜻은 아니다. 덤프가 정상이어도 뷰나 계정이 빠질 수 있고, 복원이 완료되어도 주문 합계가 어긋나거나 애플리케이션이 다른 DB에 접속할 수 있다. 복구 검증 자동화의 목적은 이러한 간극을 단계별로 좁히고, 검증 실패가 작업 전체의 실패로 이어지도록 만드는 데 있다.

이 글은 MySQL 8.0 이상을 대상으로 한다. 예제는 MySQL 8.0.46의 서로 다른 폐기용 서버 두 개에서 논리 백업과 복원을 수행하고, 정상 복원·행 누락·같은 행 수의 값 변경·뷰 누락을 비교했다. 실제 운영 애플리케이션, Aurora 클러스터, 대용량 복원 시간, PITR은 이 실험 범위에 포함하지 않는다.

1. 검증 대상은 파일, 데이터베이스, 서비스로 나뉜다

복구 검증을 단일 체크섬 검사로 축약하면 실패 원인을 구분하기 어렵다. 다음 계층을 각각 통과해야 한다.

계층 확인할 사실 통과해도 남는 위험
백업 산출물 파일 존재, 전송 무결성, 압축 해제·복호화 성공 누락된 객체나 논리적 오류
복원 실행 새 서버 기동, 적재 종료 코드, SQL 오류 없음 잘못된 백업 선택, 일부 데이터 누락
객체·데이터 테이블·뷰·루틴 목록, 정확한 행 수, 내용 비교 업무 규칙 위반, 계정·환경 차이
업무 정합성 주문 합계, 참조 관계, 상태 전이 조건 애플리케이션 연결·인증 실패
애플리케이션 실제 계정·드라이버·요청 경로로 핵심 기능 성공 부하 성능, 장시간 안정성

파일 SHA-256은 그 파일이 기록한 기준 파일과 같은가를 확인한다. 파일과 해시를 함께 바꿀 수 있는 공격자까지 방어하려면 서명 또는 신뢰할 수 있는 별도 저장소가 필요하다. 파일 해시는 덤프의 내용이 업무적으로 맞는지 판정하지 않는다.

flowchart TD
    A[백업과 복구 목표 확정] --> B[격리된 복원 환경 생성]
    B --> C[파일 무결성 확인과 복원]
    C --> D[객체 목록과 데이터 비교]
    D --> E[업무 정합성과 실제 앱 점검]
    E --> F{모든 필수 검사 통과}
    F -->|예| G[검증 완료 기록과 환경 정리]
    F -->|아니오| H[실패 단계 기록과 알림]
    H --> I[증거 보존 후 환경 정리]

검사를 실행하지 못한 상태는 성공이 아니다. 타임아웃, 접속 불가, 기준 파일 누락, 권한 부족은 모두 실패 또는 미검증으로 남긴다. 운영 대시보드는 backup_created, restore_completed, validation_passed를 서로 다른 상태로 관리하는 편이 안전하다.

2. 기준 데이터는 복구 목표 시점과 같아야 한다

행 수와 체크섬을 비교하려면 먼저 어느 시점의 데이터를 정답으로 볼지 정해야 한다. 계속 쓰기가 발생하는 원본에서 오전에 덤프하고 오후에 행 수를 세면, 복원본과 값이 달라도 복원 오류라고 단정할 수 없다.

mysqldump --single-transaction은 InnoDB를 일관된 스냅샷으로 읽는다. 그러나 별도 연결에서 실행한 검증 SELECT가 덤프와 같은 Read View를 자동으로 공유하지는 않는다. 두 작업을 연달아 실행했다는 사실만으로 동일 시점 비교가 성립하지 않는다. 덤프 중 DDL도 금지해야 하며, 비트랜잭션 테이블까지 같은 보장이 확대되지 않는다.

운영에서는 다음 중 하나를 명시적으로 선택한다.

  • 쓰기를 중단하거나 복제 적용을 정해진 경계에서 멈춘 검증용 원본에서 백업과 기준값을 수집한다.
  • 백업 도구가 제공하는 일관성 경계와 연동하여 객체·행·범위별 검증 정보를 수집한다.
  • PITR이라면 기준 백업이 아니라 목표 트랜잭션까지 적용한 상태를 비교 기준으로 삼는다.
  • 기준을 독립적으로 확보할 수 없다면 업무 불변식과 복원 실행 검사를 수행하되, 원본과 전 행 동일성을 검증했다고 기록하지 않는다.

검증용 명세에는 백업 ID, 생성 시작·완료 시각, 원본 및 도구 버전, 대상 스키마, 일관성 경계, 검증 알고리즘 버전, 테이블·객체 목록을 넣는다. binlog 좌표나 GTID는 복구 경계를 설명하는 정보이지 데이터 내용의 체크섬이 아니다.

3. 행 수와 체크섬은 서로 다른 결함을 찾는다

정확한 행 수와 추정 행 수

InnoDB의 information_schema.TABLES.TABLE_ROWS는 일반적으로 추정치다. 복원 전후 정확한 비교에는 COUNT(*) 또는 검증 스트림의 실제 행 수가 필요하다. 다만 대형 테이블의 정확한 집계는 I/O와 CPU를 사용하므로 운영 원본에 매번 전체 스캔을 걸어서는 안 된다.

행 수가 같아도 특정 행의 금액이 달라질 수 있고, 삭제된 행 대신 다른 행이 들어올 수도 있다. 행 수는 저비용 선별 기준이 될 수 있지만 최종 내용 검증은 아니다.

CHECKSUM TABLE의 의미와 한계

CHECKSUM TABLE ... EXTENDED는 행을 읽어 체크섬을 계산한다. InnoDB에서 QUICK은 이를 빠르게 대체하는 방법이 아니며 NULL을 반환할 수 있다. NULL은 일치나 성공을 의미하지 않는다.

이 명령은 테이블에 읽기 잠금을 사용하므로 대형 테이블에서 쓰기 지연을 유발할 수 있다. 또한 체크섬은 행 형식에 영향을 받는다. 서로 다른 버전·행 형식 간 결과 차이를 곧바로 데이터 손실로 해석하지 않으며, 값이 같아도 충돌 가능성 때문에 수학적인 동일성 증명이 되지는 않는다. 뷰 정의, 트리거, 권한도 이 값으로 검증할 수 없다.

논리 내용 해시는 직렬화 규약이 필요하다

논리 해시는 다음 조건을 고정해야 재현 가능하다.

  1. 고유한 정렬 기준과 비교할 열 목록을 고정한다.
  2. NULL, 빈 문자열, 구분 문자, 바이너리 값을 서로 구분한다.
  3. 문자셋, 시간대, 숫자·날짜 표현을 고정한다.
  4. 테이블 이름·스키마 정의 비교는 데이터 해시와 별도로 수행한다.
  5. 대형 테이블은 PK 범위별로 스트리밍하고, 각 범위의 경계·행 수·해시를 기록한다.

단순 CONCAT_WS는 NULL 처리와 구분 문자 충돌 때문에 다른 행을 같은 문자열로 만들 수 있다. GROUP_CONCAT도 길이 제한과 정렬 문제가 있어 대형 테이블 검증의 기본 도구로 삼기 어렵다. SHA-256을 썼다는 사실보다 어떤 바이트열을 해시했는가가 먼저다.

4. 작은 주문 데이터로 복원 기준 만들기

다음 SQL은 새로 만든 폐기용 서버에서만 실행한다. restore_drill 스키마를 만들며 기존 업무 DB에 적용하는 마이그레이션이 아니다. 외부 작업이나 자동 이벤트가 개입하지 않는 정지 상태에서 기준값과 덤프를 생성한다.

CREATE DATABASE restore_drill;
USE restore_drill;
CREATE TABLE orders (
  id BIGINT PRIMARY KEY,
  customer_id BIGINT NOT NULL,
  total DECIMAL(12,2) NOT NULL,
  status VARCHAR(16) NOT NULL
) ENGINE=InnoDB;
CREATE TABLE order_items (
  id BIGINT PRIMARY KEY,
  order_id BIGINT NOT NULL,
  quantity INT NOT NULL,
  unit_price DECIMAL(12,2) NOT NULL,
  CONSTRAINT fk_drill_order FOREIGN KEY (order_id) REFERENCES orders(id)
) ENGINE=InnoDB;
INSERT INTO orders VALUES (101,1,50.00,'PAID'),(102,2,30.00,'NEW');
INSERT INTO order_items VALUES (1,101,2,20.00),(2,101,1,10.00),(3,102,1,30.00);
CREATE VIEW order_summary AS SELECT id,total,status FROM orders;

실행 결과(MySQL 8.0.x):

mysql> CREATE DATABASE restore_drill;

Query OK, 1 row affected (0.00 sec)

mysql> CREATE TABLE orders (
    ->   id BIGINT PRIMARY KEY,
    ->   customer_id BIGINT NOT NULL,
    ->   total DECIMAL(12,2) NOT NULL,
    ->   status VARCHAR(16) NOT NULL
    -> ) ENGINE=InnoDB;

Query OK, 0 rows affected (0.01 sec)

mysql> CREATE TABLE order_items (
    ->   id BIGINT PRIMARY KEY,
    ->   order_id BIGINT NOT NULL,
    ->   quantity INT NOT NULL,
    ->   unit_price DECIMAL(12,2) NOT NULL,
    ->   CONSTRAINT fk_drill_order FOREIGN KEY (order_id) REFERENCES orders(id)
    -> ) ENGINE=InnoDB;

Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO orders VALUES (101,1,50.00,'PAID'),(102,2,30.00,'NEW');

Query OK, 2 rows affected (0.00 sec)
Records: 2  Duplicates: 0  Warnings: 0

mysql> INSERT INTO order_items VALUES (1,101,2,20.00),(2,101,1,10.00),(3,102,1,30.00);

Query OK, 3 rows affected (0.00 sec)
Records: 3  Duplicates: 0  Warnings: 0

mysql> CREATE VIEW order_summary AS SELECT id,total,status FROM orders;

Query OK, 0 rows affected (0.00 sec)

복원 후에는 행 수뿐 아니라 부모가 없는 상세 행과 합계 불일치도 확인한다. FK 검사 설정만 켰다고 과거에 잘못 적재된 행까지 자동으로 재검증되는 것은 아니므로, 업무 데이터의 참조 관계를 직접 확인하는 절차가 필요하다.

USE restore_drill;
SELECT 'orders' AS table_name, COUNT(*) AS row_count FROM orders
UNION ALL
SELECT 'order_items', COUNT(*) FROM order_items;
CHECKSUM TABLE orders, order_items EXTENDED;
SELECT COUNT(*) AS orphan_count
FROM order_items i LEFT JOIN orders o ON o.id=i.order_id
WHERE o.id IS NULL;
SELECT COUNT(*) AS total_mismatch_count
FROM orders o
LEFT JOIN (
  SELECT order_id, SUM(quantity*unit_price) AS amount
  FROM order_items GROUP BY order_id
) i ON i.order_id=o.id
WHERE NOT (o.total <=> i.amount);

실행 결과(MySQL 8.0.x):

mysql> SELECT 'orders' AS table_name, COUNT(*) AS row_count FROM orders
    -> UNION ALL
    -> SELECT 'order_items', COUNT(*) FROM order_items;

+-------------+-----------+
| table_name  | row_count |
+-------------+-----------+
| orders      |         2 |
| order_items |         3 |
+-------------+-----------+
2 rows in set (0.00 sec)

mysql> CHECKSUM TABLE orders, order_items EXTENDED;

+---------------------------+------------+
| Table                     | Checksum   |
+---------------------------+------------+
| restore_drill.orders      | 1786555129 |
| restore_drill.order_items | 3114051426 |
+---------------------------+------------+
2 rows in set (0.00 sec)

mysql> SELECT COUNT(*) AS orphan_count
    -> FROM order_items i LEFT JOIN orders o ON o.id=i.order_id
    -> WHERE o.id IS NULL;

+--------------+
| orphan_count |
+--------------+
|            0 |
+--------------+
1 row in set (0.00 sec)

mysql> SELECT COUNT(*) AS total_mismatch_count
    -> FROM orders o
    -> LEFT JOIN (
    ->   SELECT order_id, SUM(quantity*unit_price) AS amount
    ->   FROM order_items GROUP BY order_id
    -> ) i ON i.order_id=o.id
    -> WHERE NOT (o.total <=> i.amount);

+----------------------+
| total_mismatch_count |
+----------------------+
|                    0 |
+----------------------+
1 row in set (0.00 sec)

여기서는 주문에 상세가 반드시 존재하고 헤더 합계가 상세 합계와 같다는 업무 규칙을 사용했다. 할인·세금·부분 취소가 있는 실제 시스템에서는 동일한 수식을 그대로 적용하면 안 된다. 업무 불변식은 데이터베이스 구조가 아니라 서비스의 정산·상태 규칙에 맞춰 정의한다.

5. 자동 판정기는 비교 결과를 종료 코드로 바꾼다

아래 restore_gate.py는 예제 전용 검증기다. Docker를 제어할 권한과 Python 3가 필요하며, 컨테이너 안의 MySQL client를 사용한다. 원본과 복원 대상은 포트를 공개하지 않고 --network none으로 만든 폐기용 컨테이너이고, 예제에서만 로컬 root 무암호 접속을 사용했다. 이 접속 구성을 운영에 적용해서는 안 된다. 실제 자동화는 별도 인증 파일·비밀 저장소와 최소 권한 계정을 사용한다.

이 코드는 위의 두 테이블과 작은 숫자 범위를 위해 작성했다. 전체 데이터를 메모리에 담고 JSON 숫자를 Python 기본 타입으로 읽으므로 대형 테이블, 고정밀 DECIMAL, 큰 부동소수점, 바이너리·시간 자료형의 범용 검증기로 사용하지 않는다. 운영 구현은 정밀도를 보존하는 타입별 인코더와 청크 스트리밍으로 바꾸고 버전을 고정해야 한다.

import hashlib
import json
import subprocess
import sys
from pathlib import Path

container, manifest_path = sys.argv[1:3]

def query(sql):
    p = subprocess.run(
        ["docker", "exec", "-i", container, "mysql", "-uroot",
         "--batch", "--raw", "--skip-column-names", "--default-character-set=utf8mb4",
         "restore_drill"],
        input="SET time_zone='+00:00';\n" + sql,
        text=True, capture_output=True, timeout=30, check=True)
    return p.stdout

def snapshot():
    result = {}
    columns = {
        "orders": "id, customer_id, total, status",
        "order_items": "id, order_id, quantity, unit_price",
    }
    for table, cols in columns.items():
        # JSON 배열은 NULL과 문자열, 열 경계를 구분한다.
        rows = query(f"SELECT JSON_ARRAY({cols}) FROM {table} ORDER BY id;")
        payload = [json.loads(line) for line in rows.splitlines()]
        canonical = json.dumps(payload, ensure_ascii=False,
                               separators=(",", ":")).encode("utf-8")
        result[table] = {
            "rows": len(payload),
            "sha256": hashlib.sha256(canonical).hexdigest(),
        }
    return result

try:
    actual = snapshot()
    if len(sys.argv) == 4 and sys.argv[3] == "--record":
        Path(manifest_path).write_text(json.dumps(actual, indent=2))
        print(json.dumps({"stage": "baseline", "tables": actual}))
        sys.exit(0)
    expected = json.loads(Path(manifest_path).read_text())
    counts_ok = all(actual[t]["rows"] == expected[t]["rows"] for t in expected)
    digest_ok = actual == expected
    orphan_count = int(query("SELECT COUNT(*) FROM order_items i LEFT JOIN orders o "
                             "ON o.id=i.order_id WHERE o.id IS NULL;"))
    mismatch_count = int(query("SELECT COUNT(*) FROM orders o LEFT JOIN "
        "(SELECT order_id,SUM(quantity*unit_price) amount FROM order_items GROUP BY order_id) i "
        "ON i.order_id=o.id WHERE NOT (o.total <=> i.amount);"))
    # 테스트용 데이터 접근 계약이며 실제 웹 애플리케이션 시험은 아니다.
    summary = query("SELECT JSON_ARRAY(id,total,status) FROM order_summary WHERE id=101;")
    smoke_ok = json.loads(summary) == [101, 50, "PAID"]
    passed = counts_ok and digest_ok and orphan_count == 0 and mismatch_count == 0 and smoke_ok
    print(json.dumps({"row_count_ok": counts_ok, "digest_ok": digest_ok,
        "orphan_count": orphan_count, "total_mismatch_count": mismatch_count,
        "data_access_smoke_ok": smoke_ok, "passed": passed}))
    sys.exit(0 if passed else 1)
except Exception as exc:
    print(json.dumps({"passed": False, "error_type": type(exc).__name__}))
    sys.exit(2)

종료 코드 0은 모든 필수 비교의 통과, 1은 값 불일치, 2는 SQL 오류 등을 포함한 검사 실행 실패다. 비교 실패와 실행 실패 모두 최종 성공으로 처리하지 않는다. 예제의 오류 출력은 원문 SQL이나 데이터를 노출하지 않도록 예외 유형만 남겼다. 운영 로그에는 접근을 제한한 상세 오류와 실패 단계를 추가한다.

원본 컨테이너 이름을 restore-drill-source, 빈 복원 컨테이너 이름을 restore-drill-target으로 준비한 뒤 다음 순서로 수행했다. 원본에는 앞 절의 데이터가 있고 대상에는 같은 이름의 빈 스키마만 만든다. 덤프를 생성한 뒤 복원본으로 기준값을 새로 기록하면 안 된다. 그것은 손상된 복원본을 정답으로 승인하는 셈이다.

set -euo pipefail
python3 restore_gate.py restore-drill-source baseline.json --record

docker exec restore-drill-source mysqldump -uroot \
  --single-transaction --quick --routines --events --triggers \
  --no-tablespaces --set-gtid-purged=OFF restore_drill > backup.sql

docker exec restore-drill-target mysql -uroot \
  -e 'CREATE DATABASE restore_drill;'
docker exec -i restore-drill-target mysql -uroot restore_drill < backup.sql
python3 restore_gate.py restore-drill-target baseline.json

이 덤프는 부분 스키마 논리 복원의 비교용이며, 계정 복원이나 복제 초기화·PITR을 위한 완성된 백업이 아니다. --set-gtid-purged=OFF로 GTID 집합을 복원 대상에 주입하지 않았고, 별도의 binlog 적용도 수행하지 않았다. 루틴·이벤트·트리거 옵션을 명시했지만 예제 데이터에는 뷰 외의 저장 객체가 없으므로 이 실험을 그 객체들의 복원 시험으로 확대하지 않는다.

실제 두 서버 왕복 검증에서 orders의 CHECKSUM TABLE 값은 양쪽 모두 1786555129, order_items는 양쪽 모두 3114051426이었다. 이 값은 이 데이터와 검증 환경의 관측값이지 운영 테이블의 기준 상수가 아니다.

정상 복원에 대한 자동 판정 결과(MySQL 8.0.46, JSON 표시용 줄바꿈):

{
  "row_count_ok": true,
  "digest_ok": true,
  "orphan_count": 0,
  "total_mismatch_count": 0,
  "data_access_smoke_ok": true,
  "passed": true
}
종료 코드: 0

검증기 자체에 실패를 주입한다

정상 복원만 통과하는 프로그램으로는 검증기의 실효성을 알 수 없다. 각 시험 전에 같은 백업으로 대상을 다시 복원하고 다음 결함을 별도로 주입했다. 결과는 실제 실행값이다.

시험 주입한 결함 행 수 비교 논리 해시 비교 추가 관측 종료 코드
정상 없음 통과 통과 업무 합계·뷰 조회 통과 0
행 누락 상세 id=2 삭제 실패 실패 합계 불일치 1건, 뷰 조회는 통과 1
동일 행 수의 변경 주문 101 금액을 51.00으로 변경 통과 실패 합계 불일치 1건, 조회 계약 실패 1
객체 누락 order_summary 뷰 삭제 통과 통과 뷰 조회에서 CalledProcessError 2

뷰 누락 시험에서는 테이블 비교를 마친 뒤 뷰 조회가 실패했다. 따라서 JSON 최종 출력에는 passed: false와 예외 유형만 남았다. 별도로 두 테이블의 행 수와 해시를 다시 수집하여 원본 기준과 같다는 assertion도 통과시켰다.

행 누락 시험에서 뷰 조회가 통과한 점도 중요하다. 주문 헤더만 읽는 한 번의 조회는 상세 누락을 발견하지 못했다. 각각의 검사는 다른 결함을 담당하며 어느 하나가 나머지를 대체하지 않는다.

6. 데이터 접근 점검과 application smoke test는 다르다

위 검증기의 뷰 조회는 DB 접근 계층의 축소 점검이다. 실제 application smoke test는 복원본에 연결한 애플리케이션 프로세스를 기동하고, 운영과 같은 드라이버·접속 옵션·권한 모델로 대표 요청을 실행해야 한다. root SQL 조회 성공을 애플리케이션 성공으로 기록해서는 안 된다.

최소 계약은 다음처럼 구성한다.

  • 인증·TLS·문자셋·시간대 설정으로 애플리케이션이 복원 DB에 연결된다.
  • 이미 알려진 주문을 조회하여 상태, 금액, 상세 목록이 기준과 일치한다.
  • 테스트 전용 주문을 생성하고 재조회하며, 재시도 시 중복 생성 방지 규칙을 확인한다.
  • 결제·메일·메시지 발행 등 외부 부작용은 차단하거나 테스트용 대역으로 보낸다.
  • 반환 HTTP 코드뿐 아니라 응답 본문과 실제 저장 결과를 확인한다.
  • 접속 대상 식별을 검증하여 운영 DB에 잘못 붙으면 요청 실행 전에 중단한다.

쓰기 권한과 트랜잭션 동작을 가볍게 확인하려면 격리된 복원본에서 다음처럼 변경과 rollback을 비교할 수 있다. 이 SQL 역시 실제 웹 요청 시험은 아니다.

USE restore_drill;
START TRANSACTION;
UPDATE orders SET status='CHECKING' WHERE id=102;
SELECT id,status FROM orders WHERE id=102;
ROLLBACK;
SELECT id,status FROM orders WHERE id=102;

실행 결과(MySQL 8.0.x):

mysql> START TRANSACTION;

Query OK, 0 rows affected (0.00 sec)

mysql> UPDATE orders SET status='CHECKING' WHERE id=102;

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

mysql> SELECT id,status FROM orders WHERE id=102;

+-----+----------+
| id  | status   |
+-----+----------+
| 102 | CHECKING |
+-----+----------+
1 row in set (0.00 sec)

mysql> ROLLBACK;

Query OK, 0 rows affected (0.00 sec)

mysql> SELECT id,status FROM orders WHERE id=102;

+-----+--------+
| id  | status |
+-----+--------+
| 102 | NEW    |
+-----+--------+
1 row in set (0.00 sec)

rollback은 DB 내부 변경을 되돌릴 뿐이다. 외부 API, 비트랜잭션 저장소, 파일 생성, 이미 전송된 메시지는 돌아오지 않는다. 실제 애플리케이션 점검에서는 별도 테스트 테넌트와 정리 절차를 마련하고 Event Scheduler와 백그라운드 작업도 명시적으로 제어한다.

7. 운영 자동화는 안전장치와 증거를 함께 남긴다

일별·주별 복원 작업은 고유한 실행 ID를 사용한다. 같은 백업을 재시도하더라도 환경 이름과 결과를 구분하고, 동시에 실행되는 두 작업이 같은 대상 DB를 초기화하지 않도록 잠금을 둔다. 스키마 삭제 같은 작업 전에 허용된 검증 대상인지 확인하는 보호 조건이 필요하다.

성공 기록에는 백업 ID와 기준 시점, 목표·실제 버전, 사용한 검증기 버전, 검사 범위, 누락 또는 생략한 항목, 단계별 시간, 최종 종료 코드를 남긴다. 표본 검사만 했다면 전체 데이터 검증과 구분한다. 기준값과 로그도 업무 데이터의 특징을 노출할 수 있으므로 접근 권한과 보존 기간을 정한다.

복원 시간은 적재 명령의 실행 시간만 재면 안 된다. 환경 생성, 키 접근, 파일 전송, 복호화, 데이터 적재, 인덱스 생성, 검증, 서비스 전환 준비까지 어떤 항목을 포함했는지 정의해야 RTO와 비교할 수 있다. 빈 검증 환경에서 성공한 작은 시험은 실제 데이터 크기의 RTO 충족을 입증하지 않는다.

실패 시 증거를 확보한 뒤 자원을 정리한다. 성공 경로뿐 아니라 타임아웃·예외 경로에도 정리가 있어야 임시 볼륨과 복원 DB가 계속 쌓이지 않는다. 단, 장애 분석에 필요한 로그까지 즉시 지우지 않도록 결과 저장과 환경 삭제의 순서를 분리한다.

8. Aurora MySQL에서는 환경 복원도 검증 대상이다

Aurora의 스냅샷 복원은 기존 클러스터를 덮어쓰는 것이 아니라 새 DB 클러스터를 만든다. 따라서 데이터 검증 외에 파라미터 그룹, 네트워크, 보안 그룹, 암호화 키 접근, 접속 대상 설정을 확인해야 한다. 사용자 지정 파라미터 그룹과 보안 그룹을 명시하지 않으면 원본과 다른 기본 설정으로 복원될 수 있다.

AWS CLI 또는 API로 스냅샷에서 클러스터를 복원하는 경우에는 writer 인스턴스 생성도 별도로 필요하다. 클러스터 복원 요청이 접수되었다는 응답만 보고 SQL 접속까지 가능하다고 판단하지 않는다. writer가 준비되고 애플리케이션 접속 경로까지 확인된 뒤 데이터·업무 검증 단계로 이동한다.

복원 테스트를 위해 운영 애플리케이션의 endpoint를 바꾸는 대신, 격리된 테스트 애플리케이션을 새 클러스터에 연결한다. 백업에 포함되지 않는 외부 캐시·검색 인덱스·파일 저장소와 DB의 시점 차이도 별도로 다룬다. 이 글의 로컬 Docker 실험은 Aurora 복원이나 그 소요 시간을 검증한 결과가 아니다.

9. 복구 승인 점검표

결론

복구 검증은 하나의 정답 쿼리가 아니라 서로 다른 실패를 감지하는 단계들의 조합이다. 파일 무결성은 전송을, 행 수는 누락을, 내용 해시는 값 변경을, 업무 검사는 의미를, application smoke test는 실제 사용 경로를 확인한다. 자동화의 최종 목표는 많은 검사를 실행했다는 기록이 아니라 무엇을 복구할 수 있고 무엇은 아직 검증하지 못했는지 판정 가능한 증거를 남기는 것이다.

이 검증 기준을 바탕으로 이후에는 복구 목표별 시간 예산, 대형 테이블의 범위별 비교, 정기 장애 복구 훈련의 승인 절차로 확장할 수 있다.

참고 문서