Prisma Transaction

윤태규·2024년 1월 9일

01. [게시판 프로젝트] Prisma Transaction

  • 1) [게시판 프로젝트] Prisma의 Transaction 💡 **Prisma**의 트랜잭션은 여러개의 쿼리를 **하나의 트랜잭션**으로 수행할 수 있는 **Sequential 트랜잭션**과 **Prisma**가 자체적으로 트랜잭션의 **성공**과 **실패**를 관리하는 **Interactive 트랜잭션**이 존재합니다. - **[Sequential 트랜잭션](https://www.prisma.io/docs/concepts/components/prisma-client/transactions#sequential-prisma-client-operations)** ```jsx import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); // Sequential 트랜잭션은 순차적으로 실행됩니다. // 결과값은 각 쿼리의 순서대로 배열에 담겨 반환됩니다. const [posts, comments] = await prisma.$transaction([ prisma.posts.findMany(), prisma.comments.findMany(), ]); ``` **Sequential 트랜잭션**은 Prisma의 여러 쿼리를 **배열**(`[]`)로 전달받아, 각 쿼리들을 **순서대로 실행**하는 특징이 있습니다. 이러한 특징은 여러 작업이 **순차적으로 실행**되어야할 때 사용할 수 있습니다. ```jsx 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`, ]); ``` 또한, **Sequential 트랜잭션**은 Prisma의 모든 쿼리 메서드뿐만 아니라, [Raw Query](https://www.prisma.io/docs/concepts/components/prisma-client/raw-database-access#queryraw)도 사용할 수 있답니다. 🙂 - **[Interactive 트랜잭션](https://www.prisma.io/docs/concepts/components/prisma-client/transactions#interactive-transactions)** ```jsx 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` 인스턴스**와 같은 기능을 수행합니다. 💪 이번 챕터에서는 단순히 여러 쿼리를 트랜잭션 내에서 실행하는 것이 아니라, 트랜잭션 진행 중 **비즈니스 로직을 추가**하는 방법에 대해 알아볼 예정입니다. 저희는 두 가지 트랜잭션 중 **Interactive 트랜잭션**을 토대로 프로젝트를 진행해보도록 하겠습니다. 😊
  • 2) [게시판 프로젝트] 회원가입 API 트랜잭션 적용하기 📌 이번 챕터에서는 **회원가입** 기능을 수정하고, **사용자 정보 변경** 기능을 구현할 예정입니다. - **📚  [게시판 프로젝트] 트랜잭션 API 명세서** [제목 없는 데이터베이스](https://www.notion.so/6bd1099e65464dbcba2e5095cc57c728?pvs=21) 기존의 회원가입 API는 아래와 같은 비즈니스 로직을 가지고 있었습니다. 💡 **[게시판 프로젝트] 회원가입 API 비즈니스 로직** 1. `email`, `password`, `name`, `age`, `gender`, `profileImage`를 **body**로 전달받습니다. 2. 동일한 `email`을 가진 사용자가 있는지 확인합니다. 3. **Users** 테이블에 `email`, `password`를 이용해 사용자를 생성합니다. 4. **UserInfos** 테이블에 `name`, `age`, `gender`, `profileImage`를 이용해 사용자 정보를 생성합니다. 5. 회원가입을 완료 처리합니다. 여기서, 3️⃣  **사용자** 및 4️⃣  **사용자 정보**를 **생성**하는 과정에서 에러가 발생하게 될 경우 문제가 생길 수 있습니다. 이를 해결하기 위해, 저희는 **트랜잭션(Transaction)**을 도입할 예정입니다. 트랜잭션을 도입하면 여러 개의 쿼리를 하나의 작업으로 묶어, 하나의 쿼리가 실패할 경우 **전체 트랜잭션을 취소(`ROLLBACK`)**할 수 있어 **데이터의 일관성을 유지**할 수 있게 될 것입니다. 그렇다면, 트랜잭션을 도입하여 회원가입 API를 리팩토링해보도록 할까요? 😎 - **[코드스니펫] [게시판 프로젝트] 회원가입 트랜잭션 예시 코드** ```jsx // src/routes/users.router.js import { Prisma } from '@prisma/client'; /** 사용자 회원가입 API 트랜잭션 **/ router.post('/sign-up', async (req, res, next) => { try { const { email, password, name, age, gender, profileImage } = req.body; const isExistUser = await prisma.users.findFirst({ where: { email, }, }); if (isExistUser) { return res.status(409).json({ message: '이미 존재하는 이메일입니다.' }); } // 사용자 비밀번호를 암호화합니다. const hashedPassword = await bcrypt.hash(password, 10); // MySQL과 연결된 Prisma 클라이언트를 통해 트랜잭션을 실행합니다. const [user, userInfo] = await prisma.$transaction( async (tx) => { // 트랜잭션 내부에서 사용자를 생성합니다. const user = await tx.users.create({ data: { email, password: hashedPassword, // 암호화된 비밀번호를 저장합니다. }, }); // 트랜잭션 내부에서 사용자 정보를 생성합니다. const userInfo = await tx.userInfos.create({ data: { UserId: user.userId, // 생성한 유저의 userId를 바탕으로 사용자 정보를 생성합니다. name, age, gender: gender.toUpperCase(), // 성별을 대문자로 변환합니다. profileImage, }, }); // 콜백 함수의 리턴값으로 사용자와 사용자 정보를 반환합니다. return [user, userInfo]; }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, }, ); return res.status(201).json({ message: '회원가입이 완료되었습니다.' }); } catch (err) { next(err); } }); ``` - **❓ Prisma에서 격리 수준은 어떻게 설정할까요?** 💡 **Prisma의 [격리수준](https://www.prisma.io/docs/concepts/components/prisma-client/transactions#transaction-isolation-level)**은 트랜잭션을 생성할 때, `isolationLevel` 옵션을 정의함으로써 설정할 수 있습니다. ```jsx import { Prisma } from '@prisma/client'; await prisma.$transaction( async (tx) => { ... }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, }, ); ``` **격리 수준(Isolation Level)**을 설정할 때, 현재 구현하려는 API에는 어떠한 격리 수준이 필요한지 명확하게 이해해야 합니다. 이를 통해 효율적인 데이터베이스의 설계를 할 수 있고, 데이터의 일관성이 깨지지 않도록 구현할 수 있게 됩니다. **회원 가입 API**는 결제시스템과 같이 **높은 수준의 일관성**을 요구하지 않기 때문에, `READ_COMMITTED` 격리 수준을 사용하였습니다. 하지만, 나중에 높은 일관성이 필요하게 된다면, 격리 수준을 변경하면 되겠죠? 😉
  • 3) [게시판 프로젝트] 사용자 히스토리 모델 생성하기 [[게시판 프로젝트] 트랜잭션 ERD](https://drawsql.app/teams/no-55/diagrams/community-hub-history) [게시판 프로젝트] 트랜잭션 ERD 새롭게 사용자 히스토리(UserHisotires) 테이블이 생성되었습니다. 이 테이블은 사용자의 정보가 변경될 때마다 변경 내역을 로깅(Logging)하기 위해 사용됩니다. 사용자 정보 변경 API를 구현하면서, 이 변경 내역을 사용자 히스토리 테이블에도 함께 데이터를 생성하도록 구현해보겠습니다. 그럼 먼저, 사용자 히스토리 model을 Prisma로 구현해볼까요?
    • [코드스니펫] 사용자 히스토리(UserHistories) model 구현하기

      // schema.prisma
      
      model Users {
        userId    Int      @id @default(autoincrement()) @map("userId")
        email     String   @unique @map("email")
        password  String   @map("password")
        createdAt DateTime @default(now()) @map("createdAt")
        updatedAt DateTime @updatedAt @map("updatedAt")
      
        UserInfos     UserInfos? // 사용자(Users) 테이블과 사용자 정보(UserInfos) 테이블이 1:1 관계를 맺습니다.
        Posts         Posts[] // 사용자(Users) 테이블과 게시글(Posts) 테이블이 1:N 관계를 맺습니다.
        Comments      Comments[] // 사용자(Users) 테이블과 댓글(Comments) 테이블이 1:N 관계를 맺습니다.
        UserHistories UserHistories[] // 사용자(Users) 테이블과 사용자 히스토리(UserHistories) 테이블이 1:N 관계를 맺습니다.
      
        @@map("Users")
      }
      
      model UserHistories {
        userHistoryId String   @id @default(uuid()) @map("userHistoryId")
        UserId        Int      @map("UserId") // 사용자(Users) 테이블을 참조하는 외래키
        changedField  String   @map("changedField") // 변경된 필드명
        oldValue      String?  @map("oldValue") // 변경 전 값
        newValue      String   @map("newValue") // 변경 후 값
        changedAt     DateTime @default(now()) @map("changedAt")
      
        // Users 테이블과 관계를 설정합니다.
        User Users @relation(fields: [UserId], references: [userId], onDelete: Cascade)
      
        @@map("UserHistories")
      }
    • [코드스니펫] Prisma db push 명령어

      # schema.prisma에 정의된 모델 정보를 DB와 동기화합니다.
      npx prisma db push
    • 🧩  SQL과 비교하기

      CREATE TABLE Users
      (
          userId    INTEGER       NOT NULL AUTO_INCREMENT PRIMARY KEY,
          email     VARCHAR(191)  UNIQUE NOT NULL,
          password  VARCHAR(191)  NOT NULL,
          createdAt DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
          updatedAt DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
      ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
      
      CREATE TABLE UserInfos
      (
          userInfoId   INTEGER      NOT NULL AUTO_INCREMENT PRIMARY KEY,
          UserId       INTEGER      UNIQUE NOT NULL, -- 1:1 관계 이므로 UNIQUE 조건을 삽입합니다.
          name         VARCHAR(191) NOT NULL,
          age          INTEGER      NOT NULL,
          gender       VARCHAR(191) NOT NULL,
          profileImage VARCHAR(191) NULL,
          createdAt    DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
          updatedAt    DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
      ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
      
      ALTER TABLE UserInfos
          ADD CONSTRAINT FK_UserInfos_Users
              FOREIGN KEY (UserId) REFERENCES Users (userId) ON DELETE CASCADE;
      
      CREATE TABLE Posts
      (
          postId    INTEGER      NOT NULL AUTO_INCREMENT PRIMARY KEY,
          UserId    INTEGER      NOT NULL,
          title     VARCHAR(191) NOT NULL,
          content   VARCHAR(191) NOT NULL,
          createdAt DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
          updatedAt DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
      ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
      
      ALTER TABLE Posts
          ADD CONSTRAINT FK_Posts_Users
              FOREIGN KEY (UserId) REFERENCES Users (userId) ON DELETE CASCADE;
      
      CREATE TABLE Comments
      (
          commentId INTEGER      NOT NULL AUTO_INCREMENT PRIMARY KEY,
          UserId    INTEGER      NOT NULL,
          PostId    INTEGER      NOT NULL,
          content   VARCHAR(191) NOT NULL,
          createdAt DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
          updatedAt DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
      ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
      
      ALTER TABLE Comments
          ADD CONSTRAINT FK_Comments_Posts
              FOREIGN KEY (PostId) REFERENCES Posts (postId) ON DELETE CASCADE;
      
      ALTER TABLE Comments
          ADD CONSTRAINT FK_Comments_Users
              FOREIGN KEY (UserId) REFERENCES Users (userId) ON DELETE CASCADE;
      
      CREATE TABLE UserHistories
      (
          userHistoryId VARCHAR(191) NOT NULL DEFAULT (UUID()) PRIMARY KEY,
          UserId        INTEGER      NOT NULL,
          changedField  VARCHAR(191) NOT NULL,
          oldValue      VARCHAR(191) NULL,
          newValue      VARCHAR(191) NOT NULL,
          changedAt     DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
      
          PRIMARY KEY (`userHistoryId`)
      ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
      
      ALTER TABLE UserHistories
          ADD CONSTRAINT FK_UserHistories_Users
              FOREIGN KEY (UserId) REFERENCES Users (userId) ON DELETE CASCADE;
    • ❓ UUID(범용 고유 식별자)란 무엇인가요?

      ![UUID.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/cd5f5c73-52ec-4d98-8889-45eb9df67998/UUID.png)
      
      <aside>
      💡 **[UUID(Universally Unique Identifier, 범용 고유 식별자)](https://ko.wikipedia.org/wiki/%EB%B2%94%EC%9A%A9_%EA%B3%A0%EC%9C%A0_%EC%8B%9D%EB%B3%84%EC%9E%90)**는 총 4개의 정보를 **하이픈(`-`)** 으로 구분하여 순차적으로 저장한 데이터 타입입니다. **시간 정보를 포함**하고 있어 **생성된 순서대로 정렬**이 되는 특징을 가지고 있습니다.
      
      **[→ UUID 디코드 사용해보기](https://www.uuidtools.com/decode)**
      
      </aside>

      사용자 히스토리(UserHistories) 테이블은 사용자 정보에 대한 모든 변경 내역을 기록합니다. 추후에 API 호출 히스토리와 같은 추가 정보를 기록하는 다른 히스토리 테이블을 만들면, 더욱 다양한 데이터를 수집할 수 있겠죠?

      사용자 히스토리 테이블은 일반적인 다른 테이블과는 조금 다르게 설계해야합니다. **Integer 타입의 기본키나 createdAt, updatedAt과 같은 컬럼을 사용하기보다는, UUID를 사용하여 컬럼 수를 최소화** 하는 것이 로그 테이블에서는 더욱 효율적인 설계가 될 것입니다. ☺️

  • 4) [게시판 프로젝트] 사용자 정보 변경 API 구현하기 💡 **[게시판 프로젝트] 사용자 정보 변경 API 비즈니스 로직** 1. 게시글을 작성하려는 클라이언트가 로그인된 사용자인지 검증합니다. 2. 변경할 사용자 정보 `name`, `age`, `gender`, `profileImage`를 **body**로 전달받습니다. 3. **사용자 정보(UserInofes) 테이블**에서 **사용자의 정보들**을 수정합니다. 4. 사용자의 **변경된 정보 이력**을 **사용자 히스토리(UserHistories)** 테이블에 저장합니다. 5. 사용자 정보 변경 API를 완료합니다. **사용자 정보 변경 API**는 3️⃣ **사용자 정보의 수정**과 4️⃣  **사용자 히스토리 데이터 삽입**, 2개의 비즈니스 로직을 하나의 작업으로 처리해야합니다. 비즈니스 로직을 수행하는 도중 오류가 발생할 경우, 데이터의 일관성이 깨지게 될 수 있습니다. 이렇게 된다면, 사용자 히스토리 테이블의 **데이터들을 믿을 수 없게되는 상황이 발생**하게 됩니다. - **[코드스니펫] [게시판 프로젝트] 사용자 정보 변경 API** ```jsx // src/routes/users.router.js /** 사용자 정보 변경 API **/ router.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 }, }); await prisma.$transaction( async (tx) => { // 트랜잭션 내부에서 사용자 정보를 수정합니다. await tx.userInfos.update({ data: { ...updatedData, }, where: { UserId: userInfo.UserId, }, }); // 변경된 필드만 UseHistories 테이블에 저장합니다. for (let key in updatedData) { if (userInfo[key] !== updatedData[key]) { await tx.userHistories.create({ data: { UserId: userInfo.UserId, changedField: key, oldValue: String(userInfo[key]), newValue: String(updatedData[key]), }, }); } } }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, }, ); return res .status(200) .json({ message: '사용자 정보 변경에 성공하였습니다.' }); } catch (err) { next(err); } }); ``` 🔥 **트랜잭션의 기본 개념**과 **격리 수준(Isolation Level)**, **Prisma에서 트랜잭션**을 사용하는 방법 등 데이터베이스와 관련된 다양한 개념을 배우게되었습니다. 😉 데이터베이스는 어플리케이션을 개발한다면 필수적인 개념이기에, **데이터 정합성 문제나 동시성 문제**와 같은 다양한 상황들을 **직접 경험하고 해결**하는 과정을 통해 여러분들은 한층 더 성장한 개발자가 될 것입니다. 그러면 다음 챕터도 화이팅입니다!!! 🔥🔥🔥
profile
끝까지 가자

0개의 댓글