WebView 브릿지를 버전 있는 프로토콜로 만들기
WebView 브릿지를 버전 있는 프로토콜로 만들기
WebView bridge의 postMessage는 문자열 전송 기능일 뿐 요청과 응답의 의미를 보장하지 않는다. protocolVersion, type, requestId, payload를 가진 envelope과 성공·실패 response를 정의하고, 양쪽에서 런타임 검증을 수행해야 한다. 버전 협상, timeout, 중복 요청, page reload까지 포함해야 bridge가 임시 callback 모음이 아니라 유지 가능한 프로토콜이 된다.
목차
- #postMessage만 연결하면 계약이 사라진다
- #먼저 메시지 종류를 분리하기
- #공통 Envelope 설계하기
- #응답과 오류도 같은 계약에 넣기
- #Web에서 Promise 기반 Client 만들기
- #Native에서 Decode와 Dispatch를 분리하기
- #양쪽 모두 런타임 검증이 필요하다
- #버전은 숫자를 붙이는 것보다 호환 정책이 중요하다
- #Handshake로 Capability를 협상하기
- #Timeout과 취소는 서로 다른 기능이다
- #중복 요청과 Replay를 안전하게 처리하기
- #Event와 Request를 섞지 않기
- #Origin과 Frame 검증을 프로토콜 앞에 두기
- #Protocol Test와 Fixture 운영하기
- #관측 가능한 Bridge 만들기
- #구현 체크리스트
- #마무리
- #관련 노트
- #참고 자료
postMessage만 연결하면 계약이 사라진다
WebView와 Native가 처음 통신할 때는 다음 정도로 시작하기 쉽다.
window.NativeBridge.postMessage(
JSON.stringify({
action: "openCamera",
}),
);
Native는 action 문자열을 보고 분기한다.
switch (message['action']) {
case 'openCamera':
return openCamera();
}
기능이 하나일 때는 충분해 보인다. 하지만 곧 optional field와 callback 이름이 추가된다.
window.NativeBridge.postMessage(
JSON.stringify({
action: "openCamera",
callback: "onCameraResult",
max: "3",
source: "profile",
}),
);
여기에는 답하기 어려운 질문이 생긴다.
max는 문자열인가 숫자인가- 사용자가 취소하면 callback을 호출하는가
- 권한 거절과 camera 고장을 어떻게 구분하는가
- 같은 callback 이름으로 요청 두 개를 보내면 어떻게 되는가
- 구버전 앱이
source를 모르면 무시해도 되는가 - page reload 뒤 과거 callback을 실행해도 되는가
TypeScript type을 선언해도 Native가 그 type을 공유하지 않으며, 외부 script가 잘못된 객체를 보낼 수도 있다. WebView 경계에서는 모든 값이 런타임 입력이다.
정상 요청 모양만 문서화해서는 부족하다. 성공·실패·취소·timeout·알 수 없는 버전·중복 request ID까지 양쪽이 같은 의미로 해석해야 한다.
이 글의 예제는 특정 프로젝트에서 가져온 실제 메시지가 아니라 photo picker와 app 설정 화면을 가정해 재구성했다.
먼저 메시지 종류를 분리하기
bridge 메시지를 세 종류로 나눈다.
| 종류 | 방향 | 응답 | 예 |
|---|---|---|---|
| Request | Web → Native | 정확히 하나 | PHOTO_PICKER_OPEN |
| Response | Native → Web | request ID에 연결 | 성공, 거절, 취소 |
| Event | Native → Web | 없음 | APP_LIFECYCLE_CHANGED |
Native가 Web 함수를 문자열 이름으로 호출하는 callback 방식보다 response envelope을 Web의 중앙 dispatcher로 전달하는 편이 안전하다.
request(req-42) → native operation → response(req-42)
event에는 request ID를 붙이지 않는다. request 결과와 상태 broadcast가 섞이면 같은 event를 어느 Promise가 소비해야 할지 불분명해진다.
공통 Envelope 설계하기
요청 envelope을 다음처럼 정한다.
{
"protocolVersion": 1,
"kind": "request",
"type": "PHOTO_PICKER_OPEN",
"requestId": "req-demo-42",
"sessionId": "page-demo-7",
"sentAtEpochMs": 1767225600000,
"payload": {
"selectionLimit": 3,
"mediaTypes": ["image"]
}
}
각 필드의 역할은 다르다.
| 필드 | 역할 |
|---|---|
protocolVersion |
envelope 해석 규칙 |
kind |
request·response·event 구분 |
type |
업무 capability |
requestId |
요청과 응답 연결 |
sessionId |
page reload 전후 instance 구분 |
sentAtEpochMs |
진단과 만료 판단 보조 |
payload |
type별 입력 |
sentAtEpochMs를 보안 nonce처럼 믿지 않는다. Web과 기기 시계는 조작되거나 어긋날 수 있다. 이 값은 latency 진단과 아주 오래된 메시지 거절의 보조 정보다.
TypeScript에서는 discriminated union으로 허용된 요청을 표현한다.
type BridgeRequest =
| BridgeEnvelope<
"PHOTO_PICKER_OPEN",
{
selectionLimit: number;
mediaTypes: Array<"image" | "video">;
}
>
| BridgeEnvelope<
"APP_SETTINGS_OPEN",
Record<string, never>
>;
type BridgeEnvelope<
TType extends string,
TPayload,
> = {
protocolVersion: 1;
kind: "request";
type: TType;
requestId: string;
sessionId: string;
sentAtEpochMs: number;
payload: TPayload;
};
이 type은 Web 개발 중 실수를 줄여 주지만 Native 입력 검증을 대신하지 않는다.
응답과 오류도 같은 계약에 넣기
성공과 실패를 같은 response envelope의 union으로 만든다.
type BridgeResponse<T> =
| {
protocolVersion: 1;
kind: "response";
requestId: string;
sessionId: string;
ok: true;
result: T;
}
| {
protocolVersion: 1;
kind: "response";
requestId: string;
sessionId: string;
ok: false;
error: {
code: BridgeErrorCode;
message?: string;
details?: Record<string, unknown>;
};
};
type BridgeErrorCode =
| "INVALID_MESSAGE"
| "UNSUPPORTED_VERSION"
| "UNSUPPORTED_TYPE"
| "PERMISSION_DENIED"
| "CANCELLED"
| "NOT_AVAILABLE"
| "TIMEOUT"
| "INTERNAL_ERROR";
오류 code는 안정적인 기계용 값이고 message는 선택적인 개발 설명이다. Native exception class나 stack trace를 그대로 details로 보내지 않는다.
{
"protocolVersion": 1,
"kind": "response",
"requestId": "req-demo-42",
"sessionId": "page-demo-7",
"ok": false,
"error": {
"code": "PERMISSION_DENIED",
"details": {
"canOpenSettings": true
}
}
}
사용자 취소를 exception으로 볼지 정상 result로 볼지는 capability마다 달리하지 말고 정책을 정한다. 이 예제에서는 호출 목적을 달성하지 못한 terminal outcome이므로 CANCELLED error code로 통일한다. UI는 이를 오류 toast가 아닌 조용한 종료로 매핑한다.
Web에서 Promise 기반 Client 만들기
호출부가 전역 callback 이름을 관리하지 않게 중앙 client가 pending request를 보관한다.
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (reason: BridgeFailure) => void;
timeoutId: ReturnType<typeof setTimeout>;
};
class WebViewBridgeClient {
private readonly pending = new Map<string, PendingRequest>();
constructor(
private readonly transport: BridgeTransport,
private readonly sessionId: string,
private readonly timeoutMs = 5000,
) {}
request<TResult>(
type: BridgeRequest["type"],
payload: unknown,
): Promise<TResult> {
const requestId = crypto.randomUUID();
const envelope = {
protocolVersion: 1,
kind: "request",
type,
requestId,
sessionId: this.sessionId,
sentAtEpochMs: Date.now(),
payload,
};
return new Promise<TResult>((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pending.delete(requestId);
reject(new BridgeFailure("TIMEOUT"));
}, this.timeoutMs);
this.pending.set(requestId, {
resolve,
reject,
timeoutId,
});
try {
this.transport.send(envelope);
} catch (error) {
clearTimeout(timeoutId);
this.pending.delete(requestId);
reject(new BridgeFailure("NOT_AVAILABLE"));
}
});
}
}
응답을 받을 때는 request ID와 session ID를 모두 확인한다.
handleResponse(raw: unknown): void {
const response = parseBridgeResponse(raw);
if (response.sessionId !== this.sessionId) {
return;
}
const pending = this.pending.get(response.requestId);
if (!pending) {
return;
}
clearTimeout(pending.timeoutId);
this.pending.delete(response.requestId);
if (response.ok) {
pending.resolve(response.result);
} else {
pending.reject(BridgeFailure.fromResponse(response.error));
}
}
WebView가 dispose되거나 page가 unload될 때 pending Promise를 모두 종료한다.
disconnect(): void {
for (const pending of this.pending.values()) {
clearTimeout(pending.timeoutId);
pending.reject(new BridgeFailure("NOT_AVAILABLE"));
}
this.pending.clear();
}
Native에서 Decode와 Dispatch를 분리하기
Flutter handler에서 JSON parse, origin 검사, 업무 실행, response serialization을 한 함수에 넣지 않는다.
Future<void> onJavaScriptMessage(
JavaScriptMessage message,
) async {
final decoded = bridgeDecoder.decode(message.message);
final response = switch (decoded) {
DecodeFailure failure => BridgeResponse.failure(
requestId: failure.requestId,
sessionId: failure.sessionId,
code: failure.code,
),
BridgeRequest request =>
await bridgeDispatcher.dispatch(request),
};
await responseTransport.send(response);
}
dispatcher는 허용한 type을 명시적으로 매핑한다.
final class BridgeDispatcher {
BridgeDispatcher({
required this.photoPicker,
required this.settings,
});
final PhotoPickerCapability photoPicker;
final AppSettingsCapability settings;
Future<BridgeResponse> dispatch(
BridgeRequest request,
) async {
return switch (request.type) {
'PHOTO_PICKER_OPEN' =>
photoPicker.execute(PhotoPickerRequest.parse(request)),
'APP_SETTINGS_OPEN' =>
settings.execute(AppSettingsRequest.parse(request)),
_ => BridgeResponse.failure(
requestId: request.requestId,
sessionId: request.sessionId,
code: 'UNSUPPORTED_TYPE',
),
};
}
}
reflection으로 Native method 이름을 직접 실행하지 않는다. allowlist switch는 반복적으로 보여도 공격 표면과 지원 capability를 명확히 한다.
양쪽 모두 런타임 검증이 필요하다
JSON decode 성공은 유효한 요청이라는 뜻이 아니다. photo picker 요청에는 구체적인 제약이 필요하다.
final class PhotoPickerRequest {
const PhotoPickerRequest({
required this.requestId,
required this.sessionId,
required this.selectionLimit,
required this.mediaTypes,
});
factory PhotoPickerRequest.parse(
BridgeRequest request,
) {
final payload = request.payload;
final limit = payload['selectionLimit'];
final rawMediaTypes = payload['mediaTypes'];
if (limit is! int || limit < 1 || limit > 10) {
throw const BridgeProtocolException(
'INVALID_MESSAGE',
);
}
if (rawMediaTypes is! List ||
rawMediaTypes.any(
(value) => value != 'image' && value != 'video',
)) {
throw const BridgeProtocolException(
'INVALID_MESSAGE',
);
}
return PhotoPickerRequest(
requestId: request.requestId,
sessionId: request.sessionId,
selectionLimit: limit,
mediaTypes: rawMediaTypes.cast<String>(),
);
}
}
Web에서도 Native response를 as PhotoPickerResult로 단언하지 않고 실제 필드를 검사한다. 양쪽 검증은 중복이 아니라 독립적으로 배포되는 두 런타임의 경계다.
검증 항목에는 다음이 포함된다.
- envelope이 object인지
- version과 kind가 지원되는지
- type이 allowlist에 있는지
- request ID와 session ID 형식·길이가 제한되는지
- payload 필수 필드와 타입이 맞는지
- 문자열, 배열, binary 크기가 제한되는지
- 알 수 없는 필드를 허용할지 거절할지
payload 전체 크기도 제한한다. bridge는 대형 파일 전송로가 아니다.
버전은 숫자를 붙이는 것보다 호환 정책이 중요하다
protocolVersion: 1을 넣는 것만으로 versioning이 완성되지는 않는다. 어떤 변경이 호환되는지 정한다.
| 변경 | 같은 버전 가능 여부 | 조건 |
|---|---|---|
| optional response field 추가 | 가능 | 구 client가 무시 |
| 새 request type 추가 | 가능 | capability로 존재 확인 |
| required field 추가 | 어려움 | default나 별도 type 필요 |
| field 타입 변경 | 불가에 가까움 | 새 type/version |
| error code 추가 | 가능 | unknown fallback 필요 |
| 기존 의미 변경 | 불가 | 새 capability version |
예를 들어 PHOTO_PICKER_OPEN의 selectionLimit 의미가 “최대 개수”에서 “정확한 개수”로 바뀐다면 같은 필드로 재사용하지 않는다.
PHOTO_PICKER_OPEN.v1 selectionLimit = 최대 개수
PHOTO_PICKER_OPEN.v2 requiredCount = 정확한 개수
구버전 Native가 새 type을 받으면 UNSUPPORTED_TYPE을 반환하고, Web은 파일 input 같은 fallback을 선택할 수 있어야 한다.
Handshake로 Capability를 협상하기
Web이 로드된 직후 Native와 handshake한다.
{
"protocolRange": {
"min": 1,
"max": 2
},
"sessionId": "page-demo-7",
"capabilities": {
"photo-picker": 2,
"app-settings": 1,
"secure-session": 1
}
}
Web은 앱 버전 문자열로 기능을 추측하지 않는다.
const handshake = await bridge.initialize();
if (handshake.capabilities["photo-picker"] >= 2) {
enableMultipleSelection();
} else {
enableSingleSelectionFallback();
}
Web이 요구하는 최소 protocol과 Native 범위가 겹치지 않으면 bridge 기능을 비활성화하고 업데이트 안내를 표시한다. handshake 자체를 받지 못하면 일반 mobile browser인지 bridge 초기화 실패인지 transport가 구분해 준다.
Timeout과 취소는 서로 다른 기능이다
Web client timeout은 기다리는 Promise를 종료할 뿐 Native photo picker를 자동으로 닫지 않는다.
sequenceDiagram
participant Web
participant Native
participant Picker
Web->>Native: request req-42
Native->>Picker: open
Note over Web: 5초 timeout
Web-->>Web: Promise reject
Picker-->>Native: 8초 뒤 선택 완료
Native-->>Web: response req-42
Web-->>Web: pending 없음, late response 폐기실제 취소가 필요하면 cancel capability를 별도로 둔다.
{
"protocolVersion": 1,
"kind": "request",
"type": "OPERATION_CANCEL",
"requestId": "req-cancel-8",
"sessionId": "page-demo-7",
"payload": {
"targetRequestId": "req-demo-42"
}
}
이미 완료됐거나 OS UI를 programmatically 닫을 수 없는 경우도 결과로 정의한다. timeout 값은 capability마다 다를 수 있다. settings 화면이나 사용자 picker에 짧은 network timeout을 그대로 적용하면 정상 사용을 실패로 만든다.
중복 요청과 Replay를 안전하게 처리하기
Web이 응답을 받기 전에 reload되거나 transport가 재전송하면 같은 request ID가 다시 올 수 있다. Native는 최근 terminal response를 짧게 cache할 수 있다.
final cached = responseCache.find(
sessionId: request.sessionId,
requestId: request.requestId,
);
if (cached != null) {
return cached;
}
if (inFlight.contains(request.requestId)) {
return BridgeResponse.failure(
requestId: request.requestId,
sessionId: request.sessionId,
code: 'DUPLICATE_REQUEST',
);
}
모든 부수효과가 replay 안전한 것은 아니다. share sheet 열기나 결제 시작을 자동 재실행하지 않는다. message timestamp, session ID, one-time operation token, user gesture 여부를 함께 검증한다.
새 session에서 과거 request ID를 보내면 cache 결과를 전달하지 않는다. cache key는 request ID 하나가 아니라 session ID와 함께 구성한다.
Event와 Request를 섞지 않기
앱 lifecycle 변화처럼 Native가 먼저 알리는 값은 event envelope을 사용한다.
{
"protocolVersion": 1,
"kind": "event",
"type": "APP_LIFECYCLE_CHANGED",
"sessionId": "page-demo-7",
"eventId": "evt-demo-19",
"payload": {
"state": "resumed"
}
}
event handler는 새 request Promise를 resolve하지 않는다.
bridgeEvents.on(
"APP_LIFECYCLE_CHANGED",
(event) => {
if (event.payload.state === "resumed") {
staleQueries.invalidate();
}
},
);
event는 놓칠 수 있다는 전제로 설계한다. 중요한 상태는 SESSION_STATE_GET 같은 request로 다시 읽을 수 있어야 한다. event ID는 중복 제거와 진단에 사용할 수 있지만 영구 message queue처럼 가정하지 않는다.
Origin과 Frame 검증을 프로토콜 앞에 두기
형식이 완벽한 악성 메시지도 악성 메시지다. decode하기 전에 현재 WebView navigation이 허용된 HTTPS origin인지 확인한다. 가능한 플랫폼에서는 top frame과 frame origin도 확인한다.
1. 현재 document origin 허용 여부
2. top frame 또는 허용 frame 여부
3. bridge session 활성 여부
4. envelope 크기 제한
5. JSON decode와 schema 검증
6. capability dispatch
외부 페이지를 열 때는 bridge handler를 제거하거나 bridge 없는 별도 WebView를 사용한다. Android의 JavaScript interface는 frame별 origin 확인이 어려운 구성이 있을 수 있으므로, 신뢰하는 content만 로드하고 navigation allowlist를 적용하는 것이 중요하다.
origin이 맞다고 payload를 신뢰하지 않고, payload가 맞다고 origin 검사를 생략하지 않는다. 자세한 책임과 보안 경계는 WebView와 Native의 책임 경계에서 다뤘다.
Protocol Test와 Fixture 운영하기
Web과 Native가 같은 fixture를 읽어 contract를 검증하면 필드 drift를 빨리 찾을 수 있다.
bridge-fixtures/
├── request.photo-picker.valid.v1.json
├── request.photo-picker.invalid-limit.v1.json
├── request.unknown-version.json
├── response.photo-picker.cancelled.v1.json
└── event.lifecycle.resumed.v1.json
Web parser test:
it("rejects a response with a mismatched session", () => {
const response = fixture("response.photo-picker.success.v1");
response.sessionId = "old-page";
expect(() => client.handleResponse(response))
.not.toResolveAnyPendingRequest();
});
Native decoder test:
test('selectionLimit가 범위를 벗어나면 거절한다', () {
final raw = fixture(
'request.photo-picker.invalid-limit.v1.json',
);
expect(
() => decoder.decode(raw),
throwsA(
isA<BridgeProtocolException>().having(
(error) => error.code,
'code',
'INVALID_MESSAGE',
),
),
);
});
필수 matrix는 다음과 같다.
- 최소·최대 지원 version
- 알 수 없는 request type
- 누락·null·잘못된 타입
- 너무 긴 ID와 큰 payload
- 중복 request ID
- 이전 session response
- timeout 뒤 늦은 response
- page reload 중 in-flight operation
- 외부 origin과 iframe 호출
관측 가능한 Bridge 만들기
bridge 장애는 Web console과 Native log 사이에서 사라지기 쉽다. 양쪽에 같은 request ID를 남긴다.
bridge_send type=PHOTO_PICKER_OPEN request_id=req-42 version=1
bridge_dispatch type=PHOTO_PICKER_OPEN request_id=req-42
bridge_result type=PHOTO_PICKER_OPEN request_id=req-42 code=CANCELLED duration_ms=812
bridge_settle type=PHOTO_PICKER_OPEN request_id=req-42 outcome=cancelled
기록할 수 있는 항목:
- protocol version과 capability type
- request ID의 비식별 축약값
- payload byte 크기
- dispatch·Native 작업·전체 왕복 시간
- 안정적인 error code
- current session과 late response 여부
기록하지 않을 항목:
- auth token
- 파일 절대 경로
- 사용자 입력 원문
- 전체 payload와 response
- Native stack trace를 Web 응답으로 전달한 값
오류율만 보지 말고 timeout 후 late response 비율, unsupported capability 비율, session mismatch를 확인하면 version과 lifecycle 문제를 찾을 수 있다.
구현 체크리스트
마무리
postMessage는 데이터를 옮겨 줄 뿐 계약을 만들어 주지 않는다. 유지 가능한 WebView bridge에는 요청·응답·event를 구분하는 envelope, 안정적인 오류 code, 양쪽의 런타임 검증이 필요하다.
protocol version은 장식용 숫자가 아니다. optional field 추가, 새 capability, field 의미 변경을 어떻게 호환할지 정하는 정책이다. 앱과 웹의 배포 시차는 handshake와 capability negotiation으로 다루고, page reload와 timeout은 session ID와 pending request lifecycle로 다룬다.
가장 중요한 것은 bridge를 Native 내부의 범용 실행 통로로 만들지 않는 것이다. 허용한 capability만 명시적으로 dispatch하고 origin·frame·payload를 함께 검증하면, Web과 Native가 독립적으로 발전하면서도 경계의 실패를 예측하고 테스트할 수 있다.
관련 노트
- WebView와 Native의 책임 경계
- WebView 로그인 세션을 안전하게 전달하기
- WebView 뒤로가기와 Native 내비게이션 연결
- 재시도 가능한 API에 Idempotency-Key 적용하기
- API 버전 관리에 URL과 헤더를 사용하는 방법
- OpenAPI 스키마를 계약으로 유지하는 방법
- 구조화 로그에 Request ID 넣기