NestJS + TypeORM 트랜잭션

TypeORM 트랜잭션의 두 가지 방법: DataSource.transaction vs QueryRunner

NestJS에서 TypeORM을 사용할 때 트랜잭션 처리는 “어떻게 경계를 잡느냐”에 따라 코드 구조와 에러 처리가 크게 달라집니다. TypeORM 공식 문서는 트랜잭션을 다루는 두 가지 명시적 방법을 제공하며, 이 글에서는 각각의 동작 방식·제약·NestJS 서비스 레이어에서의 적용 패턴을 공식 문서 근거만으로 정리합니다.

1. 방법 A: DataSource.transaction() — 콜백 기반 트랜잭션

TypeORM 공식 문서(Transactions 섹션)에서 첫 번째로 소개하는 방법입니다. DataSource 또는 EntityManager에서 .transaction()을 호출하고, 콜백 안에서 전달받은 transactionalEntityManager를 사용합니다.

await myDataSource.transaction(async (transactionalEntityManager) => {
    await transactionalEntityManager.save(users);
    await transactionalEntityManager.save(photos);
});

공식 문서가 강조하는 가장 중요한 제약:

“The most important restriction when working in a transaction is to ALWAYS use the provided instance of entity manager — transactionalEntityManager in this example. DO NOT USE GLOBAL ENTITY MANAGER. All operations MUST be executed using the provided transactional entity manager.”

즉, 콜백 안에서 주입받은 transactionalEntityManager 대신 글로벌 EntityManagerRepository를 쓰면 해당 작업은 트랜잭션 밖에서 실행됩니다. 이 점이 NestJS 서비스 레이어에서 가장 흔한 실수의 원인입니다.

Isolation Level 지정

문서에 따르면 첫 번째 인자로 격리 수준을 지정할 수 있습니다:

await myDataSource.manager.transaction(
    "SERIALIZABLE",
    async (transactionalEntityManager) => {
        // ...
    },
);

지원되는 격리 수준은 드라이버마다 다릅니다:

DB 지원 격리 수준
MySQL, PostgreSQL, SQL Server READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE
SQLite 기본 SERIALIZABLE (shared cache 모드에서 READ UNCOMMITTED 가능)
Oracle READ COMMITTED, SERIALIZABLE만 지원

2. 방법 B: QueryRunner — 수동 커넥션·트랜잭션 제어

TypeORM 공식 문서의 두 번째 방법은 QueryRunner를 직접 생성하여 단일 데이터베이스 커넥션을 확보하고, 그 위에서 트랜잭션을 수동 제어하는 방식입니다.

const queryRunner = dataSource.createQueryRunner();

// 실제 DB 커넥션 확보
await queryRunner.connect();

// 트랜잭션 시작
await queryRunner.startTransaction();

try {
    await queryRunner.manager.save(user1);
    await queryRunner.manager.save(user2);
    await queryRunner.manager.save(photos);

    // 커밋
    await queryRunner.commitTransaction();
} catch (err) {
    // 롤백
    await queryRunner.rollbackTransaction();
} finally {
    // 반드시 release — 커넥션 풀에 반환
    await queryRunner.release();
}

공식 문서에 명시된 QueryRunner 트랜잭션 API는 3개입니다:

  • startTransaction() — 트랜잭션을 시작
  • commitTransaction() — 변경 사항을 커밋
  • rollbackTransaction() — 변경 사항을 롤백

핵심 차이점: QueryRunner는 “단일 DB 커넥션”을 직접 잡고 있으므로, 해당 커넥션 위에서 raw query(queryRunner.query("SELECT ..."))와 EntityManager 작업(queryRunner.manager)을 혼합할 수 있습니다. 또한 release()를 반드시 호출해야 커넥션이 풀로 돌아갑니다.

3. 두 방법의 실무 비교

비교 항목 DataSource.transaction() QueryRunner
커넥션 관리 자동 (내부에서 커넥션 획득·반환) 수동 (connect → release 필수)
커밋/롤백 자동 (콜백 성공 시 커밋, 예외 시 롤백) 수동 (commit/rollback 직접 호출)
Raw SQL 혼합 transactionalEntityManager.query() 가능 queryRunner.query() 가능 + 같은 커넥션 보장
격리 수준 지정 첫 번째 인자로 지정 가능 (문서 명시) startTransaction(“SERIALIZABLE”) 형태로 지정 가능
실수 위험 글로벌 EM/Repository 사용 시 트랜잭션 이탈 release() 누락 시 커넥션 누수
적합한 상황 단순한 도메인 로직 트랜잭션 DDL·raw query 혼합, 세밀한 커넥션 제어 필요 시

4. NestJS 서비스에서 DataSource.transaction() 패턴 적용

NestJS에서 TypeORM을 쓸 때 DataSource는 DI로 주입할 수 있습니다. 아래는 공식 문서의 콜백 패턴을 NestJS 서비스에 적용한 구조입니다.

