외부 뷰어 리포트 인사이트 — 활동 데이터를 자연어로 바꾸기

외부 뷰어가 보는 4탭 리포트에 차트 옆으로 한 줄 자연어 메시지를 붙이는 인사이트 시스템 도입 머지를 정리한다. AI 호출 없이 결정성을 보장하는 룰 기반 메시지 생성기, 5 + 4 + 5 = 14종 긍정 규칙, 빈 데이터 폴백 카피 4종, 4탭별 메시지 슬롯 배치 — 같은 dev 머지 사이클 안에서 응답 표준 + 룰 + 폴백을 동시에 굳힌 설계 흐름과 트레이드오프를 기록한다.


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

  • 차트만으로는 외부 뷰어에게 충분하지 않다 — 비개발자가 보는 모바일 리포트에는 그래프 한 장 옆으로 “지금 어디가 좋고 어디를 챙겨야 하는지” 한 줄 자연어가 따라붙어야 한다
  • AI 호출 0회로 시작InsightGeneratorService 가 동일 응답 페이로드만 받아 결정성 있게 한국어 문장을 만든다. 같은 데이터 → 항상 같은 문장, 로그·테스트·캐시가 모두 단순해진다
  • 3 파트 인사이트 구조 + 14종 긍정 규칙positive / encouragement / improvement 세 카테고리. positive 만 다시 improved 5건 + strengths 4건 + achieved 5건으로 쪼개 14종 규칙으로 자연어를 생성한다
  • 빈 데이터 폴백 카피 4종 — 탭별 별도 트리거analysis / level / pattern / recommend 각 탭에 “활동이 아직 없다 / 아직 변동이 없다 / 패턴이 만들어지는 중이다 / 안정적이다” 안내 문구를 별도로 박아 공백 응답 자체를 의미 있게 만든다
  • 4탭 단일 응답 + 슬롯 분기GET /report/:token 단일 호출의 응답 안에서 14종 규칙이 한 번에 평가되고, 결과만 4탭으로 흩뿌려진다. 탭 전환 시 추가 호출이 없다
  • AI 메시지 부재 시점의 폴백을 1순위로 — *“AI 를 나중에 붙일 수 있게 룰이 1순위, AI 가 공백만 메우는 구조”*로 입구를 잡았다. 룰이 0건 생성한 케이스에서도 화면이 비지 않게 빈 슬롯 카피가 항상 준비된다

🎯 배경 — 차트 옆으로 한 줄이 필요했던 이유

직전 머지에서 외부 뷰어가 보는 모바일 리포트는 토큰 한 줄로 들어가는 공개 페이지로 갈아엎었다. 4탭(결과 분석 / 레벨 현황 / 활동 패턴 / 추천) UI 는 살아남았고, 응답 표준은 StudentReport 토큰을 키로 한 단일 페이로드로 굳혔다. 그런데 검토 시점에서 한 줄이 더 들어왔다.

“차트만 보면 뭐가 좋은지 뭘 챙겨야 하는지 한 번에 안 읽힌다. 그래프 옆에 한 줄씩 말로 풀어 주면 좋겠다.”

외부 뷰어는 시스템을 다룰 일이 없는 비개발자다. 차트 한 장을 해석할 수 있다는 전제 자체가 깨진다. 같은 데이터를 문장 한 줄로 풀어 주는 계층이 필요했고, 그 계층이 본 머지의 대상이다.

선택지는 둘이었다. AI 모델을 호출해서 자유 문장을 생성하는 길과, 응답 페이로드만 가지고 룰 기반으로 결정성 있게 한국어 문장을 만드는 길. 본 머지에서는 룰 기반을 1순위로 택했고, AI 호출은 본 머지 범위 밖으로 미뤘다.

📌 핵심: 외부 뷰어 리포트는 결정성이 더 큰 가치다. 같은 데이터로 같은 문장이 나와야 운영자가 미리 검토할 수 있고, 캐싱과 로깅이 단순해진다. AI 호출은 응답 시간·비용·표현 품질이 모두 비결정적이라, 룰이 안 되는 케이스에 공백만 메우는 보강 계층으로 미루는 게 맞다. 본 머지는 그 공백을 명시적으로 표현하는 빈 데이터 폴백 카피까지 같이 포함한다.

응답 표준 측면에서도 같은 결론이 나왔다. 4탭이 한 번의 GET /report/:token 호출로 채워지는 단일 페이로드 구조에서, 인사이트가 별도 API로 떨어지면 토큰 검증·만료·열람 카운트 흐름이 두 번 돈다. 인사이트는 페이로드 안의 슬롯 하나로 들어가야 한다. 그 슬롯의 타입을 InsightItem[] 로 표준화하면 4탭 분배도 같은 배열을 필터링하는 한 번의 흐름으로 끝난다.


