Skip to Content
← 블로그
개선기

인덱스는 공짜가 아닙니다 — 값을 치르고 운영에 넣기까지

같은 3개라도 난수 키 하나가 나머지 둘보다 비싸고, LOCK=NONE은 무중단이 아니다

만화로 보는 요약 — 먼저 읽어보세요
만화로 보는 요약 — 먼저 읽어보세요

1편에서 진단과 설계를 마쳤습니다. 거래 로그 테이블 4종에 인덱스가 PK 하나뿐이라 거래번호 조회가 45초 걸렸고, 원인은 풀스캔이었습니다. 단일 컬럼 인덱스 11개를 넣기로 정했고, 환경별로 반영 경로를 셋으로 나눴습니다.

그런데 넣기로 정하는 것실제로 넣는 것은 다른 일이었습니다. 이 글은 그 사이에 있는 것들입니다.

  • : 인덱스 개수는 쓰기 비용의 단위가 아니었습니다. 같은 3개라도 키가 단조 증가냐 난수냐가 훨씬 크게 갈립니다
  • 운영 투입: LOCK=NONE은 무중단이 아니고, 우리 시스템에는 그 지연이 결제 경로까지 번지는 경로가 있었습니다
  • 검증: 개발환경에서 1,881ms → 0.108ms. 운영 수치는 아직 비어 있습니다

1️⃣ 인덱스 11개는 공짜가 아닙니다 — 그런데 셋 중 하나만 비쌉니다

이 테이블들은 결제 요청마다 INSERT되는 핫패스입니다. 인덱스를 얹으면 쓰기가 그만큼 비싸집니다.

처음엔 "테이블당 3개니까 INSERT당 B-tree 갱신 3회, 수십 μs" 정도로 뭉뚱그렸습니다. 다시 보니 셋의 성격이 질적으로 달랐습니다.

인덱스 키값의 성질삽입 위치
create_time단조 증가항상 가장 오른쪽 리프
transaction_no날짜 프리픽스라 준단조대체로 오른쪽
correlationCUID2 난수매번 다른 리프

B-tree는 키 순서로 정렬된 자료구조라 삽입 위치가 곧 비용입니다.

단조 증가 키는 늘 맨 오른쪽 리프에 붙습니다. 더티가 되는 페이지가 하나뿐이고, 그 페이지가 차면 오른쪽에 새 페이지를 다는 것으로 끝납니다. 반면 난수 키는 매번 다른 리프에 떨어집니다. 손대는 페이지가 흩어지니 더티 페이지가 늘고, 페이지 중간에 끼워 넣다 자리가 없으면 페이지 분할이 일어납니다. 분할은 페이지를 반으로 쪼개 절반을 옮기는 작업이라 공간 점유율까지 떨어뜨립니다.

correlation이 정확히 후자입니다. CUID.randomCUID2()는 충돌 저항을 목표로 설계된 난수 문자열이라 시간 순서와 상관관계가 없습니다.

그런데 왜 견디느냐 — 체인지 버퍼입니다.

InnoDB는 non-unique 세컨더리 인덱스에 한해, 변경 대상 페이지가 버퍼풀에 없으면 곧바로 적용하지 않고 체인지 버퍼에 적어 둡니다. 나중에 그 페이지를 읽을 일이 생기거나 백그라운드가 정리할 때 병합합니다. 랜덤 쓰기 하나하나를 디스크 랜덤 I/O로 바꾸는 대신 모아서 처리하는 겁니다.

전제가 둘 붙습니다. 유니크 인덱스에는 안 통합니다 — 중복 검사를 하려면 어차피 페이지를 읽어야 하니까요. 그리고 페이지가 이미 버퍼풀에 있으면 체인지 버퍼를 거치지 않습니다.

이번 설계는 이 두 전제와 맞아떨어집니다. 인덱스 11개는 전부 non-unique이고, 로그 테이블은 최근 구간만 뜨겁습니다.

