개발

Prisma 에서는 여러 Repository의 트랜잭션을 어떻게 관리해야할까? (feat. SpringBoot @Transactional)

까다로운 ISTP 2025. 8. 30. 20:46
반응형

 

 

 

NestJS + Prisma 를 사용하면서 가장 많이 드는 고민은 트랜잭션 처리였습니다. NestJS와 Prisma로 서비스를 만들면서 여러 Repository를 한 트랜잭션으로 묶어야 하는 상황이 계속 생겼거든요. 예를 들어 유저 매칭이 성사되면 티켓을 차감하고, 구매 상태와 매칭 상태를 업데이트해야 하는데, 이게 하나라도 실패하면 전부 롤백되어야 합니다.

 

Prisma에서는 $transaction으로 트랜잭션을 관리하는데, 문제는 이걸 어디에 둬야 하냐는 거였습니다. Service 레이어에 올리면 Service가 ORM에 종속되고, Repository 안에서 처리하면 비즈니스 로직이 Repository에 섞이면서 코드 재사용이 어려워집니다.

Spring을 쓸 때는 @Transactional 하나만 붙이면 끝이었는데, 왜 NestJS에서는 이렇게 복잡할까요?

 

 

실제로 뜯어보니 구조적 차이가 있었습니다

 

Spring과 NestJS는 트랜잭션을 다루는 방식이 근본적으로 달랐습니다. Spring은 ThreadLocal 기반으로 현재 스레드에 트랜잭션 컨텍스트를 바인딩합니다. @Transactional 어노테이션을 붙이면 그 메서드부터 시작해서 하위 호출까지 모두 같은 트랜잭션으로 자동으로 묶입니다.

 

반면 NestJS는 JavaScript 기반이라 스레드라는 개념 자체가 없습니다. async/await로 비동기 처리를 하다 보니 컨텍스트 유지가 어렵고, 트랜잭션을 명시적으로 수동 제어해야 합니다. Prisma의 $transaction이나 TypeORM의 EntityManager를 위에서 아래로 계속 전달해야 하는 거죠.

사실 예전부터 이런 코드를 써왔습니다.

 

// Service에서 직접 트랜잭션 시작
await this.prisma.$transaction(async (tx) => {
  await this.userRepository.decreaseTicket(tx, userId);
  await this.matchingRepository.updateStatus(tx, matchingId);
});

 

당시엔 이게 당연하다고 생각했는데, 어느 날 문득 이상한 점을 발견했습니다. Service가 Prisma를 직접 알고 있다는 건 Layered Architecture를 위반하는 거 아닌가? Service는 비즈니스 로직만 담당해야 하는데 트랜잭션 관리까지 하고 있잖아?

 

 

진짜 문제는 책임 분리였습니다

 

놀란 건, 우리가 당연하게 쓰던 구조가 사실 여러 설계 원칙을 위반하고 있었다는 겁니다. Service가 트랜잭션 관리와 ORM 제어, 비즈니스 로직 처리를 모두 담당하면서 단일 책임 원칙을 어기고 있었고, 추상화된 Service가 구체적인 EntityManager에 의존하면서 의존성 역전 원칙도 무너졌습니다.

 

그러던 중 콜백 함수 패턴을 활용하면 트랜잭션 책임을 분리할 수 있다는 걸 알게 됐습니다. 트랜잭션 시작과 종료는 인프라 계층에서 담당하고, Service는 오직 비즈니스 로직만 콜백으로 전달하는 방식이었죠.

 

 

트랜잭션 책임을 인프라로 옮겼습니다

먼저 TransactionRunner라는 별도 모듈을 만들었습니다.

@Injectable()
export class TransactionRunner {
  constructor(private readonly prisma: PrismaService) {}

  async run<T>(callback: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
    return this.prisma.$transaction(callback);
  }
}

 

이제 트랜잭션을 시작하고 종료하는 책임은 TransactionRunner가 가집니다. Service는 트랜잭션이 필요한 비즈니스 로직만 콜백으로 넘기면 됩니다.

Repository는 tx를 매개변수로 받아서 DB 작업만 수행합니다.

class UserRepository {
  async decreaseTicket(tx: Prisma.TransactionClient, userId: string) {
    return tx.user.update({
      where: { id: userId },
      data: { ticketCount: { decrement: 1 } }
    });
  }
}

 

Service는 이제 Prisma를 직접 몰라도 됩니다.

class MatchingService {
  constructor(
    private readonly transactionRunner: TransactionRunner,
    private readonly userRepository: UserRepository,
    private readonly matchingRepository: MatchingRepository
  ) {}

  async completeMatching(userId: string, matchingId: string) {
    return this.transactionRunner.run(async (tx) => {
      await this.userRepository.decreaseTicket(tx, userId);
      await this.matchingRepository.updateStatus(tx, matchingId);
    });
  }
}

 

모든 Service가 트랜잭션을 쉽게 쓸 수 있도록 했습니다

 

매번 TransactionRunner를 주입받는 것도 반복적이라, BaseService를 만들어서 상속 구조로 바꿨습니다.

 

export abstract class BaseService {
  constructor(protected readonly transactionRunner: TransactionRunner) {}
}

 

이제 모든 Service는 BaseService를 상속받으면 this.transactionRunner.run()을 바로 쓸 수 있습니다. 트랜잭션이 필요한 시점에 언제든 사용할 수 있게 된 거죠.

알게 된 건, 트랜잭션도 하나의 인프라 관심사라는 점입니다. Service는 "무엇을 할지"만 정의하고, "어떻게 트랜잭션을 열고 닫을지"는 인프라가 담당해야 합니다.

 

결국 우리가 배운 건

Spring의 @Transactional이 부러웠는데, 사실 그것도 내부적으로는 복잡한 AOP와 프록시 패턴으로 구현되어 있었습니다. NestJS에서도 비슷한 라이브러리들이 있긴 하지만, 대부분 너무 무겁거나 복잡했습니다.

우리가 만든 방식은 가볍고 명시적입니다. tx를 명시적으로 전달하긴 하지만, 그 생성과 관리 책임은 완전히 분리되어 있습니다. 테스트할 때도 TransactionRunner만 mock 처리하면 되니까 훨씬 간단해졌습니다.

 

사실 완벽한 해결책은 아닙니다. 여전히 Repository 메서드마다 tx 매개변수를 받아야 하고, 트랜잭션이 필요 없는 경우와 필요한 경우를 구분해서 메서드를 만들어야 할 때도 있습니다. 그래도 Service가 ORM에 종속되는 것보다는 훨씬 나은 구조라고 생각합니다.

다음엔 이 구조를 더 발전시켜서, 트랜잭션 범위를 자동으로 전파하는 방법을 고민해보려고 합니다. AsyncLocalStorage를 활용하면 Spring의 ThreadLocal과 비슷한 효과를 낼 수 있을 것 같거든요. 물론 이것도 성능 이슈가 있다고 들어서, 실제로 도입하려면 충분한 테스트가 필요할 것 같습니다.

 

트랜잭션 처리는 정답이 없는 문제인 것 같습니다. 각 팀의 상황과 요구사항에 맞게 적절한 수준의 추상화를 찾아가는 게 중요하다고 생각합니다. 우리 팀은 지금 이 정도 수준이 딱 적당한 것 같아요. 복잡하지 않으면서도 책임이 잘 분리되어 있으니까요.

반응형