콘텐츠로 이동

Transaction Basics

분류: Layer 8 - 데이터베이스 심화 | 선수지식: RDS Basics

트랜잭션(transaction)은 여러 데이터베이스 작업을 하나의 성공/실패 단위로 묶는 경계다. 이 경계 안의 작업은 전부 확정되거나, 전부 버려져야 한다. 그래서 트랜잭션의 핵심은 SQL 문장의 개수가 아니라 “어디까지를 하나의 사실 변경으로 볼 것인가”다.

BEGIN은 경계를 열고, COMMIT은 경계 안의 변경을 확정하고, ROLLBACK은 경계 안의 변경 후보를 버린다. 애플리케이션 코드는 이 세 동작을 이용해 부분 처리 상태가 영구 저장소에 남지 않도록 만든다.

트랜잭션 성공과 실패 흐름
flowchart TD
A[BEGIN] --> B[여러 SQL을 하나의 커밋 후보로 묶음]
B --> C{경계 안 작업이 모두 성공했는가?}
C -->|yes| D[COMMIT]
D --> E[기본 설정에서는 WAL flush 후 성공 응답]
C -->|no| F[ROLLBACK]
F --> G[커밋 후보 전체 폐기]

트랜잭션은 여러 SQL을 하나의 커밋 후보로 묶고, 마지막에 확정 또는 폐기를 결정한다. WAL flush 설명은 PostgreSQL 기본 synchronous_commit=on 기준이다.

프론트엔드에서 API 실패를 만나면 화면 상태를 되돌릴 수 있다. 데이터베이스에서는 화면이 아니라 주문, 결제, 재고, 잔액 같은 영구 사실이 남기 때문에 되돌림을 감으로 처리하면 안 된다. 트랜잭션은 이 되돌림을 데이터베이스가 이해하는 규칙으로 만든다.

2. 선행 한계 → 등장 → 해결: 왜 트랜잭션이 필요한가

섹션 제목: “2. 선행 한계 → 등장 → 해결: 왜 트랜잭션이 필요한가”

RDS Basics에서 배운 데이터베이스는 테이블, 인덱스, 스토리지 엔진, 백업, 장애 조치 같은 기반을 제공한다. 하지만 애플리케이션이 다음 두 SQL을 따로 보내면 데이터베이스는 “두 SQL이 반드시 함께 성공해야 한다”는 업무 규칙을 자동으로 알지 못한다.

INSERT INTO orders (id, user_id, product_id, quantity, status)
VALUES (101, 7, 55, 1, 'created');
UPDATE products
SET stock = stock - 1
WHERE id = 55;

첫 번째 SQL이 성공한 뒤 두 번째 SQL에서 네트워크 타임아웃, 제약 조건 오류, 서버 종료가 발생하면 주문은 생겼는데 재고는 줄지 않는 상태가 남을 수 있다. 이것이 부분 처리(partial update)다. 부분 처리는 단순히 “에러가 났다”가 아니라, 저장소에 남은 사실들이 서로 모순되는 상태다.

트랜잭션은 이 문제를 All-or-Nothing 원칙으로 바꾼다. All-or-Nothing은 경계 안의 모든 변경이 함께 확정되거나, 하나라도 실패하면 전체가 없었던 일처럼 버려져야 한다는 뜻이다. 즉 트랜잭션은 오류를 없애는 도구가 아니라, 오류가 나도 저장소가 설명 가능한 상태로 남게 하는 도구다.

해결 메커니즘은 세 겹으로 나뉜다.

  1. BEGIN, COMMIT, ROLLBACK이 커밋 경계를 만든다.
  2. WAL(Write-Ahead Log, 선기록 로그)이 커밋된 변경을 장애 후에도 복구할 수 있게 한다.
  3. Lock(잠금)과 MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)가 여러 트랜잭션이 동시에 읽고 쓸 때 순서와 가시성을 제어한다.

이 세 겹을 분리해서 이해해야 한다. COMMIT은 애플리케이션이 확정을 요청하는 경계이고, WAL은 확정된 경계를 장애 후 복원하는 내부 로그이며, Lock과 MVCC는 동시에 실행되는 경계들이 서로를 망가뜨리지 않도록 조정하는 장치다.

3. 계좌이체로 보는 트랜잭션 경계

섹션 제목: “3. 계좌이체로 보는 트랜잭션 경계”

가장 작은 worked example은 계좌이체다. A 계좌에서 10만 원을 빼고 B 계좌에 10만 원을 넣는 작업은 두 행을 바꾸지만 하나의 업무 사실이다.

초기 상태:
A.balance = 500000
B.balance = 500000
해야 할 일:
A.balance = 400000
B.balance = 600000
업무 규칙:
두 변경이 함께 성공해야 한다.
둘 중 하나만 성공하면 돈이 사라지거나 생긴다.

트랜잭션이 없으면 실패 지점에 따라 다음 상태가 남을 수 있다.

1. A 계좌 잔액 조회: 500000
2. A 계좌에서 100000 차감: 400000
3. 서버 프로세스 종료
4. B 계좌 입금은 실행되지 않음
남은 상태:
A.balance = 400000
B.balance = 500000
총액 = 900000

트랜잭션이 있으면 2번의 변경은 아직 “커밋 후보”다. COMMIT 전에 장애가 나면 데이터베이스는 커밋 마크가 없는 변경 후보를 버린다.

BEGIN;
UPDATE accounts
SET balance = balance - 100000
WHERE id = 1;
UPDATE accounts
SET balance = balance + 100000
WHERE id = 2;
COMMIT;

같은 예제를 ROLLBACK으로 직접 확인하면 트랜잭션 경계가 더 분명해진다.

BEGIN;
UPDATE accounts
SET balance = balance - 100000
WHERE id = 1;
SELECT balance FROM accounts WHERE id = 1;
-- 같은 트랜잭션 안에서는 400000이 보인다.
ROLLBACK;
SELECT balance FROM accounts WHERE id = 1;
-- 트랜잭션 밖에서는 다시 500000이 보인다.

