TypeORM QueryRunner vs @Transactional

2025. 1. 5. 16:35Framework/NestJS

최근 사내에서 도입한 NestJS 프레임워크의 ORM인 TypeORM을 사용하며, 트랜잭션 관리에 대한 솔직한 피드백을 공유하고자 합니다.


TypeORM을 선택한 이유

TypeORM의 트랜잭션 관리에 대한 이야기를 하기 전에 간단히 현 개발 조직에서 다른 ORM을 두고 TypeORM을 선택하게 된 이유에 대해 주관적인 생각을 정리해보려고 합니다. 

 

현재 개발조직에서 Prisma와 같은 다른 ORM이 있음에도 TypeORM을 선택한 이유는 TypeORM이 좀 더 SQL 친화적(?)일기 때문이라고 생각합니다. Prisma의 경우 아래 예시 처럼 간단 한 CRUD 작업을 하는 것에는 문제가 없지만, 복잡한 쿼리가 필요한 경우는 raw query기능을 사용해야하는것 말고는 좋은 대안이 없다는게 결론이 었습니다. 

const users = await prisma.user.findMany({
  where: {
    OR: [
      {
        AND: [
          { age: { gt: 30 } },
          { name: { startsWith: 'A' } },
        ],
      },
      {
        email: { contains: '@example.com' },
      },
    ],
  },
});

 

 

반면, TypeORM의 경우 QueryBuilder 를 제공하기 때문에 복잡한 쿼리를 유연하게 작성하는 것이 가능합니다. 뿐만아니라 개발 조직 내에서 TypeORM이 좀더 readable 한 코드라고 판단했기 때문에 TypeORM을 선택한 것 같습니다. 

const users = await getRepository(User)
  .createQueryBuilder('user')
  .leftJoinAndSelect('user.profile', 'profile')
  .where('user.age > :age', { age: 30 })
  .andWhere('user.name LIKE :name', { name: 'A%' })
  .orWhere('user.email LIKE :email', { email: '%@example.com%' })
  .getMany();

TypeORM의 트랜잭션 관리 

TypeORM에서 트랜잭션을 관리하는 방법으로는 QueryRunner를 직접 사용하는 방법과 외부 모듈인 typeorm-transactional 라이브러리를 활용하는 방법이 있습니다.

 

첫번째로, QueryRunner는 단일 데이터베이스 연결을 제공하며, 이를 통해 트랜잭션의 시작, 커밋, 롤백등을 수동을 제어할 수 있도록 한다는 점에서 typeorm-transactional 과 차이가 있습니다. 

import { DataSource } from 'typeorm';

async function saveWithQueryRunner(dataSource: DataSource, user: User) {
  const queryRunner = dataSource.createQueryRunner();
  await queryRunner.connect();
  await queryRunner.startTransaction();
  try {
    await queryRunner.manager.save(user);
    // 추가적인 데이터베이스 작업...
    await queryRunner.commitTransaction();
  } catch (error) {
    await queryRunner.rollbackTransaction();
    throw error;
  } finally {
    await queryRunner.release();
  }
}

 

QueryRunner의 가장 큰 장점은 트랜잭션의 시작부터 종료까지 모든 과정을 명시적으로 제어할 수 있어 세민한 관리가 가능하다는게 가장 큰 장점이지만, 반대로 반복적인 코드 패턴이 발생해 가독성이 떨어 질 수 있고 트랜잭션 관리 코드가 비즈니스 로직과 혼재되어 코드의 복잡성이 증가 할 수 있다는 단점이 있습니다. 

 

NestJS TypeORM Transactions 의 설명에 의하면 공식적으로는 QueryRunner를 TypeORM의 트랜잭션 관리 방법으로 쓰는 것을 권장하고 있습니다. 이유는 아마 트랜잭션을 수동으로 통제할 수 있다는 강력한 장점 때문인것 같지만, 외부 라이브러리인 typeorm-transactional 같은 모듈을 써본 입장에서 왜 추천하고 있는지 알것만(?) 같았습니다. 

 

