본문 바로가기

IT/Node.js

nest.js와 PrismaORM 트랜잭션 여행기 #1 AsyncLocalStorage로 트랜잭션을 전파해보자

nest.js 프레임워크와 PrismaORM을 사용해 개발하고 있던 중, 트랜잭션 전파 문제를 AsyncLocalStorage를 사용해 해결한 경험을 공유하고자 합니다.

 

이 글을 이해하기 위해서, 먼저 아래 글을 읽고 와주시면 감사하겠습니다.

 


트랜잭션을 파라미터로 전달하면 강한 결합도가 생겨 버려요..

글귀 및 사진 블록으로 이루어진 게시글 서비스를 개발하고 있습니다. 게시글 구조가 복잡해 아래와 같이 Layered Architecture 구조를 사용헸습니다.

 

  • Repository는 직접 Prisma ORM을 가지고 직접 데이터베이스에 접근 및 저장합니다.
  • Domain Service는 Repository를 활용해 비즈니스 로직을 수행합니다.
  • Application Service는 여러 Domain Service을 조합하고, 트랜잭션 처리합니다.

Application Service의 큰 역할 중 하나는, 여러 도메인 서비스 중 하나라도 실패한다면 이를 DB에 모두 반영하지 않는 트랜잭션 처리를 해야 합니다. (만약 게시글 생성 후, 블록을 생성하다가 오류가 발생하면 게시글도 DB에 저장되면 안됩니다)

 

import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()

function transfer(from: string, to: string, amount: number) {
  return prisma.$transaction(async (txClient) => {
    // 이 안의 txClient를 활용한 모든 연산은 트랜잭션 처리됩니다.
    const sender = await txClient.account.update({
      data: {
        balance: {
          decrement: amount,
        },
      },
      where: {
        email: from,
      },
    })
  })
}

 

PrismaORM에서는 Interactive Transaction 기능을 제공하고 있습니다. Transaction을 위한 Client를 제공해 주고, 이 Client를 활용한 연산들은 하나의 Transaction으로 묶이게 되는 구조입니다.

 

 

하지만 이를 Layered Architecture 에 그대로 적용하기에는 문제가 있습니다.

  • Application Service가 Transaction을 선언하고, Transaction을 위한 txClient을 받습니다.
  • 하지만 이 Client를 실제로 사용할 곳은 2단계 아래인 Repository 입니다.

 

 

과연 어떻게 Repository가 2단계 위 계층인 Application Service의 트랜잭션 상황을 알 수 있을까요? 가장 쉬운 방법은 파라미터로 txClient를 Domain Service로 넘겨주고, 그대로 Repository에 전달해 주는 것 입니다.

  • Application Service가 Transaction을 선언하고, Transaction을 위한 txClient을 받습니다.
  • Domain Service가 파라미터로 txClient를 Repository에 그대로 전달합니다.
  • Repository는 txClient가 제공되었는지 여부에 따라 PrismaClient와 txClient를 번갈아 사용합니다.

하지만 만약 Application Service의 Transaction 전달 방식이 바뀐다면 어떻게 될까요? 모든 Domain Service와 Repsotiry의 로직을 바꿔야 합니다. 즉, 하위 계층 뿐만이 아니라 상위 계층까지도 큰 의존도가 생겨 버렸습니다. 기껏 Layered Architecture로 관심사를 분리해 놨는데, 모든 장점을 잃어버리는 구조가 된 것입니다.

 


트랜잭션을 관리하는 전역 객체로 분리해보자!

전역적으로 트랜잭션 상황을 추적하고, 상황에 따라 Transaction Client 혹은 일반 Client을 제공하는 Provider 객체를 만든다면 어떨까요?

  • Application Service는 Transaction이 시작되면 PrismaProvider에 정보를 전파합니다.
  • PrismaProvider는 내부적으로 Transaction 상황을 저장해 놓습니다.
  • Repository들은 PrismaProvider에 접근해 txClient 혹은 PrismaClient를 받습니다.

이제 더 이상 Repository와 Domain Service는 상위 계층에 의존하지 않습니다. PrismaProvider라는 하나의 전역 객체를 참조할 뿐이지요. 또한 원래는 Repository에 트랜잭션 상황인지 아닌지를 검사하는 로직 또한 덜어낼 수 있습니다.

 

여기서 또 문제가 생깁니다. 이렇게 전역적으로 Transaction 상황을 관리한다면 여러 요청의 뒤섞이게 될 수 있지 않을까요?

 

어떻게 요청마다 트랜잭션 상황을 독립적으로 저장할까?

 

즉, 각 요청마다 독립적인 공간을 만들어 Transaction 상황을 저장할 수 있어야 합니다.

일반적으로 Spring, JSP 등의 Multi-Thread 기반 웹 프레임워크는 Thread Local Storage를 사용해 저장합니다. 요청마다 하나의 Thread가 맡아 처리하므로, 완전히 독립적인 공간으로 사용할 수 있는 것이죠.

 

