Prisma 관계 필터·집계 쿼리 심화

Prisma Fluent API란?

Prisma Client의 Fluent API는 관계를 체이닝으로 탐색하는 쿼리 패턴입니다. include/select와 달리, 관계 체인의 끝에서 단일 관계 결과만 반환하여 타입 안전성과 쿼리 효율을 동시에 달성합니다. Prisma 5.x의 relationLoadStrategy와 결합하면 N+1 문제 없이 복잡한 관계 쿼리를 최적화할 수 있습니다.

1. Fluent API vs include/select

방식 반환 타입 SQL 패턴 적합한 상황
include 부모 + 자식 전체 JOIN 또는 추가 SELECT 부모와 자식을 함께 사용
select 선택한 필드만 필드 제한 SELECT 특정 컬럼만 필요
Fluent API 체인 끝의 관계만 단일 쿼리 관계 탐색 후 끝 노드만 필요
// Fluent API: 사용자의 주문의 상품만 가져오기
const products = await prisma.user
  .findUnique({ where: { id: userId } })
  .orders()           // User → Order[] 관계 탐색
  // Fluent는 단일 관계 체인에서만 동작
  // .orders()는 중간 단계이므로 여기서 끊어야 함

// 실제 사용 패턴: 중간 관계를 건너뛰고 끝 노드 접근
const profile = await prisma.user
  .findUnique({ where: { id: userId } })
  .profile();  // User → Profile (1:1 관계)
// 반환: Profile | null (User 객체 없이 Profile만!)

// include 방식 비교
const userWithProfile = await prisma.user.findUnique({
  where: { id: userId },
  include: { profile: true },
});
// 반환: User & { profile: Profile | null }

2. 관계 필터: where 조건 깊이 활용

Prisma의 관계 필터는 관련 레코드의 존재/조건을 부모 쿼리의 WHERE에 적용합니다.

// some: 하나라도 조건을 만족하는 관계가 있으면
const usersWithExpensiveOrders = await prisma.user.findMany({
  where: {
    orders: {
      some: {
        total: { gte: 100000 },
        status: 'COMPLETED',
      },
    },
  },
});
// SQL: WHERE EXISTS (SELECT 1 FROM orders WHERE ...)

// every: 모든 관계가 조건을 만족해야
const loyalUsers = await prisma.user.findMany({
  where: {
    orders: {
      every: {
        status: { in: ['COMPLETED', 'SHIPPED'] },
      },
    },
  },
});

// none: 조건을 만족하는 관계가 하나도 없어야
const noRefundUsers = await prisma.user.findMany({
  where: {
    orders: {
      none: {
        status: 'REFUNDED',
      },
    },
  },
});
// SQL: WHERE NOT EXISTS (SELECT 1 FROM orders WHERE status = 'REFUNDED' ...)

3. 중첩 관계 필터: 3단계 이상

// 특정 카테고리 상품을 주문한 사용자 찾기
// User → Order → OrderItem → Product → Category
const users = await prisma.user.findMany({
  where: {
    orders: {
      some: {
        items: {
          some: {
            product: {
              category: {
                name: 'Electronics',
              },
            },
          },
        },
      },
    },
  },
  select: {
    id: true,
    email: true,
    _count: {
      select: { orders: true },
    },
  },
});

// 복합 조건: AND/OR/NOT 조합
const targetUsers = await prisma.user.findMany({
  where: {
    AND: [
      // 조건 1: 최근 30일 내 주문 있음
      {
        orders: {
          some: {
            createdAt: { gte: thirtyDaysAgo },
          },
        },
      },
      // 조건 2: 환불 이력 없음
      {
        orders: {
          none: {
            status: 'REFUNDED',
          },
        },
      },
      // 조건 3: VIP 등급이거나 총 주문 5건 이상
      {
        OR: [
          { tier: 'VIP' },
          { orders: { some: { /* count 대신 존재 체크 */ } } },
        ],
      },
    ],
  },
});

4. relationLoadStrategy: JOIN vs 분리 쿼리

Prisma 5.x에서 도입된 relationLoadStrategy는 관계 로딩 방식을 제어합니다.

전략 동작 장점 단점
query (기본) 부모 SELECT 후 자식 별도 SELECT 큰 결과셋에서 안정적 쿼리 수 증가
join LATERAL JOIN으로 한 번에 단일 쿼리, 네트워크 왕복 최소 큰 관계에서 메모리 증가
// schema.prisma에서 preview feature 활성화
generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["relationJoins"]
}

// 쿼리별 전략 선택
const ordersWithItems = await prisma.order.findMany({
  relationLoadStrategy: 'join',  // LATERAL JOIN 사용
  where: { status: 'PENDING' },
  include: {
    items: {
      include: {
        product: true,
      },
    },
    user: {
      select: { id: true, email: true },
    },
  },
});

// join 전략이 유리한 경우:
// - 관계 데이터가 적은 경우 (1:1, 소수의 1:N)
// - 네트워크 지연이 큰 경우 (외부 DB)
// - 여러 관계를 동시에 로드할 때

