[NestJS] Prisma Transaction

배상준·2024년 7월 21일
0

NestJS

목록 보기
1/1

Trasaction?

Transaction?
”더 이상 분할이 불가능한 업무 처리의 단위”
하지만 백엔드나 DB에서의 트랜잭션은 데이터 베이스의 상태를 바꾸는 작업 단위다.

보통 DB에 입력되는 쿼리 명령어는 각각 하나의 트랜잭션 이라고 할 수 있다.

다만 작업의 단위는 하나의 쿼리문이 아닐 수 있다. 따라서 여러 쿼리문을 사용했을 경우, ‘특정 쿼리문에서 문제가 발생해 중단된다면 나머지 쿼리문을 어떻게 처리하는가?’(데이터 부정합 처리)가 트랜잭션의 핵심이다.

1. Prisma 클라이언트에서 제공하는 $transaciton

프리즈마에서는 따로 트랜잭션을 사용할 수 있는 함수를 제공하고 있다.

prisma클라이언트를 서비스에서 불러와준 후 해당 클라이언트에서 $transaction함수를 호출 한 뒤 내부의 프리즈마 클라이언트를 사용해주면 된다.

이 때 주의할 점은 $transaction내부에서 호출한 프리즈마 클라이언트만 사용해야 한다. 외부의 프리즈마 클라이언트를 호출해 this.prisma를 쓰는 경우에는 트랜잭션이 동작하지 않는다.

export class SomeModelService {
  constructor(private prisma: PrismaService) {}
  
  async createSomeModel(data: someData): Promise<someModel> {
    return await this.prisma.$transaction(async (tx) => {
	    const someModel = await tx.someModel.create({data})
	    await tx.elseModel.create({data: {someModelUUID: someModel.uuid} })
	    return someModel
	  }
	}
}

위 코드대로 사용한다면 someModel이 생성 된 뒤 elseModel의 생성에 실패한다면 새로 생성된 someModel이 삭제된다.

다만 외래키로 연결되어있는 모델을 한 번에 만들 경우에는 Nested write를 사용해준다면 한 쿼리문으로 처리가 가능하다.

// schema.prisma
model SomeModel {
  uuid        String     @id @default(uuid())
  ...
  elseModel   ElseModel?
}

model ElseModel {
  uuid           String    @id @default(uuid())
  someModelUUID  String    @unique
  someModel      SomeModel @relation(fields: [someModelUUID], references: [uuid])
  ...
}

// some.service.ts

export class SomeModelService {
  constructor(private prisma: PrismaService) {}
  
  async createSomeModel(data: someData): Promise<someModel> {
    return await this.prisma.someModel.create({
      data: {
        ...data,
        elseModel: {
          create: {
	           ...
          }
        }
      },
      include: {
        elseModel: true
      }
    });
}

하지만 repository패턴을 적용하고 싶다면?

서비스에서 DB를 조작하는 로직을 따로 빼두고자 한다면 어떻게 해야할까?

가장 간단한 방식은 인자로 프리즈마 클라이언트를 받고 받지 않는다면 해당 클래스에 선언된 프리즈마 클라이언트를 사용하도록 구현하면 되겠지만…

interface TransactionClient: Omit<PrismaClient<Prisma.PrismaClientOptions, never, DefaultArgs>, "$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends">

export class SomeModelRepository {
  constructor(private prisma: PrismaService) {}

