Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)
Unity WebView 호스트가 보낸 첫 contentInit 메시지를 웹 콘텐츠가 0개로 인식하는데 에러는 없다. window.vuplex가 비동기로 주입되는 탓에 detectMode()가 'standalone'을 오판하고, isStandalone() 가드가 메시지 핸들러 등록을 막은 침묵 실패를 추적해 해결한 기록.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 증상: Unity(WebView 호스트)가
contentInit으로 보낸 문제 데이터를 콘텐츠가 0개로 인식 — 콘솔은 깨끗- 표면 원인 둘: 호스트·콘텐츠의 필드명 단/복수 불일치, Standalone 전용 로더가 호스트 데이터를 덮어씀
- 진짜 범인:
detectMode()가 모듈 로드 시점에 동기 실행 →window.vuplex미주입 →'standalone'오판- 연쇄 효과:
if (isStandalone()) return가드가onInit핸들러 등록을 막아 메시지가 증발- 해결: 핸들러를 환경과 무관하게 무조건 등록, 브릿지는
vuplexready이벤트로 모드 확정- 교훈: 비동기로 주입되는 전역 객체를 동기 코드가 한 번 보고 판단하면 안 된다
🌱 배경 — 미뤄둔 침묵 실패를 잡을 차례
이전 편에서 Unity 네이티브 앱과 그 안에 임베드된 웹 콘텐츠가 PostMessage로 대화하는 ContentBridge를 설계했다. 호스트가 문제 데이터를 contentInit으로 주입하고, 콘텐츠가 결과를 contentResult로 돌려주는 구조다. 첫 콘텐츠는 브라우저 단독 실행(Standalone)과 Unity 임베드(Vuplex) 양쪽에서 잘 돌았다.
그 글 끝에 숙제 하나를 남겼다. 통합 단계에서 메시지가 오가는데 에러도 없는 침묵 실패가 있었고, 정체는 다음 편에서 다루겠다고 했다. 이번이 그 다음 편이다.
증상을 다시 정확히 적으면 이렇다. Unity는 분명히 문제 데이터를 contentInit에 담아 보냈다. 그런데 콘텐츠는 “문제 0개”라고 했다. 호스트가 보낸 첫 메시지가 콘텐츠에 도착하지 않았다. 그것도 두 클라이언트 모두 “내 쪽은 정상”이라고 주장하는 채로.
📌 핵심: 서버 호출이라면 실패가 HTTP 상태 코드로 보인다. 하지만 PostMessage 같은 클라이언트 간 통신에는 그런 명시적 실패 신호가 없다. “보냈다”와 “못 받았다”가 동시에 참인 상태 — 이게 침묵 실패의 본질이다. 범인은 한 명이 아니라 셋이었고, 그중 둘은 표면이었다.
🔥 증상 — Unity는 보냈다는데 콘텐츠는 못 받았다

