4.Node.js/Prisma(ORM)

[Prisma7] 7편. Prisma 트랜잭션과 데이터 정합성: 실무 설계와 구현

쿼드큐브 2026. 1. 21. 09:50
반응형
반응형

 

7편. Prisma 트랜잭션과 데이터 정합성: 실무 설계와 구현

 

📚 목차
1. 트랜잭션의 역할과 데이터 정합성 전략
2. Prisma 트랜잭션의 종류와 사용 시점
3. 트랜잭션 고급 제어 옵션과 성능 최적화 전략
4. 트랜잭션 기반 다중 CRUD 처리 실습

 

트랜잭션 삽화 이미지
트랜잭션 삽화 이미지

 

📂 [GitHub 예시 코드 보러가기] (https://github.com/cericube/nodejs-tutorials) /Prisma

 

1. 트랜잭션의 역할과 데이터 정합성 전략

트랜잭션(Transaction)은 여러 개의 데이터베이스 작업을 하나의 논리적 작업 단위로 묶어 처리하는 메커니즘입니다.
즉, 다음 조건을 보장합니다.
▸ 모두 성공하면 전부 반영(Commit)
▸ 하나라도 실패하면 전부 취소(Rollback)

 

관계형 데이터베이스(PostgreSQL 포함)에서 트랜잭션은 오래전부터 사용되어 온 핵심 개념이며, Prisma 역시 내부적으로 데이터베이스 트랜잭션을 그대로 활용합니다.

 

🔷 왜 트랜잭션이 필요한가?

데이터베이스 작업은 "전부 성공하거나, 전부 실패(All or Nothing)"해야 합니다. 트랜잭션 없이 작성된 아래 코드는 전형적인 정합성 파괴의 원인이 됩니다.

// ❌ 위험한 코드: 정합성 보장 불가
const user = await prisma.user.create({ data: { email: "a@test.com" } });
// 만약 이 사이에서 서버가 꺼지거나 아래 쿼리가 실패한다면?
await prisma.profile.create({ data: { userId: user.id, bio: "Hello" } }); 
// 결과: Profile 없는 User만 남음 (DB 미상태 불일치)

user.create가 이미 커밋된 상태에서 profile.create가 실패하면, User만 존재하고 Profile이 없는 데이터 정합성이 깨진 불완전한 상태가 됩니다.

 

🔷 2. [최적화 Tip] Nested Writes (중첩 쓰기)

Prisma 7에서는 단순 연관 데이터 생성을 위해 $transaction을 명시적으로 호출하기보다 Nested Writes를 우선 권장합니다.

이는 단일 네트워크 호출로 원자성을 보장하며 성능이 더 뛰어납니다.

// 효율적인 방식: 단일 쿼리로 1:1 관계 생성
await prisma.user.create({
  data: {
    email: "a@test.com",
    profile: {
      create: { bio: "Hello" }
    }
  }
});

 

2. Prisma 트랜잭션의 종류와 사용 시점

🔷 배열 기반 트랜잭션 (Batch Transaction)

Prisma에서 제공하는 가장 간단하고 직관적인 트랜잭션 방식입니다.

여러 개의 독립적인 쿼리를 하나의 배열로 묶어서 실행하고 싶을 때 사용합니다.

await prisma.$transaction([
  prisma.user.create({ ... }),
  prisma.profile.create({ ... }),
]);

🔸 원자성 보장: 배열 안의 모든 쿼리는 하나의 작업 단위로 묶입니다.
🔸 자동 롤백: 쿼리 중 단 하나라도 실패하면, 실행되었던 모든 작업이 취소(Rollback)되어 데이터의 일관성을 유지합니다.

 

✔️ 특징

▸ 가독성: 코드가 간결하고, '여러 작업을 하나로 묶었다'는 의도가 명확히 보입니다.

▸ 단순성: 복잡한 비즈니스 로직 없이, 단순히 데이터를 생성·수정·삭제하는 묶음 작업에 최적화되어 있습니다.

▸ 조건부 실행 불가: 첫 번째 쿼리의 결과값을 받아서 if문으로 분기를 태우거나, 중간에 값을 가공하여 다음 쿼리에 사용할 수 없습니다.

▸ 유연성 부족: try/catch나 반복문 같은 제어 로직을 트랜잭션 내부에서 사용할 수 없습니다.

 

✔️ 예제: 사용자(User)와 프로필(Profile) 동시 생성

COMMIT 예시

async function createUserWithProfile() {
  console.log('--- 사용자와 프로필 동시 생성 ---');
  const [user, profile] = await prisma.$transaction([
    prisma.user.create({
      data: {
        email: 'trans@example.com',
        displayName: '트랜잭션 사용자',
      },
    }),
    //
    prisma.profile.create({
      data: {
        bio: '트랜잭션 생성자 입니다.',
        user: {
          connect: { email: 'trans@example.com' },
        },
      },
    }),
  ]);

  console.log('--- 사용자와 프로필 동시 생성  종료---');
}

정상 실행결과
정상 실행 결과 화면

 

ROLLBACK 예시

// 롤백 테스트
async function createUserWithProfile() {
  const [user, profile] = await prisma.$transaction([
    prisma.user.create({
      data: {
        email: 'trans_fail@example.com',
        displayName: '트랜잭션 실패 사용자',
      },
    }),
    //
    prisma.profile.create({
      data: {
        bio: '트랜잭션 실패 생성자 입니다.',
        user: {
          connect: { email: 'trans_failfail@example.com' },
        },
      },
    }),
  ]);
  console.log('--- 사용자와 프로필 동시 생성  종료---');
}

트랜잭션 ROLLBACK 예시
트랜잭션 ROLLBACK 예시

 

🔷 대화형 트랜잭션 (Interactive Transactions)

Prisma 7 기준으로 실무에서 가장 권장되는 표준 방식입니다.

단순히 쿼리를 나열하는 것이 아니라, 트랜잭션 도중에 데이터를 조회하거나 조건에 따라 로직을 결정해야 할 때 사용합니다.

await prisma.$transaction(async (tx) => {
  // 이 블록 안에서 'tx' 객체를 통해 쿼리를 실행합니다.
});

🔸 tx (Transaction Client): 트랜잭션 범위 안에서만 유효한 전용 Prisma Client입니다.
🔸안전한 스코프: 블록 안의 코드가 모두 성공적으로 완료되면 자동으로 Commit 되고, 중간에 에러가 발생하면 자동으로 Rollback 됩니다.

 

✔️ 왜 이 방식이 실무 표준인가요? (핵심 장점)

▸ 자유로운 조건 분기 (if): 쿼리 결과에 따라 다음 작업을 진행할지, 혹은 에러를 던져 중단할지 결정할 수 있습니다.

▸ 반복문 활용 (for, map): 여러 데이터를 루프 돌며 처리해야 하는 복잡한 로직도 트랜잭션 안에서 안전하게 처리가 가능합니다.

▸ 세밀한 예외 처리 (try/catch): 로직 중간에 발생하는 예외를 직접 핸들링할 수 있어 안정성이 높습니다.
▸ 연쇄 작업 가능: 이전 쿼리에서 생성된 데이터(예: 생성된 ID값)를 다음 쿼리의 입력값으로 바로 사용할 수 있습니다.

 

✔️ 예제: 유저 생성 후 결과에 따른 로직 제어

async function createUserWithProfile() {
  console.log('--- 대화형 트랜잭션 예시 ---');
  /**
   * - Interactive Transaction (대화형 트랜잭션)
   * - 내부에서 실행되는 모든 쿼리는 하나의 DB 트랜잭션으로 묶임
   * - 내부에서 Error throw 시 → 자동 ROLLBACK
   * - 정상 종료 시 → COMMIT
   */
  const { user, profile } = await prisma.$transaction(async (tx) => {
    /**
     * - 이 시점에서 INSERT SQL이 실행됨
     * - 실패 시 (unique, not null 등) → 즉시 throw → 트랜잭션 중단
     * - 성공 시 → id, email, displayName 반환
     */
    const user = await tx.user.create({
      data: {
        // ❗ 의도적으로 잘못된 도메인 (example.com 아님)
        email: 'tx_test@exmaple.com',
        displayName: '대화 트랜잭션 사용자',
      },
      /**
       * select는 "반환 객체"만 제한할 뿐
       * DB에는 전체 컬럼이 정상적으로 저장됨
       */
      select: {
        id: true,
        email: true,
        displayName: true,
      },
    });

    /**
     * - DB 오류가 아닌 "도메인 정책 위반" 검사
     * - example.com 도메인만 허용
     *
     * ❗ 여기서 throw 발생 시:
     *   - 이미 실행된 user.create 포함
     *   - 트랜잭션 전체가 ROLLBACK 됨
     *   - User, Profile 모두 DB에 남지 않음
     */
    if (!isAllowedEmailDomain(user.email)) {
      throw new Error('example.com 도메인 이메일만 가입할 수 있습니다.');
    }

    /**
     * - 위 비즈니스 규칙을 통과한 경우에만 실행
     * - user.id 또는 unique key(email) 기반으로 연결
     */
    const profile = await tx.profile.create({
      data: {
        bio: '대화 트랜잭션 사용자',
        user: {
          connect: {
            // unique 필드(email)로 User와 연결
            email: 'tx_test@exmaple.com',
          },
        },
      },
    });
    return { user, profile };
  });

  console.log('--- 대화형 트랜잭션 예시 종료 ---');
  console.log(user);
  console.log(profile);
}

 

비즈니스 규칙 검증 오류로 인한 ROLLBACK 예시

비즈니스 규칙 검증 오류 ROLLBACK 예시
비즈니스 규칙 검증 오류로 인한 ROLLBACK 예시

 

🔷 트랜잭션 형태 선택 기준과 주의 사항

1) 상황에 맞는 트랜잭션 선택 기준

