현재 진행중인 프로젝트는 NestJs로 개발하고 있습니다. 제가 개발하고 있는 로직 중 회원가입이 완료되면 쿠폰을 발행하는 로직이 있습니다. 이를 간단하게 나타내보면
유저 DB insert -> 쿠폰 DB insert
이 동작은 하나의 로직이 되어야합니다.
그러나 유저 DB에 insert가 된 이후 모종의 이유로 쿠폰 DB에 insert 되기전 Error가 발생하면 어떻게 될까? 라는 의문이 생겨 공부하기 시작했고, 트랜잭션이라는 개념에 대해 조금 더 알게 되어 정리하게 되었습니다.
트랜잭션에는 ACID(원자성, 일관성, 독립성, 지속성)이라는 특징이 있습니다. 하지만 자세한 개념 설명은 생략하도록 하겠습니다.
트랜잭션은 데이터베이스의 상태를 변화시키기 위해서 수행하는 작업의 단위를 뜻합니다.
고객이 송금하는 상황을 예시로 들어본다면
A의 계좌에서 인출(update) → B의 계좌로 입금(update)
이렇게 두 Update는 하나의 단위로 실행이 되어야 합니다.
만약 A의 계좌에서 인출 후 B의 계좌로 입금되기 전 시점에 에러가 발생한다면 당연히 update되기 전 시점으로 돌아가야 합니다. 이를 rollback이라고 부릅니다.
만약 송금이 정상적으로 완료되었다면 트랜잭션이 관리자에게 성공적으로 완료되었다고 알려줘야합니다. 이를 commit이라고 부릅니다.
해당 코드는 TypeORM + Nestjs 기준으로 작성되었습니다.
Nestjs에서 트랜잭션을 처리하는 방법은 대표적으로 3가지입니다.
저는 queryRunner를 사용하기로 했고 그 이유는 트랜잭션 사이클을 수동적으로 관리할 수 있다는 장점이 있었습니다.
queryRunner는 데이터베이스에 대한 트랜잭션을 실행하고 쿼리를 수행하는데 사용되는 객체입니다.
서비스 단에서 queryRunner를 이용해서 트랜잭션을 구현하면 아래와 같습니다.
async registerAndIsuuedCoupon(user: Users): Promise<Users> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const result = await queryRunner.manager.save(user);
//...
const coupon = this.couponRepository.create();
await queryRunner.manager.save(coupon)
await queryRunner.commitTransaction();
return result;
} catch (e) {
await queryRunner.rollbackTransaction();
} finally {
await queryRunner.release();
}
}
하지만 매번 이렇게 트랜잭션을 구현하기 위해서 queryRunner를 생성하고 try-catch 문을 작성해야하는게 코드의 가독성이 떨어지고 번거롭다고 생각했습니다.
위의 트랜잭션을 담당하는 코드는 하나의 관심사(트랜잭션 생성, 커밋, 롤백, 완료)로 이루어져있고, 해당 로직은 하나의 모듈로 분리시킬 수 있다고 생각했습니다.
NestJs에서 AOP를 고안해 만든 Interceptor를 이용해서 해당 트랜잭션의 로직을 아래와 같이 분리했습니다.
import {
CallHandler,
ExecutionContext,
HttpException,
Injectable,
InternalServerErrorException,
NestInterceptor,
} from '@nestjs/common';
import { Observable, catchError, tap } from 'rxjs';
import { DataSource } from 'typeorm';
@Injectable()
export class TransactionInterceptor implements NestInterceptor {
constructor(private readonly dataSource: DataSource) {}
async intercept(
context: ExecutionContext,
next: CallHandler<any>,
): Promise<Observable<any>> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
const request = context.switchToHttp().getRequest();
request.queryRunnerManager = queryRunner.manager;
return next.handle().pipe(
catchError(async (e) => {
await queryRunner.rollbackTransaction();
await queryRunner.release();
if (e instanceof HttpException) {
throw new HttpException(e.getResponse(), e.getStatus());
}
throw new InternalServerErrorException();
}),
tap(async () => {
await queryRunner.commitTransaction();
await queryRunner.release();
}),
);
}
}
만약 에러가 발생한다면 catchError내에 있는 rollback이 실행되고,
에러없이 잘 끝났다면 트랜잭션을 commit합니다.
이제 이렇게 구현한 Interceptor의 queryRunner Manager를 사용하기 위해 데코레이터를 만들었습니다.
export const TransactionManager = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
return req.queryRunnerManager;
},
);
아까 담아두었던 queryRunnerManger를 return하는 간단한 데코레이터입니다.
@Post('register')
@Public()
@UseInterceptors(ClassSerializerInterceptor, TransactionInterceptor)
async register(
@Body() createUserDto: CreateUserDto,
@TransactionManager() transactionManager,
): Promise<User> {
const newUser = await this.userService.registerAndIssuedCoupon(
createUserDto,
transactionManager,
);
return newUser;
}
컨트롤러에서는 TransactionInterceptor를 사용하기 위해 @UseInterceptors() 데코레이터를 사용했고 @TransactionManager() 데코레이터를 사용해 queryRunnerManager를 인자로 받았습니다.
async registerAndIsuuedCoupon(
user: Users,
transactionManager: Entitymanager,
): Promise<Users> {
const result = await transactionManager.save(user);
//...
const coupon = this.couponRepository.create();
await transactionManager.save(coupon);
return result;
}
위는 서비스단의 코드입니다. 되게 간결해졌죠?
이렇게 작성하면 유저가 저장된 이후 쿠폰이 발행되기 전(혹은 후)에 에러가 발생하면 데이터가 롤백되게 구현했습니다.
트랜잭션을 구현하면서 유저의 회원가입
, 쿠폰 발행
은 각각 실행되어야하는 독립적인 함수여야 하지 않나? 라는 의문이 생겼습니다.
그러나 유저의 회원가입 → 쿠폰 발행
이 한 비즈니스 로직이 맞다면 해당 함수는 단일책임원칙에 위배되지 않는다고 합니다.