Unity ↔ 웹 PostMessage 브릿지 설계기

Unity 네이티브 앱이 WebView로 웹 콘텐츠를 임베드할 때, 호스트와 콘텐츠는 PostMessage로만 대화한다. Vuplex·iframe·Standalone 세 환경을 런타임에 자동 감지하는 ContentBridge 모듈을 설계하고, contentInit·contentReady·contentResult·contentExit 메시지 규격을 한곳에 고정한 과정을 정리한다. 핵심 제약은 Standalone 모드에서 기존 콘텐츠 코드가 100% 그대로 동작해야 한다는 것이었다.


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

  • 무엇: Unity 네이티브 앱이 WebView로 임베드한 웹 콘텐츠와 호스트가 대화하는 PostMessage 브릿지를 설계했다
  • 핵심 결정: 콘텐츠는 API를 직접 호출하지 않는다 — 호스트가 데이터를 주입하고 결과를 회수하는 구조
  • 3-mode 감지: Vuplex / iframe / Standalone을 런타임에 자동 판별, Standalone은 기존 코드 100% 유지가 절대 제약
  • 메시지 규격: { type, payload, timestamp } 봉투 + contentInit·contentReady·contentResult·contentExit 4종으로 고정
  • 결과: 10개 웹 콘텐츠가 같은 브릿지로 통합, 한 콘텐츠는 419개 문제를 주입받아 전부 인식
  • 트레이드오프: 동기 환경 감지와 else 없는 전송 분기가 통합 단계에서 메시지 유실로 되돌아왔다

🎯 배경 — 두 클라이언트가 같은 화면을 공유할 때

이전 편에서는 서버와 프론트엔드 사이의 합의를 Swagger 한 곳에 모으는 이야기를 했다. 이번엔 합의의 주체가 다르다. 서버가 아니라 두 클라이언트 사이의 규격이다.

제품에는 Unity로 만든 네이티브 앱이 있고, 그 안에 React로 만든 작은 웹 콘텐츠들이 화면 단위로 임베드된다. Unity 쪽은 Vuplex 3D WebView라는 WebView 컴포넌트로 웹 페이지를 렌더링한다. 사용자 입장에서는 하나의 앱이지만, 내부적으로는 네이티브 호스트 + 임베디드 웹이라는 두 런타임이 한 화면을 나눠 쓰는 구조다.

문제는 이 웹 콘텐츠들이 원래 독립 실행을 전제로 만들어졌다는 점이다. 각 콘텐츠는 자체 로그인 화면이 있고, 자체적으로 백엔드 API를 호출해 문제 데이터를 받아오고, 결과도 직접 API로 저장했다. 브라우저에서 URL로 열면 그대로 동작하는, 완결된 작은 웹앱이었다.

이걸 Unity 앱 안에 넣으면 곧바로 충돌이 난다.

콘텐츠가 WebView 안에서 직접 API를 호출하면:

1. 인증 토큰이 없다       → 네이티브 앱이 로그인 세션을 들고 있음
2. CORS가 막는다          → WebView origin이 API 서버와 다름
3. 컨텍스트를 모른다       → 지금 누가, 어떤 문제 묶음을 푸는지 콘텐츠는 모름
4. 결과를 어디로 보낼지 모른다 → 저장 대상 식별자가 네이티브 쪽에 있음

실제로 한 콘텐츠를 그냥 임베드해 봤더니 결과 저장 화면에서 404가 떴다. 콘텐츠가 WebView 안에서 자기 식별자로 API를 직접 때렸는데, 그 식별자는 네이티브 앱이 발급한 세션에만 존재하는 값이었다.

📌 핵심: WebView 안의 웹은 “서버에 직접 말을 거는 클라이언트”가 아니라, **“호스트에게 말을 거는 게스트”**가 되어야 한다. 인증·네트워크·세션 컨텍스트는 전부 네이티브 호스트가 들고 있다. 콘텐츠가 할 일은 게임 로직과 화면뿐이다. 그렇다면 둘 사이에 말이 통하는 규격이 필요하다. 그게 이번에 설계한 PostMessage 브릿지다.

브라우저에는 이런 상황을 위한 표준 API가 이미 있다. 서로 다른 컨텍스트(창, iframe, WebView 호스트) 사이에 구조화된 메시지를 던지는 postMessage다.

MDN — Window: postMessage() method
postMessage()는 서로 다른 origin·컨텍스트의 Window 객체 사이에 안전하게 메시지를 주고받는 표준 API다. 수신 측은 message 이벤트로 데이터를 받으며, 구조화 복제(structured clone) 가능한 값이면 무엇이든 전달할 수 있다.
developer.mozilla.org

Vuplex WebView도 이 모델을 따른다. WebView 안에서 window.vuplex.postMessage()를 호출하면 Unity C# 쪽으로 문자열이 전달되고, 반대로 Unity가 WebView에 메시지를 주입할 수도 있다. 브릿지는 이 저수준 채널 위에 콘텐츠가 이해할 수 있는 규격을 한 겹 얹는 작업이었다.


⚖️ 설계 결정 5건 — 무엇을 먼저 고정했나

브릿지를 설계하며 내린 결정 5건을 먼저 표로 정리하고, 본문에서 각 트레이드오프를 푼다.

#결정채택거절트레이드오프
1데이터 소유권호스트가 데이터를 주입, 콘텐츠는 API 미호출콘텐츠가 직접 API 호출인증·CORS가 사라짐 vs 콘텐츠가 호스트 페이로드에 종속
2환경 감지Vuplex / iframe / Standalone 런타임 자동 판별빌드 플래그로 모드 고정한 빌드로 3환경 대응 vs 감지 타이밍 리스크
3기존 동작 보존isStandalone() 분기로 기존 경로 우회콘텐츠를 호스트 전용으로 전면 재작성회귀 0 vs 분기문 누락 시 침묵 실패
4메시지 형식{ type, payload, timestamp } 단일 봉투메시지 타입마다 자유 형식직렬화·로깅 일관 vs 봉투 오버헤드
5결과 데이터 범위문제별 상세까지 전부 전송요약 지표만 전송분석 데이터 확보 vs 페이로드 크기

결정 1: 콘텐츠는 API를 호출하지 않는다

가장 먼저 확정한 원칙이다. WebView 안의 콘텐츠는 백엔드를 모른다. 문제 데이터는 호스트가 넣어주고, 결과는 호스트가 가져간다. 콘텐츠는 “들어온 문제를 풀게 하고, 푼 결과를 돌려주는” 순수 함수에 가까워진다.

이 결정 하나로 인증·CORS·세션 컨텍스트 세 문제가 한꺼번에 사라진다. 콘텐츠는 토큰을 가질 필요가 없고, API origin을 알 필요가 없고, 누가 푸는지 알 필요가 없다. 대신 콘텐츠는 호스트가 보내주는 페이로드 규격에 종속된다. 그 규격이 곧 이번 설계의 본체다.

결정 2: 한 빌드로 세 환경

같은 콘텐츠 코드가 세 가지 환경에서 실행된다.

  • Vuplex: Unity 앱 안의 WebView. window.vuplex 객체가 주입돼 있다.
  • iframe: 일반 웹 페이지 안의 <iframe>. 부모 창이 따로 있다.
  • Standalone: 그냥 브라우저에서 URL로 연 상태. 개발·디버깅 시 기본값.

빌드 플래그로 환경을 고정하면 세 벌의 빌드 산출물을 따로 관리해야 한다. 콘텐츠가 10종이면 30벌이다. 그래서 런타임 자동 감지를 택했다. 콘텐츠는 한 벌만 빌드하고, 실행 시점에 자기가 어느 환경에 있는지 스스로 판별한다. 대가는 감지 타이밍이다 — 이 대가가 통합 단계에서 어떻게 청구되는지는 회고에서 다룬다.

결정 3: Standalone 모드는 한 줄도 바꾸지 않는다

