지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기

다섯 개 지표의 점수를 가중평균으로 0~100 범위에 수렴시키던 설계를 폐기하고, 누계 점수를 매 묶음 완료마다 INSERT 전용으로 쌓아 distinct로 최신 1행씩 읽어 TOP1~5 순위를 굳히는 스냅샷 시스템을 짠다. 결정 5건, 트레이드오프, 두 진입점(배치고사 완료·묶음 완료)에서의 호출 패턴, 회복성 try-catch까지 코드 인용으로 정리한다.


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

  • 무엇: 5개 지표(METRIC_A~METRIC_E) 점수를 묶음 완료마다 누계로 쌓고, 그 누계 기준으로 TOP1~5 순위를 굳히는 운영 시스템
  • 계산식 변경: weight × levelMultiplier × accuracy의 가중평균 → weight × (accuracy/100)단순 누계 (levelMultiplier 삭제, 상한 없음)
  • 저장 방식 변경: 같은 날 같은 지표 1행 UPDATE → 매번 INSERT 전용 (@@unique 제거, 이력 자동 보존)
  • 조회 패턴: distinct: ['metricCode'] + orderBy createdAt desc로 지표별 최신 1행만 한 쿼리에 모음
  • 호출 진입점 2곳: 배치고사 완료 시 initializeFromDiagnostic (초기화), 묶음 완료 시 updateCumulativeScores (증분) — 둘 다 try/catch로 본 흐름과 분리
  • 백분위 표시 정책: percentile 컬럼 폐기, View단에서 score / sum × 100 계산 — 표시 정책이 바뀌어도 BE 배포 X

🎯 배경 — 지표 시스템은 사실상 0이었다

이전 편에서 웹 콘텐츠 10종을 같은 PostMessage 봉투로 묶었다. 매 시도마다 16개 통계 필드가 BE로 들어오는 상태가 됐다. 이제 그 데이터를 어디에 어떻게 쌓을지가 남았다.

설계 명세서에서 짚은 현실은 거칠었다.

기능명세
배치고사 지표 저장DiagnosticSession.resultMetrics (JSON 한 덩어리)
묶음 완료 시 지표 업데이트미구현 — 호출 자체가 없음
지표 순위 조회 (getMemberMetricRanks)TODO 상태 — 랜덤 순위 반환
MemberMetricSnapshot 사용통계 SELECT만, INSERT 호출 0건

스냅샷 테이블은 있었다. 호출이 없었을 뿐이다. 거기에 더 큰 설계 문제 두 개가 얹혀 있었다.

첫째, 점수 계산이 가중평균 방식이었다. weight × levelMultiplier × accuracy의 평균을 내고 0100 범위로 잘랐다. 회원이 100문제를 풀든 10,000문제를 풀든 결국 같은 0100 안으로 수렴한다. 학습량이 점수에 누적되지 않으니, 장기 사용 시 TOP1~5 순위 자체가 의미를 잃었다.

둘째, levelMultiplier 라는 임의 설정값이 계산식 가운데 박혀 있었다. 도메인 근거가 약했고 운영자가 조정할 길도 마땅치 않았다. 콘텐츠 가중치(metricWeights)와 정확도(accuracy) 두 신호면 충분했다.

📌 핵심: 지표 시스템의 진짜 결손은 “테이블이 없다”가 아니라 호출이 없다수렴하는 계산식이었다. 스키마는 거의 그대로 두고, 계산식과 저장 방식과 호출 진입점만 다시 짜는 작업으로 좁혔다.

이번 작업의 목표는 셋이다.

  1. 점수는 누계로 쌓되 상한을 두지 않는다 — 학습량이 점수 차이로 남는다.
  2. 저장은 INSERT 전용 — 이력 자동 보존, 동시성·고유키 충돌 없음.
  3. 호출 진입점은 두 곳에서 무조건 — 배치고사 완료, 묶음 완료. 실패해도 본 흐름은 막지 않는다.

⚖️ 설계 결정 — 5건과 트레이드오프

명세 검토 단계에서 짚은 결정 다섯 건이다. 각 결정마다 버린 것과 얻은 것을 같이 둔다.

