🗄️ TypeORM5. Transaction & DataSource03 — 트랜잭션 3가지 방법

03 — 트랜잭션 3가지 방법

질문: TypeORM에서 트랜잭션을 표현하는 세 방법이 각각 어디에 위치하며, 언제 어느 것을 써야 하는가? 한 줄 답: dataSource.transaction(cb)95%의 경우고, QueryRunner콜백 밖으로 트랜잭션을 들고 나가야 할 때만, @Transaction() 데코레이터는 0.3에서 deprecated. NestJS는 typeorm-transactional 패키지로 데코레이터 표현을 살려뒀다.


Why — 왜 세 가지가 있나

트랜잭션은 *“여러 쿼리를 하나의 BEGIN/COMMIT으로 감싼다”*는 한 가지 의미를 가진다. 그런데 그 의미를 코드에 어떻게 표현하느냐는 세 갈래로 갈렸다.

표현 방식동기트레이드오프
콜백”트랜잭션 범위를 블록으로 시각화”콜백 밖으로 EntityManager를 못 보냄
명시적 객체 (QueryRunner)“트랜잭션을 변수처럼 들고 다님”release를 직접 책임짐
데코레이터”트랜잭션 경계를 메서드 시그니처로 선언”어떤 DataSource를 쓰는지 암묵적

0.3에서 데코레이터가 deprecated된 이유는 마지막 줄이다 — 멀티 DB 시대에 암묵적 DataSource 가정이 깨졌다.


How — 방법 1: dataSource.transaction(cb)기본

import { AppDataSource } from './data-source';
 
async function transfer(fromId: string, toId: string, amount: number) {
  return AppDataSource.transaction(async (manager) => {
    const from = await manager.findOne(Account, { where: { id: fromId } });
    const to   = await manager.findOne(Account, { where: { id: toId } });
 
    if (from.balance < amount) throw new InsufficientBalanceError();
 
    from.balance -= amount;
    to.balance   += amount;
 
    await manager.save([from, to]);
 
    return { from: from.balance, to: to.balance };
  });
}

동작 순서:

  1. TypeORM이 내부적으로 QueryRunner를 만들고 풀에서 연결을 빌린다.
  2. BEGIN을 보낸다.
  3. 콜백에 manager를 넘긴다 — 이 manager는 그 트랜잭션 위에서만 동작하는 EntityManager.
  4. 콜백이 정상 종료 → COMMIT.
  5. 콜백이 throw → ROLLBACK.
  6. 어떤 경로든 finally로 QueryRunner.release().

격리 수준을 지정하려면:

await AppDataSource.transaction('SERIALIZABLE', async (manager) => {
  // ...
});

이 옵션은 04 — 격리 수준에서 자세히.


How — 방법 2: QueryRunner 직접 ⭕ 필요할 때만

async function complexFlow() {
  const qr = AppDataSource.createQueryRunner();
  await qr.connect();
  await qr.startTransaction('REPEATABLE READ');
 
  try {
    // 트랜잭션 위에서 작업
    const user = await qr.manager.findOne(User, { where: { id: 'u_1' } });
    await qr.manager.save(user);
 
    // 콜백 밖으로 manager를 다른 함수에 *명시적으로* 넘긴다
    await sendNotification(qr.manager, user);
    await auditLog(qr.manager, 'user_updated', user.id);
 
    await qr.commitTransaction();
  } catch (err) {
    await qr.rollbackTransaction();
    throw err;
  } finally {
    await qr.release(); // ⚠️ 반드시 호출
  }
}
 
// 다른 함수가 manager를 *주입받아* 같은 트랜잭션 안에서 동작
async function sendNotification(manager: EntityManager, user: User) {
  await manager.save(Notification, { userId: user.id, type: 'update' });
}

언제 이걸 쓰나transaction() 콜백이 부족한 경우:

  1. EntityManager를 다른 함수에 명시적으로 넘기고 싶을 때 — 콜백 안에 비즈니스 로직 전체를 욱여넣지 않기 위해.
  2. 트랜잭션 시작과 끝이 다른 호출 사이에 있을 때 — 예: HTTP 요청 시작 시 BEGIN, 응답 직전에 COMMIT (savepoint 활용).
  3. savepoint를 수동으로 관리해야 할 때qr.query('SAVEPOINT sp1').
  4. 트랜잭션 메타데이터를 외부에서 들여다봐야 할 때qr.isTransactionActive 등.