이번 설계에서 타협 불가였던 절대 제약이다. 변경 명세서 첫 줄에 박아둔 문장은 이랬다.

일반 Web 호출 시 기존 실행 흐름 변화 없음 — Standalone 모드에서는 현재 코드가 그대로 동작해야 한다.

이유는 단순하다. 콘텐츠 개발자는 브라우저에서 npm run dev로 콘텐츠를 띄워 디버깅한다. 브릿지를 넣는다고 이 흐름이 깨지면, 콘텐츠를 더 이상 단독으로 개발할 수 없다. 그래서 브릿지는 기존 코드에 끼어드는 게 아니라, 옆에 분기 하나를 추가하는 방식으로 들어간다.

// Standalone이면 기존 로직 그대로, 아니면 우회
fetchProblems: async () => {
  if (!bridge.isStandalone()) {
    return; // Vuplex/iframe: 호스트가 onInit으로 데이터를 넣어줌
  }
  // ↓ 기존 API 호출 로직 — 한 줄도 손대지 않음
  const res = await fetch('/api/problems');
  set({ problems: await res.json() });
}

기존 코드는 if 블록 아래에 그대로 남는다. 회귀 위험이 0에 수렴한다. 단, 이 패턴에는 함정이 있다 — 분기문을 빠뜨리면 호스트 환경에서 콘텐츠가 조용히 API를 때린다. 결정 1을 위반하는 침묵 실패다. 이 함정도 회고에서 다시 꺼낸다.

결정 4: 모든 메시지는 같은 봉투에 담는다

PostMessage로 오가는 모든 메시지는 같은 형태의 봉투에 담기로 했다.

interface PostMessagePayload<T = unknown> {
  type: string;        // 메시지 종류 — 'contentInit' | 'contentResult' | ...
  payload: T;          // 실제 데이터
  timestamp: number;   // 전송 시각 (Date.now())
  sessionId?: string;  // 세션 식별자 (호스트 제공)
}

type으로 분기하고, payload로 데이터를 나르고, timestamp로 로그를 정렬한다. 메시지 타입마다 형식이 다르면 수신 측에서 매번 다른 파싱 코드를 짜야 하지만, 봉투를 통일하면 수신 핸들러가 단 하나다. 봉투를 까서 type만 보고 분기하면 된다.

결정 5: 결과는 요약하지 않고 통째로 보낸다

콘텐츠가 끝나면 결과를 호스트로 돌려준다. 이때 “정답률 80%” 같은 요약만 보낼지, 문제별 상세까지 전부 보낼지 골라야 했다. 전부 보내기를 택했다. 학습 분석 용도로 문제별 응답 시간·시도 횟수·오답 패턴이 전부 필요했고, 호스트는 그중 필요한 것만 골라 쓰면 된다. 데이터를 버리는 건 나중에도 할 수 있지만, 안 보낸 데이터는 되살릴 수 없다.


🛠️ 구현 — ContentBridge 모듈

Unity 호스트와 웹 콘텐츠 사이의 PostMessage 브릿지 구조도 — Vuplex·iframe·Standalone 3-mode 감지와 메시지 흐름

브릿지는 콘텐츠 저장소와 분리된 작은 모듈로 만들었다. 폴더 구조는 이렇다.

content-bridge/
├── index.ts          # 진입점 + 콘텐츠별 인스턴스
├── bridge.ts         # ContentBridge 클래스 (메시지 송수신)
├── detector.ts       # 환경 감지
├── types.ts          # 메시지 타입 정의 (규격의 단일 출처)
└── handlers/
    ├── vuplex.ts     # window.vuplex.postMessage
    ├── iframe.ts     # window.parent.postMessage
    └── standalone.ts # no-op (브릿지가 동작하지 않음)

types.ts가 핵심이다. 호스트와 콘텐츠가 합의한 메시지 규격이 전부 여기 모인다. 봉투든 페이로드든, 형식이 궁금하면 이 파일 하나만 보면 된다.