#결정버린 것얻은 것
1계산식: 가중평균 → 단순 누계0~100 범위라는 직관적인 표시, levelMultiplier 튜닝 여지학습량 차이가 점수에 누적, 장기 사용 시 순위 의미 유지
2저장: UPDATE → INSERT 전용행 수 절약, 같은 날 1행 보장이력 자동 보존, @@unique 위반 없음, 동시성 안전
3조회: 날짜 키 lookup → distinct + orderBy단일 행 직접 lookup의 단순함지표별 최신 1행을 한 쿼리에, 이력은 그대로 보존
4percentile DB 저장 폐기 → View단 계산DB에 미리 계산해둔 표시값표시 정책 변경 시 BE 배포 불필요, 컬럼 1개 절감
5bundleId 컬럼 추가 (nullable)컬럼 1개 부담”어느 묶음이 이 점수를 만들었나” 원인 추적 가능, 배치고사 산 행은 null로 구분

결정 1·2가 같이 가는 이유

누계만 도입하고 저장 방식을 UPDATE로 두면 이력이 사라진다. 같은 회원이 같은 지표를 매번 더하면서 한 행만 업데이트하면, “한 달 전 점수가 얼마였나”를 알 길이 없다. 반대로 INSERT 전용만 도입하고 가중평균을 유지하면 행만 잔뜩 쌓이고 점수는 여전히 수렴한다.

두 결정은 한 덩어리로 묶여야 의미가 있다. 누계 + INSERT 전용 + bundleId 추적이 한 세트다.

결정 4가 가능한 이유

백분위는 다섯 지표 사이의 상대값이다. 한 회원 안에서 score / sum × 100만 하면 나온다. DB에 미리 계산해 둘 이유가 없다. 표시 정책(상한 95%, 로그스케일 등)이 바뀔 때 BE를 건드리지 않아도 된다는 점이 결정적이었다.

다만 통계 분석용으로 다른 회원과 비교하는 백분위는 별 얘기다. 그쪽은 다음 편 운영 테이블 도입에서 분리해 다룬다. 이번 작업은 RAW 누계만 책임진다.

prisma.io

🛠️ 구현 — 도메인 서비스 핵심 4 메서드

지표 누계 로직은 MetricAggregationService 한 곳에 모았다. 핵심은 네 메서드다.

1) 콘텐츠 단위 점수 계산 — calculateMetricScore

// metricScore = contentWeight × (accuracy / 100)
calculateMetricScore(
  accuracyPct: number,
  contentMetricWeights: Record<string, number>,
): Map<MetricCode, number> {
  const scores = new Map<MetricCode, number>();
  for (const [metricCodeStr, weight] of Object.entries(contentMetricWeights)) {
    const metricCode = metricCodeStr as MetricCode;
    const score = weight * (accuracyPct / 100);
    scores.set(metricCode, score);
  }
  return scores;
}

명세서 초안에는 weight × accuracy로 적혀 있었다. 구현 단계에서 정확도를 0~1 범위로 정규화했다. 가중치(110)와 정확도(0100)를 그대로 곱하면 한 회 점수가 너무 커져, 누계 그래프의 가독성이 떨어졌다.

⚠️ 주의: 콘텐츠 메타의 metricWeights는 난이도별로 다른 가중치를 갖는다 ({ EASY: {...}, NORMAL: {...}, HARD: {...} }). 호출부에서 getMetricWeightsForDifficulty(diffWeights, difficulty)로 한 단계 풀어 넘긴다. 이 변환을 도메인 서비스 안에 두지 않은 건 — 난이도 결정은 어플리케이션 레이어(묶음 완료 처리)의 책임이라 판단했기 때문이다.

2) 묶음 완료 시 누계 갱신 — updateCumulativeScores

