카테고리 : MySQL/기술노트

Analyze/Optimize/Check Table의 역할과 운영 시점

MySQL의 ANALYZE, OPTIMIZE, CHECK TABLE을 통계 갱신·물리 재구성·구조 검사로 구분하고 실행 시점과 운영 위험을 정리한다.

저자: MySQL 기술 노트 작성: 2026.10.23 약 10분 5,652자
다운로드

테이블이 느려졌다는 이유로 ANALYZE TABLE, OPTIMIZE TABLE, CHECK TABLE을 차례대로 실행하는 관행은 문제의 원인을 가린다. 세 명령은 같은 유지보수 문법 묶음에 속하지만, 각각 옵티마이저 통계, 데이터와 인덱스의 물리 배치, 테이블 구조의 이상 여부를 다룬다. 실행 계획이 잘못된 문제와 공간 회수가 필요한 문제, 손상이 의심되는 문제는 출발점부터 다르다.

이 글의 기준은 MySQL 8.0 이상 InnoDB다. 예제는 폐기용 Community MySQL 8.0 인스턴스에서 실행하며, MyISAM의 복구 관행을 InnoDB에 그대로 적용하지 않는다. 실습은 명령의 결과와 데이터 보존 여부를 확인하는 축소 검증이다. 대용량 처리 시간, 동시 쓰기 중단 시간, 손상 복구, 복제 전파 및 Aurora 실행 성능을 실측한 결과는 아니다.

1. 유지보수 명령보다 먼저 문제의 층위를 구분한다

  • ANALYZE TABLE: 인덱스 값 분포 등의 통계를 갱신해 옵티마이저가 비용을 추정할 근거를 바꾼다. 행을 더 촘촘하게 재배치하거나 파일을 줄이는 명령이 아니다.
  • OPTIMIZE TABLE: 일반적인 InnoDB 테이블에서는 재구축과 통계 갱신으로 이어진다. 데이터와 인덱스를 다시 구성하므로 읽기·쓰기 I/O, 추가 공간, 잠금 전환 비용을 지불한다.
  • CHECK TABLE: 엔진이 지원하는 범위에서 테이블과 인덱스 구조를 검사한다. 성능 개선이나 데이터 복구, 업무 정합성 증명을 대신하지 않는다.
flowchart TD
    A[유지보수 필요성 확인] --> B{핵심 증상은 무엇인가}
    B -->|추정 행 수와 실제 행 수의 괴리| C[쿼리와 인덱스 및 통계 점검]
    C --> D[필요한 테이블만 ANALYZE]
    B -->|대량 삭제 후 공간 회수 필요| E[테이블스페이스와 재사용 가능성 점검]
    E --> F[비용이 정당화되면 OPTIMIZE]
    B -->|손상 의심 로그 또는 구조 오류| G[백업과 가용성 및 복구 경로 확보]
    G --> H[안전한 환경에서 CHECK 검토]
    D --> I[목표 지표와 업무 결과 재검증]
    F --> I
    H --> I

잠금 대기, 느린 저장장치, 과도한 동시 연결 때문에 지연되는 상황에서는 이 명령 중 어느 것도 첫 처방이 아니다. 느린 쿼리의 실행 계획, 대기 종류, 데이터 변경 이력, 스토리지 지표를 먼저 확보해야 한다. 유지보수 전 증거가 없으면 작업 후 좋아졌는지도 판단하기 어렵다.

2. ANALYZE: 데이터가 아니라 옵티마이저의 판단 근거를 바꾼다

인덱스 통계는 실측 전수조사와 다르다

InnoDB는 인덱스 트리를 표본 조사해 카디널리티 등을 추정한다. 지속 통계를 사용하면 이 정보가 재시작 후에도 유지되며, 자동 재계산은 데이터 변경에 따라 통계를 갱신하는 데 도움을 준다. 그러나 자동 갱신이 대량 적재 완료 직후의 실행 계획까지 원하는 시점에 보장하는 것은 아니다.