환경 감지 — detector.ts

// content-bridge/detector.ts
export type BridgeMode = 'vuplex' | 'iframe' | 'standalone';

export function detectMode(): BridgeMode {
  // 1. Vuplex WebView — window.vuplex 객체가 주입돼 있음
  if (typeof window !== 'undefined' && 'vuplex' in window) {
    return 'vuplex';
  }
  // 2. iframe — 부모 창이 자기 자신이 아님
  if (window !== window.parent) {
    return 'iframe';
  }
  // 3. 그 외 전부 Standalone
  return 'standalone';
}

감지 순서가 곧 우선순위다. Vuplex를 먼저 본다 — Unity 환경이 가장 특수하기 때문이다. 그다음 iframe, 마지막이 Standalone이다. **Standalone은 “아무 단서도 없을 때의 기본값”**이라는 점이 중요하다. 환경을 잘못 감지하면 가장 안전한 쪽(기존 동작 유지)으로 떨어지도록 설계했다.

⚠️ 주의: 'vuplex' in window 체크에는 시점 문제가 숨어 있다. Vuplex는 WebView가 페이지를 로드한 뒤에 window.vuplex를 주입한다. 즉 자바스크립트 모듈이 처음 평가되는 시점에는 아직 window.vuplex가 없을 수 있다. 이 한 줄이 통합 단계에서 가장 큰 비용으로 돌아왔다 — 회고에서 자세히 다룬다.

브릿지 클래스 — bridge.ts

ContentBridge는 콘텐츠가 직접 다루는 단 하나의 객체다. API는 의도적으로 작게 유지했다.

// content-bridge/bridge.ts
export class ContentBridge {
  readonly mode: BridgeMode;
  private initData: ContentInitPayload | null = null;

  constructor(private config: { contentName: string; version: string; debug?: boolean }) {
    this.mode = detectMode();
    if (config.debug) console.log(`[Bridge] mode=${this.mode}`);
  }

  isStandalone(): boolean {
    return this.mode === 'standalone';
  }

  // 호스트 → 콘텐츠: 초기화 데이터 수신 대기
  onInit(handler: (data: ContentInitPayload) => void): void { ... }

  // 콘텐츠 → 호스트: 준비 완료 신호
  sendReady(): void { ... }

  // 콘텐츠 → 호스트: 결과 전송
  sendResult(payload: ContentResultPayload): void { ... }

  // 콘텐츠 → 호스트: 종료 요청
  sendExit(payload: ContentExitPayload): void { ... }

  // 리스너 해제
  dispose(): void { ... }
}

콘텐츠 입장에서 알아야 할 건 다섯 가지뿐이다 — isStandalone()으로 환경을 묻고, onInit()으로 데이터를 받고, sendReady()·sendResult()·sendExit()로 신호를 보낸다. 실제 PostMessage 호출은 모드별 핸들러(handlers/)가 숨긴다.

메시지 규격 — types.ts

호스트와 콘텐츠가 주고받는 메시지는 방향에 따라 둘로 나뉜다.

호스트 → 콘텐츠 — 콘텐츠를 띄우고 데이터를 넣는다.

// 초기화: 문제 데이터와 컨텍스트를 한 번에 주입
interface ContentInitPayload {
  sessionId: string;
  contentSeq: number;
  contentName: string;
  level: number;
  problems: ProblemData[];   // 풀어야 할 문제 배열 — 콘텐츠는 이걸 그대로 신뢰
  setting: SettingData;      // 제한 시간 등 콘텐츠 설정
  member: {                  // 누가 푸는지
    seq: number;
    userId: string;
    userLevel: number;
  };
}

// 일시정지 / 재개: 앱이 백그라운드로 갈 때 등
interface ContentPausePayload {
  reason: 'app_background' | 'user_action';
}

콘텐츠 → 호스트 — 준비 상태와 결과를 돌려준다.

// 준비 완료: 콘텐츠 로딩이 끝났음을 알림
interface ContentReadyPayload {
  contentName: string;
  version: string;
  loadTime: number;          // ms
}

