Prisma N+1 쿼리 성능 문제 해결하기 (50% 속도 개선)

API 응답이 느려서 쿼리 로그를 분석했더니 N+1 유사 패턴이 발견되었습니다. Prisma의 관계 중첩을 활용해 쿼리를 통합하고 50% 성능 개선을 달성한 방법을 소개합니다.

1. 문제 상황

1.1 API 성능 이슈

데이터 그리드 페이지를 열 때 API 응답이 느렸습니다. 월별 기록을 조회하는 GET /api/records API에서 약 500ms 이상 소요되는 경우가 발생했습니다.

사용자 시나리오:
1. 데이터 그리드 페이지 접속
2. API 호출: GET /api/records?year=2026&month=1
3. 항목 30개, 31일치 기록 조회 (최대 930개 레코드)
4. 응답 시간: 300~800ms (비정상적으로 느림)

1.2 데이터베이스 로그 분석

Prisma의 쿼리 로그를 활성화하고 확인해보니:

# prisma 쿼리 로그 활성화
DATABASE_URL="postgresql://..." PRISMA_LOG=query pnpm dev
-- 로그 출력
prisma:query SELECT * FROM "Item" WHERE "groupId" = $1 AND "status" IN ($2, $3)
prisma:query SELECT * FROM "Record" WHERE "itemId" IN ($1, $2, ...) AND "date" >= $4 AND "date" <= $5

문제 발견: 2개의 독립적인 쿼리가 순차적으로 실행되고 있었습니다.

1.3 기존 코드

// ❌ 기존 코드 (2개의 독립 쿼리)
export async function GET(request: NextRequest) {
  // 쿼리 1: 항목 목록 조회
  const items = await prisma.item.findMany({
    where: { groupId, status: { in: ['ACTIVE', 'INACTIVE'] } },
    select: {
      id: true,
      name: true,
      category: true,
      type: true,
      status: true,
    },
    orderBy: [{ status: 'asc' }, { name: 'asc' }],
  });

  // 쿼리 2: 기록 조회
  const records = await prisma.record.findMany({
    where: {
      item: { groupId, status: { in: ['ACTIVE', 'INACTIVE'] } },
      date: { gte: startDate, lte: endDate },
    },
    select: {
      id: true,
      itemId: true,
      date: true,
      statusType: true,
      startTime: true,
      endTime: true,
      breakStart: true,
      breakEnd: true,
      note: true,
    },
  });

  // 클라이언트용 데이터 변환
  const recordMap = buildRecordMap(records);

  return NextResponse.json({
    data: { items, records: recordMap, dates }
  });
}

2. 원인 분석

2.1 N+1 문제란?

N+1 쿼리 문제는 ORM에서 흔히 발생하는 성능 이슈입니다:

N+1 패턴:
- 1개 쿼리: 부모 데이터 조회 (예: 항목 목록)
- N개 쿼리: 각 부모의 자식 데이터 조회 (예: 각 항목의 기록)

결과: N이 클수록 쿼리 수가 급격히 증가

2.2 현재 상황 분석

우리 코드는 "전형적인 N+1"은 아니지만, 유사한 문제가 있습니다:

구분 전형적인 N+1 우리 코드
쿼리 수 1 + N개 2개
문제점 항목 수에 비례하여 쿼리 증가 독립적인 2개 쿼리로 DB 왕복 2회
복잡도 O(N) O(2)

실제로는 2개 쿼리이지만, 이 패턴을 방치하면 나중에 필터링 조건이 추가될 때 쿼리가 분산되어 더 복잡해질 수 있습니다.

2.3 DB 왕복 비용

현재 상황 (2개 쿼리):
                    ┌─────────────────────────────────────┐
  App Server  ──────│  쿼리1: items                       │─────▶ DB
                    │  (네트워크 왕복 1회)                  │
                    ├─────────────────────────────────────┤
              ──────│  쿼리2: records                     │─────▶ DB
                    │  (네트워크 왕복 2회)                  │
                    └─────────────────────────────────────┘

