Prisma - interactive transactions 문제

저뉼(스님?)·2023년 3월 23일
2

나만의 문제해결

목록 보기
1/3
post-custom-banner

Spring, JPA에서는 보통 간단히 서비스 클래스의 메서드에 @Transactional 어노테이션을 붙이면 되는데,
NestJS, Prisma에서는 그 정도까지 추상화가 되어 있지 않아서 직접 Prisma client를 사용해야 함.

( 자세한 사용법은 공식 문서가 잘 되어 있으니 다음 링크로 대체: The $transaction API )

문제

Prisma를 사용할 때, 트랜잭션 처리할 작업에 로직이 필요하면 interactive transactions(^4.7.0)를 사용하게 됨.
이때 아래와 같은 형태로 코드를 작성하게 되는데,

@Injectable()
export class ContractsService {
  constructor(
    private readonly prisma: PrismaService, // client 주입
    private readonly contractsRepository: ContractsRepository,
    private readonly bookingsRepository: BookingsRepository,
  ) {}
  
  async create(createContractDto: CreateContractDto) {
    await this.prisma.$transaction(async (tx) => { // `tx` 이놈
      // 트랜잭션으로 처리할 작업들
    });
  }
}

문제는,
기껏 repository 계층 만들어서 서비스 계층이 Prisma에 의존 안 하게 만들어 놨더니 트랜잭션 때문에 의존하게 됨.
그리고 저 tx(트랜잭션용 프리즈마 클라이언트)를 계속 들고 다녀야 함. 다시 말해, repository들에게 tx를 전달하고 그걸 통해서 쿼리를 날리게 해야 했음. ( 여기서 또 두 가지 문제가 발생하는데 아래 해결 부분에서 자세히 )

만약에,
tx를 안 쓰고 그냥 repository에서 주입받던 prisma client를 사용하면 어떻게 될까?
트랜잭션 안에 거듭 트랜잭션이 생길 수 있고 그럼 의도대로 동작하지 않음.
아래는 그 예시.

Query: BEGIN
Params: []
Duration: 0ms

Query: BEGIN
Params: []
Duration: 0ms

Query: INSERT INTO `main`.`Movie` (`title`, `director`, `year`, `createdAt`, `updatedAt`) VALUES (?,?,?,?,?) RETURNING `id` AS `id`
Params: ["너의 이름은","신카이 마코토",2017,"2023-04-13 01:57:45.154 UTC","2023-04-13 01:57:45.154 UTC"]
Duration: 0ms
Query: SELECT `main`.`Movie`.`id`, `main`.`Movie`.`title`, `main`.`Movie`.`director`, `main`.`Movie`.`year`, `main`.`Movie`.`createdAt`, `main`.`Movie`.`updatedAt` FROM `main`.`Movie` WHERE `main`.`Movie`.`id` = ? LIMIT ? OFFSET ?
Params: [1,1,0]
Duration: 0ms

Query: COMMIT
Params: []
Duration: 0ms

Query: ROLLBACK
Params: []
Duration: 0ms

트랜잭션 안에 또 트랜잭션이 생겼고, 바깥쪽 트랜잭션이 롤백되었지만 안쪽 트랜잭션이 커밋되어 소용없게 됨.
( 안쪽 트랜잭션이 생기는 이유에 관해서는 Nested writes 참고 )

아래 Prisma 공식 슬랙 답변도 참고

문제 해결

다시 tx를 사용할 때의 문제로 돌아가서,

일단 tx를 repository까지 전달해야하는 구조 자체가 매우 성가심.
repository를 사용하는 로직을 따로 함수로 추출한다면 마치 React의 prop drilling을 겪는 느낌을 받음.
그리고 repository들에게 tx를 전달해야 하니, repository 메서드에 tx를 받을 파라미터를 마련해 둬야 하고, 들어오는 값이 있는지 체크해서 어떤 client를 사용할지 결정하게 해야 함.

게다가 repository가 tx를 그대로 받으면 그 tx를 통해 다른 도메인에도 쉽게 접근할 수 있게 되고, 내가 퇴사하고 훗날 누군가 편의상 쉽게 다른 도메인을 수정해 버릴 수도 있지 않을까 불안함. 또한 repository 간에 비슷한 코드를 복붙하다 보면 의도치 않게 다른 도메인을 수정하는 버그(직접 겪음..)도 발생할 수 있음.