// 결과: 요약 + 문제별 상세를 통째로
interface ContentResultPayload {
  sessionId: string;
  contentSeq: number;
  level: number;
  summary: {
    totalProblems: number;
    correctCount: number;
    wrongCount: number;
    accuracyPct: number;     // 0-100 정수
    totalTime: number;       // ms
  };
  problems: ProblemResult[]; // 문제별 상세 (결정 5)
}

// 종료: 콘텐츠를 닫아달라는 요청
interface ContentExitPayload {
  reason: 'completed' | 'timeout' | 'user_quit' | 'error';
  hasResult: boolean;        // 결과 데이터가 동봉됐는지
}

문제별 상세는 다음 형태다. userAnswer/correctAnswer를 unknown으로 둔 게 의도적이다 — 콘텐츠마다 답의 형태가 다르다. 어떤 콘텐츠는 숫자 하나, 어떤 콘텐츠는 문자열, 어떤 콘텐츠는 배열이다. 브릿지는 형태를 강제하지 않고 그대로 나른다.

interface ProblemResult {
  problemSeq: number;     // 1-based 순서
  isCorrect: boolean;
  userAnswer: unknown;    // 콘텐츠별 형식 — 브릿지는 관여하지 않음
  correctAnswer: unknown;
  responseTime: number;   // ms
  attempts: number;       // 재시도 포함 시도 횟수
  timestamp: number;      // Date.now()
}

통신 흐름 — 한 콘텐츠의 생애

콘텐츠가 한 번 실행되고 끝나기까지 메시지는 이 순서로 흐른다.

호스트 ──[contentInit]──▶ 콘텐츠      문제 데이터 + 컨텍스트 주입
콘텐츠 ──[contentReady]─▶ 호스트      로딩 완료, 화면 표시 시작
                  ( ... 사용자가 문제를 푼다 ... )
콘텐츠 ──[contentResult]▶ 호스트      요약 + 문제별 상세 전송
콘텐츠 ──[contentExit]──▶ 호스트      콘텐츠를 닫아달라는 요청

🔍 단서: contentResult와 contentExit를 굳이 분리한 이유가 있다. 결과 전송(contentResult)은 “데이터를 넘긴다”는 행위고, 종료(contentExit)는 “화면을 닫아달라”는 요청이다. 사용자가 중간에 그만두면 contentExit만 가고(hasResult: false), 끝까지 풀면 contentResult 다음에 contentExit가 간다(hasResult: true). 데이터와 화면 제어를 한 메시지에 묶지 않은 게 나중에 “결과 없는 이탈”을 깔끔하게 처리하게 해줬다.

콘텐츠에 끼워 넣기

콘텐츠 쪽 변경은 저장소(zustand store)에 분기 하나를 추가하는 수준이다. 결과 저장 로직을 보면 결정 1·3이 코드로 어떻게 드러나는지 한눈에 보인다.

// scoreStore.ts — 결과 저장
submitResults: async () => {
  const payload = buildResultPayload(get().results, bridge.getInitData());

  if (bridge.isStandalone()) {
    // Standalone: 기존 API 호출 — 손대지 않은 원래 코드
    await fetch('/api/results', { method: 'POST', body: JSON.stringify(payload) });
  } else {
    // Vuplex/iframe: 호스트로 결과를 넘기고 종료 요청
    bridge.sendResult(payload);
    bridge.sendExit({ reason: 'completed', hasResult: true });
  }
}

if는 기존 경로, else는 호스트 경로다. 두 경로가 한 함수 안에 나란히 있고, 어느 쪽도 상대를 침범하지 않는다.


📊 결과 — 10개 콘텐츠가 같은 규격으로

규격을 확정한 뒤 첫 적용 대상은 motion-tab-2d였다. 가장 단순한 콘텐츠를 골라 브릿지를 끼우고, Standalone·Vuplex 양쪽에서 동작을 확인했다. Standalone에서는 기존과 똑같이 동작했고(절대 제약 통과), Vuplex에서는 호스트가 주입한 문제로 게임이 돌아갔다.

첫 사례가 검증되자 나머지 콘텐츠는 같은 패턴의 반복이 됐다. 이틀에 걸쳐 10개의 웹 콘텐츠가 같은 브릿지로 통합됐다.

통합 단계콘텐츠 수확인한 것
1차 — 규격 검증2첫 적용, Standalone/Vuplex 양쪽 동작
2차 — 일괄 적용8같은 패턴 반복, 콘텐츠별 답 형식 차이 흡수
합계10단일 메시지 규격으로 통일

통합 테스트에서 인상적이었던 건 페이로드 규모였다. 한 콘텐츠는 호스트로부터 419개의 문제를 한 번에 주입받았는데, contentInit 한 메시지에 그게 다 담겨 콘텐츠가 전부 정상 인식했다. PostMessage는 구조화 복제(structured clone)로 객체를 직렬화하므로, 배열이 크다고 별도 처리가 필요하지 않았다.

결과 회수도 규격대로 동작했다. 콘텐츠가 보낸 contentResult는 이런 형태로 호스트에 도착한다.

{
  "type": "contentResult",
  "payload": {
    "sessionId": "...",
    "contentSeq": 3,
    "level": 11,
    "summary": {
      "totalProblems": 419,
      "correctCount": 4,
      "wrongCount": 0,
      "accuracyPct": 1,
      "totalTime": 29000
    },
    "problems": [ /* 문제별 상세 419건 */ ]
  }
}

📌 핵심: 10개 콘텐츠는 각각 게임 방식도, 답의 형태도, 화면 구성도 전부 다르다. 그런데 호스트 입장에서는 전부 똑같이 보인다 — contentInit을 보내고 contentResult를 받는다. 콘텐츠별 차이는 전부 payload 안쪽으로 숨겨졌다. 규격을 한곳(types.ts)에 고정한 효과가 여기서 나온다. 새 콘텐츠가 11번째로 들어와도 호스트 코드는 바뀌지 않는다.

통합 중 드러난 균열

순탄하게 끝나지는 않았다. 통합 단계에서 설계의 약한 지점 두 곳이 드러났다.

하나는 호스트 환경인데 콘텐츠가 API를 직접 호출하는 경우였다. 결정 3에서 예고한 그 함정이다. 콘텐츠 한 곳이 결과 저장 분기문을 빠뜨려서, Vuplex 안에서 자기 식별자로 API를 때렸고 404가 났다. 분기문 하나를 추가해 sendResult로 돌렸다.

다른 하나는 더 까다로웠다. 콘텐츠가 보낸 메시지가 호스트에 도착하지 않는데, 에러도 없는 증상이었다. 콘솔은 깨끗하고, 콘텐츠는 “보냈다”고 생각하고, 호스트는 “안 왔다”고 한다. 이 침묵 실패의 정체는 다음 절(회고)과 다음 편의 주제다.


🔄 회고 — 다시 설계한다면

1. 환경 감지를 “동기 1회”로 둔 게 실수였다

가장 뼈아픈 지점이다. detectMode()는 모듈이 처음 평가될 때 한 번 실행되고, 그 결과가 ContentBridge.mode에 고정된다. 동기 함수다.

문제는 Vuplex가 window.vuplex를 WebView 페이지 로드 직후, 비동기로 주입한다는 점이다. 자바스크립트 번들이 처음 평가되는 순간에는 window.vuplex가 아직 없을 수 있다. 그러면 이런 일이 벌어진다.

1. 브릿지 모듈 평가 → detectMode() → window.vuplex 없음 → 'standalone' 확정
2. 콘텐츠가 isStandalone() === true 로 판단 → 호스트 핸들러 등록 안 함
3. (수백 ms 후) Vuplex가 window.vuplex 주입 → 하지만 mode는 이미 'standalone'
4. 호스트가 contentInit 전송 → 받을 핸들러가 없음 → 메시지 증발

