외부 뷰어 리포트 인사이트 — 활동 데이터를 자연어로 바꾸기
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (54편)
외부 뷰어가 보는 4탭 리포트에 차트 옆으로 한 줄 자연어 메시지를 붙이는 인사이트 시스템 도입 머지를 정리한다. AI 호출 없이 결정성을 보장하는 룰 기반 메시지 생성기, 5 + 4 + 5 = 14종 긍정 규칙, 빈 데이터 폴백 카피 4종, 4탭별 메시지 슬롯 배치 — 같은 dev 머지 사이클 안에서 응답 표준 + 룰 + 폴백을 동시에 굳힌 설계 흐름과 트레이드오프를 기록한다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 차트만으로는 외부 뷰어에게 충분하지 않다 — 비개발자가 보는 모바일 리포트에는 그래프 한 장 옆으로 “지금 어디가 좋고 어디를 챙겨야 하는지” 한 줄 자연어가 따라붙어야 한다
- AI 호출 0회로 시작 —
InsightGeneratorService가 동일 응답 페이로드만 받아 결정성 있게 한국어 문장을 만든다. 같은 데이터 → 항상 같은 문장, 로그·테스트·캐시가 모두 단순해진다- 3 파트 인사이트 구조 + 14종 긍정 규칙 —
positive/encouragement/improvement세 카테고리.positive만 다시improved5건 +strengths4건 +achieved5건으로 쪼개 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 보강 후속 머지에서 공백 채움 용도로 도입 예정 |
| 2 | 3 파트 인사이트 구조 — positive / encouragement / improvement 세 카테고리 | 한 화면에서 칭찬 / 격려 / 다음 행동 세 톤이 동시에 보이는 게 외부 뷰어 입장에서 가장 자연스러움 / 4탭 분배 시에도 슬롯 위치를 카테고리로 결정 가능 | 카테고리 구분이 흐려지는 케이스 존재 — “정답률이 올라서 다음 단계로 가도 좋다” 같은 문장은 positive 와 improvement 두 카테고리에 동시 후보. 본 머지에서는 생성 우선순위로 분리(positive 가 먼저 평가되어 같은 데이터 케이스를 선점) |
| 3 | positive 만 다시 3 서브카테고리 + 14종 규칙 — improved 5 / strengths 4 / achieved 5 | 긍정 문장은 종류가 가장 많이 나옴 / 같은 톤이라도 향상·강점·달성은 서로 다른 행동 신호 / 룰 ID 로 운영 모니터링 가능 | 규칙 14건 유지보수 비용 — 한 줄 추가가 코드 변경이라 운영자 자가 편집은 불가. 후속 머지에서 JSON 규칙 외부화 검토 |
| 4 | 빈 데이터 폴백 카피 4종 — 탭별 별도 트리거 + 별도 문구 | 공백 응답 자체가 의미 있는 정보 — “아직 활동이 없다 / 아직 변동이 없다 / 패턴이 만들어지는 중이다 / 안정적이다” 4 메시지 / 외부 뷰어에게 시스템이 비어 보이는 것보다 상황이 설명되는 것이 훨씬 안심 | 폴백 카피와 일반 카피의 톤 일관성 별도 검토 필요 — 본 머지에서는 명사 종결 / 평어체 로 통일 |
| 5 | 4탭 단일 응답 + 카테고리 슬롯 분기 — GET /report/:token 한 번 + 응답 안의 4 슬롯 | 토큰 검증·만료·열람 카운트가 한 번만 돔 / 외부 뷰어 입장에서 탭 전환이 즉시 반응 / 캐시 단위가 토큰 그대로 / N+1 회피 | 페이로드 1건이 커짐 — 4탭 데이터 + 14종 평가 결과 + 폴백 4건이 한 응답에 동승. 외부 뷰어 모바일 네트워크에서도 200ms 이내라 통째로 받기를 채택 |
| 6 | AI 호출 슬롯은 비워 두되 인터페이스만 예약 — InsightItem.source: 'rule' | 'ai' 식별자 + ruleId 필드 | AI 도입 후속 머지에서 룰이 0건 생성한 슬롯만 AI 가 메울 수 있게 공백 식별이 가능 / 운영 모니터링에서 룰 vs AI 비율 추적 가능 | 본 머지에서는 source = 'rule' 만 사용 — 빈 필드가 사양에 들어가 있어 정적 분석 도구가 의문 신호를 낼 수 있음. ESLint 인라인 주석으로 후속 도입 예정 필드를 명시 |
결정 1·4·6 이 본 머지의 입구 결정이고, 결정 2·3·5 가 데이터 모델이다. 입구 결정이 먼저 굳고 그 위에 모델이 들어왔다 — 일반 응답 표준 작업에서는 모델이 먼저 굳지만, 본 머지는 어떤 종류의 메시지를 어떤 결정성 수준으로 만들 것인가가 먼저 정해져야 모델이 안정적으로 굳었다.

⚠️ 주의: 외부 뷰어 응답에서 비결정성은 비용이 매우 크다. 같은 토큰으로 들어왔을 때 다른 문장이 나오면, 운영자가 “이 화면이 어떻게 보였을지” 를 재현하기 어렵다. 룰 기반 1순위는 표현력의 손해 위에 재현성·검토 가능성·테스트성을 산다. AI 가 더 좋은 문장을 쓴다는 사실은 본 머지에서도 인정한다 — 다만 언제 어떤 슬롯에서 좋은 문장이 필요한지가 명확해지기 전에 AI 부터 붙이면, 외부 뷰어가 받는 메시지의 변동 범위를 운영자가 통제할 수 없다.
🛠️ 구현 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종을 먼저 검토했다.
🛠️ 구현 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' 예약 |
| 평균 응답 시간 | 약 38ms | Prisma 30ms + 룰 평가 1ms 미만 + 폴백 0.1ms + 직렬화 7ms |
| 단위 테스트 | 38건 | 룰별 트리거·미트리거 페어 + 폴백 4건 + 통합 2건 |
| 결정성 검증 | 같은 페이로드 → 같은 응답 24/24 | 24 케이스 골든 응답 매칭 |
| 운영 모니터링 | 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 도입 후보 |
| 2 | AI 호출 슬롯을 비워 둔 결정 | 인터페이스의 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 |
| 2 | 3 파트 인사이트 구조 | positive / encouragement / improvement | 변경 없음 |
| 3 | 14종 긍정 규칙 | improved 5 / strengths 4 / achieved 5 | 주간 비교 5건 + 지표 분포 4건 추가 후보 |
| 4 | 빈 데이터 폴백 4종 | 탭별 1건 + 카피 룰 4원칙 | 주차별 변형 카피 |
| 5 | 단일 응답 + 탭 슬롯 분기 | GET /report/:token 1회 | 변경 없음 |
| 6 | AI 호출 인터페이스 예약 | 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. 왜 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초로