티스토리 뷰

server/Node.js

[prisma] Transaction 적용하기

지제로 2023. 3. 11. 20:22

Transaction이란?

예를 들어 하나의 데이터에 두 개의 접근이 있다고 가정해보자

A가 B에게 돈을 입금한다.

-> A의 계좌에서 해당 금액만큼 차감한다.
-> B의 계좌에서 해당 금액만큼 추가한다.

두 개처럼 보이지만 하나의 과정이 Transaction

→ 둘 중의 하나라도 실패한다면 이 과정의 이전으로 상태를 돌려야한다.

ACID

트랜잭션의 특징을 줄여서 ACID라고 부른다.

ACID는 줄임말로 각각 원자성, 일관성, 독립성, 지속성을 의미한다.

  1. Atomicity
  • 원자성으로 트랜잭션이 DB에 모두 반영되거나, 모두 반영되지 않아야 한다.
  1. Consistency
  • 일관성으로 작업 처리 결과가 늘 일관성 있어야 한다.
  1. Isolation
  • 독립성으로 트랜잭션이 여러 개가 동시에 실행되고 있는 경우 다른 트랜잭션의 연산에 끼어들 수 없다.
  1. Durability
  • 지속성으로 트랜잭션이 완료되었을 때 결과가 반영되야야 한다.

Commit, Rollback

  1. Commit - 트랜잭션이 성공적으로 끝나 DB가 일관성 있는 상태일 때를 의미한다.
  2. Rollback - 트랜잭션이 비정상적으로 종료되어 원자성이 깨진 경우 다시 시작하거나 처리된 결과를 취소시킨다.

 

그러면 언제 트랜잭션이 필요할까?

앱잼 내 두 과정에서 트랜잭션이 필요했다.

  1. 동시에 하나의 데이터가 수정되는 경우 → transaction 필요
  2. 사진 2장을 저장할 때 한장만 저장되는 경우 → transaction 필요

 

이제 Transaction을 사용해보자!

3개의 경우에 따라 이용할 수 있다.

  1. Dependent writes → Nested writes
  2. Independent writes → $transaction API, batch operations
  3. Read, modify, write → Idempotent operations, Optimistic concurrency control, Interactive transactions
  • Dependent Writes - Nested writes

✅ 동시에 ID에 관련된 2개 이상의 데이터를 만들고 싶을 때 사용한다.

✅ 동시에 ID에 관련된 2개 이상의 데이터를 수정하고 생성하고 싶을 때 사용한다.

