NestJS에서 트랜잭션 책임을 어디에 둬야 할까?

Seung Hyeon ·2026년 1월 18일

개발 스터디

목록 보기
28/28
post-thumbnail

🤔 고민의 시작

개발 초기에 API를 만들면서 가장 많이 고민했던 부분은 트랜잭션을 어느 레이어에 적용해야 하는가였다.

하나의 비즈니스 로직에 읽기+쓰기 , 또는 쓰기+쓰기 가 함께 존재할 경우 당연히 트랜잭션이 필요하다.
나는 NestJS에서 관심사 분리를 위해 Controller, Service, Repository 레이어로 나눠서 개발하고 있었기 때문에, 자연스럽게 Service에서 트랜잭션을 시작하고 Repository의 메서드들을 호출하는 구조를 떠올렸다.

그런데 막상 적용해보니 레이어를 분리하면 트랜잭션이 제대로 동작하지 않았다..
예시들을 찾아보니 대부분 QueryRunner 를 사용해서 Service 안에서 모든 것을 처리하고 있었다.
아래 코드처럼 connect, startTransaction, commit, rollback, release까지 전부 한 곳에서 처리했다.
Repository 패턴을 유지하면서 트랜잭션을 구현한 예시는 쉽게 찾아볼 수 없었다. (내가 못 찾은 걸 수도 있지만)

[트랜잭션 동작 O] Service 내부에서 직접 처리

async createNotice(dto: CreateNoticeDto) {
  const { title, content, tag } = dto;
  const queryRunner = this.dataSource.createQueryRunner();
  await queryRunner.connect();
  await queryRunner.startTransaction();
  try {
    await queryRunner.manager.save(NoticeEntity, { title, content });
    await queryRunner.manager.save(TagEntity, { tag });
    await queryRunner.commitTransaction();
  } catch (err) {
    await queryRunner.rollbackTransaction();
    throw err;
  } finally {
    await queryRunner.release();
  }
}

[트랜잭션 동작 X] Repository로 분리한 경우

// notice.service.ts
async createNotice(dto: CreateNoticeDto): Promise<void> {
  const { title, content, tag } = dto;
  const queryRunner = this.dataSource.createQueryRunner();
  await queryRunner.connect();
  await queryRunner.startTransaction();
  try {
    await this.noticeRepository.createNotice(title, content);
    await this.tagRepository.createTag(tag);
    await queryRunner.commitTransaction();
  } catch (err) {
    await queryRunner.rollbackTransaction();
    throw err;
  } finally {
    await queryRunner.release();
  }
}

// notice.repository.ts
async createNotice(title: string, content: string): Promise<InsertResult> {
  return await this.noticeModel.createQueryBuilder()
    .insert()
    .into(NoticeEntity)
    .values({ title, content })
    .execute();
}

얼핏 보면 동작할 것 같지만, Repository 메서드들은 Service에서 생성한 QueryRunner의 트랜잭션 컨텍스트를 전혀 알지 못한다.
각자 별개의 커넥션을 사용하기 때문에 트랜잭션으로 묶이지 않는 것이다.

그렇다면 Repository 패턴을 포기하고 Service에 쿼리 로직을 다 넣어야 할까?
아니면 트랜잭션 컨텍스트를 Repository까지 전달할 방법이 있을까?


Controller에서 트랜잭션 책임 관리하기

위의 QueryRunner 로직은 다음과 같은 불편함이 있었다.

  • 트랜잭션이 필요한 모든 메서드에는 connect, startTransaction, commit, rollback, release 코드가 반복된다.
  • 비즈니스 로직이 트랜잭션 관리 코드에 묻혀버려 가독성이 떨어지고 함수가 복잡해진다.

이 문제를 해결하기 위해, 반복되는 트랜잭션 코드를 Interceptor로 분리했다.

TransactionInterceptor

Interceptor는
요청이 Controller에 도달하기 전에 트랜잭션을 시작하고, 요청 처리가 완료된 후 결과에 따라 커밋/롤백을 수행한다.

이를 통해 트랜잭션 관리 로직을 한 곳에서 처리할 수 있었다.

// transaction.interceptor.ts
@Injectable()
export class TransactionInterceptor implements NestInterceptor {
  constructor(private readonly dataSource: DataSource) {}

  async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    // Request 객체에 EntityManager를 저장하여 Controller에서 접근 가능하게 함
    const request = context.switchToHttp().getRequest();
    request.queryRunnerManager = queryRunner.manager;