  async createSomeModel(
    data: Prisma.SomeModelCreateInput,
    prismaClient?: TransactionClient
  ): Promise<SomeModel> {
    const client = prismaClient || this.prisma;
    
    return client.someModel.create({
      data: {
        ...data,
        elseModel: {
          create: {
		        ...
          }
        }
      },
      include: {
        elseModel: true
      }
    });
  }
}

모든 레포지토리 함수에 다음과 같이 작성을 하기엔 너무 번거롭다. 다른 방법은 없을까?

2. Nest CLS + Prisma

nestjs-cls 패키지를 사용하자.

nestjs-cls(Continuation Local Storage)는 Node.js에서 제공해주는 공식적인 API인 Async Local Storage 를 사용해서 전역에서 사용할 수 있는 스토리지를 제공하는 패키지다. 비동기 코드, 또는 요청별로 고유한 컨텍스트를 유지할 수 있도록 해주는 패키지다.

프론트엔드로 따지자면 React의 Context API, zustand와 같이 전역 저장소와 비슷한 역할을 한다고 이해하면 될 것 같다.

Async Local Storage?

서버로 하나의 request가 들어오면 서버는 응답을 처리하기위해 여러 API를 호출하고 가공해 필요한 응답을 돌려준다. 하지만 request는 동시에 여러개가 들어오기 때문에 request별로 구분해서 모니터링 하는 작업이 필요하다.

NodeJS에서는 싱글 스레드로 동작을 하기 때문에 리퀘스트 별로 별도의 thread context가 존재하지 않는다. 따라서 특정 request단위마다 requestID를 생성해 할당하고 수행하는 작업마다 requestID를 함께 기록해 requestID 단위로 그룹핑해 확인하는 것.

그렇다면 이제 nestjs-cls/transactional-adapter-prisma 를 사용해 서버로 들어오는 요청당 프리즈마 클라이언트를 cls에 저장해 호출하는 방식으로 트랜잭션을 적용해보자.

Setup

pnpm add nestjs-cls @nestjs-cls/transactional @nestjs-cls/transactional-adapter-prisma
// app.module.ts
import { PrismaService } from './prisma/prisma.service';
import { ClsModule } from 'nestjs-cls';
import { ClsPluginTransactional } from '@nestjs-cls/transactional';
import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma';

@Module({
  imports: [
	  ...
    ClsModule.forRoot({
      plugins: [
        new ClsPluginTransactional({
          imports: [PrismaModule],
          adapter: new TransactionalAdapterPrisma({
            prismaInjectionToken: PrismaService,
          }),
        }),
      ],
      global: true,
    }),
    ...
  ],
  ...
})
export class AppModule {}

Usage

레포지토리 패턴을 사용할 것이므로 새로 레포지토리 파일을 만들어주고 모듈에서 선언해주자.

// someModel.repository.ts
@Injectable()
export class SomeModelRepository {
  constructor(
    private readonly txHost: TransactionHost<TransactionalAdapterPrisma>,
  ) {}

  async createSomeModel(data: someData) {
    return await this.txHost.tx.someModel.create({
      data: {
        ...someData
      },
    });
  }

  async createElseModel(
    someModelUuid: string,
  ) {
    return await this.txHost.tx.reservationOnUser.create({
      data: {
        someModelUuid: someModelUuid,
      },
    });
  }
}

// someModel.module.ts
@Module({
  ...
  providers: [..., ReservationRepository],
})
export class SomeModelModule {}

생성한 레포지토리를 서비스의 constructor에 선언해주고 서비스에 @Transactional 데코레이터를 상단에 선언해주면 트랜잭션이 적용된다.

// someModel.service.ts
...
import { SomeModelRepository } from './somemodel.repository';
import { Transactional } from '@nestjs-cls/transactional';

@Injectable()
export class SomeModelService {
  constructor(
    private someModelRepository: SomeModelRepository,
  ) {}
  
  @Transactional()
	async createSomeModel(data: someData): Promise<someModel> {
    const someModel = await this.someModelRepository.createSomeModel({data})
    await this.someModelRepository.createElseModel(someModel.uuid)
    return someModel
	}
}

참고

Transactions and batch queries (Reference) | Prisma Documentation

Prisma adapter | NestJS CLS

[MYSQL] 📚 트랜잭션(Transaction) 개념 & 사용 💯 완벽 정리

Asynchronous context tracking | Node.js v22.5.1 Documentation

AsyncLocalStorage

0개의 댓글