구분 배열 기반 트랜잭션 (Batch) 대화형 트랜잭션 (Interactive)
핵심 개념 “정해진 쿼리를 한 번에 실행” “로직에 따라 유연하게 실행”
적합한 상황 쿼리 순서와 개수가 고정된 단순 작업 비즈니스 로직 및 조건 분기가 필요한 복잡한 작업
로직 제어 중간 결과값 이용 불가(if / for 사용 불가) 중간 결과값에 따른 조건문·반복문 사용 가능
실무 예시 좋아요 클릭 시 (기록 생성 + 카운트 증가)
• 메인 작업 + 단순 히스토리 로그 저장
• 회원 가입 (유저 생성 → ID 획득 → 프로필 생성)
• 주문 결제 (재고 확인 → 주문 생성 → 포인트 차감)
장점 코드가 매우 간결하고 직관적 정교하고 유연한 로직 및 에러 핸들링 가능
단점 복잡한 비즈니스 로직 구현 불가 코드가 상대적으로 길어질 수 있음
실무 권장도 제한적인 상황에서만 사용 실무 표준 (Prisma 7 기준)

 

2) 핵심 주의 사항: tx 객체 사용 규칙

대화형 트랜잭션을 사용할 때 가장 많이 하는 실수는 트랜잭션 전용 객체(tx)를 사용하지 않는 것입니다.

이 규칙을 어기면 트랜잭션이 정상적으로 작동하지 않습니다.

 

❌ 잘못된 코드 (Antipattern)

문제점: prisma 객체를 직접 사용하면 트랜잭션 스코프에 포함되지 않아, 에러가 발생해도 해당 쿼리는 롤백되지 않습니다.

await prisma.$transaction(async (tx) => {
  // 전역 prisma 객체를 사용하면 트랜잭션 외부에서 실행됩니다!
  await prisma.user.create({ data: { ... } }); 
});

 

⭕ 올바른 코드 (Best Practice)

모든 작업이 tx라는 하나의 통로를 통해 실행되어 데이터 정합성이 완벽하게 보장됩니다.

await prisma.$transaction(async (tx) => {
  // 반드시 매개변수로 전달된 'tx' 객체를 사용해야 합니다.
  await tx.user.create({ data: { ... } }); 
});
반응형

 

3. 트랜잭션 고급 제어 옵션과 성능 최적화 전략

Prisma 7에서는 Interactive Transactions가 완전히 안정화되었으며, 성능과 가시성(Observability) 측면에서 더욱 정교한 제어가 가능해졌습니다.

await prisma.$transaction(
  async (tx) => {
    // 로직 수행
  },
  {
    timeout: 5000,         // 기본값: 5000ms
    maxWait: 2000,        // 트랜잭션 시작을 위해 커넥션을 기다리는 최대 시간
    isolationLevel: 'ReadCommitted', // DB별 지원 수준 확인 필요
  }
)
옵션 설명
timeout 트랜잭션 실행 후 완료될 때까지 허용되는 최대 시간
네트워크 지연, 복잡한 JOIN, 대량 INSERT 등으로 인한 장시간 커넥션 점유를 방지하기 위해 반드시 명시적으로 설정하는 것이 바람직합니다.
maxWait 커넥션 풀에서 트랜잭션용 커넥션을 확보하기까지의 최대 대기 시간
커넥션 풀이 고갈된 상황에서 요청을 무한 대기시키지 않고 빠르게 실패(fail-fast) 하여 시스템 전체 마비를 예방합니다.
isolationLevel 트랜잭션 격리 수준을 지정
기본값(ReadCommitted)을 유지하되, 금융·정산·재고 관리처럼 정합성이 중요한 도메인에서만 상위 격리 수준을 신중히 적용합니다.

 

🔷 트랜잭션 옵션: timeout과 maxWait

트랜잭션은 DB 커넥션을 점유합니다. 커넥션은 한정된 자원이므로, 이를 효율적으로 관리하는 것이 성능의 핵심입니다.


🔸timeout: 트랜잭션 로직이 실행될 수 있는 총 시간.
🔸maxWait: 커넥션 풀에서 커넥션을 할당받기 위해 기다리는 시간.

 

✔️ 예제: 사용자 회원가입 시 프로필 동시 생성

async function register(email: string, name: string, bio: string) {
  // Prisma Interactive Transaction 시작
  // tx 객체는 이 트랜잭션 범위 내에서만 유효
  const { user, profile } = await prisma.$transaction(
    async (tx) => {
      //1. User 테이블에 사용자 생성
      const user = await tx.user.create({
        data: {
          email: email,
          displayName: name,
        },
      });

      // >> 의도적으로 지연 발생
      // timeout 옵션 테스트를 위한 강제 대기 (10초)
      console.log('강제 지연....... 시작...');
      await new Promise((res) => setTimeout(res, 10000));

      //2. Profile 테이블에 프로필 생성
      // 이미 생성된 User를 email 기준으로 연결 (외래키 관계)
      const profile = await tx.profile.create({
        data: {
          bio: bio,
          user: {
            connect: {
              email: email,
            },
          },
        },
      });

      // 트랜잭션 내부 결과 반환
      return { user, profile };
    },
    {
      // 트랜잭션 전체 실행 허용 시간 (초과 시 자동 롤백)
      timeout: 5000,
      // 커넥션 풀에서 트랜잭션 커넥션을 기다리는 최대 시간
      maxWait: 2000,
    },
  );

  // 트랜잭션이 정상 커밋된 경우에만 실행
  console.log('--- 사용자 , 프로필 등록 예시 결과 ---');
  console.dir(user, { depth: null });
  console.dir(profile, { depth: null });

  return { user, profile };
}

트랜잭션 고급 옵션 실행 결과 예시
트랜잭션 고급 옵션 실행 결과 예시

 

🔷 격리 수준(Isolation Level)의 실무 적용

동시성 문제가 발생하기 쉬운 좋아요(PostLike) 집계나 게시글 작성자 검증 시나리오에서 격리 수준이 중요합니다.

 

📌 격리 수준이란?
▸ 격리 수준은 동시에 실행되는 트랜잭션 간 데이터 가시성 규칙을 의미합니다.

▸ Prisma 7 + PostgreSQL 기준에서 주로 사용하는 옵션은 다음과 같습니다.

Isolation Level 설명
ReadCommitted 커밋된 데이터만 읽음 (기본값)
RepeatableRead 트랜잭션 내 동일 쿼리는 항상 동일 결과
Serializable 가장 강력, 동시성 성능 저하

 

✔️ 예제: 게시글 작성 및 작성자 게시글 수 제한 (RepeatableRead)

특정 사용자가 게시글을 작성하는 동안, 다른 트랜잭션에 의해 작성자의 상태가 변하지 않도록 보장해야 할 때 사용합니다.

async function runRepeatableRead(email: string) {
  // 1. 초기 데이터 준비
  const user = await prisma.user.create({
    data: { email, displayName: 'Test User' },
  });
  console.log('--- 초기 사용자 생성 완료 ---');

  // [Transaction A]: RepeatableRead 격리 수준
  const txA = prisma.$transaction(
    async (tx) => {
      // STEP 1: TX A - 첫 번째 읽기(deletedAt은 null인 상태)
      const firstRead = await tx.user.findUnique({ where: { email } });
      console.log('[TX A] 1차 읽기 (deletedAt):', firstRead?.deletedAt);

      // STEP 2: 의도적인 지연 (3초) (TX B가 업데이트를 수행할 수 있도록 대기)
      await new Promise((resolve) => setTimeout(resolve, 3000));

      // STEP 4: TX A - 두 번째 읽기 (TX B가 커밋된 후에도 동일한 데이터를 보장하는지 확인)
      const secondRead = await tx.user.findUnique({ where: { email } });
      console.log('[TX A] 2차 읽기 (deletedAt):', secondRead?.deletedAt);

      // 두 읽기 결과가 같으면 격리 수준이 잘 작동하고 있다는 증거입니다.
      if (firstRead?.deletedAt === secondRead?.deletedAt) {
        console.log('[결과] Repeatable Read 성공: 데이터 일관성 유지');
      }
    },
    { isolationLevel: 'RepeatableRead' }
  );

  // [Transaction B]: 외부에서 데이터를 수정(Soft Delete)
  const txB = async () => {
    await new Promise((resolve) => setTimeout(resolve, 1000)); // TX A 실행 후 시작
   // STEP 3: TX B - 데이터 수정 및 즉시 커밋
    await prisma.user.update({
      where: { email },
      data: { deletedAt: new Date() },
    });
    console.log('[TX B] 데이터 수정 및 커밋 완료');
  };

  await Promise.all([txA, txB()]);
}

RepeatableRead 실행 결과 예시
RepeatableRead 실행 결과 예시

 

 

🔷 트랜잭션 최소화: 트랜잭션은 짧고, 단순하고, 명확해야 한다

트랜잭션 내부에 외부 API 호출(이미지 업로드, 알림 발송 등)이 들어가면 커넥션 풀이 순식간에 고갈됩니다.

 

❌ 나쁜 예시 (Anti-Pattern)

await prisma.$transaction(async (tx) => {
  const post = await tx.post.create({ data: { title: 'Hello', authorId: 1 } });
  
  // 외부 API 응답이 늦어지면 DB 커넥션을 계속 붙잡고 있음
  await axios.post('https://api.sns.com/share', { id: post.id }); 
});

 

✅ 좋은 예시 (Refactored)

// 1. DB에 임시 저장 또는 상태 저장 (트랜잭션 1)
const post = await prisma.post.create({
  data: { title: 'Hello', authorId: 1, published: false }
});

// 2. 외부 API 호출 (트랜잭션 외부)
const isShared = await snsService.share(post.id);

// 3. 결과에 따라 상태 업데이트 (트랜잭션 2)
if (isShared) {
  await prisma.post.update({
    where: { id: post.id },
    data: { published: true }
  });
}

 

🔷 Nested Write: $transaction의 강력한 대안

Prisma 스키마에 관계가 정의되어 있다면, $transaction을 명시적으로 쓰지 않고 Nested Write를 쓰는 것이 성능과 가독성 면에서 가장 좋습니다.

Prisma 엔진이 이를 단일 트랜잭션으로 처리합니다.

 

✔️ 예제: 게시글 + 첫 댓글 + 유저 프로필 수정을 한 번에

async function createPostWithInitialComment(userId: number) {
  // 1. 최상위 작업: 'User' 테이블의 특정 레코드를 수정(update)합니다.
  const user = await prisma.user.update({
    where: { id: userId }, // 수정할 유저의 ID 조건
    data: {
      // 2. 유저 정보 수정: 해당 유저의 필드를 직접 업데이트합니다.
      displayName: 'Active Author',

      // 3. 관계형 중첩 생성 (User -> Post):
      // 유저 수정과 동시에 해당 유저와 연결된 'Post' 레코드를 생성합니다.
      posts: {
        create: {
          title: 'Prisma 7 Guide',
          content: 'Transaction tips',

          // 4. 관계형 중첩 생성 (Post -> Comment):
          // 게시글 생성과 동시에 해당 게시글에 속한 'Comment' 레코드를 생성합니다.
          // depth가 깊어져도 하나의 흐름으로 이어집니다.
          comments: {
            create: {
              content: 'First comment!',
              // 작성자 ID를 현재 작업 중인 userId와 연결합니다.
              authorId: userId,
            },
          },
        },
      },
    },
    // 5. 결과 반환 설정:
    // 생성/수정된 데이터를 즉시 확인하기 위해 관계된 데이터를 모두 포함(fetch)합니다.
    include: {
      posts: {
        include: {
          comments: true, // 생성된 댓글까지 깊게(deep) 가져옵니다.
        },
      },
    },
  });
  return user;
}

 

4. 트랜잭션 기반 다중 CRUD 처리 실습

✔️ 예제 1: 유저 생성 + 프로필 자동 연결

유저가 가입할 때 프로필 정보를 반드시 함께 생성해야 하는 시나리오입니다.

유저는 생성되었는데 프로필 생성에 실패하면 유저 데이터도 생성되지 않아야 합니다.

async function signupUser(email: string, bio: string) {
  // prisma.$transaction을 사용하여 여러 작업을 '하나의 단위'로 묶습니다.
  // 이 블록 안의 작업 중 하나라도 실패하면 
  // 모든 변경사항이 취소(Rollback)되어 데이터 무결성을 보장합니다.
  const { user, profile } = await prisma.$transaction(async (tx) => {
    
    // 1. User 테이블에 새로운 레코드 생성
    const user = await tx.user.create({
      data: {
        email,
        // 이메일 주소에서 @ 앞부분만 추출하여 초기 닉네임으로 설정 
        // (예: 'test@naver.com' -> 'test')
        displayName: email.split('@')[0], 
      },
    });

    // 2. Profile 테이블에 새로운 레코드 생성 및 위에서 생성된 User와 연결
    const profile = await tx.profile.create({
      data: {
        bio,
        // 중요: 앞선 단계에서 생성된 user 객체의 id를 외래키(Foreign Key)로 사용합니다.
        // 이를 통해 DB 레벨에서 두 데이터 간의 1:1 또는 1:N 관계가 형성됩니다.
        userId: user.id, 
      },
    });

    // 트랜잭션 성공 시 외부로 반환할 데이터 구성
    return { user, profile };
  });

  // 모든 작업이 성공적으로 완료된 후 콘솔에 결과 출력
  console.log("생성된 유저 정보:", user);
  console.log("생성된 프로필 정보:", profile);
}

 

✔️ 예제 2: 게시글 등록 + 좋아요 + 댓글 삽입
운영진이 게시글을 등록함과 동시에 초기 반응(좋아요 1개, 기본 댓글 1개)을 세팅하는 복합 시나리오입니다.

async function seedPostWithEngagement(authorId: number, title: string) {
  // 하나라도 실패하면 데이터베이스는 함수 실행 전 상태로 되돌아갑니다(Rollback).
  const { post, comment } = await prisma.$transaction(async (tx) => {
    // STEP 1: 새로운 게시글 레코드 생성
    // tx는 트랜잭션 전용 Prisma Client 인스턴스입니다.
    const post = await tx.post.create({
      data: {
        title,
        content: '반갑습니다. 첫 게시글입니다.',
        authorId,
        published: true, // 생성과 동시에 바로 공개 상태로 설정
      },
    });

    // STEP 2: 게시글 작성 시 본인이 자동으로 '좋아요'를 누르도록 설정
    // post.id는 바로 위에서 생성된 게시글의 식별자를 참조합니다.
    await tx.postLike.create({
      data: {
        userId: authorId,
        postId: post.id,
      },
    });

    // STEP 3: 시스템 자동 환영 댓글 작성
    // 게시글 생성이 완료된 후 해당 게시글에 종속된 댓글을 생성합니다.
    const comment = await tx.comment.create({
      data: {
        content: '게시글 등록을 축하합니다!',
        authorId,
        postId: post.id,
      },
    });

    // 모든 작업이 성공하면 생성된 데이터를 객체 형태로 반환합니다.
    return { post, comment };
  });

  // 트랜잭션이 성공적으로 완료(Commit)된 후 콘솔에 결과를 출력합니다.
  console.log('생성된 게시글:', post);
  console.log('생성된 댓글:', comment);
}

 

✔️ 예제 3: 오류 발생 시 롤백 및 예외 처리

트랜잭션 도중 비즈니스 로직에 의해 에러를 던지거나(throw), Prisma 자체 에러가 발생했을 때 어떻게 안전하게 처리하는지 확인합니다.

async function safeUpdateUserAndPost(postId: number) {
  try {
    // 1. Prisma 트랜잭션 시작
    // tx: 트랜잭션 내에서 사용할 전용 Prisma Client 인스턴스
    await prisma.$transaction(
      async (tx) => {
        
        // 2. 게시글 정보 조회 (데이터 스냅샷 확보)
        // 트랜잭션 내부에서 조회하여 데이터의 일관성을 유지합니다.
        const post = await tx.post.findUnique({ where: { id: postId } });
        console.log('조회된 게시글:', post);

        // 3. [비즈니스 로직 검증] 게시글 상태 체크
        // 존재하지 않거나, 이미 삭제된(soft delete) 게시글인 경우 에러를 발생시킵니다.
        if (!post || post.deletedAt) {
          // 여기서 throw를 던지면 트랜잭션 내의 모든 작업이 취소(Rollback)됩니다.
          throw new Error('ALREADY_DELETED_POST'); 
        }

        // 4. 게시글 정보 수정
        // 조회된 정보를 바탕으로 제목을 수정하고 삭제 일시를 기록합니다.
        await tx.post.update({
          where: { id: postId },
          data: {
            title: '[수정됨] ' + post.title,
            deletedAt: new Date(),
          },
        });
        
        // 블록 끝까지 에러 없이 도달하면 모든 변경사항이 DB에 최종 반영(Commit)됩니다.
      },
    );
  } catch (error) {
    // A. Prisma 엔진에서 발생하는 구조적 에러 처리
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
      if (error.code === 'P2025') {
        console.error('에러: 업데이트할 레코드를 찾을 수 없습니다 (P2025).');
      } else if (error.code === 'P2002') {
        console.error('에러: 고유 제약 조건 위반이 발생했습니다 (P2002).');
      }
    } 
    // B. 위에서 직접 던진 커스텀 비즈니스 로직 에러 처리
    else if (error instanceof Error && error.message === 'ALREADY_DELETED_POST') {
      console.warn('알림: 이미 삭제된 게시글이므로 작업을 취소하고 롤백했습니다.');
    } 
    // C. 기타 예상치 못한 런타임 에러
    else {
      console.error('알 수 없는 에러 발생:', error);
    }

    // 5. 최종 에러 전파
    // 호출한 상위 함수에서 에러 발생 여부를 알 수 있도록 다시 던집니다.
    throw error;
  }
}

 


※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.

반응형

 

반응형