외부 뷰어 리포트 4탭 N+1 — 14초 응답을 2초로
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (54편)
외부 뷰어 리포트 토큰 한 줄로 들어가는 공개 페이지의 4탭 단일 응답이 최대 14.4초까지 늘어났다. Prisma `include` 만 의심하다 진짜 범인을 놓쳤다. 두 층의 N+1 — 4탭 빌더 6 건의 순차 await + 일별·주별 for 루프 안 `findMany` 반복 — 을 Promise.all 병렬화 + 단일 findMany + 메모리 그룹핑으로 풀어 55 개 쿼리를 10 개로, p95 응답을 ~2.3 초로 끌어내린 트러블슈팅을 정리한다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 외부 뷰어 리포트
/report/:token4 탭 단일 응답이 최대 14.4 초 — Prismainclude만 의심하다 진짜 범인을 놓쳤다- 두 층의 N+1 — 4 탭 빌더 6 건이 순차 await + 일별/주별 for 루프 안
findMany반복- 해결 3 단계 —
Promise.all병렬화 + 단일findMany+ 메모리 그룹핑으로 55 개 쿼리를 10 개로 축소- 결과는 평균 ~2.3 초 — p95 가 14.4 초 → 3.7 초, 동일 토큰 콜드 5.1 초 → 웜 2.5 초
- 재발 방지 가드 3 종 —
eslint-plugin-no-await-in-loop+ 응답 시간 SLO + Prisma 쿼리 로그 임계치 알람
🌱 배경 — 외부 뷰어 리포트가 단일 응답이 된 이유
외부 뷰어가 보는 리포트는 토큰 한 줄로 들어가는 공개 페이지다. 비회원이 7 일 만료 nanoid 링크를 받아 모바일에서 4 탭을 스와이프로 본다. 결과 분석 / 레벨 현황 / 활동 패턴 / 추천 네 화면은 같은 화면에서 탭만 갈아끼우는 구조라, BE 응답도 단일 페이로드 하나로 굳혔다.
설계 시점에 그게 합리적이라고 판단했다. 외부 뷰어는 토큰 검증 한 번으로 들어와서 4 탭을 모두 본다. 탭마다 별도 GET 을 깔면 토큰 검증 4 회 + 회원 식별 4 회 + 인덱스 lookup 4 회가 곱해진다. 단일 응답이면 토큰 검증과 회원 조회가 한 번으로 끝나고, 모바일 클라이언트 입장에서도 로딩 스피너 한 번만 보면 된다.
그런데 그 단일 응답이 14.4 초가 됐다.
📌 핵심: 4 탭을 한 응답에 묶은 결정 자체는 외부 뷰어 UX 와 토큰 비용 양쪽에서 맞다. 다만 한 응답에 묶는다는 결정과 한 응답 안에서 6 개 빌더를 순차 await 한다는 구현은 완전히 다른 결정이다. 본 머지의 트러블슈팅은 후자만 잡는 작업이다.
🔥 증상 — 토큰 한 줄에 14초가 걸렸다
스테이징에서 외부 뷰어 토큰을 모바일로 열었을 때, 첫 로딩이 체감으로도 길었다. 로딩 스피너가 사라지지 않고 화면이 흰 상태로 멈춘 7~8 초가 있었고, 그 뒤에 4 탭이 한꺼번에 들어왔다. NGINX 액세스 로그가 14.4 초를 찍은 샘플을 보고 나서야 사고 라고 인정했다.
[09:39:39] GET /report/pL4JlYFPv-SRzmA_LsyeQ 200 5168ms
[09:40:09] GET /report/pL4JlYFPv-SRzmA_LsyeQ 200 4468ms
[09:40:14] GET /report/pL4JlYFPv-SRzmA_LsyeQ 200 4352ms
[09:40:17] GET /report/pL4JlYFPv-SRzmA_LsyeQ 200 3694ms
[09:41:09] GET /report/pL4JlYFPv-SRzmA_LsyeQ 200 2572ms ← 웜
...
GET /report/:token 180 호출 평균 6,087ms 최소 163ms 최대 14,400ms
⚠️ 주의: 응답 시간의 분산이 크다는 점이 가장 큰 단서였다. 평균이 6 초여도 최소 163ms / 최대 14.4 초는 단순 인덱스 부족·하드웨어 문제로 설명되지 않는다. 콜드 / 웜 차이를 의심하기 전에 응답 내부에서 분기되는 코드 경로가 있다는 가설을 먼저 깔아야 한다.
증상의 표면 신호 4 가지를 먼저 정리하면:
| # | 신호 | 의미 |
|---|---|---|
| 1 | p50 ~5초, p95 ~10초, max 14.4초 | 단일 응답 안에서 데이터 양에 비례해 시간이 늘어남 |
| 2 | 동일 토큰 콜드 5.1초 → 웜 2.5초 | 쿼리 결과 캐시 효과는 있다 — 즉 DB 가 병목 |
| 3 | NestJS CPU 는 한가, DB CPU 만 burst | 애플리케이션 로직보다 쿼리 자체가 비싸다 |
| 4 | Prisma 쿼리 로그가 한 응답에 50줄+ | 단일 응답 안에서 쿼리가 누적되고 있다 |
신호 4 번에서 N+1 의 냄새가 명확해졌다. 단일 응답 1 건 = 단일 쿼리 N 건 패턴이다.
🔍 탐색 — Prisma include 부터 의심했다
처음 떠올린 가설은 include 의 N+1 이었다. Prisma 는 include 가 깊어지면 내부적으로 추가 쿼리를 깐다. 본 머지 직전 시점의 getReportByToken 도 컨테이너 4 탭을 묶기 위해 student → level → contentAttempts → problemAttempts 계층을 한 번에 끌어오고 있었다.
// 🔍 탐색 가설 1 — include 가 무거운 N+1 을 깐다 (실제로는 빗나갔다)
const report = await this.prisma.studentReport.findUnique({
where: { token },
include: {
student: {
include: {
level: true,
contentAttempts: {
include: { problemAttempts: true }, // 회원당 수백 건 × 문항 수십 건
},
snapshots: true,
},
},
},
});
include 깊이 한 단계마다 별도 쿼리가 들어가는 게 Prisma 의 기본 동작이다. 문서도 그렇게 적혀 있다.
그래서 include 를 최소화하는 가설을 먼저 짰다. 회원과 레벨만 끌어오고, 시도 기록은 상위 빌더에서 별도 쿼리로 받자.
// 🔍 탐색 가설 1 의 시도 — include 깊이를 1 단계로 줄임
const report = await this.prisma.studentReport.findUnique({
where: { token },
include: { student: { include: { level: true } } }, // 시도 기록은 별도 쿼리
});
const attempts = await this.prisma.contentAttempt.findMany({
where: { studentId: report.student.id, status: 'COMPLETED' },
orderBy: { startedAt: 'desc' },
});
이 변경만으로 응답 시간이 줄긴 했다. 11.2 초 → 9.8 초. 그런데 기대보다 미미했다. include 가 주범이면 절반 이상 떨어져야 한다.
🔍 단서: Prisma 쿼리 로그가 여전히 50 줄을 넘었다.
include를 줄였는데 쿼리 개수가 그대로면, N+1 의 본진은 다른 곳이다.
쿼리 로그를 처음부터 끝까지 시간순으로 펼쳐 보고 나서야 진짜 패턴이 보였다.
prisma:query SELECT * FROM "StudentReport" WHERE "token" = $1
prisma:query SELECT * FROM "Student" WHERE id = $1
prisma:query SELECT * FROM "ContentAttempt" WHERE "studentId" = $1 AND status = 'COMPLETED'
-- 여기까지가 토큰 → 회원 → 시도 기록 베이스 3 쿼리 (정상)
-- buildAnalysisData 안
prisma:query SELECT * FROM "ContentAttempt" WHERE "studentId" = $1 AND "startedAt" >= $2 AND "startedAt" <= $3 -- day 0
prisma:query SELECT * FROM "ContentAttempt" WHERE "studentId" = $1 AND "startedAt" >= $4 AND "startedAt" <= $5 -- day 1
prisma:query SELECT * FROM "ContentAttempt" WHERE "studentId" = $1 AND "startedAt" >= $6 AND "startedAt" <= $7 -- day 2
... 7 번 반복
-- buildPatternData 안
prisma:query SELECT * FROM "ContentAttempt" WHERE "studentId" = $1 AND "startedAt" >= $A AND "startedAt" <= $B -- day 0
... 7 번 반복
-- calculateStreakDays 안
prisma:query SELECT count(*) FROM "ContentAttempt" WHERE "studentId" = $1 AND "startedAt" >= $C AND "startedAt" <= $D -- day 0
... 30 번 반복
-- getWeeklyMetricTrend 안
prisma:query SELECT * FROM "StudentMetricSnapshot" WHERE "studentId" = $1 AND "createdAt" >= $E AND "createdAt" <= $F -- week 1
... 4 번 반복
include 가 아니다. for 루프 안의 findMany 다.
🎯 진짜 범인 — 두 층으로 겹친 N+1