여기서 중요한 오해가 있다. 같은 트랜잭션 안에서 보이는 값은 “이미 영구 저장됐다”는 뜻이 아니다. 아직 커밋하지 않았다면 그 값은 현재 트랜잭션이 바라보는 후보 상태다. COMMIT이 성공해야 다른 트랜잭션과 장애 복구 이후에도 믿을 수 있는 상태가 된다.

4. ACID를 암기가 아니라 실패 렌즈로 읽기

섹션 제목: “4. ACID를 암기가 아니라 실패 렌즈로 읽기”

ACID는 트랜잭션이 지켜야 하는 네 가지 성질이다. Atomicity(원자성), Consistency(일관성), Isolation(격리성), Durability(지속성)의 머리글자다. 이 네 단어는 시험용 약어가 아니라 장애를 분류하는 렌즈로 쓰면 실용적이다.

ACID를 장애 신호로 읽기

Atomicity

경계 안 작업은 전부 성공하거나 전부 실패해야 한다. 중간 처리 상태가 남으면 원자성이 깨진 것이다.

주문은 있는데 재고나 결제 기록이 빠져 있는 상태를 볼 때

Consistency

트랜잭션 전후 데이터가 업무 규칙과 DB 제약을 만족해야 한다.

잔액 음수, 외래키 위반, 불가능한 상태 전이가 저장될 때

Isolation

동시 트랜잭션이 서로의 중간 상태를 읽거나 덮어쓰지 않게 해야 한다.

동시 주문, 중복 예약, 같은 요청의 중복 커밋을 의심할 때

Durability

커밋 성공으로 응답한 변경은 장애 후 재시작해도 보존되어야 한다.

성공 응답을 받은 데이터가 장애 복구 후 사라졌을 때

각 속성의 반례를 손으로 떠올려보면 경계가 더 선명해진다.

속성잘못된 직관깨지는 반례필요한 장치
AtomicitySQL을 순서대로 실행하면 된다주문 생성 후 재고 차감 전에 장애BEGIN/ROLLBACK
Consistency트랜잭션이면 어떤 데이터든 안전하다트랜잭션 안에서 잔액을 음수로 만들고 커밋제약 조건, 검증 로직
Isolation원자성만 있으면 동시성도 안전하다두 요청이 같은 재고 1개를 동시에 가능하다고 판단Lock, 격리 수준, 제약
Durability응답만 성공이면 데이터 파일도 즉시 바뀐다데이터 파일 반영 전 장애가 나도 커밋은 복구되어야 함WAL, fsync, checkpoint

여기서 fsync는 OS 캐시에만 있던 커밋 로그를 durable한 저장 장치까지 밀어내도록 요청하는 동작이다. checkpoint는 데이터 파일 반영 지점을 만들어 장애 후 WAL을 어디서부터 다시 읽을지 줄여주는 내부 정리 작업이다.

PostgreSQL의 기본 synchronous_commit=on에서는 성공 응답 전에 로컬 WAL flush를 기다린다. 반면 synchronous_commit=off는 WAL flush를 기다리지 않고 성공을 먼저 반환하는 비동기 커밋이다. DB가 모순된 상태로 복구되지는 않지만, OS나 DB가 즉시 중단되면 최근에 성공으로 응답한 일부 트랜잭션이 clean abort처럼 사라질 수 있다. 따라서 결제·재고·주문 확정처럼 성공 응답 자체가 내구성 약속인 경로에는 쓰지 않고, 유실되어도 재생성 가능한 비핵심 기록에서만 지연과 내구성의 교환을 명시적으로 검토한다.

Consistency는 특히 오해하기 쉽다. 트랜잭션은 업무 규칙을 대신 설계해주지 않는다. CHECK (balance >= 0), 유니크 제약, 외래키, 상태 전이 검증이 없다면 트랜잭션은 잘못된 값을 아주 안정적으로 커밋할 수도 있다.

5. WAL: 커밋을 장애 후에도 설명 가능하게 만드는 로그

섹션 제목: “5. WAL: 커밋을 장애 후에도 설명 가능하게 만드는 로그”

WAL은 Write-Ahead Log의 약자로, 데이터 파일을 바꾸기 전에 변경 내용을 로그에 먼저 기록하는 방식이다. PostgreSQL 공식 문서는 WAL의 핵심을 데이터 파일 변경 전에 로그 레코드를 먼저 영구 저장하는 방식으로 설명한다. 기본 synchronous_commit=on에서는 커밋 시 모든 데이터 페이지를 즉시 디스크에 반영하지 않아도, WAL의 커밋 기록을 먼저 flush한 뒤 성공을 반환하므로 장애 후 상태를 재실행할 수 있다.

WAL의 철학은 단순하다. 랜덤 위치의 데이터 페이지를 매번 즉시 고쳐 쓰는 것보다, 순서대로 쌓이는 로그에 먼저 기록하는 편이 빠르고 복구하기 쉽다. 장애가 나면 데이터베이스는 마지막 체크포인트 이후의 WAL을 다시 읽고, 커밋된 변경은 재실행하며 커밋되지 않은 변경은 버린다.

BEGIN
변경 후보 시작
UPDATE accounts SET balance = 400000 WHERE id = 1
WAL에 변경 레코드 기록
데이터 파일 반영은 나중일 수 있음
COMMIT
synchronous_commit=on: WAL에 커밋 레코드 기록 및 flush 후 성공 응답
synchronous_commit=off: flush를 기다리지 않고 성공 응답
장애 후 재시작
커밋 레코드가 있으면 변경을 재실행
커밋 레코드가 없으면 변경 후보를 폐기

두 장애 지점을 비교하면 WAL의 역할이 보인다.

장애 지점WAL 상태복구 후 기대 상태학습 포인트
UPDATE 후, COMMIT변경 레코드는 있을 수 있지만 커밋 레코드가 없음롤백된 상태변경 로그만으로는 확정이 아니다
COMMIT 성공 응답 후 (synchronous_commit=on)커밋 레코드가 durable함커밋된 상태데이터 파일 반영 전이어도 WAL로 복구한다
COMMIT 성공 응답 후 (synchronous_commit=off)WAL flush 전일 수 있음최근 응답 일부가 사라질 수 있음일관성은 유지되지만 성공 응답의 내구성은 약해진다
checkpoint 전WAL 재실행 필요커밋된 변경 재현checkpoint는 복구 시작점을 줄이는 장치다