최적화 후 (1개 쿼리):
                    ┌─────────────────────────────────────┐
  App Server  ──────│  쿼리1: items with records          │─────▶ DB
                    │  (네트워크 왕복 1회)                  │
                    └─────────────────────────────────────┘

핵심: 쿼리 자체의 복잡도보다 네트워크 왕복 횟수가 성능에 더 큰 영향을 미칩니다.


3. 해결 방법

3.1 핵심 아이디어

Prisma의 select와 관계 중첩(nested relation)을 활용하여 2개 쿼리를 1개로 통합합니다.

변경 전: items 쿼리 + records 쿼리
변경 후: items 쿼리 with nested records

3.2 Prisma 관계 중첩 문법

// Prisma 관계 중첩 예시
const result = await prisma.parent.findMany({
  select: {
    id: true,
    name: true,
    children: {              // 관계 필드
      where: { ... },        // 자식 테이블 필터링
      select: { ... },       // 자식 필드 선택
    },
  },
});

3.3 최적화된 코드

// ✅ 최적화된 코드 (1개 통합 쿼리)
export async function GET(request: NextRequest) {
  // 해당 월의 날짜 배열
  const dates = getLocalMonthDates(year, month);
  const startDate = parseLocalDate(dates[0]);
  const endDate = parseLocalDate(dates[dates.length - 1]);

  // 항목 + 기록 통합 쿼리
  const itemsWithRecords = await prisma.item.findMany({
    where: { groupId, status: { in: ['ACTIVE', 'INACTIVE'] } },
    select: {
      id: true,
      name: true,
      category: true,
      type: true,
      status: true,
      records: {  // 관계 중첩!
        where: { date: { gte: startDate, lte: endDate } },
        select: {
          id: true,
          date: true,
          statusType: true,
          startTime: true,
          endTime: true,
          breakStart: true,
          breakEnd: true,
          note: true,
        },
      },
    },
    orderBy: [{ status: 'asc' }, { name: 'asc' }],
  });

  // 데이터 변환: itemId -> date -> cell
  const recordMap: Record<string, Record<string, RecordCell>> = {};
  itemsWithRecords.forEach((item) => {
    recordMap[item.id] = {};

    item.records.forEach((rec) => {
      const dateStr = formatLocalDate(rec.date) as DateString;
      recordMap[item.id][dateStr] = {
        itemId: item.id,
        date: dateStr,
        statusType: rec.statusType,
        hasNote: !!rec.note,
        hasTimeDetails: !!(
          rec.startTime ||
          rec.endTime ||
          rec.breakStart ||
          rec.breakEnd
        ),
        recordId: rec.id,
      };
    });
  });

  // items 배열에서 records 제외 (클라이언트 응답 형식 유지)
  const data: MonthlyRecordData = {
    items: itemsWithRecords.map(
      ({ records: _records, ...item }) => item
    ),
    records: recordMap,
    dates: dates as DateString[],
  };

  return NextResponse.json({ data });
}

4. Before/After 비교

4.1 쿼리 로그 비교

-- ❌ Before: 2개 쿼리
prisma:query SELECT "id", "name", "category", "type", "status"
             FROM "Item" WHERE "groupId" = $1 AND "status" IN ('ACTIVE', 'INACTIVE')
             ORDER BY "status" ASC, "name" ASC

prisma:query SELECT "id", "itemId", "date", "statusType", "startTime", "endTime", ...
             FROM "Record" WHERE "date" >= $1 AND "date" <= $2
             AND "itemId" IN (SELECT "id" FROM "Item" WHERE "groupId" = $3)

-- ✅ After: 1개 쿼리 (내부적으로 JOIN 또는 서브쿼리)
prisma:query SELECT "Item"."id", "Item"."name", ...,
             "Record"."id" AS "rec_id", "Record"."date", ...
             FROM "Item"
             LEFT JOIN "Record" ON "Item"."id" = "Record"."itemId"
                AND "Record"."date" >= $1 AND "Record"."date" <= $2
             WHERE "Item"."groupId" = $3 AND "Item"."status" IN ('ACTIVE', 'INACTIVE')
             ORDER BY "Item"."status" ASC, "Item"."name" ASC