async updateCumulativeScores(
  memberId: string,
  bundleId: string,
  bundleContents: BundleContentResult[],
): Promise<void> {
  // 1. 현재 누계 조회 (지표별 최신)
  const currentSnapshots = await this.getLatestSnapshots(memberId);
  const currentScores = new Map<MetricCode, number>();
  const currentAttemptCounts = new Map<MetricCode, number>();
  const currentTotalAccuracy = new Map<MetricCode, number>();

  for (const snapshot of currentSnapshots) {
    currentScores.set(snapshot.metricCode, snapshot.score);
    currentAttemptCounts.set(snapshot.metricCode, snapshot.attemptCount);
    // 누적 평균 → 누적 합계 역산 (평균 보존을 위해)
    currentTotalAccuracy.set(
      snapshot.metricCode,
      snapshot.avgAccuracy * snapshot.attemptCount,
    );
  }

  // 2. 이번 묶음의 지표별 점수 합산
  const bundleScores = new Map<MetricCode, number>();
  for (const content of bundleContents) {
    if (!content.metricWeights) continue;
    const contentScores = this.calculateMetricScore(
      content.accuracyPct,
      content.metricWeights,
    );
    for (const [metric, score] of contentScores) {
      bundleScores.set(metric, (bundleScores.get(metric) || 0) + score);
    }
  }

  // 3. 새 누계 계산 및 INSERT (변화가 있는 지표만)
  for (const metricCode of Object.values(MetricCode)) {
    const previousScore = currentScores.get(metricCode) || 0;
    const addedScore = bundleScores.get(metricCode) || 0;
    const newScore = previousScore + addedScore;
    ...
    if (addedScore > 0 || previousScore === 0) {
      await this.createSnapshot({
        memberId, metricCode, score: newScore,
        bundleId, attemptCount, avgAccuracy,
      });
    }
  }

  // 4. TOP1~5 순위 재계산
  await this.updateRanks(memberId);
}

네 단계로 끊었다 — 현재 누계 조회 / 묶음 합산 / 새 누계 INSERT / 순위 재계산. 각 단계가 다른 책임이라, 디버깅 로그도 단계별로 찍는다 ([MetricCumulative] UpdateScores → Previous → Added → New → Ranks).

🔍 단서: “변화가 있는 지표만 INSERT” 조건(addedScore > 0 || previousScore === 0)을 둔 이유는 — 한 묶음 안에 안 풀린 지표는 누계가 바뀌지 않으니 행을 만들 필요가 없기 때문. 단, 점수가 0이고 변화도 없는 신규 회원의 초기 행은 생성해야 해서 두 번째 조건이 붙는다.

3) 배치고사 완료 시 초기화 — initializeFromDiagnostic

async initializeFromDiagnostic(
  memberId: string,
  diagnosticResults: Record<string, number>, // { METRIC_A: 75, METRIC_B: 80, ... }
): Promise<void> {
  for (const [metricCodeStr, accuracy] of Object.entries(diagnosticResults)) {
    const metricCode = metricCodeStr as MetricCode;
    // 초기 누계 = 배치고사 정확도 × 기본 가중치(10)
    const initialScore = accuracy * this.DIAGNOSTIC_INITIAL_WEIGHT;

    await this.createSnapshot({
      memberId, metricCode, score: initialScore,
      bundleId: null,  // 배치고사에서 생성된 행
      attemptCount: 1,
      avgAccuracy: accuracy,
    });
  }
  await this.updateRanks(memberId);
}

배치고사 결과는 정확도만 들어온다 (콘텐츠 가중치 개념이 없음). 그래서 기본 가중치 10을 곱해 초기 누계로 쓴다. 이 값은 묶음 완료 한 회의 평균치와 비슷한 크기가 되도록 설계했다 — 초기 점수가 너무 작거나 너무 크면 첫 묶음 완료 직후 순위가 흔들리기 때문.

bundleId: null로 두는 게 핵심이다. 나중에 “이 회원이 배치고사로 받은 초기 점수는 얼마였나”를 WHERE bundleId IS NULL로 한 번에 뽑을 수 있다.

4) 순위 재계산 — updateRanks

async updateRanks(memberId: string): Promise<void> {
  const latestSnapshots = await this.getLatestSnapshots(memberId);
  if (latestSnapshots.length === 0) return;

  // 점수 기준 내림차순 정렬 (높은 점수 = TOP1)
  const sorted = [...latestSnapshots].sort((a, b) => b.score - a.score);
  const rankOrder: MetricRank[] = [
    MetricRank.TOP1, MetricRank.TOP2, MetricRank.TOP3,
    MetricRank.TOP4, MetricRank.TOP5,
  ];

  for (let i = 0; i < sorted.length && i < 5; i++) {
    await this.prisma.memberMetricSnapshot.update({
      where: { id: sorted[i].id },
      data: { rank: rankOrder[i] },
    });
  }
}