// query 전략이 유리한 경우:
// - 1:N 관계의 N이 매우 큰 경우
// - 깊은 중첩 관계 (3단계+)
// - 페이지네이션과 함께 사용할 때

5. _count와 집계 관계 쿼리

// 관계 카운트: 별도 쿼리 없이 COUNT 서브쿼리
const users = await prisma.user.findMany({
  select: {
    id: true,
    name: true,
    _count: {
      select: {
        orders: true,
        reviews: true,
        followers: true,
      },
    },
  },
  orderBy: {
    orders: { _count: 'desc' },  // 주문 많은 순 정렬
  },
  take: 10,
});
// 타입: { id, name, _count: { orders: number, reviews: number, followers: number } }

// 조건부 카운트: 필터링된 관계 수
const usersWithFilteredCounts = await prisma.user.findMany({
  select: {
    id: true,
    _count: {
      select: {
        orders: {
          where: { status: 'COMPLETED' },  // 완료된 주문만 카운트
        },
      },
    },
  },
});

6. 관계 쿼리 + Cursor 페이지네이션

// 관계 내부에서 커서 페이지네이션
async function getOrderItems(orderId: string, cursor?: string) {
  const order = await prisma.order.findUnique({
    where: { id: orderId },
    select: {
      id: true,
      items: {
        take: 20,
        ...(cursor && {
          skip: 1,
          cursor: { id: cursor },
        }),
        orderBy: { createdAt: 'desc' },
        select: {
          id: true,
          quantity: true,
          price: true,
          product: {
            select: { id: true, name: true, imageUrl: true },
          },
        },
      },
    },
  });

  const items = order?.items ?? [];
  const nextCursor = items.length === 20 
    ? items[items.length - 1].id 
    : null;

  return { items, nextCursor };
}

// 부모 목록 + 관계 제한 (최근 3개 주문만)
const usersWithRecentOrders = await prisma.user.findMany({
  take: 10,
  select: {
    id: true,
    name: true,
    orders: {
      take: 3,
      orderBy: { createdAt: 'desc' },
      select: {
        id: true,
        total: true,
        status: true,
      },
    },
  },
});

7. 성능 패턴: 관계 쿼리 최적화

안티패턴 문제 최적화
루프 내 findUnique N+1 쿼리 findMany + include 한 번에
불필요한 include: true 전체 컬럼 로드 select로 필드 제한
깊은 중첩 include (4단계+) 쿼리 폭발 2단계까지만, 나머지 별도 쿼리
관계 count에 findMany 사용 불필요한 데이터 전송 _count 서브쿼리 사용
필터 없는 대량 관계 로드 메모리 초과 take/skip으로 제한
// ❌ 안티패턴: 루프 내 개별 쿼리
const users = await prisma.user.findMany();
for (const user of users) {
  const orders = await prisma.order.findMany({
    where: { userId: user.id },
  });  // N번 쿼리 발생!
}

// ✅ 최적화: 한 번에 로드
const usersWithOrders = await prisma.user.findMany({
  include: {
    orders: {
      where: { status: 'ACTIVE' },
      take: 5,
      orderBy: { createdAt: 'desc' },
    },
  },
});

// ✅ 대안: 관계 ID만 먼저 수집 후 일괄 조회
const userIds = users.map(u => u.id);
const allOrders = await prisma.order.findMany({
  where: { userId: { in: userIds } },
});
// 그룹핑은 애플리케이션에서 처리
const ordersByUser = Map.groupBy(allOrders, o => o.userId);

8. Prisma.$queryRaw 폴백

Prisma Client로 표현 불가능한 복잡한 관계 쿼리는 Raw SQL로 폴백합니다.

// 윈도우 함수 + 관계: 카테고리별 상위 3개 상품
const topProducts = await prisma.$queryRaw<TopProduct[]>`
  WITH ranked AS (
    SELECT 
      p.id,
      p.name,
      p.price,
      c.name as category_name,
      ROW_NUMBER() OVER (
        PARTITION BY p.category_id 
        ORDER BY p.sales_count DESC
      ) as rank
    FROM products p
    JOIN categories c ON p.category_id = c.id
    WHERE p.status = 'ACTIVE'
  )
  SELECT id, name, price, category_name
  FROM ranked
  WHERE rank <= 3
  ORDER BY category_name, rank
`;

// 타입 안전성 유지
interface TopProduct {
  id: string;
  name: string;
  price: number;
  category_name: string;
}

마무리

Prisma의 관계 쿼리는 include/select의 기본을 넘어, Fluent API 체이닝, some/every/none 관계 필터, relationLoadStrategy, _count 서브쿼리로 복잡한 데이터 접근 패턴을 타입 안전하게 표현할 수 있습니다. Prisma 관계 쿼리 기초를 이해한 뒤, Prisma Raw SQL 심화로 Client의 한계를 넘어서는 것을 권장합니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux