콘텐츠로 이동

Transaction Basics

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

트랜잭션은 “여러 작업을 하나의 단위로 묶어서, 전부 성공하거나 전부 실패하게 만드는” 데이터 처리 방식이다.

“A 계좌에서 돈을 빼고 B 계좌에 돈을 넣는” 작업에서, 중간에 실패하면 돈이 사라진다. 트랜잭션을 이해하지 못하면 데이터가 꼬이는 버그를 만들거나, 장애 상황에서 데이터 정합성 문제를 진단할 수 없다.

프론트엔드 개발자를 위한 브릿지: React에서 폼을 제출할 때 API가 500 에러를 반환하는 경우, 서버 내부에서는 트랜잭션 롤백이 일어난 것이다. 예를 들어 “주문 생성 → 재고 차감” 중 재고 차감에서 실패하면, 트랜잭션이 전체를 되돌려서 주문도 생성되지 않은 것처럼 처리한다. 프론트에서 500을 받았다면 DB 상태는 변경 전과 동일하게 안전하다.

트랜잭션이 동작하는 방식 (전체 흐름)

섹션 제목: “트랜잭션이 동작하는 방식 (전체 흐름)”

비유: 계좌이체와 같다.

계좌이체 작업 (A → B로 10만원):
1. A 계좌 잔액 조회: 50만원
2. A 계좌 -10만원 → 40만원
3. ← 여기서 서버가 죽으면? (트랜잭션 없으면 A에서만 돈이 빠짐!)
4. B 계좌 +10만원 → 60만원
트랜잭션 있음: 2번에서 DB가 죽으면 → 자동 Rollback → A는 여전히 50만원
트랜잭션 없음: 2번에서 DB가 죽으면 → A: 40만원, B: 50만원 → 10만원 증발

원리: DB는 내부적으로 어떻게 트랜잭션을 보장하는가

  1. WAL (Write-Ahead Logging): 실제 데이터를 수정하기 전에 먼저 “무엇을 할 것인지”를 로그에 기록. 장애 시 이 로그로 복구.
  2. Lock: 같은 행을 동시에 수정하지 못하도록 잠금. INSERT/UPDATE 시 해당 행에 배타적 잠금(Exclusive Lock) 걸림.
  3. MVCC (Multi-Version Concurrency Control): PostgreSQL이 사용하는 방식. 행을 잠그는 대신 여러 버전을 유지해서 읽기 작업이 쓰기 작업을 차단하지 않도록 함.

왜 이렇게 설계되었는가 — MVCC의 철학

전통적인 Lock 기반 DB는 읽기와 쓰기가 서로를 차단한다. “A가 행을 수정하는 동안 B는 읽기도 기다려야” 하는 것이다. PostgreSQL의 MVCC는 이 문제를 해결하기 위해 “행의 여러 버전을 동시에 유지”하는 전략을 택했다. 쓰기 트랜잭션이 새 버전을 만드는 동안 읽기 트랜잭션은 이전 버전을 읽는다. 이 덕분에 Read Committed 격리 수준에서 읽기가 쓰기를 차단하지 않아 동시성이 높아진다. 단점은 오래된 버전(dead tuple)이 쌓여 VACUUM이 필요하다는 것이다.

MVCC 내부 동작 — xmin/xmax로 행 버전 관리

PostgreSQL MVCC는 각 행(tuple)에 숨겨진 시스템 컬럼 두 개로 가시성을 제어한다.

-- xmin, xmax 컬럼 직접 확인 (일반 SELECT에는 안 보이는 숨겨진 컬럼)
SELECT xmin, xmax, id, balance FROM accounts WHERE id = 1;
-- 예상 출력:
-- xmin | xmax | id | balance
-- -------+------+-----+---------
-- 12345 | 0 | 1 | 500000
-- xmin=12345: 이 행을 생성(INSERT)한 트랜잭션 ID
-- xmax=0: 아직 삭제/수정되지 않음 (0 = 유효한 행)

UPDATE가 일어날 때 MVCC 동작:

-- 트랜잭션 99999가 balance를 400000으로 UPDATE 하는 경우:
1. 기존 행: xmin=12345, xmax=0, balance=500000
→ xmax=99999로 마킹 ("이 행은 트랜잭션 99999가 무효화했다")
2. 새 행 생성: xmin=99999, xmax=0, balance=400000
→ 새로 삽입된 버전
3. 커밋 전 다른 트랜잭션이 SELECT하면?
→ xmax=99999인 기존 행을 본다 (아직 커밋 안 됨, 99999는 진행 중)
→ Read Committed: 499가 아직 진행 중이면 기존 500000을 읽음
4. 커밋 후:
→ xmax=99999인 행: dead tuple (죽은 행, 더 이상 visible하지 않음)
→ xmin=99999인 행: 이제 visible (새 버전)

