콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기

Unity WebView 안에서 동작하는 웹 콘텐츠 10종을 같은 PostMessage 브릿지 규격으로 묶는 마일스톤 작업을 정리한다. bridge/ 폴더 하드카피와 useProblemResults 훅으로 콘텐츠별 차이를 흡수하고, 통계 필드 8개·2개·contentType·problems 배열을 네 차례에 걸쳐 점진적으로 확장하면서 마주친 400 Bad Request·accuracyPct 재정의·시간 정확도 변환 로직 회수의 결정 사유와 트레이드오프를 기록한다.


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

  • 무엇: Unity WebView 안의 웹 콘텐츠 10종을 같은 PostMessage 브릿지 규격으로 묶은 마일스톤
  • 적용 패턴: src/bridge/ 폴더 하드카피 + contentName 한 줄 수정 + useProblemResults 훅 4개 호출
  • 확장 절차: 통합 후 통계 필드를 4차에 걸쳐 점진적 확장 — 8개 → 2개 → contentTypeproblems 배열
  • 사고 2건: BE DTO 미정의로 problems 400 오류, accuracyPct 분모를 totalProblemstotalAttempts로 재정의
  • 회수: Unity의 시간→정확도 변환 로직 103줄 삭제, totalTime 단위 초→ms 통일
  • 결과: 호스트(Unity)는 콘텐츠가 10종이든 11종이든 같은 코드로 받고, 분석 지표 16종이 매 시도마다 자동 누적

🎯 배경 — 규격은 완성됐고, 이제 묶을 차례였다

이전 편에서 호스트의 첫 메시지가 증발하던 Vuplex 초기화 타이밍 문제를 잡았다. 그 직전 두 편(“브릿지 설계기”, 이번 시리즈의 38편)에서는 PostMessage 봉투와 메시지 4종 규격을 한곳에 고정했다. 도구가 갖춰진 시점이다.

이번 글은 그 도구를 들고 10개의 웹 콘텐츠 저장소를 한 줄씩 묶어 넣는 작업의 결산이다. 설계가 끝났다고 통합이 자동으로 풀리지는 않는다. 콘텐츠마다 게임 로직이 다르고, 답의 형식이 다르고, 화면 전환 시점이 다르다. 같은 규격으로 묶으려면 차이가 어디서 흡수되어야 하는지부터 정해야 한다.

브릿지 도입 전 상태를 다시 확인해 두자.

[독립 실행을 전제로 만든 10개 웹 콘텐츠]
  ├─ 자체 로그인 화면
  ├─ 자체 API 호출 (문제 데이터 fetch)
  └─ 자체 API 호출 (결과 저장)

→ Unity WebView 안에 그대로 임베드하면:
  ├─ 인증 토큰 없음 (네이티브 앱이 세션 보유)
  ├─ CORS 차단 (WebView origin ≠ API 서버)
  └─ 결과 저장 시 404 (식별자가 네이티브에만 존재)

설계 단계에서 정한 절대 제약은 두 개였다.

  1. 콘텐츠는 API를 직접 호출하지 않는다 — 호스트가 데이터를 주입하고, 결과를 가져간다.
  2. Standalone 모드(브라우저 단독 실행)는 기존 코드 한 줄도 바꾸지 않는다 — 분기 추가지 치환이 아니다.

10개 콘텐츠는 각각 별도 저장소다. 모노레포가 아니라 git remote가 따로 있는 독립 프로젝트들이다. 즉 통합 작업은 저장소 10번 clone → 10번 patch → 10번 push 형태가 된다. 이 절차를 어떻게 일관되게 굴리느냐가 이번 마일스톤의 본질이었다.

📌 핵심: 통합 마일스톤의 본질은 코드를 새로 짜는 게 아니라, 같은 패턴이 10번 반복될 때 매번 같은 결과가 나오게 만드는 일이다. 그러려면 (1) 규격이 한곳에 있어야 하고, (2) 적용 절차가 표준화되어 있어야 하고, (3) 콘텐츠별 차이를 흡수할 추상화가 있어야 한다. 이 글에서 풀 결정들은 모두 이 세 줄에 수렴한다.


⚖️ 설계 결정 5건 — 통합 단계에서 정한 것

설계 단계의 결정 5건(38편 참조)은 그대로 가져가되, 통합을 굴리며 추가로 내린 결정 5건을 먼저 표로 정리한다.