여기서 얻은 감각은 "인덱스 개수"가 쓰기 비용의 단위가 아니라는 것입니다. 같은 3개라도 키가 단조 증가인지 난수인지가 훨씬 크게 갈립니다. 나중에 쓰기 부하가 문제가 된다면 제일 먼저 의심할 건 correlation 하나입니다.

그 45초짜리 풀스캔은 왜 서비스를 죽이지 않았나

인덱스 빌드는 4.6GB를 읽어 정렬하므로 버퍼풀의 작업 세트를 밀어냅니다. ALTER 직후 결제 경로가 잠시 느려질 수 있다는 뜻입니다.

그런데 여기서 이상한 점이 하나 생깁니다. 인덱스가 없던 지난 몇 달 동안, 누군가 조사할 때마다 2.2GB 풀스캔이 돌았습니다. 그때마다 버퍼풀이 로그 페이지로 가득 찼다면 결제 경로도 매번 같이 느려졌어야 합니다. 그런 일은 없었습니다.

LRU 중간 지점 삽입 덕분입니다. InnoDB의 버퍼풀 LRU는 young/old 두 구간으로 나뉘어 있고(innodb_old_blocks_pct, 기본 37%), 새로 읽은 페이지는 리스트의 머리가 아니라 old 구간의 머리로 들어갑니다. 풀스캔이 한 번 읽고 다시 안 보는 페이지는 young 구간으로 승격되지 못한 채 old 구간에서 밀려납니다. 스캔이 핫 워킹셋을 통째로 쓸어내지 못하게 막는 장치입니다.

innodb_old_blocks_time도 같은 목적입니다 — old 구간에 들어온 뒤 이 시간 안에 다시 접근되면 승격시키지 않습니다. 한 스캔이 같은 페이지를 연달아 건드리는 것만으로 승격되는 걸 막습니다.

그러니 "풀스캔 45초"는 조사하는 사람만 아팠던 문제였습니다. 이걸 알고 나니 이번 작업의 성격이 조금 다르게 보였습니다. 장애를 막는 일이 아니라, 장애를 조사하는 사람의 시간을 되찾는 일이었습니다.


2️⃣ 운영에 어떻게 넣을 것인가 — "무중단"을 믿지 않기

ALGORITHM=INPLACE, LOCK=NONE을 걸었으니 무중단이라고 생각하기 쉽습니다. 그 말이 보장하는 범위가 생각보다 좁습니다.

먼저, 이 두 절이 정확히 무엇인가

ALTER TABLE에는 두 축이 있습니다. **작업을 어떻게 수행할지(ALGORITHM)**와 **그동안 동시성을 어디까지 허용할지(LOCK)**입니다.

ALGORITHM무엇을 하나비용
INSTANT데이터 딕셔너리만 수정. 테이블 데이터를 안 건드림크기와 무관하게 즉시
INPLACE테이블을 제자리에서 재구성. 전체 복사는 안 함데이터 스캔·정렬 필요. 크기에 비례
COPY테이블 전체를 새로 만들어 복사가장 비싸고, 작업 내내 쓰기 차단
LOCK읽기쓰기
NONE허용허용
SHARED허용차단
EXCLUSIVE차단차단

연산마다 가능한 조합이 다릅니다. 자주 쓰는 것만 추리면 이렇습니다.

연산최선의 ALGORITHM동시 DML
세컨더리 인덱스 추가INPLACELOCK=NONE
인덱스 삭제INPLACE (메타데이터만)✅ 빠름
컬럼 추가·삭제INSTANT
컬럼 이름 변경INPLACE (메타데이터만)
컬럼 타입 변경COPY쓰기 차단
문자셋 변경COPY
FULLTEXT 인덱스 추가INPLACELOCK=SHARED

버전마다 지원 범위가 바뀝니다. INSTANT만 해도 8.0.12에서 컬럼 추가(맨 뒤)로 시작해 8.0.29에서 임의 위치 추가·삭제까지 넓어졌습니다. 실제 작업 전에 해당 버전 문서를 확인하는 게 맞습니다.

