← 목록으로

Aurora MySQL 인덱스 DDL이 Reader 버퍼풀을 비운다?

Aurora MySQL 인덱스 DDL이 Reader 버퍼풀을 비운다?

요약

미사용 인덱스를 정리하다 만난 Aurora 엔진 결함, 그리고 3.14 릴리즈까지

안녕하세요. 당근 DB팀에서 근무하고 있는 Eden(이든)이에요. 당근 DB팀에서는 Aurora MySQL / Aurora PostgreSQL 및 MongoDB 를 운영하고 있어요. 이번에 Aurora MySQL 을 운영하며 경험했던 이슈를 소개해 드리려고 해요.

“인덱스를 INVISIBLE 로 바꾸는 건 메타데이터만 건드리는 작업이니까 DB 에 부하를 주지 않는다.” DBA 라면 누구나 이렇게 말할 거예요. 저도 그랬고요. 그런데 Aurora MySQL 에서는 이 문장이 반쪽만 맞았어요. Writer 는 정말 아무 일도 없었지만, Reader 에서는 버퍼풀이 통째로 무효화되면서 CPU 와 ReadIOPS 가 동시에 튀었거든요.

오늘은 미사용 인덱스를 정리하다가 이 현상을 발견하고, 버퍼풀 안에서 무슨 일이 벌어지는지 직접 들여다보고, DDL 종류별로 재현해서 AWS 에 리포트하고, 결국 엔진 결함으로 확인되어 다음 릴리즈에 fix 가 실리기까지의 과정을 정리해 보려고 해요. Aurora MySQL 을 운영하는 분들께 실질적인 도움이 되면 좋겠어요.

Aurora 는 Reader 의 캐시를 어떻게 동기화할까

먼저 배경부터 짧게 짚고 갈게요. Aurora 는 인스턴스와 스토리지가 분리되어 있어요. Writer 는 데이터 페이지를 스토리지에 쓰지 않고 redo 로그만 보내고, 스토리지 계층이 그 로그로 페이지를 만들어요. Reader 는 같은 스토리지를 바라보지만 자기만의 버퍼풀을 갖고 있고요. Writer 가 어떤 페이지를 바꾸면 그 redo 로그가 Reader 에게 전달되고, Reader 는 버퍼풀에 그 페이지가 있으면 갱신해요.

이 구조에서 제가 알고 있는 규칙은 이랬어요.

  • DDL 중에서 COPY 또는 rebuild 를 동반하는 INPLACE 알고리즘이 사용되는 경우(컬럼 타입 변경, NOT NULL 변경 등)에는 내부적으로 새 테이블을 만들어 데이터를 옮기고 옛 테이블을 지워요. InnoDB 에서는 tablespace 가 새로 생기는 것이라 (SPACE id 가 바뀜), Reader 가 갖고 있던 옛 tablespace 의 캐시 페이지는 전부 의미가 없어져요. 즉 Reader 버퍼풀에서 해당 테이블의 캐시된 페이지는 소멸하게 돼요.
  • DDL 중에서 INSTANT 또는 rebuild 없는 INPLACE 알고리즘이 사용되는 경우에는 기존 테이블의 데이터·인덱스 페이지를 손대지 않아요. 인덱스 INVISIBLE 이나 컬럼 DEFAULT 변경처럼 데이터 딕셔너리만 고치거나, CREATE INDEX 처럼 새 B-tree 를 하나 더 만들 뿐이에요. 그래서 Reader 버퍼풀에서 해당 테이블의 캐시된 페이지는 그대로 유지돼야 해요.

두 번째 줄이 오늘 이야기의 전제이자, 깨진 가정이에요.

발단: 미사용 인덱스 6개

당근의 한 서비스는 Aurora MySQL 3.10.x 클러스터를 Writer 1대 + Reader 3대로 운영하고 있어요. 이 클러스터에는 인덱스가 20개 넘게 달린 테이블이 있었어요. 인덱스가 많으면 쓰기 비용이 늘고, 옵티마이저가 실제 쿼리 실행에 사용되지 않는 인덱스까지 평가하느라 실행 계획을 세우는 비용도 커져요. 그래서 안 쓰는 인덱스를 정리하기로 했어요.

