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()
);
}
저랑 같은 고민을 먼저 하셨군요. 잘 보고 갑니다!
마지막 방법은 생각도 못했는데 좋은 방법인 것 같습니다. 지금도 마지막 방법으로 사용하고 계시나요?