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. 참고 자료
- Prisma - Select fields
- Prisma - Relation queries
- Prisma - Query optimization
- Prisma GitHub - N+1 problem
10. 다음 단계
쿼리 최적화를 완료했다면, Prisma 트랜잭션으로 동시성 이슈를 해결하는 방법도 살펴보세요.
시리즈 목차:
- Prisma N+1 쿼리 성능 문제 해결하기 (50% 속도 개선) ← 현재 글
- Prisma 일괄 업데이트에서 동시성 이슈 해결하기
- 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 체크가 필요할 수 있습니다.