설계 결정 2에서 “감지 타이밍 리스크”라고 적어둔 대가가 정확히 이 형태로 청구됐다. 다시 만든다면 환경 감지를 동기 1회 판정이 아니라 vuplexready 이벤트를 기다리는 비동기 초기화로 설계한다. 호스트가 준비됐다는 신호를 받은 뒤에 모드를 확정하는 게 맞다. 이 타이밍 문제를 실제로 어떻게 잡았는지는 다음 편에서 따로 다룬다.

2. 메시지 전송에 else가 없었다

전송 핸들러의 초기 구현은 대략 이런 모양이었다.

// ❌ 초기 구현 — else가 없다
send(serialized: string) {
  if (this.mode === 'vuplex' && window.vuplex) {
    window.vuplex.postMessage(serialized);
  } else if (this.mode === 'iframe') {
    window.parent.postMessage(serialized, '*');
  }
  // mode === 'vuplex' 인데 window.vuplex 가 null 이면?
  // → 아무 분기에도 안 걸림 → 메시지가 조용히 사라짐
}

mode는 'vuplex'인데 window.vuplex가 아직 없는 상태(1번 타이밍 문제의 다른 얼굴)면, 메시지는 어느 분기에도 걸리지 않고 그냥 버려진다. 에러도, 경고도 없다. 보내는 쪽은 성공했다고 믿는다.

// ✅ else로 침묵을 깬다
send(serialized: string) {
  if (this.mode === 'vuplex' && window.vuplex) {
    window.vuplex.postMessage(serialized);
  } else if (this.mode === 'iframe') {
    window.parent.postMessage(serialized, '*');
  } else {
    console.warn('[Bridge] 메시지 전송 실패 — 채널 없음', {
      mode: this.mode, hasVuplex: !!window.vuplex,
    });
  }
}

교훈은 명확하다. 분기에서 “그 외 전부”를 비워두지 않는다. 특히 메시지·이벤트처럼 결과가 즉시 보이지 않는 코드일수록, 아무 데도 안 걸리는 입력은 반드시 로그를 남겨야 한다. 침묵 실패는 디버깅에서 가장 비싼 종류의 버그다.

3. Standalone 분기 방식은 잘한 결정이었다

반대로, 잘 풀린 것도 있다. “기존 코드 옆에 if (!bridge.isStandalone()) 분기를 추가한다”는 패턴은 끝까지 유효했다. 10개 콘텐츠를 통합하는 동안 기존 Standalone 동작이 깨진 사례는 0건이었다. 콘텐츠 개발자는 여전히 브라우저에서 단독으로 콘텐츠를 띄워 디버깅할 수 있었다. 새 구조를 들이면서 기존 워크플로를 보존하는 건, 그 구조가 실제로 채택되느냐를 가르는 조건이다.

4. 공유 모듈을 npm 패키지로 시작한 건 과설계였다

브릿지를 처음엔 모노레포의 공유 워크스페이스 패키지로 만들었다. 콘텐츠들이 import해서 쓰는 구조다. 깔끔해 보였지만, 콘텐츠 저장소가 각자 독립적으로 굴러가는 환경에서는 패키지 버전 동기화가 새로운 일거리가 됐다. 결국 나중에는 브릿지 코드를 각 콘텐츠 저장소의 src/bridge/에 직접 복사해 넣는 방식으로 바꿨다. 콘텐츠가 10개뿐이고 브릿지 코드가 작을 때는, 공유 패키지의 추상화 비용보다 복사본 N개의 단순함이 더 쌌다.


🛡️ 예방 — 클라이언트 간 규격 체크리스트

