
NestJS로 백엔드를 개발하다 보면 이런 상황을 자주 마주하게 됩니다.
“DB에 여러 작업을 해야 하는데, 이걸 하나의 트랜잭션으로 묶고 싶다.”
“그런데 서비스마다 try-catch에 트랜잭션 넣는 건 너무 반복적이다.”
링크 드라퍼에서는 이 문제를 해결하기 위해,
NestJS 인터셉터 기반 트랜잭션 처리 구조를 도입했습니다.
한 번만 구현하면 모든 요청에서 자동으로 트랜잭션을 시작하고 종료할 수 있는 구조죠.
NestJS + TypeORM 환경에서 보통 트랜잭션은 이렇게 사용합니다:
const qr = dataSource.createQueryRunner();
await qr.connect();
await qr.startTransaction();
try {
await qr.manager.save(...);
await qr.commitTransaction();
} catch (e) {
await qr.rollbackTransaction();
} finally {
await qr.release();
}
이 방식은 기능적으로는 문제없지만…
• 서비스마다 중복 코드 발생
• 트랜잭션 누락 가능성
• 컨트롤러 → 서비스 레이어 간 전달이 번거로움
결국 트랜잭션을 시스템적으로 묶어주는 방식이 필요했습니다.
NestJS의 Interceptor는 모든 요청 전후의 흐름을 제어할 수 있는 강력한 기능입니다.
이를 활용해 요청이 들어오면 트랜잭션을 자동으로 시작하고,
요청이 끝나면 커밋 혹은 롤백 후 종료되도록 만들었습니다.
💻 인터셉터 예시 코드 (변형 버전)
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable, catchError, tap } from 'rxjs';
import { DataSource } from 'typeorm';
@Injectable()
export class TransactionalInterceptor implements NestInterceptor {
constructor(private readonly db: DataSource) {}
async intercept(ctx: ExecutionContext, next: CallHandler): Promise<Observable<unknown>> {
const request = ctx.switchToHttp().getRequest();
const runner = this.db.createQueryRunner();
await runner.connect();
await runner.startTransaction();
// 요청 객체에 주입 (커스텀 데코레이터용)
request.transaction = runner;
return next.handle().pipe(
tap(async () => {
await runner.commitTransaction();
await runner.release();
}),
catchError(async (err) => {
await runner.rollbackTransaction();
await runner.release();
throw err;
}),
);
}
}
인터셉터에서 트랜잭션을 request.transaction에 주입해줬기 때문에,
서비스나 컨트롤러에서는 데코레이터를 통해 간편하게 주입받을 수 있습니다.
@Patch(':id')
async updateItem(
@Transaction() runner: QueryRunner,
@Body() dto: UpdateItemDto,
) {
await runner.manager.update(ItemEntity, { id }, dto);
}
내부적으로는 NestJS의 createParamDecorator()를 사용해,
요청 컨텍스트에서 트랜잭션 객체를 추출합니다.
모든 요청에 트랜잭션을 붙여야 할까?
• 모든 요청에 무조건 트랜잭션을 붙이면 오버헤드가 생길 수 있습니다.
• 실제로는 읽기 전용 요청(GET 등)에는 트랜잭션을 생략하거나,
내부적으로 트랜잭션을 시작하지 않도록 조건을 분기해 처리했습니다.
트랜잭션 내부에서 repository 대신 무엇을 쓸까?
• queryRunner.manager.save()를 사용해야 트랜잭션 안에서 DB 작업이 반영됩니다.
• 기존의 this.repo.save() 방식은 트랜잭션 외부에서 실행될 수 있으므로 주의해야 합니다.
저희는 ‘링크를 잘 저장하고, 다시 꺼내보게 만드는 습관’을 위한 서비스를 만들고 있습니다.
지금 링크 드라퍼(Link Dropper)는 베타 서비스 중이며,
여러분의 피드백을 기다리고 있습니다 🙌
👉 직접 사용해보고 의견을 나눠주세요:
🔗 링크 드라퍼 체험하러 가기
여러분의 사용 경험이 서비스 개선에 큰 힘이 됩니다.
많은 참여 부탁드립니다! 😊
NestJS를 실제 서비스에 적용하다 보면,
단순한 기능 구현보다 구조화된 시스템 설계가 중요해집니다.
링크 드라퍼는 프론트엔드 중심 개발자들이 직접 백엔드와 인프라까지 만들며 성장 중인 서비스입니다.
이렇게 구조적인 개선을 해 나가면서 점점 더 유지보수성과 확장성이 강한 백엔드를 만들어가고 있습니다.
다음 글에서는 실제 트랜잭션 안에서 어떻게 여러 DB 작업을 분리된 서비스 간에 연결했는지,
또는 NestJS 모듈 구조 설계 시 느낀 점들도 공유해보겠습니다!
궁금하신 점은 댓글로 언제든 환영입니다 🙌
읽어주셔서 감사합니다!
QueryRunner를 직접 주입해서 사용하는 방식 매우 흥미롭네요!
혹시 nestjs-cls 같은 라이브러리를 사용해서 트랜잭션 스코프를 request 단위로 유지하는 방식에 대해서도 고민해보셨나요?
CLS를 사용하면 서비스 메서드가 중첩되거나 구조가 복잡해지더라도 하나의 요청 내에서 동일한 트랜잭션을 자연스럽게 유지할 수 있어서, 코드가 더 깔끔해지는 장점이 있더라고요.
이 경우, 도메인 로직을 순수하게 작성할 수 있습니다! nest-cls 추천드립니다.
트랜잭션 cls 라이브러리도 있습니다!