목표: 트랜잭션의 ACID, 격리 수준, 락, MVCC, 낙관적·비관적 동시성 제어, 분산 트랜잭션(2PC, Saga) 을 이해한다. FE 개발자도 동시성 문제 사례를 알면 API 설계에서 실수를 줄일 수 있다.
0. "트랜잭션"을 은행 창구로 이해하기 — 초심자용
0-1. 한 줄 정의
트랜잭션(Transaction) = "여러 개의 작업을 하나의 덩어리처럼 묶어서, 전부 성공하거나 전부 실패하게 만드는 것." 부분 성공은 절대 없음.
0-2. 은행 이체 비유
철수가 영희에게 10만 원을 이체한다고 하자. 이건 두 가지 작업이다.
- 철수 계좌 잔액 - 10만 원
- 영희 계좌 잔액 + 10만 원
만약 1번 직후 서버가 다운되면?
- 철수 돈 10만 원이 증발
- 영희는 받은 적 없음
트랜잭션으로 묶으면: 둘 다 되거나, 둘 다 안 된다. 중간 상태는 세상에 존재하지 않는다.
BEGIN; -- 트랜잭션 시작
UPDATE ... -- 1번
UPDATE ... -- 2번
COMMIT; -- 여기까지 다 됐으면 확정
-- 또는
ROLLBACK; -- 뭔가 잘못됐으면 전부 취소
0-3. 트랜잭션 없이는 안 되는 일
- 이체, 결제: 여러 계좌/상품 상태가 동시에 바뀜
- 재고 처리: 주문 테이블에 추가 + 재고 테이블 차감
- 포인트 적립: 구매 기록 + 포인트 잔액 증가
- 회원 탈퇴: 사용자 삭제 + 연관 리소스 정리
"돈이 오가는 곳에 트랜잭션 없으면 서비스는 반드시 터진다."
0-4. 동시성 문제 맛보기
혼자 이체하면 문제없다. 그런데 같은 계좌에 10명이 동시에 이체하면? 순서에 따라 최종 잔액이 다르게 나오는 경쟁 조건(race condition) 이 발생한다.
DB는 이를 막기 위해 락(lock) 이나 MVCC 같은 메커니즘을 쓴다. 이 장의 후반부가 그 이야기.
0-5. FE 개발자와 트랜잭션
"트랜잭션은 BE 담당 아닌가?" 맞다. 하지만 FE도 알아야 하는 이유:
- Optimistic Update: 서버 응답 오기 전에 UI를 먼저 업데이트. 실패 시 되돌림 → FE 버전의 트랜잭션
- 결제 UX: "결제 실패" 화면에서 이미 차감된 포인트를 보여주면 사고. 트랜잭션 개념이 없으면 이런 UX를 잘못 설계함
- 동시 편집 충돌: Notion / Google Docs 같은 공동 편집 — 낙관적 동시성 제어 개념이 그대로 쓰임
- Idempotency Key: 결제 중복 방지를 위한 멱등성 키 — 트랜잭션과 짝이 되는 개념
0-6. 이 장의 용어 지도
| 용어 | 한 줄 |
|---|---|
| ACID | 트랜잭션이 지켜야 할 4가지 속성 |
| 격리 수준 | "다른 트랜잭션이 얼마나 보이나" 조절 다이얼 |
| 락(Lock) | "내가 쓰는 동안 넌 건드리지 마" |
| MVCC | 버전 여러 개 관리해서 락 없이 동시성 처리 |
| 낙관적 / 비관적 | 충돌이 드물다 / 자주 일어난다고 가정하고 다르게 처리 |
| 분산 트랜잭션 | 여러 DB/서비스에 걸친 트랜잭션 — 매우 어려움 |
1. 트랜잭션 기본
논리적으로 분리 불가능한 연산의 묶음. "전부 성공 아니면 전부 실패".
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 또는
ROLLBACK;
왜 필요한가 — 이체 시나리오
-- Tx 없으면
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 서버 크래시!
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- 실행 안 됨
-- → 1번 계좌에서 100이 증발
2. ACID 복습 (3-1장 참조)
- Atomicity: 전부 or 전부 아님
- Consistency: 제약 유지
- Isolation: 서로 간섭 없음
- Durability: 커밋 후 유지
3. 동시성 문제
Dirty Read
T1: UPDATE balance = 50 (커밋 안 함)
T2: SELECT balance → 50 읽음
T1: ROLLBACK
T2: 잘못된 값 기반으로 계산
Non-Repeatable Read
T1: SELECT balance → 100
T2: UPDATE balance = 50; COMMIT
T1: SELECT balance → 50 (같은 쿼리가 다른 결과)
Phantom Read
T1: SELECT COUNT(*) FROM orders WHERE date = today → 5
T2: INSERT INTO orders ... date=today; COMMIT
T1: SELECT COUNT(*) → 6 (행 수 변화)
Lost Update
T1: SELECT qty → 10
T2: SELECT qty → 10
T1: UPDATE SET qty = 10 + 5 = 15; COMMIT
T2: UPDATE SET qty = 10 + 3 = 13; COMMIT
-- 최종 13, T1의 +5가 사라짐
Write Skew (BCNF 위반 시나리오 유사)
규칙: 의사 중 1명은 항상 근무해야 함 (count >= 1)
현재: A, B 근무 중
T1: A가 off 요청 → count(다른 의사) = 1 확인 → off
T2: B가 off 요청 → count(다른 의사) = 1 확인 → off (T1 커밋 전)
-- 둘 다 off → 0명
→ Serializable 격리로만 완전 해결.
4. 격리 수준 상세
| 수준 | Dirty | NonRepeat | Phantom | LostUpdate | WriteSkew |
|---|---|---|---|---|---|
| Read Uncommitted | ✅ | ✅ | ✅ | ✅ | ✅ |
| Read Committed | ❌ | ✅ | ✅ | ✅ | ✅ |
| Repeatable Read | ❌ | ❌ | ✅* | ❌** | ✅ |
| Serializable | ❌ | ❌ | ❌ | ❌ | ❌ |
(*MySQL InnoDB는 갭 락으로 Phantom 차단. **PostgreSQL Repeatable Read는 lost update 차단.)
실전 기본값
| DB | 기본 |
|---|---|
| PostgreSQL | Read Committed |
| MySQL InnoDB | Repeatable Read |
| SQL Server | Read Committed (lock-based) |
| Oracle | Read Committed |
격리 수준 변경
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
...
COMMIT;
5. 락 (Lock)
종류
| 락 | 설명 |
|---|---|
| Shared (S) | 읽기 공유 |
| Exclusive (X) | 쓰기 독점 |
| Update (U) | 읽을 때 획득, 쓰기로 승격 |
| Intent (IS, IX) | 상위 단위에 하위 락 의도 표시 |
레벨
- Row lock
- Gap lock (MySQL) — 인덱스 범위
- Table lock
- Advisory lock — 애플리케이션 정의
SELECT FOR UPDATE
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- 행 X 락
-- 다른 트랜잭션은 이 행 UPDATE 대기
UPDATE accounts SET balance = ... WHERE id = 1;
COMMIT;
- 비관적 락 구현에 핵심
FOR SHARE: S 락
Deadlock
T1: 락 A 소유, B 대기
T2: 락 B 소유, A 대기 → 영원
DB는 주기적 탐지 후 한 트랜잭션을 희생시킴 (deadlock detected 에러).
대응
- 항상 같은 순서로 락 획득 (예: id 오름차순)
- 트랜잭션 짧게
- 락 타임아웃 설정
- 재시도 로직
6. MVCC (Multi-Version Concurrency Control)
읽기는 쓰기를 블로킹하지 않는다. 쓰기는 새 버전을 만들고, 읽기는 자신에게 맞는 스냅샷을 본다.
PostgreSQL MVCC
- 각 행에
xmin(생성 Tx),xmax(삭제 Tx) 메타 - UPDATE는 새 행을 만들고 이전 행을 dead로 표시
VACUUM이 dead tuple 회수
장점
- 읽기/쓰기 병행 성능 ↑
- 다수 사용자 서비스에 적합
단점
- 저장 공간 ↑
- VACUUM 부담 — 방치하면 테이블 팽창, 통계 노후화
- 긴 트랜잭션이 VACUUM을 막아 HOT(Heap-Only-Tuple) 업데이트 실패
7. 낙관적 vs 비관적 동시성 제어
비관적 (Pessimistic)
BEGIN;
SELECT * FROM products WHERE id = 1 FOR UPDATE;
-- 락 잡힘
UPDATE products SET qty = qty - 1 WHERE id = 1;
COMMIT;
- 충돌을 미리 막음
- 락 경합 높으면 처리량 ↓
낙관적 (Optimistic)
-- 버전 컬럼 사용
UPDATE products
SET qty = qty - 1, version = version + 1
WHERE id = 1 AND version = 7;
-- 영향 받은 행 0 → 다른 트랜잭션이 먼저 수정함 → 재시도
- 락 없음 → 처리량 ↑
- 충돌 많으면 재시도 비용 ↑
선택
| 상황 | 전략 |
|---|---|
| 충돌 드묾 (CRUD 대부분) | 낙관적 |
| 충돌 빈번 (재고 차감) | 비관적 또는 CAS |
| 긴 트랜잭션 | 낙관적 + 이벤트 소싱 |
8. 분산 트랜잭션
여러 서비스/DB에 걸친 원자성. MSA 시대의 핵심 고민.
2PC (Two-Phase Commit)
Coordinator Participant A Participant B
│ │ │
│─── prepare? ──────▶│ │
│─── prepare? ──────────────────────────▶ │
│ │ │
│◀── ready ──────────│ │
│◀── ready ─────────────────────────────── │
│ │ │
│─── commit ─────────▶│ │
│─── commit ────────────────────────────▶ │
문제
- Coordinator 장애 시 블로킹 (participant가 무한 대기)
- 네트워크 왕복 2회 → 느림
- CAP에서 A를 포기
실무에서는 2PC 대신 Saga 패턴 이 선호됨.
Saga 패턴
각 서비스가 로컬 트랜잭션 수행 → 실패 시 보상 트랜잭션 (Compensating Tx) 실행.
주문 생성 → 결제 → 재고 차감 → 배송
실패 시: ← 환불 ← 재고 복구 ← 배송 취소
구현 방식
- Choreography: 각 서비스가 이벤트 구독/발행
- Orchestration: 중앙 오케스트레이터가 흐름 제어
Outbox 패턴
DB에 이벤트를 함께 저장하고, 별도 프로세스가 메시지 브로커로 릴레이 → DB와 이벤트의 원자성 보장.
9. 프론트엔드와 동시성
낙관적 업데이트
// React Query
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries(['todos']);
const prev = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
return { prev };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.prev); // 롤백
},
onSettled: () => queryClient.invalidateQueries(['todos']),
});
If-Match 헤더 (ETag 기반)
PUT /posts/1
If-Match: "v7"
→ 412 Precondition Failed (버전 불일치)
HTTP 수준의 낙관적 락.
충돌 해결 UX
- Auto-merge (문서 에디터)
- Last-write-wins (간단)
- Manual resolve (Git-style)
- CRDT 기반 (Yjs, Automerge) — 협업 에디터
10. 실전 패턴
멱등성 키
POST /orders
Idempotency-Key: uuid-1234
- 서버가 같은 키 요청을 1회만 처리
- 결제·주문 API에 필수
재고 차감
-- ❌ Lost Update 가능
SELECT qty FROM products WHERE id = 1; -- 10
qty = 10 - 1;
UPDATE products SET qty = 9 WHERE id = 1;
-- ✅ 원자적
UPDATE products SET qty = qty - 1 WHERE id = 1 AND qty > 0;
-- 영향 받은 행 수가 0이면 재고 부족
큐 기반 비동기 처리
- 주문 → 큐 → 워커가 순차 처리
- 동시성 제거, 유저는 빠른 응답
11. ⚠️ 자주 하는 실수
| 실수 | 결과 |
|---|---|
| 긴 트랜잭션 안에서 외부 API 호출 | 락 길게 잡힘 → 대기 폭발 |
트랜잭션 내에서 await로 긴 작업 | DB 커넥션 고갈 |
| 격리 수준을 필요 없이 Serializable | 처리량 급락 |
| 락 순서 임의 | Deadlock |
| 재시도 로직 없이 낙관적 락 | 일시적 충돌에 실패 |
| Saga에 보상 트랜잭션 누락 | 데이터 부정합 |
12. 연습 문제
Q1. Lost Update가 발생하는 시나리오와 해결책은?
정답
두 트랜잭션이 같은 행을 읽고 각자 계산한 값으로 쓰면 한 쪽의 갱신이 사라진다. 해결:
- 원자적 UPDATE:
SET qty = qty - 1 - 비관적 락:
SELECT FOR UPDATE - 낙관적 락: 버전 컬럼 체크
- 격리 수준 Repeatable Read+ (PG는 lost update를 감지해 에러)
Q2. MVCC가 "읽기는 쓰기를 블로킹하지 않는다"는 어떻게 가능한가?
정답
쓰기가 이전 버전을 즉시 덮지 않고 새 행을 추가 하고 xmax로 이전 행을 soft-delete 표시. 읽기는 자기 스냅샷에 해당하는 버전을 선택. 락 없이 일관된 스냅샷 읽기 가능. 대신 VACUUM으로 dead row를 회수해야 함.
Q3. SELECT FOR UPDATE와 낙관적 락 중 재고 차감엔 어느 쪽이 나은가?
정답
상황에 따라:
- 인기 상품 (동시 주문 다수): 비관적 락이 재시도 폭풍 방지
- 일반 상품: 낙관적 락이 처리량 유리
- 실제로는
UPDATE SET qty = qty - 1 WHERE qty > 0같은 원자적 쿼리 + affected rows 검사가 가장 효율적
Q4. Deadlock 예방 3가지 방법은?
정답
- 락 순서 일관화: 항상 같은 순서 (예: id 오름차순) 로 여러 행 락
- 트랜잭션 짧게: 락 점유 시간 최소
- 락 타임아웃 + 자동 재시도: 애플리케이션 레벨에서 서킷 브레이커
- 가능하면 비관적 락 대신 원자적 쿼리 나 낙관적 락
Q5. 분산 트랜잭션에서 2PC의 치명적 약점은?
정답
Coordinator 장애 시 블로킹. Prepare 완료한 Participant는 commit/abort 결정을 받지 못해 락을 잡은 채 대기. 복구될 때까지 전체 시스템이 멈춘다. 또 네트워크 왕복 2회로 느리고, CAP에서 A를 희생. 그래서 실무에선 Saga 패턴이 선호됨.
Q6. 멱등성 키(Idempotency Key) 가 왜 필요한가?
정답
네트워크 재시도·타임아웃으로 같은 요청이 두 번 도달할 수 있다. 특히 결제 API에서 중복 결제가 치명적. 서버가 Idempotency-Key를 기억해두고 같은 키 요청은 저장된 응답을 재사용 → 안전한 재시도 가능. Stripe, Toss 등이 표준화.
Q7. React Query의 낙관적 업데이트에서 onError cleanup이 왜 중요한가?
정답
UI를 먼저 변경했는데 서버 요청이 실패하면 화면과 서버 상태가 불일치. onError 에서 onMutate 의 snapshot을 setQueryData 로 복원하면 UI가 정확한 상태로 되돌아감. 빼먹으면 사용자는 "이미 저장됐다"고 착각.
13. 체크리스트
- 동시성 문제 5가지(Dirty/Non-Repeat/Phantom/Lost/WriteSkew) 를 설명한다
- 격리 수준 4단계와 각각이 막는 문제를 안다
- MVCC의 원리와 VACUUM의 필요성을 안다
- 비관적·낙관적 동시성 제어의 trade-off를 안다
- Deadlock 원인과 예방법을 안다
- 2PC vs Saga의 차이를 설명할 수 있다
- 멱등성 키와 낙관적 업데이트 패턴을 쓸 수 있다