#결정채택거절트레이드오프
1브릿지 배포 방식src/bridge/ 폴더 하드카피npm 워크스페이스 공유 패키지버전 동기화 불필요 vs 10개 저장소 동시 패치
2통계 수집 추상화useProblemResults 단일 훅콘텐츠별 직접 계산16개 필드 자동 누적 vs 훅 사용 강제
3accuracyPct 분모totalAttempts (재시도 포함)totalProblems (문제 수)시도 기반 정확한 정확도 vs 기존 분모 변경
4결과 타입 분류contentType: 'accuracy' | 'time'Unity에서 시간→정확도 변환콘텐츠가 자기 타입 선언 vs 호스트 변환 로직 폐기
5BE 필드 확장점진적 추가 (4차에 걸쳐)한 번에 모든 필드 확정실제 필요 시점에 추가 vs 마이그레이션 N회

결정 1: 하드카피로 돌아왔다

처음에는 packages/content-bridge/라는 npm 워크스페이스 패키지로 시작했다. 깔끔해 보였다. 그런데 콘텐츠 저장소들이 각자 독립적인 git 원격을 갖고 있다 보니 패키지 버전 변경 한 번에 10개 저장소의 pnpm install을 다시 돌려야 했다. 작은 변경 한 줄의 비용이 저장소 10개의 의존성 갱신으로 청구된다.

docs/content-bridge-standard.md 첫 줄에 결국 이 한 줄이 박혔다.

⚠️ packages/content-bridge/는 레거시로 삭제됨. 사용하지 말 것.

대신 src/bridge/ 폴더를 각 콘텐츠 저장소에 통째로 복사한다. 폴더 구조는 다음 형태로 고정했다.

src/bridge/
├── index.ts              # 인스턴스 + export (contentName만 콘텐츠별로 수정)
├── ContentBridge.ts      # 브릿지 클래스 (변경 X)
├── types.ts              # 메시지·페이로드 타입 정의 (Single Source of Truth)
├── README.md             # 사용법
└── hooks/
    ├── useProblemResults.ts   # ⭐ 통계 수집 핵심 훅
    ├── useBridgeInit.ts       # 초기화
    ├── usePauseResume.ts      # 일시정지/재개
    └── useContentTimer.ts     # 전체 타이머

브릿지 코드의 진본은 한 콘텐츠(나중에 cross-number가 기준이 됐다)에 두고, 그 폴더를 나머지 9개에 복사한다. 콘텐츠별로 수정해야 하는 건 index.ts 한 줄뿐이다.

// src/bridge/index.ts (콘텐츠별 유일한 차이)
export const bridge = new ContentBridge({
  contentName: 'match-equation',  // ← 이 한 줄만 콘텐츠 이름으로
  version: '1.0.0',
  debug: import.meta.env.DEV,
});

추상화의 비용과 단순함의 비용을 둘 다 치러봤다. 콘텐츠가 10개 수준에서는 복사본 10개의 단순함이 더 쌌다. 100개가 되면 다시 계산할 일이다.

결정 2: 통계는 훅 하나로 자동 누적

콘텐츠마다 답을 다루는 방식은 전부 다르지만, 결과를 누적하는 방식은 같아야 했다. 그래야 호스트가 받는 데이터가 같은 모양이 된다. 그래서 만든 게 useProblemResults 훅이다.

콘텐츠가 호출하는 메서드는 네 개뿐이다.

// 1. 콘텐츠 시작 시
useProblemResults.getState().init();

// 2. 각 문제 표시 시 (0-based index)
useProblemResults.getState().startProblem(problemIndex);

// 3. 오답 발생 시 (재시도 추적)
useProblemResults.getState().incrementAttempts(problemIndex);

// 4. 답 제출 시 (problemId 필수 — BE ProblemAttempt FK용)
useProblemResults.getState().addResult({
  problemIndex,
  problemId: initData.problems[problemIndex]?.id,
  isCorrect,
  userAnswer: 사용자선택값,
  correctAnswer: 정답값,
});

훅 내부에서 responseTime(문제 시작 시점부터 답 제출까지의 ms 차)과 attempts(누적 시도 횟수)가 자동으로 채워진다. 결과 전송 시점에는 getSummary(contentType) 한 번으로 16개 필드가 다 나온다.

// 결과 전송 시
const summary = useProblemResults.getState().getSummary('accuracy');  // 또는 'time'
const problems = useProblemResults.getState().getProblems();

bridge.sendResult({
  attemptId: bridge.getInitData()?.attemptId,
  contentSeq: bridge.getInitData()?.contentSeq || 1,
  level: currentLevel,
  summary,
  problems,
});