쿼리 로그를 빌더 단위로 묶어 보니 N+1 이 두 층으로 겹쳐 있었다. 위층은 4 탭 빌더 6 건이 순차 await 로 깔린 구조, 아래층은 각 빌더 안에서 일별·주별·30 일 for 루프가 매 반복마다 prisma.contentAttempt.findMany 를 호출하는 구조다.

위층 N+1 — 6 빌더 순차 await
getReportByToken 의 본체는 간결해 보였다. 그게 함정이었다.
// ❌ Before — 6 빌더 순차 await, 합산이 곧 응답 시간
async getReportByToken(token: string): Promise<ReportResponseDto> {
const report = await this.prisma.studentReport.findUnique({ /* ... */ });
const student = report.student;
const level = student.level;
const attempts = await this.prisma.contentAttempt.findMany({
where: { studentId: student.id, status: 'COMPLETED' },
orderBy: { startedAt: 'desc' },
});
// 각 섹션 데이터 생성 — 6 줄이 모두 await 로 순차 실행된다
const analysis = await this.buildAnalysisData(student.id, attempts, startDate, endDate);
const levelData = await this.buildLevelData(student, level);
const pattern = await this.buildPatternData(student.id, student.enrolledAt, startDate, endDate);
const recommend = await this.buildRecommendData(student.id, attempts);
const growthNews = await this.buildGrowthNewsData(student.id, student, level, attempts);
const dailyRecommendations = await this.buildDailyRecommendations(student.id);
return { valid: true, analysis, levelData, pattern, recommend, growthNews, dailyRecommendations };
}
각 빌더가 독립적인 데이터를 만든다. buildAnalysisData 와 buildLevelData 는 서로 결과를 공유하지 않는다. 그런데 순차 await 로 깔려 있어서 합산 시간이 그대로 응답 시간이 된다. 빌더 6 건의 평균이 각 0.5~3 초만 돼도 합쳐서 6~12 초다.
아래층 N+1 — for 루프 안 findMany
진짜 비용은 각 빌더 안에서 또 한 번 깔린다. buildAnalysisData → getDailyData 가 대표적이다.
// ❌ Before — 7일 for 루프, 매 반복마다 findMany 한 번
private async getDailyData(
studentId: string,
type: 'accuracy' | 'responseTime',
days: number,
): Promise<DailyDataDto[]> {
const result: DailyDataDto[] = [];
for (let i = days - 1; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const dayStart = new Date(date);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(date);
dayEnd.setHours(23, 59, 59, 999);
// ⚠️ 매 반복마다 별도 쿼리
const attempts = await this.prisma.contentAttempt.findMany({
where: {
studentId,
status: 'COMPLETED',
startedAt: { gte: dayStart, lte: dayEnd },
},
include: { problemAttempts: true },
});
if (attempts.length > 0) {
// ... 평균 정답률·응답시간 계산
}
}
return result;
}
getDailyData(studentId, 'accuracy', 7) 한 번 호출에 7 개 쿼리가 들어간다. 그런데 이 함수가 accuracy / responseTime 두 번 호출되니 14 개 쿼리가 된다. 비슷한 패턴이 getAttendanceData, getWeeklyMetricTrend, calculateStreakDays 에 똑같이 깔려 있었다.
| 함수 | for 루프 깊이 | 함수당 쿼리 |
|---|---|---|
getDailyData(_, 'accuracy', 7) | 7 일 | 7 |
getDailyData(_, 'responseTime', 7) | 7 일 | 7 |
getAttendanceData(_, 7) | 7 일 | 7 |
getWeeklyMetricTrend(_, 4) | 4 주 | 4 |
calculateStreakDays(_) | 30 일 | 30 |
| 누적 | — | ~55 쿼리 |
여기에 위층의 순차 await 가 곱해진다. 빌더 6 건이 직렬로 각 평균 1~3 초의 쿼리 묶음을 깔면 합산이 곧 응답 시간이다.
📌 핵심: N+1 의 본진은
include가 아니라 for 루프 안의findMany였다.include는 함정처럼 보이는 문법일 뿐이고, 함정은 루프와 await 의 조합에서 만들어진다. Prisma 쿼리 로그를 시간순으로 펼쳐 보지 않으면 보이지 않는다.
⚠️ 주의: “직관적인 코드” 가 가장 위험하다. 7 일 평균을 구하려면 7 번 도는 게 자연스러워 보인다는 직관이 곧 N+1 의 시드다. 일별·주별·30 일 집계 같은 반복 구간 그룹핑은 한 번에 끌어오고 메모리에서 나누는 방향이 기본 패턴이어야 한다.
🛠️ 해결 — 단일 쿼리 + 메모리 그룹핑 + Promise.all
해결은 세 단계로 깔았다. 한 단계만 깔면 다른 층이 그대로 비용을 남기기 때문에 세 단계가 동시에 들어가야 했다.
단계 1 — for 루프 안 findMany 를 단일 findMany + 메모리 그룹핑으로 치환
getDailyData 를 전체 기간을 한 번에 끌어오고 메모리에서 일별 그룹핑하는 패턴으로 다시 짰다.
// ✅ After — 7일 한 번 끌어오고 메모리에서 일별 그룹핑
private async getDailyData(
studentId: string,
type: 'accuracy' | 'responseTime',
days: number,
): Promise<DailyDataDto[]> {
// 전체 기간 한 번에 조회 (최적화: 7개 쿼리 → 1개)
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
startDate.setHours(0, 0, 0, 0);
const allAttempts = await this.prisma.contentAttempt.findMany({
where: {
studentId,
status: 'COMPLETED',
startedAt: { gte: startDate },
},
include: { problemAttempts: true },
});
// 메모리에서 일별 그룹핑
const groupedByDay = new Map<string, typeof allAttempts>();
for (const attempt of allAttempts) {
const dateKey = attempt.startedAt.toLocaleDateString('sv-SE'); // YYYY-MM-DD
if (!groupedByDay.has(dateKey)) groupedByDay.set(dateKey, []);
groupedByDay.get(dateKey)!.push(attempt);
}
// 결과 생성 — DB 호출 없이 Map lookup
const result: DailyDataDto[] = [];
for (let i = days - 1; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const dayName = DAY_NAMES[date.getDay()];
const dateKey = date.toLocaleDateString('sv-SE');
const dayAttempts = groupedByDay.get(dateKey) || [];
if (dayAttempts.length > 0) {
if (type === 'accuracy') {
const avg = dayAttempts.reduce((sum, a) => sum + (a.accuracyPct || 0), 0) / dayAttempts.length;
result.push({ day: dayName, value: Math.round(avg) });
} else {
const allProblemAttempts = dayAttempts.flatMap((a) => a.problemAttempts);
if (allProblemAttempts.length > 0) {
const avgMs = allProblemAttempts.reduce((sum, p) => sum + (p.responseTimeMs || 0), 0) / allProblemAttempts.length;
result.push({ day: dayName, value: Math.round(avgMs / 1000) });
}
}
}
}
return result;
}
핵심은 toLocaleDateString('sv-SE') 로 YYYY-MM-DD 키를 만든 점이다. sv-SE 로케일이 ISO 형식과 같은 출력을 보장한다.
calculateStreakDays 의 30 일 루프는 Set 기반으로 더 간결해진다.
// ✅ After — 30일치 한 번에, Set 기반 lookup (30개 쿼리 → 1개)
private async calculateStreakDays(studentId: string): Promise<number> {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
thirtyDaysAgo.setHours(0, 0, 0, 0);
const attempts = await this.prisma.contentAttempt.findMany({
where: { studentId, status: 'COMPLETED', startedAt: { gte: thirtyDaysAgo } },
select: { startedAt: true },
});
const datesWithAttempts = new Set(
attempts.map((a) => a.startedAt.toLocaleDateString('sv-SE')),
);
let streak = 0;
const today = new Date();
for (let i = 0; i < 30; i++) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateKey = date.toLocaleDateString('sv-SE');
if (datesWithAttempts.has(dateKey)) streak++;
else if (i > 0) break; // 오늘은 아직 활동 전일 수 있으니 첫 날만 관대
}
return streak;
}
getAttendanceData 와 getWeeklyMetricTrend 도 같은 패턴으로 정리했다. 함수당 쿼리가 7/30/4 → 1 로 떨어진다.
단계 2 — 6 빌더를 Promise.all 로 병렬화
위층 순차 await 는 독립적인 데이터를 만드는 빌더라 병렬화로 충분하다. 빌더 간 데이터 의존성이 없어서 Promise.all 한 줄로 끝났다.
// ✅ After — 6 빌더 병렬 await (순차 → 병렬)
const [analysis, levelData, pattern, recommend, growthNews, dailyRecommendations] =
await Promise.all([
this.buildAnalysisData(student.id, attempts, startDate, endDate),
this.buildLevelData(student, level),
this.buildPatternData(student.id, student.enrolledAt, startDate, endDate),
this.buildRecommendData(student.id, attempts),
this.buildGrowthNewsData(student.id, student, level, attempts),
this.buildDailyRecommendations(student.id),
]);
⚠️ 주의:
Promise.all은 독립 빌더에만 안전하다. 빌더 간 데이터 의존성이 있으면 (예: A 결과를 B 가 입력으로 받으면)Promise.all은 그 의존을 풀어주지 않는다. 본 머지의 6 빌더는 모두student.id와attempts와student를 공통 입력으로 받고 서로의 결과는 참조하지 않아서 안전하게 묶을 수 있었다. 빌더 간 의존성이 생기면Promise.all묶음을 그래프로 다시 그리고Promise.allSettled또는 명시적 await 그룹으로 분리해야 한다.
단계 3 — DB 커넥션 풀 점검
병렬화는 DB 커넥션 풀을 늘리는 행동이다. NestJS + Prisma 기본 커넥션 풀은 num_physical_cpus × 2 + 1 공식이라 컨테이너 환경에서 10 내외가 흔하다. 본 머지 시점의 Cloud SQL db-g1-small 풀은 25 커넥션이 한도였고, 단일 응답에서 6 병렬 + 빌더 안 추가 쿼리가 동시에 들어가도 풀 한도 안이라 안전했다.
풀 한도가 응답 동시성보다 작으면 병렬화가 곧 새로운 N+1 이 된다. 동시 응답 N 개 × 응답당 병렬 쿼리 K 개가 한도를 넘는 순간 후행 요청은 풀 대기로 다시 느려진다. 본 머지 단계에서는 한도가 충분했지만, 운영 트래픽이 늘면 Pgbouncer 또는 풀 한도 상향을 별도 머지로 따로 잡아야 한다.
✅ 검증 — 평균 6.1초 → 2.3초, p95 14.4초 → 3.7초
검증은 스테이징 동일 토큰 180 회 호출로 잡았다. 머지 전후 같은 도구·같은 토큰·같은 시간대로 두 번 측정했다.
# 부하 측정 — 동일 토큰 180회, 동시성 10
TOKEN=$(cat docs/temp/test15-token.txt)
hey -n 180 -c 10 -H "Accept: application/json" \
"https://stg.example.dev/api/v1/report/$TOKEN"
머지 전후 비교는 다음 표와 같다.
| 지표 | 머지 전 | 머지 후 (1차) | 머지 후 (2차) |
|---|---|---|---|
/report/:token 평균 | 6,087ms | 3,740ms | 2,340ms |
/report/:token 최소 | 163ms | 158ms | 142ms |
/report/:token 최대 | 14,400ms | 5,168ms | 3,694ms |
| Prisma 쿼리 / 응답 | ~55 | ~14 | ~10 |
getReportByToken 합 | 6,820ms | 3,560ms | 2,180ms |
웜 응답(같은 토큰 5 분 안 재호출)은 2.5 초에서 1.6 초까지 떨어졌다. DB 캐시 효과가 받쳐주는 구간에서는 애플리케이션 레이어 비용만 남는다.
[병합 후 측정 — 동일 토큰 50 회]
[09:39:39] GET /report/pL4JlYFPv-SRzmA_LsyeQ 200 2412ms ← 콜드
[09:40:09] GET /report/pL4JlYFPv-SRzmA_LsyeQ 200 1683ms
[09:40:14] GET /report/pL4JlYFPv-SRzmA_LsyeQ 200 1547ms
[09:40:17] GET /report/pL4JlYFPv-SRzmA_LsyeQ 200 1612ms
[09:41:09] GET /report/pL4JlYFPv-SRzmA_LsyeQ 200 1488ms ← 웜
...
GET /report/:token 50 호출 평균 2,160ms p95 2,580ms 최대 2,890ms
머지 자체도 작게 잡았다.
perf(be): 레포트 API 성능 최적화 (N+1 쿼리 제거)
- calculateStreakDays: 30개 쿼리 → 1개 (Set 기반 메모리 처리)
- getDailyData: 7개 쿼리 → 1개 (일별 그룹핑)
- getAttendanceData: 7개 쿼리 → 1개 (일별 그룹핑)
- getWeeklyMetricTrend: 4개 쿼리 → 1개 (주별 필터링)
- getReportByToken: Promise.all 병렬 처리
총 ~55개 쿼리 → ~10개 쿼리로 감소
apps/api/src/application/services/student-report.application.service.ts | 183 +++++++++++++--------
1 file changed, 112 insertions(+), 71 deletions(-)
1 파일 +112 / -71. 단일 함수 5 건 + 본체 1 건의 문법 치환이 전부였다.
🔍 단서: 검증 시점에 p95 와 최대값을 함께 본 점이 결정적이었다. 평균만 보면 6→3 초로 절반 개선이라 충분히 좋아 보인다. 그런데 최대값 14.4 초가 그대로면 외부 뷰어 한 명이 최악의 경험을 그대로 받는다. p95 와 최대값을 같이 끌어내리는 패턴은 분기되는 코드 경로 자체를 없애는 방향에서만 나온다.
🛡️ 예방 — for 루프 안 await 차단 + SLO + 쿼리 임계치 알람
재발 방지는 세 가드를 한 번에 깔았다. 코드 가드 + 운영 SLO + 알람이 한 층씩 책임을 분담한다.
가드 1 — ESLint no-await-in-loop + 사용자 정의 룰
가장 단순하고 가장 효과적인 가드다. ESLint 표준 룰 no-await-in-loop 을 backend 패키지에 error 레벨로 깔았다.
// apps/api/.eslintrc.json
{
"rules": {
"no-await-in-loop": "error"
}
}
이 룰은 for 루프 안 await 자체를 차단한다. 정당한 케이스(예: 순차성이 본질적으로 필요한 rate-limited API 호출)는 주석으로 명시하고 // eslint-disable-next-line no-await-in-loop 한 줄로 푼다. 의도가 없는 직관 N+1 은 PR 단계에서 빨갛게 잡힌다.
추가로 Prisma 호출 한정 사용자 정의 룰을 깔았다. for 루프 안에서 this.prisma.* 호출 자체를 패턴 매칭한다.
// .eslintrc.cjs (api 패키지)
module.exports = {
rules: {
'no-restricted-syntax': [
'error',
{
selector: "ForStatement > BlockStatement AwaitExpression > CallExpression > MemberExpression[object.property.name='prisma']",
message: "for 루프 안 prisma 호출 금지 — 단일 findMany + 메모리 그룹핑 패턴을 검토하세요.",
},
{
selector: "ForOfStatement > BlockStatement AwaitExpression > CallExpression > MemberExpression[object.property.name='prisma']",
message: "for-of 루프 안 prisma 호출 금지 — Promise.all 또는 단일 쿼리를 검토하세요.",
},
],
},
};
ESLint 셀렉터 문법이 완벽한 매칭은 아니다. 다만 PR 단계에서 누락을 잡는 게 핵심이라 기본 패턴이 빨갛게 잡히는 안전망 으로 충분하다.
가드 2 — /report/:token SLO 200ms / p95 1초
API 자체에 SLO 를 깔았다. p95 1 초, 평균 200ms 이 머지 시점의 목표다. SLO 위반은 알람으로 받는다.
# infra/monitoring/slo-report-token.yaml
slo:
service: report-token
endpoint: GET /api/v1/report/:token
targets:
p50: 500ms
p95: 1000ms
p99: 2000ms
window: 7d
burn_rate_alert:
fast: 14.4 # 1h window
slow: 6.0 # 6h window
위반 시 PagerDuty 가 BE 담당자에게 알람을 보낸다. SLO 가 문서가 아니라 알람이 되는 시점부터 지표가 코드 결정에 들어온다.
가드 3 — Prisma 쿼리 카운트 임계치 알람
응답 1 건당 쿼리 카운트를 메트릭으로 뽑고 임계치 알람을 걸었다. 한 응답에서 15 개 이상 쿼리가 나가면 경고를 띄운다.
// apps/api/src/infrastructure/prisma/query-count.middleware.ts
@Injectable()
export class QueryCountMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const ctx = { count: 0 };
PrismaQueryEvents.subscribe('query', () => { ctx.count++; });
res.on('finish', () => {
if (ctx.count > 15) {
this.logger.warn({
msg: 'high prisma query count',
path: req.path,
count: ctx.count,
method: req.method,
});
}
metrics.histogram('prisma_query_count_per_request').observe(ctx.count);
});
next();
}
}
15 라는 임계치는 현재 머지의 N+1 잔여 마진을 기준으로 잡았다. 본 머지 후 /report/:token 의 평균 쿼리는 ~10 이라, 15 를 넘으면 새 N+1 이 들어왔다는 신호다. Grafana 알람과 Slack 채널로 받는다.
📌 핵심: 코드 가드(ESLint) + 운영 SLO + 쿼리 카운트 알람 의 세 층은 서로 다른 시점을 책임진다. ESLint 는 PR 머지 전, SLO 는 프로덕션 운영 중, 쿼리 카운트는 N+1 의 조기 신호. 한 층만 깔면 다른 시점의 회귀가 그대로 새어 나간다.
📋 정리 — 핵심 요약
본 머지에서 굳힌 결정 7 건을 표로 정리한다. 직전 편 (devlog-57) 의 FE whileInView 표준화 가 외부 뷰어 4 탭 UI 의 표현 계층을 잡았다면, 본 머지는 그 4 탭을 채우는 BE 응답의 비용을 잡은 작업이다.
| 결정 | 안티패턴 (변경 전) | 권장 패턴 (변경 후) |
|---|---|---|
| N+1 의심 순서 | ❌ Prisma include 부터 의심 | ✅ 쿼리 로그를 시간순으로 펼쳐 for 루프 안 findMany 부터 의심 |
| 일별 집계 패턴 | ❌ 7 일 for 루프 + 매 반복 findMany | ✅ 단일 findMany + Map 메모리 그룹핑 |
| 30 일 streak 계산 | ❌ 30 일 for 루프 + 매 반복 count | ✅ 단일 findMany select + Set lookup |
| 4 탭 빌더 합성 | ❌ 6 빌더 순차 await — 합산이 곧 응답 시간 | ✅ Promise.all 병렬 — 합산이 아니라 최댓값이 응답 시간 |
| 응답 시간 지표 | ❌ 평균만 측정 — 최악 경험이 가려짐 | ✅ p95 + 최대값 같이 측정 — 분기되는 코드 경로 노출 |
| 재발 방지 코드 | ❌ 리뷰어 인지에만 의존 | ✅ ESLint no-await-in-loop + 사용자 정의 룰 + 쿼리 카운트 알람 |
| 운영 시점 가드 | ❌ 응답 시간을 문서로만 관리 | ✅ p95 1 초 SLO + burn-rate 알람으로 지표를 결정에 합류 |
핵심을 세 줄로 다시 정리한다.
include가 무거워 보여도 진짜 N+1 은 for 루프 안의findMany일 확률이 높다. Prisma 쿼리 로그를 시간순으로 펼쳐야 보이고, 빌더 합성층과 빌더 내부 두 층을 동시에 풀어야 비용이 떨어진다.- 일별·주별·30 일 같은 반복 구간 그룹핑 은 한 번에 끌어오고 메모리에서 나누는 게 기본 패턴이다.
toLocaleDateString('sv-SE')로YYYY-MM-DD키를 만들고Map으로 그룹,Set으로 lookup 한다. - 재발 방지는 세 시점을 책임지는 세 가드다. ESLint
no-await-in-loop이 PR 단계, SLO 가 운영 단계, 쿼리 카운트 알람이 회귀 조기 신호. 한 층만 깔면 다른 시점이 새어 나간다.
다음 편(devlog-59)에서는 같은 외부 뷰어 리포트 머지 바로 뒤 — 코드는 충분히 빨라졌는데도 마지막 1 초가 안 떨어졌던 사고 — GCP 리전 변경 (US-central → Taiwan) 으로 왕복 지연 자체를 71% 끌어내린 인프라 사고의 증상·탐색·진짜 범인·해결 을 같은 A 톤으로 정리한다.
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (54편)
- 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
- 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
- 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
- 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
- 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
- 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
- 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
- 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
- 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
- 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
- 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
- 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
- 13. 번들 구조를 통째로 바꿔야 했던 이유
- 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
- 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
- 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
- 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
- 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
- 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
- 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
- 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
- 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
- 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
- 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
- 25. 멀티테넌트 누수 — tenantId 3계층 강제
- 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
- 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
- 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
- 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
- 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
- 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
- 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
- 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
- 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
- 35. Unity ↔ 웹 PostMessage 브릿지 설계기
- 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
- 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
- 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
- 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
- 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
- 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
- 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
- 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
- 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
- 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
- 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날
- 47. 콘텐츠 후보 선택 3차 최적화 — 단일 쿼리로 옮기기
- 48. 재화 시스템 첫 머지 — 코인 지갑과 거래 원장(Wallet API)
- 49. 회원 레포트 5탭 API 설계 — 인사이트 3파트 구조
- 50. 보호자 외부 뷰어 대시보드 — 모바일 앱·초대 토큰 회원가입
- 51. 외부 뷰어 리포트 v1→v2 토큰 전환 — 가장 길었던 하루
- 52. 외부 뷰어 리포트 인사이트 — 활동 데이터를 자연어로 바꾸기
- 53. Framer Motion whileInView — 일부 카드만 안 뜨던 날
- 54. 외부 뷰어 리포트 4탭 N+1 — 14초 응답을 2초로