순위는 최신 행에만 둔다. 과거 행의 rank는 그 시점의 순위를 그대로 보존하고, 새 순위는 새로 들어온 최신 행에 업데이트된다. 이력 조회 시 “이 회원의 TOP1이 한 달 사이에 어떻게 바뀌었나”를 그대로 따라갈 수 있다.


🪜 호출 진입점 — 두 곳, 그리고 try/catch

도메인 서비스가 잘 짜여도 호출이 안 되면 의미가 없다. 진입점은 정확히 두 곳에 둔다.

진입점 1: 배치고사 완료 — MemberDiagnosticApplicationService

// 배치고사 결과 저장 + 레벨 진행 갱신 직후
this.logger.log(
  `Diagnostic completed: avgAccuracy=${avgAccuracy.toFixed(1)}%, ...`,
);

// 5. 초기 지표 스냅샷 생성 (신규 도입)
try {
  await this.metricAggregationService.initializeFromDiagnostic(
    session.memberId,
    currentMetrics,
  );
} catch (error) {
  // 스냅샷 생성 실패해도 배치고사 완료는 유지
  this.logger.error(
    `Failed to initialize metric snapshots: ${error.message}`,
  );
}

진입점 2: 묶음 완료 — MemberAssignmentApplicationService.completeBundle

const bundleContentsForMetric = bundle.contents
  .filter((c) => c.contentId && c.content?.metricWeights)
  .map((c) => {
    const difficulty = c.currentAttempt?.difficulty ?? c.difficulty ?? 'EASY';
    const diffWeights = c.content?.metricWeights as unknown as DifficultyMetricWeights;
    const weights = getMetricWeightsForDifficulty(diffWeights, difficulty);
    return {
      contentId: c.contentId!.toString(),
      accuracyPct: c.currentAttempt?.accuracyPct ?? 0,
      metricWeights: weights as unknown as Record<string, number>,
    };
  });

if (bundleContentsForMetric.length > 0) {
  try {
    await this.metricAggregationService.updateCumulativeScores(
      memberId, bundleId, bundleContentsForMetric,
    );
  } catch (error) {
    // 지표 업데이트 실패해도 묶음 완료는 유지 (try-catch)
    this.logger.error(
      `Failed to update cumulative scores for bundle ${bundleId}: ${error.message}`,
    );
  }
}

두 진입점 모두 try/catch로 본 흐름과 분리한다. 지표 갱신이 터졌다고 배치고사·묶음의 완료 상태를 되돌리면 사용자 입장에서 데이터가 일관성을 잃는다. 지표는 부수 작업이고, 정합성보다 본 흐름의 회복성이 우선이라는 명시적 결정이다.

📌 핵심: 본 흐름(배치고사 완료 / 묶음 완료) 트랜잭션이 끝난 에 지표 갱신을 부른다. 같은 트랜잭션 안에 묶으면 지표 INSERT 실패가 본 흐름 롤백으로 이어진다. 그건 우리가 원하는 모양이 아니다.

직접 정리한 지표 누계 시스템 설계 결정 + 호출 흐름도
직접 정리한 지표 누계 시스템 설계 결정 + 호출 흐름도


📊 결과 — 어디까지 달라졌나

스키마 변경분 표로 정리한다.

필드변경
score의미 변경: 가중 점수(0~100) → 누계 점수(상한 없음)
percentile삭제 (View단 계산)
avgTimeMs삭제 (사용 안 함)
snapshotDate삭제 (createdAt으로 대체)
bundleId추가 (nullable, 원인 추적)
@@unique([memberId, metricCode, snapshotDate])삭제 (INSERT 전용)
@@index([memberId, createdAt(sort: Desc)])추가 (최신 조회용)

스키마는 컬럼 3개 줄고 1개 늘었고, 인덱스는 unique 1개 빼고 최신 조회용 1개 더했다. 마이그레이션은 한 번에 도는 ALTER 한 묶음으로 끝났다.

호출·로그 패턴

배치고사 한 번 + 묶음 한 번이 한 회원에 들어오면 다음과 같은 로그가 찍힌다 ([MetricCumulative] 태그로 grep 가능).