표준 명세서에는 이 패턴을 깨지 말라는 경고를 큰 글씨로 명시했다.

절대 수동으로 payload 계산하지 말 것 — 15개 필드 직접 계산하면 콘텐츠마다 미세하게 달라진다. ✅ getSummary() 한 번으로 끝.

이 규약을 깨고 직접 계산하는 콘텐츠가 한 곳이라도 생기면, 그 콘텐츠 결과만 분석 대시보드에서 통계가 어긋난다. 규격을 통일하는 가장 확실한 방법은 통일된 데이터를 만드는 함수를 한곳에만 두는 것이다.

결정 3: accuracyPct의 분모를 바꿨다

가장 논쟁적이었던 결정이다. 처음에는 accuracyPct = correctCount / totalProblems × 100이었다. 직관적이다. 5문제 중 4개 맞추면 80%다.

그런데 시간 기반 콘텐츠(make-ten, cross-number, match-equation)는 한 문제를 맞출 때까지 푼다. 5문제짜리 콘텐츠에서 매 문제마다 평균 3번씩 시도했다면 총 시도는 15회다. 이때 “5번 다 맞췄으니 100%“라고 보고하는 건 학습 분석 측면에서 무용하다. 첫 시도에 맞춘 문제와 5번 만에 맞춘 문제를 구분할 수 없다.

표준 명세서 7.2 절에 이렇게 명시했다.

accuracyPct = (correctCount / totalAttempts) × 100

분모가 totalAttempts로 바뀌면서 의미도 바뀐다. “푼 문제 비율”이 아니라 “시도 대비 정답 비율”이다. 5문제를 평균 3시도(총 15)에 모두 맞췄다면 5/15 ≈ 33%다. 첫 시도에 다 맞춘 회원(5/5 = 100%)과 명확히 갈린다.

대신 기존에 correctCount / totalProblems를 기대하던 match-equation 같은 콘텐츠의 결과 화면이 한 번에 깨졌다. FE가 같이 따라 움직여야 했고, 변경 직후 한 콘텐츠(match-equation)는 결과 화면에 진입할 때 useProblemResults 훅을 적용해 시도를 정확히 추적하도록 다시 손봤다.

⚠️ 주의: 의미가 바뀌는 필드 이름을 그대로 두는 건 위험하다. 다음 같은 결정에서는 firstAttemptAccuracyPct처럼 분모를 이름에 명시하는 패턴을 더 적극적으로 쓸 생각이다. accuracyPct 한 단어가 두 가지 정의를 가질 수 있다는 사실을 매번 명세서로 보강하는 건 비싸다.

결정 4: 시간→정확도 변환을 호스트에서 뺐다

시간 기반 콘텐츠는 결과 화면에 “12초 걸렸어”라고 표시한다. 그런데 BE 쪽 분석 지표는 정확도 기반이라, 시간을 어떻게든 정확도 점수로 환산해야 했다. 초기 구현은 Unity 쪽에 환산 테이블을 박아둔 형태였다.

// ❌ Unity ContentBridgeReceiver.cs 초기 구현
if (IsTimeBasedContent(contentName)) {
  // 10초 이하: 100%, 11-15초: 90%, 16-25초: 80%, 26-40초: 70%, >40초: 60%
  resultData.summary.accuracyPct = ConvertTimeToAccuracy(totalTime);
}

문제는 변환 테이블이 한 군데(Unity) 더 있게 됐다는 점이다. 콘텐츠가 자기 타입(시간 기반인지)을 암묵적으로 알리고, 호스트가 추정해서 환산한다. 콘텐츠가 11번째로 늘어났을 때 Unity의 timeBasedContents 배열에 이름을 추가하는 걸 잊으면, 그 콘텐츠는 정확도 0으로 누적된다.

해결은 콘텐츠가 자기 타입을 선언하게 만든 것이다. 메시지 페이로드에 contentType 필드 한 개를 추가했다.

// 콘텐츠가 결과 전송 시 자기 타입 선언
summary: {
  contentType: 'time',  // 또는 'accuracy'
  totalProblems: 5,
  correctCount: 5,
  // ...
}

호스트는 더 이상 환산하지 않는다. 받은 값을 그대로 BE로 넘기고, BE가 contentType에 맞춰 누적한다. Unity 쪽 변환 코드는 그대로 103줄이 삭제됐다.

73c0f94 refactor(unity): 시간→정확도 변환 로직 제거
1 file changed, 103 deletions(-)

