Transaction Basics
분류: Layer 8 - 데이터베이스 심화 | 선수지식: RDS Basics
1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”트랜잭션은 “여러 작업을 하나의 단위로 묶어서, 전부 성공하거나 전부 실패하게 만드는” 데이터 처리 방식이다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”“A 계좌에서 돈을 빼고 B 계좌에 돈을 넣는” 작업에서, 중간에 실패하면 돈이 사라진다. 트랜잭션을 이해하지 못하면 데이터가 꼬이는 버그를 만들거나, 장애 상황에서 데이터 정합성 문제를 진단할 수 없다.
프론트엔드 개발자를 위한 브릿지: React에서 폼을 제출할 때 API가 500 에러를 반환하는 경우, 서버 내부에서는 트랜잭션 롤백이 일어난 것이다. 예를 들어 “주문 생성 → 재고 차감” 중 재고 차감에서 실패하면, 트랜잭션이 전체를 되돌려서 주문도 생성되지 않은 것처럼 처리한다. 프론트에서 500을 받았다면 DB 상태는 변경 전과 동일하게 안전하다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”트랜잭션이 동작하는 방식 (전체 흐름)
섹션 제목: “트랜잭션이 동작하는 방식 (전체 흐름)”비유: 계좌이체와 같다.
계좌이체 작업 (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는 내부적으로 어떻게 트랜잭션을 보장하는가
- WAL (Write-Ahead Logging): 실제 데이터를 수정하기 전에 먼저 “무엇을 할 것인지”를 로그에 기록. 장애 시 이 로그로 복구.
- Lock: 같은 행을 동시에 수정하지 못하도록 잠금.
INSERT/UPDATE시 해당 행에 배타적 잠금(Exclusive Lock) 걸림. - 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 Read | Non-Repeatable Read | Phantom 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 에러 코드: 40P01Deadlock 방지법: 여러 트랜잭션에서 항상 동일한 순서로 잠금을 획득한다.
실전 아키텍처 패턴
섹션 제목: “실전 아키텍처 패턴”패턴 1: @Transactional 데코레이터 패턴 (프로덕션 권장)
typeorm-transactional 라이브러리를 사용하면 메서드에 데코레이터를 붙이는 것만으로 트랜잭션을 관리할 수 있다. 2025년 현재 수백만 건 트랜잭션을 처리하는 프로덕션 환경에서 검증된 패턴이다.
// npm install typeorm-transactional// main.ts에서 반드시 먼저 초기화 (NestFactory.create() 전!)import { initializeTransactionalContext } from "typeorm-transactional";initializeTransactionalContext(); // ← 이게 먼저여야 함
// order.service.tsimport { 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 마이그레이션을 프로덕션에 안전하게 적용하는 트랜잭션 설정:
# 기본값: 모든 마이그레이션을 단일 트랜잭션으로 실행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 점유 시간 최소화4. 실무에서 어디에 쓰이나
섹션 제목: “4. 실무에서 어디에 쓰이나”- 결제 처리 (주문 생성 + 재고 감소 + 결제 확인을 하나로)
- 사용자 가입 (계정 생성 + 프로필 생성 + 초기 설정을 하나로)
- 배치 데이터 처리 (여러 레코드를 한 번에 업데이트)
- 데이터 마이그레이션 (안전하게 변환 후 커밋)
5. 현재 내 업무와 연결점
섹션 제목: “5. 현재 내 업무와 연결점”- 데이터 정합성 이슈 발생 시 트랜잭션이 제대로 처리됐는지 확인
- Deadlock 에러 로그 발생 시 원인 분석
- DB 성능 이슈 시 격리 수준과 잠금(lock) 문제 의심
- 새 기능 개발 시 “이 작업들이 하나의 트랜잭션이어야 하는지” 판단
6. 자주 헷갈리는 개념 비교
섹션 제목: “6. 자주 헷갈리는 개념 비교”| 개념 A | 개념 B | 차이점 |
|---|---|---|
| Commit | Rollback | Commit은 확정, Rollback은 취소 |
| Lock | Transaction | Lock은 데이터 잠금 메커니즘, Transaction은 작업 묶음 (Lock을 사용함) |
| Optimistic Lock | Pessimistic Lock | Optimistic은 충돌 시 재시도, Pessimistic은 미리 잠그고 시작 |
| ACID | BASE | ACID는 강한 일관성(RDB), BASE는 최종 일관성(NoSQL) |
| dataSource.transaction() | QueryRunner | 전자는 간결함, 후자는 세밀한 제어 가능 (둘 다 TypeORM 트랜잭션 방법) |
| @Transactional | dataSource.transaction() | 데코레이터는 메서드 레벨 선언, dataSource는 코드 레벨 제어 |
6.5. 트러블슈팅
섹션 제목: “6.5. 트러블슈팅”🔧 TypeORM에서 “deadlock detected” 에러가 발생한다
섹션 제목: “🔧 TypeORM에서 “deadlock detected” 에러가 발생한다”증상: QueryFailedError: deadlock detected 로그 발생, 일부 주문이 실패
원인: 두 트랜잭션이 같은 행들을 반대 순서로 잠금 획득 시도 (예: 트랜잭션A는 Product→Order 순서, B는 Order→Product 순서)
해결:
- 코드에서 모든 트랜잭션이 항상 동일한 순서로 잠금 획득하도록 통일
- 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을 오래 점유 해결:
-
트랜잭션 범위를 DB 작업만으로 최소화 — 외부 API 호출은 트랜잭션 밖으로 이동
-
외부 결제 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 레코드를 읽어서 발행7. 체크리스트
섹션 제목: “7. 체크리스트”- ACID 4가지를 각각 한 문장으로 설명할 수 있다
- Commit과 Rollback의 차이를 설명할 수 있다
- Deadlock이 뭔지 설명할 수 있다
- “이 작업에 트랜잭션이 필요한가?”를 판단할 수 있다
- dataSource.transaction()과 QueryRunner의 차이를 설명할 수 있다
- 트랜잭션 범위를 최소화해야 하는 이유를 설명할 수 있다
8. 추가 학습 키워드
섹션 제목: “8. 추가 학습 키워드”분산 트랜잭션, Two-Phase Commit, Saga Pattern, Optimistic Locking, WAL(Write-Ahead Logging), Connection Pool, typeorm-transactional
8.5. 추천 리소스
섹션 제목: “8.5. 추천 리소스”- 📖 ACID Databases Explained - FreeCodeCamp — ACID 각 속성의 의미와 위반 시 어떤 문제가 생기는지 예시 중심으로 설명 (입문)
- 📖 TypeORM Transactions with NestJS - Medium — dataSource.transaction()과 QueryRunner 두 가지 방식 비교 구현 가이드 (입문)
- 📖 NestJS Transactions: A Comprehensive Guide — 실무 NestJS 트랜잭션 패턴 총정리, 격리 수준 설정 포함 (중급)
- 📖 PostgreSQL Understanding Deadlocks - Cybertec — PostgreSQL 데드락 발생 원리와 시스템 로그에서 진단하는 방법 상세 (중급)
- 📖 typeorm-transactional @Transactional 데코레이터 가이드 - Medium — @Transactional 데코레이터로 비즈니스 로직에서 트랜잭션 코드를 분리하는 프로덕션 패턴 (중급)
9. 내가 직접 확인해볼 것
섹션 제목: “9. 내가 직접 확인해볼 것”- 팀 서비스 코드에서 트랜잭션이 사용된 부분 찾아보기
예상 출력: 트랜잭션 있으면 파일 경로와 줄 번호 표시
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
10. 5줄 요약
섹션 제목: “10. 5줄 요약”- 트랜잭션은 여러 작업을 하나로 묶어 전부 성공 또는 전부 실패하게 한다
- ACID(원자성, 일관성, 격리성, 지속성)가 트랜잭션의 핵심 성질이다
- Commit은 확정, Rollback은 취소 — 중간 상태를 방지한다
- 격리 수준이 높을수록 안전하지만 성능이 떨어진다
- 데이터 정합성 문제의 원인은 대부분 트랜잭션 처리에 있다
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 "트랜잭션을 걸었는데도 데이터가 꼬인다" 항목 참고2025년 최신 동향
섹션 제목: “2025년 최신 동향”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;}
// 재고 차감 시 버전 체크 → 충돌 시 OptimisticLockVersionMismatchErrorawait 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 패턴 (중급)