레퍼런스 코드가 협소한 NestJS 사용자😢들과
그보다 더 협소한 NestJS + Sequelize 사용자😭분들께 받칩니다.
NestJS 프로젝트들을 보면 사용하는 데이터베이스 ORM으로 TypeORM을 쓰는 것 같습니다.
하지만 저는 Express와 Sequelize로 상당히 오랫동안 호흡은 맞춰온 관계로 아무래도 TypeORM보다는 Sequelize가 더 친숙해서 그런지 Sequelize로 쿼리를 짜는게 즐겁네요.
따라서 이번 글에서는 TypeORM이 아닌 Sequelize를 사용하여 트랜잭션을 적용하는 방법을 적어보겠다.
src
- route
- [로직 이름]
- controller.ts
- service.ts
- provider.ts # service에 DI 대상 정의
- module.ts # 가져올 모듈과 NestJS의 injector에 의해 인스턴스화 되는 class를 지정
- share
- transaction
- interceptor.ts
- param.ts
# share/transaction/interceptor.ts
import { CallHandler, ExecutionContext, Inject, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { Transaction } from 'sequelize';
import { Sequelize } from 'sequelize-typescript';
@Injectable()
export class TransactionInterceptor implements NestInterceptor {
constructor(
@Inject('SEQUELIZE')
private readonly sequelizeInstance: Sequelize
) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const httpContext = context.switchToHttp();
const req = httpContext.getRequest();
const transaction: Transaction = await this.sequelizeInstance.transaction({
logging: true,
});
req.transaction = transaction;
return next.handle().pipe(
tap(async () => {
await transaction.commit();
}),
catchError(async (err) => {
await transaction.rollback();
return throwError(err);
})
);
}
}
먼저, 인터셉터를 만들어야 한다.
인터셉터는 SEQUELIZE
를 주입 받아서 Sequelize
인스턴스를 가지고 트랜잭션 결과에 따라 에러가 발생하면 rollback()
메소드를, 성공적이라면 commit()
메소드를 호출할 수 있도록 코드를 추가해줍니다.
# share/transaction/param.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
const TransactionParam = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
return req.transaction;
});
export { TransactionParam };
다음으로 트랜잭션 파라미터는 인터셉터에서 추가한
이 코드(req.transaction = transaction;
)로 인해 추가된 transaction
key를
Nest에서 제공해주는 컨택스트 접근 방법으로 req.transaction
을
데코레이션 형태로 Controller에게 매개변수로 전달합니다.
# controller.ts
import { Response } from 'express';
import { Transaction } from 'sequelize';
import { Controller, Res, Get, Body, UseInterceptors } from '@nestjs/common';
import { TransactionParam } from '@/share/transaction/param';
import { TransactionInterceptor } from '@/share/transaction/interceptor';
@Controller('api')
export class TestController {
@UseInterceptors(TransactionInterceptor)
@Get('/test')
async getTestOne(@Res() res: Response, @TransactionParam() transaction: Transaction) {
const result = await this.service.findOne(transaction);
res.status(200).json(result);
}
}
이제 컨트롤러가 매개변수로 받은 transaction
을 서비스에게 넘겨서 서비스에서 트랜잭션 동작이 일어나도록 인자로 전달합니다.
# service.ts
import { Transaction } from 'sequelize';
import { Injectable, Inject } from '@nestjs/common';
import { TestModel } from '@/model';
@Injectable()
export class Service {
constructor(
@Inject('testRepository')
private readonly testRepository: typeof TestModel
) {}
async findOne(transaction: Transaction): any {
const result = await this.testRepository.findOne<TestModel>({
where: {...},
transaction,
});
...
return result;
}
}
서비스에서 매개변수 transaction
을 통해 sequelize에서는 쿼리 실행 코드에서 트랜잭션에 걸려서 문제 발생 시 자동으로 rollback을 진행하게 되고, 문제가 없으면 commit 후 정상적으로 요청 내용을 반환해줍니다.
굳이 서비스에 매개변수 형태로 transaction
변수를 넘기지 않더라도
@UseInterceptors(TransactionInterceptor)
만 있어도 트랜잭션은 해당 로직에만 정상적으로 적용이 됩니다.
이렇게 동작하는 이유를 추측해보자면, 인터셉터의 동작 원리 상 인터셉터가 호출 된 위치의 실행 전과 실행 후로 동작을 제어 할 수 있다보니
로직 실행 전에 sequelize 인스턴스에서 transaction
메소드를 호출을 하기에 트랜잭션이 걸리게 되고
해당 로직이 끝난 후엔 rxjs를 통해 commit이나 rollback 코드가 실행이 되기에 따로 쿼리 메소드에 transaction을 달아줄 필요가 없어 보이는 것으로 추측합니다.