raw
Database

Real MySQL 8.0 — 트랜잭션과 잠금 완전정복

2026.03.02·26분

Real MySQL 8.0을 드디어 읽다.....

ORM만 쓰던 저한테 MySQL 내부는 그냥 블랙박스였거든요. INSERT 날리면 들어가고, SELECT 하면 나오고. 그게 전부인 줄 알았죠.

이 글은 Real MySQL 8.0 1권에서 트랜잭션과 잠금 챕터에 내용입니다. 나중에 제가 다시 꺼내볼 레퍼런스이기도 하고, 같이 공부하는 분들께도 도움이 됐으면 해서 올려요.


MySQL 아키텍처 — 다른 DB와 다른 이유

MySQL이 Oracle이나 PostgreSQL과 결정적으로 다른 점이 하나 있다. 바로 2계층 구조다.

text
1┌─────────────────────────────────────┐
2│ MySQL Server Layer │
3│ SQL 파싱 · 쿼리 최적화 · 캐시 │
4│ 접속 관리 · 권한 체크 │
5├─────────────────────────────────────┤
6│ Storage Engine Layer │
7│ ┌──────────┐ ┌────────┐ ┌───────┐ │
8│ │ InnoDB │ │ MyISAM │ │Memory │ │
9│ └──────────┘ └────────┘ └───────┘ │
10└─────────────────────────────────────┘

MySQL Server는 SQL을 받아서 어떻게 처리할지 결정하는 두뇌다. 스토리지 엔진은 실제로 데이터를 디스크에 읽고 쓰는 손발이다. 이 둘이 분리되어 있어서 테이블마다 다른 엔진을 쓸 수도 있다.

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)를 읽어온다.

text
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 테이블 데이터 페이지 보조 인덱스 페이지

테이블 데이터뿐 아니라 인덱스도 별도의 페이지로 구성된다. 보조 인덱스 페이지라는 개념은 체인지 버퍼를 이해할 때 핵심이다.

sql
1SHOW VARIABLES LIKE 'innodb_page_size';
2-- 결과: 16384 (= 16KB)

버퍼 풀(Buffer Pool): InnoDB의 캐시

버퍼 풀은 InnoDB가 사용하는 메모리 공간이다. 디스크에서 읽어온 페이지를 여기 올려두고 재사용한다.

text
1디스크 읽기 과정:
2 쿼리 요청 → 버퍼 풀에 해당 페이지 있으면? → 바로 반환 (빠름)
3 ↓ 없으면
4 디스크에서 페이지 읽어옴 → 버퍼 풀에 올림 → 반환 (느림)

버퍼 풀 크기가 클수록 디스크 I/O가 줄어 성능이 좋아진다. 운영 서버에서는 전체 메모리의 50~80%를 버퍼 풀에 할당하는 게 일반적이다.

sql
1SHOW VARIABLES LIKE 'innodb_buffer_pool_size';

체인지 버퍼(Change Buffer): 쓰기 성능의 비밀

체인지 버퍼는 보조 인덱스 페이지에 대한 변경사항을 버퍼링하는 특수한 메모리 구조다.

보조 인덱스는 데이터와 별도로 저장되어 있어서 INSERT/UPDATE/DELETE 시 랜덤 I/O가 발생한다. 보조 인덱스가 5개면 하나의 쓰기 작업에 랜덤 디스크 I/O가 5번 일어날 수 있다.

text
1체인지 버퍼가 없으면:
2 INSERT → 보조 인덱스 페이지 디스크에서 읽어옴 → 업데이트 → 씀
3 (랜덤 I/O 발생)
4
5체인지 버퍼가 있으면:
6 INSERT → "나중에 email 인덱스 업데이트 필요" 메모만 해둠
7 → 나중에 해당 페이지가 어차피 버퍼 풀에 올라올 때 한꺼번에 처리
8 (랜덤 I/O 생략)

주의할 점이 있어요. UNIQUE 인덱스에는 체인지 버퍼가 작동하지 않는다. 중복 체크를 위해 반드시 디스크를 읽어야 하기 때문이다.
아마 이 부분도 인덱스 에서 자세하게 이야기를 다룰 거에요.


