Prisma 관계 쿼리 최적화

Prisma 관계 쿼리란?

Prisma의 관계 쿼리includeselect를 통해 연관 데이터를 타입 안전하게 로딩하는 핵심 기능입니다. SQL JOIN을 직접 작성하지 않아도 Prisma가 최적화된 쿼리를 자동 생성합니다. 하지만 사용 방식에 따라 N+1 문제나 과도한 데이터 로딩이 발생할 수 있어, 올바른 패턴을 이해하는 것이 중요합니다.

이 글에서는 include/select 깊은 활용법, Fluent API, 관계 필터링, 집계, 그리고 성능 최적화 전략까지 실전 중심으로 다루겠습니다.

include vs select

항목 include select
동작 모든 스칼라 필드 + 관계 추가 명시한 필드만 가져옴
기본 필드 자동 포함 명시해야 포함
혼용 같은 레벨에서 include + select 동시 사용 불가
성능 편리하지만 과도한 데이터 가능 최소 데이터만 전송 (권장)
// Schema 예시
model User {
  id        Int       @id @default(autoincrement())
  name      String
  email     String    @unique
  posts     Post[]
  profile   Profile?
  createdAt DateTime  @default(now())
}

model Post {
  id         Int        @id @default(autoincrement())
  title      String
  content    String?
  published  Boolean    @default(false)
  author     User       @relation(fields: [authorId], references: [id])
  authorId   Int
  category   Category?  @relation(fields: [categoryId], references: [id])
  categoryId Int?
  tags       TagOnPost[]
  comments   Comment[]
  createdAt  DateTime   @default(now())
}

model Tag {
  id    Int         @id @default(autoincrement())
  name  String      @unique
  posts TagOnPost[]
}

model TagOnPost {
  post   Post @relation(fields: [postId], references: [id])
  postId Int
  tag    Tag  @relation(fields: [tagId], references: [id])
  tagId  Int
  @@id([postId, tagId])
}

include: 관계 데이터 추가 로딩

// 기본 include: 모든 스칼라 필드 + 관계
const user = await prisma.user.findUnique({
  where: { id: 1 },
  include: {
    profile: true,           // 1:1
    posts: true,             // 1:N (모든 게시글)
  },
});
// user.id, user.name, user.email, user.createdAt → 모두 포함
// user.profile → Profile | null
// user.posts → Post[]

// 중첩 include: 게시글의 댓글과 카테고리까지
const userDeep = await prisma.user.findUnique({
  where: { id: 1 },
  include: {
    posts: {
      include: {
        comments: {
          include: {
            author: true,    // 댓글 작성자까지
          },
        },
        category: true,
        tags: {
          include: {
            tag: true,       // N:M 중간 테이블 → 태그
          },
        },
      },
    },
  },
});

// include 내 필터링 + 정렬 + 제한
const userWithRecentPosts = await prisma.user.findUnique({
  where: { id: 1 },
  include: {
    posts: {
      where: { published: true },
      orderBy: { createdAt: 'desc' },
      take: 5,
      include: {
        comments: {
          take: 3,
          orderBy: { createdAt: 'desc' },
        },
      },
    },
  },
});

select: 필요한 필드만 정밀 선택

// select로 API 응답에 필요한 최소 데이터만 조회
const userSummary = await prisma.user.findUnique({
  where: { id: 1 },
  select: {
    id: true,
    name: true,
    // email, createdAt → 제외됨
    posts: {
      select: {
        id: true,
        title: true,
        // content → 제외 (목록에서 불필요)
        _count: {
          select: { comments: true },   // 댓글 수만
        },
      },
      where: { published: true },
      orderBy: { createdAt: 'desc' },
      take: 10,
    },
    _count: {
      select: { posts: true },          // 총 게시글 수
    },
  },
});

// 타입이 자동 추론됨:
// {
//   id: number;
//   name: string;
//   posts: { id: number; title: string; _count: { comments: number } }[];
//   _count: { posts: number };
// }

Fluent API: 체이닝 관계 탐색

// Fluent API로 관계를 체인 형태로 탐색
// 유저 → 게시글 목록
const posts = await prisma.user
  .findUnique({ where: { id: 1 } })
  .posts({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
  });

// 게시글 → 작성자 → 프로필
const authorProfile = await prisma.post
  .findUnique({ where: { id: 1 } })
  .author()
  .profile();

// 주의: Fluent API는 각 단계에서 별도 쿼리 실행
// 성능이 중요하면 include/select 사용 권장

