Node 강의/숙련

2-7 [게시판 프로젝트] Prisma Transaction

kagan-draca 2024. 9. 9. 17:32

1. Prisma의 Transaction

 

Prisma의 트랜잭션은 여러 개의 쿼리를 하나의 트랜잭션으로 수행할 수 있는

Sequential 트랜잭션과 Prisma가 자체적으로 트랜잭션의 성공과 실패를

관리하는 Interactive 트랜잭션이 존재합니다.

 

2. Sequential 트랜잭션

- Sequential 트랜잭션 1

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// Sequential 트랜잭션은 순차적으로 실행됩니다.
// 결과값은 각 쿼리의 순서대로 배열에 담겨 반환됩니다.
const [posts, comments] = await prisma.$transaction([
  prisma.posts.findMany(),
  prisma.comments.findMany(),
]);

 

- Sequential 트랜잭션 2

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// Sequential 트랜잭션은 순차적으로 실행됩니다.
// Raw Quyery를 이용하여, 트랜잭션을 실행할 수 있습니다.
const [users, userInfos] = await prisma.$transaction([
  prisma.$queryRaw`SELECT * FROM Users`,
  prisma.$queryRaw`SELECT * FROM UserInfos`,
]);

 

Sequentai 트랜잭션은 Prisma의 여러 쿼리를 배열([])로 전달받아,

각 쿼리들을 순서대로 실행하는 특징이 있습니다. 이러한 특징은

여러 작업이 순차적으로 실행되어야 할 때 사용할 수 있습니다.

 

3. Interactive 트랜잭션

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// Prisma의 Interactive 트랜잭션을 실행합니다.
const result = await prisma.$transaction(async (tx) => {
  // 트랜잭션 내에서 사용자를 생성합니다.
  const user = await tx.users.create({
    data: {
      email: 'testuser@gmail.com',
      password: 'aaaa4321',
    },
  });

  // 에러가 발생하여, 트랜잭션 내에서 실행된 모든 쿼리가 롤백됩니다.
  throw new Error('트랜잭션 실패!');
  return user;
});

 

Interactive 트랜잭션은 모든 비즈니스 로직이 성공적으로 완료되거나 에러가 발생한

경우 Prisma 자체적으로  Commit 또는 RollBack을 실행하여 트랜잭션을 관리하는

장점을 가지고 있습니다.

 

Interactive 트랜잭션은 트랜잭션 진행 중에도 비즈니스 로직을 처리할 수 있어,

복잡한 쿼리 시나리오를 효과적으로 구현할 수 있습니다.

 

이때, $transation() 메서드의 첫 번째 인자 async(tx)는 저희가 일반적으로 사용하는

prisma 인스턴스와 같은 기능을 수행합니다.

 

4. 회원가입 API 트랜잭션 적용하기

 

 

사용자사용자 정보생성하는 과정에서 에러가 발생할 수 있기 때문에,

트랜잭션(Transaction)도입해 하나의 작업으로 묶어주겠습니다.

 

먼저,

import { Prisma } from "@prisma/client";

을 import 받은 후,

 

 

// User 테이블에 값 넣기
    const user = await prisma.Users.create({
      data: {
        email,
        password: hashedPassword,
      },
    });

    // UserInfos 테이블에 값 넣기
    const userInfo = await prisma.UserInfos.create({
      data: {
        userId: user.userId,
        //생성한 유저의 userId를 바탕으로 사용자 정보를 생성합니다.
        name,
        age,
        gender: gender.toUpperCase(), //성별 대문자로 변환
        profileImage,
      },
    });

기존 작업 물을 복사한 뒤

 

const [첫번째 테이블 결과, 두번째 테이블 결과] = await prisma.$transaction(
  async tx => {

    //기존 작업 물 붙여 넣기

    // 콜백 함수의 리턴값으로 사용자와 사용자 정보를 반환합니다.
    return [ 번째 테이블 결과, 번째 테이블 결과];
  },

  {
    isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
  }
);

위와 같은 틀에 붙여넣습니다.

 

그 후, 우리는 transaction을 사용해야하기 때문에,

 

prisma의 각 테이블 조회 부분을

