회원 레포트 5탭 API 설계 — 인사이트 3파트 구조
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (54편)
관리자 페이지 회원 레포트 페이지를 5탭(개요·학습 분석·지표 분석·레벨 이력·상세 기록)으로 설계한 머지. 신규 4 API + 기존 1 API 재사용, 공통 기간 필터, 인사이트 3파트(긍정/격려/개선) 구조, 학습 진도 집계 단위를 묶음에서 콘텐츠로 바꾼 결정, 상세 기록의 드릴다운 3계층(과제→묶음→콘텐츠) 응답 표준을 정리한다. NestJS + Prisma 환경에서 명세 우선·FE Mock 선행 워크플로우로 BE 구현 전 사용자 검토를 확보한 도입 단계 마일스톤이다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 관리자 페이지 회원 레포트를 5탭(개요·학습·지표·레벨·상세)으로 설계한 도입 머지 — 신규 4 API + 기존
level-history1 API 재사용- 공통
ReportQueryDto로 기간 필터(week/month/3months/all) 단일 진입점 — 4 API가 같은 쿼리 파라미터 한 줄을 공유insights를 3파트(positive/encouragement/improvement)로 분리 — 추천(recommendations) 단일 배열 초안을 폐기하고 신호 의미에 맞는 그릇으로 재구성- 학습 진도 집계 단위를 묶음(Bundle)에서 콘텐츠(ContentAttempt)로 변경 — 묶음은 5콘텐츠 묶음이라 활동량을 거시적으로만 보여줘 일별 추이 신호가 둔해진다
- 상세 기록은 드릴다운 3계층 응답(과제 → 묶음 → 콘텐츠) + 페이지네이션 — 한 응답으로 펼침/접힘 모두 처리, FE에서 추가 요청 없이 트리 렌더
- 명세 → Mock → 사용자 검토 → BE 구현 순으로 단계 분리 — 본 머지는 명세 + FE Mock + UI까지, 실제 BE API 구현은 다음 머지로 분리
🎯 배경 — 회원 상세 페이지의 빈 두 탭을 깐 직후
직전 머지에서 관리자 페이지 회원 상세 페이지의 빈 두 탭(학습 진도 / 레벨 이력)을 신규 API 두 개로 채웠다. 학습 진도는 일별 통계 + 최근 묶음 이력 + 지표별 정확도, 레벨 이력은 변동 이벤트 타임라인이다. 이걸로 회원 상세 페이지는 4탭(기본 정보 / 학습 진도 / 레벨 이력 / 메모) 구성이 됐다.
그리고 다음 요구가 들어왔다 — “회원 레포트 페이지를 별도 라우트로 만들고, 상세보다 더 자세한 5탭 페이지로 깔자”. 단순히 상세 페이지를 복사해서 차트를 늘리는 일이 아니다. 상세는 운영자가 회원을 빠르게 파악하는 페이지이고, 레포트는 같은 데이터를 학습 코치 관점에서 해석하는 페이지다. 두 페이지의 그릇은 다르고, 그릇이 다르면 API도 달라야 한다.
본 머지의 단계 분리는 분명했다 — 명세서를 먼저 확정하고(PM 단계), FE가 Mock으로 화면을 깐 다음 사용자가 검토하고, 그 검토 결과를 반영한 다음 BE가 구현한다. 명세를 BE 구현과 같은 머지에 묶지 않은 이유는, 레포트 그릇의 기획은 화면을 보고서야 결정되는 종류의 일이기 때문이다. 차트 형태·인사이트 문구·드릴다운 계층 수는 명세서만 보고 결정할 수 없고, FE Mock UI를 사용자가 직접 클릭해야 다음 단계로 넘어갈 수 있는 결정이 나온다.
📌 핵심: 화면이 핵심인 신규 페이지는 명세 → Mock → 사용자 검토 → BE 구현 순으로 단계를 자르면, BE가 화면 결정을 떠안지 않는다. 명세에 명시된 응답 DTO만 충실히 구현하면 끝이고, 화면 의사결정의 비용은 Mock 단계에서 회수된다.
⚖️ 설계 결정 6건 — 무엇을 명세에 확정하고, 무엇을 미뤘나
명세 + Mock 단계에서 결정 6건을 명시했다. 본문은 이 표의 결정 순서대로 응답 DTO·인사이트 구조·드릴다운 응답·Mock-first 워크플로우 코드를 따라간다.
| # | 결정 | 채택 사유 | 트레이드오프 |
|---|---|---|---|
| 1 | 5탭 구조 — 신규 4 API + 기존 level-history 1 API 재사용 | 레벨 이력 탭은 회원 상세 페이지에서 이미 구현된 응답 DTO와 그릇이 동일. 같은 데이터를 두 페이지에서 두 번 구현하지 않는다 | 재사용 API는 레포트 페이지의 디자인 결정(예: 강조 색상·타임라인 간격)에 영향을 받기 어려움. 그릇이 같아 색상만 페이지 측에서 결정 |
| 2 | 공통 ReportQueryDto — period?: 'week' | 'month' | '3months' | 'all' | 4 신규 API가 모두 기간 필터를 받음. 탭마다 다른 쿼리 파라미터를 쓰면 FE에서 “탭 전환 시 기간 유지” 로직이 깨짐 | 탭별로 의미 있는 기본 기간이 달라도(예: 상세 기록은 month가 적합, 지표는 3months) 단일 기본값(month)으로 통일됨 |
| 3 | recommendations 단일 배열 폐기 — insights 3파트(positive / encouragement / improvement) | 초기 명세는 recommendations: Item[] 단일 배열이었으나, “성장 강조”·“격려”·“개선 권고”의 신호 강도와 색상 코드가 달라 같은 배열에 섞으면 UI에서 매번 분류해야 함 | positive는 다시 improved/strengths/achieved 3 sub-key로 쪼개져 응답 트리가 한 단계 깊어짐. 빈 데이터 Mock도 3 빈 배열을 모두 명시해야 함 |
| 4 | 학습 진도 집계 단위 변경 — Bundle → ContentAttempt | 묶음(Bundle)은 5콘텐츠 묶음이라 일별 통계가 거시적이고 신호 변화가 느림. 콘텐츠(ContentAttempt) 기준으로 바꾸면 일별 추이가 세밀해지고 지표·레벨·응답시간 같은 보조 신호도 같은 단위로 묶임 | DTO 필드명을 4건 동시 변경(completedBundles → completedContents, totalBundles → totalContents, BundleHistoryDto → ContentHistoryDto, recentBundles → recentContents). FE Mock + UI도 같은 머지에 함께 수정 |
| 5 | 상세 기록은 드릴다운 3계층 응답 — assignments[].bundles[].contents[] + 페이지네이션 | 운영자가 회원의 학습 흔적을 추적할 때 “과제 단위 → 묶음 단위 → 콘텐츠 단위” 순으로 펼쳐가는 흐름이 자연스러움. 한 응답으로 모든 계층을 내려주면 FE에서 추가 요청 없이 트리 렌더 가능 | 페이지당 응답이 무거워짐(과제 10개 × 묶음 3개 × 콘텐츠 5개 = 150 콘텐츠 노드). pageSize 기본값을 10으로 잡고, 묶음/콘텐츠는 필요시 lazy load로 분리 가능한 구조로 둠 |
| 6 | 명세 → Mock → 사용자 검토 → BE 구현 단계 분리 — 본 머지는 명세 + Mock + FE UI까지 | 화면이 핵심인 신규 페이지는 응답 DTO를 BE가 먼저 만들면 화면 의사결정을 BE가 떠안게 됨. Mock으로 화면을 먼저 깔면 사용자가 직접 클릭한 뒤 인사이트 문구·차트 형태를 결정할 수 있음 | BE 구현이 다음 머지로 미뤄지면서 통합 시점에 명세 변경 가능성이 남음(recommendations → insights 같은 변경이 한 차례 더 발생할 수 있음). 본 머지 명세서에 “v0.1 draft” 표기 |

