개빠르게 해야함
최근에 스크럼을 하면서 트랜잭션에 대한 이야기를 나누었는데, 트랜잭션을 사용할때 간과하고 있었던 점을 깨닫게 되어 공유해보려고 한다.
회사에서는 매일 스크럼을 진행한다. 간단하게 업무 내용을 공유하고, 논의해야하거나 고민중인 문제들을 같이 얘기해보곤한다. 오늘도 여느때와 다름 없는 스크럼이었는데, 색다른 주제가 나왔다. 바로 트랜잭션 안에 비즈니스 로직을 넣어도 될지 말지에 대한 주제 였다.
회사에서는 Nestjs와 ORM으로 prisma를 주로 사용한다. prisma에서는 트랜잭션을 2가지 방법으로 지원하는데, 그 중 어떤 방법을 사용할지에 대한 판단이 쉽지 않다는 것이었다.
prisma에서 transaction을 사용하는 방법은 쿼리를 함수들을 리스트로 받아 트랜잭션 처리하는 방법과, 트랜잭션의 인자로 익명 함수를 받아 그 안에 트랜잭션에서 처리하고 싶은 쿼리들을 넣어 실행하는 방법이 있다. 코드로 예시를 들어보자면은 아래와 같다.
첫번째 방법을 코드로 예를 들어보자면 아래와 같다.
const [posts, totalPosts] = await prisma.$transaction([
prisma.post.findMany({ where: { title: { contains: 'prisma' } } }),
prisma.post.count(),
])
$transaction이 트랙잭션을 적용하는 메소드인데, 인자로 함수의 리스트를 받아 전달 받은 함수를 모두 하나의 트랜잭션에서 처리한다.
이어서 두번째 방법의 예시이다.
prisma.$transaction(async (tx) => {
// 1. Decrement amount from the sender.
const sender = await tx.account.update({
data: {
balance: {
decrement: amount,
},
},
where: {
email: from,
},
})
// 2. Verify that the sender's balance didn't go below zero.
if (sender.balance < 0) {
throw new Error(`${from} doesn't have enough to send ${amount}`)
}
// 3. Increment the recipient's balance by amount
const recipient = await tx.account.update({
data: {
balance: {
increment: amount,
},
},
where: {
email: to,
},
})
return recipient
})
$transaction 메소드가 이번엔 익명함수를 인자로 받아 단순히 함수를 트랜잭셔널하게 처리한다.
개인적으로 첫인상은 두번째 방법이 좋아보였다. 다들 비슷한 생각이지 않을까?
두번째 방법을 사용하면 내가 수행시키고 싶은 로직을 그대로 함수에 담아 인자로 넘기면 된다. 어찌보면 트랜잭션을 사용하지 않는 느낌이 들기도 한다. 그냥 일반 함수를 실행시키는데 알아서 트랜잭션이 되는 그런 마법같은 느낌?
하지만 두번째 방법에는 커다란 문제점이 있다. 바로 쿼리와 쿼리 결과를 서버와 DB가 주고 받는 동안에도 트랜잭션이 지속되고 있다는 점(...!)이다.
이것이 그렇게 큰 문제가 되냐고? 큰 문제가 된다. 왜냐면 트랜잭션이 지속되는 동안 락이 걸리기 때문이다. 만약 트래픽이 많은 공간인 경우 동시다발적인 요청이 수시로 들어올것인데, 각 요청마다 락이 걸린다면 대기시간이 선형으로 증가할것이다.
물론 이는 트랜잭션 공통이긴 하나, 두번째 방법 처럼 쿼리 함수 뿐만 아니라 어떤 로직이든 들어가는 것이 가능한 경우 쿼리 실행 시간이 아닌 다른 원인으로 트랜잭션이 종료되기까지의 시간이 증가하는 것이 너무나도 가능하다.
스레드 1이 DB와 티키타카하는 동안 스레드2는 하염없이 기다릴 수 밖에 없다. 만약 스레드3가 생긴다면 얘는 또 스레드2를 목빠져라 기다릴것이다🥲.
그에 반해,
첫번째 방법은 트랜잭션 안에서 서버와 티키타카하는 것 없이 DB에서 트랜잭셔널하게 쿼리만 수행하고 결과만 서버에 던져주면 되기 때문에 상대적으로 안전하다고 볼 수 있다.
현실에서 예를 들어보자면 고속도로의 하이패스와 대면 결제로 비유할 수 있지 않을까
차가 엄청 많은 명절 고속도로를 상상해 보았을때, 하이패스는 단 한번의 상호작용으로 슝하고 결제가 가능하다. 그와 반면에 대면 결제는 티키타카가 많다. 인사하고, 카드 결제인지 물어보고, 현금이면 거스름돈 세고..🤦♂️
동시처리가 되지 않으니, 줄을 설수 밖에 없고, 앞에 차의 용무가 길어질 수록 뒷 차의 대기 시간은 길어져만 간다. 물론 대면 결제는 하이패스보다 다양한 요구사항을 충족할 수 있겠지만, 하이패스로 가능한 요구사항이라면 하이패스를 사용하는게 더 이득일것이다.
사실 저는 명절에 고속도로에 가질 않습니다.
그렇기 때문에 트래픽이 많은 환경이라면 두번째 사용을 지양하는 것이 좋을 것이라고 팀장님이 조언을 해주셨다. 덧붙여서 트랜잭션은 빠르게 실행하고 빠르게 끝낼 수 있는 확신이 있을때에만 사용하는게 좋다고 말씀해주셨다.
예시를 prisma라는 ORM 환경에 국한하여 제시하였지만, 다른 ORM에서도 충분히 적용될 수 있는 사항이지 않을까 한다.
특히, ORM은 아니지만 raw query를 사용하는 환경에서는 더욱 더 잘 발생할 수 있는 환경이 아닐까
트랜잭션=락이라는 인식을 확실히 가져야할 것 같다. 연속적으로 실행되어야하는 쿼리를 짜야할때, 중간에 실패하면 모두 없던일로 돌리고 싶기에, 마법을 바라는 마음으로 트랜잭션을 찾게되더라.
물론 트랜잭션을 안 쓸 수는 없겠지만, 안쓰면 너무 위험할때(ex. 결제) 말고는 최대한 서버 단에서 에러에 대한 후처리를 하는것이 성능과 안정성 사이의 가장 좋은 타협이지 싶다. 또는 에러가 발생하는 것 자체는 감수하고 그 에러에 대한 대응 방법을 최대한 쉽게 하는 것도 고려해봄직 할것 같다.
그래서 내린 오늘의 찐 결론
➡️ 트랜잭션이 실행되는 동안에는 쿼리만 실행되도록 하자. 그리고, 트랜잭션 사용은 최대한 피하자(특히 트래픽이 많은 환경). 일단 서버에서 해결해보고 정 불가피하면 그 때 고민해보자.