const createVote = async (...) => {
    const data = await prisma.vote.create({
        data: {
           ...
            status: true,
            count: 0,
            created_at: dayjs().add(9, "hour").format(),
            date: +dayjs().format("YYYYMM"),
           ** Picture: {
                create: [
                    { url: voteDTO.pictures[0], count: 0 },
                    { url: voteDTO.pictures[1], count: 0 },
                ],
            },**
        },
    });

상황: 투표 테이블에 데이터를 추가한 후에 사진 테이블에 해당 url을 추가한다.

→ Transaction 중 Nested writes를 이용하여 Picture 테이블에 저장하여 한번에 과정이 이루어지도록 구현했다.

  • Independent writes - Bulk operations

✅ 하나의 트랜잭션에서 같은 타입의 여러 레코드를 쓸 때 사용한다.

✅ updateMany, deleteMany

await prisma.email.updateMany({
  where: {
    user: {
      id: 10,
    },
    unread: true,
  },
  data: {
    unread: false,
  },
})

위의 예시처럼 한번에 여러 데이터에 대해서 true과 false 값을 줄 수 있다.

  • Independent writes - $transaction API

✅ 하나의 트랜잭션으로 여러 작업을 실행할 수 있다.

✅ 독립적인 쓰기를 돕는 일반적인 방법이다.

const deletePicture = prisma.picture.deleteMany({
	...
});

const deleteVote = prisma.vote.deleteMany({
	...
});

const deleteUser = prisma.user.delete({
	...
});

await prisma.$transaction([deletePicture, deleteVote, deleteUser])

deletePicture, deleteVote, deleteUser → 이 세개의 함수를 하나의 트랜잭션으로 다 같이 성공하거나 다 같이 실패한다.

  • Read, modify, write - Idempotent(멱등성) APIs

✅같은 파라미터로 같은 로직을 여러 번 실행하여 동일한 결과를 얻을 수 있다.

✅ 멱등성이란 이름 그대로 연산을 여러 번 적용하더라도 결과가 달라지지 않는다.

const teamId = 9
const planId = 'plan_id'

// Count team members
const numTeammates = await prisma.user.count({
  where: {
    teams: {
      some: {
        id: teamId,
      },
    },
  },
})

// Create a customer in Stripe for plan-9454549
const customer = await stripe.customers.create({
  externalId: teamId,
  plan: planId,
  quantity: numTeammates,
})

// Update the team with the customer id to indicate that they are a customer
// and support querying this customer in Stripe from our application code.
await prisma.team.update({
  data: {
    customerId: customer.id,
  },
  where: {
    id: teamId,
  },
})

위의 코드는 겉으로 보기엔 문제가 없어 보인다.

하지만 여러 번 연산을 하게 되면 문제가 발생한다.

  1. Team의 externalId가 이미 존재하면 customerId를 반환받을 수 없다.
  2. Team의 externalId가 unique한 값이 아니기에 다른 subscription을 생성한다. → 멱등성을 지키지 않는다.
// Calculate the number of users times the cost per user
const numTeammates = await prisma.user.count({
  where: {
    teams: {
      some: {
        id: teamId,
      },
    },
  },
})

// Find customer in Stripe
let customer = await stripe.customers.get({ externalId: teamID })

if (customer) {
  // If team already exists, update
  customer = await stripe.customers.update({
    externalId: teamId,
    plan: 'plan_id',
    quantity: numTeammates,
  })
} else {
  customer = await stripe.customers.create({
    // If team does not exist, create customer
    externalId: teamId,
    plan: 'plan_id',
    quantity: numTeammates,
  })
}

// Update the team with the customer id to indicate that they are a customer
// and support querying this customer in Stripe from our application code.
await prisma.team.update({
  data: {
    customerId: customer.id,
  },
  where: {
    id: teamId,
  },
})

그렇기에 위의 코드로 수정하는 것이 바람직하다.

멱등성을 지키기 위해 먼저 get을 통해 customer를 받아와 상황에 맞는 함수를 실행하는 것이 좋다.

  • Read, modify, write - Optimistic concurrency control

✅ OCC는 locking에 의존하지 않는 하나의 객체에 대한 동시 작업을 수행하는 모델이다.

✅ 동시에 요청 수가 많을 때, 동시 요청 간의 충돌이 거의 발생하지 않기 위해 사용한다.

const movieName = 'Hidden Figures'

// Find first available seat
const availableSeat = await prisma.seat.findFirst({
  where: {
    movie: {
      name: movieName,
    },
    claimedBy: null,
  },
})

// Throw an error if no seats are available
if (!availableSeat) {
  throw new Error(`Oh no! ${movieName} is all booked.`)
}

// Claim the seat
await prisma.seat.update({
  data: {
    claimedBy: userId,
  },
  where: {
    id: availableSeat.id,
  },
})

영화관 좌석을 예시로 본다면 두 사람에게 A라는 좌석이 보이고, 두 사람 모두 A라는 좌석을 선택할 수 있다. 첫 번째 사람이 선택하였더라도 시스템에선 결과적으로 두 번째 사람의 선택만이 남게 된다. 이를 version을 통해 관리할 수 있다.

const userEmail = 'alice@prisma.io'
const movieName = 'Hidden Figures'

// Find the first available seat
// availableSeat.version might be 0
const availableSeat = await client.seat.findFirst({
  where: {
    Movie: {
      name: movieName,
    },
    claimedBy: null,
  },
})

if (!availableSeat) {
  throw new Error(`Oh no! ${movieName} is all booked.`)
}

// Only mark the seat as claimed if the availableSeat.version
// matches the version we're updating. Additionally, increment the
// version when we perform this update so all other clients trying
// to book this same seat will have an outdated version.
const seats = await client.seat.updateMany({
  data: {
    claimedBy: userEmail,
    version: {
      increment: 1,
    },
  },
  where: {
    id: availableSeat.id,
    version: availableSeat.version, // This version field is the key; only claim seat if in-memory version matches database version, indicating that the field has not been updated
  },
})

if (seats.count === 0) {
  throw new Error(`That seat is already booked! Please try again.`)
}

version을 통해 첫 번째 사람과 두 번째 사람에게 둘 다 version이 0인 A좌석이 보인다. 첫 번째 사람이 예약한 순간 version이 0에서 1로 바뀌게 된다. 두 번째 사람이 A좌석을 예약할 때 DB의 version과 맞지 않아 예약에 실패하게 된다.

  • Read, modify, write - Interactive transactions

✅ $transaction에 비동기 함수를 전달한다.

✅ 비동기 함수에 전달되는 첫 번째 인자는 PrismaClient의 인스턴스(= tx)이다.

tx에서 호출된 모든 Prisma 호출은 트랜잭션으로 캡슐화된다.

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

async function transfer(from: string, to: string, amount: number) {
  return await 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 = tx.account.update({
      data: {
        balance: {
          increment: amount,
        },
      },
      where: {
        email: to,
      },
    })

    return recipient
  })
}

async function main() {
  // This transfer is successful
  await transfer('alice@prisma.io', 'bob@prisma.io', 100)
  // This transfer fails because Alice doesn't have enough funds in her account
  await transfer('alice@prisma.io', 'bob@prisma.io', 100)
}

main()
  .then(async () => {
    await prisma.$disconnect()
  })
  .catch(async (e) => {
    console.error(e)
    await prisma.$disconnect()
    process.exit(1)
  })

하나의 트랜잭션으로 묶어 처리할 수 있으며 함수의 끝에 도달한 경우 트랜잭션이 commit되고, 함수의 끝에 error를 만나게 되면 비동기 함수는 예외를 던져 자동적으로 트랜잭션이 rollback된다.

const data = await prisma.$transaction(async (tx) => {
            const sticker = await findSticker(
                tx,
                stickerCreateDto.pictureId,
                stickerCreateDto.emoji
            );
					...
            });

위의 코드도 마찬가지로 interactive transaction을 이용하여 하나의 트랜잭션으로 묶어 실행하게 구현하였습니다.

 

 

참고: https://www.prisma.io/docs/guides/performance-and-optimization/prisma-client-transactions-guide

구현한 코드: https://github.com/Pic-me-Pic-me/Pic.me-server

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
글 보관함