Atomicity
경계 안 작업은 전부 성공하거나 전부 실패해야 한다. 중간 처리 상태가 남으면 원자성이 깨진 것이다.
주문은 있는데 재고나 결제 기록이 빠져 있는 상태를 볼 때분류: 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 실패를 만나면 화면 상태를 되돌릴 수 있다. 데이터베이스에서는 화면이 아니라 주문, 결제, 재고, 잔액 같은 영구 사실이 남기 때문에 되돌림을 감으로 처리하면 안 된다. 트랜잭션은 이 되돌림을 데이터베이스가 이해하는 규칙으로 만든다.
RDS Basics에서 배운 데이터베이스는 테이블, 인덱스, 스토리지 엔진, 백업, 장애 조치 같은 기반을 제공한다. 하지만 애플리케이션이 다음 두 SQL을 따로 보내면 데이터베이스는 “두 SQL이 반드시 함께 성공해야 한다”는 업무 규칙을 자동으로 알지 못한다.
INSERT INTO orders (id, user_id, product_id, quantity, status)VALUES (101, 7, 55, 1, 'created');
UPDATE productsSET stock = stock - 1WHERE id = 55;첫 번째 SQL이 성공한 뒤 두 번째 SQL에서 네트워크 타임아웃, 제약 조건 오류, 서버 종료가 발생하면 주문은 생겼는데 재고는 줄지 않는 상태가 남을 수 있다. 이것이 부분 처리(partial update)다. 부분 처리는 단순히 “에러가 났다”가 아니라, 저장소에 남은 사실들이 서로 모순되는 상태다.
트랜잭션은 이 문제를 All-or-Nothing 원칙으로 바꾼다. All-or-Nothing은 경계 안의 모든 변경이 함께 확정되거나, 하나라도 실패하면 전체가 없었던 일처럼 버려져야 한다는 뜻이다. 즉 트랜잭션은 오류를 없애는 도구가 아니라, 오류가 나도 저장소가 설명 가능한 상태로 남게 하는 도구다.
해결 메커니즘은 세 겹으로 나뉜다.
BEGIN, COMMIT, ROLLBACK이 커밋 경계를 만든다.이 세 겹을 분리해서 이해해야 한다. COMMIT은 애플리케이션이 확정을 요청하는 경계이고, WAL은 확정된 경계를 장애 후 복원하는 내부 로그이며, Lock과 MVCC는 동시에 실행되는 경계들이 서로를 망가뜨리지 않도록 조정하는 장치다.
가장 작은 worked example은 계좌이체다. A 계좌에서 10만 원을 빼고 B 계좌에 10만 원을 넣는 작업은 두 행을 바꾸지만 하나의 업무 사실이다.
초기 상태: A.balance = 500000 B.balance = 500000
해야 할 일: A.balance = 400000 B.balance = 600000
업무 규칙: 두 변경이 함께 성공해야 한다. 둘 중 하나만 성공하면 돈이 사라지거나 생긴다.트랜잭션이 없으면 실패 지점에 따라 다음 상태가 남을 수 있다.
1. A 계좌 잔액 조회: 5000002. A 계좌에서 100000 차감: 4000003. 서버 프로세스 종료4. B 계좌 입금은 실행되지 않음
남은 상태: A.balance = 400000 B.balance = 500000 총액 = 900000트랜잭션이 있으면 2번의 변경은 아직 “커밋 후보”다. COMMIT 전에 장애가 나면 데이터베이스는 커밋 마크가 없는 변경 후보를 버린다.
BEGIN;
UPDATE accountsSET balance = balance - 100000WHERE id = 1;
UPDATE accountsSET balance = balance + 100000WHERE id = 2;
COMMIT;같은 예제를 ROLLBACK으로 직접 확인하면 트랜잭션 경계가 더 분명해진다.
BEGIN;
UPDATE accountsSET balance = balance - 100000WHERE id = 1;
SELECT balance FROM accounts WHERE id = 1;-- 같은 트랜잭션 안에서는 400000이 보인다.
ROLLBACK;
SELECT balance FROM accounts WHERE id = 1;-- 트랜잭션 밖에서는 다시 500000이 보인다.여기서 중요한 오해가 있다. 같은 트랜잭션 안에서 보이는 값은 “이미 영구 저장됐다”는 뜻이 아니다. 아직 커밋하지 않았다면 그 값은 현재 트랜잭션이 바라보는 후보 상태다. COMMIT이 성공해야 다른 트랜잭션과 장애 복구 이후에도 믿을 수 있는 상태가 된다.
ACID는 트랜잭션이 지켜야 하는 네 가지 성질이다. Atomicity(원자성), Consistency(일관성), Isolation(격리성), Durability(지속성)의 머리글자다. 이 네 단어는 시험용 약어가 아니라 장애를 분류하는 렌즈로 쓰면 실용적이다.
경계 안 작업은 전부 성공하거나 전부 실패해야 한다. 중간 처리 상태가 남으면 원자성이 깨진 것이다.
주문은 있는데 재고나 결제 기록이 빠져 있는 상태를 볼 때트랜잭션 전후 데이터가 업무 규칙과 DB 제약을 만족해야 한다.
잔액 음수, 외래키 위반, 불가능한 상태 전이가 저장될 때동시 트랜잭션이 서로의 중간 상태를 읽거나 덮어쓰지 않게 해야 한다.
동시 주문, 중복 예약, 같은 요청의 중복 커밋을 의심할 때커밋 성공으로 응답한 변경은 장애 후 재시작해도 보존되어야 한다.
성공 응답을 받은 데이터가 장애 복구 후 사라졌을 때각 속성의 반례를 손으로 떠올려보면 경계가 더 선명해진다.
| 속성 | 잘못된 직관 | 깨지는 반례 | 필요한 장치 |
|---|---|---|---|
| Atomicity | SQL을 순서대로 실행하면 된다 | 주문 생성 후 재고 차감 전에 장애 | 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), 유니크 제약, 외래키, 상태 전이 검증이 없다면 트랜잭션은 잘못된 값을 아주 안정적으로 커밋할 수도 있다.
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 경로부터 확인한다.content/topics/L8/cdc-outbox.mdx에서 다룬다.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, stockFROM productsWHERE id = 55FOR UPDATE;
-- stock이 충분한지 판단하고 차감한다.UPDATE productsSET stock = stock - 1WHERE 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 작업만 담는 것이 기본값이다.
MVCC는 Multi-Version Concurrency Control의 약자로, 하나의 행에 대해 여러 버전을 관리해 읽기와 쓰기가 불필요하게 서로를 막지 않게 하는 방식이다. PostgreSQL은 MVCC를 사용한다. 쓰기 트랜잭션이 새 버전을 만드는 동안, 읽기 트랜잭션은 자신이 볼 수 있는 이전 버전을 읽는다.
전통적인 잠금만으로 동시성을 처리하면 “누군가 행을 수정하는 동안 다른 사용자는 읽기도 기다리는” 상황이 늘어난다. MVCC의 철학은 읽는 사람에게 일관된 스냅샷을 보여주고, 쓰는 사람은 새 버전을 만들게 하자는 것이다. 덕분에 읽기 요청이 쓰기 요청을 덜 막고, 쓰기 요청도 읽기 요청 때문에 덜 멈춘다.
PostgreSQL의 각 행 버전(tuple)에는 내부적으로 생성 트랜잭션과 무효화 트랜잭션을 가리키는 정보가 있다. 학습용으로 단순화하면 다음과 같다.
SELECT xmin, xmax, id, balanceFROM accountsWHERE id = 1;
-- 예시 출력-- xmin | xmax | id | balance-- ------+------+----+----------- 12345 | 0 | 1 | 500000xmin은 이 행 버전을 만든 트랜잭션 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이 오래된 버전을 정리하지 못한다.격리 수준(isolation level)은 동시에 실행되는 트랜잭션들이 서로의 변경을 어느 정도까지 볼 수 있는지 정하는 규칙이다. 격리 수준은 “높을수록 무조건 좋다”가 아니다. 높이면 일부 이상 현상을 막을 수 있지만, 대기, 재시도, rollback 가능성도 늘어난다.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom 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; -- 400000A는 같은 트랜잭션 안에서 같은 행을 두 번 읽었지만 다른 값을 봤다. 이것이 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개가 생김각 트랜잭션은 원자적으로 실행됐지만, 전체 불변식은 깨졌다. 이 경우 선택지는 여러 가지다.
SELECT ... FOR UPDATE로 잠근다.이 예시는 DB 모델링이나 인덱스 설계 자체를 깊게 다루려는 것이 아니다. 핵심은 격리 수준만 올린다고 모든 업무 규칙이 자동으로 생기지 않으며, 불변식을 어디에 표현할지 결정해야 한다는 점이다.
격리 수준은 다음 순서로 결정한다.
CHECK, SELECT ... FOR UPDATE로 명확히 만들 수 있는지 본다.실무에서 중요한 결론은 이것이다. 격리 수준 상향은 “안전 스위치를 켠다”가 아니라 “실패 모드를 조용한 불일치에서 lock 대기, serialization failure, 재시도로 바꾼다”에 가깝다.
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 하나만 다시 실행하는 것이 아니라, 읽기와 판단을 포함한 전체 트랜잭션을 다시 실행해야 안전하다.
Pessimistic Lock(비관적 잠금)은 충돌이 날 것이라고 보고 먼저 잠근다. SELECT ... FOR UPDATE가 대표적이다. Optimistic Lock(낙관적 잠금)은 일단 진행하고, 커밋이나 업데이트 시점에 버전이 바뀌었는지 확인해 충돌하면 실패시키고 재시도한다.
| 판단 기준 | Optimistic Lock 쪽 | Pessimistic Lock 쪽 |
|---|---|---|
| 충돌 빈도 | 낮다. 같은 행을 동시에 수정하는 일이 드물다 | 높다. 재고, 좌석, 계좌처럼 같은 행 경쟁이 잦다 |
| 재시도 비용 | 낮다. 다시 읽고 계산해도 사용자 영향이 작다 | 높다. 실패하면 사용자가 긴 작업을 다시 해야 한다 |
| 대기 허용 | 대기보다 빠른 실패와 재시도가 낫다 | 잠깐 기다려도 단일 성공 순서가 중요하다 |
| 트랜잭션 길이 | 짧고 외부 부작용이 없다 | 짧아야 하지만 충돌 지점을 먼저 잡아야 한다 |
| 예시 | 프로필 수정, CMS 문서 제목 수정 | 재고 차감, 티켓 예약, 계좌이체 |
정량 기준은 절대값보다 관측 방법이 중요하다. 예를 들어 낙관적 잠금 경로에서 1주일 동안 버전 충돌이 전체 요청의 1% 미만이면 재시도 비용이 작을 가능성이 높다. 반대로 10% 가까이 충돌하고, 재시도마다 같은 인기 상품을 다시 읽는다면 retry storm으로 처리량이 더 나빠질 수 있다. 이때는 비관적 잠금, 큐잉, 재고 예약 테이블 같은 다른 설계를 검토한다.
선택할 때는 세 숫자를 같이 본다.
낙관적 잠금은 “잠금을 쓰지 않는다”가 아니다. 충돌을 늦게 발견하는 전략이다. 비관적 잠금은 “무조건 안전하다”가 아니다. 충돌을 빨리 직렬화하지만 대기와 deadlock 위험을 만든다.
NestJS와 TypeORM을 쓰더라도 핵심은 프레임워크 API가 아니라 경계다. 어떤 저장소 변경이 하나의 업무 사실인지 먼저 정하고, 그 경계 안의 DB 작업이 같은 트랜잭션 연결을 쓰게 해야 한다.
dataSource.transaction() 기본형@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", }); }); }}이 예제의 학습 포인트는 세 가지다.
manager를 사용한다.TypeORM 공식 문서가 강조하는 제약도 이 3번이다. transaction() 콜백 안에서는 제공된 transactional entity manager를 사용해야 한다. this.orderRepository.save() 같은 원본 repository를 섞으면 다른 커넥션에서 실행되어 트랜잭션 경계 밖으로 빠질 수 있다.
퀴즈
콜백이 제공한 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() | 짧고 자동 rollback | manager 대신 repository를 섞는 실수 | 대부분의 서비스 로직 |
| QueryRunner | 생명주기 직접 제어 | rollback, release 누락 | 세밀한 연결 제어가 필요한 경우 |
| 데코레이터 기반 라이브러리 | 비즈니스 코드가 깔끔함 | 초기화 순서, async context 경계 | 팀 표준이 있고 테스트된 경우 |
데이터베이스 트랜잭션은 데이터베이스 안의 변경을 묶는다. 외부 결제 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", });});이 코드는 두 가지 문제가 있다.
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되는 것이다. 그러면 외부 시스템은 존재하지 않는 주문을 들은 셈이 된다.
안전한 방향은 보통 둘 중 하나다.
두 번째 방식은 CDC & Outbox 패턴 토픽의 영역이다. 여기서는 원칙만 기억한다. DB 트랜잭션은 DB 안의 사실을 묶고, 외부 부작용은 별도 실패 경계와 보상 전략이 필요하다.
트랜잭션은 “많이 쓰면 안전해지는 장식”이 아니다. 경계가 너무 작으면 부분 처리 상태가 남고, 너무 크면 lock 대기와 장애 반경이 커진다.
여러 DB 변경이 함께 성공해야 같은 사실이 된다면 하나의 트랜잭션으로 묶는다.
주문 생성 + 재고 차감, 계좌 출금 + 입금일부만 성공해도 후속 잡이나 보상 로직으로 안전하게 맞출 수 있으면 경계를 나눌 수 있다.
프로필 업데이트 후 검색 인덱스 갱신DB rollback으로 되돌릴 수 없는 작업은 같은 트랜잭션 안에 넣어도 원자적이지 않다.
결제 승인, 이메일 발송, 메시지 publish트랜잭션 안의 느린 작업은 같은 행을 기다리는 요청 전체의 지연으로 전파된다.
외부 API p99가 높거나, 배치가 대량 행을 갱신할 때판단 질문은 다음 네 개면 충분하다.
답이 “부분 커밋은 모순이고, 자동 복구가 어렵고, DB 작업만 있고, 짧게 끝난다”에 가깝다면 트랜잭션으로 묶는 것이 자연스럽다. 답이 “외부 부작용이 있고, 오래 걸리고, 후속 처리가 가능하다”에 가깝다면 트랜잭션을 작게 나누고 outbox, 재시도, 보상 작업을 설계한다.
트랜잭션 장애 대응에서 중요한 것은 처음부터 긴 SQL 런북을 외우는 것이 아니다. 관측된 증상을 어떤 개념의 실패로 볼지 연결하는 것이다.
| 증상 | 먼저 의심할 개념 | 의미 | 다음 확인 |
|---|---|---|---|
| 주문은 있는데 재고가 그대로다 | Atomicity, 트랜잭션 경계 | 함께 커밋되어야 할 DB 작업이 분리됨 | 같은 transaction() 콜백과 manager 사용 여부 |
| 결제 이벤트를 받았는데 주문 조회가 안 된다 | 외부 부작용 경계, Isolation | 커밋 전 이벤트 발행 또는 rollback 후 발행 | 커밋 후 발행인지, outbox 사용 여부 |
deadlock detected, 40P01 | Lock 순서, Deadlock | 서로 다른 순서로 같은 행을 잠금 | 잠금 획득 순서 통일, 전체 트랜잭션 재시도 |
could not serialize access, 40001 | Isolation level | DB가 직렬화 불가능한 동시 실행을 rollback | 전체 트랜잭션 재시도 |
| 요청이 갑자기 느려지고 lock wait가 증가 | Lock hold time | 트랜잭션이 잠금을 오래 잡음 | 외부 API, 대량 작업, idle in transaction |
| 시간이 지나면 DB 연결이 고갈된다 | QueryRunner 생명주기 | 연결이 풀로 반환되지 않음 | finally { release() } 누락 |
| 업데이트 많은 테이블이 계속 커진다 | MVCC, VACUUM | dead tuple 정리가 지연됨 | 장기 트랜잭션, autovacuum 상태 |
운영 SQL은 상황마다 달라질 수 있지만, 위 연결은 안정적이다. 증상을 개념으로 되돌려 읽으면 “무엇을 봐야 하는지”가 먼저 정해지고, 그 다음에 팀의 관측 도구와 DB 권한에 맞는 쿼리를 고르면 된다.
이 실습은 긴 로컬 환경 구축이 아니라 개념 확인용이다. PostgreSQL이나 호환 SQL 환경에서 작은 테스트 테이블을 만들 수 있다면 충분하다.
ROLLBACK 확인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다.-- 여기서는 이름을 외우기보다 "이 세션이 어떤 격리 규칙으로 읽는가"를 확인하는 감각만 잡는다.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 후에는 다른 트랜잭션에서도 볼 수 있는 확정 상태가 된다.아래는 실행형 실습이라기보다 순서를 보는 사고 실험이다. 직접 실행하려면 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 오름차순으로 잠금 획득아래는 긴 on-call runbook이 아니라 코드 리뷰와 장애 초동 분류에 필요한 최소 관찰 포인트다.
transaction() 콜백 안의 DB 호출이 모두 manager 또는 manager.getRepository()를 쓰는지 본다.this.repository.save()가 콜백 안에 섞이면 rollback 범위에서 빠질 수 있다.try/catch/finally와 release()가 항상 있는지 본다.SELECT pid, wait_event_type, wait_event, state, queryFROM pg_stat_activityWHERE wait_event_type = 'Lock'ORDER BY state_change;이 쿼리는 “현재 lock을 기다리는 세션이 있는가”를 보는 출발점이다. 바로 세션을 종료하는 런북으로 넘어가기보다, 어떤 요청이 어떤 트랜잭션 경계를 오래 잡고 있는지 먼저 확인해야 한다.
statement_timeout은 단일 쿼리 실행 시간을 제한한다.lock_timeout은 잠금 대기 시간을 제한한다.idle_in_transaction_session_timeout은 BEGIN 후 아무 작업 없이 열린 트랜잭션을 정리하는 데 도움을 준다.| 헷갈리는 개념 | 구분 |
|---|---|
| Transaction vs Lock | Transaction은 작업 경계이고, 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 커밋 안에 이벤트 레코드를 저장하고, 커밋 후 별도 발행자가 처리하게 하는 패턴이다. |
SELECT ... FOR UPDATE40001 재시도이 키워드 중 Outbox와 CDC는 이 문서의 본문 주제가 아니다. 트랜잭션이 끝난 뒤 이벤트를 안전하게 발행하는 문제로 넘어갈 때 content/topics/L8/cdc-outbox.mdx에서 이어서 보면 된다.
synchronous_commit 모드별 성공 응답과 내구성 경계를 확인할 때 본다.transaction() 콜백과 QueryRunner 사용 규칙을 확인할 때 본다.synchronous_commit=on에서 커밋된 변경을 장애 후 복구 가능하게 하며, off는 최근 성공 응답 유실을 허용한다.