인덱스 추가는 INSTANT가 안 됩니다. B-tree를 실제로 만들어야 하니 데이터를 읽어야 하고, INSTANT는 딕셔너리만 바꾸는 연산 전용이기 때문입니다. 그래서 우리가 고를 수 있는 최선이 INPLACE였습니다.

명시하는 이유 — 요청이 아니라 단언이다

1편에서 "생략하면 조용히 COPY로 떨어진다"고 했는데, 그 이유를 한 겹 더 들어가면 이렇습니다.

ALGORITHM=INPLACE, LOCK=NONE은 "이렇게 해줘"가 아니라 "이 조건을 못 지키면 실행하지 마라"입니다. 생략하면 MySQL이 알아서 낮춰 잡고, 조건이 안 맞으면 아무 말 없이 COPY로 내려갑니다. 명시하면 그 경우 에러로 실패합니다. 성능 지시가 아니라 가드레일입니다.

그래서 무엇이 가능한지 모르겠으면 명시해서 실패로 알아내면 됩니다.

-- ① 가장 싼 것부터
ALTER TABLE t ADD COLUMN c INT, ALGORITHM=INSTANT;

-- ② 실패하면 한 단계 내린다
ALTER TABLE t ADD COLUMN c INT, ALGORITHM=INPLACE, LOCK=NONE;

-- ③ 그것도 실패하면 COPY 뿐 → 운영에서는 여기서 멈추고 계획을 다시 짠다
--    (점검 시간 확보 · pt-online-schema-change / gh-ost 같은 외부 도구 검토)

실패는 즉시 납니다. 데이터를 만지기 전에 거부되니 비용이 없습니다.

그런데 LOCK=NONE도 무중단은 아니다

인덱스 추가는 세 구간으로 나뉘고, 지연이 생기는 이유가 구간마다 다릅니다.

구간소요DML지연 원인
① 준비순간잠깐 차단배타적 MDL 획득
② 빌드수 분허용I/O 경합, redo 압박, 버퍼풀 교란
③ 적용·커밋수 초잠깐 차단배타적 MDL 재획득

LOCK=NONE이 해당하는 건 ②번뿐입니다. ①과 ③은 테이블 정의를 바꾸는 순간이라 배타적 메타데이터 락(MDL)이 필요합니다. 정상이면 밀리초입니다.

MDL 대기는 큐를 만든다

문제는 못 잡을 때입니다. MDL은 트랜잭션이 끝나야 풀립니다. 그래서 이 연쇄가 생깁니다.

  1. 오래 열려 있는 트랜잭션이 대상 테이블의 공유 MDL을 쥐고 있다
  2. ALTER가 배타적 MDL을 요청하고 대기한다
  3. 그 뒤에 도착한 모든 쿼리가 ALTER 뒤에 줄을 선다 — 단순 SELECT까지

3번이 핵심입니다. 대기 중인 배타적 요청이 뒤따르는 공유 요청을 막습니다. 트랜잭션 하나 때문에 인덱스를 걸려던 작업이 테이블 전체를 세웁니다.

그리고 ③이 ①보다 위험합니다. ①은 시작이라 실패해도 잃는 게 없지만, ③은 수 분간 빌드를 끝낸 뒤입니다. 그 사이 누가 긴 트랜잭션을 열어두면 다 해놓고 마지막에 물립니다. 조사하겠다고 DataGrip에 쿼리 하나 띄워둔 채 두는 것만으로 걸립니다.

여기서 우리 시스템의 진짜 문제가 나왔습니다

"로그 적재는 @Async니까 결제 경로와 분리돼 있다"고 생각했습니다. 코드를 다시 봤더니 아니었습니다.

// AsyncConfig — @Primary, 즉 수식어 없는 @Async 가 전부 여기로 온다
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(200);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

CallerRunsPolicy. 큐가 가득 차면 호출자 스레드가 그 작업을 직접 실행합니다.

① 코어 4개가 INSERT 에서 블록
② 큐 200칸이 찬다
③ 풀이 8개까지 늘어난다 → 그것도 블록
④ 거부 → CallerRunsPolicy → 호출자가 직접 INSERT
                             ↑ LoggingFilter 를 부른 요청 스레드 = 결제 요청

@Async는 완충이지 차단벽이 아닙니다. 약 208건(스레드 8 + 큐 200)이 밀리면 그때부터 결제 스레드가 블로킹 INSERT를 떠안습니다. HTTP 요청 1건당 로그가 2건(요청+응답) 쌓이니, 초당 10요청이면 10초 남짓이면 그 지점에 닿습니다.

한 가지 더 있었습니다. LogService는 수식어 없는 @Async전용 loggingExecutor 빈이 따로 있는데도 @Primary인 전역 풀을 씁니다. 로그 적재 정체가 그 풀을 공유하는 다른 비동기 기능까지 같이 마비시킨다는 뜻입니다.

그래서 이건 인덱스 작업과 별개의 개선 과제로 적어 뒀습니다. @Async("loggingExecutor")로 바꿔 로그 적재의 역압(backpressure)을 전역 풀에서 떼어내는 것. 한 줄이면 되지만, 인덱스 작업과 같이 배포하면 문제가 생겼을 때 원인이 둘이 됩니다.

빌드 구간의 조용한 지연

MDL과 무관하게 ②번 구간에도 지연이 생깁니다. 이쪽은 날카롭지 않고 완만해서 놓치기 쉽습니다.

  • I/O 경합 — 2.2GB를 읽고, 정렬 임시 파일을 쓰고, 약 450MB 인덱스를 씁니다. tmpdir이 데이터와 같은 볼륨이면 경합이 두 배입니다.
  • redo 압박 — 인덱스 빌드는 대량 redo를 만듭니다. redo 용량이 부족하면 InnoDB가 공간 확보를 위해 모든 사용자 트랜잭션을 스로틀링합니다. MDL과 달리 빌드 내내 전 DML이 느려집니다. 과소평가하기 쉬운 항목입니다.
  • 복제 지연 — 복제본이 있다면 DDL은 복제 적용기에서 단일 스레드로 다시 실행됩니다. 복제본은 ALTER 전체 소요시간만큼 통째로 지연됩니다. 조회를 복제본에서 받는 경로가 있으면 그동안 낡은 데이터를 봅니다.

그래서 실제로 이렇게 합니다

-- [사전] redo 용량과 임시 공간 위치
SELECT @@innodb_redo_log_capacity / 1024 / 1024 AS redo_mb;
SELECT @@innodb_tmpdir, @@tmpdir, @@datadir;

-- [사전] 장수명 트랜잭션 — ①구간 직전, ③구간 직전 둘 다
SELECT trx_id, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec,
       trx_mysql_thread_id, LEFT(trx_query, 80)
FROM information_schema.innodb_trx ORDER BY trx_started;

-- [세션] 대기 대신 빨리 실패시킨다
SET SESSION lock_wait_timeout = 10;

-- [작업 중] MDL 대기가 생겼는지 — 보이면 즉시 판단
SELECT * FROM performance_schema.metadata_locks WHERE LOCK_STATUS = 'PENDING';

-- [작업 중] 진행률 (사전에 계측기 활성화 필요)
SELECT stage, work_completed, work_estimated,
       ROUND(work_completed / NULLIF(work_estimated,0) * 100, 1) AS pct
FROM performance_schema.events_stages_current;

lock_wait_timeout이 특히 중요합니다. 무한정 기다리는 게 가장 나쁩니다 — 기다리는 동안 뒤에 큐가 쌓이니까요. 10초 만에 실패하고 다시 시도하는 편이 항상 낫습니다.

그리고 규칙 세 가지.

  1. 작업 창 동안 이 테이블을 조회하는 세션이 하나도 없게 한다. 특히 DataGrip 열어두기 금지 — ③구간에서 물리는 원인 1순위입니다.
  2. 작은 테이블부터 친다 (1편에서 정한 순서). 앞의 둘로 온라인 DDL이 실제로 통과하는지와 소요 시간을 확인한 뒤 2GB짜리로 갑니다.
  3. 트래픽이 가장 적은 시간대. 위의 완충 208건이 소진되는 속도가 곧 트래픽이므로, 트래픽이 낮으면 같은 MDL 지연도 결제 경로까지 안 옵니다.

정리하면, 이 작업에서 "잠깐 느려진다"보다 나쁠 수 있는 구간은 ③ 적용·커밋 시점의 MDL 하나입니다. 나머지는 완만한 저하고 트래픽 적은 시간대면 대개 견딥니다. 그래서 대비도 그 한 점에 집중하는 게 맞습니다.


넣지 않기로 한 것들

인덱스는 11개 전부 단일 컬럼입니다. 복합 인덱스를 넣지 않았습니다.

커버링 인덱스를 안 쓴 것도 같은 이유입니다. 세컨더리 인덱스가 조회에 필요한 컬럼을 전부 들고 있으면 클러스터드 인덱스로 되돌아갈 필요가 없어집니다(이 왕복이 정확히 무엇인지는 바로 다음 절에서 쪼갭니다). 테이블 접근이 사라지고 EXPLAINExtraUsing index가 뜹니다. 예를 들어 이렇게 하면 요약 조회에서 왕복이 없어집니다.

-- 요약만 볼 때: (transaction_no, create_time, status) 로 커버
SELECT transaction_no, create_time, status FROM response_log WHERE transaction_no = ?;

넣지 않은 이유는 어떤 컬럼 조합을 자주 보는지 아직 모르기 때문입니다. 커버링 인덱스는 포함 컬럼만큼 인덱스가 커지고, 그만큼 쓰기 비용과 버퍼풀 점유가 늘어납니다. 조사 패턴이 굳기 전에 넣으면 안 쓰이는 큰 인덱스가 남습니다. 단건 추적은 어차피 결과가 한 자릿수라 왕복 몇 번을 아끼는 이득도 작습니다.

기간 조사 쪽도 마찬가지로 어떤 조건 조합이 자주 쓰이는지 데이터가 아직 없습니다. 실제 조사 쿼리가 쌓인 뒤 EXPLAIN을 보고 추가하는 게 맞다고 판단했습니다.

보관정책도 이번 범위에서 뺐습니다. 810만 행 5GB에 삭제·아카이빙 정책이 없다는 건 분명한 문제입니다. 인덱스를 넣어도 계속 쌓이면 언젠가 같은 자리로 돌아옵니다. 그런데 같이 하면 안 되는 이유가 있었습니다.

파티셔닝을 하려면 파티션 키가 모든 UNIQUE/PK에 포함되어야 합니다. 지금 PK가 id 단독이라, 시각 기준 파티셔닝을 하려면 PK를 복합키로 바꿔야 하고 → 엔티티 구조 변경 → 2.2GB 테이블 재구성으로 번집니다. 인덱스 추가와 묶는 순간 롤백 지점이 사라집니다.

대신 이번에 create_time 인덱스를 함께 넣었습니다. 기간 조사용이기도 하지만, 진짜 이유는 미래의 삭제 배치입니다. 언젠가 DELETE ... WHERE create_time < ?를 돌려야 하는데, 인덱스가 없으면 그 삭제 배치 자체가 풀스캔이라 운영 중에 못 돌립니다. 나중에 별도 ALTER를 다시 치는 것보다 지금 함께 넣는 게 쌉니다.

알아두면 좋은 것 하나. InnoDB는 DELETE해도 디스크를 OS에 반환하지 않습니다. 테이블스페이스 내부에서 재사용될 뿐이라 5GB는 그대로입니다. 회수하려면 OPTIMIZE TABLE인데 사실상 테이블 재구성이라 더 무겁습니다. 디스크 회수가 목적이면 파티셔닝이 정답이고, 그게 위 과제를 언젠가 해야 하는 이유입니다.


