개발 초기에 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까지 전달할 방법이 있을까?
위의 QueryRunner 로직은 다음과 같은 불편함이 있었다.
이 문제를 해결하기 위해, 반복되는 트랜잭션 코드를 Interceptor로 분리했다.
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();
}),
);
}
}
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();
}
이 방식에는 몇 가지 한계가 있었다.
신입 초기에는 이 방식을 줄곧 사용해왔지만, 이러한 한계점 때문에 결국 Controller가 아닌 Service에 트랜잭션 책임을 두는 방식을 찾아보았고, 아래와 같은 방법을 발견했다.
▶️ AsyncLocalStorage를 이용해 Transaction 관심사 분리하기 (lines 40% 감소)
▶️ AsyncLocalStorage를 이용한 트랜잭션 데코레이터 구현
▶️ Async Local Storage 공식 Doc
솔직히 현재로선 무슨 말인지 전혀 몰랐다..
(nest.js와 PrismaORM 트랜잭션 여행기 #1 AsyncLocalStorage로 트랜잭션을 전파해보자 → 그나마 이 글이 AsyncLocalStorage의 개념에 대해서 이해가 잘 간 내용이었다.)
서비스에 Transaction 데코레이터를 만들어서 orm별로 트랜잭션을 다르게 적용하는 방법은 분명히 매력적이었지만, 코드가 상당히 복잡해보여 이해가 잘 안된 채로 실제로 나의 로직에 적용시키란 쉽지 않아보였다.
AsyncLocalStorage란?
AsyncLocalStorage는 Node.js에서 제공하는 기능으로, 비동기 작업이 진행되는 동안 접근 가능한 독립적인 저장 공간을 만들어준다.
쉽게 비유하자면, 식당에서 각 테이블마다 주문서를 따로 관리하는 것과 같다. 테이블 A의 주문서와 테이블 B의 주문서는 서로 섞이지 않고 각 테이블의 직원들은 자신의 테이블 주문서에만 접근할 수 있다.
마찬가지로 AsyncLocalStorage를 사용하면:
어떻게 동작하나?
이 방식은 AOP(관점 지향 프로그래밍) 를 따른다.
AOP는 핵심 비즈니스 로직과 공통 관심사(트랜잭션, 보안, 로깅 등)를 분리하는 프로그래밍 패러다임이다. 데코레이터를 사용해 트랜잭션 로직을 비즈니스 로직에서 완전히 분리하여 단일 책임 원칙을 지킬 수 있게 된다.
기존에는 EntityManager를 함수 인자로 계속 전달해야 했다면,
AsyncLocalStorage를 사용하면:
즉, 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;
}
코드를 보면, 위 블로그의 저자는
즉, 별도의 Repository 클래스를 두지 않고 Service 레이어에서 모든 데이터 접근 로직을 처리하는 구조였다.
트랜잭션 관심사를 독립적으로 분리한다는 측면에서는 내가 원하는 방향과 비슷했지만,
그리고 이 방식은 구현이 복잡하고, AsyncLocalStorage의 동작 원리를 우선 충분히 이해해야만 적용할 수 있어 실제 프로젝트에 바로 적용하기에는 부담스러웠다.
NestJS + TypeORM 공식 가이드에서는 트랜잭션 방식으로 dataSource.transaction() 을 권장한다고 한다.
위 글을 참고하여 queryRunner 방식과 비교했을 때,
가 장점이라고 한다.
실제로 구현해보았을 때, 확실히 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을 위한 트랜잭션 데코레이터 라이브러리로, 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도 깔끔하게 정리해봐야겠다.