[MetricCumulative] InitFromDiagnostic - member=u-001
[MetricCumulative]   Input: {"METRIC_A":75,"METRIC_B":80,"METRIC_C":60,"METRIC_D":55,"METRIC_E":70}
[MetricCumulative]   Created snapshots: 5 metrics, initialScores={...}
[MetricCumulative] UpdateRanks - member=u-001
[MetricCumulative]   Ranks: TOP1=METRIC_B(800), TOP2=METRIC_A(750), TOP3=METRIC_E(700), TOP4=METRIC_C(600), TOP5=METRIC_D(550)

[MetricCumulative] UpdateScores - member=u-001, bundle=b-042
[MetricCumulative]   Previous: {"METRIC_A":750,"METRIC_B":800,"METRIC_C":600,"METRIC_D":550,"METRIC_E":700}
[MetricCumulative]   Added: {"METRIC_A":3.5,"METRIC_B":4.2,"METRIC_C":2.1,"METRIC_D":1.8,"METRIC_E":3.9}
[MetricCumulative]   New: {"METRIC_A":753.5,"METRIC_B":804.2,"METRIC_C":602.1,"METRIC_D":551.8,"METRIC_E":703.9}
[MetricCumulative] UpdateRanks - member=u-001
[MetricCumulative]   Ranks: TOP1=METRIC_B(804), TOP2=METRIC_A(754), TOP3=METRIC_E(704), TOP4=METRIC_C(602), TOP5=METRIC_D(552)

로그 한 묶음을 grep 하나로 줄세울 수 있다는 게 운영 단계에서 결정적이었다. 누계가 0.1점씩만 움직이는 정상 흐름과, 갑자기 100점 점프하는 이상 흐름의 구분이 눈에 바로 들어온다.

숫자로 보는 도입 결과

  • 삭제 컬럼: 3개 (percentile, avgTimeMs, snapshotDate)
  • 추가 컬럼: 1개 (bundleId, nullable)
  • 삭제 메서드: 4개 (getMetricMultiplier, calculateWeightedScore, calculateTimeWeightedScore, calculatePercentile)
  • 신규/수정 메서드: 5개 (calculateMetricScore, getLatestScores, updateCumulativeScores, initializeFromDiagnostic, updateRanks + getMemberMetricRanks 실제 구현)
  • 호출 진입점: 2곳 (MemberDiagnosticApplicationService, MemberAssignmentApplicationService.completeBundle) — 둘 다 try/catch 격리
  • 로그 태그: [MetricCumulative] 하나로 통일, 5단계(Init/UpdateScores/Previous/Added/New/Ranks) 일관

🔄 회고 — 다음에 같은 결정을 한다면

이번 작업이 끝난 직후 명확해진 한계 두 가지가 있다.

1) 화면 표시 점수와 RAW 누계는 다른 테이블에 넣었어야 했다

누계 점수는 상한이 없다. 좋은 점이지만, 학부모·외부 뷰어에게 “당신 자녀의 METRIC_A 점수는 837.2”를 보여줄 수는 없다. 결국 View단에서 score / sum × 100으로 백분위를 계산하는 패턴을 강요하게 됐고, 화면마다 같은 변환 코드가 반복됐다.

다음 호흡에서 **운영 테이블(member_metric_display)**을 따로 만들었다. RAW는 그대로 두고, 표시용 0~9500 스케일에 로그스케일을 곱해 굳히는 분리 설계로 갔다. 이번 글의 1차 버전은 운영 테이블 분리가 빠져 있다 — 이건 다음 편의 주제다.

2) 동시 묶음 완료에서 순위 재계산이 race 가능

updateCumulativeScores 끝에서 updateRanks를 부른다. 한 회원이 짧은 간격으로 두 묶음을 연달아 완료하면, 두 호출이 같은 회원의 rank 컬럼을 동시 업데이트할 수 있다. 현재는 묶음 완료 자체가 한 회원에 거의 동시에 일어날 일이 적어 우선순위에서 뺐다.

실서비스에서 race가 보이면 — 회원 단위 advisory lock(pg_advisory_xact_lock(hashtext(memberId)))을 묶음 완료 트랜잭션 안에 두는 게 다음 카드다. Prisma는 raw SQL로 advisory lock을 거는 패턴이 있고, 이미 다른 서비스에서 같은 패턴을 한 번 깐 적이 있어 비용이 낮다.