첫 신고는 number-tap 콘텐츠에서 올라왔다. Unity 안에서 콘텐츠를 띄우면 게임 화면 대신 “문제 데이터가 없습니다” 안내가 떴다.
콘텐츠 쪽 콘솔 로그는 이랬다.
[Bridge] mode=standalone
[Loading] questions already loaded: 0
[number-tap] 문제 데이터가 없습니다
호스트(Unity) 쪽 로그는 정반대였다.
[Unity] loadContent → contentInit 전송 (problems: 84)
호스트는 84개를 보냈다고 하고, 콘텐츠는 0개라고 한다. 둘 사이 어디선가 메시지 84개가 통째로 사라졌다.
🔍 단서: 콘솔에 빨간 줄이 한 줄도 없었다. 예외도, 경고도, 네트워크 실패도 없다.
try/catch로 잡을 게 없고, 스택 트레이스도 없다. 이런 증상은 “코드가 터진 것”이 아니라 “코드가 실행되지 않은 것”을 의심해야 한다.
비대칭 — 브라우저는 되고 WebView는 안 된다
결정적 단서는 환경에 따라 결과가 갈린다는 점이었다.
- 브라우저 단독 실행 (
npm run dev): 콘텐츠가 자체 API로 문제를 받아와 정상 동작 - Unity 임베드 (Vuplex WebView): “문제 0개”
같은 코드, 같은 빌드다. 환경만 다르다. 이전 편에서 정한 절대 제약 — “Standalone 모드는 한 줄도 바꾸지 않는다” — 은 지켜진 셈이다. 깨진 건 호스트 환경뿐이었다. 문제의 범위가 브릿지가 개입하는 경로로 좁혀졌다.
🔍 탐색 — 표면을 먼저 벗긴다
contentInit이 콘텐츠에 들어와 화면에 그려지기까지 거쳐야 하는 단계는 여럿이다. 호스트가 메시지를 보내고 → 브릿지가 받고 → 저장소에 넣고 → 콘텐츠가 읽어 렌더링한다. 이 경로를 거꾸로 짚어 올라가며 두 개의 버그를 먼저 찾아냈다. 둘 다 진짜 버그였지만, 둘 다 진짜 범인은 아니었다.
표면 1: 호스트와 콘텐츠의 필드명이 어긋났다
문제 데이터를 정규화하는 함수를 열어 봤다. 호스트가 보낸 문제 객체를 콘텐츠가 쓰는 형태로 바꿔주는 normalizeProblems다.
// ❌ Before — 단수 필드명만 읽는다
function normalizeProblems(raw: any[]): QuestionItem[] {
return raw.map((p, idx) => ({
seq: idx + 1,
formulaNumber: p.formulaNumber, // 호스트는 formulaNumbers(복수)로 보낸다
formulaSign: p.formulaSign, // 호스트는 formulaSigns(복수)로 보낸다
}));
}
콘텐츠 코드는 수식 필드를 formulaNumber·formulaSign 단수로 읽는데, 호스트가 주입하는 객체는 formulaNumbers·formulaSigns 복수였다. 콘텐츠는 독립 실행 시절 자체 API 응답 형식에 맞춰 단수로 짜여 있었고, 호스트는 DB 스키마의 복수형 컬럼명을 그대로 실어 보냈다. 양쪽이 합의 없이 각자의 이름을 쓴 것이다.
// ✅ After — 단/복수 양쪽을 수용
function normalizeProblems(raw: any[]): QuestionItem[] {
return raw.map((p, idx) => ({
seq: idx + 1,
formulaNumbers: p.formulaNumbers ?? p.formulaNumber ?? [],
formulaSigns: p.formulaSigns ?? p.formulaSign ?? [],
}));
}
⚠️ 주의: 필드명 불일치는 문제가 들어온 뒤에 작동한다. 데이터가 저장소까지는 도착했는데 콘텐츠가 못 읽는 상황이다. 그래서 이걸 고치면 일부 콘텐츠는 증상이 나아진다 — 데이터가 실제로 들어오던 콘텐츠라면. 하지만 데이터 자체가 안 들어오는 콘텐츠는 이걸 고쳐도 그대로다. 이게 표면 버그를 진짜 범인으로 착각하기 쉬운 이유다.
표면 2: Standalone 전용 로더가 호스트 데이터를 덮어썼다
다음은 number-making 콘텐츠였다. 여기서는 호스트가 보낸 36개 문제가 분명히 저장소에 들어왔다. 그런데 게임 화면으로 넘어가는 순간 사라졌다.
코드를 보니 두 경로가 같은 저장소(gameStore)를 건드리고 있었다.
// ❌ Before — 환경과 무관하게 표준 로더가 항상 돈다
useEffect(() => {
bridge.onInit((data) => {
useGameStore.getState().setQuestions(data.problems); // 호스트 데이터 36개 세팅
});
loadForUserContent(); // ← Standalone 전용인데 무조건 실행
}, []);
loadForUserContent()는 콘텐츠가 자체 API로 문제를 받아오는 원래 로더다. Standalone 환경에서만 의미가 있다. 그런데 호스트 환경에서도 이게 무조건 돌았다. 호스트 환경에서는 자체 API가 빈 응답을 주므로, 빈 배열이 gameStore로 흘러들어가 방금 브릿지가 채운 36개를 덮어쓴다.
// ✅ After — Standalone에서만 표준 로더를 돌린다
useEffect(() => {
bridge.onInit((data) => {
useGameStore.getState().setQuestions(data.problems);
});
if (bridge.isStandalone()) {
loadForUserContent(); // 호스트 모드에서는 호출하지 않는다
}
}, []);
이전 편의 설계 결정 3 — “기존 동작은 isStandalone() 분기로 우회한다” — 을 정확히 빠뜨린 케이스였다. 분기문 하나가 누락되면 호스트 환경에서 콘텐츠가 조용히 자기 로직을 실행한다.
둘을 고쳤는데, number-tap은 여전히 0개였다
표면 버그 둘을 고치자 number-making은 호전됐다. 그런데 number-tap은 끄떡없었다. 여전히 콘솔에 questions already loaded: 0이 찍혔다.
여기서 방향을 바꿨다. 필드명도 맞고 덮어쓰기도 막았는데 0개라면, 데이터는 저장소에 들어오기 전부터 0개라는 뜻이다. bridge.onInit의 콜백이 애초에 한 번도 호출되지 않은 것이다.
🔍 단서: 핸들러 안에
console.log를 한 줄 넣고 다시 돌렸다. 그 로그가 끝까지 안 찍혔다. 호스트는 메시지를 보냈고, 콘텐츠에는 그 메시지를 받을 핸들러가 없었다. 데이터 정규화도, 저장소 덮어쓰기도 아니다. 메시지 핸들러 등록 자체가 안 된 것이다.
🔬 진짜 범인 — 환경 감지가 너무 일찍 끝났다