두번째로, typeorm-transactional 라이브러리를 사용하는 방법은 데코레이터를 활용해서 트랜잭션 관리를 간소화하는 방법입니다. 

nestjs에서 데코레이터는 Spring framework의 어노테이션(@Annotaion) 철학을 그대로 모방(?)했다고 할 수 있는 기술 입니다. 

import { Transactional } from 'typeorm-transactional';

class UserService {
  @Transactional()
  async createUser(user: User) {
    await this.userRepository.save(user);
    // 추가적인 데이터베이스 작업...
  }
}

 

데코레이터를 사용해서 트랜잭션 관리를 선언적으로 처리할 수 있어 비교적 코드가 간결해지고 반복적인 트랜잭션 관리 코드를 줄여 가독성이 향상된다는 점에서 큰 장점이 있지만, 아쉽게도 트랜잭션의 세밀한 제어나 복잡한 트랜잭션 로직을 구현한 경우에 한계가 있을 수 있고 무엇보다 외부 라이브러리에 대한 의존성이 생긴다는 치명적인 단점이 있습니다. 

 

정리하자면, 

QueryRunner: 트랜잭션 관리를 개발자가 명시적으로 제어함으로써 데이터베이스 작업에 대한 완전한 통제권을 부여합니다. 이는 세밀한 제어가 필요한 상황에서 유용하지만, 반복적인 코드 작성과 복잡성을 증가시킬 수 있습니다.

typeorm-transactional: 데코레이터를 통한 선언적 트랜잭션 관리를 지향하며, 이는 코드의 간결성과 가독성을 높입니다. 그러나 자동화된 관리로 인해 세밀한 제어가 필요한 경우에는 제한이 있을 수 있습니다.

typeorm-transactional 의존성 사용과 솔직한 피드백

현재 개발 조직에서는 QueryRunncer를 사용해서 트랜잭션을 관리하는 팀도 있었지만, 우리 팀은 typeorm-transactional 데코레이터를 사용해서 트랜잭션을 관리하는 방법을 사용해서 개발을 하기로 결정했습니다. 다만, NestJS를 사용하기 시작한지 채 2달도 되지 않고 의존성에 대한 제대로된 이해가 부족한 상황에서 사용했기 때문에 몇가지 문제가 발생했습니다. 

 

그 중 가장 큰 문제는 예외나 오류가 발생할 때 롤백(rollabck)되지 않아 데이터베이스 상에 데이터가 제거되지 않고 남아있는 현상을 확인했습니다. (이 문제를 인지하기 까지 삽질이 조금 있었습니다..설마 트랜잭션 롤백이 안되서 생긴 문제라고는 생각하지 못했으니까요...)

 

그래서 만약 typeorm-transactional 의존성을 선택한다면 반드시 인지하고 있어야하는 몇가지 특이사항에 대해 정리한 내용을 공유 해보려고 합니다. 

 

1. 어플리케이션의 루트 모듈에서 데이터베이스 연결을 설정할 때 주의사항

 

먼저 잘못된 사용예시를 보겠습니다. 

# app.module.ts