콘텐츠 한 곳에 책임을 모으면, 그 책임을 떠받치고 있던 다른 위치의 코드가 함께 사라진다. 분산되어 있던 로직이 한곳으로 모이는 게 통합의 또 다른 얼굴이다.

결정 5: BE 필드는 4차에 걸쳐 점진적으로 추가했다

가장 검증하기 어려웠던 결정이다. 통계 필드를 처음부터 다 확정하고 마이그레이션을 한 번에 칠 수도 있었지만, 실제로 어떤 지표가 필요한지는 콘텐츠를 통합해보기 전에는 모른다. 그래서 필드를 4차에 걸쳐 점진적으로 추가했다.

차수추가 필드트리거BE 커밋
1차8개 — avgResponseTime·minResponseTime·maxResponseTime·medianResponseTime·totalAttempts·avgCorrectResponseTime·avgWrongResponseTime·firstAttemptAccuracyPct응답 시간 분포 분석 필요2d895bc
2차2개 — streakMax(최대 연속 정답)·improvementTrend(전반부 vs 후반부)학습 흐름 분석 필요dee9cf9
3차1개 — contentType: 'accuracy' | 'time'Unity 변환 로직 회수8dc208b
4차problems 배열 (문제별 상세 7필드)오답 패턴·문항 난이도 분석 필요[긴급] 400 오류 대응

5번 결정의 비용은 마이그레이션 4번이다. 한 번에 했으면 1번이다. 그러나 한 번에 했다면 8필드 중 실제로 쓰이지 않는 필드를 거의 확실히 포함했을 것이다. 지금 16필드는 전부 대시보드 어딘가에서 쓰이고 있고, 한 필드도 버린 게 없다. 점진적 확장의 가치는 불필요한 필드를 만들지 않은 것에 있다.

대가는 다음 절(구현·결과)에서 한 사고로 청구된다.


🛠️ 구현 — 한 콘텐츠 적용 절차

10개 콘텐츠는 모두 같은 5단계 패턴으로 통합됐다. 첫 콘텐츠(motion-tab-2d)에서 패턴이 굳어지자 나머지는 한 콘텐츠당 1~2시간이면 통합이 끝났다.

10개 콘텐츠 통합 5단계 패턴과 통계 필드 4차 점진적 확장 타임라인

1단계 — src/bridge/ 폴더 복사

기준 콘텐츠(cross-number)의 src/bridge/ 폴더를 그대로 복사한다. git diff로 확인할 변경은 폴더가 통째로 추가된 것 한 덩어리뿐이다.

cp -r ../cross-number/src/bridge ./src/bridge

2단계 — index.tscontentName 한 줄 수정

// src/bridge/index.ts
export const bridge = new ContentBridge({
  contentName: 'number-tap',  // ← 콘텐츠 이름 (URL 슬러그와 일치)
  version: '1.0.0',
  debug: import.meta.env.DEV,
});

3단계 — 초기화 + 결과 전송 분기 추가

App.tsx에 브릿지 초기화 한 줄, 결과 전송 함수에 분기 한 덩어리.

// App.tsx — 진입점에서 1회 초기화
useEffect(() => {
  useBridgeInit.getState().initialize();
}, []);

// scoreStore.ts — 결과 저장 분기
submitResults: async () => {
  const summary = useProblemResults.getState().getSummary('accuracy');
  const problems = useProblemResults.getState().getProblems();

  if (bridge.isStandalone()) {
    // Standalone: 기존 API 호출 — 한 줄도 안 바꿈
    await fetch('/api/results', {
      method: 'POST',
      body: JSON.stringify({ summary, problems }),
    });
  } else {
    // Vuplex: 호스트로 결과 전송 + 종료 요청
    bridge.sendResult({
      attemptId: bridge.getInitData()?.attemptId,
      contentSeq: bridge.getInitData()?.contentSeq || 1,
      level: currentLevel,
      summary,
      problems,
    });
    bridge.sendExit({ reason: 'completed', hasResult: true });
  }
}

if (bridge.isStandalone()) 위쪽은 절대 건드리지 않는다. 기존 코드는 그대로 남고, 호스트 경로만 else에 추가된다. 콘텐츠 개발자는 여전히 npm run dev로 브라우저 단독 디버깅이 가능하다.

4단계 — 데이터 주입 분기 추가

문제 데이터를 fetch하던 곳에도 같은 패턴이 들어간다.