명시적 ANALYZE TABLE을 검토할 대표적인 시점은 대량 적재·삭제 후 분포가 크게 달라졌거나, 인덱스와 조건식이 적절한데도 추정 행 수가 실제와 크게 어긋나는 경우다. 실행 전후에 같은 조건의 대표 쿼리와 계획을 보관한다. EXPLAIN ANALYZE는 실제 쿼리를 실행하므로 운영에서 비용이 큰 조회를 무심코 실행하지 않는다.

통계 갱신은 좋은 계획을 보장하지 않는다. 표본 오차, 컬럼 간 상관관계, 누락된 인덱스, 자료형 변환, 부정확한 비용 모델은 남을 수 있다. 새 통계가 일부 쿼리의 계획을 악화시킬 수도 있으므로 평균 응답 시간뿐 아니라 중요한 쿼리의 상위 지연 구간과 읽은 행 수도 비교한다.

인덱스 통계와 Histogram은 따로 관리한다

기본 ANALYZE TABLE과 UPDATE HISTOGRAM은 서로 다른 통계 작업이다. 특히 MySQL 8.0에서 일반 분석만 반복한다고 기존 Histogram이 최신 데이터 분포로 갱신되는 것은 아니다. MySQL 8.4에는 Histogram의 자동 갱신 선택지가 있으므로 정확한 버전과 생성 옵션을 함께 확인해야 한다. 아래에서는 두 계열에서 사용할 수 있는 명시적 Histogram 갱신 문법만 사용한다.

다음 예제는 인덱스 통계의 저장과 비인덱스 컬럼의 Histogram 생성을 구분한다. 작은 데이터이므로 실행 계획 개선을 입증하는 성능 실험으로 해석하지 않는다. 조회한 n_rows는 통계 추정값이지 업무상 정확한 건수 API가 아니다.

CREATE TABLE maintenance_stats (
  id INT PRIMARY KEY,
  tenant_id INT NOT NULL,
  state VARCHAR(12) NOT NULL,
  KEY idx_tenant (tenant_id)
) ENGINE=InnoDB STATS_PERSISTENT=1 STATS_AUTO_RECALC=0;
INSERT INTO maintenance_stats VALUES
  (1,10,'open'),(2,10,'open'),(3,20,'open'),(4,20,'closed');
ANALYZE TABLE maintenance_stats;
SELECT table_name, n_rows
FROM mysql.innodb_table_stats
WHERE database_name=DATABASE() AND table_name='maintenance_stats';
ANALYZE TABLE maintenance_stats UPDATE HISTOGRAM ON state WITH 8 BUCKETS;
SELECT COLUMN_NAME,
       JSON_UNQUOTE(JSON_EXTRACT(HISTOGRAM, '$."histogram-type"')) AS histogram_type,
       JSON_LENGTH(JSON_EXTRACT(HISTOGRAM, '$.buckets')) AS bucket_count
FROM information_schema.COLUMN_STATISTICS
WHERE SCHEMA_NAME=DATABASE() AND TABLE_NAME='maintenance_stats';
DROP TABLE maintenance_stats;

실행 결과(MySQL 8.0.x):

준비·정리 문장과 긴 SELECT 원문은 줄이고, 통계 조회 결과 및 주요 상태를 발췌했다. 검증 버전은 MySQL 8.0.46이다.

mysql> CREATE TABLE maintenance_stats (...);
Query OK, 0 rows affected (0.01 sec)

mysql> INSERT INTO maintenance_stats VALUES (...);
Query OK, 4 rows affected (0.00 sec)
Records: 4  Duplicates: 0  Warnings: 0

mysql> ANALYZE TABLE maintenance_stats;
Table: mysql_tech_note.maintenance_stats
Op: analyze, Msg_type: status, Msg_text: OK

mysql> SELECT table_name, n_rows FROM mysql.innodb_table_stats ...;
+-------------------+--------+
| table_name        | n_rows |
+-------------------+--------+
| maintenance_stats |      4 |
+-------------------+--------+
1 row in set (0.00 sec)

mysql> ANALYZE TABLE maintenance_stats UPDATE HISTOGRAM ON state WITH 8 BUCKETS;
Op: histogram, Msg_type: status
Msg_text: Histogram statistics created for column 'state'.

mysql> SELECT COLUMN_NAME, ... FROM information_schema.COLUMN_STATISTICS ...;
+-------------+----------------+--------------+
| COLUMN_NAME | histogram_type | bucket_count |
+-------------+----------------+--------------+
| state       | singleton      |            2 |
+-------------+----------------+--------------+
1 row in set (0.01 sec)

STATS_AUTO_RECALC=0은 실습에서 자동 갱신을 배제하기 위한 설정이며 운영 권장 기본값이 아니다. WITH 8 BUCKETS도 정확히 8개 구간을 만들라는 보장이 아니라 최대 버킷 수 지정이다. 값 종류가 적으면 실제 버킷 수는 더 적을 수 있다. mysql.innodb_table_stats 조회에는 별도의 읽기 권한이 필요하며, 관리형 환경의 일반 계정에서 그대로 허용된다고 가정하지 않는다.

분석 작업도 무부하·무잠금 작업은 아니다

표본 조사에는 I/O와 CPU가 필요하고, 메타데이터 잠금 및 다른 작업과의 상호작용을 고려해야 한다. 공식 문서는 분석 중 read lock을 설명한다. MySQL 8.0.24에서는 과거 flush lock 때문에 후속 작업이 장시간 기다리던 문제가 개선되었지만, 이를 모든 버전·상황에서 대기가 없다는 의미로 확대하면 안 된다. 운영 중 영향은 정확한 minor version과 워크로드로 확인한다.

3. OPTIMIZE: 물리 재구성이 필요할 때만 비용을 지불한다

일반적인 InnoDB OPTIMIZE TABLE은 ALTER TABLE ... FORCE에 대응하는 재구축 경로를 사용한다. 결과에 Table does not support optimize, doing recreate + analyze instead라는 note가 나오는 것은 이 경로를 설명하는 메시지다. 이 note만 보고 실패로 처리해서도, 반대로 마지막 상태를 확인하지 않고 성공으로 처리해서도 안 된다.

일반적인 지원 조건에서는 online DDL로 재구축되지만, 준비와 마무리에는 배타적 잠금 구간이 존재한다. 긴 트랜잭션이 메타데이터 잠금 전환을 막을 수 있고, 재구축 중 쓰기가 많으면 변경분을 추적·적용하는 비용도 커진다. FULLTEXT 인덱스나 old_alter_table 설정 등은 다른 경로로 이어질 수 있다. “온라인”은 “테이블을 다시 만들지 않는다” 또는 “서비스 영향이 없다”는 뜻이 아니다.

공간 회수 판단은 저장 구조에 달려 있다. file-per-table에서는 재구축 후 파일이 작아질 수 있지만, 공유 테이블스페이스의 내부 여유 공간이 곧바로 운영체제에 반환되는 것은 아니다. 곧 비슷한 양의 데이터를 다시 넣는 테이블이라면 기존 빈 공간을 재사용하는 편이 더 경제적일 수 있다. DATA_FREE 하나만으로 모든 테이블의 OPTIMIZE 대상을 자동 선정하지 않는다.

다음 실습은 재구축 전후의 모든 행 내용을 양방향으로 대조한다. 세 행짜리 예제는 공간 회수량을 보여주려는 것이 아니라, 재구축의 결과 메시지와 논리적 데이터 보존을 확인하기 위한 것이다.

CREATE TABLE maintenance_rebuild (
  id INT PRIMARY KEY,
  payload VARCHAR(40) NOT NULL,
  KEY idx_payload (payload)
) ENGINE=InnoDB;
INSERT INTO maintenance_rebuild VALUES (1,'keep-a'),(2,'remove-b'),(3,'keep-c');
DELETE FROM maintenance_rebuild WHERE id=2;
CREATE TABLE maintenance_expected LIKE maintenance_rebuild;
INSERT INTO maintenance_expected SELECT * FROM maintenance_rebuild;
OPTIMIZE TABLE maintenance_rebuild;
SELECT COUNT(*) AS mismatch_count
FROM (
  SELECT e.id FROM maintenance_expected e
  LEFT JOIN maintenance_rebuild a ON a.id=e.id
  WHERE a.id IS NULL OR NOT (a.payload <=> e.payload)
  UNION ALL
  SELECT a.id FROM maintenance_rebuild a
  LEFT JOIN maintenance_expected e ON e.id=a.id
  WHERE e.id IS NULL
) AS differences;
SELECT id,payload FROM maintenance_rebuild ORDER BY id;
DROP TABLE maintenance_expected, maintenance_rebuild;