WAL을 이해할 때의 경계도 중요하다. WAL은 데이터베이스 내부 복구 로그이지, 애플리케이션 이벤트 스트림이 아니다. 주문 생성 후 Kafka 이벤트를 발행해야 한다면, WAL을 알았다고 바로 이벤트 유실 문제가 해결되지는 않는다. 이 지점은 뒤의 CDC & Outbox 패턴 토픽에서 더 깊게 다룬다.

실패 신호는 다음처럼 나타난다.

  • 커밋 성공 응답 후 장애 복구에서 데이터가 사라졌다면 synchronous_commit 값과 WAL flush 경로부터 확인한다.
  • CDC(Change Data Capture)는 DB 변경을 외부 이벤트로 읽어내는 방식이고, replication slot은 CDC consumer가 읽어야 할 WAL 보존 위치를 DB에 기억시키는 장치다. slot이나 CDC가 WAL을 오래 잡고 있으면 디스크 사용량이 증가할 수 있다. 이 문서에서는 원리만 기억하고, 이벤트 발행과 CDC 운영은 content/topics/L8/cdc-outbox.mdx에서 다룬다.
  • WAL이 있다고 해서 커밋 전 변경을 외부 시스템이 읽을 수 있는 것은 아니다. 다른 커넥션은 격리 수준 규칙에 따라 커밋된 상태만 본다.

6. Lock: 같은 데이터를 동시에 바꾸는 순서를 정하는 장치

섹션 제목: “6. Lock: 같은 데이터를 동시에 바꾸는 순서를 정하는 장치”

Lock은 잠금이다. 트랜잭션이 어떤 행이나 테이블을 바꾸는 동안 다른 트랜잭션이 같은 대상을 동시에 바꿔 모순을 만들지 않도록 순서를 정한다. UPDATE, DELETE, SELECT ... FOR UPDATE는 보통 관련 행에 쓰기 잠금을 만든다.

다음 예시는 재고 1개를 두 요청이 동시에 주문하는 상황이다.

초기 상태:
products.id = 55
stock = 1
요청 A:
stock을 읽고 1이므로 주문 가능하다고 판단
요청 B:
거의 같은 시점에 stock을 읽고 1이므로 주문 가능하다고 판단
둘 다 stock = stock - 1을 실행하면?
결과가 -1이 되거나, 둘 중 하나의 업데이트가 덮어써질 수 있다.

이럴 때 명시적으로 행을 잠그면 한 요청이 판단하고 차감하는 동안 다른 요청은 기다린다.

BEGIN;
SELECT id, stock
FROM products
WHERE id = 55
FOR UPDATE;
-- stock이 충분한지 판단하고 차감한다.
UPDATE products
SET stock = stock - 1
WHERE id = 55;
INSERT INTO orders (user_id, product_id, quantity, status)
VALUES (7, 55, 1, 'created');
COMMIT;

Lock은 안전을 주지만 공짜가 아니다. 잠금을 오래 잡으면 다른 트랜잭션이 대기한다. 외부 API 호출, 긴 계산, 사용자 입력 대기 같은 작업이 트랜잭션 안에 있으면 lock hold time이 그 시간만큼 늘어난다.

정량 감각으로 보면, 외부 결제 API p99가 800ms이고 그 호출을 잠금 안에서 기다린다면 같은 상품을 주문하는 다른 요청은 최대 800ms 이상 추가 대기할 수 있다. 초당 100개의 주문 요청이 같은 인기 상품 행을 건드린다면, 이 800ms는 단순 지연이 아니라 lock wait 큐를 만드는 원인이 된다. 트랜잭션은 짧고 결정적인 DB 작업만 담는 것이 기본값이다.

7. MVCC: 읽기와 쓰기를 덜 막기 위한 다중 버전 전략

섹션 제목: “7. MVCC: 읽기와 쓰기를 덜 막기 위한 다중 버전 전략”

MVCC는 Multi-Version Concurrency Control의 약자로, 하나의 행에 대해 여러 버전을 관리해 읽기와 쓰기가 불필요하게 서로를 막지 않게 하는 방식이다. PostgreSQL은 MVCC를 사용한다. 쓰기 트랜잭션이 새 버전을 만드는 동안, 읽기 트랜잭션은 자신이 볼 수 있는 이전 버전을 읽는다.

전통적인 잠금만으로 동시성을 처리하면 “누군가 행을 수정하는 동안 다른 사용자는 읽기도 기다리는” 상황이 늘어난다. MVCC의 철학은 읽는 사람에게 일관된 스냅샷을 보여주고, 쓰는 사람은 새 버전을 만들게 하자는 것이다. 덕분에 읽기 요청이 쓰기 요청을 덜 막고, 쓰기 요청도 읽기 요청 때문에 덜 멈춘다.

PostgreSQL의 각 행 버전(tuple)에는 내부적으로 생성 트랜잭션과 무효화 트랜잭션을 가리키는 정보가 있다. 학습용으로 단순화하면 다음과 같다.

SELECT xmin, xmax, id, balance
FROM accounts
WHERE id = 1;
-- 예시 출력
-- xmin | xmax | id | balance
-- ------+------+----+---------
-- 12345 | 0 | 1 | 500000
  • xmin은 이 행 버전을 만든 트랜잭션 ID다.
  • xmax는 이 행 버전을 삭제하거나 새 버전으로 대체한 트랜잭션 ID다.
  • xmax = 0은 아직 무효화되지 않은 버전이라는 뜻으로 볼 수 있다.

UPDATE는 제자리 수정처럼 보이지만, MVCC 관점에서는 기존 버전을 무효화하고 새 버전을 만든다.

트랜잭션 99999가 accounts.id = 1의 balance를 400000으로 바꾼다.
1. 기존 버전
xmin = 12345, xmax = 0, balance = 500000
2. UPDATE 실행
기존 버전의 xmax = 99999로 표시
새 버전 생성: xmin = 99999, xmax = 0, balance = 400000
3. 99999가 커밋하기 전
다른 트랜잭션은 아직 500000 버전을 볼 수 있다.
4. 99999가 커밋한 뒤
새로 시작한 트랜잭션은 400000 버전을 본다.
오래된 500000 버전은 더 이상 보이지 않는 dead tuple이 된다.

MVCC의 장점은 읽기 성능과 동시성이다. 단점은 오래된 버전이 쌓인다는 점이다. 그래서 PostgreSQL에는 더 이상 필요 없는 dead tuple을 정리하는 VACUUM이 있다. RDS 환경에서는 autovacuum이 자동으로 돌지만, 오래 열린 트랜잭션이 오래된 스냅샷을 붙잡고 있으면 dead tuple 정리가 지연될 수 있다.

숫자로 감을 잡아보자. 어떤 배치가 한 테이블의 100만 행을 갱신하면 MVCC는 새 버전 100만 개를 만든다. 오래된 버전 100만 개는 즉시 파일에서 사라지는 것이 아니라, 더 이상 어떤 트랜잭션도 필요로 하지 않을 때 정리된다. 장기 트랜잭션이 남아 있으면 테이블과 인덱스가 불필요하게 커지고, 이후 조회 비용도 증가할 수 있다.

MVCC의 실패 신호는 다음과 같다.

  • idle in transaction 세션이 오래 남아 있고, vacuum이 오래된 버전을 정리하지 못한다.
  • 업데이트가 많은 테이블에서 dead tuple이 계속 늘고, 테이블 크기가 줄지 않는다.
  • 같은 트랜잭션 안에서 “내가 방금 읽은 값”과 “다음 쿼리에서 보이는 값”이 왜 달라지는지 헷갈린다면 격리 수준과 스냅샷 시점을 다시 봐야 한다.

8. 격리 수준: 동시에 실행되는 트랜잭션이 서로를 얼마나 보나

섹션 제목: “8. 격리 수준: 동시에 실행되는 트랜잭션이 서로를 얼마나 보나”

격리 수준(isolation level)은 동시에 실행되는 트랜잭션들이 서로의 변경을 어느 정도까지 볼 수 있는지 정하는 규칙이다. 격리 수준은 “높을수록 무조건 좋다”가 아니다. 높이면 일부 이상 현상을 막을 수 있지만, 대기, 재시도, rollback 가능성도 늘어난다.

격리 수준Dirty ReadNon-Repeatable ReadPhantom Read핵심 감각
Read Uncommitted허용 가능허용 가능허용 가능커밋 전 변경까지 볼 수 있는 모델이지만 PostgreSQL에서는 사실상 Read Committed처럼 동작
Read Committed방지발생 가능발생 가능각 SQL이 시작할 때의 커밋된 스냅샷을 본다
Repeatable Read방지방지DB별 차이트랜잭션 시작 시점의 스냅샷을 더 오래 유지한다
Serializable방지방지방지마치 순차 실행된 것처럼 보이게 하며 실패 시 재시도가 필요할 수 있다

이 표는 격리 수준의 일반 모델이다. PostgreSQL은 Read Uncommitted를 사실상 Read Committed처럼 처리하고, Repeatable Read도 MVCC 구현 특성 때문에 다른 DB의 같은 이름과 세부 동작이 다를 수 있다. 이름만 외우기보다 “어떤 이상 현상이 남고, 실패가 lock 대기인지 재시도인지”를 확인해야 한다.

PostgreSQL의 기본값은 Read Committed다. Read Committed에서는 같은 트랜잭션 안에서도 첫 번째 SELECT와 두 번째 SELECT가 서로 다른 결과를 볼 수 있다. 각 쿼리가 시작한 시점에 이미 커밋된 데이터를 기준으로 보기 때문이다.

초기 상태:
accounts.id = 1, balance = 500000
트랜잭션 A (Read Committed):
SELECT balance FROM accounts WHERE id = 1; -- 500000
트랜잭션 B:
UPDATE accounts SET balance = 400000 WHERE id = 1;
COMMIT;
트랜잭션 A:
SELECT balance FROM accounts WHERE id = 1; -- 400000

A는 같은 트랜잭션 안에서 같은 행을 두 번 읽었지만 다른 값을 봤다. 이것이 Non-Repeatable Read다. API 한 번에서 같은 값을 여러 번 읽어도 항상 같을 거라고 생각하면 이 지점에서 버그가 난다.

좌석 예약처럼 여러 행을 읽고 업무 규칙을 판단한 뒤 쓰는 흐름은 더 위험하다.

업무 규칙:
같은 공연의 VIP 좌석은 최대 1개만 예약 가능하다.
초기 상태:
reservations 테이블에 VIP 예약이 0개다.
트랜잭션 A:
SELECT COUNT(*) FROM reservations
WHERE show_id = 10 AND zone = 'VIP'; -- 0
아직 가능하다고 판단
트랜잭션 B:
같은 SELECT 결과를 0으로 봄
아직 가능하다고 판단
A와 B가 각각 INSERT 후 COMMIT:
VIP 예약 2개가 생김

각 트랜잭션은 원자적으로 실행됐지만, 전체 불변식은 깨졌다. 이 경우 선택지는 여러 가지다.

  • 유니크 제약이나 부분 인덱스로 “VIP 예약은 한 행만 가능”을 DB 제약으로 만든다.
  • 충돌 지점을 대표하는 행을 SELECT ... FOR UPDATE로 잠근다.
  • 여러 행/조건의 불변식을 DB가 직렬화해 검증해야 한다면 Serializable을 검토한다.

이 예시는 DB 모델링이나 인덱스 설계 자체를 깊게 다루려는 것이 아니다. 핵심은 격리 수준만 올린다고 모든 업무 규칙이 자동으로 생기지 않으며, 불변식을 어디에 표현할지 결정해야 한다는 점이다.

