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%' -- ✓
잠금(Lock) — 종류와 실무 쓰임새
MySQL의 락은 범위에 따라 4가지로 나뉜다.
1넓은 범위 ←──────────────────────────→ 좁은 범위2글로벌 락 > 테이블 락 > 네임드 락 > 레코드 락3(DB 전체) (테이블) (사용자 정의) (행 단위)글로벌 락 — DB 전체를 얼린다
1FLUSH TABLES WITH READ LOCK; -- 전체 DB 읽기 전용2-- 모든 INSERT/UPDATE/DELETE 대기 상태가 됨3
4-- 백업 완료 후 해제5UNLOCK TABLES;전체 백업 시 일관된 스냅샷이 필요할 때 쓰인다. 하지만 InnoDB만 쓴다면 --single-transaction 옵션으로 대체하는 게 낫다. 락 없이 MVCC를 이용해 일관된 백업이 가능하기 때문이다.
테이블 락 — MyISAM 시절 유물
1LOCK TABLES orders READ; -- 읽기만 허용2LOCK TABLES orders WRITE; -- 나만 읽고 쓰기3UNLOCK TABLES;InnoDB는 행 단위 락이 있어서 테이블 락을 거의 쓰지 않는다. MyISAM 테이블을 다루거나 레거시 코드에서 볼 수 있다.
네임드 락 — 애플리케이션 레벨 분산 락
1-- '결제처리' 이름으로 락 획득 (5초 대기)2SELECT GET_LOCK('결제처리', 5);3-- 반환값: 1(성공), 0(타임아웃), NULL(에러)4
5-- 비즈니스 로직 처리6UPDATE accounts SET balance = balance - 1000 WHERE id = 1;7
8-- 락 해제9SELECT RELEASE_LOCK('결제처리');DB 레벨 락이 아니라 애플리케이션 레벨 동시성 제어에 쓴다. Redis 없이 분산 락을 구현하거나, 같은 유저의 중복 결제 요청을 막거나, 배치 작업이 여러 서버에서 중복 실행되지 않도록 할 때 유용하다.
메타데이터 락 — DDL과 DML의 충돌 방지
명시적으로 걸지 않아도 자동으로 걸리는 락이다.
1-- 트랜잭션 1: SELECT 실행 중2BEGIN;3SELECT * FROM orders WHERE id = 1;4-- (아직 COMMIT 안 함)5
6-- 트랜잭션 2: 스키마 변경 시도7ALTER TABLE orders ADD COLUMN memo TEXT;8-- → 트랜잭션 1이 끝날 때까지 대기!프로덕션에서 자주 일어나는 사고다. 긴 트랜잭션이 열려있는 상태에서 ALTER TABLE을 날리면 그 트랜잭션이 끝날 때까지 DDL이 대기한다. 거기다 이후 DML도 모두 대기 상태가 되면서 서비스 전체가 멈출 수 있다.
InnoDB 레코드 락 3종 — 실무에서 가장 중요
InnoDB의 락은 행(Row)이 아니라 인덱스 항목에 걸린다. 이 점이 핵심이다.
1SELECT * FROM users WHERE id = 2 FOR UPDATE;2-- → id=2 행이 아니라, PRIMARY KEY 인덱스의 id=2 항목에 락이 걸림Record Lock — 딱 그 인덱스 항목 하나
1id: [1] [2] [3] [4] [5]2 ★ ← id=2에만 락3
4→ id=1, id=3은 자유롭게 수정 가능5→ id=2는 다른 트랜잭션이 수정 불가Gap Lock — 존재하지 않는 공간을 잠근다
1id: [1] [2] [3] [5]2 ░░░░░░░░░░░ ← (3, 5) 사이 GAP에 락3
4→ id=4인 행의 INSERT를 막음5→ 팬텀 리드 방지가 목적1SELECT * FROM users WHERE id BETWEEN 3 AND 5 FOR UPDATE;2-- id=3, id=5에 Record Lock + (3~5) 사이에 Gap Lock3-- → 다른 트랜잭션이 id=4를 INSERT하려 하면 대기Next-Key Lock — Record + Gap의 조합 (InnoDB 기본)
1id: [1] [2] [3] [4] [5]2 ░░░░[★3]░░░░ ← id=3 레코드 + 앞의 Gap3
4= (1, 3] 범위를 잠금InnoDB가 기본적으로 Next-Key Lock을 쓰는 이유는 팬텀 리드 방지다. 같은 트랜잭션에서 동일한 SELECT를 두 번 실행했을 때 결과가 달라지는 현상을 막는다.
[!warning] 인덱스 없으면 테이블 전체 락
sql1-- email에 인덱스가 없는 상태에서2SELECT * FROM users WHERE email = 'alice@example.com' FOR UPDATE;34-- InnoDB가 Full Table Scan 실행5→ 전체 레코드에 Next-Key Lock 적용6→ 사실상 테이블 전체 락!7→ 다른 트랜잭션 모두 대기 → 장애
FOR UPDATE를 쓸 컬럼에는 반드시 인덱스가 있어야 한다.
데드락 — 서로가 서로를 기다리는 교착 상태
왜 발생하나?
1트랜잭션 A 트랜잭션 B2─────────────────────────────────────────3BEGIN;4UPDATE users SET name='Alice2'5WHERE id = 1; ← id=1 락 획득 ✓6 BEGIN;7 UPDATE users SET name='Bob3'8 WHERE id = 2; ← id=2 락 획득 ✓9
10UPDATE users SET name='Bob2'11WHERE id = 2; ← id=2 기다림...12 UPDATE users SET name='Alice3'13 WHERE id = 1; ← id=1 기다림...14
15A는 B를 기다리고, B는 A를 기다린다 → 영원히 진행 불가InnoDB는 데드락을 자동 감지하고 더 적은 변경을 한 트랜잭션을 희생자(victim)로 선정해 롤백한다.
1ERROR 1213 (40001): Deadlock found when trying to get lock;2 try restarting transaction방지법 1: 항상 같은 순서로 락 획득
1-- 나쁜 패턴: A는 id=1→2, B는 id=2→1 순서 (순서가 다름)2
3-- 좋은 패턴: 항상 작은 id → 큰 id 순서로4BEGIN;5UPDATE users SET name='...' WHERE id = 1; -- 항상 먼저6UPDATE users SET name='...' WHERE id = 2; -- 항상 나중7COMMIT;방지법 2: 트랜잭션을 짧게 유지
1-- 나쁜 패턴: 락 잡은 채로 외부 API 호출2BEGIN;3SELECT * FROM orders WHERE id = 1 FOR UPDATE;4-- 여기서 결제 API 호출 (수초 소요) ← 락을 잡은 채로!5UPDATE orders SET status = 'paid' WHERE id = 1;6COMMIT;7
8-- 좋은 패턴: 필요한 데이터 먼저 읽고 락은 최소한으로9SELECT * FROM orders WHERE id = 1; -- 먼저 읽기만10-- 외부 API 호출11BEGIN;12UPDATE orders SET status = 'paid' WHERE id = 1; -- 락 시간 최소화13COMMIT;방지법 3: 재시도 로직 구현
데드락은 100% 방지가 불가능하다. 현실적인 해법은 재시도다.
1async function processOrder(orderId: number) {2 const MAX_RETRIES = 3;3
4 for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {5 try {6 return await dataSource.transaction(async (manager) => {7 // 비즈니스 로직8 const order = await manager.findOne(Order, {9 where: { id: orderId },10 lock: { mode: 'pessimistic_write' },11 });12 // ...13 });14 } catch (error) {15 if (error.code === 'ER_LOCK_DEADLOCK' && attempt < MAX_RETRIES - 1) {16 await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)));17 continue;18 }19 throw error;20 }21 }22}사내에서 데드락 관련해서 잘 모르고 과도하게 대처하려고 했는데, 이 교착상태는 생각보다 잘 일어나지 않으며, 재시도 로직으로 쉽게 방지가 되는 걸 이걸 보고 알았다.
1-- 가장 최근 데드락 정보 확인2SHOW ENGINE INNODB STATUS\G3-- LATEST DETECTED DEADLOCK 섹션에서 원인 파악 가능트랜잭션 격리 수준 — 4단계로 이해하기
두 트랜잭션이 동시에 실행될 때 3가지 문제가 생길 수 있다.
| 문제 | 설명 |
|---|---|
| Dirty Read | 커밋 안 된 데이터를 읽음 |
| Non-Repeatable Read | 같은 쿼리를 두 번 날렸는데 결과가 다름 |
| Phantom Read | 없던 행이 갑자기 생김 |
격리 수준은 이 문제들을 어디까지 허용할 것이냐의 트레이드오프다.
READ UNCOMMITTED — 거의 쓰지 않음
1트랜잭션 A 트랜잭션 B2─────────────────────────────────────────3BEGIN;4UPDATE accounts5SET balance = 5000;6-- 아직 COMMIT 안 함7 SELECT balance; → 5000 읽힘! (Dirty Read)8
9ROLLBACK;10-- 5000원은 존재하지 않는 값이었다11-- B는 유령 데이터를 기반으로 뭔가를 했을 수도 있음데이터 정합성이 전혀 필요 없는 경우에만 쓴다. 실무에서 쓸 일이 거의 없다.
READ COMMITTED — Oracle, PostgreSQL 기본값
1트랜잭션 A 트랜잭션 B2─────────────────────────────────────────3BEGIN;4UPDATE accounts SET balance = 5000;5-- 아직 COMMIT 안 함6 SELECT balance; → 10000 (커밋 전은 안 읽힘 ✓)7
8COMMIT;9 SELECT balance; → 5000 (달라짐! Non-Repeatable Read)Dirty Read는 해결되지만 같은 트랜잭션 안에서 같은 쿼리 결과가 달라질 수 있다. 통계나 대시보드처럼 최신 데이터가 필요한 경우에 의도적으로 선택하기도 한다.
REPEATABLE READ — MySQL InnoDB 기본값 ★
1트랜잭션 A 트랜잭션 B2─────────────────────────────────────────3BEGIN;4SELECT balance; → 100005 BEGIN;6 UPDATE accounts SET balance = 5000;7 COMMIT;8
9SELECT balance; → 10000 ← B가 바꿔도 안 바뀜!10COMMIT;트랜잭션 시작 시점의 스냅샷을 계속 읽는다. 이게 가능한 이유가 MVCC(Multi-Version Concurrency Control) 다.
MVCC — 락 없이 일관된 읽기를 보장하는 마법
InnoDB는 데이터를 바꿀 때 덮어쓰지 않고 버전을 쌓아둔다.
1accounts 행의 버전 히스토리:2 버전 1: balance=10000, 트랜잭션 100번이 만듦3 버전 2: balance=5000, 트랜잭션 101번이 만듦 ← 최신 버전4
5트랜잭션 B가 100번 시점에 시작했다면:6 → "100번 이후에 만들어진 버전은 내 트랜잭션에서 보이지 않음"7 → 항상 버전 1(10000원)을 읽음8 → 락 없이 일관된 읽기!이 덕분에 읽기 트랜잭션이 쓰기 트랜잭션을 블로킹하지 않는다. InnoDB가 높은 동시성을 유지할 수 있는 핵심 이유다.
InnoDB의 REPEATABLE READ는 Next-Key Lock 덕분에 Phantom Read도 거의 방지한다.
SERIALIZABLE — 완벽한 격리, 최악의 성능
모든 SELECT에 공유 락이 자동으로 걸린다. 트랜잭션이 사실상 순서대로 실행된다. 모든 문제를 해결하지만 동시성이 극도로 떨어진다. 금융 정산처럼 정합성이 절대적으로 중요한 극소수의 케이스에서만 쓴다.
격리 수준 한눈에 비교
1격리 수준 Dirty Non-Rep Phantom 성능2────────────────────────────────────────────────3READ UNCOMMITTED ✗ ✗ ✗ 최고4READ COMMITTED ✓ ✗ ✗ 좋음5REPEATABLE READ ✓ ✓ △ 보통 ← MySQL 기본6SERIALIZABLE ✓ ✓ ✓ 최저7
8✓ 해결됨 ✗ 문제 발생 가능 △ 거의 해결(InnoDB의 경우)1-- 현재 격리 수준 확인2SELECT @@transaction_isolation;3-- 결과: REPEATABLE-READ4
5-- 특정 세션에서만 변경6SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;[!tip] 실무 선택 기준
- 일반 웹 서비스: REPEATABLE READ (기본값 그대로)
- 통계/대시보드: READ COMMITTED (최신 커밋 데이터 필요)
- 금융 정산: SERIALIZABLE 또는 REPEATABLE READ + FOR UPDATE
실무에서 락을 직접 다뤄야 하는 경우
대부분의 락은 InnoDB가 자동으로 처리한다. 명시적으로 다뤄야 하는 경우는 생각보다 많지 않다.
1자동 처리 (대부분의 경우):2 ✓ 일반 INSERT / UPDATE / DELETE3 ✓ 트랜잭션 내 DML4 ✓ InnoDB 백업 (--single-transaction)5
6수동이 필요한 경우:7 ✓ 읽고 → 계산 → 쓰기 패턴 → FOR UPDATE8 ✓ 분산 환경 중복 방지 → GET_LOCK()9 ✓ 레거시 MyISAM 백업 → 글로벌 락가장 자주 쓰는 패턴은 SELECT ... FOR UPDATE다.
1-- 위험한 코드: 동시에 두 트랜잭션이 같은 잔액을 읽으면?2SELECT balance FROM accounts WHERE id = 1; -- 잔액: 10,000원3-- 다른 트랜잭션이 여기서 출금하면?4UPDATE accounts SET balance = balance - 5000 WHERE id = 1; -- 잘못된 값 기반5
6-- 안전한 코드: 읽 으면서 동시에 락 획득7BEGIN;8SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 락 획득9-- 다른 트랜잭션은 이 행에 접근 불가10UPDATE accounts SET balance = balance - 5000 WHERE id = 1;11COMMIT;재고 차감, 잔액 처리, 좌석 예약, 쿠폰 사용 같은 상황에서 FOR UPDATE 없이 개발하면 반드시 데이터가 꼬인다.
마치며
Real MySQL 8.0을 읽으면서 가장 많이 든 생각은 "ORM이 이걸 다 숨겨주고 있었구나"였어요.
TypeORM이나 Prisma를 쓰면 SQL을 직접 쓸 일이 줄어들지만, 내부에서 어떤 락이 걸리고 어떤 격리 수준으로 동작하는지는 개발자가 알아야 한다. 모르면 버그가 생겨도 왜 생겼는지 찾을 수가 없다.
세 가지만 기억하면 된다.
- InnoDB의 락은 인덱스에 걸린다.
FOR UPDATE를 쓸 컬럼에는 인덱스가 있어야 한다. - REPEATABLE READ + MVCC가 InnoDB의 핵심이다. 락 없이 읽기 일관성을 보장한다.
- 데드락은 방지보다 감지 + 재시도가 현실적이다.
다음은 실행 계획(EXPLAIN)을 더 깊게 파고, 쿼리 최적화 챕터로 넘어갈 예정이에요. 이 글이 도움이 됐으면 좋겠어요 ㅎㅎ.