// questionStore.ts — 문제 로드 분기
fetchProblems: async () => {
  if (!bridge.isStandalone()) {
    return; // Vuplex: 호스트가 onInit으로 데이터를 넣어줌 — 자기가 fetch 하지 않음
  }
  // ↓ Standalone: 기존 API 호출 (변경 없음)
  const res = await fetch('/api/problems');
  set({ problems: await res.json() });
}

// userStore.ts — useBridgeInit 핸들러로 호스트 데이터 수신
useEffect(() => {
  bridge.onInit((data) => {
    setUser(data.member);
    setQuestions(data.problems);
    setSetting(data.setting);
  });
}, []);

5단계 — 게임 로직에 훅 호출 4곳 삽입

콘텐츠별 게임 로직 어느 지점에 4개 훅 호출이 들어가는지가 콘텐츠마다 가장 다르다. 표준 명세서에 8.1 절을 따로 둘 정도로 문제 순서 매핑 함정이 컸다.

// 예: cross-number는 floor 3→2→1 순서로 진행하지만, 문제는 1→2→3
const floorToProblemIndex = (floor: number) => 3 - floor;
// floor 3 = problemIndex 0 (problemSeq 1)
// floor 2 = problemIndex 1 (problemSeq 2)
// floor 1 = problemIndex 2 (problemSeq 3)

내부 게임 순서를 그대로 problemIndex로 넘기면 통계가 어긋난다. 8개 콘텐츠 모두 이 매핑을 한 번씩 정리해야 했다.

타임오버도 함정이었다. 답을 못 낸 채 시간이 다 가면 responseTime을 0으로 넣고 싶지만, 그러면 평균 응답 시간이 왜곡된다.

// ❌ 잘못됨 — 평균이 0으로 끌려 내려감
responseTimeOverride: 0,

// ✅ 올바름 — 제한 시간을 그대로 응답 시간으로
const timeLimitMs = initData?.setting?.timeLimit * 1000;
responseTimeOverride: timeLimitMs ?? avgCompletedTime,

8.1·8.2 같은 함정 항목은 모두 표준 명세서에 박혔다. 같은 실수를 11번째 콘텐츠에서 또 하지 않으려면, 발견 즉시 문서에 적어야 한다.


📊 결과 — 숫자로 보는 통합

이틀에 걸쳐 10개 콘텐츠가 같은 브릿지로 통합됐다.

#콘텐츠contentType검증
1motion-tab-2daccuracy✅ 1차 — 첫 적용·규격 검증
2puzzle-bobbleaccuracy✅ 1차 — Standalone/Vuplex 양쪽
3math (giftQuest)accuracy✅ 2차
4make-tentime✅ 2차
5number-tapaccuracy✅ 2차
6number-makingaccuracy✅ 2차
7match-equationtime✅ 2차 (919e17b — 419문제 인식)
8cross-numbertime✅ 2차 (기준 콘텐츠)
9math-mazeaccuracy✅ 2차 (419문제 인식, totalTime 29초)
10number-combiningaccuracy✅ 2차 (정답 5/5, 100% / 233초)

match-equationmath-maze는 한 콘텐츠에 419개의 문제를 한 번에 주입받았다. contentInit 한 메시지에 그게 다 담겨 콘텐츠가 전부 정상 인식했다. PostMessage는 구조화 복제(structured clone) 알고리즘으로 객체를 직렬화하므로, 배열이 크다고 별도 처리가 필요하지 않았다.

호스트가 받는 결과 페이로드

콘텐츠가 다 다르지만 호스트가 받는 형태는 똑같다. match-equation의 한 시도 결과는 이렇다.

{
  "type": "contentResult",
  "payload": {
    "attemptId": 209,
    "contentSeq": 3,
    "level": 11,
    "summary": {
      "contentType": "time",
      "totalProblems": 419,
      "correctCount": 4,
      "wrongCount": 0,
      "accuracyPct": 1,
      "totalTime": 29000,
      "avgResponseTime": 7250,
      "minResponseTime": 1200,
      "maxResponseTime": 12000,
      "medianResponseTime": 6500,
      "totalAttempts": 8,
      "avgCorrectResponseTime": 5500,
      "avgWrongResponseTime": 0,
      "firstAttemptAccuracyPct": 75,
      "streakMax": 3,
      "improvementTrend": 25
    },
    "problems": [ /* 419건 중 푼 만큼 상세 */ ]
  }
}