격리 수준은 다음 순서로 결정한다.

  1. 기본값(Read Committed)에서 시작한다.
  2. 충돌 지점을 유니크 제약, 외래키, CHECK, SELECT ... FOR UPDATE로 명확히 만들 수 있는지 본다.
  3. 한 트랜잭션 안에서 같은 조회 결과가 계속 같아야 하는 리포트, 정산, 대량 검증이면 Repeatable Read를 검토한다.
  4. 여러 행이나 여러 테이블에 걸친 불변식이 동시성 때문에 깨질 수 있고, 다른 방법으로 충돌 지점을 표현하기 어렵다면 Serializable을 검토한다.
  5. Serializable 또는 serialization failure 가능성이 있는 경로는 전체 트랜잭션 재시도를 설계한다.

실무에서 중요한 결론은 이것이다. 격리 수준 상향은 “안전 스위치를 켠다”가 아니라 “실패 모드를 조용한 불일치에서 lock 대기, serialization failure, 재시도로 바꾼다”에 가깝다.

9. Deadlock: 서로가 가진 잠금을 기다리는 상태

섹션 제목: “9. Deadlock: 서로가 가진 잠금을 기다리는 상태”

Deadlock(교착 상태)은 두 트랜잭션이 서로가 가진 잠금을 기다리며 더 이상 진행하지 못하는 상태다. 데이터베이스는 보통 deadlock을 감지하면 둘 중 하나를 강제 rollback하고 에러를 반환한다. PostgreSQL에서는 대표적으로 40P01 코드가 보인다.

트랜잭션 A: 트랜잭션 B:
LOCK account 1 성공 LOCK account 2 성공
LOCK account 2 대기 LOCK account 1 대기
둘 다 상대가 가진 잠금을 기다림
DB가 하나를 rollback

데드락을 막는 가장 기본적인 원칙은 모든 코드 경로에서 잠금을 같은 순서로 잡는 것이다. 계좌이체라면 fromId, toId 순서가 아니라 계좌 ID 오름차순으로 잠근다.

const [firstId, secondId] = [fromId, toId].sort((a, b) => a - b);
await dataSource.transaction(async (manager) => {
const first = await manager.findOne(Account, {
where: { id: firstId },
lock: { mode: "pessimistic_write" },
});
const second = await manager.findOne(Account, {
where: { id: secondId },
lock: { mode: "pessimistic_write" },
});
// 잠금 순서는 고정하지만, 실제 출금/입금 방향은 fromId/toId로 판단한다.
});

위 코드는 완전한 서비스 구현이 아니라 원칙을 보여주는 예시다. 중요한 점은 모든 요청이 같은 행 집합을 같은 순서로 잠그게 만드는 것이다. A 요청은 1번 후 2번, B 요청도 1번 후 2번을 잠그면 순환 대기가 생기지 않는다.

Deadlock은 데이터가 깨졌다는 뜻은 아니다. DB가 하나의 트랜잭션을 rollback해 정합성을 지킨 것이다. 하지만 사용자 요청은 실패한다. 그래서 deadlock 경로에는 짧은 재시도와 지터(jitter, 무작위 대기)를 둔다.

const MAX_RETRIES = 3;
for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
try {
return await transferMoney();
} catch (error) {
const code = error?.driverError?.code;
const canRetry = code === "40P01" || code === "40001";
if (!canRetry || attempt === MAX_RETRIES - 1) {
throw error;
}
const delayMs = 50 + Math.random() * 100;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}

40P01은 deadlock, 40001은 serialization failure를 의미하는 PostgreSQL 코드로 자주 다룬다. 둘 다 같은 SQL 하나만 다시 실행하는 것이 아니라, 읽기와 판단을 포함한 전체 트랜잭션을 다시 실행해야 안전하다.

10. Optimistic Lock과 Pessimistic Lock 선택 기준

섹션 제목: “10. Optimistic Lock과 Pessimistic Lock 선택 기준”

Pessimistic Lock(비관적 잠금)은 충돌이 날 것이라고 보고 먼저 잠근다. SELECT ... FOR UPDATE가 대표적이다. Optimistic Lock(낙관적 잠금)은 일단 진행하고, 커밋이나 업데이트 시점에 버전이 바뀌었는지 확인해 충돌하면 실패시키고 재시도한다.

판단 기준Optimistic Lock 쪽Pessimistic Lock 쪽
충돌 빈도낮다. 같은 행을 동시에 수정하는 일이 드물다높다. 재고, 좌석, 계좌처럼 같은 행 경쟁이 잦다
재시도 비용낮다. 다시 읽고 계산해도 사용자 영향이 작다높다. 실패하면 사용자가 긴 작업을 다시 해야 한다
대기 허용대기보다 빠른 실패와 재시도가 낫다잠깐 기다려도 단일 성공 순서가 중요하다
트랜잭션 길이짧고 외부 부작용이 없다짧아야 하지만 충돌 지점을 먼저 잡아야 한다
예시프로필 수정, CMS 문서 제목 수정재고 차감, 티켓 예약, 계좌이체

정량 기준은 절대값보다 관측 방법이 중요하다. 예를 들어 낙관적 잠금 경로에서 1주일 동안 버전 충돌이 전체 요청의 1% 미만이면 재시도 비용이 작을 가능성이 높다. 반대로 10% 가까이 충돌하고, 재시도마다 같은 인기 상품을 다시 읽는다면 retry storm으로 처리량이 더 나빠질 수 있다. 이때는 비관적 잠금, 큐잉, 재고 예약 테이블 같은 다른 설계를 검토한다.

선택할 때는 세 숫자를 같이 본다.

  • 충돌률: 전체 트랜잭션 중 버전 충돌이나 deadlock/serialization failure 비율
  • lock wait time: 잠금 때문에 기다린 시간의 p95/p99
  • 재시도 성공률: 재시도 1~3회 안에 성공하는 비율

낙관적 잠금은 “잠금을 쓰지 않는다”가 아니다. 충돌을 늦게 발견하는 전략이다. 비관적 잠금은 “무조건 안전하다”가 아니다. 충돌을 빨리 직렬화하지만 대기와 deadlock 위험을 만든다.

11. 애플리케이션 코드에서 트랜잭션 경계 잡기