일단 구조는 그대로 두고,

두 번째 문제와 관련하여 제한을 좀 가하고 싶어서 아래와 같이 repository에게 자기 도메인에 해당하는 일부만 전달함.

this.contractsRepository.create({}, tx.contract); // `tx` 대신 `tx.contract` 전달

사실, 기존에 repository가 주입 받던 prisma client도 버그 예방과 도메인 접근 제한을 위해 생성자에서 이런 식으로 일부만 할당해서 사용했음.

결과적으로 repository의 코드는 아래와 같아짐.

@Injectable()
export class ContractsRepository {
  // ...
  
  create(
    input: Prisma.ContractCreateInput,
    txPrismaContract?: PrismaService['contract'],
  ) {
    const prismaContract = txPrismaContract ?? this.prismaContract;
    // ...
  }
}

( 다시 보니 create()Prisma.ContractCreateInput을 받는 게 맞는지는 좀 의문. 데이터만 전달하는 게 맞으려나? )


구조에 관하여,
김영한님의 강의(스프링 DB 1편 - 데이터 접근 핵심 원리)를 듣다가 과거 스프링 이전 JDBC 시절 이미 같은 고민을 했었다는 사실을 알게 됨.

저기 findById()에 전달되는 con이 우리의 tx와 비슷한 놈.

이를 해결하기 위해 스프링에서는 트랜잭션 매니저와 트랜잭션 동기화 매니저를 이용하는데,
( 💭스프링에서는 리포지토리에서 커넥션을 가져갈 수 있도록 쓰레드 로컬을 이용해 커넥션을 저장해 둔다는데 뭔가 프론트에서 Redux 같은 거 이용하는 것과 본질적으로 일맥상통하는 건가.. )
같은 식으로 한번 해볼랬더니 $transaction() 스코프로 트랜잭션이 끝나버려서 tx를 빼내어 저장한들 의도대로 안 됨.
그래서 비지니스 로직을 함수로 감싸서 전달하여 $transaction() 스코프 안에서 실행하도록 해 보았다.

// 서비스 계층

const movies = await transaction(async () => { // 추상화된 transaction 함수
  await moviesRepository.findAll();    // tx 전달하지 않음
  return await myRepository.findAll(); // tx 전달하지 않음
});
prisma:query BEGIN
prisma:query SELECT `my-db`.`Movie`.`id`, `my-db`.`Movie`.`name` FROM `my-db`.`Movie` WHERE 1=1
prisma:query SELECT `my-db`.`Movie`.`id`, `my-db`.`Movie`.`name` FROM `my-db`.`Movie` WHERE 1=1
prisma:query COMMIT

이제 interactive transactions를 사용할 때 리포지토리 메서드에 tx를 전달하지 않아도 된다!

아래는 나머지 코드. 컨셉대로 작동하는지 확인하기 위해 대충 짜 본 코드인 것을 감안하기 바람.

// 위의 `transaction()` 함수 정의
import {PrismaClient} from '@prisma/client';
import {saveTx} from './tx-sync-manager';

export async function transaction(bizLogic: () => {}) {
  const prisma = new PrismaClient();
  return await prisma.$transaction(async tx => {
    saveTx(tx);
    return bizLogic();
  });
}
// 리포지토리 계층
import {getTx} from './tx-sync-manager';

export class MoviesRepository {
  async findAll() {
    return await getTx().movies.findMany();
  }
}
// tx-sync-manager.ts
import * as runtime from '@prisma/client/runtime/library';
import {PrismaClient} from '@prisma/client';

let txClient: Omit<PrismaClient, runtime.ITXClientDenyList>;

export function saveTx(tx: Omit<PrismaClient, runtime.ITXClientDenyList>) {
  txClient = tx;
}

export function getTx() {
  return (
    txClient ??
    new PrismaClient()
  );
}
post-custom-banner

3개의 댓글

comment-user-thumbnail
2024년 5월 17일

저랑 같은 고민을 먼저 하셨군요. 잘 보고 갑니다!

마지막 방법은 생각도 못했는데 좋은 방법인 것 같습니다. 지금도 마지막 방법으로 사용하고 계시나요?

1개의 답글