try-catch 없는 서비스 — NestJS 트랜잭션 처리

LinkDropper·2025년 4월 30일
post-thumbnail

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 인터셉터를 사용한 전역 트랜잭션 처리

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()를 사용해,
요청 컨텍스트에서 트랜잭션 객체를 추출합니다.


🤔 구현하면서 겪은 고민들

  1. 모든 요청에 트랜잭션을 붙여야 할까?
    • 모든 요청에 무조건 트랜잭션을 붙이면 오버헤드가 생길 수 있습니다.
    • 실제로는 읽기 전용 요청(GET 등)에는 트랜잭션을 생략하거나,
    내부적으로 트랜잭션을 시작하지 않도록 조건을 분기해 처리했습니다.

  2. 트랜잭션 내부에서 repository 대신 무엇을 쓸까?
    • queryRunner.manager.save()를 사용해야 트랜잭션 안에서 DB 작업이 반영됩니다.
    • 기존의 this.repo.save() 방식은 트랜잭션 외부에서 실행될 수 있으므로 주의해야 합니다.


📈 도입 효과

  • 트랜잭션 관리 코드가 서비스 로직에서 완전히 제거됨
    • 예외 처리, 롤백 누락 등의 실수 방지
    • 코드 일관성과 안정성 향상
    • 테스트 작성 시 의도한 커밋/롤백 흐름을 더 명확히 확인 가능

🚀 링크 드라퍼 베타 테스터를 모집합니다!

저희는 ‘링크를 잘 저장하고, 다시 꺼내보게 만드는 습관’을 위한 서비스를 만들고 있습니다.
지금 링크 드라퍼(Link Dropper)는 베타 서비스 중이며,
여러분의 피드백을 기다리고 있습니다 🙌

👉 직접 사용해보고 의견을 나눠주세요:
🔗 링크 드라퍼 체험하러 가기

여러분의 사용 경험이 서비스 개선에 큰 힘이 됩니다.
많은 참여 부탁드립니다! 😊


✍ 마무리하며

NestJS를 실제 서비스에 적용하다 보면,
단순한 기능 구현보다 구조화된 시스템 설계가 중요해집니다.

링크 드라퍼는 프론트엔드 중심 개발자들이 직접 백엔드와 인프라까지 만들며 성장 중인 서비스입니다.
이렇게 구조적인 개선을 해 나가면서 점점 더 유지보수성과 확장성이 강한 백엔드를 만들어가고 있습니다.


다음 글에서는 실제 트랜잭션 안에서 어떻게 여러 DB 작업을 분리된 서비스 간에 연결했는지,
또는 NestJS 모듈 구조 설계 시 느낀 점들도 공유해보겠습니다!

궁금하신 점은 댓글로 언제든 환영입니다 🙌
읽어주셔서 감사합니다!

profile
“기록하는 습관을 도구로 만들다 — 두 개발자의 링크 드라퍼 구축기”

2개의 댓글

comment-user-thumbnail
2025년 5월 1일

QueryRunner를 직접 주입해서 사용하는 방식 매우 흥미롭네요!

혹시 nestjs-cls 같은 라이브러리를 사용해서 트랜잭션 스코프를 request 단위로 유지하는 방식에 대해서도 고민해보셨나요?

CLS를 사용하면 서비스 메서드가 중첩되거나 구조가 복잡해지더라도 하나의 요청 내에서 동일한 트랜잭션을 자연스럽게 유지할 수 있어서, 코드가 더 깔끔해지는 장점이 있더라고요.

이 경우, 도메인 로직을 순수하게 작성할 수 있습니다! nest-cls 추천드립니다.

트랜잭션 cls 라이브러리도 있습니다!

1개의 답글