브릿지 인스턴스는 src/bridge/index.ts에서 모듈이 import되는 순간 만들어진다.
// src/bridge/index.ts
export const bridge = new ContentBridge({
contentName: 'number-tap',
version: '1.0.0',
});
ContentBridge의 생성자는 환경을 동기로 판정한다.
// src/bridge/ContentBridge.ts
constructor(config: BridgeConfig) {
this.mode = detectMode(); // ← 모듈이 로드되는 순간 실행
}
detectMode()는 이전 편에서 본 그 함수다.
// src/bridge/detector.ts
export function detectMode(): BridgeMode {
if (typeof window !== 'undefined' && 'vuplex' in window) return 'vuplex';
if (window !== window.parent) return 'iframe';
return 'standalone';
}
여기까지는 멀쩡해 보인다. 문제는 'vuplex' in window 한 줄에 숨어 있었다.
window.vuplex는 페이지 로드 뒤에 들어온다
Vuplex 공식 문서가 이 동작을 명시한다.
공식 문서 표현 그대로다 — window.vuplex는 “페이지 로딩이 끝나고 수 밀리초 뒤에 추가된다”. 즉 자바스크립트 번들이 처음 평가되는 시점, ContentBridge 생성자가 도는 그 순간에는 window.vuplex가 아직 없을 수 있다.
그러면 detectMode()는 이렇게 답한다.
'vuplex' in window→ false (아직 주입 전)window !== window.parent→ false (WebView는 최상위 창)- 결론 →
'standalone'
브릿지는 틀린 답을 한 게 아니다. 너무 일찍 답한 것이다. 수백 밀리초만 더 기다렸으면 정답을 알았을 텐데, 모듈 로드 시점에 한 번 보고 결론을 내려버렸다.
그리고 콘텐츠가 그 틀린 답으로 결정을 내렸다
오판 자체보다 더 아픈 건 다음 단계다. 콘텐츠 코드는 useEffect 안에서 브릿지에 환경을 묻고, 그 답으로 핸들러 등록 여부를 정했다.
// ❌ Before — 환경을 먼저 묻고, 그 답으로 등록 여부를 결정
useEffect(() => {
if (bridge.isStandalone()) return; // 'standalone' 오판 → 여기서 빠져나감
bridge.onInit((data) => {
useQuestionStore.getState().setQuestions(normalizeProblems(data.problems));
});
}, []);
bridge.isStandalone()은 생성자가 캐싱해둔 this.mode를 그대로 읽는다. 그 값은 'standalone'이다. 그래서 useEffect는 return으로 즉시 빠져나가고, onInit 핸들러는 등록되지 않는다.
전체 순서를 시간축으로 늘어놓으면 이렇다.
T0 브릿지 모듈 로드 → detectMode() → window.vuplex 없음 → mode = 'standalone'
T1 컴포넌트 useEffect → isStandalone() === true → onInit 핸들러 등록 건너뜀
T2 (+수백 ms) vuplexready 이벤트 → window.vuplex 주입 → 호스트 준비 완료
T3 호스트가 contentInit 전송 → 받을 핸들러가 없음 → 메시지 증발
T2에서 환경은 분명히 vuplex가 됐다. 하지만 이미 늦었다. 콘텐츠는 T1에서 돌이킬 수 없는 결정(핸들러 등록 안 함)을 내린 뒤였다. T3에 도착한 메시지 84개는 받을 사람이 없는 편지처럼 그냥 버려진다.