섹션 제목: “11. 애플리케이션 코드에서 트랜잭션 경계 잡기”

NestJS와 TypeORM을 쓰더라도 핵심은 프레임워크 API가 아니라 경계다. 어떤 저장소 변경이 하나의 업무 사실인지 먼저 정하고, 그 경계 안의 DB 작업이 같은 트랜잭션 연결을 쓰게 해야 한다.

@Injectable()
export class OrderService {
constructor(private readonly dataSource: DataSource) {}
async createOrder(dto: CreateOrderDto) {
return this.dataSource.transaction(async (manager) => {
const product = await manager.findOne(Product, {
where: { id: dto.productId },
lock: { mode: "pessimistic_write" },
});
if (!product || product.stock < dto.quantity) {
throw new BadRequestException("재고 부족");
}
await manager.decrement(
Product,
{ id: dto.productId },
"stock",
dto.quantity,
);
return manager.save(Order, {
userId: dto.userId,
productId: dto.productId,
quantity: dto.quantity,
status: "created",
});
});
}
}

이 예제의 학습 포인트는 세 가지다.

  1. 재고 확인과 차감, 주문 생성이 하나의 트랜잭션 안에 있다.
  2. 재고 행은 판단과 차감 사이에 바뀌면 안 되므로 쓰기 잠금을 잡는다.
  3. 콜백 안의 모든 DB 작업은 manager를 사용한다.

TypeORM 공식 문서가 강조하는 제약도 이 3번이다. transaction() 콜백 안에서는 제공된 transactional entity manager를 사용해야 한다. this.orderRepository.save() 같은 원본 repository를 섞으면 다른 커넥션에서 실행되어 트랜잭션 경계 밖으로 빠질 수 있다.

퀴즈

TypeORM transaction 콜백 안에서 this.orderRepository.save()를 섞으면 왜 위험한가?

정답 보기

콜백이 제공한 transactionalEntityManager가 아닌 repository는 별도 커넥션으로 실행될 수 있다. 그래서 코드상 트랜잭션 안처럼 보여도 rollback 범위에서 빠져 일부 테이블만 남는 silent failure가 생길 수 있다.

QueryRunner는 연결, 트랜잭션 시작, commit, rollback, release를 직접 제어하는 방식이다. 대부분의 단순 서비스 로직에는 dataSource.transaction()이 더 안전하다. QueryRunner는 트랜잭션 생명주기를 세밀하게 제어해야 하거나, 한 연결에 묶인 여러 단계 작업을 명시적으로 다뤄야 할 때만 선택한다.

const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await queryRunner.manager.save(Order, order);
await queryRunner.manager.decrement(Product, { id: productId }, "stock", 1);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}

QueryRunner의 가장 흔한 실패는 release() 누락이다. 연결을 풀에 반환하지 않으면 시간이 지나면서 커넥션 풀이 고갈되고, 트랜잭션 문제가 아니라 전체 DB 요청 타임아웃처럼 보인다.

방식장점위험기본 선택
dataSource.transaction()짧고 자동 rollbackmanager 대신 repository를 섞는 실수대부분의 서비스 로직
QueryRunner생명주기 직접 제어rollback, release 누락세밀한 연결 제어가 필요한 경우
데코레이터 기반 라이브러리비즈니스 코드가 깔끔함초기화 순서, async context 경계팀 표준이 있고 테스트된 경우

12. 외부 부작용은 트랜잭션 안에 넣을 수 없다

섹션 제목: “12. 외부 부작용은 트랜잭션 안에 넣을 수 없다”

데이터베이스 트랜잭션은 데이터베이스 안의 변경을 묶는다. 외부 결제 API 호출, 이메일 발송, 메시지 브로커 publish, 파일 업로드는 DB의 ROLLBACK으로 되돌릴 수 없다. 이것이 external side effect boundary, 즉 외부 부작용 경계다.

잘못된 패턴은 외부 API를 트랜잭션 안에서 호출하는 것이다.

await dataSource.transaction(async (manager) => {
const order = await manager.save(Order, orderData);
const payment = await paymentClient.charge(order.id, amount);
await manager.update(Order, order.id, {
paymentId: payment.id,
status: "paid",
});
});

이 코드는 두 가지 문제가 있다.

  1. 결제 API를 기다리는 동안 DB 잠금을 오래 잡을 수 있다.
  2. 결제 API는 성공했는데 DB COMMIT이 실패하면 DB rollback만으로 결제를 되돌릴 수 없다.

반대로 결제를 먼저 호출하고 DB를 나중에 커밋해도 다른 문제가 생긴다. DB 커밋 실패 시 환불이나 취소 같은 보상 흐름이 필요하다. 그래서 외부 부작용은 “트랜잭션 안에 넣으면 안전하다”가 아니라, “DB 경계와 별도 경계로 설계해야 한다”가 맞다.

이벤트 발행도 같은 문제다.

await dataSource.transaction(async (manager) => {
const order = await manager.save(Order, orderData);
eventEmitter.emit("order.created", order);
return order;
});

이벤트 리스너가 다른 DB 커넥션에서 주문을 조회하면 아직 커밋되지 않은 주문을 못 볼 수 있다. 더 위험한 경우는 이벤트는 발행됐는데 트랜잭션이 rollback되는 것이다. 그러면 외부 시스템은 존재하지 않는 주문을 들은 셈이 된다.

안전한 방향은 보통 둘 중 하나다.

  • 커밋 후에 이벤트를 발행한다. 단, 발행 실패 시 재시도와 누락 감지가 필요하다.
  • 트랜잭션 안에서는 outbox 테이블에 이벤트 레코드만 저장하고, 별도 processor가 커밋된 outbox를 읽어 발행한다.

두 번째 방식은 CDC & Outbox 패턴 토픽의 영역이다. 여기서는 원칙만 기억한다. DB 트랜잭션은 DB 안의 사실을 묶고, 외부 부작용은 별도 실패 경계와 보상 전략이 필요하다.

13. 트랜잭션으로 묶을지 결정하는 기준

섹션 제목: “13. 트랜잭션으로 묶을지 결정하는 기준”