그래서 얼마나 빨라지나 — 기대치를 쪼개 보면

여기까지가 진단과 설계, 그리고 코드·문서 작업입니다. 운영 적용은 아직 하지 않았습니다.

기대치를 대충 "1ms"라고 던지지 않고 한 번 쪼개 보겠습니다. WHERE transaction_no = ? 한 건을 찾는 데 실제로 일어나는 일은 두 번의 트리 탐색입니다.

세컨더리 인덱스는 리프에 PK 값을 들고 있습니다. 인덱스에서 행을 찾았다고 끝이 아니라, 그 PK로 클러스터드 인덱스를 다시 타고 내려가야 실제 행에 닿습니다. 흔히 말하는 테이블 접근(bookmark lookup)이죠. 370만 행 규모면 각 트리 높이가 3 안팎이니 페이지 접근이 대략 6번입니다.

여기에 SELECT *행마다 페이지 요청이 하나씩 더 붙습니다.

⚠️ 정정 (2026-08). 원래 이 자리에 "1편에서 말한 JSON 오버플로 페이지를 더 읽습니다"라고 썼는데, 그 설명이 틀렸습니다. 이 JSON은 2.1KB라 오버플로 임계값(이 스키마에서 8,105바이트)에 한참 못 미쳐 페이지 안에 그대로 들어 있어요. 추가 요청은 붙는 게 맞지만 원인이 다릅니다 — 1편의 정정과 『타지 않는 인덱스』 5장을 보세요.

그래서 정확히 말하면 이렇습니다. 상위 노드가 버퍼풀에 상주하는 warm 상태라면 1ms 미만이고, 전부 디스크에서 읽어야 하는 cold 상태라면 랜덤 I/O 몇 번만큼 더 걸립니다. 어느 쪽이든 45초와는 자릿수가 다르지만, "무조건 1ms"는 아닙니다. 이건 선택도 382만분의 1과 트리 높이에서 나오는 산술적 기대치이지 측정값이 아닙니다.

검증 — 같은 쿼리 세 개를 전후로 돌린다

기대치는 기대치일 뿐이라, 전후를 같은 조건에서 재기로 했습니다. 아래 세 쿼리가 전부입니다.

준비 — 거래번호를 변수에 담는다

SET @transaction_no = '실제거래번호';   -- 세션에 한 번만

"AS-IS와 TO-BE가 같은 값을 써야 한다"는 걸 규율이 아니라 구조로 보장하려는 것입니다. 리터럴을 두 번 타이핑하면 언젠가 다른 값을 넣습니다. 변수에 담아두면 그럴 수가 없습니다.

덤으로 따옴표 실수도 한 번만 하면 됩니다. 이건 실전에서 제일 자주 물리는 함정입니다.

WHERE transaction_no = 20260806000123     -- ❌ 인덱스 무시
WHERE transaction_no = '20260806000123'   -- ✅

거래번호가 숫자처럼 생긴 문자열이라 따옴표를 빠뜨리기 쉽습니다. 그러면 MySQL이 컬럼 전체를 숫자로 캐스팅해서 비교하므로 인덱스를 못 씁니다. 에러도 안 납니다. 그냥 조용히 느립니다. 인덱스를 걸고 나서 "왜 그대로지?" 하게 만드는 1순위 원인입니다.

TO-BE를 재기 전에 반드시

ANALYZE TABLE request_log, response_log, back_request_log, back_response_log;

1편에서 옵티마이저 추정이 37만 배 틀렸던 이유가 "샘플링할 인덱스가 없어서"였습니다. 인덱스를 만들면 샘플링 대상은 생기지만, 통계가 그 순간 자동으로 갱신되지는 않습니다. 낡은 통계로 재면 인덱스를 만들어 놓고도 옵티마이저가 안 고를 수 있습니다 — 인덱스를 걸었는데 type: ALL이 그대로 나오는 당황스러운 상황이 여기서 나옵니다.