관계 필터: where 조건에 관계 활용

// some: 하나 이상의 관계가 조건을 만족
const usersWithPublishedPosts = await prisma.user.findMany({
  where: {
    posts: {
      some: {
        published: true,
        createdAt: { gte: new Date('2026-01-01') },
      },
    },
  },
});

// every: 모든 관계가 조건을 만족
const usersAllPublished = await prisma.user.findMany({
  where: {
    posts: {
      every: { published: true },
    },
  },
});

// none: 조건을 만족하는 관계가 없음
const usersWithoutDrafts = await prisma.user.findMany({
  where: {
    posts: {
      none: { published: false },
    },
  },
});

// 중첩 관계 필터: 특정 태그의 게시글이 있는 유저
const usersWithTag = await prisma.user.findMany({
  where: {
    posts: {
      some: {
        tags: {
          some: {
            tag: { name: 'typescript' },
          },
        },
      },
    },
  },
});

관계 집계: _count, _avg, _sum

// 관계 카운트 정렬: 인기 유저 목록
const popularUsers = await prisma.user.findMany({
  select: {
    id: true,
    name: true,
    _count: {
      select: {
        posts: true,
        // 필터 적용도 가능
      },
    },
  },
  orderBy: {
    posts: { _count: 'desc' },     // 게시글 수로 정렬
  },
  take: 10,
});

// groupBy + 관계 집계
const postsByCategory = await prisma.post.groupBy({
  by: ['categoryId'],
  _count: { id: true },
  _avg: { viewCount: true },
  where: { published: true },
  orderBy: { _count: { id: 'desc' } },
  take: 10,
});

N+1 문제와 해결

// ❌ N+1 패턴: 루프 내에서 관계 쿼리
const users = await prisma.user.findMany();
for (const user of users) {
  // 유저마다 별도 쿼리 → N+1 발생!
  const posts = await prisma.post.findMany({
    where: { authorId: user.id },
  });
}

// ✅ 해결 1: include로 한 번에 로딩
const usersWithPosts = await prisma.user.findMany({
  include: {
    posts: {
      where: { published: true },
    },
  },
});

// ✅ 해결 2: IN 쿼리로 배치 로딩
const users = await prisma.user.findMany();
const userIds = users.map(u => u.id);
const allPosts = await prisma.post.findMany({
  where: { authorId: { in: userIds } },
});
// 직접 매핑
const postsByUser = new Map();
for (const post of allPosts) {
  const arr = postsByUser.get(post.authorId) || [];
  arr.push(post);
  postsByUser.set(post.authorId, arr);
}

중첩 Write: 관계 데이터 동시 생성

// create + 관계 동시 생성
const newUser = await prisma.user.create({
  data: {
    name: 'John',
    email: 'john@example.com',
    profile: {
      create: { bio: 'Developer', avatarUrl: '/avatar.jpg' },
    },
    posts: {
      create: [
        {
          title: 'First Post',
          content: 'Hello World',
          tags: {
            create: [
              { tag: { connectOrCreate: {
                where: { name: 'intro' },
                create: { name: 'intro' },
              }}},
            ],
          },
        },
      ],
    },
  },
  include: { profile: true, posts: { include: { tags: true } } },
});

// update + 관계 조작
const updated = await prisma.user.update({
  where: { id: 1 },
  data: {
    posts: {
      updateMany: {
        where: { published: false },
        data: { published: true },        // 모든 초안 발행
      },
      deleteMany: {
        createdAt: { lt: new Date('2025-01-01') },  // 오래된 글 삭제
      },
    },
  },
});

성능 최적화 체크리스트

항목 권장 사항
select vs include API 응답에는 select로 필요한 필드만 (네트워크·DB 부하 감소)
중첩 깊이 3단계 이상 중첩 시 별도 쿼리로 분리
take/skip 관계 로딩 시 반드시 take로 제한
_count 전체 데이터 불필요 시 _count만 조회
Middleware 로깅 미들웨어로 느린 쿼리 감지
Client Extension 공통 include/select 패턴을 Extension으로 재사용

마무리

Prisma의 관계 쿼리는 includeselect를 중심으로 타입 안전한 데이터 로딩을 제공합니다. some/every/none 필터로 관계 기반 조건 검색이 가능하고, _count 집계로 불필요한 데이터 전송을 줄일 수 있습니다. 성능을 위해서는 select 우선 사용, 중첩 깊이 제한, take 필수 적용 세 가지 원칙을 지키세요.

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