회사에서 팀장님이 Real MySQL 8.0을 읽어보라고 책을 주셔서 읽었습니다.
두 권짜리 책인데 1권만 해도 500페이지가 넘거든요. 평소에 ORM만 쓰다 보니 "MySQL이 알아서 잘 하겠지"라고 생각해왔는데, 프로덕션에서 이상한 에러를 마주한 뒤로는 그냥 넘어갈 수가 없었어요. 락 타임아웃, 예상보다 10배 느린 쿼리, 인덱스 걸었는데도 느린 상황들.
이 글은 1권 챕터 1~8까지 공부하면서 정리한 학습 노트입니다. 완전한 요약이 아니라 "이게 이런 거였어?" 싶었던 것들 위주로 골랐어요.
MySQL 아키텍처 — 다른 DBMS와 결정적으로 다른 점
MySQL을 처음 공부할 때 가장 중요하게 이해해야 할 구조가 있어요. 바로 2계층 분리 구조입니다.
1┌─────────────────────────────────┐2│ MySQL Server Layer │3│ SQL 파싱 · 쿼리 옵티마이저 │4│ 접속 관리 · 권한 · 캐시 │5├─────────────────────────────────┤6│ Storage Engine Layer │7│ ┌─────────┐ ┌──────┐ ┌─── ───┐ │8│ │ InnoDB │ │MyISAM│ │Memory│ │9│ └─────────┘ └──────┘ └──────┘ │10└─────────────────────────────────┘MySQL Server는 쿼리를 받아서 "어떻게 처리할지" 결정하는 두뇌다. 스토리지 엔진은 실제로 데이터를 디스크에 읽고 쓰는 손발이다.
Oracle이나 PostgreSQL은 이 둘이 통합된 구조인데, MySQL은 분리되어 있어서 테이블마다 다른 엔진을 쓸 수 있다. 덕분에 유연하지만, 트랜잭션 같은 핵심 기능이 엔진 레벨에 구현된다는 걸 항상 의식해야 한다.
[!info] 왜 InnoDB만 써야 하나? MyISAM은 트랜잭션을 지원하지 않는다. 트랜잭션이 없으면 부분 업데이트가 실패했을 때 롤백이 안 된다. 실무에서 MyISAM을 쓸 이유는 거의 없다. InnoDB가 기본값인 이유 가 있다.
InnoDB 내부 구조 — 성능의 비밀이 여기 있다
페이지(Page): 읽기/쓰기의 최소 단위
InnoDB는 데이터를 페이지(Page) 단위로 디스크에서 읽고 메모리에 올린다. 기본 크기는 16KB다.
여기서 중요한 포인트가 있어요. MySQL은 행 하나를 따로 읽어올 수 없다. 무조건 그 행이 속한 페이지 전체(16KB)를 읽어온다. 그래서 필요 이상으로 큰 데이터가 한 행에 있으면 I/O가 낭비된다.
테이블 데이터뿐 아니라 인덱스도 별도의 페이지로 저장된다. 이 개념은 뒤에서 인덱스 구조를 이해할 때 다시 나온다.
버퍼 풀(Buffer Pool): 디스크 I/O를 줄이는 캐시
버퍼 풀은 InnoDB가 사용하는 핵심 메모리 공간이다. 디스크에서 읽어온 페이지를 여기 올려두고 재사용한다.
1쿼리 요청 → 버퍼 풀에 있으면? → 바로 반환 (빠름)2 ↓ 없으면3 디스크에서 읽어옴 → 버퍼 풀에 올림 → 반환 (느림)버퍼 풀 크기가 클수록 디스크 I/O가 줄어 성능이 좋아진다. 운영 서버에서는 전체 메모리의 50~80%를 버퍼 풀에 할당하는 게 일반적이다.
1SHOW VARIABLES LIKE 'innodb_buffer_pool_size';Undo Log & Redo Log — 데이터 안전의 두 기둥
이 두 로그를 이해하면 MVCC가 어떻게 동작하는지도 같이 이해된다.
Undo Log는 변경 전 데이터를 저장한다.
1UPDATE accounts SET balance = 5000 WHERE id = 1;2-- 실행 전: balance = 100003-- Undo Log에 "id=1, balance=10000" 저장4-- 실제 데이터는 5000으로 변경5-- ROLLBACK 시 Undo Log의 10000으로 복구Undo Log는 롤백뿐 아니라 MVCC의 구버전 데이터로도 쓰인다. 다른 트랜잭션이 예전 버전 데이터를 읽어야 할 때 여기서 가져온다.
Redo Log는 변경 후 데이터를 기록한다.
InnoDB는 성능을 위해 데이터를 메모리(버퍼 풀)에서 먼저 수정하고, 디스크에는 나중에 쓴다. 만약 그 사이에 서버가 다운되면 메모리 내용이 사라진다. Redo Log가 있으면 재시작 시 이 로그를 재실행(Redo)해서 데이터를 복구할 수 있다.
1쓰기 요청 → 버퍼 풀(메모리) 수정 → Redo Log 기록 → 응답 반환2 ↓3 나중에 디스크에 반영 (비동기)[!tip] 왜 Redo Log에 먼저 쓸까? 디스크 랜덤 I/O는 느리고, Redo Log 쓰기는 순차 I/O라 빠르다. Redo Log에 빠르게 기록하고 응답을 돌려주는 방식이 성능상 유리하다.
MVCC — 락 없이 읽기 일관성을 보장하는 방법
MVCC(Multi-Version Concurrency Control) 는 읽기 트랜잭션과 쓰기 트랜잭션이 서로를 블로킹하지 않게 해주는 핵심 메커니즘이다.
InnoDB는 데이터를 수정할 때 덮어쓰지 않는다. 버전을 쌓아둔다.
1accounts 행의 버전 히스토리 (Undo Log):2 버전 1: balance=10000 ← 트랜잭션 100번이 만들었음3 버전 2: balance=5000 ← 트랜잭션 101번이 만들었음 (최신)4
5트랜잭션 B가 100번 시점에 BEGIN했다면:6 → "101번 이후 버전은 내 스냅샷에 없음"7 → 항상 버전 1(10000)을 읽음8 → 락 없이 일관된 읽기!이 덕분에 SELECT가 UPDATE를 블로킹하지 않는다. InnoDB가 높은 동시성을 유지하는 핵심 이유다.
트랜잭션과 잠금 — 핵심 3줄 요약
이 주제는 별도 글에서 더 자세히 다뤘어요. 여기서는 핵심만 짚고 넘어간다.
- InnoDB의 락은 인덱스에 걸린다. 인덱스가 없으면
FOR UPDATE하나가 테이블 전체를 잠근다. - REPEATABLE READ + MVCC가 MySQL의 기본 격리 수준이다. 트랜잭션 시작 시점의 스냅샷을 읽는다.
- 데드락은 방지보다 감지 + 재시도가 현실적이다. InnoDB가 자동으로 감지하고 롤백하지만, 애플리케이션에서 재시도 로직을 항상 넣어야 한다.
인덱스 — 가장 중요한 챕터
솔직히 이 책에서 가장 많이 공부한 챕터가 여기예요. 인덱스를 "그냥 걸면 빨라지는 것" 정도로만 알고 있었는데, 내부 구조를 이해하고 나니 왜 어떤 쿼리는 인덱스를 써도 느린지, 왜 순서가 중요한지가 보이기 시작했어요.
B-Tree 인덱스 — 인덱스의 기본 구조
MySQL 인덱스의 기본은 B-Tree(Balanced Tree) 다. 항상 균형을 유지하는 트리 구조다.
1// 정확히는 B+tree 다 2 [15]3 / \4 [7] [23]5 / \ / \6 [3] [11] [18] [27]7 ↓ ↓ ↓ ↓8 데이터 데이터 데이터 데이터검색 시 루트에서 시작해서 크고 작음을 비교하며 내려간다. 100만 건이 있어도 트리 깊이가 몇 단계에 불과해서 탐색이 빠르다.
B-Tree 인덱스가 빠른 이유는 정렬된 상태를 항상 유지하기 때문이다. 덕분에 =, >, <, BETWEEN, LIKE 'prefix%' 같은 조건에서 효과적이다.
[!warning] LIKE '%suffix'는 인덱스를 못 쓴다 B-Tree는 앞에서부터 비교한다.
LIKE '%alice'처럼 앞이 불확정이면 어디서 시작해야 할지 모르니 전체 스캔을 한다.LIKE 'alice%'는 앞이 고정이라 인덱스를 탄다.
클러스터드 인덱스 — InnoDB PK가 특별한 이유
InnoDB에서 PRIMARY KEY는 단순한 인덱스가 아니다. 데이터 자체가 PK 순서로 정렬되어 저장된다. 이걸 클러스터드 인덱스라고 한다.
1PK(id) 기준으로 데이터가 물리적으로 정렬됨:2 Page 1: id=1~1003 Page 2: id=101~2004 Page 3: id=201~3005 ...PK로 조회하면 해당 페이지를 바로 찾아갈 수 있어서 가장 빠르다. 다른 인덱스(세컨더리 인덱스)는 PK를 거쳐서 데이터를 찾아야 하는 2단계 구조다.
여기서 PK 설계에서 자주 하는 실수가 나온다. UUID를 PK로 쓰면 어떻게 될까요?
1-- UUID를 PK로 쓸 경우2CREATE TABLE users (3 id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),4 -- ...5);UUID는 랜덤 값이라 새 데이터가 트리의 어디 에 삽입될지 예측 불가능하다. 그때마다 페이지 분할(Page Split) 이 발생해서 INSERT 성능이 저하되고 인덱스가 단편화된다.
AUTO_INCREMENT 정수 PK가 권장되는 이유가 바로 이거다. 항상 마지막에 추가되어 페이지 분할이 최소화된다.
세컨더리 인덱스 — 2단계 조회를 이해해야 한다
PK 외의 인덱스를 세컨더리 인덱스(Secondary Index) 라고 한다. 세컨더리 인덱스는 인덱스 값 + PK를 저장한다.
1email 세컨더리 인덱스:2 "alice@.." → id=13 "bob@.." → id=24 "carol@.." → id=35
6SELECT * FROM users WHERE email = 'alice@...' 쿼리 실행 시:7 1단계: email 인덱스에서 "alice@.." 찾음 → id=18 2단계: PK 인덱스에서 id=1 찾음 → 실제 데이터 반환이 2단계 조회를 북마크 룩업(Bookmark Lookup) 이라고도 한다. 세컨더리 인덱스로 조회할 때마다 이 과정이 발생한다.
커버링 인덱스 — 2단계 조회를 없애는 방법
2단계 조회가 불필요한 상황이 있다. 인덱스 자체에 필요한 컬럼이 다 있으면 PK로 다시 찾아갈 필요가 없다.
1-- idx_user_status: (user_id, status) 복합 인덱스가 있을 때2
3-- 커버링 인덱스 가능: SELECT 컬럼이 인덱스 안에 있음4SELECT user_id, status5FROM orders6WHERE user_id = 123;7
8-- 커버링 인덱스 불가능: memo는 인덱스에 없음9SELECT user_id, status, memo10FROM orders11WHERE user_id = 123;EXPLAIN에서 Extra: Using index가 보이면 커버링 인덱스가 적용된 것이다. 데이터 페이지를 아예 읽지 않아서 성능이 확 올라간다.
인덱스가 무효화되는 패턴
인덱스를 걸어도 쿼리에 따라 못 쓰는 경우가 있다. 이걸 모르면 "인덱스 걸었는데 왜 느리지?" 하게 된다.
1-- ① 컬럼에 함수 적용2WHERE YEAR(created_at) = 2024 -- ✗ 인덱스 무효3WHERE created_at >= '2024-01-01' -- ✓4
5-- ② 묵시적 형변환6WHERE user_id = '123' -- user_id가 INT인데 문자열로 비교 → ✗7WHERE user_id = 123 -- ✓8
9-- ③ 앞에 % 붙은 LIKE10WHERE name LIKE '%alice%' -- ✗11WHERE name LIKE 'alice%' -- ✓12
13-- ④ NULL 비교 (인덱스는 쓰이지만 조심)14WHERE column IS NULL -- 인덱스 타긴 함15WHERE column IS NOT NULL -- 카디널리티 낮으면 무시될 수 있음16
17-- ⑤ OR 조건 (인덱스 병합이 안 되는 경우)18WHERE user_id = 123 OR status = 'paid' -- 인덱스 하나만 쓰거나 Full Scan가장 자주 실수하는 게 함수 적용과 묵시적 형변환이에요. 특히 형변환은 에러도 안 나고 느리기만 해서 찾기가 어렵다.
복합 인덱스 — 순서가 전부다
복합 인덱스는 왼쪽 컬럼부터 순서대로 사용해야 한다. 전화번호부를 생각하면 이해하기 쉬워요. 성(姓)→이름 순으로 정렬되어 있으면 이름만으로는 찾을 수가 없다.
1-- 자주 실행되는 쿼리2SELECT * FROM orders3WHERE user_id = 123 AND status = 'paid'4ORDER BY created_at DESC;5
6-- 최적 복합 인덱스7-- 등호 조건 → 카디널리티 높은 것 → 범위/정렬 순서8CREATE INDEX idx_user_status_date ON orders (user_id, status, created_at);1-- (user_id, status, created_at) 인덱스 기준2
3-- 사용됨 ✓4WHERE user_id = 1235WHERE user_id = 123 AND status = 'paid'6WHERE user_id = 123 AND status = 'paid' AND created_at > '2024-01-01'7
8-- 사용 안 됨 ✗9WHERE status = 'paid' -- 첫 컬럼 빠짐10WHERE created_at > '2024-01-01' -- 첫 컬럼 빠짐