summary 안의 16개 필드는 콘텐츠가 직접 채우지 않는다. useProblemResults.getSummary('time') 한 번 호출로 계산이 끝난다. 10개 콘텐츠가 모두 같은 함수를 호출하므로, 분석 대시보드에서 콘텐츠를 가로질러 같은 지표를 같은 정의로 비교할 수 있다.

📌 핵심: 10개 콘텐츠는 각각 게임 방식·답 형식·화면 구성이 전부 다르다. 호스트 입장에서는 전부 똑같이 보인다contentInit을 보내고 contentResult를 받는다. 콘텐츠별 차이는 payload 안쪽으로 숨겨졌고, 그 차이를 흡수하는 책임은 useProblemResults 훅 하나가 진다. 11번째 콘텐츠가 들어와도 호스트 코드는 한 줄도 바뀌지 않는다.

통합 중 발생한 사고 2건

순탄하게 끝나지는 않았다. 통합 단계 막판에 두 가지 사고가 났다.

사고 1 — problems 배열 400 Bad Request. Unity가 problems 배열까지 포함해 PUT /api/v1/student/content-attempts/:id/result를 호출했는데 BE가 한 번에 거절했다.

{
  "error": {
    "code": "BAD_REQUEST",
    "message": "problems.0.property problemSeq should not exist, problems.0.property isCorrect should not exist, problems.0.property userAnswer should not exist, ..."
  },
  "path": "/api/v1/student/content-attempts/374/result"
}

원인은 단순했다. BE의 SubmitContentResultDtoproblems 필드가 정의되어 있지 않아 class-validatorforbidNonWhitelisted 옵션이 알 수 없는 필드를 일괄 거부한 것이다. 표준 명세에는 4차 확장으로 추가하기로 정해뒀지만, BE 작업이 다음 머지에 들어가 있었다.

수정은 두 단계로 나뉘었다. 먼저 ProblemResultDto를 만들었다.

// student-content.dto.ts
export class ProblemResultDto {
  @IsOptional() @IsInt()    problemSeq?: number;
  @IsOptional() @IsBoolean() isCorrect?: boolean;
  @IsOptional() @IsString()  userAnswer?: string;
  @IsOptional() @IsString()  correctAnswer?: string;
  @IsOptional() @IsInt()     responseTime?: number;  // ms
  @IsOptional() @IsInt()     attempts?: number;
  @IsOptional() @IsInt()     timestamp?: number;

  // 하위 호환 — 기존 BE 필드도 유지
  @IsOptional() @IsInt()     problemId?: number;
  @IsOptional() @IsInt()     problemOrder?: number;
  @IsOptional() @IsBoolean() correct?: boolean;
  @IsOptional() @IsInt()     responseTimeMs?: number;
}

그다음 SubmitContentResultDtoproblems 배열을 옵션으로 끼웠다.

@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProblemResultDto)
problems?: ProblemResultDto[];

서비스 로직은 두 형식(Unity 표준 / 기존 BE)을 모두 받도록 했다.

const isCorrect    = problem.isCorrect    ?? problem.correct        ?? false;
const responseTime = problem.responseTime ?? problem.responseTimeMs ?? 0;

이 사고가 38편 회고의 4번 항목에서 예고한 DTO 사전 합의 부재의 실제 청구서다. 점진적 확장(결정 5)을 택한 이상, 확장 시점에 Unity와 BE의 DTO 합의를 동기화하는 절차가 필요했다. 다음 확장에서는 BE PR과 Unity PR을 같은 머지 단위로 묶기로 했다.

🔍 단서: class-validatorwhitelist: true + forbidNonWhitelisted: true 조합은 유연한 점진적 확장과 정반대 방향이다. 보안상 켜는 게 맞지만, 점진적 확장을 굴리는 단계에서는 옵션 한쪽이 합의되지 않은 필드를 즉시 거부한다는 사실을 인지하고 PR 흐름을 짜야 한다.

사고 2 — totalTime 단위 불일치. BE가 totalTime을 초 단위로 받아 내부에서 *1000 변환하던 코드가 남아 있었다. 콘텐츠 브릿지 표준이 모든 시간 필드는 ms로 통일되면서 이 변환은 더 이상 옳지 않다. Unity도 /1000 변환을 빼고 그대로 ms를 보내고 있었기 때문에, 결과적으로 2번 변환된 값이 저장되고 있었다.

수정은 한 줄이었다.

// ❌ Before — student-content.application.service.ts
const totalTimeMs = dto.totalTime * 1000;

// ✅ After — 변환 제거
const totalTimeMs = dto.totalTime;
675d2e0 fix(be): totalTime 단위 ms로 통일
2 files changed, 8 insertions(+), 8 deletions(-)

단위 통일 결정은 명세서에 명시했지만, 이미 다르게 동작하던 코드는 자동으로 따라오지 않는다. 표준이 바뀌면 표준을 어기던 코드를 찾아 같이 바꿔야 한다. 이번 작업의 마지막 한 줄이었다.


🔄 회고 — 다시 통합한다면

1. 표준 명세서가 가장 큰 무기였다

docs/content-bridge-standard.md 하나가 통합 과정의 모든 결정·함정·실수를 흡수했다. 10개 콘텐츠를 작업하면서 “이건 어떻게 처리하지?”가 나올 때마다 명세서를 열어 답을 찾았고, 답이 없으면 답을 정해서 명세서에 추가했다.

8.1(문제 순서 매핑), 8.2(타임오버 responseTime), 8.4(훅 사용 필수) 같은 절은 모두 통합 도중 발견한 실수를 그대로 명시한 것이다. 그 결과 명세서는 점점 통합 절차의 체크리스트가 됐다. 새 콘텐츠를 통합할 때 이 명세서를 처음부터 끝까지 한 번 읽으면 95%의 함정을 피할 수 있다.

📌 핵심: 통합 마일스톤에서 가장 비싼 자산은 코드가 아니라 명세서다. 코드는 다시 짤 수 있지만, 왜 그렇게 정했는지는 그때 쓰지 않으면 다시 복원할 수 없다.

2. 점진적 확장은 옳았지만, PR 동기화는 부족했다

결정 5(필드 점진적 확장)는 결과적으로 옳았다. 16개 필드 중 한 필드도 버려지지 않았다. 다만 확장 시점에 Unity·BE·FE 세 저장소의 PR을 같은 머지 단위로 묶는 절차가 없었다. 그래서 problems 배열 400 사고가 났다.

다음 같은 패턴에서는 확장 1차당 세 저장소의 PR 3개를 같이 만들고 같은 시점에 머지하는 흐름을 강제할 생각이다. 점진적 확장은 자유도가 높은 만큼 동기화 비용을 따로 설계해야 한다.

3. useProblemResults 훅으로 책임을 모은 게 결정적이었다

호스트가 받는 데이터가 같은 모양이 되려면 계산 함수가 한곳에 있어야 한다는 게 통합 후반에 가장 강하게 와닿은 교훈이다. 콘텐츠마다 직접 correctCount = results.filter(r => r.isCorrect).length를 적었다면, 10개 콘텐츠가 미세하게 다른 정의를 갖게 됐을 것이다. 훅 하나에 16필드 계산을 모은 덕에 정의가 한 곳에서 갱신되면 10개 콘텐츠에 자동으로 반영된다(폴더 복사 시).

다만 이 패턴은 콘텐츠 개발자가 훅을 무조건 쓴다는 약속이 깨지면 그 순간 무너진다. 그래서 명세서 8.4에 “절대 수동으로 payload 계산하지 말 것”을 큰 글씨로 명시했고, 코드 리뷰에서 가장 먼저 확인하는 항목이 됐다.

4. 시간→정확도 변환을 호스트에서 뺀 게 가장 큰 정리였다

103줄 삭제는 단순한 코드 줄 수가 아니다. 콘텐츠 타입 정보가 어디에 있어야 하는가에 대한 답이 바뀐 결과다. 호스트가 “이 콘텐츠는 시간 기반”이라고 알아야 하는 구조는, 11번째 콘텐츠가 추가될 때마다 호스트 코드를 수정해야 한다는 뜻이다. 콘텐츠 자신이 자기 타입을 메시지에 적어 보내면, 호스트는 더 이상 콘텐츠 카탈로그를 들고 있을 필요가 없다.

콘텐츠 타입은 콘텐츠가 가장 잘 안다. 그 정보를 그대로 메시지에 적게 만든다.

다음 같은 설계에서는 호스트가 콘텐츠를 추정하는 코드를 찾아 먼저 지운다는 원칙을 우선순위 높게 둘 생각이다.

5. 하드카피의 비용은 콘텐츠 100개부터 다시 계산

packages/content-bridge/를 폐기하고 폴더 하드카피로 돌아간 결정(결정 1)은 콘텐츠가 10개 수준에서는 명백히 옳다. 한 번 복사한 후 거의 변하지 않는 코드라면, 추상화 비용을 치를 이유가 없다.