미사용 판정은 performance_schema.table_io_waits_summary_by_index_usage 를 썼어요. 이 테이블은 인스턴스가 시작된 이후 인덱스별 읽기/쓰기 횟수를 누적하니까, 일주일 간격으로 두 번 스냅샷을 떠서 그 사이에 count_read 가 늘지 않은 인덱스를 골라냈어요.

SELECT object_name, index_name, count_star, count_read, count_write
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_name = 'store_reviews';

(여기서 한 가지 팁. 이 조회는 반드시 모든 노드에서 해야 해요. 노드별로 쿼리 패턴이 완전히 달라서, Writer 에서 count_read = 0 인 인덱스가 특정 Reader 에서는 많이 사용될 수 있거든요. 4개 노드 × 2주 동안 스냅샷 모두에서 읽기가 0 인 인덱스 6개를 최종 후보로 골랐어요.)

바로 DROP 하지는 않았어요. 인덱스를 INVISIBLE 로 바꿔서 옵티마이저가 못 보게 만든 뒤 며칠 관찰하고, 문제가 없으면 DROP 하는 절차로 진행하려고 했어요. INVISIBLE 은 인덱스 자체는 그대로 두니까 무언가 잘못되면 VISIBLE 로 즉시 되돌릴 수 있어요. 메타데이터만 바꾸는 작업이니 부담 없이, 인덱스 하나씩 텀을 두고 실행했어요.

ALTER TABLE store_reviews ALTER INDEX idx_sentiment_store_id_score_created_at INVISIBLE;
ALTER TABLE store_reviews ALTER INDEX idx_store_id_score_created_at INVISIBLE;
ALTER TABLE store_reviews ALTER INDEX idx_region_id_score_category_id INVISIBLE;

현상: Writer 는 조용한데 Reader 만 튄다

CloudWatch 를 보니 ALTER INDEX INVISIBLE 세 번에 Reader 스파이크 세 번이 정확히 1:1 로 대응하고 있었어요.

그림 1. INVISIBLE 3회에 1:1 로 대응하는 Reader 의 CPU · ReadIOPS 스파이크 (Reader 3대 중 1대)

Reader 3대가 동시에 튀었어요.

  • ReadIOPS 는 평소 수백에서 3.1K 로 올라갔어요.
  • CPU 는 30% 에서 50~60% 로 올라갔어요.
  • BufferCacheHitRatio 는 순간적으로 떨어졌어요.

같은 시각 Writer 는 ReadIOPS / CPU / BufferCacheHitRatio 모두 평소와 같았어요.

BufferCacheHitRatio 가 떨어지고 ReadIOPS 가 튄다는 건, 버퍼풀에서 데이터 페이지가 순간적으로 사라져 스토리지에서 다시 읽어 오는 상황이 아닐까 의심했어요.

그래서 INDEX INVISIBLE DDL 직전과 직후에 Reader 에서 인덱스별 캐시 페이지 수가 변경되는 게 있는지 확인해 봤어요.

SELECT INDEX_NAME, COUNT(*) AS pages, ROUND(COUNT(*) * 16 / 1024) AS size_mb
FROM information_schema.INNODB_BUFFER_PAGE
WHERE TABLE_NAME LIKE '%store_reviews%'
GROUP BY INDEX_NAME
ORDER BY pages DESC;

(위 쿼리를 할 때 주의할 점이 하나 있어요. 이 뷰는 버퍼풀 전체를 훑기 때문에 버퍼풀이 큰 인스턴스에서는 조회 자체가 수 초 걸리고 부하도 줘요. 운영 중에 습관처럼 돌리는 쿼리는 아니에요. 공식 문서도 운영 시스템에서는 성능 영향을 확인한 뒤에만 조회하라고 경고해요.)

-- DDL 직전
INDEX_NAME pages size_mb
---------------------------------------------------------------
PRIMARY 252527 3946
idx_author_id_visibility_score 33963 531
idx_author_id_created_at_product_id_visibility 33456 523
idx_author_id_product_id_created_at 30904 483
…
합계 511866 7998

-- DDL 직후
INDEX_NAME pages size_mb
---------------------------------------------------------------
PRIMARY 252656 3948
idx_author_id_visibility_score 33965 531
idx_author_id_created_at_product_id_visibility 33464 523
idx_author_id_product_id_created_at 30916 483
…
합계 511847 7998