4.2 성능 비교

지표 Before After 개선
쿼리 수 2개 1개 -50%
DB 왕복 2회 1회 -50%
응답 시간 300~800ms 150~400ms ~50% 감소
네트워크 오버헤드 높음 낮음 개선

4.3 응답 구조 유지

클라이언트 코드 변경 없이 동일한 응답 구조를 유지합니다:

// 응답 구조 (변경 없음)
interface MonthlyRecordData {
  items: Array<{
    id: string;
    name: string;
    category: string | null;
    type: ItemType;
    status: ItemStatus;
  }>;
  records: Record<string, Record<string, RecordCell>>;
  dates: DateString[];
}

핵심: 서버 내부 최적화로 클라이언트 영향 없음


5. 데이터 변환 로직

5.1 중첩 데이터 평탄화

Prisma 응답은 중첩 구조이지만, 클라이언트는 평탄화된 구조를 기대합니다:

// Prisma 응답 (중첩 구조)
[
  {
    id: 'item-1',
    name: '항목A',
    records: [
      { id: 'rec-1', date: '2026-01-01', statusType: 'COMPLETE', ... },
      { id: 'rec-2', date: '2026-01-02', statusType: 'PENDING', ... },
    ]
  },
  // ...
]

// 클라이언트 기대 (평탄화된 구조)
{
  items: [{ id: 'item-1', name: '항목A' }, ...],
  records: {
    'item-1': {
      '2026-01-01': { statusType: 'COMPLETE', ... },
      '2026-01-02': { statusType: 'PENDING', ... },
    }
  }
}

5.2 변환 코드

// 중첩 데이터를 평탄화된 구조로 변환
const recordMap: Record<string, Record<string, RecordCell>> = {};

itemsWithRecords.forEach((item) => {
  recordMap[item.id] = {};

  item.records.forEach((rec) => {
    const dateStr = formatLocalDate(rec.date) as DateString;
    recordMap[item.id][dateStr] = {
      itemId: item.id,
      date: dateStr,
      statusType: rec.statusType,
      hasNote: !!rec.note,
      hasTimeDetails: !!(
        rec.startTime || rec.endTime || rec.breakStart || rec.breakEnd
      ),
      recordId: rec.id,
    };
  });
});

// items 배열에서 records 제거
const items = itemsWithRecords.map(
  ({ records: _records, ...item }) => item
);

6. 핵심 개념 정리

6.1 Prisma select vs include

구분 select include
용도 특정 필드만 선택 관계 데이터 포함
기본 필드 명시한 필드만 모든 필드 + 관계
성능 필요한 것만 가져옴 더 많은 데이터
관계 중첩 select 내에서 관계 정의 관계 전체 포함
// select: 필요한 필드만
const result = await prisma.item.findMany({
  select: {
    id: true,
    name: true,
    records: {
      select: { id: true, date: true },
    },
  },
});

// include: 모든 필드 + 관계
const result = await prisma.item.findMany({
  include: {
    records: true,  // 모든 record 필드
  },
});

6.2 관계 중첩에서의 필터링

// 관계 데이터에 조건 적용
const result = await prisma.item.findMany({
  select: {
    id: true,
    records: {
      where: { date: { gte: startDate, lte: endDate } },  // 조건
      select: { id: true, date: true },
      orderBy: { date: 'asc' },  // 정렬도 가능
      take: 10,  // 개수 제한도 가능
    },
  },
});

6.3 쿼리 최적화 원칙

1. 필요한 필드만 select
   - 불필요한 데이터 전송 감소
   - 인덱스 활용 가능성 증가

2. 관계 쿼리는 중첩으로
   - 별도 쿼리보다 효율적
   - 네트워크 왕복 감소

3. 필터링은 가능한 DB에서
   - JS에서 필터링하지 말고 where 사용
   - 전송 데이터량 감소

7. 추가 최적화 팁

7.1 인덱스 확인