실행 결과(MySQL 8.0.x):

준비·정리 문장 일부와 긴 비교 SELECT 원문을 생략하고 주요 결과를 발췌했다. OPTIMIZE의 결과 행은 읽기 쉽도록 항목별로 풀어 적었다.

mysql> DELETE FROM maintenance_rebuild WHERE id=2;
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO maintenance_expected SELECT * FROM maintenance_rebuild;
Query OK, 2 rows affected (0.01 sec)
Records: 2  Duplicates: 0  Warnings: 0

mysql> OPTIMIZE TABLE maintenance_rebuild;
Table: mysql_tech_note.maintenance_rebuild
Op: optimize, Msg_type: note
Msg_text: Table does not support optimize, doing recreate + analyze instead
Op: optimize, Msg_type: status, Msg_text: OK
2 rows in set (0.00 sec)

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

mysql> SELECT id,payload FROM maintenance_rebuild ORDER BY id;
+----+---------+
| id | payload |
+----+---------+
|  1 | keep-a  |
|  3 | keep-c  |
+----+---------+
2 rows in set (0.00 sec)

mismatch_count=0은 이 실습의 두 컬럼과 모든 행이 기준 데이터와 일치한다는 뜻이다. 행 수만 같은지 확인하는 것보다 강한 검증이지만, 동시 변경이 없는 작은 데이터의 결과다. 이 결과로 운영 환경의 재구축 소요 시간이나 복제 지연을 예측할 수는 없다. 공간 회수 자체는 파일 크기·할당 크기의 전후 관측이 별도로 필요하다.

4. CHECK: 구조 검사의 성공과 업무 정합성은 별개다

CHECK TABLE은 엔진이 지원하는 검사 범위에서 인덱스와 테이블의 구조적 이상을 찾는다. 반환된 OK는 외부 원장과의 금액 일치, 행 누락 없음, 애플리케이션의 상태 전이 적합성까지 보장하지 않는다. 예를 들어 잔액이 음수가 되면 안 된다는 규칙이 SQL 제약조건이나 애플리케이션에 구현되지 않았다면, 그 규칙을 어긴 행도 물리적으로는 정상 저장될 수 있다.

CREATE TABLE maintenance_accounts (
  id INT PRIMARY KEY,
  balance DECIMAL(12,2) NOT NULL
) ENGINE=InnoDB;
INSERT INTO maintenance_accounts VALUES (1,100.00),(2,-50.00);
CHECK TABLE maintenance_accounts;
SELECT id,balance FROM maintenance_accounts WHERE balance < 0;
DROP TABLE maintenance_accounts;

실행 결과(MySQL 8.0.x):

mysql> CREATE TABLE maintenance_accounts (
    ->   id INT PRIMARY KEY,
    ->   balance DECIMAL(12,2) NOT NULL
    -> ) ENGINE=InnoDB;

Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO maintenance_accounts VALUES (1,100.00),(2,-50.00);

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

mysql> CHECK TABLE maintenance_accounts;

+--------------------------------------+-------+----------+----------+
| Table                                | Op    | Msg_type | Msg_text |
+--------------------------------------+-------+----------+----------+
| mysql_tech_note.maintenance_accounts | check | status   | OK       |
+--------------------------------------+-------+----------+----------+
1 row in set (0.01 sec)

mysql> SELECT id,balance FROM maintenance_accounts WHERE balance < 0;

+----+---------+
| id | balance |
+----+---------+
|  2 |  -50.00 |
+----+---------+
1 row in set (0.00 sec)

mysql> DROP TABLE maintenance_accounts;

Query OK, 0 rows affected (0.00 sec)

여기서 음수 잔액은 검사 한계를 보여주기 위해 의도적으로 입력한 값이다. 구조 검사와 업무 규칙 조회가 서로 다른 질문에 답한다는 점이 핵심이다. 실무에서는 참조 무결성, 합계 대조, 누락 탐지, 대표 업무 읽기 등을 복구 검증에 별도로 포함한다.

손상이 의심되는 운영 서버에서 무작정 검사하지 않는다