위 결과에서 볼 수 있듯 INDEX INVISIBLE 작업 이후에도 Reader 에서 해당 테이블이 메모리에 캐시된 페이지 수는 변하지 않았어요.
그렇다면 왜 Reader 버퍼풀의 페이지 수는 그대로인데 스토리지 읽기만 폭증했던 걸까요?

버퍼풀 안에서 무슨 일이: IS_STALE

Reader 버퍼풀의 페이지 수는 그대로인데 스토리지 읽기가 폭증한다는 건, 페이지가 메모리 안에서 더 이상 쓰이지 못하게 무효화(invalidate)되는 건 아닐까 하는 의심이 들었어요.

이 의문에 대한 답은 information_schema.INNODB_BUFFER_PAGE 에서 찾을 수 있었어요. 이 뷰에는 IS_STALE 이라는 컬럼이 있는데, MySQL 8.0.24 에 추가된 표준 컬럼이에요 (Aurora 전용이 아니에요). 이 컬럼은 더 이상 메모리에서 유효하지 않은 페이지를 나타내요.

원래 용도는 이래요. MySQL 8.0.23 에서 InnoDB 는 큰 테이블을 DROP 하거나 TRUNCATE 할 때의 성능을 개선했어요. 이전에는 tablespace 를 지우면서 버퍼풀 전체를 훑어 그 tablespace 의 페이지를 즉시 걷어냈는데, 버퍼풀이 큰 인스턴스에서는 이 스캔 자체가 큰 부하였거든요. 8.0.23 부터는 페이지를 바로 지우지 않아요. 삭제된 tablespace 쪽에 “삭제됨” 표시와 새 버전 번호만 남겨 두고, 이후 조회나 LRU 스캔이 그 tablespace 의 페이지를 만나면 그때 버려요 (WL#14100).

8.0.24 는 이 상태를 INNODB_BUFFER_PAGE 의 IS_STALE 컬럼으로 볼 수 있게 했어요. 다만 페이지마다 플래그를 써 두는 건 아니에요. 페이지는 자기가 올라올 때의 tablespace 버전을 기억하고 있고, 만날 때마다 그 버전이 tablespace 의 현재 버전과 다른지 비교해서 stale 로 판정해요. IS_STALE 은 조회하는 시점에 그 판정 결과를 YES/NO 로 보여 주는 거예요. 즉 커뮤니티 MySQL 에서 stale 은 “삭제되어 더 이상 참조될 일이 없는 tablespace 의 페이지라서, 접근되거나 LRU 스캔에 걸릴 때 걷어낼 대상” 이라는 뜻이에요.

처음 가졌던 의문인 “Aurora 에서는 혹시 인덱스 작업 시 Reader 에서 해당 페이지들이 더 이상 유효하지 않게 되는 거 아닐까?” 를 확인해 보기 위해, Aurora Reader 한 대에서 INDEX INVISIBLE 작업 직전과 직후로 IS_STALE 값을 비교해 보았어요. (지면상 몇 개의 인덱스만 표시했어요)

SELECT TABLE_NAME, INDEX_NAME,
COUNT(*) AS pages,
SUM(IS_STALE = 'YES') AS stale_pages,
ROUND(SUM(IS_STALE = 'YES') / COUNT(*) * 100, 1) AS stale_pct
FROM information_schema.INNODB_BUFFER_PAGE
WHERE TABLE_NAME LIKE '%store_reviews%'
GROUP BY TABLE_NAME, INDEX_NAME
ORDER BY stale_pages DESC;
-- INDEX INVISIBLE 직전 (평상시)
INDEX_NAME pages stale_pages stale_pct
------------------------------------------------------------------
PRIMARY 252527 0 0
idx_user_id_created_at_visibility 21316 0 0
idx_author_id_visibility_score 33963 0 0

-- INDEX INVISIBLE 수행

ALTER TABLE store_reviews ALTER INDEX idx_user_id_created_at_visibility INVISIBLE;

-- INDEX INVISIBLE 직후
INDEX_NAME pages stale_pages stale_pct
------------------------------------------------------------------
PRIMARY 252656 245625 97.2
idx_user_id_created_at_visibility 21254 20874 98.2
idx_author_id_visibility_score 33965 32111 94.5

이 표에서 확인할 수 있는 건 세 가지예요.

  • pages 는 그대로인데 stale_pages 만 급증했어요. 페이지가 버퍼풀에서 사라진 게 아니라, “유효하지 않음” 상태로 바뀌었어요. 그래서 페이지 수는 그대로인데 스토리지 읽기만 폭증한 거죠.
  • 같은 시각 Writer 는 stale 이 0 이었어요. Reader 쪽에서만 일어나는 무효화였어요.
  • INVISIBLE 로 바꾼 인덱스는 하나인데, PRIMARY (데이터 페이지) 와 나머지 모든 인덱스까지 94~98% 가 stale 이 됐어요. 무효화 단위가 인덱스가 아니라 테이블 전체예요. (stale 이 100% 가 아닌 이유는, 실시간으로 조회하는 쿼리들이 해당 페이지를 다시 스토리지에서 읽어왔기 때문이에요)

결과적으로 Aurora 는 INDEX INVISIBLE 작업 시 Reader 에서 버퍼풀에 있는 해당 테이블의 페이지를 “유효하지 않음” 상태로 만들게 되고, 해당 페이지 읽기 요청이 오면 스토리지에서 새로 읽어 오는 것으로 확인했어요. 이러한 동작으로 인해 우리가 처음에 겪었던 상황인 INDEX INVISIBLE 직후 Reader 에서 CPU / ReadIOPS / BufferCacheHitRatio 가 모두 튀는 상황이 발생했던 거죠.

재현: DDL 종류별로 Reader 버퍼풀은 어떻게 반응할까

그럼 혹시 INDEX INVISIBLE 작업 시에만 이렇게 동작하는 걸까? 아니면 다른 DDL 도 같은 문제가 발생할까? 이 답을 얻기 위해 DDL 알고리즘 종류별로 아래 7가지 케이스를 테스트했어요.

  • INSTANT : ADD COLUMN
  • INPLACE, rebuild 없음 : ALTER COLUMN … SET DEFAULT
  • INPLACE, rebuild 있음 : MODIFY COLUMN … NOT NULL
  • COPY : MODIFY COLUMN (데이터 타입 변경)
  • INDEX 관련 DDL : CREATE / DROP / RENAME INDEX

각 케이스 모두 아래 쿼리로 버퍼풀 상태를 조회했어요. (a, b 는 SPACE 없이 조회했어요)

SELECT GROUP_CONCAT(DISTINCT SPACE ORDER BY SPACE) AS spaces,
TABLE_NAME, INDEX_NAME, COUNT(*) AS pages, ROUND(COUNT(*) * 16384 / 1024 / 1024, 1) AS size_mb,
SUM(IS_STALE = 'YES') AS stale_pages, ROUND(SUM(IS_STALE = 'YES') / COUNT(*) * 100, 1) AS stale_pct
FROM information_schema.INNODB_BUFFER_PAGE
WHERE TABLE_NAME LIKE '%order_logs%'
GROUP BY TABLE_NAME, INDEX_NAME
ORDER BY stale_pages DESC LIMIT 30;

a. ADD COLUMN (INSTANT)

# Reader 버퍼풀 상태 조회
reader> (버퍼풀 조회 쿼리)
+---------------------+------------+-------+---------+-------------+-----------+
| TABLE_NAME | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |
+---------------------+------------+-------+---------+-------------+-----------+
| `test`.`order_logs` | idx_order | 262 | 4.1 | 0 | 0.0 |
| `test`.`order_logs` | PRIMARY | 869 | 13.6 | 0 | 0.0 |
+---------------------+------------+-------+---------+-------------+-----------+
2 rows in set (0.38 sec)

# Writer DDL 수행
writer> alter table order_logs add column tt int,algorithm=instant;
Query OK, 0 rows affected (0.21 sec)
Records: 0 Duplicates: 0 Warnings: 0

# Reader 버퍼풀 상태 조회
reader> (버퍼풀 조회 쿼리)
Empty set (0.46 sec)

reader> select * from order_logs limit 1;
+----+----------+---------+-------------+---------------------+------+
| id | order_id | action | log_message | logged_at | tt |
+----+----------+---------+-------------+---------------------+------+
| 4 | 42986 | shipped | log entry 4 | 2026-03-22 11:09:07 | NULL |
+----+----------+---------+-------------+---------------------+------+
1 row in set (0.01 sec)

reader> (버퍼풀 조회 쿼리)
+---------------------+------------+-------+---------+-------------+-----------+
| TABLE_NAME | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |
+---------------------+------------+-------+---------+-------------+-----------+
| `test`.`order_logs` | idx_order | 262 | 4.1 | 0 | 0.0 |
| `test`.`order_logs` | PRIMARY | 869 | 13.6 | 0 | 0.0 |
+---------------------+------------+-------+---------+-------------+-----------+

→ Reader 버퍼풀 영향 없음 (stale page = 0)

(DDL 직후 첫 조회가 Empty set 인 건 테이블 정의 캐시가 새로 고쳐지면서 TABLE_NAME 매핑이 잠시 비는 것이고, 캐시된 페이지 자체가 없어진 건 아니에요. SELECT ... LIMIT 1 로 테이블을 다시 열면 페이지가 그대로 보여요. 아래 케이스들도 같아요.)

b. ALTER COLUMN SET DEFAULT (INPLACE / NO REBUILD)

# Reader 버퍼풀 상태 조회
reader> (버퍼풀 조회 쿼리)
+---------------------+------------+-------+---------+-------------+-----------+
| TABLE_NAME | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |
+---------------------+------------+-------+---------+-------------+-----------+
| `test`.`order_logs` | idx_order | 262 | 4.1 | 0 | 0.0 |
| `test`.`order_logs` | PRIMARY | 869 | 13.6 | 0 | 0.0 |
+---------------------+------------+-------+---------+-------------+-----------+

# Writer DDL 수행
writer> alter table order_logs alter column tt set default 0,algorithm=inplace;
Query OK, 0 rows affected (0.01 sec)
Records: 0 Duplicates: 0 Warnings: 0

# Reader 버퍼풀 상태 조회
reader> (버퍼풀 조회 쿼리)
Empty set (0.41 sec)

reader> select * from order_logs limit 1;
1 row in set (0.00 sec)

reader> (버퍼풀 조회 쿼리)
+---------------------+------------+-------+---------+-------------+-----------+
| TABLE_NAME | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |
+---------------------+------------+-------+---------+-------------+-----------+
| `test`.`order_logs` | idx_order | 262 | 4.1 | 0 | 0.0 |
| `test`.`order_logs` | PRIMARY | 869 | 13.6 | 0 | 0.0 |
+---------------------+------------+-------+---------+-------------+-----------+

→ Reader 버퍼풀 영향 없음 (stale page = 0)

c. MODIFY COLUMN … NOT NULL (INPLACE / REBUILD)

# Reader 버퍼풀 상태 조회
reader> (SPACE 포함 버퍼풀 조회 쿼리)
+--------+---------------------+---------------+-------+---------+-------------+-----------+
| spaces | TABLE_NAME | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |
+--------+---------------------+---------------+-------+---------+-------------+-----------+
| 98989 | `test`.`order_logs` | idx_logged_at | 258 | 4.0 | 0 | 0.0 |
| 98989 | `test`.`order_logs` | idx_order | 260 | 4.1 | 0 | 0.0 |
| 98989 | `test`.`order_logs` | PRIMARY | 844 | 13.2 | 0 | 0.0 |
+--------+---------------------+---------------+-------+---------+-------------+-----------+
3 rows in set (0.15 sec)

# Writer DDL 수행
writer> alter table order_logs modify column log_message varchar(256) not null, algorithm=inplace, lock=none;
Query OK, 0 rows affected (1.36 sec)
Records: 0 Duplicates: 0 Warnings: 0

# Reader 버퍼풀 상태 조회
reader> (SPACE 포함 버퍼풀 조회 쿼리)
Empty set (0.26 sec)

reader> select * from order_logs limit 1;
+----+----------+-----------+-------------+---------------------+
| id | order_id | action | log_message | logged_at |
+----+----------+-----------+-------------+---------------------+
| 1 | 64088 | cancelled | log entry 1 | 2026-03-23 19:05:10 |
+----+----------+-----------+-------------+---------------------+
1 row in set (0.03 sec)

reader> (SPACE 포함 버퍼풀 조회 쿼리)
+--------+---------------------+------------+-------+---------+-------------+-----------+
| spaces | TABLE_NAME | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |
+--------+---------------------+------------+-------+---------+-------------+-----------+
| 98990 | `test`.`order_logs` | PRIMARY | 2 | 0.0 | 0 | 0.0 |
+--------+---------------------+------------+-------+---------+-------------+-----------+
1 row in set (0.17 sec)

→ tablespace 변경(98989→98990)으로 Reader 에서 해당 테이블의 캐시된 페이지 전부 소멸 (예상된 동작)

d. MODIFY COLUMN (COPY)

# Reader 버퍼풀 상태 조회
reader> (SPACE 포함 버퍼풀 조회 쿼리)
+--------+---------------------+------------+-------+---------+-------------+-----------+
| spaces | TABLE_NAME | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |
+--------+---------------------+------------+-------+---------+-------------+-----------+
| 98423 | `test`.`order_logs` | idx_order | 186 | 2.9 | 0 | 0.0 |
| 98423 | `test`.`order_logs` | PRIMARY | 834 | 13.0 | 0 | 0.0 |
+--------+---------------------+------------+-------+---------+-------------+-----------+

# Writer DDL 수행
writer> alter table order_logs modify order_id bigint not null,algorithm=copy;
Query OK, 221640 rows affected (6.87 sec)
Records: 221640 Duplicates: 0 Warnings: 0

# Reader 버퍼풀 상태 조회
reader> select * from order_logs limit 1;
1 row in set (0.03 sec)

reader> (SPACE 포함 버퍼풀 조회 쿼리)
+--------+---------------------+------------+-------+---------+-------------+-----------+
| spaces | TABLE_NAME | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |
+--------+---------------------+------------+-------+---------+-------------+-----------+
| 98424 | `test`.`order_logs` | PRIMARY | 2 | 0.0 | 0 | 0.0 |
+--------+---------------------+------------+-------+---------+-------------+-----------+

→ tablespace 변경(98423→98424)으로 Reader 에서 해당 테이블의 캐시된 페이지 전부 소멸 (예상된 동작)

e. CREATE INDEX

# Reader 버퍼풀 상태 조회
reader> (SPACE 포함 버퍼풀 조회 쿼리)
+--------+---------------------+---------------+-------+---------+-------------+-----------+
| spaces | TABLE_NAME | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |
+--------+---------------------+---------------+-------+---------+-------------+-----------+
| 98992 | `test`.`order_logs` | idx_logged_at | 200 | 3.1 | 0 | 0.0 |
| 98992 | `test`.`order_logs` | PRIMARY | 831 | 13.0 | 0 | 0.0 |
+--------+---------------------+---------------+-------+---------+-------------+-----------+
2 rows in set (0.24 sec)

# Writer DDL 수행
writer> alter table order_logs add index idx_action(action);
Query OK, 0 rows affected (0.84 sec)
Records: 0 Duplicates: 0 Warnings: 0

# Reader 버퍼풀 상태 조회
reader> (SPACE 포함 버퍼풀 조회 쿼리)
Empty set (0.28 sec)

reader> select * from order_logs limit 1;
+----+----------+---------+-------------+---------------------+
| id | order_id | action | log_message | logged_at |
+----+----------+---------+-------------+---------------------+
| 1 | 89836 | shipped | log entry 1 | 2026-04-09 23:50:37 |
+----+----------+---------+-------------+---------------------+
1 row in set (0.02 sec)

reader> (SPACE 포함 버퍼풀 조회 쿼리)
+--------+---------------------+---------------+-------+---------+-------------+-----------+
| spaces | TABLE_NAME | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |
+--------+---------------------+---------------+-------+---------+-------------+-----------+
| 98992 | `test`.`order_logs` | PRIMARY | 831 | 13.0 | 829 | 99.8 |
| 98992 | `test`.`order_logs` | idx_logged_at | 200 | 3.1 | 200 | 100.0 |
+--------+---------------------+---------------+-------+---------+-------------+-----------+
2 rows in set (0.23 sec)

→ Reader 에서 해당 테이블의 캐시된 페이지 전부 무효화 (⚠️ 예상 외)

f. DROP INDEX

# Reader 버퍼풀 상태 조회
reader> (SPACE 포함 버퍼풀 조회 쿼리)
+--------+---------------------+---------------+-------+---------+-------------+-----------+
| spaces | TABLE_NAME | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |
+--------+---------------------+---------------+-------+---------+-------------+-----------+
| 98992 | `test`.`order_logs` | idx_logged_at | 200 | 3.1 | 0 | 0.0 |
| 98992 | `test`.`order_logs` | idx_order | 186 | 2.9 | 0 | 0.0 |
| 98992 | `test`.`order_logs` | PRIMARY | 831 | 13.0 | 0 | 0.0 |
+--------+---------------------+---------------+-------+---------+-------------+-----------+
3 rows in set (0.30 sec)

# Writer DDL 수행
writer> alter table order_logs drop index idx_order;
Query OK, 0 rows affected (0.02 sec)
Records: 0 Duplicates: 0 Warnings: 0

# Reader 버퍼풀 상태 조회
reader> (SPACE 포함 버퍼풀 조회 쿼리)
Empty set (0.27 sec)

reader> select * from order_logs limit 1;
+----+----------+---------+-------------+---------------------+
| id | order_id | action | log_message | logged_at |
+----+----------+---------+-------------+---------------------+
| 1 | 89836 | shipped | log entry 1 | 2026-04-09 23:50:37 |
+----+----------+---------+-------------+---------------------+
1 row in set (0.02 sec)

reader> (SPACE 포함 버퍼풀 조회 쿼리)
+--------+---------------------+---------------+-------+---------+-------------+-----------+
| spaces | TABLE_NAME | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |
+--------+---------------------+---------------+-------+---------+-------------+-----------+
| 98992 | `test`.`order_logs` | PRIMARY | 831 | 13.0 | 829 | 99.8 |
| 98992 | `test`.`order_logs` | idx_logged_at | 200 | 3.1 | 200 | 100.0 |
+--------+---------------------+---------------+-------+---------+-------------+-----------+
2 rows in set (0.27 sec)

→ Reader 에서 해당 테이블의 캐시된 페이지 전부 무효화 (⚠️ 예상 외)

g. RENAME INDEX

# Reader 버퍼풀 상태 조회
reader> (SPACE 포함 버퍼풀 조회 쿼리)
+--------+---------------------+---------------+-------+---------+-------------+-----------+
| spaces | TABLE_NAME | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |
+--------+---------------------+---------------+-------+---------+-------------+-----------+
| 98992 | `test`.`order_logs` | idx_logged_at | 200 | 3.1 | 0 | 0.0 |
| 98992 | `test`.`order_logs` | idx_order | 186 | 2.9 | 0 | 0.0 |
| 98992 | `test`.`order_logs` | PRIMARY | 831 | 13.0 | 0 | 0.0 |
+--------+---------------------+---------------+-------+---------+-------------+-----------+
3 rows in set (0.24 sec)

# Writer DDL 수행
writer> alter table order_logs rename index idx_logged_at to removed_idx_logged_at;
Query OK, 0 rows affected (0.02 sec)
Records: 0 Duplicates: 0 Warnings: 0

# Reader 버퍼풀 상태 조회
reader> (SPACE 포함 버퍼풀 조회 쿼리)
Empty set (0.26 sec)

reader> select * from order_logs limit 1;
+----+----------+---------+-------------+---------------------+
| id | order_id | action | log_message | logged_at |
+----+----------+---------+-------------+---------------------+
| 1 | 89836 | shipped | log entry 1 | 2026-04-09 23:50:37 |
+----+----------+---------+-------------+---------------------+
1 row in set (0.02 sec)

reader> (SPACE 포함 버퍼풀 조회 쿼리)
+--------+---------------------+-----------------------+-------+---------+-------------+-----------+
| spaces | TABLE_NAME | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |
+--------+---------------------+-----------------------+-------+---------+-------------+-----------+
| 98992 | `test`.`order_logs` | PRIMARY | 831 | 13.0 | 829 | 99.8 |
| 98992 | `test`.`order_logs` | removed_idx_logged_at | 200 | 3.1 | 200 | 100.0 |
| 98992 | `test`.`order_logs` | idx_order | 186 | 2.9 | 186 | 100.0 |
+--------+---------------------+-----------------------+-------+---------+-------------+-----------+
3 rows in set (0.23 sec)

→ Reader 에서 해당 테이블의 캐시된 페이지 전부 무효화 (⚠️ 예상 외)

정리하면 아래와 같아요.

그림 2. DDL 종류별 Reader 버퍼풀 반응 요약 (a~h) (h 는 앞 운영 실측(INVISIBLE))

a, b 처럼 INSTANT 혹은 테이블 rebuild 가 발생하지 않는 INPLACE DDL 은 Reader 버퍼풀에 아무 영향을 주지 않아요.

c, d 처럼 COPY 혹은 테이블 rebuild 가 발생하는 INPLACE DDL 은 테이블이 다시 만들어져 tablespace 가 바뀌므로 캐시가 사라지는 건 정상이에요.

하지만 tablespace 가 그대로인 인덱스 DDL 작업 e, f, g, h 에서 테이블 전체가 무효화되는 건 예상 밖의 동작이었어요.

원인: Aurora MySQL 3.08.0 버전의 결함

기술 지원을 통해서 위 테스트 내용을 첨부하며 “tablespace 가 바뀌지 않는 인덱스 DDL 에서 Reader 버퍼풀이 초기화되는 게 버그인가요, 의도된 동작인가요?” 를 문의했어요.

얼마 후 Aurora MySQL 서비스팀의 조사 결과가 왔어요. 요지는 이랬어요.

  • 확인 결과 INDEX DDL 작업이 Reader 에서 해당 테이블의 버퍼풀 페이지 무효화를 트리거한 게 맞고, INDEX 작업은 tablespace 와 데이터 페이지가 바뀌지 않는 metadata-only 작업이므로 이 무효화는 원래 불필요한 동작.
  • 이 현상은 Aurora MySQL 3.08.0 에서 기능 개선 일환으로 도입되었고, INDEX INVISIBLE, CREATE INDEX, DROP INDEX 같은 DDL 에 의도치 않게 필요 이상으로 광범위한 무효화가 적용되고 있음.
  • 엔진 결함으로 식별하였고, metadata-only DDL 은 Reader 에서 테이블 정의 캐시만 새로 고치도록 하는 수정을 향후 릴리즈에 포함할 예정. 그전까지는 인덱스 DDL 을 저트래픽 시간대에 실행하기를 권고.

해결 방안

이후 기술 지원에서 Aurora MySQL 3.14 에 위 이슈에 대한 fix 가 포함될 예정이라는 답을 받았어요.

영향을 받는 건 이 동작이 들어온 3.08.0 부터 3.14 이전까지의 버전이라, 이 구간을 쓰고 있다면 3.14 가 나온 뒤 업그레이드하는 것이 근본적인 해결이에요.

다만 3.14 이전 버전으로 백포트할 계획은 현재로서는 없다고 해요. 당근 DB팀에서는 LTS 버전인 3.10.x 를 쓰고 있기 때문에 3.14 가 나와도 바로 올리기 어려워서, fix 가 포함된 다음 LTS 를 기다리고 있어요.

그 전까지는 인덱스 DDL 을 “Reader 버퍼풀을 비우는 작업” 으로 보고 아래와 같은 기준으로 운영하면 좋을 것 같아요.

  • 건수가 많고 트래픽이 높은 테이블일수록 버퍼풀 무효화 뒤 다시 데워지는 동안 ReadIOPS 와 CPU 가 크게 튈 수 있어서, 트래픽이 적은 시간대에 실행해요.
  • 인덱스 여러 개를 바꿀 땐 한 ALTER 문으로 묶어요. Reader 버퍼풀 무효화를 N 번에서 1 번으로 줄일 수 있어요.

마무리

인덱스 작업 하나가 모든 Reader 의 버퍼풀을 비우는 동작은 서비스에 따라 치명적으로 느껴질 수 있어요. 다행히 AWS 가 빠르게 결함을 확인한 뒤 fix 까지 진행해 줘서, 다음 버전부터는 같은 이슈가 재발하지 않을 것으로 기대하고 있어요.

비슷한 이슈를 겪으셨거나 앞으로 겪을 수 있는 분들께 조금이나마 도움이 되면 좋겠어요.

긴 글 읽어 주셔서 감사해요.

참고

당근에서 함께 데이터베이스를 고민하고 싶다면 여기를 눌러 당근 채용 공고를 확인해보세요! https://team.daangn.com/jobs/

Aurora MySQL 인덱스 DDL이 Reader 버퍼풀을 비운다? was originally published in 당근 기술 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.

← 목록으로