import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { User } from './user.entity';
import { Profile } from './profile.entity';

@Injectable()
export class UserService {
  constructor(private readonly dataSource: DataSource) {}

  async createUserWithProfile(dto: CreateUserDto) {
    return this.dataSource.transaction(async (manager) => {
      const user = manager.create(User, { name: dto.name });
      await manager.save(user);

      const profile = manager.create(Profile, {
        bio: dto.bio,
        user,
      });
      await manager.save(profile);

      return user;
    });
  }
}

주의: 콜백 안에서는 this.userRepository 같은 DI 주입된 Repository를 쓰면 안 됩니다. 그 Repository는 글로벌 EntityManager에 묶여 있어 트랜잭션 밖에서 실행됩니다. 반드시 manager(transactionalEntityManager)를 사용해야 합니다.

5. NestJS 서비스에서 QueryRunner 패턴 적용

QueryRunner는 더 세밀한 제어가 필요할 때, 또는 트랜잭션 중간에 raw query를 실행해야 할 때 유용합니다.

import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Order } from './order.entity';
import { Inventory } from './inventory.entity';

@Injectable()
export class OrderService {
  constructor(private readonly dataSource: DataSource) {}

  async placeOrder(dto: PlaceOrderDto) {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // raw query로 재고 차감 (SELECT FOR UPDATE)
      await queryRunner.query(
        `UPDATE inventory SET stock = stock - $1
         WHERE product_id = $2 AND stock >= $1`,
        [dto.quantity, dto.productId],
      );

      // EntityManager로 주문 생성
      const order = queryRunner.manager.create(Order, {
        productId: dto.productId,
        quantity: dto.quantity,
      });
      await queryRunner.manager.save(order);

      await queryRunner.commitTransaction();
      return order;
    } catch (err) {
      await queryRunner.rollbackTransaction();
      throw err;
    } finally {
      await queryRunner.release();
    }
  }
}

release()를 finally에 두는 이유: 커밋이든 롤백이든 예외가 발생하든, 커넥션은 반드시 풀로 반환되어야 합니다. finally 블록에 넣지 않으면 예외 경로에서 커넥션 누수가 발생할 수 있습니다.

6. “글로벌 Repository를 트랜잭션 안에서 쓰면 안 되는” 이유 상세

TypeORM의 Repository는 생성 시점에 특정 EntityManager에 바인딩됩니다. NestJS에서 @InjectRepository()로 주입받은 Repository는 글로벌(기본) EntityManager에 연결되어 있습니다.

DataSource.transaction()의 콜백에서 전달되는 transactionalEntityManager별도의 트랜잭션 전용 EntityManager입니다. 따라서:

  • transactionalEntityManager.save(entity) → 트랜잭션 안에서 실행 ✅
  • this.userRepository.save(entity) → 글로벌 EM → 트랜잭션 밖에서 실행 ❌

이 차이를 모르면 “트랜잭션으로 감쌌는데 일부 작업만 롤백되지 않는” 문제가 발생합니다. 이것은 TypeORM 공식 문서에서 대문자·볼드로 경고하는 내용입니다.

QueryRunner에서도 마찬가지로, 반드시 queryRunner.manager를 통해 작업해야 같은 커넥션(=같은 트랜잭션)에서 실행됩니다.

7. N+1 문제와 성능: 트랜잭션 밖에서도 알아야 할 것

TypeORM 공식 문서의 Performance and Optimization 섹션에서는 다음을 강조합니다:

  • N+1 문제 회피: leftJoinAndSelect 또는 innerJoinAndSelect로 한 번의 쿼리에서 연관 엔티티를 함께 가져오기
  • 필요한 컬럼만 선택: select()로 필요한 필드만 지정하거나 getRawMany()로 raw 데이터만 가져오기
  • Lazy vs Eager Loading: eager: true는 항상 JOIN을 실행하므로 연관 데이터가 항상 필요한 경우에만 사용. 그렇지 않으면 lazy: true로 필요 시점에 로딩

트랜잭션 범위를 넓게 잡을수록 이런 성능 이슈의 영향도 커집니다. 트랜잭션 안에서 N+1 쿼리가 발생하면 락 보유 시간이 늘어나 동시성 성능에 직접적인 악영향을 줍니다.

8. 실무 체크리스트: 트랜잭션 안전하게 쓰기

  • DataSource.transaction() 콜백 안에서는 전달받은 manager만 사용
  • QueryRunnerfinally에서 반드시 release()
  • ✅ DI 주입된 Repository를 트랜잭션 콜백 안에서 직접 쓰지 말 것
  • ✅ 격리 수준은 DB별 지원 범위 확인 후 지정
  • ✅ 트랜잭션 범위는 최소한으로 — 락 보유 시간을 줄여 동시성 확보
  • ✅ 연관 엔티티 로딩은 JOIN으로 한 번에 — 트랜잭션 안 N+1 방지

참고 (원문 근거)

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux