October 10, 2026

Prisma Nested Write에서 자식 데이터가 10건이면 INSERT가 10번 발생할까?

typescript

@Injectable()
export class OrderService {
    constructor(private readonly prisma: PrismaService) {
    }
    
    async create(userId: UUID, dto: OrderCreateDto) { 
        const order = await this.prisma.order.create({
            data: { 
                userId,
                total: dto.total,
                status: OrderStatus.PENDING,
                items: { 
                    create: dto.items.map((item) => ({ 
                        productId: item.productId,
                        quantity: item.quantity,
                        price: item.price, 
                    })),
                },
            },
            include: { items: { include: { product: true } } },
        });
        
        return order;
    }
}

위 코드는 부모 테이블인 Order, 자식 테이블인 OrderItem, 그리고 주문 항목 한 건에 대한 연관 테이블인 Product에 대한 관계를 갖고 Prisma ORM을 통해 SQL을 데이터베이스에 보냅니다.

이를 위해 create 메서드에서는 Prisma Nested Write가 사용되었습니다.

Prisma Nested Write란 별도의 $transaction을 사용하지 않아도 Prisma Query Engine이 단일 트랜잭션으로 묶어 원자성을 보장하는 방법입니다. 따라서, Order 혹은 OrderItem에 대한 작업이 단 하나라도 실패하면 전체가 롤백됩니다.

또한 부모 레코드가 생성되기 전에 Order ID를 알 수 없지만, Prisma Nested Transaction에선 부모 레코드를 생성하고 반환된 ID를 캡처해서 자식 레코드에 할당합니다.

Prisma Nested Write가 적용된 위 코드는 아래와 같은 흐름으로 동작합니다.

  1. BEGIN으로 트랜잭션이 시작된다.
  2. 부모 레코드가 삽입된다.
  3. 자식 레코드를 일괄 삽입한다.
  4. 결과 조회되고 트랜잭션을 COMMIT 한다.

여기서 주목할 부분은 3번과 4번 과정에서 어떤 SQL 구문이 몇 번 실행되는가? 입니다.

먼저, 3번의 경우에는 dto에 items가 다건이니 INSERT 구문도 items의 개수에 맞춰 발생할 것처럼 보입니다.

실제로 구버전의 Prisma에선 중첩 create 시에 여러 건의 데이터에 대한 INSERT가 개별로 발생했기 때문에 중첩 쓰기에서 create 대신 createMany로 Multi-row BATCH INSERT가 발생하도록 명시적 최적화를 했었습니다.

그러나 v5.x 이후로 create를 써도 Multi-row BATCH INSERT가 발생하도록 개선되었습니다.

sql
BEGIN IMMEDIATE;
      INSERT INTO `Order` ( `id`, `userId`, `total`, ... ) 
          VALUES (?, ..., ?) RETURNING `id` AS `id`; 
      INSERT INTO `OrderItem` (`orderId`, `price`, `productId`, ... ) -- 10건을 배치 INSERT 
          VALUES (?, ..., ?), (?, ..., ?), (?, ..., ?), (?, ..., ?),(?, ..., ?),
                 (?, ..., ?),(?, ..., ?),(?, ..., ?),(?, ..., ?),(?, ..., ?);
      -- 생략 ... 
COMMIT;

4번에선 코드의 include: { items: { include: { product: true } } }에 의해서 주문을 생성한 수 객체를 반환할 때, 자식 엔티티인 OrderItem과 자식이 참조하는 엔티티인 Product를 한 번에 묶어서 가져옵니다.

이를 2단계 중첩 즉시 로딩(Nested Eager Loading)이라 하는데, Order와 OrderItem이 1:N 관계일 때, Order에 속한 모든 OrderItem 목록을 가져오고, 각 OrderItem이 참조하는 Product 상세 정보도 가져와 각 항목과 합칩니다.

그래서 결과적으로 중첩된 단일 JSON 객체 형태로 반환됩니다.

그렇다면 4번은 LEFT JOIN이 두 번 발생할까요? 아마 아래와 같은 형태로 DB에 쿼리가 전달될 겁니다.

sql
SEELCT * 
FROM `Order` o
       LEFT JOIN `OrderItem` oi ON o.id = oi.orderId -- 1개 -> 10개로 증가
       LEFT JOIN `Product` p ON oi.productId = p.id -- 10개 각각에 상품 1개씩 결합
       WHERE o.id = 1;

JOIN 연산은 데카르트 곱 연산 문제를 발생시킵니다.

데카르트 곱 연산이란?
서로 다른 두 그룹에서 하나씩 골라 만들 수 있는 모든 조합 연산을 의미한다.
예를 들어, 카페 메뉴에서 음료는 아메리카노, 카페라떼, 디저트는 크루아상, 소금빵, 초코케이크가 있다면, 으로 전체 메뉴 목록을 구성하게 된다.

현재 Order <–1:N–> OrderItem, OrderItem <–N:1–> Product 관계에서, items가 10건일 때, LEFT JOIN을 수행해 조회하게 되면 DB는 으로 10건의 중복 부모 데이터를 내려주게 됩니다.

text
[Row 1]  order_id: 1, total: 60000, status: 'PENDING' | item_id: 101, qty: 1 | product_name: '키보드'
[Row 2]  order_id: 1, total: 60000, status: 'PENDING' | item_id: 102, qty: 2 | product_name: '마우스'
[Row 3]  order_id: 1, total: 60000, status: 'PENDING' | item_id: 103, qty: 1 | product_name: '장패드'
[Row 4]  order_id: 1, total: 60000, status: 'PENDING' | item_id: 104, qty: 1 | product_name: '모니터'
... (아이템이 10개이므로 10번째 행까지 반복) ...
[Row 10] order_id: 1, total: 60000, status: 'PENDING' | item_id: 110, qty: 1 | product_name: '스피커' 

왼쪽의 order_id, total, status는 모두 같으므로 사실 1번만 있으면 됩니다. 그러나 테이블 구조상 빈칸을 둘 수 없기 때문에 DB가 10줄 모두에 같은 데이터를 복사해 네트워크로 전송합니다. 때문에 I/O 및 직렬화 비용이 증가하게 됩니다.

Prisma는 이러한 데카르트 곱 연산 문제를 방지하기 위해서 Data Loader 패턴을 사용합니다.
Data Loader 패턴이란 Prisma Query Engine이 분할 SELECT 후 인메모리에서 결과를 병합하는 패턴을 말합니다.

따라서 Order, OrderItem, 그리고 Product에 대한 3건의 독립적인 분할 SELECT 구문이 수행됩니다.

sql
SELECT `Order`.* FROM `Order` 
                 WHERE `Order`.`id` = ? LIMIT 1 OFFSET 0;
SELECT `OrderItem`.* FROM `OrderItem` 
                 WHERE `OrderItem`.`orderId` IN (?) LIMIT -1 OFFEST 0; 
SEELCT `Product`.* FROM `Product` 
                 WHERE `Product`.`id` IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) LIMIT -1 OFFSET 0;

그리고 이 세 개의 SELECT 결과를 받은 Prisma Query Engine이 메모리 안에서 중첩된 단일 JSON 객체를 생성합니다.

여기서 더 개선이 필요하다면 select를 사용해 필요한 데이터만 불러오도록 해볼 수 있습니다.
select을 이용해서 오버페칭을 피해 직렬화 비용을 낮출 수 있고, API를 통해 필요한 데이터만 보이도록 캡슐화할 수 있습니다.

typescript
const order = await this.prisma.order.create({
  data: {
    userId,
    total: dto.total,
    status: OrderStatus.PENDING,
    items: {
      create: dto.items.map((item) => ({
        productId: item.productId,
        quantity: item.quantity,
        price: item.price,
      })),
    },
  },
  select: {
    id: true,
    total: true,
    status: true,
    createdAt: true,
    items: {
      select: {
        id: true,
        productId: true,
        quantity: true,
        price: true,
        product: {
          select: {
            name: true,
            thumbnailUrl: true,
          },
        },
      },
    },
  },
});

그러나 Data Loader 패턴은 레코드 개수()만큼 쿼리가 폭증하는 문제는 방지하지만, 분할 SELECT가 네트워크 왕복()를 강제하므로 경우에 따라서 병목이 될 수 있습니다.

우선 예제로 소개된 코드는 모두 단일 트랜잭션에서 실행됩니다. 그렇기 때문에 분할 SELECT 쿼리가 수행되는 동안 트랜잭션은 계속 열려있는 상태입니다.
만약에 SELECT 쿼리에 지연이 발생한다면 전체 트랜잭션 길이가 길어지기 때문에, 동시 요청이 발생하는 환경이라면 순간적으로 커넥션 풀이 고갈되어 커넥션을 얻기 위해 큐에 대기중인 요청들이 누적되어 미완료 요청에 대한 메모리 급증 혹은 DB를 포함한 다른 서비스들의 정체가 발생합니다. (DB의 경우에는, 긴 트랜잭션들이 행 락을 쥔 채 늦게 COMMIT/ROLLBACK되면서, DB 내부의 Lock Wait Queue까지 정체되기 때문입니다.)
또한 커넥션 풀 타임아웃이 연쇄적으로 발생하게 되는데, 재시도를 수행한다면 계속해서 요청을 밀어넣게 되어 모든 자원을 타임아웃 에러를 내는데 소진하게 됩니다.

이 문제를 빠른 실패, 타임아웃 단축, Read/Write 분리 등의 아키텍처적으로 해결할 수 있지만, 단순히 코드와 Prisma ORM 레벨에서 알아보겠습니다.

  1. Prisma의 relationJoins 기능을 활성화 한다.
  • Prisma는 데이터베이스 엔진의 JSON 집계 함수를 이용해 단 1번의 쿼리로 중첩 데이터를 조회하는 relationJoins를 지원합니다. 이는 여전히 데카르트 곱 연산 문제를 방지하기 위함입니다.
  • PostgreSQL의 경우 LEFT JOIN과 json_build_object(), json_agg()를 사용해 DB 내장 JSON Aggregation 단일 쿼리를 만들어 단 1번의 RTT로 반환합니다.
plaintext
generator client {
  provider = "prisma-client-js"
  previewFeatures = ["relationJoins"]
}
  • 단, 실제로 쿼리 실행 계획이 어떻게 변하는지 확인해 봐야 합니다.
  • 또한 PostgreSQL, MySQL, CockroachDB에서만 지원됩니다.
sql
SELECT
  "t1"."id", "t1"."total", "t1"."status",
  "t2"."items"
FROM "Order" AS "t1"
LEFT JOIN LATERAL (
  SELECT
    -- DB 엔진이 직접 1:N 아이템들을 JSON 배열로 집계(Aggregate)함
    COALESCE(json_agg(json_build_object(
      'id', "t3"."id",
      'quantity', "t3"."quantity",
      'price', "t3"."price",
      'product', "t4"."product"
    )), '[]') AS "items"
  FROM "OrderItem" AS "t3"
  LEFT JOIN LATERAL (
    SELECT json_build_object('id', "t5"."id", 'name', "t5"."name") AS "product"
    FROM "Product" AS "t5"
    WHERE "t5"."id" = "t3"."productId"
  ) AS "t4" ON true
  WHERE "t3"."orderId" = "t1"."id"
) AS "t2" ON true
WHERE "t1"."id" = $1;
  • 제 환경에서 실제로 실행헀을 때 수행된 SQL문 입니다. 부모 데이터 10건을 중복시키는 대신, DB 엔진이 json_agg()로 단 1개의 JSON 객체 칼럼으로 압축해서 오직 1행만 서버로 전송합니다.
  • 이 경우 3회 RTT에서 1회 RTT로 개선되지만, JOIN의 특성상(JSON 직렬화 및 서브쿼리 연산) 단순 인덱스 조회만 수행하는 분할 SELECT 방식과 비교했을 때 DB CPU를 더 사용할 수 있습니다.
  • 그러나 분할 SELECT 방식이 엔진이 인메모리에서 객체를 조립하기 때문에 애플리케이션의 CPU를 더 사용할 수 있습니다.
  • 따라서 조회 패턴, 조회에 필요한 데이터 양()과 자원 소모량을 파악해 분할 SELECT 방식과 JOIN 방식 중에서 어떤 것을 사용할 지 적절히 선택해야 합니다.
  1. CQS 패턴을 적용한다.
  • 주문을 생성하는 트랜잭션 안에서 굳이 연관 상품 정보까지 읽어올 필요는 없습니다.
  • 그렇기 때문에 생성과 조회의 책임을 분리해 트랜잭션을 짧게 유지하는 것이 좋습니다.
typescript
const createOrder = await this.prisma.order.create({
    data: { 
        userId, 
        total: dto.total, 
        items: { 
            create: dto.items.map((item) => ({ /* 생략... */ }))
        },
    },
    select: { id: true } // PK만 반환하고 즉시 COMMIT 
})
  • 이 경우 DB 동시 처리량이 개선되기 때문에, CQS 혹은 CQRS에 대해 꼭 알아보고 적용할 것을 권해 드립니다.