트랜잭션은 “많이 쓰면 안전해지는 장식”이 아니다. 경계가 너무 작으면 부분 처리 상태가 남고, 너무 크면 lock 대기와 장애 반경이 커진다.

트랜잭션 경계 결정 기준

하나의 업무 사실

여러 DB 변경이 함께 성공해야 같은 사실이 된다면 하나의 트랜잭션으로 묶는다.

주문 생성 + 재고 차감, 계좌 출금 + 입금

부분 실패 복구 가능

일부만 성공해도 후속 잡이나 보상 로직으로 안전하게 맞출 수 있으면 경계를 나눌 수 있다.

프로필 업데이트 후 검색 인덱스 갱신

외부 부작용 포함

DB rollback으로 되돌릴 수 없는 작업은 같은 트랜잭션 안에 넣어도 원자적이지 않다.

결제 승인, 이메일 발송, 메시지 publish

잠금 시간

트랜잭션 안의 느린 작업은 같은 행을 기다리는 요청 전체의 지연으로 전파된다.

외부 API p99가 높거나, 배치가 대량 행을 갱신할 때

판단 질문은 다음 네 개면 충분하다.

  1. 이 변경들 중 하나만 커밋되면 어떤 사용자나 운영자가 모순을 보게 되는가?
  2. 모순이 생겼을 때 자동으로 복구할 수 있는가, 아니면 사람이 직접 보정해야 하는가?
  3. 경계 안에 DB가 rollback할 수 없는 외부 부작용이 있는가?
  4. 경계 안 작업이 오래 걸려 다른 요청을 대기시킬 가능성이 큰가?

답이 “부분 커밋은 모순이고, 자동 복구가 어렵고, DB 작업만 있고, 짧게 끝난다”에 가깝다면 트랜잭션으로 묶는 것이 자연스럽다. 답이 “외부 부작용이 있고, 오래 걸리고, 후속 처리가 가능하다”에 가깝다면 트랜잭션을 작게 나누고 outbox, 재시도, 보상 작업을 설계한다.

14. 실패 신호를 개념으로 되돌려 읽기

섹션 제목: “14. 실패 신호를 개념으로 되돌려 읽기”

트랜잭션 장애 대응에서 중요한 것은 처음부터 긴 SQL 런북을 외우는 것이 아니다. 관측된 증상을 어떤 개념의 실패로 볼지 연결하는 것이다.

증상먼저 의심할 개념의미다음 확인
주문은 있는데 재고가 그대로다Atomicity, 트랜잭션 경계함께 커밋되어야 할 DB 작업이 분리됨같은 transaction() 콜백과 manager 사용 여부
결제 이벤트를 받았는데 주문 조회가 안 된다외부 부작용 경계, Isolation커밋 전 이벤트 발행 또는 rollback 후 발행커밋 후 발행인지, outbox 사용 여부
deadlock detected, 40P01Lock 순서, Deadlock서로 다른 순서로 같은 행을 잠금잠금 획득 순서 통일, 전체 트랜잭션 재시도
could not serialize access, 40001Isolation levelDB가 직렬화 불가능한 동시 실행을 rollback전체 트랜잭션 재시도
요청이 갑자기 느려지고 lock wait가 증가Lock hold time트랜잭션이 잠금을 오래 잡음외부 API, 대량 작업, idle in transaction
시간이 지나면 DB 연결이 고갈된다QueryRunner 생명주기연결이 풀로 반환되지 않음finally { release() } 누락
업데이트 많은 테이블이 계속 커진다MVCC, VACUUMdead tuple 정리가 지연됨장기 트랜잭션, autovacuum 상태

운영 SQL은 상황마다 달라질 수 있지만, 위 연결은 안정적이다. 증상을 개념으로 되돌려 읽으면 “무엇을 봐야 하는지”가 먼저 정해지고, 그 다음에 팀의 관측 도구와 DB 권한에 맞는 쿼리를 고르면 된다.

15. 짧은 실습: 경계를 손으로 확인하기

섹션 제목: “15. 짧은 실습: 경계를 손으로 확인하기”

이 실습은 긴 로컬 환경 구축이 아니라 개념 확인용이다. PostgreSQL이나 호환 SQL 환경에서 작은 테스트 테이블을 만들 수 있다면 충분하다.

CREATE TABLE tx_test (
id INT PRIMARY KEY,
name TEXT NOT NULL
);
BEGIN;
INSERT INTO tx_test (id, name) VALUES (1, 'rollback-me');
SELECT * FROM tx_test;
ROLLBACK;
SELECT * FROM tx_test;
-- 빈 결과가 나오면 rollback 경계를 확인한 것이다.
SHOW transaction_isolation;
-- PostgreSQL 기본값은 보통 read committed다.
-- 여기서는 이름을 외우기보다 "이 세션이 어떤 격리 규칙으로 읽는가"를 확인하는 감각만 잡는다.

15.3 같은 트랜잭션 안에서 보이는 후보 상태

섹션 제목: “15.3 같은 트랜잭션 안에서 보이는 후보 상태”
BEGIN;
INSERT INTO tx_test (id, name) VALUES (2, 'commit-me');
SELECT * FROM tx_test WHERE id = 2;
COMMIT;
SELECT * FROM tx_test WHERE id = 2;
-- COMMIT 후에는 다른 트랜잭션에서도 볼 수 있는 확정 상태가 된다.

15.4 데드락을 짧게 재현하는 사고 실험

섹션 제목: “15.4 데드락을 짧게 재현하는 사고 실험”

아래는 실행형 실습이라기보다 순서를 보는 사고 실험이다. 직접 실행하려면 accounts(id, balance) 같은 작은 테이블과 두 행을 먼저 만들고, PostgreSQL에서는 deadlock이 감지되면 보통 40P01 오류가 난다. 핵심은 두 트랜잭션이 같은 자원을 반대 순서로 잡는다는 점이다.

세션 1:
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
SELECT * FROM accounts WHERE id = 2 FOR UPDATE; -- 대기 가능
세션 2:
BEGIN;
SELECT * FROM accounts WHERE id = 2 FOR UPDATE;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- 대기 가능
결과:
서로 반대 순서로 잠금을 잡아 deadlock 가능
예방:
모든 세션이 id 오름차순으로 잠금 획득

16. 선택 부록: 실무 코드 리뷰에서 볼 최소 체크

섹션 제목: “16. 선택 부록: 실무 코드 리뷰에서 볼 최소 체크”

아래는 긴 on-call runbook이 아니라 코드 리뷰와 장애 초동 분류에 필요한 최소 관찰 포인트다.

TypeORM 트랜잭션 코드 리뷰 체크
  • transaction() 콜백 안의 DB 호출이 모두 manager 또는 manager.getRepository()를 쓰는지 본다.
  • this.repository.save()가 콜백 안에 섞이면 rollback 범위에서 빠질 수 있다.
  • QueryRunner를 쓰면 try/catch/finallyrelease()가 항상 있는지 본다.
  • 재시도는 단일 SQL이 아니라 전체 트랜잭션 함수를 다시 실행하는 구조인지 본다.
  • 트랜잭션 안에 외부 API, 메시지 publish, 이메일 발송이 있으면 실패 경계가 잘못 섞였을 가능성이 높다.
Lock 대기 초동 관찰 포인트
SELECT pid, wait_event_type, wait_event, state, query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
ORDER BY state_change;

이 쿼리는 “현재 lock을 기다리는 세션이 있는가”를 보는 출발점이다. 바로 세션을 종료하는 런북으로 넘어가기보다, 어떤 요청이 어떤 트랜잭션 경계를 오래 잡고 있는지 먼저 확인해야 한다.

트랜잭션 타임아웃을 볼 때의 기준
  • statement_timeout은 단일 쿼리 실행 시간을 제한한다.
  • lock_timeout은 잠금 대기 시간을 제한한다.
  • idle_in_transaction_session_timeoutBEGIN 후 아무 작업 없이 열린 트랜잭션을 정리하는 데 도움을 준다.
  • 숫자는 서비스 p95/p99, 배치 특성, 사용자 재시도 정책에 맞춰 정한다. 예시 값을 그대로 복사하는 것보다 typical transaction time을 먼저 측정하는 것이 중요하다.
헷갈리는 개념구분
Transaction vs LockTransaction은 작업 경계이고, Lock은 그 경계들이 동시에 실행될 때 순서를 정하는 메커니즘이다.
Commit vs 데이터 파일 즉시 반영기본 synchronous_commit=on의 Commit은 데이터 파일이 아니라 WAL의 durable한 확정 기록을 먼저 보장한다. off는 최근 성공 응답 유실을 허용한다.
Rollback vs 보상 트랜잭션Rollback은 커밋 전 DB 변경 후보를 버리는 것이다. 외부 결제 취소처럼 이미 일어난 부작용을 되돌리는 것은 보상 트랜잭션이다.
MVCC vs Lock 없음MVCC는 읽기와 쓰기의 차단을 줄이지만, 쓰기 충돌과 불변식 보호에는 여전히 lock이나 격리 수준이 필요하다.
Serializable vs 완전 무오류Serializable은 조용한 불일치를 줄이는 대신 serialization failure와 재시도를 요구할 수 있다.
Outbox vs 이벤트 즉시 발행Outbox는 DB 커밋 안에 이벤트 레코드를 저장하고, 커밋 후 별도 발행자가 처리하게 하는 패턴이다.
  • WAL(Write-Ahead Log)
  • MVCC(Multi-Version Concurrency Control)
  • SELECT ... FOR UPDATE
  • Deadlock과 lock ordering
  • Optimistic Lock, Pessimistic Lock
  • Serializable isolation과 40001 재시도
  • Outbox Pattern
  • Saga Pattern과 보상 트랜잭션

이 키워드 중 Outbox와 CDC는 이 문서의 본문 주제가 아니다. 트랜잭션이 끝난 뒤 이벤트를 안전하게 발행하는 문제로 넘어갈 때 content/topics/L8/cdc-outbox.mdx에서 이어서 보면 된다.

트랜잭션 복습 체크

  • 트랜잭션을 SQL 묶음이 아니라 커밋 경계로 설명할 수 있다
  • 계좌이체 예제로 BEGIN, COMMIT, ROLLBACK의 차이를 설명할 수 있다
  • ACID 네 속성을 각각 어떤 실패 신호와 연결할 수 있는지 말할 수 있다
  • WAL이 데이터 파일 즉시 반영이 아니라 장애 복구 가능한 커밋 기록이라는 점을 설명할 수 있다
  • synchronous_commit=off가 DB 일관성을 깨뜨리지는 않지만 최근 성공 응답의 내구성을 약화한다는 점을 설명할 수 있다
  • MVCC가 읽기와 쓰기의 차단을 줄이는 대신 dead tuple과 VACUUM 비용을 만든다는 점을 설명할 수 있다
  • Read Committed에서 같은 트랜잭션의 두 SELECT가 달라질 수 있는 이유를 설명할 수 있다
  • Deadlock을 막기 위해 같은 행 집합을 항상 같은 순서로 잠가야 하는 이유를 설명할 수 있다
  • TypeORM transaction 콜백 안에서 manager를 사용해야 하는 이유를 설명할 수 있다
  • 외부 API 호출과 이벤트 발행이 DB rollback으로 되돌릴 수 없는 부작용이라는 점을 설명할 수 있다
  1. 트랜잭션은 여러 DB 변경을 하나의 커밋 경계로 묶어 부분 처리 상태를 막는다.
  2. ACID는 암기용 약어가 아니라 원자성, 일관성, 격리성, 지속성 실패를 분류하는 렌즈다.
  3. WAL은 기본 synchronous_commit=on에서 커밋된 변경을 장애 후 복구 가능하게 하며, off는 최근 성공 응답 유실을 허용한다.
  4. 격리 수준과 잠금 전략은 안전성과 대기, 재시도 비용 사이의 선택이다.
  5. DB 트랜잭션은 외부 API나 이벤트 발행을 rollback하지 못하므로, 외부 부작용은 커밋 후 발행, outbox, 보상 흐름으로 별도 설계해야 한다.