JUNSEOK
04 · 데이터베이스·18분·4개 레슨

트랜잭션

ACID, 격리 수준, 락과 데드락.

목표: 트랜잭션의 ACID, 격리 수준, 락, MVCC, 낙관적·비관적 동시성 제어, 분산 트랜잭션(2PC, Saga) 을 이해한다. FE 개발자도 동시성 문제 사례를 알면 API 설계에서 실수를 줄일 수 있다.


0. "트랜잭션"을 은행 창구로 이해하기 — 초심자용

0-1. 한 줄 정의

트랜잭션(Transaction) = "여러 개의 작업을 하나의 덩어리처럼 묶어서, 전부 성공하거나 전부 실패하게 만드는 것." 부분 성공은 절대 없음.

0-2. 은행 이체 비유

철수가 영희에게 10만 원을 이체한다고 하자. 이건 두 가지 작업이다.

  1. 철수 계좌 잔액 - 10만 원
  2. 영희 계좌 잔액 + 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. 격리 수준 상세

수준DirtyNonRepeatPhantomLostUpdateWriteSkew
Read Uncommitted
Read Committed
Repeatable Read✅*❌**
Serializable

(*MySQL InnoDB는 갭 락으로 Phantom 차단. **PostgreSQL Repeatable Read는 lost update 차단.)

실전 기본값

DB기본
PostgreSQLRead Committed
MySQL InnoDBRepeatable Read
SQL ServerRead Committed (lock-based)
OracleRead 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가 발생하는 시나리오와 해결책은?

정답

두 트랜잭션이 같은 행을 읽고 각자 계산한 값으로 쓰면 한 쪽의 갱신이 사라진다. 해결:

  1. 원자적 UPDATE: SET qty = qty - 1
  2. 비관적 락: SELECT FOR UPDATE
  3. 낙관적 락: 버전 컬럼 체크
  4. 격리 수준 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가지 방법은?

정답
  1. 락 순서 일관화: 항상 같은 순서 (예: id 오름차순) 로 여러 행 락
  2. 트랜잭션 짧게: 락 점유 시간 최소
  3. 락 타임아웃 + 자동 재시도: 애플리케이션 레벨에서 서킷 브레이커
  4. 가능하면 비관적 락 대신 원자적 쿼리 나 낙관적 락

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의 차이를 설명할 수 있다
  • 멱등성 키와 낙관적 업데이트 패턴을 쓸 수 있다

← 4-4. 인덱스 | 4-6. FE가 자주 마주치는 이슈 →

진도 체크시작 전
NEXT · 4-6

FE가 자주 만나는 이슈

N+1, 페이지네이션, 동시성, 시간대 처리.

이어서 학습하기 →