이 dead tuple들이 쌓이면 VACUUM이 주기적으로 청소한다. RDS에서는 autovacuum이 자동으로 실행된다.

📖 더 보기: PostgreSQL MVCC Internals: From xmin/xmax to Isolation Levels - DEV Community — xmin/xmax 필드부터 dead tuple, VACUUM까지 PostgreSQL MVCC를 코드 수준으로 분석 (중급)


Deadlock 방지의 핵심 원칙 — 항상 같은 순서로 잠금 획득

두 트랜잭션이 서로 다른 순서로 동일한 행에 락을 걸면 교착 상태(Deadlock)가 발생한다. PostgreSQL은 ERROR: deadlock detected (40P01)을 반환하며 둘 중 하나를 강제 롤백한다. 방지 원칙은 단순하다: 여러 행에 락을 걸어야 할 때 항상 동일한 순서(예: id 오름차순) 로 잠금을 획득한다. 재시도 로직과 구체적인 TypeORM 구현은 트러블슈팅 섹션을 참고하세요.

WAL과 Commit의 관계 — 왜 커밋해야 저장되는가:

BEGIN 실행
→ DB: "트랜잭션 시작. 변경사항은 아직 임시 공간에만 저장"
UPDATE accounts SET balance = 40만원 WHERE id = 1
→ DB: WAL에 "accounts id=1의 balance를 40만원으로 변경할 것"을 기록
→ 실제 데이터 파일은 아직 변경 안 됨
COMMIT
→ DB: WAL을 디스크에 확정 저장 → 실제 데이터 파일 업데이트
→ "이 변경은 영구히 반영됨"

만약 COMMIT 전에 서버가 죽으면 → WAL에 커밋 마크가 없음 → 재시작 시 롤백 처리.

PostgreSQL WAL 파일 구조 (참고):

WAL 파일 위치: $PGDATA/pg_wal/ 디렉토리
파일 명명: 000000010000000000000001 (16진수)
파일 크기: 기본 16MB씩 분할 저장
크래시 복구 과정:
1. PostgreSQL 재시작
2. 마지막 체크포인트(checkpoint) 위치 찾기
3. 체크포인트 이후의 WAL 레코드를 순서대로 재실행
4. 커밋 마크 있는 트랜잭션만 반영, 없는 것은 롤백
→ 데이터베이스가 일관된 상태로 복구됨

📖 더 보기: PostgreSQL WAL - The backbone of reliable transaction logging — WAL 파일 구조, 체크포인트, 크래시 복구 흐름을 2025년 기준으로 상세 설명 (중급)

📖 더 보기: ACID Databases Explained - FreeCodeCamp — 위 WAL, Lock, MVCC 동작 원리를 시각적으로 설명, Commit/Rollback 흐름 다이어그램 제공


ACID 속성

트랜잭션이 지켜야 할 4가지 성질:

속성의미위반 시 발생하는 문제
Atomicity(원자성)전부 성공 또는 전부 실패. 중간 상태 없음.부분 처리로 데이터 불일치 (돈이 증발/복제)
Consistency(일관성)트랜잭션 전후로 데이터가 규칙에 맞는 상태잔액이 음수가 되거나 외래키 제약 위반
Isolation(격리성)동시 트랜잭션이 서로 간섭하지 않음더티 리드, 팬텀 리드 등 동시성 문제
Durability(지속성)커밋된 데이터는 장애에도 보존장애 후 재시작 시 커밋된 데이터가 사라짐

Commit과 Rollback

  • Commit: “이 작업들을 확정해라” → 데이터가 실제로 저장됨. WAL에 기록 후 적용.
  • Rollback: “이 작업들을 취소해라” → 트랜잭션 시작 전 상태로 되돌림.
-- 직접 SQL로 실험해볼 수 있는 예시 (PostgreSQL)
BEGIN;
UPDATE accounts SET balance = balance - 100000 WHERE id = 1;
-- 이 시점에서 SELECT하면 변경된 값이 보임 (같은 트랜잭션 내에서)
UPDATE accounts SET balance = balance + 100000 WHERE id = 2;
COMMIT; -- 확정 (또는 ROLLBACK으로 취소)
-- ROLLBACK 테스트:
BEGIN;
DELETE FROM orders WHERE id = 9999;
ROLLBACK; -- 삭제 취소 — orders 테이블은 그대로