다만 한 가지 약점은 브릿지 자체 버그를 잡을 때다. 10개 저장소에 같은 패치를 10번 적용해야 한다. 버그 수정 빈도가 높아지면 다시 패키지화를 검토할 시점이 온다. 콘텐츠가 100개를 넘기 전에는 그 시점이 오지 않을 것 같다.


🛡️ 예방 — 콘텐츠 통합 체크리스트

같은 패턴으로 11번째 콘텐츠를 통합한다면, 이 순서로 확인한다.

  • src/bridge/ 폴더가 기준 콘텐츠와 동일한가 (diff -r로 확인)
  • src/bridge/index.tscontentName만 콘텐츠 이름으로 바뀌었는가
  • App.tsxuseBridgeInit.getState().initialize() 호출이 있는가
  • 데이터 로드 함수에 if (!bridge.isStandalone()) return; 분기가 있는가
  • 결과 전송 함수에 if (bridge.isStandalone()) { 기존 } else { sendResult } 분기가 있는가
  • useProblemResults의 4개 메서드(init·startProblem·incrementAttempts·addResult)가 모두 호출되는가
  • addResult({ problemId, ... })problemId가 들어가는가 (BE ProblemAttempt FK 필수)
  • 게임 내부 순서와 problemSeq의 매핑 함수가 명시되어 있는가 (8.1 함정)
  • 타임오버 responseTime이 0이 아닌 제한시간 ms로 들어가는가 (8.2 함정)
  • 결과 전송 시 getSummary('accuracy' \| 'time')을 호출하고, 직접 계산하지 않았는가
  • Standalone(npm run dev)에서 기존 동작이 회귀 없이 돌아가는가
  • Vuplex(WebView)에서 contentInitcontentReadycontentResultcontentExit가 순서대로 흐르는가

이 12개 항목이 한 콘텐츠 통합의 PR 체크리스트로 굳었다.


📋 정리 — 통합 마일스톤 핵심 결정 요약

항목안티패턴권장 패턴
브릿지 배포npm 워크스페이스 공유 패키지src/bridge/ 폴더 하드카피 (10개 콘텐츠 규모)
통계 계산콘텐츠별 직접 계산useProblemResults 훅 하나로 16필드 자동 누적
accuracyPct 분모totalProblems (시도 무시)totalAttempts (시도 기반 정확한 정의)
콘텐츠 타입 정보호스트가 콘텐츠 이름으로 추정콘텐츠가 contentType: 'accuracy' | 'time' 선언
BE 필드 확장한 번에 모든 필드 확정 + 마이그레이션 1회점진적 확장 + 단, PR 동기화 절차 필수
단위 통일일부 ms / 일부 초모든 시간 필드 ms로 통일 (초→ms 변환 코드 회수)
표준 합의 위치코드 안의 주석docs/content-bridge-standard.md 한 파일
게임 순서 매핑내부 순서를 그대로 problemSeqproblemSeq로 변환하는 함수를 콘텐츠별로 명시

숫자로 보는 통합 마일스톤

  • 통합 콘텐츠: 10종 (정확도 7 + 시간 3)
  • 콘텐츠당 변경 파일 수: 4~6개 (bridge/ 폴더 + 진입점 + 데이터 store + 결과 store + 게임 로직)
  • 자동 누적 통계 필드: 16개 (요약 16 + 문제별 7)
  • 점진적 확장 차수: 4차 (8 → 2 → 1 → 7(problems) 필드)
  • 호스트 코드 회수: Unity 시간→정확도 변환 103줄 삭제
  • 기존 Standalone 동작 회귀: 0건
  • 통합 중 사고: 2건 (DTO 미정의 400 Bad Request, totalTime 단위 이중 변환)
  • 표준 명세서: 1개 (docs/content-bridge-standard.md) — 통합 절차의 단일 출처

이전 편에서 잡은 Vuplex 초기화 타이밍 문제는 한 콘텐츠가 메시지 한 개를 놓치던 사건이었다. 이번 편은 그 도구가 10개 콘텐츠 전부에 같은 모양으로 들어가는 작업이었다. 설계 → 트러블슈팅 → 통합 마일스톤의 세 편이 한 줄로 이어진다.

다음 편은 이 통합 결과를 분석 지표 누계로 끌어올리는 작업을 다룬다. 매 시도마다 16개 필드가 들어오는 상태에서, 그걸 회원·콘텐츠·기간별로 누계하는 운영 테이블을 어떻게 설계했는지 — TOP5 인지 지표 실시간 추적 시스템이다.

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