Real MySQL 8.0 정리 (1) — 아키텍처부터 인덱스까지
Real MySQL 8.0 1권을 읽으며 챕터 1~8까지 공부한 내용을 정리했어요. ORM 뒤에 숨어있던 MySQL 내부 구조, InnoDB의 동작 원리, 그리고 인덱스를 제대로 이해하기까지의 학습 기록입니다.
회사에서 팀장님이 Real MySQL 8.0을 읽어보라고 책을 주셔서 읽었습니다.
두 권짜리 책인데 1권만 해도 500페이지가 넘거든요. 평소에 ORM만 쓰다 보니 "MySQL이 알아서 잘 하겠지"라고 생각해왔는데, 프로덕션에서 이상한 에러를 마주한 뒤로는 그냥 넘어갈 수가 없었어요. 락 타임아웃, 예상보다 10배 느린 쿼리, 인덱스 걸었는데도 느린 상황들.
이 글은 1권 챕터 1~8까지 공부하면서 정리한 학습 노트입니다. 완전한 요약이 아니라 "이게 이런 거였어?" 싶었던 것들 위주로 골랐어요.
MySQL 아키텍처 — 다른 DBMS와 결정적으로 다른 점
MySQL을 처음 공부할 때 가장 중요하게 이해해야 할 구조가 있어요. 바로 2계층 분리 구 조입니다.
┌─────────────────────────────────┐│ MySQL Server Layer ││ SQL 파싱 · 쿼리 옵티마이저 ││ 접속 관리 · 권한 · 캐시 │├─────────────────────────────────┤│ Storage Engine Layer ││ ┌─────────┐ ┌──────┐ ┌──────┐ ││ │ InnoDB │ │MyISAM│ │Memory│ ││ └─────────┘ └──────┘ └──────┘ │└─────── ──────────────────────────┘MySQL Server는 쿼리를 받아서 "어떻게 처리할지" 결정하는 두뇌다. 스토리지 엔진은 실제로 데이터를 디스크에 읽고 쓰는 손발이다.
Oracle이나 PostgreSQL은 이 둘이 통합된 구조인데, MySQL은 분리되어 있어서 테이블마다 다른 엔진을 쓸 수 있다. 덕분에 유연하지만, 트랜잭션 같은 핵심 기능이 엔진 레벨에 구현된다는 걸 항상 의식해야 한다.
InnoDB 내부 구조 — 성능의 비밀이 여기 있다
페이지(Page): 읽기/쓰기의 최소 단위
InnoDB는 데이터를 페이지(Page) 단위로 디스크에서 읽고 메모리에 올린다. 기본 크기는 16KB다.
여기서 중요한 포인트가 있어요. MySQL은 행 하나를 따로 읽어올 수 없다. 무조건 그 행이 속한 페이지 전체(16KB)를 읽어온다. 그래서 필요 이상으로 큰 데이터가 한 행에 있으면 I/O가 낭비된다.
테이블 데이터뿐 아니라 인덱스도 별도의 페이지로 저장된다. 이 개념은 뒤에서 인덱스 구조를 이해할 때 다시 나온다.
버퍼 풀(Buffer Pool): 디스크 I/O를 줄이는 캐시
버퍼 풀은 InnoDB가 사용하는 핵심 메모리 공간이다. 디스크에서 읽어온 페이지를 여기 올려두고 재사용한다.
쿼리 요청 → 버퍼 풀에 있으면? → 바로 반환 (빠름) ↓ 없으면 디스크에서 읽어옴 → 버퍼 풀에 올림 → 반환 (느림)버퍼 풀 크기가 클수록 디스크 I/O가 줄어 성능이 좋아진다. 운영 서버에서는 전 체 메모리의 50~80%를 버퍼 풀에 할당하는 게 일반적이다.
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';Undo Log & Redo Log — 데이터 안전의 두 기둥
이 두 로그를 이해하면 MVCC가 어떻게 동작하는지도 같이 이해된다.
Undo Log는 변경 전 데이터를 저장한다.
UPDATE accounts SET balance = 5000 WHERE id = 1;-- 실행 전: balance = 10000-- Undo Log에 "id=1, balance=10000" 저장-- 실제 데이터는 5000으로 변경-- ROLLBACK 시 Undo Log의 10000으로 복구Undo Log는 롤백뿐 아니라 MVCC의 구버전 데이터로도 쓰인다. 다른 트랜잭션이 예전 버전 데이터를 읽어야 할 때 여기서 가져온다.
Redo Log는 변경 후 데이터를 기록한다.
InnoDB는 성능을 위해 데이터를 메모리(버퍼 풀)에서 먼저 수정하고, 디스크에는 나중에 쓴다. 만약 그 사이에 서버가 다운되면 메모리 내용이 사라진다. Redo Log가 있으면 재시작 시 이 로그를 재실행(Redo)해서 데이터를 복구할 수 있다.
쓰기 요청 → 버퍼 풀(메모리) 수정 → Redo Log 기록 → 응답 반환 ↓ 나중에 디스크에 반영 (비동기)MVCC — 락 없이 읽기 일관성을 보장하는 방법
MVCC(Multi-Version Concurrency Control) 는 읽기 트랜잭션과 쓰기 트랜잭션이 서로를 블로킹하지 않게 해주는 핵심 메커니즘이다.
InnoDB는 데이터를 수정할 때 덮어쓰지 않는다. 버전을 쌓아둔다.
accounts 행의 버전 히스토리 (Undo Log): 버전 1: balance=10000 ← 트랜잭션 100번이 만들었음 버전 2: balance=5000 ← 트랜잭션 101번이 만들었음 (최신)
트랜잭션 B가 100번 시점에 BEGIN했다면: → "101번 이후 버전은 내 스냅샷에 없음" → 항상 버전 1(10000)을 읽음 → 락 없이 일관된 읽기!이 덕분에 SELECT가 UPDATE를 블로킹하지 않는다. InnoDB가 높은 동시성을 유지하는 핵심 이유다.
트랜잭션과 잠금 — 핵심 3줄 요약
이 주제는 별도 글에서 더 자세히 다뤘어요. 여기서는 핵심만 짚고 넘어간다.
- InnoDB의 락은 인덱스에 걸린다. 인덱스가 없으면
FOR UPDATE하나가 테이블 전체를 잠근다. - REPEATABLE READ + MVCC가 MySQL의 기본 격리 수준이다. 트랜잭션 시작 시점의 스냅샷을 읽는다.
- 데드락은 방지보다 감지 + 재시도가 현실적이다. InnoDB가 자동으로 감지하고 롤백하지만, 애플리케이션에서 재시도 로직을 항상 넣어야 한다.
인덱스 — 가장 중요한 챕터
솔직히 이 책에서 가장 많이 공부한 챕터가 여기예요. 인덱스를 "그냥 걸면 빨라지는 것" 정도로만 알고 있었는데, 내부 구조를 이해하고 나니 왜 어떤 쿼리는 인덱스를 써도 느린지, 왜 순서가 중요한지가 보이기 시작했어요.
B-Tree 인덱스 — 인덱스의 기본 구조
MySQL 인덱스의 기본은 B-Tree(Balanced Tree) 다. 항상 균형을 유지하는 트리 구조다.
// 정확히는 B+tree 다 [15] / \ [7] [23] / \ / \ [3] [11] [18] [27] ↓ ↓ ↓ ↓ 데이터 데이터 데이터 데이터검색 시 루트에서 시작해서 크고 작음을 비교하며 내려간다. 100만 건이 있어도 트리 깊이가 몇 단계에 불과해서 탐색이 빠르다.
B-Tree 인덱스가 빠른 이유는 정렬된 상태를 항상 유지하기 때문이다. 덕분에 =, >, <, BETWEEN, LIKE 'prefix%' 같은 조건에서 효과적이다.
클러스터드 인덱스 — InnoDB PK가 특별한 이유
InnoDB에서 PRIMARY KEY는 단순한 인덱스가 아니다. 데이터 자체가 PK 순서로 정렬되어 저장된다. 이걸 클러스터드 인덱스라고 한다.
PK(id) 기준으로 데이터가 물리적으로 정렬됨: Page 1: id=1~100 Page 2: id=101~200 Page 3: id=201~300 ...PK로 조회하면 해당 페이지를 바로 찾아갈 수 있어서 가장 빠르다. 다른 인덱스(세컨더리 인덱스)는 PK를 거쳐서 데이터를 찾아야 하는 2단계 구조다.
여기서 PK 설계에서 자주 하는 실수가 나온다. UUID를 PK로 쓰면 어떻게 될까요?
-- UUID를 PK로 쓸 경우CREATE TABLE users ( id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()), -- ...);UUID는 랜덤 값이라 새 데이터가 트리의 어디에 삽입될지 예측 불가능하다. 그때마다 페이지 분할(Page Split) 이 발생해서 INSERT 성능이 저하되고 인덱스가 단편화된다.
AUTO_INCREMENT 정수 PK가 권장되는 이유가 바로 이거다. 항상 마지막에 추가되어 페이지 분할이 최소화된다.
세컨더리 인덱스 — 2단계 조회를 이해해야 한다
PK 외의 인덱스를 세컨더리 인덱스(Secondary Index) 라고 한다. 세컨더리 인덱스는 인덱스 값 + PK를 저장한다.
email 세컨더리 인덱스: "alice@.." → id=1 "bob@.." → id=2 "carol@.." → id=3
SELECT * FROM users WHERE email = 'alice@...' 쿼리 실행 시: 1단계: email 인덱스에서 "alice@.." 찾음 → id=1 2단계: PK 인덱스에서 id=1 찾음 → 실제 데이터 반환이 2단계 조회를 북마크 룩업(Bookmark Lookup) 이라고도 한다. 세컨더리 인덱스로 조회할 때마다 이 과정이 발생한다.
커버링 인덱스 — 2단계 조회를 없애는 방법
2단계 조회가 불필요한 상황이 있다. 인덱스 자체에 필요한 컬럼이 다 있으면 PK로 다시 찾아갈 필요가 없다.
-- idx_user_status: (user_id, status) 복합 인덱스가 있을 때
-- 커버링 인덱스 가능: SELECT 컬럼이 인덱스 안에 있음SELECT user_id, statusFROM ordersWHERE user_id = 123;
-- 커버링 인덱스 불가능: memo는 인덱스에 없음SELECT user_id, status, memoFROM ordersWHERE user_id = 123;EXPLAIN에서 Extra: Using index가 보이면 커버링 인덱스가 적용된 것이다. 데이터 페이지를 아예 읽지 않아서 성능이 확 올라간다.
인덱스가 무효화되는 패턴
인덱스를 걸어도 쿼리에 따라 못 쓰는 경우가 있다. 이걸 모르면 "인덱스 걸었는데 왜 느리지?" 하게 된다.
-- ① 컬럼에 함수 적용WHERE YEAR(created_at) = 2024 -- ✗ 인덱스 무효WHERE created_at >= '2024-01-01' -- ✓
-- ② 묵시적 형변환WHERE user_id = '123' -- user_id가 INT인데 문자열로 비교 → ✗WHERE user_id = 123 -- ✓
-- ③ 앞에 % 붙은 LIKEWHERE name LIKE '%alice%' -- ✗WHERE name LIKE 'alice%' -- ✓
-- ④ NULL 비교 (인덱스는 쓰이지만 조심)WHERE column IS NULL -- 인덱스 타긴 함WHERE column IS NOT NULL -- 카디널리티 낮으면 무시될 수 있음
-- ⑤ OR 조건 (인덱스 병합이 안 되는 경우)WHERE user_id = 123 OR status = 'paid' -- 인덱스 하나만 쓰거나 Full Scan가장 자주 실수하는 게 함수 적용과 묵시적 형변환이에요. 특히 형변환은 에러도 안 나고 느리기만 해서 찾기가 어렵다.
복합 인덱스 — 순서가 전부다
복합 인덱스는 왼쪽 컬럼부터 순서대로 사용해야 한다. 전화번호부를 생각하면 이해하기 쉬워요. 성(姓)→이름 순으로 정렬되어 있으면 이름만으로는 찾을 수가 없다.
-- 자주 실행되는 쿼리SELECT * FROM ordersWHERE user_id = 123 AND status = 'paid'ORDER BY created_at DESC;
-- 최적 복합 인덱스-- 등호 조건 → 카디널리티 높은 것 → 범위/정렬 순서CREATE INDEX idx_user_status_date ON orders (user_id, status, created_at);-- (user_id, status, created_at) 인덱스 기준
-- 사용됨 ✓WHERE user_id = 123WHERE user_id = 123 AND status = 'paid'WHERE user_id = 123 AND status = 'paid' AND created_at > '2024-01-01'
-- 사용 안 됨 ✗WHERE status = 'paid' -- 첫 컬럼 빠짐WHERE created_at > '2024-01-01' -- 첫 컬럼 빠짐옵티마이저가 인덱스를 무시하는 경우 — "인덱스 걸었는데 왜 느리지?"
인덱스를 만들었는데도 EXPLAIN에서 type: ALL이 뜨는 경우가 있어요. 옵티마이저가 의도적으로 인덱스를 무시하는 거다.
이유가 뭘까요? 옵티마이저는 인덱스를 쓸지 말지 항상 비용(Cost)을 계산한다. 세컨더리 인덱스는 2단계 조회가 필요하다. 데이터가 많이 걸릴수록 이 2단계 비용이 누적된다.
전체 100만 건 중 50만 건 조회 시: 인덱스 사용: 50만 번 인덱스 레코드 읽기 + 50만 번 PK로 랜덤 I/O → 매우 느림
Full Table Scan: 전체 페이지를 순서대로 읽기 → 오히려 더 빠름선택도(Selectivity) 라는 개념이 여기서 나온다. 전체 대비 조회되는 비율이 낮을수록 인덱스가 효과적이다. 일반적으로 전체의 20~25% 이상을 읽어야 하면 옵티마이저는 Full Scan을 선택한다.
-- 테이블의 인덱스 통계 확인SHOW INDEX FROM orders;
-- Cardinality 컬럼: 중복 없는 값의 추정 개수-- Cardinality가 낮을수록 선택도가 낮아서 인덱스 효과 없음PK 없는 테이블 — InnoDB가 몰래 하는 일
"InnoDB 테이블에 PK를 안 만들면 어떻게 되나요?" — 이것도 궁금했던 포인트다.
InnoDB는 PK가 없으면 두 가지를 순서대로 시도한다.
1순위: PK가 있으면 그걸 클러스터드 인덱스로 사용2순위: NOT NULL + UNIQUE 컬럼이 있으면 그걸 클러스터드 인덱스로 사용3순위: 둘 다 없으면 InnoDB가 내부적으로 6바이트 숨겨진 rowid를 만들어 사용3순위까지 가면 문제가 생긴다. 이 숨겨진 rowid는 개발자가 접근할 수 없고, 세컨더리 인덱스를 전혀 사용할 수 없다. 모든 조회가 Full Table Scan이 된다.
PK는 반드시 명시적으로 선언해야 한다.
세컨더리 인덱스에 왜 PK가 포함되나
세컨더리 인덱스는 인덱스 값 + PK를 저장한다고 했는데, 왜 PK까지 저장하는 걸까요?
다른 DB(예: Oracle)는 인덱스에 ROWID(디스크 물리 주소)를 저장한다. 빠르게 접근할 수 있지만 데이터가 이동하면(INSERT로 페이지 분할 등) ROWID가 바뀌어서 모든 인덱스를 다시 써야 한다.
InnoDB는 PK를 저장하는 방식을 택했다. 데이터가 이동해도 PK는 안 바뀌니까 세컨더리 인덱스를 재작성할 필요가 없다. 유지보수 비용을 줄이는 설계다.
반대급부로 세컨더리 인덱스 크기가 커지고(PK가 크면 더 크게), 2단계 조회가 필요하다는 단점이 있다. 이래서 PK는 가능한 작고 단순하게 INT AUTO_INCREMENT를 쓰는 게 좋다.
복합 인덱스에서 범위 조건 이후가 잘리는 이유
복합 인덱스에서 자주 오해하는 부분이 있다. 범위 조건(>, <, BETWEEN) 이후 컬럼은 인덱스를 타지 못한다.
-- (user_id, created_at, status) 복합 인덱스
-- created_at이 범위 조건이면 그 이후 status는 인덱스 못 탐WHERE user_id = 123 AND created_at > '2024-01-01' -- 여기까지만 인덱스 사용 AND status = 'paid' -- 이건 인덱스 후 WHERE 필터링왜 그럴까요? 복합 인덱스는 (user_id, created_at, status) 순서로 정렬된다. user_id가 같은 구간에서 created_at으로 정렬, created_at이 같으면 status로 정렬하는 구조다.
created_at > '2024-01-01'처럼 범위가 정해지면 그 안에서 status가 뒤섞여 있다. 정렬이 보장되지 않으니 인덱스를 탈 수 없다.
user_id=123인 구간: (123, 2024-01-01, 'paid') (123, 2024-01-02, 'cancel') ← created_at 범위 안에서 (123, 2024-01-02, 'paid') status가 뒤섞여 있음 (123, 2024-01-03, 'refund')이 때문에 범위 조건 컬럼은 복합 인덱스에서 최대한 뒤에 배치하는 게 원칙이다.
ORDER BY와 인덱스 — 정렬이 공짜가 되는 조건
ORDER BY가 인덱스 순서와 일치하면 별도 정렬 없이 인덱스 순서대로 읽으면 된다.
-- (user_id, created_at) 복합 인덱스가 있을 때
-- 인덱스로 정렬 가능 → Using filesort 없음 ✓SELECT * FROM ordersWHERE user_id = 123ORDER BY created_at DESC;
-- 인덱스로 정렬 불가 → Using filesort 발생 ✗SELECT * FROM ordersWHERE user_id = 123ORDER BY status DESC; -- status는 인덱스에 없음EXPLAIN에서 Extra: Using filesort가 보이면 정렬을 메모리나 디스크에서 따로 하고 있다는 뜻이다. 데이터가 많으면 심각한 성능 저하가 된다.
인덱스 스킵 스캔 — MySQL 8.0 신기능
MySQL 8.0부터 인덱스 스킵 스캔이 추가됐다. 복합 인덱스의 첫 번째 컬럼이 쿼리에 없어도 인덱스를 활용할 수 있게 됐다.
-- (gender, age) 복합 인덱스가 있을 때-- 원래는 첫 컬럼(gender) 없으면 인덱스 못 씀SELECT * FROM users WHERE age > 25;
-- MySQL 8.0에서는 옵티마이저가 이렇게 처리:-- WHERE gender = 'M' AND age > 25 (M 케이스)-- WHERE gender = 'F' AND age > 25 (F 케이스)-- 두 결과 합쳐서 반환단, gender의 카디널리티가 낮을 때(값의 종류가 적을 때) 효과적이다. 카디널리티가 높으면 너무 많은 스킵이 발생해서 Full Scan보다 느릴 수 있다.
EXPLAIN으로 인덱스 확인하기
인덱스 설계 후 EXPLAIN으로 반드시 확인해야 한다.
EXPLAIN SELECT * FROM ordersWHERE user_id = 123 AND status = 'paid'ORDER BY created_at DESC;+------+-------+---------------------+------+-------+-------------+| type | key | possible_keys | rows | Extra | filtered |+------+-------+---------------------+------+-------+-------------+| ref | idx_.. | idx_user_status_dt | 12 | | 100.00 |+------+-------+---------------------+------+-------+-------------+type 컬럼이 핵심이다:
| type | 의미 | 상태 |
|---|---|---|
ALL | Full Table Scan | 최악 — 고쳐야 함 |
index | 인덱스 전체 스캔 | 나쁨 |
range | 인덱스 범위 스캔 | 괜찮음 |
ref | 인덱스로 특정 값 검색 | 좋음 |
const | PK/UNIQUE로 1건 검색 | 최고 |
type: ALL에 rows가 수십만이면 장애 예약이다. Extra: Using filesort도 위험 신호다 — ORDER BY를 인덱스가 처리하지 못해서 정렬을 따로 하고 있다는 뜻이다.
인덱스가 많을수록 좋은 게 아닌 이유
처음엔 "인덱스 많이 걸수록 빠르지 않나?" 싶었어요. 근데 인덱스에는 확실한 비용이 있다.
인덱스가 10개 걸린 테이블에 INSERT 하면: 1. 실제 데이터 페이지 쓰기 2. 인덱스 1 업데이트 3. 인덱스 2 업데이트 ... 11. 인덱스 10 업데이트조회가 빨라지는 대신 쓰기가 느려진다. 인덱스마다 B-Tree를 재정렬해야 하고, 페이지 분할도 각각 발생할 수 있다. 인덱스가 많아질수록 INSERT/UPDATE/DELETE 성능이 떨어진다.
또한 인덱스는 디스크 공간도 차지한다. 데이터 크기보다 인덱스 크기가 더 큰 테이블도 있다.
인덱스는 읽기와 쓰기의 트레이드오프다. 쓰기가 많은 테이블에서는 인덱스를 최소화해야 한다.
인덱스 설계 체크리스트
공부하면서 정리한 실무 인덱스 설계 원칙이에요.
✓ WHERE 절에 자주 등장하는 컬럼에 인덱스✓ JOIN 연결 컬럼에 인덱스 (FK에는 항상)✓ 카디널리티가 높은 컬럼부터 복합 인덱스 구성✓ ORDER BY 컬럼을 복합 인덱스 마지막에 배치✓ FOR UPDATE를 쓸 컬럼에는 반드시 인덱스✓ 인덱스 변경 후 EXPLAIN으로 확인
✗ 카디널리티 낮은 컬럼 단독 인덱스 (gender, boolean 등)✗ 인덱스 너무 많이 걸기 (쓰기 성능 저하)✗ UUID를 PK로 사용 (페이지 분할 주의)✗ 컬럼에 함수 적용한 채로 인덱스 기대마치며
챕터 8까지 읽으면서 가장 많이 든 생각은 "ORM이 이 복잡함을 다 숨겨줬구나" 였어요.
Prisma나 TypeORM으로 findMany({ where: { userId: 123 } }) 한 줄이면 끝나는 것처럼 보이지만, 내부에서는 인덱스 선택, 페이지 I/O, 버퍼 풀 히트, 락 획득, MVCC 스냅샷 읽기가 동시에 일어나고 있다.
세 가지만 기억하면 된다.
- 인덱스는 B-Tree 정렬 구조다. 왼쪽 컬럼부터, 함수 없이, 타입 일치해야 효과가 있다.
- 클러스터드 인덱스가 PK다. UUID PK는 페이지 분할을 유발한다. AUTO_INCREMENT를 써라.
- EXPLAIN은 매번 확인해라. type: ALL 보이면 고쳐야 한다.
2권(쿼리 최적화, 파티셔닝, 레플리케이션)도 마저 읽고 정리해볼게요 ㅎㅎ.