하지만 node.js 환경에 기반하는 nest.js는 어떨까요? node.js는 기본적으로 Single-Thread 기반이고, 비동기 함수로 각 요청을 다루고 있습니다. 즉, 여러 요청을 하나의 Thread가 맡아 처리하는 구조입니다. 그래서 Thread Local Storage는 여러 요청이 접근할 수 있는 독립적인 공간이 아닙니다.

 

그렇다면 어떤 기준으로 각 요청을 분리할 수 있을까요? 아까 비동기 함수로 각 요청을 다룬다 했습니다. Thread 대신 비동기 함수를 추적해 독립적인 공간을 만들면 됩니다.


AsyncLocalStorage로 비동기 함수별로 독립적인 공간을 만들자!

 

다행이 Node.js에는 비동기 함수를 추적하고, 독립적인 공간을 만들 수 있는 AsyncLocalStorage API를 제공해 줍니다. 내부적으로 Async_Hooks를 사용해 비동기 함수의 생성 및 소멸을 추적하고, 그 사이에 독립적인 공간(Context)를 만들어 줍니다.

 

자세한 동작 원리는 아래 링크를 참조해 주세요.

 

// app.module.ts
@Global()
@Module({
  ...
  providers: [
    ...
    {
      provide: 'TRANSACTION_STORAGE',
      useValue: new AsyncLocalStorage<{ txClient?: Prisma.TransactionClient }>(),
    },
  ],
  exports: ['TRANSACTION_STORAGE'],
})

 

최상위 AppModule에 전역으로 사용할 AsyncLocalStorage를 사용해 줍시다. 가끔씩 nest.js 객체가 singleton이 되지 않는 경우가 생기므로, 꼭 최상위 AppModule에 provider로 등록하고, 이를 Export해 전역적으로 사용해 줍시다.

 

@Injectable()
export class PrismaProvider {
  constructor(
    private readonly prismaClient: PrismaService,
    @Inject('TRANSACTION_STORAGE')
    private readonly asyncLocalStorage: AsyncLocalStorage<{ txClient?: Prisma.TransactionClient }>,
  ) {}

  get(): Prisma.TransactionClient {
    const store = this.asyncLocalStorage.getStore();
    return store?.txClient || this.prismaClient;
  }

  async beginTransaction<T>(fn: () => Promise<T>): Promise<T> {
    return this.prismaClient.$transaction(async (txClient) => {
      return this.asyncLocalStorage.run({ txClient }, fn);
    });
  }
}

 

PrismaProvider를 구현해 주었습니다.

  • 아까 선언했던 TRANSACTION_STORAGE를 주입받아 사용합니다.
  • beginTransaction() 함수는 수행할 메소드를 묶은 비동기 함수를 받고, 이 함수 안에서만 txClient를 제공하도록 해 줍니다.
  • get() 함수는 Transaction 상황이라면 txClient를 제공하고, 아니면 일반 prismaClient를 제공해 줍니다.

이제 beginTransaction()을 선언한다면, 받은 비동기 함수 안에서는 txClient가 존재할 것입니다. 비동기 함수는 Prisma의 Transaction Callback 안에 있으므로, 만약 실패한다면 자동으로 Rollback되는 메커니즘입니다.

 

@Injectable()
export class FileRepository {
  constructor(private readonly prisma: PrismaProvider) {}

  async createFile(data: Prisma.FileCreateInput) {
    return this.prisma.get().file.create({ data });
  }
  
  ...

 

이제 모든 Repository들은 이 PrismaProvider에 get() 메소드로 접근하게 됩니다. 이제 더 이상 Repository들은 Transaction 상황인지 여부를 신경쓰지 않아도 됩니다.

 

...
return this.prisma.beginTransaction<PostDto>(async () => {
  const summary = await this.summaryService.summarizePost(post.title, blocks);
  const createdPost = await this.postService.createPost(userUuid, { ...post, summary });
  const user = await this.userService.findUserByUniqueInput({ uuid: userUuid });
  ...
});

 

Application Service에서 beginTransaction 메소드를 활용하는 예시입니다. Domain Service들의 동작을 하나의 비동기 함수로 묶었습니다. 자동으로 Domain Service 아래에 있는 PrismaProvider들이 대신 txClient를 제공할 것 입니다.

 


 

이처럼 Transaction 처리를 횡적 관심사로 분리함으로써, 각 계층 간의 의존성을 줄일 수 있었습니다. 하지만 Application Service에서는 여전히 Prisma에 의존하고 있고, 코드 상으로 트랜잭션 범위를 설정해 비즈니스 로직을 잘 확인할 수 없습니다.

 

다음 글에서는 데코레이터를 활용해 선언적 트랜잭션을 구현해볼 예정입니다. 많은 관심 바랍니다.