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/correctAnswerunknown으로 둔 게 의도적이다 — 콘텐츠마다 답의 형태가 다르다. 어떤 콘텐츠는 숫자 하나, 어떤 콘텐츠는 문자열, 어떤 콘텐츠는 배열이다. 브릿지는 형태를 강제하지 않고 그대로 나른다.

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]──▶ 호스트      콘텐츠를 닫아달라는 요청

🔍 단서: contentResultcontentExit를 굳이 분리한 이유가 있다. 결과 전송(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.vuplexWebView 페이지 로드 직후, 비동기로 주입한다는 점이다. 자바스크립트 번들이 처음 평가되는 순간에는 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 풀스택 트러블슈팅 시리즈 (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 권한 가드 — 목록은 막고 상세는 뚫린 날