격리 수준(Isolation Level)

동시에 여러 트랜잭션이 실행될 때 어느 정도까지 서로를 볼 수 있는지:

격리 수준Dirty ReadNon-Repeatable ReadPhantom Read성능
Read Uncommitted발생발생발생가장 빠름
Read Committed방지발생발생빠름
Repeatable Read방지방지발생중간
Serializable방지방지방지가장 느림
  • PostgreSQL 기본값: Read Committed
  • MySQL InnoDB 기본값: Repeatable Read

실무에서 격리 수준을 변경하는 경우는 드물다. 기본값을 이해하고 동시성 문제 발생 시 참고.

TypeORM에서 격리 수준 명시적 설정:

// 중요한 금융 트랜잭션에서 격리 수준 명시 권장
await dataSource.transaction("SERIALIZABLE", async (manager) => {
// Serializable: 완전한 격리, 팬텀 리드 방지
const account = await manager.findOne(Account, { where: { id: 1 } });
await manager.update(
Account,
{ id: 1 },
{ balance: account.balance - 100000 },
);
});
// 기본 (Read Committed)
await dataSource.transaction(async (manager) => {
// 격리 수준을 명시하지 않으면 DB 기본값(PostgreSQL: Read Committed) 사용
await manager.save(Order, orderData);
});

NestJS + TypeORM에서 트랜잭션 사용: 두 가지 방법 비교

방법 1: dataSource.transaction() — 간결, 간단한 트랜잭션에 적합

// DataSource.transaction()으로 여러 작업을 하나의 단위로 묶기
@Injectable()
export class OrderService {
constructor(private dataSource: DataSource) {}
async createOrder(dto: CreateOrderDto) {
return this.dataSource.transaction(async (manager) => {
// 1. 재고 확인 (FOR UPDATE로 잠금 — 동시 주문 방지)
const product = await manager.findOne(Product, {
where: { id: dto.productId },
lock: { mode: "pessimistic_write" },
});
if (product.stock < dto.quantity) {
throw new BadRequestException("재고 부족");
// 예외 발생 → 자동 Rollback
}
// 2. 재고 차감
await manager.decrement(
Product,
{ id: dto.productId },
"stock",
dto.quantity,
);
// 3. 주문 생성
const order = await manager.save(Order, {
userId: dto.userId,
productId: dto.productId,
quantity: dto.quantity,
status: "pending",
});
return order;
// 여기까지 오면 자동 Commit
});
}
}

예상 동작:

성공 시: Product.stock -= quantity, Order 생성 → Commit
재고 부족 시: BadRequestException → 자동 Rollback → 재고/주문 변경 없음
DB 오류 시: QueryFailedError → 자동 Rollback → 원래 상태 유지

방법 2: QueryRunner — 복잡한 트랜잭션, 세밀한 제어가 필요할 때

// QueryRunner: 트랜잭션 생명주기를 직접 제어 (장점: 유연성, 단점: 코드량 많음)
async createOrderWithQueryRunner(dto: CreateOrderDto) {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const product = await queryRunner.manager.findOne(Product, {
where: { id: dto.productId },
lock: { mode: 'pessimistic_write' },
});
await queryRunner.manager.decrement(Product, { id: dto.productId }, 'stock', dto.quantity);
const order = await queryRunner.manager.save(Order, { ...dto, status: 'pending' });
await queryRunner.commitTransaction();
return order;
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release(); // ← 반드시 release! 안 하면 커넥션 풀 고갈
}
}

두 방법 비교:

항목dataSource.transaction()QueryRunner
코드 간결성간결함보일러플레이트 많음
에러 처리자동 Rollback직접 try/catch/finally 작성
복잡한 흐름중첩 어려움유연하게 제어 가능
실수 위험낮음release() 누락 시 커넥션 풀 고갈
권장 상황대부분의 경우멀티 DB, 조건부 커밋/롤백 등 복잡한 경우

📖 더 보기: TypeORM Transactions with NestJS - Medium — 위 dataSource.transaction()과 QueryRunner 두 가지 방식 비교 구현 가이드


Deadlock (교착상태)

두 트랜잭션이 서로가 가진 잠금(lock)을 기다리며 영원히 멈추는 상태. DB가 자동으로 하나를 강제 롤백한다.

트랜잭션 A: 트랜잭션 B:
LOCK Row 1 (성공) LOCK Row 2 (성공)
LOCK Row 2 대기... → LOCK Row 1 대기... →
↑ ↑
└──── 서로 기다림 → Deadlock ──┘
DB: "A를 강제 Rollback하고 B 진행"
→ PostgreSQL 에러 코드: 40P01

Deadlock 방지법: 여러 트랜잭션에서 항상 동일한 순서로 잠금을 획득한다.

패턴 1: @Transactional 데코레이터 패턴 (프로덕션 권장)

typeorm-transactional 라이브러리를 사용하면 메서드에 데코레이터를 붙이는 것만으로 트랜잭션을 관리할 수 있다. 2025년 현재 수백만 건 트랜잭션을 처리하는 프로덕션 환경에서 검증된 패턴이다.

// npm install typeorm-transactional
// main.ts에서 반드시 먼저 초기화 (NestFactory.create() 전!)
import { initializeTransactionalContext } from "typeorm-transactional";
initializeTransactionalContext(); // ← 이게 먼저여야 함
// order.service.ts
import { Transactional, IsolationLevel } from "typeorm-transactional";
@Injectable()
export class OrderService {
constructor(
@InjectRepository(Order) private orderRepo: Repository<Order>,
@InjectRepository(Product) private productRepo: Repository<Product>,
) {}
@Transactional() // 이 메서드 전체가 하나의 트랜잭션
async createOrder(dto: CreateOrderDto) {
// this.orderRepo와 this.productRepo가 자동으로 같은 트랜잭션 사용
const product = await this.productRepo.findOne({
where: { id: dto.productId },
});
if (product.stock < dto.quantity) {
throw new BadRequestException("재고 부족"); // 자동 Rollback
}
await this.productRepo.decrement(
{ id: dto.productId },
"stock",
dto.quantity,
);
return this.orderRepo.save({ ...dto, status: "pending" });
// 메서드 종료 시 자동 Commit
}
@Transactional({ isolationLevel: IsolationLevel.SERIALIZABLE })
async transferMoney(fromId: number, toId: number, amount: number) {
// 금융 트랜잭션: SERIALIZABLE 격리 수준 명시
await this.accountRepo.decrement({ id: fromId }, "balance", amount);
await this.accountRepo.increment({ id: toId }, "balance", amount);
}
}

장점: 비즈니스 로직에 트랜잭션 코드가 섞이지 않아 가독성 향상. @InjectRepository로 주입한 Repository가 자동으로 같은 트랜잭션 컨텍스트를 공유함.

패턴 2: 마이그레이션 트랜잭션 전략 (프로덕션 안전성)

TypeORM 마이그레이션을 프로덕션에 안전하게 적용하는 트랜잭션 설정:

Terminal window
# 기본값: 모든 마이그레이션을 단일 트랜잭션으로 실행
npm run typeorm migration:run
# 각 마이그레이션을 독립 트랜잭션으로 실행 (대용량 데이터 변경 시 권장)
npm run typeorm migration:run -- --transaction each
# CREATE INDEX CONCURRENTLY 등 트랜잭션 안에서 실행 불가한 작업이 있을 때
npm run typeorm migration:run -- --transaction none

중요: synchronize: true는 프로덕션에서 절대 사용하지 말 것 — 의도치 않은 스키마 변경으로 데이터 손실 가능.

패턴 3: 트랜잭션 범위 최소화 원칙

장기 트랜잭션은 다른 요청들의 Lock 대기를 유발한다. 트랜잭션 범위를 DB 작업만으로 최소화하는 것이 핵심이다.