서버-클라이언트가 아니라 클라이언트-클라이언트 규격을 설계할 때, 이 작업 이후로 확인하는 항목이다.

  • 메시지는 단일 봉투({ type, payload, timestamp })로 통일했는가
  • 규격 타입이 한 파일에 모여 있는가 (types.ts 단일 출처)
  • 환경 감지가 비동기 주입(window.vuplex 등)을 기다리는가 — 동기 1회 판정 금지
  • 전송 분기에 else(또는 default)가 있고, 거기서 경고 로그를 남기는가
  • 기존 단독 실행 모드가 100% 보존되는가 — 분기 추가지 코드 치환이 아님
  • “데이터 전송”과 “화면 제어”가 별도 메시지로 분리됐는가
  • 콘텐츠별로 다를 수 있는 값(답 형식 등)은 unknown으로 열어뒀는가

세 번째와 네 번째 항목이 이번에 비싸게 배운 것이다. 둘 다 침묵 실패를 막는 항목이다. 클라이언트 간 통신은 서버 호출과 달리 HTTP 상태 코드 같은 명시적 실패 신호가 없다. 실패가 보이게 만드는 건 전적으로 설계자의 책임이다.


📋 정리 — 핵심 결정 요약

항목안티패턴권장 패턴
데이터 소유콘텐츠가 직접 API 호출호스트가 주입, 콘텐츠는 API 미호출
환경 대응환경별 빌드 분리한 빌드 + 런타임 자동 감지
환경 감지 시점동기 1회 판정호스트 준비 신호 후 비동기 확정
기존 동작콘텐츠 전면 재작성isStandalone() 분기 추가 (치환 아님)
메시지 형식타입별 자유 형식{ type, payload, timestamp } 단일 봉투
전송 분기else 없는 if/else ifelse에서 경고 로그
결과·종료한 메시지에 결합contentResult / contentExit 분리
공유 코드소규모인데 npm 패키지화콘텐츠 수가 적으면 복사본이 더 쌈

숫자로 보는 브릿지 설계

  • 통합 콘텐츠: 10개 (1차 검증 2 + 2차 일괄 8)
  • 메시지 타입: 6종 (호스트→콘텐츠 2 + 콘텐츠→호스트 3 + 봉투 1)
  • 지원 환경: 3종 (Vuplex / iframe / Standalone), 빌드는 1벌
  • 최대 페이로드: contentInit 한 메시지에 문제 419건 주입 — 전부 정상 인식
  • 기존 Standalone 동작 회귀: 0건
  • 통합 중 드러난 설계 결함: 2건 (동기 환경 감지, else 없는 전송 분기)

서버와 클라이언트 사이의 규격은 HTTP·OpenAPI 같은 잘 닦인 길이 있다. 하지만 클라이언트와 클라이언트 사이에는 그런 표준 합의가 없다. PostMessage라는 저수준 채널만 주어지고, 그 위의 규격은 직접 설계해야 한다. 이번 작업의 본질은 “콘텐츠는 게스트, 호스트는 집주인”이라는 한 문장을 메시지 6종과 분기 한 줄로 번역하는 일이었다.

다음 편에서는 이 회고에서 미뤄둔 숙제 — Vuplex가 window.vuplex를 주입하는 타이밍 때문에 호스트의 첫 메시지가 통째로 증발하던 문제를 어떻게 잡았는지 다룬다. 동기 환경 감지가 청구한 청구서의 실제 금액이다.

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

  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초로
  55. 55. Cloud SQL 리전 트랩 — US→Taiwan 71% 트러블슈팅
  56. 56. QR 배치고사 + Firebase Hosting 멀티 사이트 배포
  57. 57. 1,974줄 풀 백업 — 1인 개발에서 상태 관리하는 법
  58. 58. 주간 출석 KST 타임존 — 월요일이 사라진 트러블슈팅
  59. 59. 연락처 포맷 통일 — 저장은 숫자만, 표시는 하이픈
  60. 60. react-hook-form + Zod 폼 표준 정착기
  61. 61. Soft Delete 구현 — deletedAt 한 컬럼이 닿은 27곳의 설계
  62. 62. 교육과정 자동 승급의 늪 — 도메인 버그 3 건 트러블슈팅
  63. 63. 교육과정 도메인 BE 완성과 같은 날 핫픽스 7 건 — NestJS @Cron 2 중 실행 묶음