    return next.handle().pipe(
      // 에러 발생 시 롤백
      catchError(async (e) => {
        await queryRunner.rollbackTransaction();
        await queryRunner.release();
        throw e instanceof HttpException ? e : new InternalServerErrorException();
      }),
      // 성공 시 커밋
      tap(async () => {
        await queryRunner.commitTransaction();
        await queryRunner.release();
      }),
    );
  }
}

TransactionManager 데코레이터

Interceptor가 Request 객체에 저장한 EntityManager를 Controller의 파라미터로 꺼내오는 역할을 한다.

// transaction.decorator.ts
export const TransactionManager = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const req = ctx.switchToHttp().getRequest();
    return req.queryRunnerManager;
  }
);

사용 예시

Controller에서 @UseInterceptors로 트랜잭션을 적용하고, @TransactionManager()로 EntityManager를 받아 Service에 전달한다.

Service는 전달받은 EntityManager를 Repository에 넘겨주고, Repository는 이를 통해 DB 작업을 수행한다.
(즉, EntityManager가 Controller → Service → Repository로 전달되는 구조)

덕분에 Service와 Repository는 트랜잭션 관리 코드 없이 각자의 역할에만 집중할 수 있다.

// Controller
@UseInterceptors(TransactionInterceptor)
@Post('/')
async createNotice(
  @Body() dto: CreateNoticeDto,
  @TransactionManager() manager: EntityManager,
) {
  await this.noticeService.createNotice(dto, manager);
}

// Service
async createDiary(dto: CreateDiaryDto, manager: EntityManager): Promise<void> {
  await this.diaryRepository.createDiary(dto, userId, manager);
}

// Repository
async createDiary(dto: CreateDiaryDto, userId: number,  manager: EntityManager) {
    return await manager
       .createQueryBuilder()
       .insert()
       .into(DiaryEntity)
       .values(dto)
       .execute();
}

한계점

이 방식에는 몇 가지 한계가 있었다.

  • 모든 Service 메서드에 EntityManager를 인자로 전달해야 하고, Service 간 호출 시(예: DiaryService → UserService)에도 매번 전달해야 하는 번거로움이 있다.
  • 스케줄러처럼 Controller를 거치지 않는 경우에는 이 방식을 적용할 수 없다.
  • Controller에 트랜잭션 관련 데코레이터가 계속 늘어나면 Controller 본연의 역할이 흐려질 수 있다.

신입 초기에는 이 방식을 줄곧 사용해왔지만, 이러한 한계점 때문에 결국 Controller가 아닌 Service에 트랜잭션 책임을 두는 방식을 찾아보았고, 아래와 같은 방법을 발견했다.

AsyncLocalStorage

▶️ AsyncLocalStorage를 이용해 Transaction 관심사 분리하기 (lines 40% 감소)
▶️ AsyncLocalStorage를 이용한 트랜잭션 데코레이터 구현
▶️ Async Local Storage 공식 Doc