// ❌ 잘못된 패턴: 외부 API 호출이 트랜잭션 안에 있음
await dataSource.transaction(async (manager) => {
const order = await manager.save(Order, orderData); // DB 작업
const result = await externalPaymentApi.charge(amount); // 외부 API (느림!)
await manager.update(Order, order.id, { paymentId: result.id }); // DB 작업
}); // 외부 API 응답 대기 동안 Lock 점유 → 다른 요청 대기
// ✅ 올바른 패턴: 외부 API를 트랜잭션 밖으로 이동
const order = await orderRepo.save(orderData); // 트랜잭션 없이 저장
const result = await externalPaymentApi.charge(amount); // 트랜잭션 밖에서 호출
await dataSource.transaction(async (manager) => {
// DB 작업만 트랜잭션에 포함
await manager.update(Order, order.id, {
paymentId: result.id,
status: "paid",
});
}); // Lock 점유 시간 최소화
  • 결제 처리 (주문 생성 + 재고 감소 + 결제 확인을 하나로)
  • 사용자 가입 (계정 생성 + 프로필 생성 + 초기 설정을 하나로)
  • 배치 데이터 처리 (여러 레코드를 한 번에 업데이트)
  • 데이터 마이그레이션 (안전하게 변환 후 커밋)
  • 데이터 정합성 이슈 발생 시 트랜잭션이 제대로 처리됐는지 확인
  • Deadlock 에러 로그 발생 시 원인 분석
  • DB 성능 이슈 시 격리 수준과 잠금(lock) 문제 의심
  • 새 기능 개발 시 “이 작업들이 하나의 트랜잭션이어야 하는지” 판단
개념 A개념 B차이점
CommitRollbackCommit은 확정, Rollback은 취소
LockTransactionLock은 데이터 잠금 메커니즘, Transaction은 작업 묶음 (Lock을 사용함)
Optimistic LockPessimistic LockOptimistic은 충돌 시 재시도, Pessimistic은 미리 잠그고 시작
ACIDBASEACID는 강한 일관성(RDB), BASE는 최종 일관성(NoSQL)
dataSource.transaction()QueryRunner전자는 간결함, 후자는 세밀한 제어 가능 (둘 다 TypeORM 트랜잭션 방법)
@TransactionaldataSource.transaction()데코레이터는 메서드 레벨 선언, dataSource는 코드 레벨 제어

🔧 TypeORM에서 “deadlock detected” 에러가 발생한다

섹션 제목: “🔧 TypeORM에서 “deadlock detected” 에러가 발생한다”

증상: QueryFailedError: deadlock detected 로그 발생, 일부 주문이 실패 원인: 두 트랜잭션이 같은 행들을 반대 순서로 잠금 획득 시도 (예: 트랜잭션A는 Product→Order 순서, B는 Order→Product 순서) 해결:

  1. 코드에서 모든 트랜잭션이 항상 동일한 순서로 잠금 획득하도록 통일
  2. Deadlock 발생 시 재시도 로직 추가:
    try {
    return await this.dataSource.transaction(async (manager) => { ... });
    } catch (error) {
    if (error.driverError?.code === '40P01') { // PostgreSQL deadlock 에러 코드
    // 짧은 대기 후 재시도
    await new Promise(resolve => setTimeout(resolve, 100));
    return this.createOrder(dto); // 재귀 재시도
    }
    throw error;
    }

🔧 트랜잭션을 걸었는데도 데이터가 꼬인다

섹션 제목: “🔧 트랜잭션을 걸었는데도 데이터가 꼬인다”

증상: dataSource.transaction() 안에서 처리했는데 중간 실패 시 일부 데이터만 변경됨 원인: 트랜잭션 manager 대신 원본 repository를 사용하고 있음. 원본 repository는 별도 커넥션을 사용하므로 트랜잭션에 포함되지 않음 해결:

// 잘못된 코드 (this.orderRepository는 트랜잭션 밖)
await this.dataSource.transaction(async (manager) => {
await this.orderRepository.save(order); // ← 이 repository는 트랜잭션에 포함 안 됨!
});
// 올바른 코드 (manager 사용)
await this.dataSource.transaction(async (manager) => {
await manager.save(Order, order); // ← manager가 같은 트랜잭션 커넥션 사용
});

🔧 트랜잭션이 너무 오래 걸려서 다른 요청들이 대기한다

섹션 제목: “🔧 트랜잭션이 너무 오래 걸려서 다른 요청들이 대기한다”

증상: 특정 요청 처리 시 DB 응답이 느려지고, 로그에 “waiting for lock” 관련 경고 발생 원인: 트랜잭션 안에서 외부 API 호출, 무거운 연산 등 시간이 오래 걸리는 작업을 실행 → Lock을 오래 점유 해결:

  1. 트랜잭션 범위를 DB 작업만으로 최소화 — 외부 API 호출은 트랜잭션 밖으로 이동

  2. 외부 결제 API 호출 후 결과를 받아서 트랜잭션 시작:

    // 잘못된 패턴: 트랜잭션 안에서 외부 API 호출
    await dataSource.transaction(async (manager) => {
    await externalPaymentApi.charge(amount); // ← 느린 외부 호출이 Lock 점유
    await manager.save(Order, order);
    });
    // 올바른 패턴: 외부 호출 먼저, DB 작업만 트랜잭션에
    const paymentResult = await externalPaymentApi.charge(amount); // 트랜잭션 밖
    await dataSource.transaction(async (manager) => {
    await manager.save(Order, { ...order, paymentId: paymentResult.id }); // DB만
    });