@Module({
  imports: [
    TypeOrmModule.forRoot({
      ...getOrmConfig(),
      entities: ['dist/modules/**/entities/*.entity{.ts,.js}'],
    }),
    TypeOrmModule.forRootAsync({
      useFactory() {
        return {
          ...getOrmConfig(),
          entities: ['dist/modules/**/entities/*.entity{.ts,.js}'],
        };
      },
      async dataSourceFactory(options) {
        if (!options) {
          throw new Error('Invalid options passed');
        }

        return (
          getDataSourceByName('default') ||
          addTransactionalDataSource(new DataSource(options))
        );
      },
    }),
  controllers: [AppController],
  providers: [AppService],
})

 

 

NestJS에서 데이터베이스 연결을 구성할 때, app.module.ts 파일에서 TypeOrmModule 클래스를 활용합니다. TypeOrmModule.forRoot와 TypeOrmModule.forRootAsync는 각각 동기식과 비동기식으로 데이터베이스 설정을 주입하는 메서드입니다. 설정 정보가 하드코딩되어 있다면 전자를, 환경 변수 등을 통해 비동기적으로 로드해야 하는 경우에는 후자를 사용할 수 있습니다.

 

해당 코드에는 두가지 문제점이 있습니다. 하나는 데이터베이스 연결 구성을 두번 했다는 점과, 다른 하나는 typeorm-transactional 라이브러리를 사용하는 경우 Datasource를 수동으로 등록해야하는데 해당 과정이 누락되었다는 점입니다. 

 

먼저 데이터베이스 연결을 구성을 두 번 하게 되면 트랜잭션이 롤백을 해야하는 시점에 어떤 DataSource를 참고해야할지 선택하는 과정에서 폭파(?) 될 수 있다는 점입니다. 반드시 구성정보는 더더말고 1번만 합니다. 

 

두번째 문제는 typeorm-transactional를 사용하는 경우는 DataSource를 수동으로 등록해야 합니다. 수동으로 DataSource를 등록한다는 말의 의미는 "DataSource"를 직접 생성하고 초기화한 후, NestJS의 의존성 주입 시스템에 등록하여 사용해야한다는 것을 의미합니다. 

 

https://github.com/Aliheym/typeorm-transactional

 

아래 코드 처럼 addTransactionalDataSource 메서드를 사용해서 수동으로 DataSource를 등록해야 합니다. 

 

# app.module.ts

@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      useFactory: () => ({
        type: 'mysql',
        host: process.env.DB_HOST || 'localhost',
        port: Number(process.env.DB_PORT) || 3306,
        username: process.env.DB_USERNAME || 'root',
        password: process.env.DB_PASSWORD || 'rootpw',
        database: process.env.DB_DATABASE || 'nestjs_test',
        entities: [Board],
        synchronize: true,
        logging: true,
      }),
    }),
  ],

  controllers: [AppController],
  providers: [AppService, DataSourceInitializer],
})
export class AppModule {}

# datasource.initializer.ts

@Injectable()
export class DataSourceInitializer implements OnApplicationBootstrap {
  constructor(private readonly dataSource: DataSource) {}

  onApplicationBootstrap() {
    addTransactionalDataSource(this.dataSource);
  }
}

 

2. Transactional 데코레이터는 반드시 비동기 호출

 

두 번째 주의사항은 @Transactional는 반드시 비동기로 선언된 메서드에 대해서 사용가능 합니다. 

typeorm-transactional 오픈소스를 확인해보면 @Transactional 데코레이터가 WrapInTransactionOptions 인터페이스를 구현하고 있음을 확인할 수 있습니다. 트랜잭션을 관리하는 transactionCallback 메서드는 비동기로 선언된것을 확인할 수 있습니다. 때문에 반드시 @Transactional 는 비동기로 선언된 메서드에서 사용하시기 바랍니다. 

 

https://github.com/Aliheym/typeorm-transactional/blob/master/src/transactions/wrap-in-transaction.ts#L57

 

아래는 사용 예시입니다. 

# 예시) board.service.ts

@Injectable()
export class BoardService {
  private readonly logger = new Logger(BoardService.name);

  constructor(
    @InjectRepository(Board)
    private readonly boardRepository: Repository<Board>,
  ) {}

  @Transactional()
  async createBoard(createBoardDto: CreateBoardDto): Promise<Board> {
    const { title, content } = createBoardDto;
    
    // 새로운 Board 엔티티 인스턴스 생성
    const board = this.boardRepository.create({
      title,
      content,
    });
    
    await this.boardRepository.save(board);

    return board;
  }
}

 

이 외에도 여러 가지 특이사항들이 추가로 발견되면 해당 포스팅을 추가로 업데이트 하겠습니다. 긴글 읽어 주셔서 감사합니다.