const user = await prisma.Users.create({
const userInfo = await prisma.UserInfos.create({
const user = await tx.Users.create({
const userInfo = await tx.UserInfos.create({

으로 수정해주고,

 

return [ 번째 테이블 결과, 번째 테이블 결과];

        return [user, userInfo];

으로 테이블 조회 결과를 배열 분해 및 할당으로 전달합니다.

 

전체 코드 : 

    const [user, userInfo] = await prisma.$transaction(
      async tx => {
        // User 테이블에 값 넣기
        const user = await tx.Users.create({
          data: {
            email,
            password: hashedPassword,
          },
        });

        // UserInfos 테이블에 값 넣기
        const userInfo = await tx.UserInfos.create({
          data: {
            userId: user.userId,
            //생성한 유저의 userId를 바탕으로 사용자 정보를 생성합니다.
            name,
            age,
            gender: gender.toUpperCase(), //성별 대문자로 변환
            profileImage,
          },
        });
        return [user, userInfo];
      },
      {
        isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
      }
    );

 

위의 코드를 분석해보면, 

await prisma.$transaction(

 

prisma.$transaction으로 트랜직션을 사용할 것을 명시적으로 작성합니다.

그리고,

async tx =>
const user = await tx.Users.create({
const userInfo = await tx.UserInfos.create({

해당 tx(transaction) 값으로 해당 쿼리 내부를 실행시킵니다.

 

마지막으로, 최종 로직이 완료 됐을 때

        return [user, userInfo];

결과를 반환되고,

    const [user, userInfo] = await prisma.$transaction(

에 할당 됩니다.

 

      {
        isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
      }

은 격리수준으로 

isolationLevel

키를 바탕으로,

Prisma.TransactionIsolationLevel.ReadCommitted,

Prisma의 TransactionIsolationLevel을 ReadCommitted으로 지정해줍니다.

 

TranactionIsolationLevel에는

 

  • ReadUncommitted : 트랜잭션이 아직 커밋되지 않은 데이터도 읽을 수 있다.
  • ReadCommitted : 실제 커밋된 결과값만 읽을 수 있도록 한다.
  • RepeatableRead : 트랜잭션이 시작된 후 데이터를 다시 읽을 때 동일한 결과가 보장됩니다.
  • Serializeable : 가장 높은 격리 수준으로, 트랜잭션이 직렬화된 것 처럼 실행됩니다.                                                                                모든 종류의 일관성 문제를 방지하지만 성능에 큰 영향을 줄 수 있습니다.

 

이 존재합니다.

 

이제 Insomnia에서 회원가입을 진행하면

 

위와 같이 정상적으로 회원가입이 완료되고,

 

log.middleware.js 를 통해

 

Transaction의ISOLATION LEVEL이 READ COMMITTED이라는 걸 확인할 수 있습니다.

 

만약 고의적으로 Error를 발생시킬 경우,

 

트랜잭션이 ROLLBACK되는걸 확인할 수 있습니다.

 

5. 사용자 히스토리 모델 생성하기

사용자 히스토리(UsersHistories) 테이블이 생성되었습니다.

 

이 테이블은 사용자의 정보가 변경될 때 마다 변경 내역을 로깅(Logging) 하기 위해 사용 됩니다.

사용자 정보 변경 API를 구현하면서, 이 변경 내역을 사용자 히스토리 테이블에도 함께 데이터를

생성하도록 구현하겠습니다.

 

model UserHistories{
  userHistoryId String @id @default(uuid()) @map("userHistoryId")

  //uuid()는 실제 생성된 데이터의 날짜나 시간 등 다양한 정보를 제공하는 식별자입니다.
  userId Int @map("userId")
  changedField String @map("changedField")
  oldValue String? @map("oldValue")
  newValue String @map("newValue")
  changedAt DateTime @updatedAt @map("changedAt")

  // Users 테이블과 관계를 설정합니다.
  user Users @relation(fields: [userId], references: [userId], onDelete : Cascade)

  @@map("UserHistories")
}

 

내용을 schema.prisma에 추가해줍니다.

 

그리고, 

 

model Users {

   ~~~~

  userHistories UserHistories[] // 사용자(Users) 테이블과 사용자 히스토리(UserHistories) 테이블이 1 : N 관계를 맺습니다.
}

을 추가시켜줍니다.

 

위와 같이 수정 및 작성을 완료한 후,

 

npx prisma db push를 수행해줍니다.

 

UUID(범용 고유 식별자)란 무엇인가요?

총 4가지 정보하이픈(-)으로 구분하여 순차적으로 저장한 데이터 타입입니다.

시간 정보를 포함하고 있어 새성된 순서대로 정렬이 되는 특징을 가지고 있습니다.

 

사용자 히스토리 테이블은 UUID를 사용하여 컬럼 수를 최소화

하는 것이 로그 테이블에서는 더욱 효율적인 설계가 될 것입니다

 

6. 사용자 정보 변경 API

// 사용자 정보 변경 API
UsersRouter.patch("/users", authMiddleware, async (req, res, next) => {
  try {
    const { userId } = req.user;
    const updatedData = req.body;

    const userInfo = await prisma.userInfos.findFirst({
      where: {
        userId: +userId,
      },
    });
    if (!userInfo)
      return res
        .status(404)
        .json({ mssage: "사용자 정보가 존재하지 않습니다." });

    await prisma.$transaction(
      async tx => {
        await tx.UserInfos.update({
          where: {
            userId: +userId,
          },
          data: {
            ...updatedData,
            //spread 연산자로 변경사항이 있는 값만 변경해준다.
          },
        });

        //반복문과 조건문으로 변경이 있는 데이터만 변경을 조회한다.
        for (let key in updatedData) {
          if (userInfo[key] !== updatedData[key]) {
            await tx.UserHistories.create({
              data: {
                userId: +userId,
                changedField: key,
                // key가 변경된 값이기 때문에 key를 제공한다.
                oldValue: String(userInfo[key]),
                // 이전 값은 userInfo에 있으니깐 userInfo[key]로 값을 전달
                newValue: String(updatedData[key]),
                // 새로운값은 updatedData가 갖고 있어 때문에 updatedDaya[key]로 값을 전달
              },
            });
          }
        }
      },
      {
        isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
      }
    );

    return res
      .status(200)
      .json({ massage: "사용자 정보 변경에 성공하였습니다." });
  } catch (err) {
    next(err);
  }
});

 

Insomnia에서 변경을 시켜보면,

변경전
변경 후
변경된 결과 확인
History Table 변경 정보

위와 같이 변경이 잘 이뤄지고, History Table에도 잘 반영된 것을 확인할 수 있습니다.