🔧 QueryRunner를 사용 후 커넥션 풀이 고갈된다

섹션 제목: “🔧 QueryRunner를 사용 후 커넥션 풀이 고갈된다”

증상: 일정 시간 후 DB 요청이 모두 타임아웃 (DriverPackageNotInstalledError 또는 connection timeout) 원인: QueryRunner를 사용했지만 finally 블록에서 queryRunner.release()를 호출하지 않음. 커넥션이 반환되지 않아 풀이 고갈됨 해결:

const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// ... DB 작업
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
// ← 이 finally 블록이 반드시 있어야 함!
// 예외가 발생해도 반드시 실행되어 커넥션을 풀에 반환
await queryRunner.release();
}

🔧 @Transactional 데코레이터가 적용되지 않는다 (typeorm-transactional)

섹션 제목: “🔧 @Transactional 데코레이터가 적용되지 않는다 (typeorm-transactional)”

증상: @Transactional() 데코레이터를 붙였는데 트랜잭션 없이 각 쿼리가 별도로 실행됨 원인: initializeTransactionalContext()NestFactory.create() 이후에 호출됨. 반드시 앱 초기화 전에 호출해야 ALS(Async Local Storage)가 정상 동작 해결:

// main.ts - 올바른 순서
import { initializeTransactionalContext } from "typeorm-transactional";
initializeTransactionalContext(); // ← 반드시 맨 위에서 먼저 호출!
async function bootstrap() {
const app = await NestFactory.create(AppModule); // ← 이 이후가 아님!
await app.listen(3000);
}
bootstrap();
// ❌ 잘못된 순서 (이렇게 하면 @Transactional이 동작 안 함)
async function bootstrap() {
const app = await NestFactory.create(AppModule);
initializeTransactionalContext(); // 너무 늦음!
await app.listen(3000);
}

🔧 트랜잭션 안에서 이벤트를 발행했는데 구독자가 DB를 못 읽는다

섹션 제목: “🔧 트랜잭션 안에서 이벤트를 발행했는데 구독자가 DB를 못 읽는다”

증상: 트랜잭션 안에서 this.eventEmitter.emit('order.created', order) 호출 → 리스너가 orderRepo.findOne()으로 해당 order를 조회하는데 찾지 못함 원인: 트랜잭션이 아직 커밋되지 않은 상태에서 이벤트가 발행됨. 리스너는 다른 DB 커넥션을 사용하므로 커밋 전 데이터가 보이지 않음 (Read Committed 격리 수준 기준) 해결:

// ❌ 잘못된 패턴: 트랜잭션 내에서 이벤트 발행
async createOrder(dto: CreateOrderDto) {
return await this.dataSource.transaction(async (manager) => {
const order = await manager.save(Order, dto);
this.eventEmitter.emit('order.created', order); // ← 커밋 전 발행!
return order;
});
}
// ✅ 올바른 패턴 1: 트랜잭션 커밋 후 발행
async createOrder(dto: CreateOrderDto) {
const order = await this.dataSource.transaction(async (manager) => {
return await manager.save(Order, dto);
}); // ← 여기서 커밋 완료
this.eventEmitter.emit('order.created', order); // 커밋 후 발행
return order;
}
// ✅ 올바른 패턴 2: Outbox Pattern (더 안전)
// 트랜잭션 안에서 outbox 테이블에 이벤트 레코드 저장
// 별도 Processor가 커밋된 outbox 레코드를 읽어서 발행
  • ACID 4가지를 각각 한 문장으로 설명할 수 있다
  • Commit과 Rollback의 차이를 설명할 수 있다
  • Deadlock이 뭔지 설명할 수 있다
  • “이 작업에 트랜잭션이 필요한가?”를 판단할 수 있다
  • dataSource.transaction()과 QueryRunner의 차이를 설명할 수 있다
  • 트랜잭션 범위를 최소화해야 하는 이유를 설명할 수 있다