InnoDB의 CHECK TABLE은 손상된 페이지나 특정 내부 필드 오류를 만날 때 서버 종료를 유발할 수 있다. Oracle 문서는 보조 인덱스 손상에서도 서버가 종료될 수 있음을 경고한다. 따라서 “읽기 검사니까 장애 중인 writer에 안전하다”는 판단은 위험하다.

먼저 오류 로그와 증상을 보존하고, 백업 상태·복구 가능한 복제본·장애 전환 가능성을 확인한다. 가능하면 복구한 격리 환경에서 범위를 좁혀 검사한다. 백업 복원이나 인덱스 재구축 여부는 손상 범위와 복구 절차에 따라 결정해야 하며, REPAIR TABLE을 InnoDB의 일반 수리 명령으로 사용하지 않는다. 이 글은 실제 손상 페이지를 주입하거나 서버 종료를 재현하지 않았다.

QUICK, FAST, CHANGED 등 검사 옵션의 효과도 엔진마다 다르다. MyISAM을 전제로 한 정기 검사 스크립트를 InnoDB의 동일한 안전성·효율성을 보장하는 절차로 옮기지 않는다. 정상 테이블에 대한 매일 전체 검사는 읽기 부하를 만들 뿐 아니라, 수행할 필요가 있는 업무 정합성 검사를 놓치게 할 수도 있다.

5. 실행 시점은 목적·대기·전파 정책을 묶어서 정한다

운영자가 먼저 답해야 할 질문

  1. 무엇을 개선할 것인가? 통계가 원인인 계획 문제인지, 회수할 공간이 실제로 필요한지, 구조 오류의 증거가 있는지 정한다.
  2. 무엇이 작업을 막는가? 긴 트랜잭션과 메타데이터 잠금 보유자를 확인한다. 쿼리가 Sleep 상태여도 트랜잭션이 끝났다고 단정하지 않는다.
  3. 어디까지 전파할 것인가? 기본 ANALYZE와 OPTIMIZE는 binary log에 기록되어 일반 binlog 복제본에서도 실행될 수 있다. NO_WRITE_TO_BINLOG 또는 LOCAL은 이 기록을 억제하지만, 복제본 자체의 통계·공간 관리까지 해결하지 않는다. CHECK TABLE은 같은 방식의 binlog 전파 대상으로 취급하지 않는다.
  4. 성공은 무엇인가? 클라이언트 종료 코드와 함께 결과 집합의 Msg_type, Msg_text를 검사한다. status/OK 이후에도 계획·데이터·공간이라는 원래 목표를 따로 확인한다.
  5. 중단 기준은 무엇인가? 업무 지연, 잠금 대기, 남은 공간, replica 지연을 기준으로 승인된 중단 조건을 정한다. 작업 취소도 정리 비용을 유발할 수 있어 즉시 원상복귀를 보장하지 않는다.

다음 조회는 실습 서버의 기본 설정과 대기 중인 테이블 메타데이터 잠금을 확인한다. 운영에서는 전체 대기를 보존한 뒤 대상 스키마·테이블로 범위를 좁히고, sys.schema_table_lock_waits 등으로 보유자와 대기자의 관계를 이어서 조사한다.

SELECT @@version AS mysql_version,
       @@innodb_stats_persistent AS persistent_stats,
       @@innodb_stats_auto_recalc AS auto_recalc,
       @@innodb_file_per_table AS file_per_table,
       @@old_alter_table AS old_alter_table;
SELECT OBJECT_SCHEMA,OBJECT_NAME,LOCK_TYPE,LOCK_STATUS,OWNER_THREAD_ID
FROM performance_schema.metadata_locks
WHERE OBJECT_TYPE='TABLE' AND LOCK_STATUS='PENDING'
ORDER BY OBJECT_SCHEMA,OBJECT_NAME
LIMIT 10;

실행 결과(MySQL 8.0.x):

mysql> SELECT @@version AS mysql_version,
    ->        @@innodb_stats_persistent AS persistent_stats,
    ->        @@innodb_stats_auto_recalc AS auto_recalc,
    ->        @@innodb_file_per_table AS file_per_table,
    ->        @@old_alter_table AS old_alter_table;