인덱스 — 락을 이해하기 위한 선행 지식

InnoDB의 락은 행(Row)이 아니라 인덱스에 걸린다. 이걸 모르면 락 관련 장애가 왜 일어나는지 영원히 이해 못 한다.

PRIMARY KEY 인덱스 (클러스터드 인덱스)

InnoDB에서 PRIMARY KEY는 특별하다. 데이터 자체가 PK 순서로 정렬되어 저장된다. 이를 클러스터드 인덱스라고 한다.

text
1PRIMARY KEY 인덱스:
2
3 [id=2]
4 / \
5 [id=1] [id=3]
6 ↓ ↓
7 실제 데이터 실제 데이터
8 (Alice 행) (Carol 행)

보조 인덱스 (Secondary Index)

보조 인덱스는 값 + PRIMARY KEY를 저장하고, 실제 데이터는 PK로 찾아간다.

text
1email 보조 인덱스:
2
3 [bob@]
4 / \
5 [alice@] [carol@]
6 ↓ ↓
7 id=1 → id=3 →
8 PK로 실제 PK로 실제
9 데이터 찾음 데이터 찾음

보조 인덱스 조회는 항상 2단계다: 보조 인덱스에서 PK 찾기 → PK로 실제 데이터 찾기.

카디널리티 — 인덱스 설계의 핵심

카디널리티는 해당 컬럼의 중복되지 않는 값의 수다. 카디널리티가 낮은 컬럼에 인덱스를 걸면 효과가 거의 없다.

sql
1SELECT
2 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을 선택하기도 한다.

복합 인덱스 — 순서가 전부다

복합 인덱스는 왼쪽 컬럼부터 사용해야 한다. 전화번호부가 성 → 이름 순서로 정렬되어 있을 때 이름만으로 찾으면 전체를 뒤져야 하는 것과 같다.

sql
1-- 자주 실행되는 쿼리
2SELECT * FROM orders
3WHERE 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);
sql
1-- (user_id, status, created_at) 인덱스 기준
2
3-- 사용됨 ✓
4WHERE user_id = 123
5WHERE 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 — 인덱스 사용 여부 확인

sql
1EXPLAIN SELECT * FROM orders WHERE user_id = 123;
text
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의미상태
ALLFull Table Scan최악 — 무조건 고쳐야 함
index인덱스 전체 스캔나쁨
range인덱스 범위 스캔괜찮음
ref인덱스로 특정 값 검색좋음
constPK/UNIQUE로 1건 검색최고

type: ALLrows가 수백만이면 장애 예약이다. refconst가 될 때까지 인덱스를 조정해야 한다.

[!warning] 인덱스가 무효화되는 패턴

sql
1-- 함수 적용 → 인덱스 무효화
2WHERE YEAR(created_at) = 2024 -- ✗
3WHERE created_at >= '2024-01-01' -- ✓
4
5-- 앞에 % 붙은 LIKE → 인덱스 무효화
6WHERE name LIKE '%Alice%' -- ✗
7WHERE name LIKE 'Alice%' -- ✓

잠금(Lock) — 종류와 실무 쓰임새

MySQL의 락은 범위에 따라 4가지로 나뉜다.

text
1넓은 범위 ←──────────────────────────→ 좁은 범위
2글로벌 락 > 테이블 락 > 네임드 락 > 레코드 락
3(DB 전체) (테이블) (사용자 정의) (행 단위)

글로벌 락 — DB 전체를 얼린다

sql
1FLUSH TABLES WITH READ LOCK; -- 전체 DB 읽기 전용
2-- 모든 INSERT/UPDATE/DELETE 대기 상태가 됨
3
4-- 백업 완료 후 해제
5UNLOCK TABLES;

전체 백업 시 일관된 스냅샷이 필요할 때 쓰인다. 하지만 InnoDB만 쓴다면 --single-transaction 옵션으로 대체하는 게 낫다. 락 없이 MVCC를 이용해 일관된 백업이 가능하기 때문이다.

