Ch.15 유사 사례와 키워드 정리¶
이번 챕터에서는 DB 레벨의 동시성 문제를 파고들었다. Transaction이 있다고 동시성이 자동으로 해결되지 않는다는 것, Isolation Level에 따라 보호 수준이 다르다는 것, 그리고 SELECT ... FOR UPDATE로 명시적 잠금을 거는 방법을 확인했다.
같은 원리가 적용되는 실무 사례를 몇 가지 더 본다.
15-5. 유사 사례¶
포인트 중복 차감¶
이벤트 보상으로 포인트 10,000점을 지급했다. 사용자가 동시에 두 개의 주문을 넣는다. 각 주문에서 7,000점을 차감한다.
-- 주문 A
SELECT points FROM users WHERE id=1; --> 10000
-- 주문 B
SELECT points FROM users WHERE id=1; --> 10000 (같은 값!)
-- 주문 A
UPDATE users SET points = 10000 - 7000 WHERE id=1; --> 3000
-- 주문 B
UPDATE users SET points = 10000 - 7000 WHERE id=1; --> 3000 (다시 3000으로!)
재고 문제와 완전히 같은 구조다. 읽기 -> 계산 -> 쓰기가 원자적이지 않다. 포인트 10,000점으로 14,000점어치를 쓴 셈이다.
해결: SELECT ... FOR UPDATE로 행 잠금을 건다. 또는 UPDATE users SET points = points - 7000 WHERE id=1 AND points >= 7000처럼 UPDATE 구문 자체에 조건을 넣는다. 후자는 DB 엔진이 원자적으로 처리한다.
좌석 예약 동시성¶
콘서트 좌석 예약. A석 1자리가 남았다. 두 명이 동시에 예약 버튼을 누른다.
# 사용자 1
seat = session.query(Seat).filter(Seat.id == "A1").first()
if seat.status == "available":
seat.status = "reserved"
seat.reserved_by = user_1_id
session.commit()
# 사용자 2 (거의 동시에)
seat = session.query(Seat).filter(Seat.id == "A1").first()
if seat.status == "available": # 사용자 1의 COMMIT 전이면 아직 "available"
seat.status = "reserved"
seat.reserved_by = user_2_id
session.commit() # 사용자 1의 예약이 덮어씌워진다!
이것도 같은 구조다. 재고든 포인트든 좌석이든, "읽기 -> 체크 -> 쓰기"가 원자적이지 않으면 동시성 문제가 발생한다.
해결: 좌석 예약에는 비관적 잠금이 적합하다. 인기 좌석은 충돌이 잦기 때문이다.
seat = (
session.query(Seat)
.filter(Seat.id == "A1")
.with_for_update() # 행 잠금
.first()
)
if seat.status == "available":
seat.status = "reserved"
seat.reserved_by = user_id
session.commit()
else:
session.rollback()
return "이미 예약된 좌석입니다"
Lost Update (갱신 분실)¶
Dirty Read, Non-repeatable Read, Phantom Read는 "읽기"에서 생기는 문제다. 그런데 "쓰기"에서도 문제가 생긴다.
두 Transaction이 같은 행을 동시에 UPDATE하면, 나중에 쓴 쪽이 먼저 쓴 쪽의 변경을 덮어쓴다. 이걸 Lost Update라고 한다. 재고 사례에서 본 것과 정확히 같은 현상이다.
Transaction A: 재고를 10으로 읽음
Transaction B: 재고를 10으로 읽음
Transaction A: 재고를 9로 UPDATE (10 - 1)
Transaction B: 재고를 9로 UPDATE (10 - 1, A의 변경을 덮어씀)
--> 2개 팔았는데 1개만 차감됨
Lost Update는 격리 수준을 올리는 것만으로는 안전하게 막기 어려운 문제다. 이유가 있다. SQL 표준이 정의한 3대 이상현상(Dirty Read·Non-repeatable Read·Phantom Read)에 Lost Update는 포함되지 않는다. 이 셋은 "읽기가 오염되는" 문제라 격리 수준이 다루지만, Lost Update는 "각자 읽은 값을 따로 계산해 덮어쓰는" 쓰기 충돌이라 결이 다르다. MVCC의 Consistent Read는 내가 읽은 행이 그 사이 바뀌었는지를 검사하지 않기 때문이다.
게다가 엔진마다 처리가 달라서 "격리 수준을 올리면 알아서 막힌다"에 기대면 이식성이 없다. InnoDB는 SERIALIZABLE에서 일반 SELECT를 잠금 읽기로 바꿔 충돌시켜 막고, PostgreSQL은 REPEATABLE READ에서도 같은 행 갱신을 감지해 에러를 낸다. 엔진과 무관하게 확실한 방법은 명시적 잠금(SELECT ... FOR UPDATE)이나 버전 검사(Optimistic Lock)다.
그래서 실무에서는 어떻게 하는가¶
1. 동시성이 중요한 곳에는 SELECT ... FOR UPDATE를 쓴다¶
# 재고 차감, 포인트 차감, 좌석 예약 등
with Session(engine) as session:
product = (
session.query(Product)
.filter(Product.id == product_id)
.with_for_update() # 명시적 잠금
.first()
)
if product.stock >= quantity:
product.stock -= quantity
session.commit()
"모든 SELECT에 FOR UPDATE를 붙여야 하는가?" 아니다. 동시 수정이 발생할 수 있는 곳에만 쓴다. 단순 조회(상품 목록, 게시글 읽기)에는 필요 없다. 불필요한 잠금은 처리량을 떨어뜨린다.
2. UPDATE 구문에 조건을 넣는다¶
Python에서 비교하지 않고 SQL 자체에 조건을 넣으면, DB가 행 잠금 + 조건 체크 + 갱신을 원자적으로 처리한다. SELECT ... FOR UPDATE를 안 써도 되는 경우가 많다.
# SQLAlchemy에서
rows_updated = (
session.query(Product)
.filter(Product.id == product_id, Product.stock >= quantity)
.update({Product.stock: Product.stock - quantity})
)
session.commit()
if rows_updated == 0:
return "품절"
rows_updated가 0이면 조건에 맞는 행이 없었다는 뜻이다. 재고가 부족했거나, 다른 Transaction이 먼저 차감한 거다.
3. 충돌이 적으면 Optimistic Lock을 고려한다¶
# version 컬럼 활용
rows_updated = (
session.query(Product)
.filter(Product.id == product_id, Product.version == current_version)
.update({
Product.stock: Product.stock - quantity,
Product.version: Product.version + 1
})
)
게시글 수정, 설정 변경 같은 곳은 동시 수정이 드물다. 비관적 잠금으로 매번 행을 잠그는 건 과잉 대응이다. 낙관적 잠금으로 충돌 시에만 재시도하는 게 효율적이다.
4. Transaction을 짧게 유지한다¶
# 나쁜 패턴: Transaction 안에서 외부 API를 호출한다
with Session(engine) as session:
product = session.query(Product).with_for_update().first()
response = requests.post("https://payment.api/charge") # 외부 호출 (수 초 소요)
if response.ok:
product.stock -= 1
session.commit()
# 외부 API 응답 올 때까지 행 잠금이 유지된다!
# 좋은 패턴: 잠금 구간을 최소화한다
response = requests.post("https://payment.api/charge") # 먼저 외부 처리
if response.ok:
with Session(engine) as session:
product = session.query(Product).with_for_update().first()
product.stock -= 1
session.commit()
# 잠금 구간이 극히 짧다
SELECT ... FOR UPDATE로 잠긴 행은 Transaction이 끝날 때까지 다른 Transaction을 블로킹한다. Transaction이 길면 다른 요청이 전부 대기한다. 그러면 Connection Pool이 고갈되고 서버가 먹통이 된다.
(Ch.6에서 Connection Pool 고갈을 다뤘다. Ch.16에서 Slow Query가 Connection Pool을 죽이는 사례를 더 자세히 본다.)
5. Deadlock에 대비한다¶
비관적 잠금을 쓰면 Deadlock 가능성이 생긴다. Ch.5에서 다뤘던 그 Deadlock이다.
Transaction A: SELECT ... FOR UPDATE WHERE id=1 --> 행 1 잠금
Transaction B: SELECT ... FOR UPDATE WHERE id=2 --> 행 2 잠금
Transaction A: SELECT ... FOR UPDATE WHERE id=2 --> 대기 (B가 잠금 중)
Transaction B: SELECT ... FOR UPDATE WHERE id=1 --> 대기 (A가 잠금 중)
--> Deadlock!
MySQL InnoDB는 Deadlock을 자동 감지한다. Deadlock이 감지되면 한쪽 Transaction을 강제로 ROLLBACK한다 (보통 비용이 적은 쪽). 애플리케이션에서는 이 ROLLBACK을 감지하고 재시도하면 된다.
from sqlalchemy.exc import OperationalError
max_retries = 3
for attempt in range(max_retries):
try:
with Session(engine) as session:
product = session.query(Product).with_for_update().first()
product.stock -= quantity
session.commit()
break
except OperationalError as e:
if "Deadlock" in str(e):
continue # 재시도
raise
Ch.5에서 Lock Ordering을 배웠다. DB에서도 같다. 여러 행을 잠글 때 항상 같은 순서(id 오름차순 등)로 잠그면 Deadlock을 방지할 수 있다.
면접 Q&A (이 챕터로 답할 수 있는 단골 질문)¶
ACID가 뭔가요?
Transaction이 지켜야 하는 4가지 성질이다. Atomicity(원자성)는 전부 성공하거나 전부 실패해야 한다는 것, Consistency(일관성)는 트랜잭션 전후로 데이터가 일관된 상태여야 한다는 것, Isolation(격리성)은 동시에 실행되는 트랜잭션이 서로 간섭하지 않아야 한다는 것, Durability(지속성)는 커밋된 결과는 장애가 나도 유지되어야 한다는 거다. 재고가 -10이 되는 사고는 Isolation이 완벽하지 않아서 생긴 일이다.
Dirty Read, Non-repeatable Read, Phantom Read의 차이가 뭔가요?
Dirty Read는 다른 Transaction이 아직 COMMIT하지 않은 데이터를 읽는 거다. ROLLBACK되면 존재하지 않는 값을 기반으로 로직이 돈다. Non-repeatable Read는 같은 행을 두 번 읽었는데 다른 Transaction의 UPDATE+COMMIT 때문에 값이 달라진 거다. Phantom Read는 같은 범위 조건으로 두 번 SELECT했는데 그 사이 INSERT/DELETE로 결과 행 수가 달라진 거다. 기존 행 값 변경(Non-repeatable)과 행 자체 추가/제거(Phantom)의 차이다.
MySQL InnoDB의 기본 Isolation Level이 REPEATABLE READ인 이유는?
SQL 표준대로라면 REPEATABLE READ에서 Phantom Read가 발생해야 하는데, InnoDB는 Gap Lock과 Next-Key Lock으로 대부분의 Phantom을 막아준다. Gap Lock은 인덱스 레코드 사이의 간격을 잠가서, 해당 범위에 다른 Transaction이 INSERT하는 걸 막는다. SERIALIZABLE까지 올리지 않아도 실용적으로 충분한 보호를 제공하면서 동시성도 유지할 수 있어서 기본값으로 적합하다.
REPEATABLE READ와 SERIALIZABLE의 차이가 뭔가요?
REPEATABLE READ는 같은 Transaction 안에서 같은 행을 여러 번 읽어도 항상 같은 값이 나오게 한다(MVCC 스냅샷). 일반 SELECT는 잠금 없이 읽는다. SERIALIZABLE은 가장 엄격해서 모든 SELECT가 자동으로 SELECT ... FOR SHARE로 변환된다. 읽기에도 잠금이 걸리므로 Dirty/Non-repeatable/Phantom 전부 방지되지만, 동시 처리량이 급격히 떨어지고 Deadlock 확률이 올라간다.
재고가 -1이 되는 사고는 왜 발생하나요? Transaction을 썼는데도요.
Transaction을 쓰더라도 일반 SELECT는 잠금 없이 실행된다(Consistent Read). 그래서 두 Transaction이 같은 순간 같은 재고 값을 읽고, 둘 다 "재고 ≥ 1"을 통과하고, 둘 다 차감하는 일이 생긴다. "읽기 → 체크 → 쓰기"가 원자적이지 않다는 점은 Ch.5의 Race Condition과 정확히 같은 구조다. 무대만 Heap에서 DB로 바뀌었을 뿐이다.
Pessimistic Lock과 Optimistic Lock 중 뭘 써야 하나요?
충돌 빈도로 결정한다. 한정 수량 이벤트나 금융 거래처럼 충돌이 잦으면 SELECT ... FOR UPDATE(Pessimistic)가 안전하다. 미리 잠그니까 확실하게 차단된다. 게시글 수정이나 설정 변경처럼 충돌이 드물면 version 컬럼 기반 Optimistic Lock이 처리량 측면에서 유리하다. 충돌 시 재시도 비용을 감수해야 하지만, 잠금 대기가 없다.
동시성 문제를 SELECT ... FOR UPDATE 없이 해결하는 방법도 있나요?
있다. UPDATE 구문 자체에 조건을 넣으면 된다. UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock >= 1처럼 쓰면 DB가 행 잠금 + 조건 체크 + 갱신을 원자적으로 처리한다. rows_updated가 0이면 재고가 부족했거나 다른 Transaction이 먼저 차감한 거다. SELECT ... FOR UPDATE보다 잠금 구간이 짧아서 처리량이 좋다.
3. 오늘 키워드 정리¶
Ch.5의 Race Condition이 DB 레벨로 확장되었다. 도구와 키워드가 달라졌지만 본질은 같다: "읽기 -> 체크 -> 쓰기"를 원자적으로 만들어야 한다는 것.
Transaction (트랜잭션)
데이터베이스에서 하나의 논리적 작업 단위를 구성하는 연산들의 묶음이다. "다 되거나, 아예 안 되거나"가 핵심이다. BEGIN으로 시작해서 COMMIT(반영) 또는 ROLLBACK(취소)으로 끝난다.
Ch.5의 Critical Section이 "코드 레벨의 보호 구간"이라면, Transaction은 "DB 레벨의 보호 구간"이다.
ACID
Transaction이 지켜야 하는 4가지 성질이다.
- Atomicity (원자성): 전부 성공하거나 전부 실패. Ch.5의 Atomicity와 같은 개념이 DB로 확장.
- Consistency (일관성): 트랜잭션 전후로 데이터가 일관된 상태 유지.
- Isolation (격리성): 동시 트랜잭션이 서로 간섭하지 않음. Isolation Level로 수준을 조절.
- Durability (지속성): 커밋된 결과는 장애가 나도 유지됨.
Isolation Level (격리 수준)
동시에 실행되는 Transaction 간의 격리 정도를 결정하는 설정이다. READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE의 4단계가 있다. 올라갈수록 안전하지만 느리다.
MySQL InnoDB의 기본값은 REPEATABLE READ이고, Gap Lock 덕분에 SQL 표준보다 강력한 보호를 제공한다.
Dirty Read (더티 리드)
다른 Transaction이 아직 COMMIT하지 않은 데이터를 읽는 현상이다. ROLLBACK되면 존재하지 않는 데이터를 기반으로 로직을 실행하게 된다. READ UNCOMMITTED에서 발생하고, READ COMMITTED 이상에서 방지된다.
Non-repeatable Read (반복 불가능한 읽기)
같은 Transaction 안에서 같은 행을 두 번 읽었는데 값이 달라진 현상이다. 다른 Transaction이 중간에 해당 행을 수정하고 COMMIT했기 때문이다. READ COMMITTED에서 발생하고, REPEATABLE READ 이상에서 방지된다.
Phantom Read (팬텀 리드)
같은 Transaction 안에서 같은 범위 조건으로 SELECT를 두 번 실행했는데 결과 행 수가 달라진 현상이다. 다른 Transaction이 해당 범위에 INSERT/DELETE했기 때문이다. REPEATABLE READ에서 발생할 수 있지만, MySQL InnoDB는 Gap Lock으로 대부분 방지한다.
Pessimistic Lock (비관적 잠금)
"충돌이 날 거라고 비관적으로 가정"하고 읽는 시점에 잠금을 거는 전략이다. SELECT ... FOR UPDATE가 대표적이다. 충돌이 잦은 환경(한정 수량 이벤트, 금융 거래)에서 유리하다. Ch.5의 threading.Lock()이 비관적 잠금이다.
(Java/JPA: @Lock(LockModeType.PESSIMISTIC_WRITE))
Optimistic Lock (낙관적 잠금)
"충돌이 안 날 거라고 낙관적으로 가정"하고 잠금 없이 읽은 뒤, 쓸 때 version 컬럼으로 충돌 여부를 확인하는 전략이다. 충돌이 적은 환경(게시글 수정, 설정 변경)에서 유리하다. 충돌 시 재시도가 필요하다.
(Java/JPA: @Version)
재등장 키워드¶
| 키워드 | 최초 등장 | 이번 챕터에서의 역할 |
|---|---|---|
| Race Condition | Ch.5 | Heap에서 DB로 무대가 바뀌었을 뿐, 같은 구조의 문제. "읽기 -> 체크 -> 쓰기"의 비원자성 |
| Mutex / Lock | Ch.5 | SELECT ... FOR UPDATE가 DB 레벨의 Mutex. threading.Lock()과 같은 역할 |
| Connection Pool | Ch.6 | Transaction이 길어지면 Connection을 오래 점유. Pool 고갈의 원인 |
키워드 연관 관계¶
graph LR
TX["Transaction"] --> ACID
ACID --> ATOM["Atomicity<br/>(Ch.5)"]
ACID --> CONS["Consistency"]
ACID --> ISO["Isolation"]
ACID --> DUR["Durability"]
ISO --> IL["Isolation Level"]
IL --> RU["READ<br/>UNCOMMITTED"]
IL --> RC["READ<br/>COMMITTED"]
IL --> RR["REPEATABLE<br/>READ"]
IL --> SER["SERIALIZABLE"]
RU -->|"발생"| DR["Dirty Read"]
RC -->|"발생"| NRR["Non-repeatable<br/>Read"]
RR -->|"발생 가능<br/>(InnoDB는 대부분 방지)"| PR["Phantom Read"]
RR -->|"InnoDB 기본값"| GL["Gap Lock"]
GL --> NKL["Next-Key Lock"]
TX -->|"동시성 제어"| PL["Pessimistic Lock"]
TX -->|"동시성 제어"| OL["Optimistic Lock"]
PL --> SFU["SELECT ...<br/>FOR UPDATE"]
OL --> VER["version 컬럼"]
RACECOND["Race Condition<br/>(Ch.5)"] -.->|"DB 레벨"| TX
MUTEX["Mutex<br/>(Ch.5)"] -.->|"DB 레벨"| SFU
CP["Connection Pool<br/>(Ch.6)"] -.->|"Transaction이<br/>길면 고갈"| TX
style DR fill:#f96,stroke:#333
style NRR fill:#f96,stroke:#333
style PR fill:#f96,stroke:#333
여기까지의 지도 (누적)¶
위의 "키워드 연관 관계"가 이번 챕터 안의 연결이라면, 아래는 Ch.1부터 지금까지 도메인별로 쌓인 키워드다. 빈 레인은 앞으로 그 챕터에서 채워진다 — 다 채워지는 과정이 곧 이 강의의 지도다. (이번 챕터에서 추가된 키워드는 굵게.)
| 도메인 | 누적 키워드 (등장 챕터) |
|---|---|
| OS·실행 | System Call·Kernel·Mode Switch·Buffer·File Descriptor(2) → CPU/IO Bound·GIL·Event Loop·async/await·Thread/Process Pool·Context Switch·IPC(3) → Process·Thread·PCB/TCB·Memory Layout·Virtual Memory·Page·TLB·OOM(4) |
| 동시성·트랜잭션 | Race Condition·Critical Section·Mutex/Lock·Deadlock·Semaphore·Starvation(5) → ACID·Transaction·Isolation Level·Dirty/Phantom Read·Pessimistic/Optimistic Lock(15) |
| 자료구조·알고리즘 | Hash Table·Time/Space Complexity·Linear Search·Load Factor(10) → Binary Search·B-Tree/B+Tree·Index·Full Table Scan·Tim Sort·EXPLAIN(11) → BFS·DFS·DAG·Topological Sort·Trie·Adjacency List(12) |
| DB·캐시·튜닝 | ORM·N+1·Lazy/Eager Loading·QEP·CBO(13) → Covering/Composite Index·Cardinality·Hash Index(14) |
| 네트워크·분산 | TCP/IP·Socket·3/4-Way Handshake·Connection Pool·Keep-Alive·TIME_WAIT(6) |
| 설계·AI·품질·보안 | Computational Thinking·CS 키워드(1) → LLM·Token·Context Window·Hallucination·Prompt Engineering(7) → 키워드 사전·DNS·Load Balancing·Circuit Breaker·CQRS(8) → Code Review·YAGNI·Cache Stampede(9) |
다음에 이어지는 이야기¶
이번 챕터에서는 Transaction과 Isolation Level, 그리고 Pessimistic/Optimistic Lock으로 DB 레벨의 동시성 문제를 해결하는 방법을 확인했다. 그런데 동시성 문제가 해결되었다고 DB 성능이 좋아지는 건 아니다.
Transaction이 길어지면 Connection을 오래 잡고 있고, Connection Pool이 고갈되고, 서버가 먹통이 된다. Slow Query 하나가 이 연쇄 반응을 일으킨다. Index를 안 걸어놓고 Redis를 설치하는 사람도 있다.
Ch.16에서는 DB 성능 튜닝의 실무를 다룬다. Slow Query가 Connection Pool을 어떻게 죽이는지, 서브쿼리와 JOIN과 EXISTS의 차이가 뭔지 파고든다.