회원 레포트 5탭 API 설계 — 인사이트 3파트 구조

관리자 페이지 회원 레포트 페이지를 5탭(개요·학습 분석·지표 분석·레벨 이력·상세 기록)으로 설계한 머지. 신규 4 API + 기존 1 API 재사용, 공통 기간 필터, 인사이트 3파트(긍정/격려/개선) 구조, 학습 진도 집계 단위를 묶음에서 콘텐츠로 바꾼 결정, 상세 기록의 드릴다운 3계층(과제→묶음→콘텐츠) 응답 표준을 정리한다. NestJS + Prisma 환경에서 명세 우선·FE Mock 선행 워크플로우로 BE 구현 전 사용자 검토를 확보한 도입 단계 마일스톤이다.


💡 Tip. 바쁜 현대인들을 위한 본문 요약

  • 관리자 페이지 회원 레포트를 5탭(개요·학습·지표·레벨·상세)으로 설계한 도입 머지 — 신규 4 API + 기존 level-history 1 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 워크플로우 코드를 따라간다.

#결정채택 사유트레이드오프
15탭 구조 — 신규 4 API + 기존 level-history 1 API 재사용레벨 이력 탭은 회원 상세 페이지에서 이미 구현된 응답 DTO와 그릇이 동일. 같은 데이터를 두 페이지에서 두 번 구현하지 않는다재사용 API는 레포트 페이지의 디자인 결정(예: 강조 색상·타임라인 간격)에 영향을 받기 어려움. 그릇이 같아 색상만 페이지 측에서 결정
2공통 ReportQueryDtoperiod?: 'week' | 'month' | '3months' | 'all'4 신규 API가 모두 기간 필터를 받음. 탭마다 다른 쿼리 파라미터를 쓰면 FE에서 “탭 전환 시 기간 유지” 로직이 깨짐탭별로 의미 있는 기본 기간이 달라도(예: 상세 기록은 month가 적합, 지표는 3months) 단일 기본값(month)으로 통일됨
3recommendations 단일 배열 폐기 — 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건 동시 변경(completedBundlescompletedContents, totalBundlestotalContents, BundleHistoryDtoContentHistoryDto, recentBundlesrecentContents). 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 구현이 다음 머지로 미뤄지면서 통합 시점에 명세 변경 가능성이 남음(recommendationsinsights 같은 변경이 한 차례 더 발생할 수 있음). 본 머지 명세서에 “v0.1 draft” 표기

직접 정리한 회원 레포트 5탭 API 명세 도식 — 탭 라우팅·인사이트 3파트·드릴다운 응답 구조도
직접 정리한 회원 레포트 5탭 API 명세 도식 — 탭 라우팅·인사이트 3파트·드릴다운 응답 구조도

결정 4가 본 머지의 가장 무거운 결정이다. 직전 머지에서 막 풀린 학습 진도 API가 묶음 기준이었는데, 같은 응답을 그대로 가져다 쓰면 레포트 페이지의 일별 추이 차트가 거의 평탄한 선이 되는 게 Mock 단계에서 드러났다. 회원 1명이 하루 평균 묶음 23개를 끝낸다면 일별 통계에서 일별 변화는 12 단위로만 움직인다. 같은 회원이 콘텐츠 단위로는 10~18개를 다루므로, 추이 차트의 해상도가 한 자릿수에서 두 자릿수로 올라간다. 그 변화가 인사이트 생성 규칙(향상 +3% 이상, 약점 60% 미만 등)의 신호 대비 잡음 비를 결정적으로 바꾼다.

⚠️ 주의: 동일 도메인의 두 페이지(상세 + 레포트)가 같은 데이터를 보여주더라도, 집계 단위가 다르면 응답 DTO를 별도로 만드는 게 낫다. 같은 DTO를 양쪽에서 쓰려고 무리하면, 한 페이지의 차트가 거칠어지거나 한 페이지의 응답이 불필요하게 무거워진다. “같은 데이터, 다른 그릇”은 SQL 한 줄 비용으로 사후 정합성을 사는 비용보다 싸다.

docs.nestjs.com

🛠️ 구현 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에 두고 기간은 컴포넌트 상태로 두면, 탭 전환 시 기간이 초기값으로 돌아가는 잔존 버그가 생긴다. 둘째, LevelHistoryTabperiod를 받지 않는다 — 재사용 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 구현 시 새 카드 종류를 추가하려면 명세서를 먼저 갱신해야 한다.

