Real MySQL 8.0을 드디어 읽다.....
ORM만 쓰던 저한테 MySQL 내부는 그냥 블랙박스였거든요. INSERT 날리면 들어가고, SELECT 하면 나오고. 그게 전부인 줄 알았죠.
이 글은 Real MySQL 8.0 1권에서 트랜잭션과 잠금 챕터에 내용입니다. 나중에 제가 다시 꺼내볼 레퍼런스이기도 하고, 같이 공부하는 분들께도 도움이 됐으면 해서 올려요.
MySQL 아키텍처 — 다른 DB와 다른 이유
MySQL이 Oracle이나 PostgreSQL과 결정적으로 다른 점이 하나 있다. 바로 2계층 구조다.
1┌────── ───────────────────────────────┐2│ MySQL Server Layer │3│ SQL 파싱 · 쿼리 최적화 · 캐시 │4│ 접속 관리 · 권한 체크 │5├─────────────────────────────────────┤6│ Storage Engine Layer │7│ ┌──────────┐ ┌────────┐ ┌───────┐ │8│ │ InnoDB │ │ MyISAM │ │Memory │ │9│ └──────────┘ └────────┘ └───────┘ │10└─────────────────────────────────────┘MySQL Server는 SQL을 받아서 어떻게 처리할지 결정하는 두뇌다. 스토리지 엔진은 실제로 데이터를 디스크에 읽고 쓰는 손발이다. 이 둘이 분리되어 있어서 테이블마다 다른 엔진을 쓸 수도 있다.
1-- 테이블별로 다른 엔진 사용 가능2CREATE TABLE logs (id INT, msg TEXT) ENGINE = MyISAM;3CREATE TABLE orders (id INT, amount INT) ENGINE = InnoDB;4
5-- 현재 엔진 확인6SHOW TABLE STATUS WHERE Name = 'orders'\G반면 Oracle은 모든 게 하나로 통합된 모놀리식 구조다. PostgreSQL은 단일 엔진이지만 익스텐션으로 기능을 확장한다. MySQL의 분리 구조는 유연하지만, 트랜잭션 같은 중요한 기 능이 엔진 레벨에서 구현된다는 걸 이해해야 한다. 트랜잭션은 MyISAM은 지원 안 하고, InnoDB만 지원한다.
[!info] InnoDB와 Oracle의 관계 InnoDB는 원래 핀란드 회사 Innobase Oy가 만든 MySQL 플러그인이었다. 2005년 Oracle이 Innobase를 인수했고, 2010년 Sun을 인수하면서 MySQL 전체를 소유하게 됐다. Oracle Database와 MySQL은 완전히 별개의 제품이다.
InnoDB 내부 구조 — 진짜 이해해야 할 것들
페이지(Page): 읽기/쓰기의 최소 단위
InnoDB는 데이터를 페이지(Page) 단위로 디스크에서 읽고 메모리에 올린다. 기본 크기는 16KB다.
중요한 건 MySQL이 행(Row) 하나만 따로 읽어올 수 없다는 거예요. 무조건 그 행이 속한 페이지 전체(16KB)를 읽어온다.
1┌──────────────────┐ ┌──────────────────┐2│ Data Page 1 │ │ Index Page A │3│ id=1 ~ id=100 │ │ alice@ → Page1 │4│ (16KB) │ │ bob@ → Page1 │5├──────────────────┤ ├──────────────────┤6│ Data Page 2 │ │ Index Page B │7│ id=101 ~ id=200 │ │ carol@ → Page2 │8│ (16KB) │ │ dave@ → Page2 │9└──────────────────┘ └──────────────────┘10 테이블 데이터 페이지 보조 인덱스 페이지테이블 데이터뿐 아니라 인덱스도 별도의 페이지로 구성된다. 보조 인덱스 페이지라는 개념은 체인지 버퍼를 이해할 때 핵심이다.
1SHOW VARIABLES LIKE 'innodb_page_size';2-- 결과: 16384 (= 16KB)버퍼 풀(Buffer Pool): InnoDB의 캐시
버퍼 풀은 InnoDB가 사용하는 메모리 공간이다. 디스크에서 읽어온 페이지를 여기 올려두고 재사용한다.
1디스크 읽기 과정:2 쿼리 요청 → 버퍼 풀에 해당 페이지 있으면? → 바로 반환 (빠름)3 ↓ 없으면4 디스크에서 페이지 읽어옴 → 버퍼 풀에 올림 → 반환 (느림)버퍼 풀 크기가 클수록 디스크 I/O가 줄어 성능이 좋아진다. 운영 서버에서는 전체 메모리의 50~80%를 버퍼 풀에 할당하는 게 일반적이다.
1SHOW VARIABLES LIKE 'innodb_buffer_pool_size';체인지 버퍼(Change Buffer): 쓰기 성능의 비밀
체인지 버퍼는 보조 인덱스 페이지에 대한 변경사항을 버퍼링하는 특수한 메모리 구조다.
보조 인덱스는 데이터와 별도로 저장되어 있어서 INSERT/UPDATE/DELETE 시 랜덤 I/O가 발생한다. 보조 인덱스가 5개면 하나의 쓰기 작업에 랜덤 디스크 I/O가 5번 일어날 수 있다.
1체인지 버퍼가 없으면:2 INSERT → 보조 인덱스 페이지 디스크에서 읽어옴 → 업데이트 → 씀3 (랜덤 I/O 발생)4
5체인지 버퍼가 있으면:6 INSERT → "나중에 email 인덱스 업데이트 필요" 메모만 해둠7 → 나중에 해당 페이지가 어차피 버퍼 풀에 올라올 때 한꺼번에 처리8 (랜덤 I/O 생략)주의할 점이 있어요. UNIQUE 인덱스에는 체인지 버퍼가 작동하지 않는다. 중복 체크를 위해 반드시 디스크를 읽어야 하기 때문이다.
아마 이 부분도 인덱스 에서 자세하게 이야기를 다룰 거에요.
인덱스 — 락을 이해하기 위한 선행 지식
InnoDB의 락은 행(Row)이 아니라 인덱스에 걸린다. 이걸 모르면 락 관련 장애가 왜 일어나는지 영원히 이해 못 한다.
PRIMARY KEY 인덱스 (클러스터드 인덱스)
InnoDB에서 PRIMARY KEY는 특별하다. 데이터 자체가 PK 순서로 정렬되어 저장된다. 이를 클러스터드 인덱스라고 한다.
1PRIMARY KEY 인덱스:2
3 [id=2]4 / \5 [id=1] [id=3]6 ↓ ↓7 실제 데이터 실제 데이터8 (Alice 행) (Carol 행)보조 인덱스 (Secondary Index)
보조 인덱스는 값 + PRIMARY KEY를 저장하고, 실제 데이터는 PK로 찾아간다.
1email 보조 인덱스:2
3 [bob@]4 / \5 [alice@] [carol@]6 ↓ ↓7 id=1 → id=3 →8 PK로 실제 PK로 실제9 데이터 찾음 데이터 찾음보조 인덱스 조회는 항상 2단계다: 보조 인덱스에서 PK 찾기 → PK로 실제 데이터 찾기.
카디널리티 — 인덱스 설계의 핵심
카디널리티는 해당 컬럼의 중복되지 않는 값의 수다. 카디널리티가 낮은 컬럼에 인덱스를 걸면 효과가 거의 없다.
1SELECT2 COUNT(DISTINCT gender) as gender_cardinality, -- 2 (M/F)3 COUNT(DISTINCT status) as status_cardinality, -- 4~5가지4 COUNT(DISTINCT user_id) as user_id_cardinality -- 행 수와 동일5FROM orders;gender에 인덱스를 걸면 절반을 스캔해야 해서 MySQL 옵티마이저가 인덱스를 무시하고 Full Scan을 선택하기도 한다.
복합 인덱스 — 순서가 전부다
복합 인덱스는 왼쪽 컬럼부터 사용해야 한다. 전화번호부가 성 → 이름 순서로 정렬되어 있을 때 이름만으로 찾으면 전체를 뒤져야 하는 것과 같다.
1-- 자주 실행되는 쿼리2SELECT * FROM orders3WHERE user_id = 123 AND status = 'paid'4ORDER BY created_at DESC;5
6-- 최적 복합 인덱스: 등호 조건 → 카디널리티 높은 것 → 범위/정렬7CREATE 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'EXPLAIN — 인덱스 사용 여부 확인
1EXPLAIN SELECT * FROM orders WHERE user_id = 123;1+----+------+--------+------+-----------------+-----------------+------+-------+2| id | type | table | key | possible_keys | key | rows | Extra |3+----+------+--------+------+-----------------+-----------------+------+-------+4| 1 | ref | orders | NULL | idx_user_status | idx_user_status | 150 | |5+----+------+--------+------+-----------------+-----------------+------+-------+type 컬럼만 봐도 상황이 보인다:
| type | 의미 | 상태 |
|---|---|---|
ALL | Full Table Scan | 최악 — 무조건 고쳐야 함 |
index | 인덱스 전체 스캔 | 나쁨 |
range | 인덱스 범위 스캔 | 괜찮음 |
ref | 인덱스로 특정 값 검색 | 좋음 |
const | PK/UNIQUE로 1건 검색 | 최고 |
type: ALL에 rows가 수백만이면 장애 예약이다. ref나 const가 될 때까지 인덱스를 조정해야 한다.
[!warning] 인덱스가 무효화되는 패턴
sql1-- 함수 적용 → 인덱스 무효화2WHERE YEAR(created_at) = 2024 -- ✗3WHERE created_at >= '2024-01-01' -- ✓45-- 앞에 % 붙은 LIKE → 인덱스 무효화6WHERE name LIKE '%Alice%' -- ✗7WHERE name LIKE 'Alice%' -- ✓