⚖️ 설계 결정 6건 — 무엇을 룰로 굳히고 무엇을 비워 뒀나

본 머지의 결정 6건을 표로 정리한다. 직전 머지의 결정 표(공개 토큰 / 인증 게이트 분리 / 신규 발급 시 기존 토큰 폐기 등)와 같은 양식으로 이어진다.

#결정채택 사유트레이드오프
1룰 기반 메시지 생성을 1순위로 — AI 호출은 본 머지 범위 밖결정성·테스트성·재생산성 / 같은 페이로드 → 같은 문장 / 캐싱·로깅 단순화 / 외부 뷰어 응답 시간 50ms 이내 보장한 줄의 표현 다양성이 떨어짐 — 같은 패턴이 반복되면 문장이 닳음. AI 보강 후속 머지에서 공백 채움 용도로 도입 예정
23 파트 인사이트 구조positive / encouragement / improvement 세 카테고리한 화면에서 칭찬 / 격려 / 다음 행동 세 톤이 동시에 보이는 게 외부 뷰어 입장에서 가장 자연스러움 / 4탭 분배 시에도 슬롯 위치를 카테고리로 결정 가능카테고리 구분이 흐려지는 케이스 존재 — “정답률이 올라서 다음 단계로 가도 좋다” 같은 문장은 positiveimprovement 두 카테고리에 동시 후보. 본 머지에서는 생성 우선순위로 분리(positive 가 먼저 평가되어 같은 데이터 케이스를 선점)
3positive 만 다시 3 서브카테고리 + 14종 규칙improved 5 / strengths 4 / achieved 5긍정 문장은 종류가 가장 많이 나옴 / 같은 톤이라도 향상·강점·달성은 서로 다른 행동 신호 / 룰 ID 로 운영 모니터링 가능규칙 14건 유지보수 비용 — 한 줄 추가가 코드 변경이라 운영자 자가 편집은 불가. 후속 머지에서 JSON 규칙 외부화 검토
4빈 데이터 폴백 카피 4종 — 탭별 별도 트리거 + 별도 문구공백 응답 자체가 의미 있는 정보“아직 활동이 없다 / 아직 변동이 없다 / 패턴이 만들어지는 중이다 / 안정적이다” 4 메시지 / 외부 뷰어에게 시스템이 비어 보이는 것보다 상황이 설명되는 것이 훨씬 안심폴백 카피와 일반 카피의 톤 일관성 별도 검토 필요 — 본 머지에서는 명사 종결 / 평어체 로 통일
54탭 단일 응답 + 카테고리 슬롯 분기GET /report/:token 한 번 + 응답 안의 4 슬롯토큰 검증·만료·열람 카운트가 한 번만 돔 / 외부 뷰어 입장에서 탭 전환이 즉시 반응 / 캐시 단위가 토큰 그대로 / N+1 회피페이로드 1건이 커짐 — 4탭 데이터 + 14종 평가 결과 + 폴백 4건이 한 응답에 동승. 외부 뷰어 모바일 네트워크에서도 200ms 이내라 통째로 받기를 채택
6AI 호출 슬롯은 비워 두되 인터페이스만 예약InsightItem.source: 'rule' | 'ai' 식별자 + ruleId 필드AI 도입 후속 머지에서 룰이 0건 생성한 슬롯만 AI 가 메울 수 있게 공백 식별이 가능 / 운영 모니터링에서 룰 vs AI 비율 추적 가능본 머지에서는 source = 'rule' 만 사용 — 빈 필드가 사양에 들어가 있어 정적 분석 도구가 의문 신호를 낼 수 있음. ESLint 인라인 주석으로 후속 도입 예정 필드를 명시

결정 1·4·6 이 본 머지의 입구 결정이고, 결정 2·3·5 가 데이터 모델이다. 입구 결정이 먼저 굳고 그 위에 모델이 들어왔다 — 일반 응답 표준 작업에서는 모델이 먼저 굳지만, 본 머지는 어떤 종류의 메시지를 어떤 결정성 수준으로 만들 것인가가 먼저 정해져야 모델이 안정적으로 굳었다.

직접 정리한 외부 뷰어 리포트 인사이트 — 4탭 슬롯 맵 / 3파트 구조 + 14종 규칙 / 빈 데이터 폴백 카피 4종 도식
직접 정리한 외부 뷰어 리포트 인사이트 — 4탭 슬롯 맵 / 3파트 구조 + 14종 규칙 / 빈 데이터 폴백 카피 4종 도식

⚠️ 주의: 외부 뷰어 응답에서 비결정성은 비용이 매우 크다. 같은 토큰으로 들어왔을 때 다른 문장이 나오면, 운영자가 “이 화면이 어떻게 보였을지” 를 재현하기 어렵다. 룰 기반 1순위는 표현력의 손해 위에 재현성·검토 가능성·테스트성을 산다. AI 가 더 좋은 문장을 쓴다는 사실은 본 머지에서도 인정한다 — 다만 언제 어떤 슬롯에서 좋은 문장이 필요한지가 명확해지기 전에 AI 부터 붙이면, 외부 뷰어가 받는 메시지의 변동 범위를 운영자가 통제할 수 없다.

docs.nestjs.com

🛠️ 구현 1 — InsightItem 응답 표준과 3 파트 카테고리

응답 표준의 인사이트 슬롯은 InsightItem[] 단일 배열로 통일했다. 카테고리·서브카테고리·룰 ID 가 동일 인터페이스 안에서 표현돼야 4탭 분배가 한 번의 필터링으로 끝난다.

// apps/api/src/application/dtos/insight.dto.ts
export type InsightType = 'positive' | 'encouragement' | 'improvement';
export type PositiveCategory = 'improved' | 'strengths' | 'achieved';
export type InsightSource = 'rule' | 'ai';

export class InsightItem {
  type!: InsightType;

  // type === 'positive' 일 때만 의미가 있는 서브카테고리
  category?: PositiveCategory;

  // 4탭 분배 키 — 룰이 생성 시점에 결정
  tab!: 'analysis' | 'level' | 'pattern' | 'recommend';

  // 룰 식별자 (예: 'improved.weekly_accuracy_up')
  ruleId!: string;

  // 출력 한국어 문장 — 룰이 템플릿 보간으로 생성
  message!: string;

  // AI 보강 후속 머지에서 사용 — 본 머지에서는 항상 'rule'
  source!: InsightSource;

  // 모니터링·테스트용 보조 데이터 (지표명·delta 등) — 외부 뷰어에는 노출 X
  payload?: Record<string, string | number | null>;
}

4탭별 응답 DTO 는 같은 배열을 받지만 슬롯 위치만 다르다.

// apps/api/src/application/dtos/report-response.dto.ts
export class ReportTabAnalysisDto {
  metricAccuracy!: MetricAccuracyDto[];
  insights!: InsightItem[];   // tab === 'analysis' 필터링 결과
}

export class ReportTabLevelDto {
  events!: LevelChangeEventDto[];
  currentLevel!: CurrentLevelDto;
  insights!: InsightItem[];   // tab === 'level'
}

export class ReportTabPatternDto {
  streakMax!: number;
  dayOfWeekPattern!: DayOfWeekPatternDto;
  insights!: InsightItem[];   // tab === 'pattern'
}

export class ReportTabRecommendDto {
  improvementPriority!: ImprovementPriorityDto[];
  insights!: InsightItem[];   // tab === 'recommend'
}

export class ReportResponseDto {
  student!: ReportStudentSummaryDto;
  analysis!: ReportTabAnalysisDto;
  level!: ReportTabLevelDto;
  pattern!: ReportTabPatternDto;
  recommend!: ReportTabRecommendDto;
}

타입 분기는 카테고리 가 아닌 으로 한다. positive 카테고리 안에서도 improved 는 활동 패턴 탭에 들어갈 수 있고, achieved 는 레벨 현황 탭에 들어간다. 의미상 카테고리와 위치상 탭은 직교하기 때문에 둘 다 인터페이스 상에 박혀 있어야 한다.

응답 표준에서 4탭 슬롯 위치만 결정하는 헬퍼는 한 줄짜리다.

function filterForTab(items: InsightItem[], tab: InsightItem['tab']): InsightItem[] {
  // 생성 시점에 tab 이 박혀 있어 추가 검증 불요 — 같은 배열에서 필터링만
  return items.filter((it) => it.tab === tab);
}

🔍 단서: 같은 룰이 두 탭에 동시에 나타나면 안 되는가? 의 결정은 본 머지에서 나타나지 않는다로 굳혔다. 같은 정보를 두 탭에서 반복하면 외부 뷰어가 읽었던 문장을 다시 만나며 시스템이 데이터가 부족하다고 오해할 수 있다. 룰 단위로 tab 을 1건 부여하는 강제 제약이 인터페이스에 박혀 있다.


🛠️ 구현 2 — 룰 기반 메시지 생성기 (5 + 4 + 5 = 14종 + 격려·개선)

룰 생성기는 단일 클래스로 모았다. 카테고리별 평가 메서드 5개(positiveImproved / positiveStrengths / positiveAchieved / encouragement / improvement) 가 각각 0~N 개의 InsightItem 을 반환하고, 상위 generate()생성 우선순위와 빈 데이터 폴백 분기를 담당한다.

// apps/api/src/application/services/insight-generator.service.ts
@Injectable()
export class InsightGeneratorService {
  generate(payload: ReportInsightPayload): InsightItem[] {
    const items: InsightItem[] = [];

    // 1) positive 14종 — 가장 먼저 평가 (다른 카테고리가 선점하지 못함)
    items.push(...this.positiveImproved(payload));   // 5종
    items.push(...this.positiveStrengths(payload));  // 4종
    items.push(...this.positiveAchieved(payload));   // 5종

    // 2) 빈 데이터·저활동 → encouragement (positive 가 3건 미만이거나 활동 공백)
    if (this.shouldEncourage(items, payload)) {
      items.push(this.encouragement(payload));
    }

    // 3) improvement — 하위 2 지표 우선
    items.push(...this.improvement(payload, /* topN */ 2));

    return items;
  }

  private positiveImproved(p: ReportInsightPayload): InsightItem[] {
    const out: InsightItem[] = [];

    // 규칙 1 — 주간 정답률 상승 (5%p 이상)
    if (p.weeklyTrend.accuracyDelta >= 5) {
      out.push({
        type: 'positive',
        category: 'improved',
        tab: 'analysis',
        ruleId: 'improved.weekly_accuracy_up',
        source: 'rule',
        message: `이번 주 정답률이 지난주 대비 ${p.weeklyTrend.accuracyDelta.toFixed(1)}%p 올랐습니다.`,
        payload: { delta: p.weeklyTrend.accuracyDelta },
      });
    }

    // 규칙 2 — 평균 응답 시간 단축 (200ms 이상)
    if (p.weeklyTrend.responseTimeDeltaMs <= -200) {
      out.push({
        type: 'positive',
        category: 'improved',
        tab: 'pattern',
        ruleId: 'improved.response_time_down',
        source: 'rule',
        message: `평균 응답 시간이 지난주 대비 ${Math.abs(p.weeklyTrend.responseTimeDeltaMs)}ms 빨라졌습니다.`,
      });
    }

    // 규칙 3 — 연속 활동일 최대치 갱신
    if (p.streakMax > p.previousStreakMax) {
      out.push({
        type: 'positive',
        category: 'improved',
        tab: 'pattern',
        ruleId: 'improved.streak_max_grew',
        source: 'rule',
        message: `연속 활동일이 ${p.streakMax}일로 최고 기록을 갱신했습니다.`,
      });
    }

    // 규칙 4 — 완료율 상승 (10%p 이상)
    if (p.weeklyTrend.completionRateDelta >= 10) {
      out.push({
        type: 'positive',
        category: 'improved',
        tab: 'analysis',
        ruleId: 'improved.completion_rate_up',
        source: 'rule',
        message: `이번 주 완료율이 지난주 대비 ${p.weeklyTrend.completionRateDelta.toFixed(0)}%p 올랐습니다.`,
      });
    }

    // 규칙 5 — 지표 균형 개선 (편차 감소)
    if (p.metricVarianceDelta < -5) {
      out.push({
        type: 'positive',
        category: 'improved',
        tab: 'analysis',
        ruleId: 'improved.metric_balance_up',
        source: 'rule',
        message: `지표 간 편차가 줄어 균형 있게 향상되고 있습니다.`,
      });
    }

    return out;
  }

  private positiveStrengths(p: ReportInsightPayload): InsightItem[] {
    const out: InsightItem[] = [];
    const top = [...p.metricAccuracy].sort((a, b) => b.accuracyPct - a.accuracyPct);
    const best = top[0];

    // 규칙 6 — 특정 지표 80% 이상
    if (best && best.accuracyPct >= 80) {
      out.push({
        type: 'positive',
        category: 'strengths',
        tab: 'analysis',
        ruleId: 'strengths.metric_over_80',
        source: 'rule',
        message: `${best.metricName} 영역에서 ${best.accuracyPct.toFixed(0)}%로 강한 모습을 보이고 있습니다.`,
        payload: { metricName: best.metricName, accuracyPct: best.accuracyPct },
      });
    }

    // 규칙 7 — 요일 일관성 (활동일 5일 이상 중 편차 작음)
    if (p.dayOfWeekPattern.activeDays >= 5 && p.dayOfWeekPattern.variance < 0.2) {
      out.push({
        type: 'positive',
        category: 'strengths',
        tab: 'pattern',
        ruleId: 'strengths.day_of_week_consistent',
        source: 'rule',
        message: `요일별 활동량이 고른 편입니다. 꾸준한 리듬이 안착되고 있습니다.`,
      });
    }

    // 규칙 8 — 정답률 변동성 낮음 (표준편차 작음)
    if (p.accuracyVariance !== null && p.accuracyVariance < 7) {
      out.push({
        type: 'positive',
        category: 'strengths',
        tab: 'analysis',
        ruleId: 'strengths.accuracy_variance_low',
        source: 'rule',
        message: `정답률 변동이 작아 안정적인 흐름이 이어지고 있습니다.`,
      });
    }

    // 규칙 9 — 최고 지표가 또래 p50 보다 우위 (벤치마크 있을 때만)
    if (best && p.classBenchmark && best.accuracyPct - p.classBenchmark.p50 >= 10) {
      out.push({
        type: 'positive',
        category: 'strengths',
        tab: 'analysis',
        ruleId: 'strengths.fastest_metric_leads_peers',
        source: 'rule',
        message: `${best.metricName} 영역이 또래 중간값보다 두드러집니다.`,
        payload: { metricName: best.metricName },
      });
    }

    return out;
  }

  // positiveAchieved 5종도 같은 패턴 — 레벨업 이벤트, 첫 완료, 주간 목표 달성,
  // 100% 정답 일자, POOR 하향 후 원복 5건
  // ...
}

규칙 한 건의 조건 수치(5%p / 200ms / 10%p 등) 는 외부 뷰어 검토 단계에서 체감 기준으로 잡았다. 데이터로 최적값을 찾지는 않았고, 본 머지에서는 과도 발화보다 과소 발화를 받는 쪽으로 임계치를 보수적으로 잡았다. 같은 회원이 연속으로 들어왔을 때 같은 문장을 받지 않게, 임계치 부근에서 미세 흔들림이 발화·미발화를 오가는 케이스가 사용자 경험상 더 거슬리기 때문이다.

격려·개선 카테고리는 수치 기반 조건이 아니라 상태 기반 조건이다.

private shouldEncourage(items: InsightItem[], p: ReportInsightPayload): boolean {
  // positive 가 3건 미만이거나 활동 공백이 3일 이상
  const positiveCount = items.filter((i) => i.type === 'positive').length;
  const recentGapDays = differenceInDays(new Date(), p.lastActivityAt ?? new Date(0));
  return positiveCount < 3 || recentGapDays > 3;
}

private encouragement(p: ReportInsightPayload): InsightItem {
  // 격려는 항상 1건 — 비교 표현 없음, 또래 언급 없음, 결과보다 노력 강조
  return {
    type: 'encouragement',
    tab: 'recommend',
    ruleId: 'encouragement.steady_effort',
    source: 'rule',
    message: `오늘 한 줄의 활동이 다음 주 흐름을 만듭니다. 자기 속도대로 이어가도 충분합니다.`,
  };
}

private improvement(p: ReportInsightPayload, topN: number): InsightItem[] {
  // 약한 지표 topN — gap 정렬 (또래 p50 대비 부족분 큰 순)
  const gaps = p.metricAccuracy
    .map((m) => ({
      metric: m,
      gap: (p.classBenchmark?.byMetric[m.metricCode]?.p50 ?? 60) - m.accuracyPct,
    }))
    .filter((g) => g.gap > 0)
    .sort((a, b) => b.gap - a.gap)
    .slice(0, topN);

  return gaps.map((g) => ({
    type: 'improvement',
    tab: 'recommend',
    ruleId: `improvement.${g.metric.metricCode.toLowerCase()}`,
    source: 'rule',
    // 카피 룰 — 부족분 수치를 본문에 노출하지 않는다. *부족분*이 아니라 *다음 행동*이 들어간다.
    message: `${g.metric.metricName} 영역의 활동을 한 단계 늘리면 다음 주 분석이 더 또렷해집니다.`,
    payload: { metricName: g.metric.metricName, metricCode: g.metric.metricCode },
  }));
}

improvement 카테고리에서 가장 신경 쓴 결정은 부족분 수치를 본문에 노출하지 않는다는 카피 룰이다. “33% 부족하다” 같은 문장은 외부 뷰어 입장에서 비난으로 읽힐 수 있다. 같은 정보를 다음 행동으로 바꿔 “한 단계 더 늘리면 다음 주가 또렷해진다” 식으로 풀어 쓴다. 룰의 문장 자체가 한 줄의 디자인 결정이고, 카피 룰을 위반한 룰 추가는 코드 리뷰 단계에서 차단한다.


🛠️ 구현 3 — 빈 데이터 폴백 카피 4종

룰이 0건 생성한 케이스에서도 화면이 비지 않아야 한다. 4탭 각각의 빈 데이터 트리거대응 카피를 같은 자료구조로 정리했다.

// apps/api/src/application/services/empty-state-copy.ts
import type { InsightItem } from '../dtos/insight.dto.js';

type Tab = InsightItem['tab'];

interface EmptyStateRule {
  trigger: string;                  // 운영 로그용 라벨
  predicate: (p: ReportInsightPayload) => boolean;
  copy: string;
}

export const EMPTY_STATE_RULES: Record<Tab, EmptyStateRule> = {
  analysis: {
    trigger: 'totalContents === 0',
    predicate: (p) => p.totalContents === 0,
    copy: '활동 데이터가 아직 누적되지 않았습니다. 지표 차트는 활동이 더 쌓이면 자동으로 나타납니다.',
  },
  level: {
    trigger: 'events.length === 0',
    predicate: (p) => p.events.length === 0,
    copy: '아직 레벨 변동이 없습니다. 현재 레벨이 출발점이며, 변동이 발생하면 이력에 추가됩니다.',
  },
  pattern: {
    trigger: 'streakMax < 2',
    predicate: (p) => p.streakMax < 2,
    copy: '연속 활동 패턴이 만들어지고 있습니다. 3일 연속 활동이 쌓이면 패턴 분석이 시작됩니다.',
  },
  recommend: {
    // 보통 improvement 0건 = 모든 지표가 또래 p50 이상이라 *권장 행동*이 없는 케이스
    trigger: 'improvement.length === 0',
    predicate: (p) => p.metricAccuracy.every((m) => {
      const peer = p.classBenchmark?.byMetric[m.metricCode]?.p50 ?? 60;
      return m.accuracyPct >= peer;
    }),
    copy: '모든 지표가 안정적입니다. 현재 학습 흐름을 그대로 이어가면 충분합니다.',
  },
};

export function applyEmptyState(
  items: InsightItem[],
  payload: ReportInsightPayload,
): InsightItem[] {
  const tabsCovered = new Set(items.map((i) => i.tab));
  const out = [...items];

  (Object.keys(EMPTY_STATE_RULES) as Tab[]).forEach((tab) => {
    // 이미 인사이트가 1건이라도 있으면 폴백 카피는 *덮어쓰지 않는다*
    if (tabsCovered.has(tab)) return;
    const rule = EMPTY_STATE_RULES[tab];
    if (!rule.predicate(payload)) return;

    out.push({
      type: 'encouragement',
      tab,
      ruleId: `empty_state.${tab}`,
      source: 'rule',
      message: rule.copy,
    });
  });

  return out;
}

폴백 카피 4종의 카피 룰은 3 파트 인사이트와 동일하게 비난·비교 금지를 따른다. 핵심 결정 4건:

원칙적용
비난 금지”활동이 없다” 가 아니라 “아직 누적되지 않았다” — 시간 축으로 표현
다음 행동 명시”3일 연속 활동이 쌓이면 패턴 분석이 시작된다” — 조건이 무엇인지 명확
또래 비교 금지빈 데이터 케이스에서 또래 언급은 고립감을 키움 — 폴백 카피는 또래 언급 0건
지표명은 한글enum 코드(MATRIX_REASONING 등)는 외부 뷰어 응답에 노출 X — 항상 행렬추리 / 양적추론 식 한글명

룰의 말투까지 응답 표준의 일부다. 본 머지에서는 명사 종결 + 평어체로 통일했다. “~네요” / “~군요” 같은 친근체는 어조의 변화가 인사이트 톤 안에 또 다른 결정성 손실을 낳아 채택하지 않았다.

📊 데이터: 폴백 카피 4건은 모든 회원의 첫 주에 거의 반드시 보인다. 첫 주 빈 데이터 비율을 운영 데이터로 보면 80% 가까이가 analysis 폴백 + level 폴백 두 건을 받는다. 폴백 카피의 품질이 입구 사용자 경험의 대부분을 결정한다는 뜻이라, 본 머지에서는 룰 14종보다 폴백 4종을 먼저 검토했다.

prisma.io

🛠️ 구현 4 — 4탭 응답에 슬롯 배치

응답 표준이 인사이트 슬롯을 카테고리가 아닌 탭으로 분배한다고 결정한 결과, 슬롯 배치는 응답 생성 마지막 단계에서 한 번의 필터링으로 끝난다.

// apps/api/src/application/services/student-report.application.service.ts
@Injectable()
export class StudentReportApplicationService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly insightGenerator: InsightGeneratorService,
  ) {}

  async getReportByToken(token: string): Promise<ReportResponseDto> {
    const report = await this.prisma.studentReport.findFirst({
      where: { token, expiresAt: { gt: new Date() } },
      include: { student: true },
    });
    if (!report) throw new ReportNotFoundException();

    // viewedAt 박제 — 첫 열람 시점 기록 + viewCount 증가
    await this.touchView(report.id);

    // 4탭 데이터 한 번에 적재
    const payload = await this.collectPayload(report.studentId, report.periodDays);

    // 14종 룰 평가 → 0~N 건의 InsightItem 생성
    let allInsights = this.insightGenerator.generate(payload);

    // 빈 데이터 폴백 4종 — 인사이트가 0건인 탭만 채움
    allInsights = applyEmptyState(allInsights, payload);

    return {
      student: {
        name: report.student.name,
        levelName: payload.currentLevel.name,
      },
      analysis: {
        metricAccuracy: payload.metricAccuracy,
        insights: filterForTab(allInsights, 'analysis'),
      },
      level: {
        events: payload.events,
        currentLevel: payload.currentLevel,
        insights: filterForTab(allInsights, 'level'),
      },
      pattern: {
        streakMax: payload.streakMax,
        dayOfWeekPattern: payload.dayOfWeekPattern,
        insights: filterForTab(allInsights, 'pattern'),
      },
      recommend: {
        improvementPriority: payload.improvementPriority,
        insights: filterForTab(allInsights, 'recommend'),
      },
    };
  }
}

generate()applyEmptyState()filterForTab() 순서가 고정 흐름이다. 룰 생성이 폴백 평가보다 먼저 와야 룰이 0건일 때만 폴백이 들어가고, 폴백이 탭 분배보다 먼저 와야 탭별 비어 있음이 정확히 판정된다.

🔍 단서: 응답 시간은 평균 38ms 였다. 룰 14종 평가가 1ms 미만이고, 폴백 평가가 0.1ms, Prisma 쿼리가 약 30ms 를 차지한다. 외부 뷰어 모바일 네트워크의 응답 지연 200~400ms 와 비교하면, 인사이트 계층의 비용은 측정 가능한 범위 밖에 가깝다. 룰 기반 1순위 결정이 결정성 뿐 아니라 응답 시간 측면에서도 합리적이라는 사후 데이터다.

FE 측은 응답 슬롯을 탭별 한 곳에서 받아 그대로 렌더링한다. 슬롯 위치는 도식 상단의 4탭 카드와 동일하다.

// apps/parent-report/src/pages/report/tabs/AnalysisTab.tsx
export function AnalysisTab({ data }: { data: ReportTabAnalysisDto }) {
  return (
    <div className="space-y-4">
      <MetricRadar metrics={data.metricAccuracy} />
      <InsightSection
        items={data.insights.filter((i) => i.type === 'positive')}
        title="잘하고 있는 점"
      />
      <InsightSection
        items={data.insights.filter((i) => i.type === 'improvement' || i.type === 'encouragement')}
        title="다음 한 걸음"
      />
    </div>
  );
}

InsightSection 컴포넌트는 한 카테고리의 메시지 0~N 건을 받아 카드 리스트로 그린다. 0건일 때는 섹션 자체를 숨긴다 — 빈 섹션의 비어 있음 보다 섹션이 없는 화면이 사용자 경험상 안정적이다. 빈 데이터 케이스는 BE 의 폴백 카피가 이미 1건 들어와 있어 섹션이 비지 않는다.


📊 결과 — 응답 1건, 룰 14, 폴백 4, AI 호출 0

본 머지 직후 측정한 지표를 한 표에 모았다.

항목수치메모
응답 표준 변경insights: InsightItem[] 슬롯 4건 추가4탭별 1슬롯, 전체 응답 1건 유지
룰 카운트14건 (positive 14 + encouragement 1 + improvement 변수)improved 5 / strengths 4 / achieved 5
빈 데이터 폴백4건 (탭별)analysis / level / pattern / recommend
AI 호출 횟수0회source = 'rule' 만 사용, 인터페이스는 'ai' 예약
평균 응답 시간약 38msPrisma 30ms + 룰 평가 1ms 미만 + 폴백 0.1ms + 직렬화 7ms
단위 테스트38건룰별 트리거·미트리거 페어 + 폴백 4건 + 통합 2건
결정성 검증같은 페이로드 → 같은 응답 24/2424 케이스 골든 응답 매칭
운영 모니터링ruleId 분포 로그룰별 발화 횟수·탭 분포 추적

룰 14종은 본 머지 단계의 최소 셋이고, 운영 데이터를 보고 점진적으로 늘리는 자료구조로 잡았다. 한 줄 추가가 카테고리·탭·트리거·문장 4 필드 변경으로 끝난다. 다음 머지에서 주간 비교 기반 5건지표 분포 기반 4건을 추가 후보로 잡아 두었다.

측정 단위룰 14건폴백 4건
코드 줄 수480줄92줄
단위 테스트28건8건
평균 발화회원당 3.2건 (첫 주 1.1건)회원당 1.7건 (첫 주 2.4건)
운영 책임개발자 단독 — 코드 변경개발자 단독 — 코드 변경

운영 책임이 둘 다 개발자 단독이라는 점이 본 머지의 남은 빚이다. 운영자가 문장만 바꾸고 싶을 때도 PR 이 필요하다는 뜻이라, 후속 머지에서 룰 JSON 외부화 를 우선 후보로 잡았다.


🔄 회고 — 다음 머지에서 갈아엎고 싶은 4 결정

#회고사후 평가
1룰 14종을 코드로 둔 결정본 머지 단계에서는 옳음 — 임계치·문장 동시 조정이 코드 리뷰를 통해 검토되는 게 안전. 다만 한 달 뒤 시점에 운영자가 문장만 바꾸고 싶을 때 의 비용이 큼. 후속 머지에서 룰 JSON 외부화 + 어드민 UI 도입 후보
2AI 호출 슬롯을 비워 둔 결정인터페이스의 source: 'rule' | 'ai'ruleId 만 예약한 게 지금 시점에서 가장 가벼운 선택이었음. 다만 언제 AI 가 들어오는가의 조건이 본 머지에서 정해지지 않아 후속 머지에서 룰 0건 케이스 비율 데이터를 보고 다시 결정
3빈 데이터 폴백 카피 4종이 운영 데이터의 80%본 머지에서 룰 14종보다 폴백 4종을 먼저 검토한 게 맞았음. 첫 주 회원의 거의 대부분이 폴백 1~2 건을 받는다. 다만 폴백 4종이 첫 주의 전체 경험이라는 사실이 사후에 또렷해진 만큼, 같은 폴백 카피를 주차에 따라 변형하는 후속 정밀화가 후보
4카피 룰을 비결정성 안전망으로 본 결정”비난 금지 / 다음 행동 명시 / 또래 비교 금지 / 한글 지표명” 4 원칙은 카피 추가의 발화 임계치가 됐음. 다만 룰 14종이 임계치 부근에서 흔들리는 케이스 모니터링이 본 머지에 부재 — 같은 회원이 발화·미발화를 오가는 빈도를 추적하는 후속 모니터링 후보

빚 4건의 공통 축은 운영 데이터가 부족한 시점에 결정을 굳혔다는 사실이다. 본 머지 단계에서는 다른 선택지가 없었다 — 운영 데이터가 룰을 굳히고 나서야 생기기 때문이다. 다음 머지의 입구는 2~3주 분의 발화 데이터를 보고 임계치·문장·폴백 카피를 한 번 같이 갈아엎는 흐름이다.


📋 정리 — 핵심 요약

본 머지의 결정 6건을 한 표로 묶고, 안티/권장 비교를 같이 둔다.

결정 요약

#결정본 머지후속 머지 후보
1룰 기반 1순위14종 코드 박음JSON 외부화 + 어드민 UI
23 파트 인사이트 구조positive / encouragement / improvement변경 없음
314종 긍정 규칙improved 5 / strengths 4 / achieved 5주간 비교 5건 + 지표 분포 4건 추가 후보
4빈 데이터 폴백 4종탭별 1건 + 카피 룰 4원칙주차별 변형 카피
5단일 응답 + 탭 슬롯 분기GET /report/:token 1회변경 없음
6AI 호출 인터페이스 예약source: 'rule' | 'ai' + ruleId룰 0건 슬롯에 한해 AI 보강

안티 / 권장 패턴

상황안티패턴권장 패턴
차트 옆 자연어 메시지 도입 시작점첫 머지에 AI 호출부터 붙임 — 응답 시간·비용·표현 변동 모두 동시 등장룰 1순위 + AI 인터페이스 예약 — 결정성을 먼저 확보하고 AI 는 공백만
인사이트 카테고리 설계카테고리 = 탭 (의미와 위치를 합침)카테고리(의미) ⊥ 탭(위치) 직교 — 같은 카테고리가 여러 탭에 갈 수 있음
룰 함수 분리한 함수 안에서 14건 if/else카테고리별 메서드 5개 + 룰별 평가식 분리 — 단위 테스트가 룰 단위로 작성 가능
부족분 표현”33% 부족하다” 식 수치 노출”다음 한 걸음” 식 행동 제안 — 카피 룰로 강제
빈 데이터 화면섹션 자체를 숨기거나 “데이터 없음”탭별 폴백 카피 1건이 상황 설명 — 시간 축으로 표현
메시지 결정성 검증회귀 테스트 없이 운영 투입골든 응답 24 케이스 + 룰별 트리거/미트리거 페어 38건

다음 편(devlog-57)에서는 본 머지에서 남겨 둔 운영 데이터 수집 흐름과 별도로, 같은 모바일 컨테이너에서 표현 계층을 담당한 Framer Motion whileInView 이슈 — 빈 데이터·인사이트 카드가 뷰포트 안에서 동시에 들어올 때 일부 카드의 애니메이션이 트리거되지 않던 트러블슈팅 — 의 증상·탐색·진짜 범인·해결을 A 톤으로 정리할 예정이다.

📚 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초로