💡 인사이트: 1인 개발 환경에서 결정은 항상 “지금 막을 필요가 있는가”와 “막는 비용은 얼만가”의 곱이다. race는 막을 비용은 낮지만, 지금 막을 필요는 낮다 — 그래서 코멘트로 남기고 다음으로 넘긴다.


🛡️ 예방 — 같은 패턴을 다른 도메인에 옮길 때 체크리스트

이 패턴(누계 + INSERT 전용 + 최신 distinct + 순위 박제)을 다른 도메인에 적용할 때 빠지기 쉬운 함정을 모았다.

  • 계산식 정규화 단위 확정weight × accuracy인지 weight × (accuracy/100)인지 첫 호출 직전에 고정. 도중에 바꾸면 누계 그래프 단위가 깨진다.
  • 호출 진입점 try/catch 분리 — 부수 갱신이 본 흐름 트랜잭션 안에 들어가 있지 않은지 한 번 더 확인.
  • @@unique 제거 + @@index([entityId, createdAt(sort: Desc)]) 추가 — 두 변경은 한 마이그레이션에 묶는다.
  • distinct + orderBy 조합distinct만 두면 어느 행이 뽑힐지 보장이 없다. orderBy createdAt desc 필수.
  • 원인 추적용 nullable FK 추가 — 초기화 행(null)과 증분 행(값 있음)이 한 테이블에 섞이는 설계라면 컬럼 1개로 구분 가능.
  • 로그 태그 통일[XxxCumulative] 같은 prefix 한 개로 묶음 → 운영 grep 한 줄로 모든 단계 추적 가능.
  • 순위 박제 정책 — “과거 행의 rank는 그 시점 그대로”를 명문화. 잘못 바꾸면 이력 분석이 무너진다.
  • View단 계산 폴백sum === 0일 때 0 반환. 신규 회원의 첫 화면이 NaN으로 깨지는 사례를 자주 본다.

📋 정리 — 핵심 요약

항목AS-ISTO-BE
계산식weight × levelMultiplier × accuracy 가중평균 → 0~100 수렴weight × (accuracy/100) 단순 누계 → 상한 없음
저장 방식같은 날 1행 UPDATE (@@unique)매번 INSERT 전용 (@@unique 제거)
조회 패턴where snapshotDate = ... 단일 행 lookupdistinct ['metricCode'] + orderBy createdAt desc
백분위 컬럼DB에 미리 계산해 저장삭제 — View단에서 score / sum × 100
원인 추적없음bundleId nullable 컬럼 추가 (초기화 행은 null)
호출 진입점없음 (TODO)2곳 — 배치고사 완료, 묶음 완료. 둘 다 try/catch 분리
순위 계산TODO — 랜덤 반환최신 행에 rank 박제, 과거 행은 그 시점 순위 보존
로그 태그없음[MetricCumulative] 통일 — Init/UpdateScores/Previous/Added/New/Ranks 5단계
마이그레이션unique 삭제 + 컬럼 3 삭제 + bundleId 추가 + 인덱스 1 추가 (ALTER 한 묶음)
회복성지표 갱신 실패해도 본 흐름은 commit, 에러 로깅만

이전 편이 매 시도마다 16개 통계 필드를 BE로 들여보내는 길을 열었다면, 이번 편은 그 데이터를 회원 단위로 누계해 TOP1~5 순위로 굳히는 운영 시스템을 짠 작업이다. 통합 → 누계 → 표시의 세 호흡 중 두 번째다.

다음 편은 이번 글의 회고 1번에서 미뤘던 운영 테이블 분리 — 0~9500 스케일 + 로그스케일 + 95% 하드캡으로 표시 점수를 굳히는 member_metric_display 도입 — 이 아니라, 그보다 먼저 잡혀 있던 배치 작업 첫 구현 — 킥오프 배치와 Winston 로깅이다. 매일 자정 도는 첫 크론 작업에서 만난 멱등성 함정과 로깅 라이브러리 전환 결정을 트러블슈팅 톤으로 정리한다.

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

  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 권한 가드 — 목록은 막고 상세는 뚫린 날