// schema.prisma
model Record {
  id     String   @id @default(cuid())
  itemId String
  date   DateTime @db.Date
  // ...

  item Item @relation(fields: [itemId], references: [id])

  @@unique([itemId, date])  // 복합 인덱스 (이미 존재)
  @@index([date])           // 날짜 인덱스 추가 고려
}

7.2 대용량 데이터 처리

항목 수가 많아지면 페이지네이션 고려:

// 페이지네이션 적용 예시
const items = await prisma.item.findMany({
  where: { groupId },
  select: {
    // ...
    records: {
      where: { date: { gte: startDate, lte: endDate } },
    },
  },
  skip: (page - 1) * pageSize,
  take: pageSize,
  orderBy: { name: 'asc' },
});

7.3 캐싱 전략

자주 조회되는 데이터는 캐싱 고려:

// Redis 캐싱 예시 (향후 확장)
const cacheKey = `records:${groupId}:${year}:${month}`;
const cached = await redis.get(cacheKey);

if (cached) {
  return NextResponse.json({ data: JSON.parse(cached) });
}

const data = await fetchRecordData();
await redis.setex(cacheKey, 300, JSON.stringify(data));  // 5분 캐시

return NextResponse.json({ data });

8. 베스트 프랙티스

8.1 쿼리 최적화 체크리스트

□ 필요한 필드만 select하고 있는가?
□ 관계 데이터를 별도 쿼리로 조회하고 있지 않은가?
□ 필터링을 JS 대신 DB에서 하고 있는가?
□ 적절한 인덱스가 존재하는가?
□ 쿼리 로그를 통해 실제 쿼리를 확인했는가?

8.2 Prisma 성능 모니터링

// prisma/client 확장으로 쿼리 시간 측정
const prisma = new PrismaClient({
  log: [
    { level: 'query', emit: 'event' },
  ],
});

prisma.$on('query', (e) => {
  console.log('Query:', e.query);
  console.log('Duration:', e.duration + 'ms');
});

8.3 언제 통합 쿼리를 사용할까?

✅ 적합한 경우:
- 부모-자식 관계 데이터를 함께 조회
- 데이터 양이 많지 않은 경우 (수천 개 이하)
- 클라이언트에서 두 데이터를 함께 사용

❌ 부적합한 경우:
- 자식 데이터가 매우 많은 경우 (별도 페이지네이션 필요)
- 부모와 자식을 독립적으로 캐싱해야 하는 경우
- 실시간 업데이트가 필요한 경우 (각각 갱신)

9. 참고 자료


10. 다음 단계

쿼리 최적화를 완료했다면, Prisma 트랜잭션으로 동시성 이슈를 해결하는 방법도 살펴보세요.

시리즈 목차:

  1. Prisma N+1 쿼리 성능 문제 해결하기 (50% 속도 개선) ← 현재 글
  2. Prisma 일괄 업데이트에서 동시성 이슈 해결하기
  3. Prisma Decimal to Number 변환 시 silent failure 방지하기

11. FAQ (자주 묻는 질문)

Q: Prisma에서 N+1 쿼리 문제를 어떻게 확인하나요?

A: prisma.$on('query') 이벤트로 쿼리 로그를 확인하거나, Prisma Studio에서 모니터링합니다. 환경변수 PRISMA_LOG=query를 설정하면 모든 쿼리가 콘솔에 출력됩니다.

Q: select와 include 중 어떤 것을 사용해야 하나요?

A: 필요한 필드만 가져오려면 select, 관계의 모든 필드가 필요하면 include를 사용합니다. 성능상 select가 더 효율적이므로 대부분 select를 권장합니다.

Q: 관계 중첩 쿼리가 항상 더 빠른가요?

A: 대부분의 경우 더 빠르지만, 자식 데이터가 매우 많은 경우(수만 개)에는 별도 쿼리 + 페이지네이션이 더 나을 수 있습니다. 데이터 크기에 따라 판단하세요.

Q: 통합 쿼리 사용 시 주의할 점은?

A: 클라이언트 응답 구조가 변경되지 않도록 데이터 변환 로직을 추가해야 합니다. 또한 관계 데이터가 없는 경우 빈 배열이 반환되므로 null 체크가 필요할 수 있습니다.