“그거 타이밍 이슈예요.” 다른 플랫폼을 맡은 팀에게서 몇 년 동안 들어 온 말이다. 신사업 프로젝트를 맡고 나서 그 말을 직접 들여다봤다.
문제 파악
맡은 프로젝트는 모바일에서 이어지는 티켓팅 funnel, 그러니까 사용자가 앱 안에서 거쳐 가는 일련의 고객 여정이었다. WebView로 띄운 구간은 funnel 전체였고, 웹 코드는 JS/React로 짰다.
당시 다른 플랫폼들은 수년 전에 만들어진 터라 native와 웹이 대화하는 방식도 window.location.href에 custom scheme URL을 넣고 native가 그걸 가로채는 방식이었는데, 대부분의 경우에는 그걸로도 큰 문제 없이 돌아갔다. 다만 그 플랫폼을 관리하는 팀에서는 가끔 생기는 알 수 없는 실패를 늘 “타이밍 이슈”라고 불렀다. 무엇이 언제 왜 실패하는지는 누구도 정확히 짚지 않았고, 다들 그 말 한마디로 넘어가곤 했다. 이름만 있고 정의는 없는 문제였다.
구조 다이어그램 코드 보기
Drawing diagram
신사업을 맡았으니 같은 문제를 물려받고 싶지 않았다. 그래서 그 모호한 표현이 실제로 무슨 일을 가리키는지부터 확인했다.
흐름을 따라가 보면 이렇다. native 앱이 WebView를 띄운다. WebView 안에서는 페이지를 받고 script를 실행하면서 메시지를 받을 준비를 한다. 그런데 native는 그 준비가 끝났는지 모른 채 WebView를 띄우자마자 요청을 보냈다. 아직 들을 귀가 없는 WebView는 아무 반응도 하지 않았고 바깥에서 보기엔 가끔 되고 가끔 안 되는 버그가 됐다. 원인은 실기기 테스트와 log 분석으로 확인했다.
문제 정의
이건 설계 문제였다. native와 WebView는 서로 다른 실행 환경이라 준비 시점이 맞춰져 있지 않은데, 준비가 끝났는지 확인하지 않고 요청을 보내고 있었다.
둘은 동기화돼 있지 않고 native가 WebView 상태를 지켜보고 있지도 않다. 그러니 “지금쯤이면 준비됐겠지” 하고 보내는 순간부터 운에 맡기는 통신이 된다. 비동기로 주고받아야 하는 관계였다.
문제 해결
해결 방법은 handshake였다. 본격적으로 통신하기 전에 양쪽이 “나 준비됐어”를 주고받고 그게 확인된 다음에만 요청을 보내는 방식이다. 메시지는 window.postMessage가 아니라, native가 주입한 bridge 객체의 postMessage로 주고받는다. iOS는 window.webkit.messageHandlers와 WKScriptMessageHandler를 쓰고, Android는 addJavascriptInterface로 노출한 객체를 쓴다.
handshake 흐름
당시 회의와 협의에 쓰려고 직접 그렸던 시퀀스 다이어그램을 그대로 옮겼다.
Drawing diagram
순서로 풀면 이렇다. WebView는 로딩이 끝나면 native가 주입한 window.WebViewBridge가 있는지 100ms 간격으로 확인한다. 이 확인의 한계는 30초, 곧 100ms를 300번 도는 시간이다. handshake 응답을 기다리는 제한 시간이 아니다. 메시지는 {path, data}를 JSON으로 직렬화한, REST에 가까운 형식이다. 이 형식은 내가 설계했다. bridge가 보이면 WebView가 먼저 path가 getAppInfo인 메시지를 보낸다. 이 메시지가 곧 “웹은 이제 들을 수 있다”는 신호다. native는 이걸 받은 뒤에야 기기 정보를 모아 nativeReady를 켜고 응답을 돌려준다. 웹이 응답을 받아 completed를 켜면 그때부터 양방향 통신을 시작한다. handshake 전에는 native가 먼저 말을 걸지 않으니, 그 전에 준비 안 된 WebView로 요청이 가지 않는다.
웹 쪽 코드 스케치
아래는 웹 쪽 흐름을 설명하려고 다시 쓴 단순화 예시다. 실제 프로젝트 코드는 아니고 메시지 형식은 다이어그램에 맞췄다. 각 팀이 정한 진입점 이름은 직관적이었다. 웹에서 앱으로는 sendWebToApp, 앱에서 웹으로는 sendAppToWeb이다.
// 단순화한 예시입니다. 실제 프로젝트 코드가 아닙니다.
const POLL_INTERVAL = 100; // ms
const MAX_TRIES = 300; // bridge 주입 확인, 최대 30초
const bridgeState = { completed: false, deviceInfo: null };
const waiting = []; // { message, resolve, reject } handshake 전에 들어온 호출
// 앱 → 웹
window.sendAppToWeb = (raw) => {
const message = typeof raw === "string" ? JSON.parse(raw) : raw;
if (message.path === "getAppInfo") {
bridgeState.deviceInfo = message.data.deviceInfo;
bridgeState.completed = true;
waiting.splice(0).forEach(({ message, resolve }) => {
sendWebToApp(message); // 보관해 둔 호출을 이제 보냄
resolve();
});
return;
}
// 그 밖의 메시지 처리
};
// 웹 → 앱
function sendWebToApp(message) {
window.WebViewBridge.postMessage(JSON.stringify(message));
}
export function callNative(path, data) {
const message = { path, data };
if (bridgeState.completed) {
sendWebToApp(message);
return Promise.resolve();
}
return new Promise((resolve, reject) => {
waiting.push({ message, resolve, reject }); // 아직 연결 전이면 보내지 않음
});
}
function startHandshake(tries = 0) {
if (window.WebViewBridge) {
sendWebToApp({ path: "getAppInfo", data: { source: "web" } });
return;
}
if (tries >= MAX_TRIES) {
const error = new Error("bridge was not injected in time");
waiting.splice(0).forEach(({ reject }) => reject(error));
return;
}
setTimeout(() => startHandshake(tries + 1), POLL_INTERVAL);
}
if (document.readyState === "complete") startHandshake();
else window.addEventListener("load", () => startHandshake());
iOS는 웹에서 앱으로 오는 메시지를 WKScriptMessageHandler로 받고, 앱에서 웹으로는 evaluateJavaScript로 보낸다. Android는 addJavascriptInterface로 노출한 객체로 웹에서 앱 메시지를 받고, 앱에서 웹으로는 evaluateJavascript로 보낸다.
회의에서 제안하고 iOS·Android와 맞추기
기술적으로 답을 찾는 것보다 시간이 더 든 건 합의였다. handshake는 웹 혼자 바꿔서 되는 일이 아니라 iOS와 Android가 둘 다 같은 약속을 지켜야 동작한다. 나는 프로젝트 리드로서 회의에서 이 구조를 제안했고 각 팀과 메시지 형식이나 순서를 하나씩 맞춰 나갔다.
그 자리에서 시퀀스 다이어그램이 꽤 큰 역할을 했다. “준비되면 알려 주세요”라고 말로 하면 각자 다르게 이해하기 쉬운데, 누가 먼저 무엇을 보내는지 그림으로 펼쳐 놓으니 질문이 구체적으로 바뀌었다. 특별히 나온 반대는 없었다. native 쪽 변경은 순서 하나였다. 웹의 getAppInfo를 받은 뒤에 응답하고, 그 전에는 먼저 보내지 않는 것이다.
적용한 뒤로 원인이 모호한 실패는 사라졌고, 통신이 실패하면 handshake 완료 여부부터 확인하면 됐다.
개선할 점
수치는 없다. 주니어 개발자들이 그 timing issue가 사실은 설계 문제라는 걸 이해하게 된 것이 남은 결과다.
bridge 주입을 확인하는 한계는 30초이고, 그 안에 window.WebViewBridge가 없으면 기다리던 호출을 reject한다. handshake 응답을 기다리는 제한 시간은 따로 두지 않았다.
돌아보면 쓴 기술 자체는 새로울 게 없었다. 오래 쓰이던 표현 하나를 실제 원인으로 바꿔 말했고 웹과 iOS, Android가 같은 순서를 지키게 해서 그 원인이 다시 생길 틈을 막았다. 여러분 팀에도 원인 대신 이름만 남은 버그가 있는가?
참고
- Apple Developer, WKScriptMessageHandler: https://developer.apple.com/documentation/webkit/wkscriptmessagehandler
- Android Developers, WebView (addJavascriptInterface, evaluateJavascript): https://developer.android.com/reference/android/webkit/WebView