테이블 락 — MyISAM 시절 유물

sql
1LOCK TABLES orders READ; -- 읽기만 허용
2LOCK TABLES orders WRITE; -- 나만 읽고 쓰기
3UNLOCK TABLES;

InnoDB는 행 단위 락이 있어서 테이블 락을 거의 쓰지 않는다. MyISAM 테이블을 다루거나 레거시 코드에서 볼 수 있다.

네임드 락 — 애플리케이션 레벨 분산 락

sql
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의 충돌 방지

명시적으로 걸지 않아도 자동으로 걸리는 락이다.

sql
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)이 아니라 인덱스 항목에 걸린다. 이 점이 핵심이다.

sql
1SELECT * FROM users WHERE id = 2 FOR UPDATE;
2-- → id=2 행이 아니라, PRIMARY KEY 인덱스의 id=2 항목에 락이 걸림

Record Lock — 딱 그 인덱스 항목 하나

text
1id: [1] [2] [3] [4] [5]
2 ★ ← id=2에만 락
3
4→ id=1, id=3은 자유롭게 수정 가능
5→ id=2는 다른 트랜잭션이 수정 불가

Gap Lock — 존재하지 않는 공간을 잠근다

text
1id: [1] [2] [3] [5]
2 ░░░░░░░░░░░ ← (3, 5) 사이 GAP에 락
3
4→ id=4인 행의 INSERT를 막음
5→ 팬텀 리드 방지가 목적
sql
1SELECT * FROM users WHERE id BETWEEN 3 AND 5 FOR UPDATE;
2-- id=3, id=5에 Record Lock + (3~5) 사이에 Gap Lock
3-- → 다른 트랜잭션이 id=4를 INSERT하려 하면 대기

Next-Key Lock — Record + Gap의 조합 (InnoDB 기본)

text
1id: [1] [2] [3] [4] [5]
2 ░░░░[★3]░░░░ ← id=3 레코드 + 앞의 Gap
3
4= (1, 3] 범위를 잠금

InnoDB가 기본적으로 Next-Key Lock을 쓰는 이유는 팬텀 리드 방지다. 같은 트랜잭션에서 동일한 SELECT를 두 번 실행했을 때 결과가 달라지는 현상을 막는다.

[!warning] 인덱스 없으면 테이블 전체 락

sql
1-- email에 인덱스가 없는 상태에서
2SELECT * FROM users WHERE email = 'alice@example.com' FOR UPDATE;
3
4-- InnoDB가 Full Table Scan 실행
5→ 전체 레코드에 Next-Key Lock 적용
6→ 사실상 테이블 전체 락!
7→ 다른 트랜잭션 모두 대기 → 장애

FOR UPDATE를 쓸 컬럼에는 반드시 인덱스가 있어야 한다.


데드락 — 서로가 서로를 기다리는 교착 상태

왜 발생하나?

text
1트랜잭션 A 트랜잭션 B
2─────────────────────────────────────────
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)로 선정해 롤백한다.

text
1ERROR 1213 (40001): Deadlock found when trying to get lock;
2 try restarting transaction

방지법 1: 항상 같은 순서로 락 획득

sql
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: 트랜잭션을 짧게 유지

sql
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% 방지가 불가능하다. 현실적인 해법은 재시도다.

typescript
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}

사내에서 데드락 관련해서 잘 모르고 과도하게 대처하려고 했는데, 이 교착상태는 생각보다 잘 일어나지 않으며, 재시도 로직으로 쉽게 방지가 되는 걸 이걸 보고 알았다.

sql
1-- 가장 최근 데드락 정보 확인
2SHOW ENGINE INNODB STATUS\G
3-- LATEST DETECTED DEADLOCK 섹션에서 원인 파악 가능

트랜잭션 격리 수준 — 4단계로 이해하기

두 트랜잭션이 동시에 실행될 때 3가지 문제가 생길 수 있다.