docs.nestjs.com

🛠️ 구현 3 — 학습 진도 집계 단위를 묶음에서 콘텐츠로

본 머지의 가장 무거운 결정(결정 4)이다. 같은 날 오전 머지에서 직전에 풀린 학습 진도 API가 묶음 기준이었는데, 레포트 Mock 단계에서 일별 통계 차트의 해상도가 부족하다는 사실이 드러났다. DTO 필드명을 4건 동시 변경했다 — completedBundlescompletedContents, totalBundlestotalContents, BundleHistoryDtoContentHistoryDto, recentBundlesrecentContents.

// 변경 전 (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 같은 컬럼을 별도로 둬서 인덱스도 함께 잡을 수 있게 했다.

둘째, recentContentsinclude 트리가 한 단계 깊다. 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건개요 / 학습 분석 / 지표 분석 / 상세 기록
재사용 API1건level-history 응답 DTO 그대로
공통 쿼리 DTO1건ReportQueryDto.period
Mock 데이터 (실데이터/빈데이터)4 + 4명세서 8~9장 그대로 옮김
FE 탭 수54 신규 + 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가 화면 결정을 떠안는 비용을 사후에 받게 된다.


📋 정리 — 결정 표와 다음 편

#결정채택사후 평가
15탭 구조 — 신규 4 API + 기존 level-history 1 API 재사용한 응답 DTO가 두 페이지에서 무리 없이 살아남음 — 재사용 비용 거의 0
2공통 ReportQueryDtoperiod?: week|month|3months|all탭 전환 시 기간 유지 로직이 단순해짐 — 기본값 통일의 사소한 비용은 받아들임
3insights 3파트(positive/encouragement/improvement)단일 배열 폐기는 옳았으나 더 빨리 갈렸어야 함 — Mock 두 번 작성 회피 가능했음
4학습 진도 집계 단위 — Bundle → ContentAttempt⚠️그릇 결정이 옳았지만 직전 머지 갈아엎기 비용 발생 — 명세 단계 검토 필요성 시사
5상세 기록 드릴다운 3계층 + 페이지네이션한 응답으로 트리 렌더 — lazy 로드 분리 여지를 키 경로에 남겨둠
6명세 → Mock → 사용자 검토 → BE 구현 단계 분리화면 의사결정 비용을 Mock 단계로 회수 — 보호자 대시보드에서도 같은 패턴 반복

다음 편(devlog-54)에서는 본 머지의 명세 패턴을 그대로 적용한 보호자(외부 뷰어) 대시보드 도입 머지 — 별도 로그인 흐름과 회원 선택 UI, 토큰 기반 외부 뷰어 설계의 결정·코드·트레이드오프를 정리한다.

📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (54편)

  1. 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
  2. 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
  3. 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
  4. 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
  5. 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
  6. 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
  7. 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
  8. 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
  9. 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
  10. 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
  11. 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
  12. 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
  13. 13. 번들 구조를 통째로 바꿔야 했던 이유
  14. 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
  15. 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
  16. 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
  17. 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
  18. 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
  19. 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
  20. 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
  21. 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
  22. 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
  23. 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
  24. 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
  25. 25. 멀티테넌트 누수 — tenantId 3계층 강제
  26. 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
  27. 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
  28. 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
  29. 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
  30. 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
  31. 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
  32. 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
  33. 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
  34. 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
  35. 35. Unity ↔ 웹 PostMessage 브릿지 설계기
  36. 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
  37. 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
  38. 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
  39. 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
  40. 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
  41. 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
  42. 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
  43. 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
  44. 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
  45. 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
  46. 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날
  47. 47. 콘텐츠 후보 선택 3차 최적화 — 단일 쿼리로 옮기기
  48. 48. 재화 시스템 첫 머지 — 코인 지갑과 거래 원장(Wallet API)
  49. 49. 회원 레포트 5탭 API 설계 — 인사이트 3파트 구조
  50. 50. 보호자 외부 뷰어 대시보드 — 모바일 앱·초대 토큰 회원가입
  51. 51. 외부 뷰어 리포트 v1→v2 토큰 전환 — 가장 길었던 하루
  52. 52. 외부 뷰어 리포트 인사이트 — 활동 데이터를 자연어로 바꾸기
  53. 53. Framer Motion whileInView — 일부 카드만 안 뜨던 날
  54. 54. 외부 뷰어 리포트 4탭 N+1 — 14초 응답을 2초로