솔직히 현재로선 무슨 말인지 전혀 몰랐다..
(nest.js와 PrismaORM 트랜잭션 여행기 #1 AsyncLocalStorage로 트랜잭션을 전파해보자 → 그나마 이 글이 AsyncLocalStorage의 개념에 대해서 이해가 잘 간 내용이었다.)

서비스에 Transaction 데코레이터를 만들어서 orm별로 트랜잭션을 다르게 적용하는 방법은 분명히 매력적이었지만, 코드가 상당히 복잡해보여 이해가 잘 안된 채로 실제로 나의 로직에 적용시키란 쉽지 않아보였다.

AsyncLocalStorage란?
AsyncLocalStorage는 Node.js에서 제공하는 기능으로, 비동기 작업이 진행되는 동안 접근 가능한 독립적인 저장 공간을 만들어준다.

쉽게 비유하자면, 식당에서 각 테이블마다 주문서를 따로 관리하는 것과 같다. 테이블 A의 주문서와 테이블 B의 주문서는 서로 섞이지 않고 각 테이블의 직원들은 자신의 테이블 주문서에만 접근할 수 있다.

마찬가지로 AsyncLocalStorage를 사용하면:

  • 각 HTTP 요청마다 독립된 저장 공간이 생긴다.
  • 그 요청 안에서 실행되는 모든 함수(Service, Repository)는 같은 저장 공간에 접근할 수 있다.
  • 다른 요청의 저장 공간과는 섞이지 않는다.

어떻게 동작하나?

이 방식은 AOP(관점 지향 프로그래밍) 를 따른다.
AOP는 핵심 비즈니스 로직과 공통 관심사(트랜잭션, 보안, 로깅 등)를 분리하는 프로그래밍 패러다임이다. 데코레이터를 사용해 트랜잭션 로직을 비즈니스 로직에서 완전히 분리하여 단일 책임 원칙을 지킬 수 있게 된다.

기존에는 EntityManager를 함수 인자로 계속 전달해야 했다면,
AsyncLocalStorage를 사용하면:

  1. Service 메서드에 @Transactional() 데코레이터를 붙인다.
  2. 데코레이터가 트랜잭션을 시작하고, EntityManager를 AsyncLocalStorage에 저장한다
  3. Repository에서 필요할 때 AsyncLocalStorage에서 EntityManager를 꺼내 쓴다

즉, Controller → Service → Repository로 manager를 파라미터로 전달할 필요 없이, 필요한 곳에서 알아서 꺼내 쓸 수 있다.

// Service에 데코레이터만 붙이면
@Transactional('typeorm')
async createDiary(dto: CreateDiaryDto) {
  // Repository에서 자동으로 트랜잭션이 적용된 Repository를 가져옴
  const repo = getLocalStorageRepository(DiaryEntity);
  await repo.save(dto);
}

// transaction.decorator.ts
export function getLocalStorageRepository<T extends ObjectLiteral>(
  target,
): Repository<T> {
  const queryRunner = queryRunnerLocalStorage.getStore();
  return queryRunner?.qr?.manager.getRepository(target);
}
export function getSession(): ClientSession {
  const session = sessionLocalStorage.getStore();
  return session?.session;
}

코드를 보면, 위 블로그의 저자는

  • getLocalStorageRepository() 함수를 만든 뒤,
  • Service 내부에서 해당 함수를 직접 호출해서,
  • TypeORM의 기본 Repository를 가져다 쓰는 방식을 사용하고 있었다.

즉, 별도의 Repository 클래스를 두지 않고 Service 레이어에서 모든 데이터 접근 로직을 처리하는 구조였다.

트랜잭션 관심사를 독립적으로 분리한다는 측면에서는 내가 원하는 방향과 비슷했지만,

  • Repository 패턴을 적용해 Service가 데이터베이스 구현 세부사항을 알지 않도록 하고
  • 데이터 접근 로직을 별도의 계층으로 분리하고자 했던 나의 요구사항을 충족시키지는 못했다.

그리고 이 방식은 구현이 복잡하고, AsyncLocalStorage의 동작 원리를 우선 충분히 이해해야만 적용할 수 있어 실제 프로젝트에 바로 적용하기에는 부담스러웠다.

dataSource.transaction( )

NestJS + TypeORM 공식 가이드에서는 트랜잭션 방식으로 dataSource.transaction() 을 권장한다고 한다.

DataSource 완전 정복 가이드

위 글을 참고하여 queryRunner 방식과 비교했을 때,

  • 코드 간결성 / 가독성
  • 오류 방지 / 실수 방지
  • 중첩된 try-catch 에러 처리 불필요

가 장점이라고 한다.

실제로 구현해보았을 때, 확실히 queryRunner 방식보다 코드의 간결성이 더욱 좋아졌고, dataSource.transaction() 메서드가 별도 start, commit, rollback, release 작성 없이 내부적으로 알아서 처리해주니까 편리했다.

// notice.repository.ts
  @Injectable()
  export class NoticeRepository {
    async createNotice(dto: CreateNoticeDto, userId: number, manager: EntityManager) {
      const result = await manager
        .createQueryBuilder()
        .insert()
        .into(NoticeEntity)
        .values({ ...dto, userId })
        .execute();
    }

    async saveNoticeFile(
      noticeId: number,
      fileUrl: string,
      manager: EntityManager,
    ) {
      return await manager
        .createQueryBuilder()
        .insert()
        .into(NoticeFileEntity)
        .values({ noticeId, fileUrl })
        .execute();
    }

  // notice.service.ts
  @Injectable()
  export class NoticeService {
    constructor(
      private readonly noticeRepository: NoticeRepository,
      private readonly dataSource: DataSource,
    ) {}

    async createNotice(
      dto: CreateNoticeDto,
      file: Express.Multer.File[],
      userId: number,
    ):Promise<void> {
      return await this.dataSource.transaction(async (manager) => {
        // DB 접근1
        const notice = await this.noticeRepository.createNotice(dto, userId, manager);
		// DB 접근2
        await this.noticeRepository.saveNoticeFile(
          notice.id,
          file[0].filename,
          manager,
        );
      });
    }
  }

다만, 몇가지 아쉬운 점도 존재했다.

여전히 EntityManager를 인자로 전달해야 한다는 점은 초기의 Controller 기반 방식과 크게 다르지 않았다. (물론 Controller에서부터 전달하던 구조에 비하면, 트랜잭션 책임이 Service 레이어로 내려왔다는 점에서 훨씬 낫다고 느꼈다.)

또한 트랜잭션의 시작 시점을 제어할 수 없다는 점도 아쉬웠다.
dataSource.transaction() 방식은 함수에 진입하는 순간 트랜잭션이 자동으로 시작되기 때문에, 특정 조건에 따라 트랜잭션을 시작하거나 생략하는 흐름을 유연하게 구성하기에는 적합하지 않았다.

typeorm-transactional 라이브러리

typeorm-transactional
사실 위 라이브러리에 대해 좀 더 일찍 알았다면 어땠을까 라는 아쉬움이 많았다.

typeorm-transactional은 TypeORM을 위한 트랜잭션 데코레이터 라이브러리로, typeorm-transactional-cls-hooked에서 forked 되어 만들어졌으며, 주간 다운로드 수가 최대 10만 건에 달하는 꽤 널리 사용되는 라이브러리 이다.
cls-hooked를 사용하여 트랜잭션을 자동으로 전파한다고 한다.

▶️ NestJS에서 트랜잭션을 위해 typeorm-transactional을 알아보자
▶️ Integrating TypeORM-Transactional in Your Nest.js Project: A Step-by-Step Guide

해당 라이브러리에서 제공하는 @Transactional은 내가 원하던 모든 요구사항을 만족했다.

@Transactional() 데코레이터로 Service 메서드에서 트랜잭션 관리
데이터 접근 로직을 별도의 Repository 클래스로 완전히 분리
EntityManager를 인자로 계속 전달할 필요가 없어 코드가 깔끔
스케줄러 같은 백그라운드 작업에서도 사용 가능

설치 및 설정

npm install typeorm-transactional
// main.ts - 앱 시작 전에 초기화 
async function bootstrap() {
  initializeTransactionalContext();  // 추가
  ...
}
import { InternalServerErrorException } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModuleAsyncOptions } from '@nestjs/mongoose';
import { TypeOrmModuleAsyncOptions, TypeOrmModuleOptions } from '@nestjs/typeorm';
import { join } from 'path';
import { DataSource } from 'typeorm';
import { addTransactionalDataSource } from 'typeorm-transactional';

// database.config.ts - DataSource 생성 및 트랜잭션 등록
export const TYPEORM_CONFIG: TypeOrmModuleAsyncOptions = {
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: async (configService: ConfigService): Promise<TypeOrmModuleOptions> => ({
    type: configService.get<string>('DB_TYPE'),,
    host: configService.get<string>('DB_HOST'),
    port: configService.get<number>('DB_PORT'),
    username: configService.get<string>('DB_USER'),
    password: configService.get<string>('DB_PW'),
    database: configService.get<string>('DB_NAME'),
    charset: 'utf8mb4',
    entities: [join(__dirname, '../entity/**/*.entity{.ts,.js}')],
    synchronize: true,
    logging: false,
  }),

  async dataSourceFactory(option) {
    if (!option) {
      throw new InternalServerErrorException('Transaction Error: Invalid options passed');
    }
    // TypeORM DataSource 인스턴스 생성
    const dataSource = new DataSource(option)
    // 생성된 DataSource를 typeorm-transactional 라이브러리에 등록
    // 등록된 DataSource를 NestJS에 반환
    return addTransactionalDataSource(dataSource);
  },
};

dataSourceFactory의 역할
TypeORM의 DataSource를 생성하고 typeorm-transactional 라이브러리에 등록하는 역할을 한다.

addTransactionalDataSource() 는 생성된 DataSource를 typeorm-transactional 라이브러리에 알려주는 역할을 한다.
이 과정이 없으면 @Transactional() 데코레이터가 어떤 데이터베이스 연결을 사용해야 할지 알 수 없어 동작하지 않는다.

전체 흐름

  1. NestJS 앱 시작
     ↓
  2. TypeOrmModule.forRootAsync() 실행
     ↓
  3. dataSourceFactory() 호출
     ↓
  4. new DataSource(option) - DataSource 생성
     ↓
  5. addTransactionalDataSource(dataSource) - 라이브러리에 등록
     ↓
  6. Service에서 @Transactional() 데코레이터는 해당 DataSource 사용 가능

사용 예시

// notice.service.ts
@Transactional()
async createNoticeForUser(
  noticeInfo: CreateNoticeDto,
  userName: string,
  noticeImage?: Express.Multer.File,
): Promise<void> {
  const noticeIdx: number = await this.noticeRepository.createNotice(noticeInfo, userName);

  /* 첨부 이미지가 있다면 */
  if (noticeImage) {
    noticeImage.originalname = Buffer.from(noticeImage.originalname, 'ascii').toString('utf8');
    const imageInfo: NoticeImageInfo = { imageName: noticeImage.originalname, imageSize: noticeImage.size };
    // 2. DB에 저장
    await this.noticeRepository.createNoticeImage(noticeIdx, imageInfo);
  }

  return;
}
  
// notice.repository.ts
async createNotice(noticeInfo: CreateNoticeDto, writerName: string): Promise<number> {
    const result: InsertResult = await this.noticeModel
      .createQueryBuilder()
      .insert()
      .into(NoticeEntity)
      .values({ creatorName: writerName, lastEditorName: writerName, lastUpdateAt: new Date(), ...noticeInfo })
      .execute();

    const noticeIdx: number = result.identifiers[0].noticeIdx;

    return noticeIdx;
}

async createNoticeImage(noticeIdx: number, imageInfo: NoticeImageInfo): Promise<void> {
    /* image entity */
    const result: InsertResult = await this.imageModel
      .createQueryBuilder()
      .insert()
      .into(ImageEntity)
      .values(imageInfo)
      .execute();

    const imageIdx: number = result.identifiers[0].imageIdx;

    /* notice_has_image entity */
    await this.noticeImageModel
      .createQueryBuilder()
      .insert()
      .into(NoticeHasImageEntity)
      .values({ noticeIdx, imageIdx })
      .execute();
  }

Repository 메서드에서 별도 인자를 전달하지 않아도 트랜잭션이 작동하였다!

@Transactional 데코레이터가 붙은 Service 메서드 내에서 호출되는 모든 Repository 메서드는 자동으로 같은 트랜잭션 컨텍스트를 공유한다.
typeorm-transactional 라이브러리가 내부적으로 트랜잭션을 전파해주기 때문에, EntityManager를 함수 인자로 일일이 전달할 필요가 없었다.

코드는 더욱 간결해지고 트랜잭션의 일관성은 자동으로 보장되었다. 👍🏻

마무리하며,

여러 방식을 시도해본 결과, TypeORM에서는 typeorm-transactional 라이브러리가 정답인 것 같다. Service에서 트랜잭션을 관리하면서도, 매번 EntityManager를 넘겨줄 필요가 없으니 코드가 정말 깔끔해졌다.

그런데 또 다른 문제가...
최근 프로젝트에서 TypeORM과 Mongoose를 같이 써야 하는 상황이 생겼다. Mongoose도 트랜잭션을 지원하긴 하는데, 급하게 개발하다 보니 지금은 Service에 startSession(), commitTransaction(), abortTransaction(), endSession() 코드가 전부 박혀있다.
TypeORM에서 겪었던 문제가 Mongoose에서도 똑같이 반복되고 있는 거다. 이건 좀 아닌 것 같아서...

앞으로 해야 할 것들 🏃‍♀️‍➡️
앞서 봤던 AsyncLocalStorage 방식을 보면, @Transactional('mongoose')처럼 orm 종류에 따라 트랜잭션을 다르게 적용할 수 있을 것 같았다.

이참에 AsyncLocalStorage를 제대로 파봐야겠다. 개념을 확실히 잡아두면 나중에 또 써먹을 일이 있을 것 같다.
그리고 Mongoose에도 적용할 수 있는 AOP 방식이 있는지 찾아봐야겠다. TypeORM처럼 데코레이터 하나로 깔끔하게 쓸 수 있다면 정말 좋고, 두 orm의 트랜잭션을 하나의 데코레이터로 관리할 수 있으면 베스트다.

결국 트랜잭션 관리의 핵심은 관심사 분리인 것 같다. 비즈니스 로직에서 트랜잭션 코드를 완전히 분리할 수 있다면, 어떤 orm을 쓰든 코드는 깔끔해질 거다.

이번 삽질(?)이 좋은 공부가 됐으니, 다음엔 Mongoose도 깔끔하게 정리해봐야겠다.

profile
대기업 채용 인적성 검사 시스템을 개발하고 있습니다.

0개의 댓글