문제설명
Dirty Read커밋 안 된 데이터를 읽음
Non-Repeatable Read같은 쿼리를 두 번 날렸는데 결과가 다름
Phantom Read없던 행이 갑자기 생김

격리 수준은 이 문제들을 어디까지 허용할 것이냐의 트레이드오프다.

READ UNCOMMITTED — 거의 쓰지 않음

text
1트랜잭션 A 트랜잭션 B
2─────────────────────────────────────────
3BEGIN;
4UPDATE accounts
5SET balance = 5000;
6-- 아직 COMMIT 안 함
7 SELECT balance; → 5000 읽힘! (Dirty Read)
8
9ROLLBACK;
10-- 5000원은 존재하지 않는 값이었다
11-- B는 유령 데이터를 기반으로 뭔가를 했을 수도 있음

데이터 정합성이 전혀 필요 없는 경우에만 쓴다. 실무에서 쓸 일이 거의 없다.

READ COMMITTED — Oracle, PostgreSQL 기본값

text
1트랜잭션 A 트랜잭션 B
2─────────────────────────────────────────
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 기본값 ★

text
1트랜잭션 A 트랜잭션 B
2─────────────────────────────────────────
3BEGIN;
4SELECT balance; → 10000
5 BEGIN;
6 UPDATE accounts SET balance = 5000;
7 COMMIT;
8
9SELECT balance; → 10000 ← B가 바꿔도 안 바뀜!
10COMMIT;

트랜잭션 시작 시점의 스냅샷을 계속 읽는다. 이게 가능한 이유가 MVCC(Multi-Version Concurrency Control) 다.

MVCC — 락 없이 일관된 읽기를 보장하는 마법

InnoDB는 데이터를 바꿀 때 덮어쓰지 않고 버전을 쌓아둔다.

text
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에 공유 락이 자동으로 걸린다. 트랜잭션이 사실상 순서대로 실행된다. 모든 문제를 해결하지만 동시성이 극도로 떨어진다. 금융 정산처럼 정합성이 절대적으로 중요한 극소수의 케이스에서만 쓴다.

격리 수준 한눈에 비교

text
1격리 수준 Dirty Non-Rep Phantom 성능
2────────────────────────────────────────────────
3READ UNCOMMITTED ✗ ✗ ✗ 최고
4READ COMMITTED ✓ ✗ ✗ 좋음
5REPEATABLE READ ✓ ✓ △ 보통 ← MySQL 기본
6SERIALIZABLE ✓ ✓ ✓ 최저
7
8✓ 해결됨 ✗ 문제 발생 가능 △ 거의 해결(InnoDB의 경우)
sql
1-- 현재 격리 수준 확인
2SELECT @@transaction_isolation;
3-- 결과: REPEATABLE-READ
4
5-- 특정 세션에서만 변경
6SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

[!tip] 실무 선택 기준

  • 일반 웹 서비스: REPEATABLE READ (기본값 그대로)
  • 통계/대시보드: READ COMMITTED (최신 커밋 데이터 필요)
  • 금융 정산: SERIALIZABLE 또는 REPEATABLE READ + FOR UPDATE

실무에서 락을 직접 다뤄야 하는 경우

대부분의 락은 InnoDB가 자동으로 처리한다. 명시적으로 다뤄야 하는 경우는 생각보다 많지 않다.

text
1자동 처리 (대부분의 경우):
2 ✓ 일반 INSERT / UPDATE / DELETE
3 ✓ 트랜잭션 내 DML
4 ✓ InnoDB 백업 (--single-transaction)
5
6수동이 필요한 경우:
7 ✓ 읽고 → 계산 → 쓰기 패턴 → FOR UPDATE
8 ✓ 분산 환경 중복 방지 → GET_LOCK()
9 ✓ 레거시 MyISAM 백업 → 글로벌 락

가장 자주 쓰는 패턴은 SELECT ... FOR UPDATE다.

sql
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)을 더 깊게 파고, 쿼리 최적화 챕터로 넘어갈 예정이에요. 이 글이 도움이 됐으면 좋겠어요 ㅎㅎ.