📌 핵심: 이전 편 회고에서 “환경 감지를 동기 1회 판정으로 둔 게 실수였다”고 적어둔 청구서가, 정확히 이 형태로 청구됐다. 동기 1회 판정의 진짜 위험은 “값이 틀릴 수 있다”가 아니다. 틀린 값이 캐싱돼서, 나중에 환경이 바뀌어도 아무도 다시 묻지 않는다는 것이다.
🛠️ 해결 — 묻지 말고 등록하라
수정은 세 갈래였다. 핵심은 첫 번째다.
해결 1: 핸들러를 환경과 무관하게 무조건 등록한다
if (bridge.isStandalone()) return 가드는 원래 최적화였다. “Standalone이면 호스트가 없으니 핸들러를 등록할 필요가 없다”는 합리적으로 들리는 판단이다. 하지만 이 판단에는 잘못된 전제가 있었다 — 핸들러 등록이 비싸다는 전제다.
onInit 핸들러 등록은 message 이벤트 리스너를 다는 일이다. Standalone 환경에서는 호스트가 없으니 contentInit 메시지가 영영 오지 않고, 그러면 핸들러는 그냥 한 번도 안 불릴 뿐이다. 부작용이 0이다. 가드는 아무것도 아낀 게 없으면서, 오판 한 번에 전체를 무너뜨렸다.
// ✅ After — 환경을 묻지 않고 무조건 등록
useEffect(() => {
// Standalone이면 contentInit이 영영 오지 않으므로 이 핸들러는 no-op
const off = bridge.onInit((data) => {
useQuestionStore.getState().setQuestions(normalizeProblems(data.problems));
});
return off; // 언마운트 시 리스너 해제
}, []);
isStandalone() 한 줄을 지웠다. 이제 콘텐츠는 환경이 무엇이든 onInit 핸들러를 단다. T1 시점에 환경이 'standalone'으로 오판돼 있어도 상관없다 — 핸들러는 이미 달려 있고, T3에 메시지가 오면 받는다.
📌 핵심: 핸들러 등록 자체는 어느 환경에서도 무해하다. 부작용은 “등록할까 말까”를 불확실한 정보로 결정할 때 생긴다. 불확실하면 등록하는 쪽이 안전하다. 이 한 줄이 이번 디버깅의 본체다.
해결 2: 브릿지가 vuplexready를 기다린다
가드를 없애면 핸들러는 등록된다. 하지만 그 핸들러에 메시지가 실제로 흘러들어오려면, 브릿지가 Vuplex 메시지 채널을 올바른 시점에 열어야 한다. Vuplex 메시지는 window.vuplex.addEventListener('message', ...)로 받는데, window.vuplex가 없는 모듈 로드 시점에 이걸 호출하면 그대로 터진다.
그래서 채널 연결을 게으르게(lazy) 바꿨다. 공식 문서가 권장하는 vuplexready 패턴 그대로다.
// src/bridge/ContentBridge.ts — vuplexready를 기다려 채널을 연다
private attachVuplexChannel() {
const attach = () => {
this.mode = 'vuplex'; // 뒤늦게라도 모드를 바로잡는다
window.vuplex.addEventListener('message', this.handleMessage);
};
if ('vuplex' in window) {
attach(); // 이미 주입돼 있으면 바로
} else {
// 아직이면 주입 완료 이벤트를 한 번만 기다린다
window.addEventListener('vuplexready', attach, { once: true });
}
}
detectMode()로 'iframe'·'standalone'은 동기로 1차 판정하되, Vuplex 가능성만은 vuplexready로 끝까지 열어둔다. window.vuplex가 끝내 안 들어오면 그대로 Standalone — 가장 안전한 기본값이다. 환경 감지가 “동기 1회 스냅샷”에서 “이벤트를 기다리는 비동기 확정”으로 바뀌었다.
⚠️ 주의:
vuplexready리스너에는{ once: true }를 줬다. 이벤트는 한 번만 발생하고, 리스너가 남아 있으면 메모리 누수가 된다. 일회성 초기화 이벤트는 항상once옵션이나 수동 해제를 챙겨야 한다.
해결 3: 등록 위치를 한 곳으로 모은다
number-tap은 onInit 등록이 저장소 파일과 컴포넌트 여러 곳에 흩어져 있었다. 어디서 등록되는지 추적하기가 어려웠고, 그래서 “등록이 안 됐다”는 사실을 발견하는 데도 오래 걸렸다.
호스트 모드의 엔트리 화면인 Loading 컴포넌트 한 곳으로 등록을 모았다.
// Loading.tsx — 호스트 모드 엔트리에서 onInit을 직접 등록
useEffect(() => {
console.log(`[Loading] onInit 핸들러 등록 (mode: ${bridge.mode})`);
const off = bridge.onInit((data) => {
console.log(`[Loading] contentInit 수신: ${data.problems.length}개 문제`);
useQuestionStore.getState().setQuestions(
normalizeProblems(data.problems),
data.level,
);
});
return off;
}, []);
데이터가 들어오는 입구가 하나면, “핸들러가 등록됐는가”를 한 곳에서 확인할 수 있다. 로그 두 줄 — 등록 시점과 수신 시점 — 이 입구를 지킨다.
표면 버그 둘(필드명, 덮어쓰기)도 이 수정과 함께 반영했다. 진짜 범인까지 잡고 나니 세 콘텐츠 모두 호스트 데이터를 정상 인식했다.
✅ 검증 — 모드가 틀려도 메시지는 들어온다
Unity 안에서 콘텐츠를 다시 띄우고 로그를 확인했다.
number-tap 확인 포인트:
[Loading] onInit 핸들러 등록 (mode: standalone)
[Loading] contentInit 수신: 84개 문제
여기서 가장 중요한 건 첫 줄이다. mode: standalone — 환경 판정은 여전히 틀린 채로 찍혀 있다. 그런데도 둘째 줄에서 84개 문제가 정상 수신됐다. 핸들러 등록이 환경 판정과 분리됐기 때문이다. 모드가 틀려도 메시지는 들어온다 — 이게 수정이 제대로 됐다는 증거다.
number-making 확인 포인트:
[Loading] Vuplex - 36개 문제 로드— 호스트 데이터 수신- 게임 화면 진입 후에도
gameStore에 36개 문제 보존 (표준 로더가 덮어쓰지 않음) - “문제 데이터가 없습니다” 경고 사라짐
이후 통합 테스트에서 한 콘텐츠는 호스트로부터 contentInit 한 메시지에 문제 419개를 주입받아 전부 정상 인식했다. 첫 메시지가 증발하던 콘텐츠가, 가장 큰 페이로드를 한 번에 받아내는 콘텐츠가 됐다.
🔍 단서: 타이밍 버그를 고쳤는지 검증할 때는 “정상 케이스가 됐다”만 보면 부족하다. 틀린 조건에서도 동작하는지를 봐야 한다.
mode: standalone이 찍힌 채로 메시지가 들어오는 로그가 그 증거다. 운 좋게 타이밍이 맞아 통과한 것과, 타이밍과 무관하게 통과하는 것은 다르다.
🛡️ 예방 — 비동기 전역 객체 체크리스트
window.vuplex처럼 런타임에 비동기로 주입되는 전역 객체를 다룰 때, 이 작업 이후로 확인하는 항목이다.
- 전역 객체 감지를 모듈 로드 시점의 동기 코드 한 번으로 끝내지 않는가
- 주입 완료 이벤트(
vuplexready등)가 있으면 그것을 기다리는가 - 일회성 초기화 이벤트 리스너에
{ once: true }또는 수동 해제가 있는가 - 메시지 핸들러 등록을 환경 판정 결과로 가드하지 않는가 — 등록은 무해, 가드가 위험
- 핸들러 등록 위치가 한 곳(엔트리 화면)에 모여 있는가
- 환경 전용 로더가 자기 환경 가드(
isStandalone()) 안에서만 실행되는가 - 호스트가 보내는 필드명과 콘텐츠가 읽는 필드명이 일치하는가 (단/복수 포함)
- 같은 상태를 두 경로가 쓸 때, 나중 경로가 빈 데이터로 먼저 경로를 덮어쓰지 않는가
핵심은 위 두 항목이다. 비동기로 들어오는 값을 동기 코드가 한 번 보고 캐싱하면, 그 캐시는 영영 갱신되지 않는다. 값이 들어오는 순간을 알려주는 이벤트가 있다면, 스냅샷이 아니라 그 이벤트를 신뢰해야 한다.
📌 핵심: 침묵 실패를 막는 일반 원칙은 하나다 — 불확실한 정보로 “하지 않기”를 결정하지 않는다. “할까 말까”가 헷갈리면, 부작용 없는 쪽(여기서는 핸들러 등록)을 그냥 한다. 안 하는 결정은 되돌릴 기회조차 없이 침묵 속에 묻힌다.
📋 정리 — 핵심 요약
| 상황 | 안티패턴 | 권장 패턴 |
|---|---|---|
| 비동기 전역 객체 감지 | 모듈 로드 시 동기 1회 판정 후 캐싱 | 주입 이벤트(vuplexready) 대기 |
| 메시지 핸들러 등록 | 환경 판정 결과로 가드 | 환경과 무관하게 무조건 등록 |
| 일회성 이벤트 리스너 | 등록 후 방치 | { once: true } 또는 수동 해제 |
| 환경 전용 로더 | 무조건 실행 | 해당 환경 가드 안에서만 |
| 핸들러 등록 위치 | 여러 파일에 분산 | 엔트리 화면 한 곳으로 통일 |
| 호스트↔콘텐츠 필드명 | 한쪽 형식만 가정 | 단/복수 양쪽 수용 정규화 |
| 같은 상태 이중 경로 | 나중 경로가 무조건 덮어씀 | 빈 데이터로는 덮어쓰지 않음 |
숫자로 보는 디버깅
- 표면 버그: 2건 (필드명 단/복수, 표준 로더 덮어쓰기) — 고쳐도 증상 일부 잔존
- 진짜 범인: 1건 (동기 환경 감지 +
isStandalone()등록 가드) - 타이밍 간극: 모듈 로드 ~
vuplexready사이 수백 ms - 수정 후 최대 페이로드:
contentInit한 메시지에 문제 419건 — 전부 정상 인식 - 본질적 코드 변경:
if (isStandalone()) return한 줄 제거
이번 버그가 비쌌던 이유는 코드가 어려워서가 아니다. detectMode()도, if 가드도 한 줄짜리 평범한 코드다. 비쌌던 건 틀린 값이 침묵 속에 캐싱됐기 때문이다. 에러가 났다면 30분이면 잡았을 문제를, 에러가 안 났기 때문에 메시지 경로 전체를 거꾸로 짚어야 했다. 침묵 실패는 늘 코드의 난이도가 아니라 신호의 부재에서 비싸진다.
다음 편에서는 이렇게 안정된 브릿지 위에서 게임 방식이 제각각인 웹 콘텐츠 10종을 같은 규격으로 통합한 마무리 작업을 다룬다. 첫 메시지가 증발하던 브릿지가, 10개 콘텐츠의 공용 통신 규격으로 정착하기까지의 기록이다.
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)
- 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 권한 가드 — 목록은 막고 상세는 뚫린 날