그리고 두 번 돌려 2회차를 기록합니다. AS-IS도 warm 상태에서 잰 값이라 조건을 맞춰야 공정합니다.


쿼리 ① 규모 — 인덱스가 정말 없는가

SELECT table_name, table_rows,
       ROUND(data_length /1024/1024, 1) AS data_mb,
       ROUND(index_length/1024/1024, 1) AS index_mb
FROM information_schema.tables
WHERE table_schema = DATABASE()
  AND table_name IN ('request_log','response_log','back_request_log','back_response_log');

AS-IS (2026-08-06 운영 실측)

back_request_log,383714,415.0,0.0
back_response_log,380046,482.0,0.0
request_log,3705149,2207.0,0.0
response_log,3632990,2015.0,0.0

index_mb가 네 테이블 모두 0.0. PK는 클러스터드라 data_length에 포함되므로, 세컨더리 인덱스가 하나도 없다는 뜻입니다.

TO-BE (측정 예정 — index_mb가 0 초과로 바뀌어야 합니다. 4개 합 1GB 내외 예상)

(측정 후 여기에 붙여넣기)

쿼리 ② 실행 계획 — 옵티마이저가 무엇을 고르는가

EXPLAIN SELECT * FROM request_log WHERE transaction_no = @transaction_no;

AS-IS

id, select_type, table,       partitions, type, possible_keys, key, key_len, ref, rows,    filtered, Extra
1,  SIMPLE,      request_log, ,           ALL,  ,              ,    ,        ,    3726611, 10,       Using where

type=ALL(풀스캔), key가 비어 있고(쓸 인덱스 없음), filtered=10. 이 10은 컬럼 분포를 계산한 값이 아니라 기본 상수입니다.

TO-BE (측정 예정 — typeref로, keyidx_request_log_transaction_no가, rows가 한 자릿수로 바뀌어야 합니다)

(측정 후 여기에 붙여넣기)

쿼리 ③ 실측 — 실제로 몇 초 걸리는가

EXPLAIN ANALYZE SELECT * FROM request_log WHERE transaction_no = @transaction_no;

EXPLAIN ANALYZE는 계획만 보는 게 아니라 쿼리를 실제로 실행합니다. AS-IS에서는 이 한 줄이 45초를 씁니다.

AS-IS

-> Filter: (request_log.transaction_no = <cache>((@transaction_no)))
     (cost=86661 rows=372663) (actual time=45358..45361 rows=1 loops=1)
    -> Table scan on request_log
         (cost=86661 rows=3.73e+6) (actual time=1.21..41174 rows=3.82e+6 loops=1)

Table scan이 실제로 3.82e+6행을 읽고, Filter가 남긴 건 1행. 총 45.36초, 그중 41.17초가 순수 스캔입니다.

TO-BE (측정 예정 — Table scan이 사라지고 Index lookup이 나타나야 합니다)

(측정 후 여기에 붙여넣기)

쿼리 ④ 인덱스가 실제로 붙었는지 (TO-BE 전용)

SHOW INDEX FROM request_log WHERE Key_name LIKE 'idx_%';

(측정 예정 — 3행. 네 테이블 합계 11개)


개발환경에서 먼저 돌려봤습니다

운영에 넣기 전에 개발 DB에 그대로 적용했습니다. 37만 행 / 325MB — 운영의 약 1/10 규모라 절대값은 운영에 대입할 수 없지만, 메커니즘이 예상대로 작동하는지는 여기서 확인됩니다.

항목AS-ISTO-BE
EXPLAIN typeALLref
EXPLAIN keyNULLidx_request_log_transaction_no
EXPLAIN key_len81
EXPLAIN rows (추정)373,89635
EXPLAIN filtered10100
EXPLAIN ExtraUsing whereNULL
옵티마이저 cost57,82134.8
실측 시간1,881 ms0.108 ms
index_mb (request_log)0.039.7

실행계획 최상단 한 줄이 통째로 바뀝니다.

AS-IS  -> Table scan on request_log
            (cost=57821 rows=373896) (actual time=0.856..1858 rows=376083 loops=1)

TO-BE  -> Index lookup on request_log using idx_request_log_transaction_no
            (transaction_no='...')
            (cost=34.8 rows=35) (actual time=0.0328..0.108 rows=35 loops=1)

1,881ms → 0.108ms, 약 17,400배. 그런데 이 숫자보다 흥미로운 게 둘 더 있었습니다.

① 추정이 정확해졌습니다. AS-IS는 rows=37390으로 추정했는데 실제는 35행 — 1,068배 틀렸습니다. TO-BE는 rows=35 추정에 실제 35행, 정확히 일치합니다. "인덱스가 없으면 옵티마이저에게 줄 정보가 없다"던 이야기의 결말이 여기서 나옵니다. 인덱스를 만들자 샘플링할 대상이 생겼고, 그래서 맞히기 시작한 겁니다.

filtered=10이 상수라는 게 여기서 더 깨끗하게 드러났습니다.

373,896 × 0.1 = 37,389.6  →  표시값 37,390

운영에서는 3,726,611의 10%가 372,661인데 표시값이 372,663이라 2 어긋났었습니다. 개발 데이터가 오히려 더 정확한 증거를 줬습니다 — 컬럼 분포를 계산한 값이 아니라 그냥 10%를 꽂은 것이 맞습니다.

덤 — key_len=81이 컬럼 정의를 역산해 줍니다

utf8mb4 char(20) = 20 × 4 = 80 바이트
NULL 허용 플래그  =          +1 바이트
                            ─────────
                                  81

NOT NULL이었다면 80이 나왔을 겁니다. 즉 이 컬럼은 NULL을 허용합니다.

1편 5장에서 "요청 로그의 거래번호는 요청 본문에서 뽑는데, 채번 전 최초 요청에는 그 값이 없다"고 했죠. 실행계획이 그 사실을 옆에서 확인해 준 셈입니다. 엔티티에는 nullable = false로 선언돼 있는데 실제로는 NULL이 들어간다는 것 — 이건 이번 범위 밖이라 별도 과제로 적어 뒀습니다.


대조표 — 운영 (측정 예정)

위 개발 수치를 그대로 옮겨 적지 않습니다. 행 수가 10배, 데이터가 6.8배 차이라 절대값이 다릅니다. 운영 실측이 나오면 이 표를 채웁니다.

<!-- TODO: 운영 적용 후 after 열을 실측값으로 채울 것 -->
항목AS-IS (실측)TO-BE
EXPLAIN typeALL(측정 예정)
EXPLAIN key(없음)(측정 예정)
EXPLAIN rows3,726,611(측정 예정)
EXPLAIN filtered10 (분포값 아닌 상수)(측정 예정)
EXPLAIN ANALYZE 실측45.36초(측정 예정)
그중 테이블 스캔41.17초(측정 예정)
실제 읽은 행 → 반환 행3,820,000 → 1(측정 예정)
index_mb (4개 합)0.0 MB(측정 예정)
ALTER 소요 시간(테이블별 측정 예정)

인덱스를 거는 일 자체는 ALTER 한 줄입니다. 그런데 이 글의 대부분은 그 한 줄 바깥에 있었습니다 — 무엇을 치르는지(1장), 언제 어떻게 넣는지(2장), 그리고 정말 나아졌는지 어떻게 아는지(검증).

특히 2장은 처음 판단을 뒤집은 대목이었습니다. "로그 적재는 @Async니까 결제 경로와 분리돼 있다"고 믿고 있었는데, 큐가 차면 CallerRunsPolicy가 그 작업을 호출자에게 돌려준다는 걸 코드를 다시 읽고서야 알았습니다. 비동기는 완충이지 차단벽이 아니었습니다.

개발환경에서는 확인이 끝났습니다. 운영 수치가 나오면 이 글의 대조표를 채우러 다시 오겠습니다.