분산 트랜잭션, Two-Phase Commit, Saga Pattern, Optimistic Locking, WAL(Write-Ahead Logging), Connection Pool, typeorm-transactional

  • 팀 서비스 코드에서 트랜잭션이 사용된 부분 찾아보기
    Terminal window
    grep -r "dataSource.transaction\|QueryRunner\|@Transaction" src/ --include="*.ts"
    예상 출력: 트랜잭션 있으면 파일 경로와 줄 번호 표시
  • DB에서 Deadlock 이력 확인 PostgreSQL: SELECT * FROM pg_stat_activity WHERE wait_event_type = 'Lock'; 예상 출력: 현재 Lock 대기 중인 쿼리 목록 (없으면 빈 테이블)
  • 간단한 SQL로 BEGIN → INSERT → ROLLBACK 해보기
    BEGIN;
    INSERT INTO test_table(name) VALUES ('test');
    SELECT * FROM test_table; -- 'test'가 보임 (아직 커밋 안 됨)
    ROLLBACK;
    SELECT * FROM test_table; -- 'test'가 사라짐
  • 현재 DB의 기본 격리 수준 확인 PostgreSQL: SHOW transaction_isolation; 예상 출력: read committed
  1. 트랜잭션은 여러 작업을 하나로 묶어 전부 성공 또는 전부 실패하게 한다
  2. ACID(원자성, 일관성, 격리성, 지속성)가 트랜잭션의 핵심 성질이다
  3. Commit은 확정, Rollback은 취소 — 중간 상태를 방지한다
  4. 격리 수준이 높을수록 안전하지만 성능이 떨어진다
  5. 데이터 정합성 문제의 원인은 대부분 트랜잭션 처리에 있다

11. 실전 장애 대응 시나리오 (On-Call Runbook)

섹션 제목: “11. 실전 장애 대응 시나리오 (On-Call Runbook)”

트랜잭션 관련 장애는 데이터 정합성 문제이므로 신중한 접근이 필요하다

시나리오 A: “DB 응답이 갑자기 느려졌다” (Lock 대기 의심)

섹션 제목: “시나리오 A: “DB 응답이 갑자기 느려졌다” (Lock 대기 의심)”
PostgreSQL 기준 즉각 확인:
1. Lock 대기 중인 쿼리 확인 (RDS 콘솔 또는 직접 접속)
SELECT pid, wait_event_type, wait_event, state, query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
ORDER BY state_change;
→ wait_event_type = 'Lock'인 행이 있으면 Lock 대기 중
2. Lock을 점유하고 있는 쿼리 찾기
SELECT
blocking.pid AS blocking_pid,
blocking.query AS blocking_query,
blocked.pid AS blocked_pid,
blocked.query AS blocked_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;
→ blocking_query가 오래 실행 중인 트랜잭션
3. 장시간 실행 중인 트랜잭션 강제 종료 (신중하게!)
-- 먼저 해당 쿼리/트랜잭션을 DBA 또는 개발자와 확인 후
SELECT pg_cancel_backend([blocking_pid]); -- 쿼리 취소 (Rollback)
SELECT pg_terminate_backend([blocking_pid]); -- 연결 강제 종료
4. 재발 방지
→ 문제가 된 코드 찾기: 외부 API 호출이 트랜잭션 안에 있는지 확인
→ 트랜잭션 타임아웃 설정 검토:
PostgreSQL: SET statement_timeout = '30s';
TypeORM: { extra: { statement_timeout: 30000 } }

시나리오 B: “deadlock detected” 에러가 반복적으로 발생한다