이 패턴의 유일한 위험release() 누락 — 05 — QueryRunner 심화에서 깊이.


How — 방법 3: @Transaction() 데코레이터 ❌ deprecated

// ❌ 0.2.x — 0.3에서 deprecated
import { Transaction, TransactionManager, EntityManager } from 'typeorm';
 
class UserService {
  @Transaction()
  async createUser(
    @TransactionManager() manager: EntityManager,
    dto: CreateUserDto,
  ) {
    const user = manager.create(User, dto);
    await manager.save(user);
    return user;
  }
}

왜 deprecated인가:

  1. 어느 DataSource를 쓰는지 암묵적 — 데코레이터는 전역 EntityManager를 가정. 멀티 DB에서 결정 불가.
  2. @TransactionManager() 인자 주입이 프레임워크 마법. 표준 TypeScript 데코레이터 spec과 어긋난다.
  3. TC39 데코레이터 spec이 바뀌면서 호환성 위험 — TypeORM은 0.3에서 명시적 함수 호출 패턴으로 무게중심을 옮겼다.

0.3에서 이 데코레이터는 남아 있지만 새 코드에서 쓰지 마라제거 예고.


How — 방법 3.5: typeorm-transactional 패키지 (NestJS 진영)

NestJS 사용자들은 데코레이터 표현을 완전히 포기하지 못했다. typeorm-transactional 패키지가 그 자리를 채웠다.

import { Transactional, Propagation, IsolationLevel } from 'typeorm-transactional';
 
@Injectable()
export class TransferService {
  @Transactional({
    propagation: Propagation.REQUIRED,
    isolationLevel: IsolationLevel.REPEATABLE_READ,
  })
  async transfer(fromId: string, toId: string, amount: number) {
    // 이 메서드 안의 모든 Repository 호출이 *자동으로* 같은 트랜잭션에 묶임
    const from = await this.accountRepo.findOne({ where: { id: fromId } });
    from.balance -= amount;
    await this.accountRepo.save(from);
 
    // 다른 서비스 호출도 같은 트랜잭션
    await this.notificationService.send(fromId);
  }
}

핵심 차이 — 이 패키지는 AsyncLocalStorage현재 트랜잭션 컨텍스트를 들고 다닌다. Repository 호출이 암묵적으로 그 컨텍스트의 EntityManager를 쓴다.

특성TypeORM 내장 @Transactiontypeorm-transactional
상태❌ deprecated✅ 활발히 유지
메커니즘메서드 인자 주입AsyncLocalStorage
Propagation❌ 없음✅ REQUIRED · REQUIRES_NEW · NESTED
Multi-DataSource⭕ (옵션)
Spring @Transactional과 유사도낮음매우 높음

언제 쓰나:

  • NestJS + 여러 Service가 한 트랜잭션을 공유해야 할 때.
  • Spring 출신 개발자가 익숙한 표현을 원할 때.
  • 전파(Propagation) 시맨틱을 원할 때 — REQUIRES_NEW로 별도 트랜잭션에서 로그를 기록.

What — 세 방법의 비교

항목transaction(cb)QueryRunner@Transactiontypeorm-transactional
상태✅ 기본✅ 활발❌ deprecated✅ NestJS 표준
release자동수동자동자동
격리 수준 지정첫 인자startTransaction(level)데코레이터 인자옵션
트랜잭션 범위콜백 안BEGIN ~ COMMIT 명시메서드 전체메서드 전체
manager 전달콜백 인자qr.manager 변수@TransactionManager 주입AsyncLocalStorage
여러 함수 분할⚠️ manager를 넘겨야 함✅ 명시⚠️ 데코레이터 중첩 모호✅ 자동
Propagation수동 (savepoint)
테스트 격리✅ 쉬움⚠️ 전역 가정
추천도⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐ (NestJS만)

What — 결정 트리


What-if — 트랜잭션 밖에서 save하면

가장 흔한 실수.

// ❌ manager가 아니라 *원래 Repository*를 사용
await AppDataSource.transaction(async (manager) => {
  const user = await userRepo.findOne({ where: { id: 'u_1' } });
  //                  ^^^^^^^^ — 트랜잭션 밖의 Repository!
  user.balance -= 100;
  await userRepo.save(user);
  //    ^^^^^^^^ — 이 save는 트랜잭션 *밖에서* 일어난다
});

userRepoDataSource에서 만든 기본 Repository다. 콜백 인자로 받은 manager별개다. userRepo.save()풀에서 다른 연결을 빌려 자기만의 트랜잭션(autocommit)에서 실행된다.

증상: 콜백 안의 다른 작업이 throw로 rollback돼도 — 이 save는 이미 commit돼서 데이터가 남는다. 반쪽짜리 일관성.

정답:

// ✅ 콜백 안에서는 *오직 manager*만 쓴다
await AppDataSource.transaction(async (manager) => {
  const user = await manager.findOne(User, { where: { id: 'u_1' } });
  user.balance -= 100;
  await manager.save(user);
});

이 함정이 흔한 이유 — NestJS의 Repository DI가 트랜잭션을 자동으로 따라가지 않는다. typeorm-transactional이 등장한 진짜 이유다.


Insight — 왜 콜백 패턴이 기본인가

세 가지가 다 동등한 표현인데, 왜 0.3은 콜백 패턴을 기본으로 밀었나?

답은 release 보장이 언어 메커니즘으로 자동화되기 때문이다.

  • 콜백 = 함수 호출 스택의 자연스러운 경계. throw가 위로 전파되면 그 위의 finally가 cleanup한다.
  • QueryRunner = 변수 lifecycle. 변수가 들고 다니는 객체의 정리를 개발자가 책임진다.
  • 데코레이터 = 메서드 시그니처. 메서드의 entry/exit를 가로채야 하므로 프레임워크 마법이 필요.

0.3의 베팅: “표현은 단순하게, lifecycle은 언어에 맡긴다”. 이게 콜백이 기본이 된 이유다.


흥미로운 이야기 — Spring @Transactional이 30년 동안 살아남은 이유

자바 진영에서 @Transactional사실상 표준이다. Spring이 이 데코레이터 패턴을 *AOP(Aspect-Oriented Programming)*로 구현했고, AsyncLocalStorage에 해당하는 ThreadLocal로 트랜잭션 컨텍스트를 들고 다닌다.

TypeORM이 0.2에서 시도한 @Transaction그 표현은 베꼈지만 메커니즘은 못 베꼈다 — Node.js의 단일 스레드 + 비동기 모델에서는 동기 ThreadLocal이 없기 때문이다.

해결책으로 등장한 게 Node.js의 AsyncLocalStorage (Node 14+). 비동기 컨텍스트를 따라가는 storage. typeorm-transactional 패키지가 이것을 활용해서 Spring 스타일을 Node.js에서 재현했다.

즉, @Transaction이 deprecated된 자리에 typeorm-transactional이 들어선 게 아니다 — 그 자리는 AsyncLocalStorage가 가능해진 시점에야 비로소 제대로 채울 수 있었다.


요약 (Pyramid Top 재정렬)

트랜잭션 표현 방식은 세 갈래로 나뉘었다가 두 개로 수렴했다.

  1. dataSource.transaction(cb) — 95%의 경우. release 자동, 격리 수준은 첫 인자.
  2. QueryRunner 직접 — 콜백 밖으로 manager를 들고 나가야 할 때만. try/finally 강제.
  3. @Transaction() 데코레이터deprecated. 새 코드에서 쓰지 마라.
  4. typeorm-transactional (NestJS) — 데코레이터 표현이 진짜로 필요한 자리. AsyncLocalStorage가 받쳐준다.

다음 문서는 이 트랜잭션의 강도를 정하는 격리 수준을 다룬다.

다음: 04 — 격리 수준READ COMMITTED / REPEATABLE READ / SERIALIZABLE.