+---------------+------------------+-------------+----------------+-----------------+
| mysql_version | persistent_stats | auto_recalc | file_per_table | old_alter_table |
+---------------+------------------+-------------+----------------+-----------------+
| 8.0.46        |                1 |           1 |              1 |               0 |
+---------------+------------------+-------------+----------------+-----------------+
1 row in set (0.00 sec)

mysql> SELECT OBJECT_SCHEMA,OBJECT_NAME,LOCK_TYPE,LOCK_STATUS,OWNER_THREAD_ID
    -> FROM performance_schema.metadata_locks
    -> WHERE OBJECT_TYPE='TABLE' AND LOCK_STATUS='PENDING'
    -> ORDER BY OBJECT_SCHEMA,OBJECT_NAME
    -> LIMIT 10;

Empty set (0.00 sec)

대기가 없는 실습에서는 두 번째 조회가 Empty set을 반환한다. 이것은 해당 관측 순간에 보이는 pending table lock이 없다는 뜻이지, 다음 유지보수 작업이 잠기지 않는다는 보증이 아니다. 계정 권한과 wait/lock/metadata/sql/mdl instrumentation이 관측 범위를 충족하는지도 확인한다. 기본 설정 조회 결과는 테이블별 STATS_PERSISTENT, STATS_AUTO_RECALC 옵션이나 기존 테이블스페이스의 배치를 대신하지 않는다.

유지보수 명령은 업무 트랜잭션과 연결을 분리한다. 세 명령은 암묵적 커밋을 일으킬 수 있으므로, 업무 DML 뒤에 붙여 실행한 다음 ROLLBACK으로 함께 되돌리는 절차로 설계하지 않는다. 운영 대상에는 최소 필요 권한만 부여하고, 실행 대상 목록을 확정한 뒤 하나씩 결과를 기록한다.

6. Aurora MySQL에서는 로컬 파일 모델을 그대로 적용하지 않는다

Aurora는 클러스터 볼륨과 인스턴스 로컬 임시 저장소를 구분한다. 따라서 Community MySQL의 .ibd 파일 축소 실험을 Aurora의 사용량·비용 감소량으로 치환하면 안 된다. 작업 중에는 클러스터 스토리지 지표뿐 아니라 인스턴스의 로컬 임시 공간, CPU, I/O, 업무 지연과 replica 지연도 확인해야 한다.

  • 통계 작업: 대상 Aurora 엔진 버전과 권한, reader의 읽기 전용 제약을 확인하고 실행 위치를 정한다. Community의 binlog 복제본에서 LOCAL을 쓰는 모델과 클러스터 내부 reader를 동일한 구조로 취급하지 않는다.
  • 재구축 작업: writer에서 발생하는 쓰기와 자원 소모가 업무에 미칠 영향을 평가한다. VolumeBytesUsed 같은 실제 지표의 변화로 결과를 판정하되, SQL 성공 직후 비용이 같은 비율로 감소한다고 약속하지 않는다.
  • 손상 의심: 관리형 스토리지의 복제·복구 기능이 있다고 SQL 구조 검사와 업무 정합성 검사를 생략하지 않는다. 반대로 기반 파일에 직접 접근하는 self-managed 복구 명령을 Aurora에 적용하지도 않는다. 서비스 이벤트, 엔진 로그, 지원 절차와 격리 복원 검증을 함께 사용한다.

앞서 제시한 예제들의 실행 결과는 Aurora에서 얻은 것이 아니다. SQL 호환성, 운영 권한, 자원 사용과 잠금 영향은 대상 Aurora 버전의 검증 환경에서 별도로 확인해야 한다.

7. 실행 전후 체크리스트

마무리

ANALYZE는 판단 근거를, OPTIMIZE는 물리 배치를, CHECK는 구조 검사 결과를 바꾼다. 세 명령을 정기적으로 묶어 실행하는 것보다, 증거에 따라 하나를 선택하고 원래 목적의 달성 여부를 확인하는 편이 안전하다. 이후 스키마 변경과 운영 자동화에서도 같은 기준을 적용한다. 명령의 성공은 출발점이며, 업무 결과와 서비스 가용성까지 확인해야 유지보수가 끝난다.

참고 문서