섹션 제목: “시나리오 B: “deadlock detected” 에러가 반복적으로 발생한다”
Deadlock은 DB가 자동으로 한 트랜잭션을 강제 종료하므로 데이터 손실은 없음.
그러나 주문 실패 등 사용자 경험 문제가 발생.
즉각 확인:
1. CloudWatch Log Insights에서 deadlock 빈도 확인:
filter @message like /deadlock detected/ or @message like /40P01/
| stats count(*) as cnt by bin(5m)
| sort @timestamp desc
2. 어떤 서비스/코드에서 발생하는지 확인
filter @message like /deadlock/
| fields service, endpoint, @message
| limit 20
원인 분석 접근:
→ PostgreSQL 로그에서 Deadlock 상세 내용 확인
RDS 파라미터 그룹: log_lock_waits = on, deadlock_timeout = 200ms 설정
→ CloudWatch Logs에서 "deadlock detected on relation" 로그 확인
→ 어떤 테이블의 어떤 행들이 연관됐는지 파악
근본 해결:
→ 섹션 6.5 "deadlock detected 에러가 발생한다" 항목의 해결법 적용
→ 잠금 순서 통일 + Deadlock 자동 재시도 로직 추가
Deadlock 자동 재시도 패턴 (NestJS 전체 적용):
@Injectable()
export class DeadlockRetryInterceptor implements NestInterceptor {
async intercept(context: ExecutionContext, next: CallHandler) {
const MAX_RETRIES = 3;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
return await lastValueFrom(next.handle());
} catch (error) {
const isDeadlock = error?.driverError?.code === '40P01' // PostgreSQL
|| error?.driverError?.errno === 1213; // MySQL ER_LOCK_DEADLOCK
if (isDeadlock && attempt < MAX_RETRIES - 1) {
const delay = 50 + Math.random() * 100; // 50~150ms 랜덤 대기
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}
}
// 사용: 전역 또는 민감한 컨트롤러에 적용
// app.useGlobalInterceptors(new DeadlockRetryInterceptor());

시나리오 C: “데이터 정합성이 깨졌다” (트랜잭션 누락 의심)

섹션 제목: “시나리오 C: “데이터 정합성이 깨졌다” (트랜잭션 누락 의심)”
증상: 주문은 있는데 재고가 안 줄었거나, 결제 기록은 있는데 주문 상태가 여전히 pending
즉각 조치:
1. 먼저 현재 불일치 범위 파악 (얼마나 많은 건수?)
SELECT COUNT(*) FROM orders o
LEFT JOIN payments p ON p.order_id = o.id
WHERE o.status = 'paid' AND p.id IS NULL; -- 결제 없는 paid 주문
2. 수동 데이터 정합성 복구 (DB 직접 수정은 최후 수단, 반드시 트랜잭션 사용)
BEGIN;
-- 조회로 영향 범위 확인
SELECT ... ;
-- 수정 SQL 실행
UPDATE ... ;
-- 결과 재확인 후 COMMIT 또는 ROLLBACK
COMMIT;
원인 분석:
→ 코드에서 여러 DB 작업이 같은 트랜잭션으로 묶여 있는지 확인
grep -r "dataSource.transaction\|@Transactional\|QueryRunner" src/
→ transaction() 안에서 this.repository (트랜잭션 외부 레포지토리) 사용 여부 확인
→ 섹션 6.5 "트랜잭션을 걸었는데도 데이터가 꼬인다" 항목 참고

TypeORM save() vs insert()/update() Deadlock 주의

TypeORM GitHub 이슈(#10586, #5521)에서 지속적으로 보고되는 패턴: save()를 동시 호출하면 TypeORM이 내부적으로 SELECT → UPDATE/INSERT 순서로 처리하면서 Deadlock이 발생할 수 있다. 2025년 기준 권장 해결책:

  • 삽입: save() 대신 insert() 사용 (SELECT 없이 직접 INSERT)
  • 수정: save() 대신 update() 사용 (명시적 WHERE 조건)
  • 충돌 처리: createQueryBuilder().insert().orUpdate() 패턴 (Upsert)

Optimistic Locking 도입 트렌드

재고 차감, 좌석 예약 등 동시성 충돌이 잦은 도메인에서 Pessimistic Lock(FOR UPDATE) 대신 Optimistic Lock(버전 번호 기반)을 도입하는 패턴이 늘고 있다. Lock 대기 없이 충돌 시 재시도하는 방식으로 처리량을 높일 수 있다.

// TypeORM Optimistic Lock 예시
@Entity()
export class Product {
@VersionColumn()
version: number; // 업데이트마다 자동 증가
@Column()
stock: number;
}
// 재고 차감 시 버전 체크 → 충돌 시 OptimisticLockVersionMismatchError
await productRepo.save({ id, stock: newStock, version: currentVersion });
// 충돌 발생 시 → 재시도 로직으로 최신 데이터 다시 읽어서 처리

분산 트랜잭션 → Saga Pattern으로 전환

마이크로서비스 환경에서 여러 서비스에 걸친 트랜잭션은 Two-Phase Commit 대신 Saga Pattern으로 처리하는 것이 2025년 표준이다. 각 서비스가 로컬 트랜잭션을 처리하고, 실패 시 보상 트랜잭션(Compensating Transaction)을 실행한다. AWS Step Functions이 Saga 오케스트레이션의 관리형 옵션으로 많이 채택되고 있다.

📖 더 보기: How to implement DEADLOCK retry on every endpoint automatically in NestJS — NestJS 전체 엔드포인트에 Deadlock 자동 재시도를 적용하는 Interceptor 패턴 (중급)