결정 4가 본 머지의 가장 무거운 결정이다. 직전 머지에서 막 풀린 학습 진도 API가 묶음 기준이었는데, 같은 응답을 그대로 가져다 쓰면 레포트 페이지의 일별 추이 차트가 거의 평탄한 선이 되는 게 Mock 단계에서 드러났다. 회원 1명이 하루 평균 묶음 23개를 끝낸다면 일별 통계에서 일별 변화는 12 단위로만 움직인다. 같은 회원이 콘텐츠 단위로는 10~18개를 다루므로, 추이 차트의 해상도가 한 자릿수에서 두 자릿수로 올라간다. 그 변화가 인사이트 생성 규칙(향상 +3% 이상, 약점 60% 미만 등)의 신호 대비 잡음 비를 결정적으로 바꾼다.
⚠️ 주의: 동일 도메인의 두 페이지(상세 + 레포트)가 같은 데이터를 보여주더라도, 집계 단위가 다르면 응답 DTO를 별도로 만드는 게 낫다. 같은 DTO를 양쪽에서 쓰려고 무리하면, 한 페이지의 차트가 거칠어지거나 한 페이지의 응답이 불필요하게 무거워진다. “같은 데이터, 다른 그릇”은 SQL 한 줄 비용으로 사후 정합성을 사는 비용보다 싸다.
🛠️ 구현 1 — 공통 쿼리 DTO와 5탭 라우팅
명세서의 첫 줄은 공통 쿼리 파라미터 한 개다. 4 신규 API가 모두 동일한 기간 필터를 받고, 기본값은 month로 통일한다. 탭 전환 시 같은 기간이 유지되면 FE 상태 관리도 단순해진다.
// docs/changes/2026-01-31_회원_레포트_API_명세.md 인용 (실제 BE 구현은 다음 머지)
interface ReportQueryDto {
/** 기간 필터 */
period?: 'week' | 'month' | '3months' | 'all'; // 기본값: 'month'
}
// 4 신규 엔드포인트 + 1 재사용
// GET /api/v1/academy/students/:id/report/overview
// GET /api/v1/academy/students/:id/report/learning-analysis
// GET /api/v1/academy/students/:id/report/metric-analysis
// GET /api/v1/academy/students/:id/report/records
// (재사용) GET /api/v1/academy/students/:id/level-history
NestJS의 OpenAPI 가이드에 따르면, 쿼리 DTO는 단일 클래스로 정의해 @Query() 데코레이터 한 줄로 받는 게 권장 패턴이다. Swagger 스키마도 단일 진입점으로 묶이고, FE의 타입 추출 도구(openapi-typescript 등)에서도 한 곳에서 갱신된다. 본 머지에서는 명세 단계라 코드로 박지 않았지만, 다음 머지의 BE 구현 진입점은 이 단일 클래스로 출발한다.
FE 라우팅은 Refine의 useNavigation 패턴 위에 5개 탭 컴포넌트를 얹는다. 탭 키는 URL 쿼리 파라미터로 빠지고, 페이지 새로고침 후에도 같은 탭이 열린 상태로 복원된다.
// apps/academy-portal/src/pages/students/report.tsx (FE 라우팅 골자)
const TABS = [
{ key: 'overview', label: '개요' },
{ key: 'learning', label: '학습 분석' },
{ key: 'metric', label: '지표 분석' },
{ key: 'history', label: '레벨 이력' }, // 재사용
{ key: 'records', label: '상세 기록' },
] as const;
type TabKey = (typeof TABS)[number]['key'];
export default function MemberReportPage() {
const { id } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = (searchParams.get('tab') ?? 'overview') as TabKey;
const period = (searchParams.get('period') ?? 'month') as ReportQueryDto['period'];
return (
<ReportLayout
activeTab={activeTab}
period={period}
onTabChange={(key) => setSearchParams({ tab: key, period: period! })}
onPeriodChange={(p) => setSearchParams({ tab: activeTab, period: p })}
>
{activeTab === 'overview' && <OverviewTab id={id!} period={period!} />}
{activeTab === 'learning' && <LearningAnalysisTab id={id!} period={period!} />}
{activeTab === 'metric' && <MetricAnalysisTab id={id!} period={period!} />}
{activeTab === 'history' && <LevelHistoryTab id={id!} />}
{activeTab === 'records' && <RecordsTab id={id!} period={period!} />}
</ReportLayout>
);
}
두 결정이 이 코드에 박혀 있다. 첫째, 탭 키와 기간을 URL 쿼리에 양쪽 다 넣었다. 탭만 URL에 두고 기간은 컴포넌트 상태로 두면, 탭 전환 시 기간이 초기값으로 돌아가는 잔존 버그가 생긴다. 둘째, LevelHistoryTab만 period를 받지 않는다 — 재사용 API의 시그니처가 ?limit=50이라 기간 필터 자체가 없다. 결정 1(재사용)의 비용을 그대로 받아들이고 UI 측에서 분기를 명시했다.
🛠️ 구현 2 — insights 3파트 구조로 추천 단일 배열을 폐기
명세 v0.1 초안은 recommendations: RecommendationItem[] 단일 배열이었다. Mock UI를 그려보니 한 배열 안에 “정확도가 향상되었습니다”(긍정)와 “작업기억이 낮습니다”(개선)이 섞이면, 카드 색상·아이콘 톤·우선순위를 매번 type 필드로 분기해야 했다. 표면적으로는 단순해 보였지만 FE 분기 코드가 매 카드마다 6개 분기로 늘었다. 그래서 응답 DTO 단에서 그릇 자체를 3파트로 잘랐다.
interface ReportOverviewResponseDto {
// ... (student, overallGrade, summaryCards, highlights, recentWeek 생략)
/** 인사이트 — 3파트로 분리한 신호 그릇 */
insights: {
/** 긍정 파트 — 향상·강점·달성 */
positive: {
improved: InsightItem[]; // 향상된 것 (정확도 +, 지표 +, 레벨 업 등)
strengths: InsightItem[]; // 잘하는 것 (TOP1 >= 90%, 꾸준한 학습 등)
achieved: InsightItem[]; // 달성한 것 (연속 N일, 주간 목표 등)
};
/** 격려 파트 — 임박·복귀·첫 학습 */
encouragement: InsightItem[];
/** 개선 필요 파트 — 약점 지표·낮은 출석·정확도 하락 */
improvement: InsightItem[];
};
// ... (achievementDistribution 생략)
}
interface InsightItem {
type: string; // 'accuracy_up', 'top1_excellent', 'metric_weak', ...
priority: 'high' | 'medium' | 'low';
icon: string; // 이모지 또는 아이콘 코드
title: string;
message: string;
metric?: string; // 관련 지표 코드
value?: number | string;
comparison?: { before: number; after: number; change: number };
}
positive가 한 단계 더 깊어진 이유는, 긍정 신호 안에서도 강조 색상·노출 순위가 달라야 했기 때문이다. improved는 변화율(+3%)을 강조하는 카드, strengths는 절대값(92.3%)을 강조하는 배지, achieved는 마일스톤 메달 형식이다. 같은 priority: medium이라도 그릇이 다르면 UI에서 다른 컴포넌트로 렌더된다.
빈 데이터 Mock도 같은 트리 구조를 그대로 따른다 — 한 줄이라도 누락되면 FE에서 Cannot read property 'improved' of undefined 류 런타임 에러가 난다.
// apps/academy-portal/src/mocks/member-report.ts (FE Mock)
export const mockReportOverviewEmpty = {
// ... (다른 필드들 빈 값으로 생략)
insights: {
positive: { improved: [], strengths: [], achieved: [] }, // 3 sub-key 모두 명시
encouragement: [],
improvement: [],
},
};
🔍 단서: 빈 데이터 Mock을 응답 트리와 동일 구조로 명시해두면, FE 컴포넌트가 옵셔널 체이닝(
?.) 없이 직접 접근해도 안전하다. 옵셔널 체이닝은 한 곳을 빠뜨리면 그 뒤가 침묵 실패(silent undefined)로 흐르는데, 빈 배열을 명시한 Mock은 컴파일 단계에서 누락을 잡을 수 있다.
긍정 파트의 생성 규칙은 명세서에 표로 박혀 있다. improved는 5종(정확도 향상 +3% / 지표 향상 +5% / 응답시간 단축 -10% / 레벨 상승 / 연속 출석), strengths는 4종(TOP1 >= 90% / 지표 >= 85% / 꾸준한 학습 / 높은 출석), achieved는 5종(EXCELLENT 달성 / 주간 목표 / 레벨 달성 / 연속 N일 / 배치고사 대비 성장). 각 항목의 title·icon·message 템플릿이 명세서 6장에 모두 박혀 있어, BE 구현 시 새 카드 종류를 추가하려면 명세서를 먼저 갱신해야 한다.
🛠️ 구현 3 — 학습 진도 집계 단위를 묶음에서 콘텐츠로
본 머지의 가장 무거운 결정(결정 4)이다. 같은 날 오전 머지에서 직전에 풀린 학습 진도 API가 묶음 기준이었는데, 레포트 Mock 단계에서 일별 통계 차트의 해상도가 부족하다는 사실이 드러났다. DTO 필드명을 4건 동시 변경했다 — completedBundles → completedContents, totalBundles → totalContents, BundleHistoryDto → ContentHistoryDto, recentBundles → recentContents.
// 변경 전 (Bundle 기준)
interface LearningProgressResponseDto {
dailyStats: { date: string; completedBundles: number; ... }[];
recentBundles: BundleHistoryDto[];
summary: { totalBundles: number; ... };
}
interface BundleHistoryDto {
bundleId: string;
completedAt: string;
avgAccuracyPct: number | null;
contentCount: number; // 묶음 안의 콘텐츠 수
status: 'COMPLETE' | 'INCOMPLETE';
}
// 변경 후 (ContentAttempt 기준)
interface LearningProgressResponseDto {
dailyStats: { date: string; completedContents: number; ... }[];
recentContents: ContentHistoryDto[];
summary: { totalContents: number; ... };
}
interface ContentHistoryDto {
contentAttemptId: string;
contentId: string; // 신규 — 콘텐츠 ID
contentName: string; // 신규 — 콘텐츠명
bundleId: string; // 유지 — 소속 묶음 ID
completedAt: string;
accuracyPct: number | null; // 단일 시도라 avg 불필요
metricCode: string | null; // 신규 — 지표 코드
metricName: string | null; // 신규
levelName: string; // 신규 — 시도 레벨명
}
BE 측 변경의 핵심은 집계 쿼리를 Bundle 테이블에서 ContentAttempt로 옮긴다는 점이다. Prisma groupBy로 일별 완료 콘텐츠 수를 집계하고, _avg로 정확도와 응답시간을 함께 끌어온다.
// apps/api/src/application/services/academy-student.application.service.ts
// (실제 BE 변경 — 같은 머지 사이클의 새 노출 단계)
const dailyStats = await this.prisma.contentAttempt.groupBy({
by: ['completedDate'], // endedAt을 날짜로 변환한 generated 컬럼
where: {
studentId,
status: 'COMPLETED',
endedAt: { gte: startDate, lte: endDate },
},
_count: { id: true }, // completedContents
_avg: { accuracyPct: true, responseTimeMs: true },
});
const recentContents = await this.prisma.contentAttempt.findMany({
where: { studentId, status: 'COMPLETED', endedAt: { gte: startDate } },
orderBy: { endedAt: 'desc' },
take: 20,
include: {
content: true,
level: true,
bundleContent: {
select: { metricRank: true, bundle: { select: { id: true } } },
},
},
});
const summary = await this.prisma.contentAttempt.aggregate({
where: { studentId, status: 'COMPLETED', endedAt: { gte: startDate } },
_count: { id: true }, // totalContents
_avg: { accuracyPct: true },
});
세 가지 패턴을 짚어둔다.
첫째, completedDate 같은 generated 컬럼을 두지 않으면 groupBy + 날짜 변환이 어색해진다. Prisma의 groupBy는 표현식이 아닌 컬럼 이름만 받기 때문에, DATE(endedAt) 식의 즉석 변환은 raw SQL로 빠져야 한다. 본 머지에서는 endedAt 옆에 completedDate Date @generated 같은 컬럼을 별도로 둬서 인덱스도 함께 잡을 수 있게 했다.
둘째, recentContents의 include 트리가 한 단계 깊다. bundleContent.bundle.id까지 따라가는 이유는, 콘텐츠가 어느 묶음에 속했는지를 응답에 같이 실어주기 위해서다. 묶음 ID는 상세 기록 탭에서 드릴다운의 키로 다시 쓰이므로, 학습 진도 응답에 미리 실어두면 페이지 간 일관성이 유지된다.
셋째, summary.totalContents는 별도 aggregate 호출이다. dailyStats를 합산해 클라이언트에서 계산할 수도 있지만, 기간 필터가 일별 통계의 정확도 결손(특정 일자 데이터 없음)과 무관하게 총량을 정확히 줘야 한다. SQL 한 줄 비용으로 클라이언트 합산 버그를 피한다.
📌 핵심: 같은 데이터를 다른 단위로 집계하려면 응답 DTO를 별도로 만드는 게 낫다. “학습 진도 API 하나로 두 페이지 다 처리”는 그릇이 같을 때만 성립하고, 차트 해상도가 다르면 그릇도 달라야 한다. DTO 분기보다 한 페이지의 차트가 평탄해지는 비용이 더 크다.
🛠️ 구현 4 — 상세 기록의 드릴다운 3계층 응답
상세 기록 탭은 회원이 끝낸 과제들을 운영자가 펼쳐가며 추적하는 페이지다. 한 응답으로 “과제 → 묶음 → 콘텐츠” 3계층을 모두 내려주는 결정(결정 5)이 가장 응답을 무겁게 만들지만, 펼침/접힘마다 추가 요청을 보내는 비용보다는 낫다는 판단이다.
interface RecordsResponseDto {
/** 과제 목록 (드릴다운 구조) */
assignments: {
assignmentId: string;
date: string;
status: 'COMPLETED' | 'EXPIRED';
achievementState: 'EXCELLENT' | 'NORMAL' | 'POOR' | null;
completedAt: string | null;
durationMinutes: number | null;
avgAccuracyPct: number | null;
/** 묶음 목록 */
bundles: {
bundleId: string;
bundleOrder: number;
status: 'COMPLETE' | 'INCOMPLETE' | 'IN_PROGRESS';
avgAccuracyPct: number | null;
reviewLevelName: string | null;
/** 콘텐츠 목록 */
contents: {
contentAttemptId: string;
contentOrder: number;
contentId: string;
contentName: string;
metricRank: 'TOP1' | 'TOP3' | 'TOP4' | 'TOP5' | null;
metricCode: string | null;
metricName: string | null;
levelName: string;
isReview: boolean;
status: 'COMPLETED' | 'ABANDONED' | 'IN_PROGRESS';
accuracyPct: number | null;
responseTimeMs: number | null;
totalProblems: number;
correctCount: number;
}[];
}[];
}[];
/** 페이지네이션 */
pagination: {
total: number;
page: number;
pageSize: number;
totalPages: number;
};
}
페이지네이션은 최상위(과제) 기준이다. 한 페이지에 10 과제, 한 과제에 3 묶음, 한 묶음에 5 콘텐츠 = 150 콘텐츠 노드까지 한 응답에 실린다. 응답 크기가 부담스러우면 다음 머지에서 bundles / contents만 lazy 로드로 분리할 수 있도록 키 경로를 유지했다 — GET /records?assignmentId=X 같은 보조 엔드포인트가 추가될 여지가 응답 트리 안에 이미 있는 셈이다.
쿼리 파라미터 단의 결정 한 가지를 더 짚어둔다.
interface RecordsQueryDto {
period?: 'week' | 'month' | '3months' | 'all';
status?: 'all' | 'COMPLETED' | 'EXPIRED'; // 과제 상태 필터
page?: number; // 기본 1
pageSize?: number; // 기본 10
}
status: 'all'을 enum 값으로 명시한 이유는, “필터 없음”을 표현하는 방법이 응답 일관성에 영향을 주기 때문이다. undefined를 보내면 FE에서 URLSearchParams로 직렬화할 때 키 자체가 빠지고, 같은 페이지를 새로고침했을 때 기본값과 명시적 선택이 구분되지 않는다. all을 enum에 명시하면 BE는 분기 한 줄(if (status !== 'all') where.status = status), FE는 셀렉터의 첫 옵션이 그대로 URL로 직렬화돼 새로고침 후에도 같은 화면이 복원된다.
💡 인사이트: “필터 없음”을 enum 값으로 명시하는 패턴은 응답 일관성과 URL 직렬화를 동시에 푼다.
undefined대 명시 값의 차이는 URL/State 동기화에서 항상 함정이 되는데, enum에all같은 키를 박으면 양쪽의 표현이 일치한다.
📊 결과 — 명세 1건, Mock 4건, FE 5탭, 같은 날 안에
명세 + Mock + FE UI까지 같은 dev 사이클 약 10시간에 들어갔다. 본 머지의 산출물은 BE 코드 없이 명세 + 화면이다.
$ git log --oneline --stat 2026-01-31 -- 'docs/changes/**' 'apps/academy-portal/**'
13:10 3599006 docs(pm): 회원 레포트 API 명세서 + 기획 문서 (1 file +~750)
13:30 52b80fb refactor(be): 학습 진도 API 묶음→콘텐츠 기준 변경 (2 files ~+120/-80)
14:50 ---- feat(fe): Mock 데이터 작성 (4 신규 + 4 빈데이터) (1 file +280)
16:00 ---- feat(fe): 5탭 라우팅 + 레이아웃 + 기간 셀렉터 (3 files +220)
18:30 ---- feat(fe): 개요 탭 (요약 + 인사이트 3파트) (2 files +340)
20:00 ---- feat(fe): 학습/지표 분석 탭 (차트 + 추이) (4 files +520)
22:00 ---- feat(fe): 레벨 이력(재사용) + 상세 기록 드릴다운 (3 files +410)
23:00 4834257 docs(pm): API 명세 확장 — insights 3파트 + 지표 색상 (1 file +~180)
23:30 6108252 docs(pm): 학습/지표 분석 API 확장 — 추가 데이터 필드 (1 file +~140)
23:59 ---- feat(fe): UI/UX 개선 + Mock 데이터 확장 (5 files +420)
본 머지 사이클의 핵심 지표를 한 표로 정리한다.
| 항목 | 도입 단계 | 비고 |
|---|---|---|
| 신규 API 명세 | 4건 | 개요 / 학습 분석 / 지표 분석 / 상세 기록 |
| 재사용 API | 1건 | level-history 응답 DTO 그대로 |
| 공통 쿼리 DTO | 1건 | ReportQueryDto.period |
| Mock 데이터 (실데이터/빈데이터) | 4 + 4 | 명세서 8~9장 그대로 옮김 |
| FE 탭 수 | 5 | 4 신규 + 1 재사용 |
| BE 코드 변경 | 1건 | 학습 진도 묶음→콘텐츠 4 필드명 + 서비스 집계 단위 |
| 발견된 함정 | 1건 | zustand persist hydration 타이밍 (Mock 활성화에도 Skeleton만 표시) |
발견된 함정 한 건의 회고를 별도로 둔다. Mock 모드 토글을 zustand persist로 저장했더니, 첫 렌더 시점에 localStorage에서 hydration이 끝나기 전에 컴포넌트가 그려져 Skeleton만 계속 보이는 증상이 났다. hasHydrated 상태 + onRehydrateStorage 콜백으로 hydration 완료 시점을 명시했다. 본 머지의 자체 패치였지만, 일반화하자면 “지속화된 클라이언트 상태에 의존하는 UI는 hydration 완료 신호를 명시적으로 기다려야 한다”는 패턴이다.
// apps/academy-portal/src/stores/mock-mode.ts (단순화 인용)
export const useMockModeStore = create(
persist<MockModeState>(
(set) => ({
useMock: false,
hasHydrated: false, // 추가
setUseMock: (v) => set({ useMock: v }),
setHasHydrated: (v) => set({ hasHydrated: v }),
}),
{
name: 'mock-mode',
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true); // hydration 완료 시점에 true
},
},
),
);
// 컴포넌트
const { useMock, hasHydrated } = useMockModeStore();
if (!hasHydrated) return <Skeleton />;
const data = useMock ? mockReportOverview : apiData;
이 패치 한 건이 Mock 단계의 검증 도구 자체의 안정성을 회수했다 — Mock UI 시연 단계에서 화면이 안 그려지면 그릇 결정 자체가 진행될 수 없다.
🔄 회고 — 명세 분리는 옳았나, insights 3파트는 더 빨리 갈렸어야 했나
본 머지의 결정 중 사후 며칠~몇 주 안에 재검토가 필요했던 부분을 정리한다.
첫째, 명세 → Mock → 사용자 검토 → BE 단계 분리(결정 6)는 옳았다. Mock 단계에서 recommendations 단일 배열이 그릇으로 맞지 않다는 사실이 드러났고, BE 구현 전이라 응답 DTO 변경 비용이 명세서 표 하나 갱신으로 끝났다. 만약 BE를 같은 머지에 묶었다면 컨트롤러·서비스·테스트 코드까지 갈아엎어야 했을 결정이, 명세 갱신 + Mock 갱신 + FE UI 갱신 세 단계로 끝났다. 분리한 비용은 다음 머지에 BE 통합이 한 차례 더 필요해진다는 점이지만, BE를 같은 머지에 묶었을 때 발생했을 재구현 비용이 더 컸다.
둘째, insights 3파트 분리(결정 3)는 더 빨리 갈렸어야 했다. 명세 v0.1 초안 시점에 사용자 검토가 한 번 들어왔다면, FE Mock 작성을 두 번 하지 않았을 가능성이 높다. 본 머지에서는 14:50 Mock 작성 → 18:30 개요 탭 구현 → 23:00 명세 확장(insights 분리 반영) → 23:59 UI/UX 개선 순으로 두 번의 Mock 갱신이 발생했다. 명세 단계에서 사용자 검토 한 번을 더 끼웠다면 한 번에 끝났을 결정이다 — 단계 분리의 가치를 살리려면 명세 단계 자체에도 사용자 검토 진입점이 필요하다.
셋째, 학습 진도 묶음→콘텐츠 변경(결정 4)은 통합 비용을 받아들였다. 직전 머지의 응답 DTO를 본 머지에서 갈아엎었다 — 직전 머지의 BE/FE 코드와 Mock이 모두 함께 변경됐다. 같은 날 안에 처리됐다는 점에서 비용은 제한적이었지만, 일반화하자면 “응답 그릇은 화면을 보고 결정된다”는 원칙이 명세 단계에서도 작동했어야 한다는 회고가 남는다.
넷째, 재사용 API(level-history, 결정 1)는 자연스러웠다. 두 페이지가 같은 데이터를 그릇 분기 없이 공유했고, 디자인 결정(타임라인 색상·아이콘)은 모두 페이지 측 컴포넌트에서 분기됐다. 한 응답 DTO가 두 페이지에서 무리 없이 살아남았다는 점에서, 재사용 결정의 비용은 거의 0이었다.
본 머지의 단일 절제선(명세를 BE 구현보다 먼저 확정하고, Mock으로 화면을 깐 다음 사용자 검토에 넘긴다)은 다음 머지 사이클의 보호자(외부 뷰어) 대시보드 도입 머지에서도 그대로 적용됐다. 같은 패턴이 두 번 반복되면서 “신규 페이지는 명세 → Mock → 검토 → BE 순”이 팀 컨벤션으로 굳었다.
💡 인사이트: 응답 DTO를 BE 구현보다 먼저 명세에 박으면, 화면 의사결정의 비용이 Mock 단계에서 회수된다. BE는 명세를 충실히 구현하면 끝이고, 화면 결정의 책임은 PM·FE·사용자 검토 단계 안에서 닫힌다. 명세 → 구현 직행 패턴은 BE가 화면 결정을 떠안는 비용을 사후에 받게 된다.
📋 정리 — 결정 표와 다음 편
| # | 결정 | 채택 | 사후 평가 |
|---|---|---|---|
| 1 | 5탭 구조 — 신규 4 API + 기존 level-history 1 API 재사용 | ✅ | 한 응답 DTO가 두 페이지에서 무리 없이 살아남음 — 재사용 비용 거의 0 |
| 2 | 공통 ReportQueryDto — period?: week|month|3months|all | ✅ | 탭 전환 시 기간 유지 로직이 단순해짐 — 기본값 통일의 사소한 비용은 받아들임 |
| 3 | insights 3파트(positive/encouragement/improvement) | ✅ | 단일 배열 폐기는 옳았으나 더 빨리 갈렸어야 함 — Mock 두 번 작성 회피 가능했음 |
| 4 | 학습 진도 집계 단위 — Bundle → ContentAttempt | ⚠️ | 그릇 결정이 옳았지만 직전 머지 갈아엎기 비용 발생 — 명세 단계 검토 필요성 시사 |
| 5 | 상세 기록 드릴다운 3계층 + 페이지네이션 | ✅ | 한 응답으로 트리 렌더 — lazy 로드 분리 여지를 키 경로에 남겨둠 |
| 6 | 명세 → Mock → 사용자 검토 → BE 구현 단계 분리 | ✅ | 화면 의사결정 비용을 Mock 단계로 회수 — 보호자 대시보드에서도 같은 패턴 반복 |
다음 편(devlog-54)에서는 본 머지의 명세 패턴을 그대로 적용한 보호자(외부 뷰어) 대시보드 도입 머지 — 별도 로그인 흐름과 회원 선택 UI, 토큰 기반 외부 뷰어 설계의 결정·코드·트레이드오프를 정리한다.
📚 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초로