TCP 3-way Handshake 프로토콜 기반의 웹뷰-네이티브 양방향 통신 라이브러리입니다.
npm install @geongyu/react-native-bridge
# 또는
yarn add @geongyu/react-native-bridgeNative(React Native)와 Web(Next.js 등)에서 각각 다음 peer dependency가 필요합니다.
- Native:
react,react-native,react-native-webview - Web:
react,react-dom
Bridge는 React Native 앱과 웹뷰 간의 안정적이고 타입 안전한 양방향 통신을 제공합니다. 기존 postMessage 방식의 단방향 통신을 넘어, 요청-응답 패턴과 콜백 기반 비동기 처리를 지원합니다.
- 요청-응답 패턴: 메시지 전송 시 콜백을 등록하여 응답을 비동기로 받을 수 있습니다
- 타입 안전성: TypeScript 제네릭을 활용한 완전한 타입 체크를 제공합니다
- 메시지 손실 방지: 3-way Handshake를 통해 웹뷰 준비 상태를 확인하고 안전하게 통신합니다
- 플랫폼 독립성: iOS/Android 자동 감지 및 동일한 API 제공
- 에러 핸들링: Strict mode와 Validator를 통한 견고한 에러 처리
- 통신 관찰(Inspector): 런타임 토글 한 줄로 브리지 트래픽·RTT·타임아웃을 콘솔이나 커스텀 싱크로 관찰 (평상시 오버헤드 0)
React Native 앱에서 웹뷰로 메시지를 전송하고 응답을 받습니다.
import { usePostMessageBridge } from '@geongyu/react-native-bridge/native';
function MapScreen() {
const { ref, postMessage } = usePostMessageBridge();
const requestLocation = () => {
postMessage({
message: { type: 'getCurrentLocation' },
onResponse: (response) => {
console.log('위치:', response);
}
});
};
return (
<View>
<WebView
ref={ref}
source={{ uri: 'https://web.sangyeol.com' }}
/>
<Button title="위치 요청" onPress={requestLocation} />
</View>
);
}또는 WebviewWithBridge 컴포넌트를 사용:
import { WebviewWithBridge } from '@geongyu/react-native-bridge/native';
function MapScreen() {
return (
<WebviewWithBridge
source={{ uri: 'https://web.sangyeol.com' }}
onBridgeMessage={(message) => {
// 웹에서 보낸 요청 처리
if (message.type === 'openCamera') {
return { success: true };
}
}}
onReadyToMessage={() => {
console.log('웹뷰 준비 완료');
}}
/>
);
}Next.js 웹 앱에서 네이티브로 메시지를 전송하고 응답을 받습니다.
'use client';
import { useBridge } from '@geongyu/react-native-bridge/web';
function MapPage() {
const { request } = useBridge();
const openCamera = () => {
request({
requestMessage: { type: 'openCamera' },
responseCallback: (response) => {
console.log('카메라 결과:', response);
},
onErrorCallback: (error) => {
console.error('에러:', error);
}
});
};
return (
<button onClick={openCamera}>
카메라 열기
</button>
);
}웹 앱의 루트 레이아웃에서 네이티브 요청 수신:
// app/layout.tsx
import { BridgeRequestListener } from '@geongyu/react-native-bridge/web';
export default function RootLayout({ children }) {
return (
<html>
<body>
<BridgeRequestListener
onRequest={(message) => {
if (message.type === 'getCurrentLocation') {
const position = await getCurrentPosition();
return { lat: position.lat, lng: position.lng };
}
}}
/>
{children}
</body>
</html>
);
}웹뷰로 메시지를 전송하는 React Hook입니다.
타입 파라미터
ReqType: 요청 메시지의 타입ResType: 응답 메시지의 타입
반환값
{
ref: RefObject<WebView>;
postMessage: (props: PostMessageProps<ReqType, ResType>) => void;
}사용 예시
interface LocationRequest {
type: "getLocation";
}
interface LocationResponse {
lat: number;
lng: number;
}
const { ref, postMessage } = usePostMessageBridge<
LocationRequest,
LocationResponse
>();
postMessage({
message: { type: "getLocation" },
onResponse: (res) => {
console.log(res.lat, res.lng); // 타입 안전
},
});브릿지 기능이 통합된 WebView 컴포넌트입니다.
Props
interface WebViewWithBridgeProps<ReqMessage, ResMessage> {
// 웹으로부터 메시지를 받았을 때 실행될 핸들러
onBridgeMessage?: (
reqMessage: ReqMessage
) => ResMessage | Promise<ResMessage> | void;
// Handshake 완료 후 실행될 콜백
onReadyToMessage?: () => void;
// 메시지 수신 시 실행될 미들웨어
middleware?: (message: ReqMessage) => void;
// 응답이 없을 때 에러를 던질지 여부 (기본값: true)
strictMode?: boolean;
// WebView의 ref
ref?: Ref<WebView>;
// 나머지 WebView props
...WebViewProps
}사용 예시
<WebviewWithBridge<
{ type: string; data?: any },
{ success: boolean; data?: any }
>
source={{ uri: 'https://example.com' }}
onBridgeMessage={async (message) => {
if (message.type === 'getPhoto') {
const photo = await pickImage();
return { success: true, data: photo };
}
}}
onReadyToMessage={() => {
console.log('웹뷰 통신 준비 완료');
}}
strictMode={false}
/>네이티브로 메시지를 전송하는 React Hook입니다.
타입 파라미터
ReqBody: 요청 메시지의 body 타입ResBody: 응답 메시지의 body 타입
반환값
{
request: (props: RequestProps<ReqBody, ResBody>) => void;
}RequestProps
interface RequestProps<ReqBody, ResBody> {
requestMessage: ReqBody;
responseCallback?: (resMessage: ResBody) => void;
onErrorCallback?: (error: Error) => void;
}사용 예시
interface CameraRequest {
type: "openCamera";
options?: {
quality: number;
};
}
interface CameraResponse {
uri: string;
width: number;
height: number;
}
const { request } = useBridge<CameraRequest, CameraResponse>();
request({
requestMessage: {
type: "openCamera",
options: { quality: 0.8 },
},
responseCallback: (res) => {
console.log(res.uri); // 타입 안전
},
onErrorCallback: (err) => {
console.error(err);
},
});네이티브로부터의 요청을 수신하는 컴포넌트입니다.
Props
interface BridgeRequestListenerProps<RequestType, ResponseType> {
// 네이티브로부터 요청을 받았을 때 실행될 핸들러
onRequest: (reqMessage: RequestType) => ResponseType;
// 요청 메시지 유효성 검사 함수
requestValidator?: (reqMessage?: RequestType) => boolean;
// 응답이 없을 때 에러를 던질지 여부 (기본값: false)
strictMode?: boolean;
}사용 예시
<BridgeRequestListener<
{ type: string; data?: any },
{ success: boolean; result?: any }
>
onRequest={(message) => {
switch (message.type) {
case 'getCurrentLocation':
const pos = getCurrentPosition();
return { success: true, result: pos };
case 'getLocalStorage':
const data = localStorage.getItem(message.data.key);
return { success: true, result: data };
default:
return { success: false };
}
}}
requestValidator={(message) => {
return message?.type !== undefined;
}}
strictMode={true}
/>Bridge는 TCP의 3-way Handshake 프로토콜을 모방하여 안정적인 통신 연결을 보장합니다.
Web Native
│ │
├─ SYN ───────────────────────>│ 1. 웹이 연결 요청
│ { syn: 1, ack: null } │
│ │
│<────────────────────── SYN-ACK 2. 네이티브가 수신 확인 및 응답
│ { syn: 1, ack: webId } │
│ │
├─ ACK ───────────────────────>│ 3. 웹이 최종 확인
│ { syn: 0, ack: nativeId } │
│ │
✓ 연결 완료 ✓ 연결 완료
모든 메시지는 다음 구조를 따릅니다:
interface WebviewBridgeMessage<Body> {
_id: string; // 메시지 고유 ID (랜덤 생성)
ack: string | null; // 응답 대상 메시지 ID (응답일 경우)
flag: {
syn: 0 | 1; // SYN 플래그 (1=SET, 0=RESET)
};
body?: Body; // 실제 전달 데이터
}RWindow는 TCP의 Receive Window를 모방한 콜백 관리 시스템입니다.
- 최대 20개의 대기 중인 메시지 추적
- 메시지 ID별 콜백 함수 저장
- 응답 수신 시 해당 콜백 실행 후 자동 정리
// Native에서 메시지 전송
const message = bridge.createMessage({ body: data });
message.send((response) => {
// 콜백이 RWindow에 등록됨
console.log(response);
});
// 웹에서 응답 전송
bridge
.createMessage({
ack: messageId, // RWindow에서 콜백을 찾아 실행
body: responseData,
})
.send();완전한 타입 안전성을 위해 메시지 타입을 명시하세요:
// 메시지 타입 정의
type BridgeMessage =
| { type: "getLocation"; payload?: never }
| { type: "openCamera"; payload: { quality: number } }
| { type: "saveData"; payload: { key: string; value: string } };
type BridgeResponse =
| { type: "getLocation"; data: { lat: number; lng: number } }
| { type: "openCamera"; data: { uri: string } }
| { type: "saveData"; data: { success: boolean } };
// Native
const bridge = usePostMessageBridge<BridgeMessage, BridgeResponse>();
bridge.postMessage({
message: { type: "getLocation" },
onResponse: (res) => {
if (res.type === "getLocation") {
console.log(res.data.lat); // 타입 체크됨
}
},
});
// Web
const { request } = useBridge<BridgeMessage, BridgeResponse>();
request({
requestMessage: { type: "openCamera", payload: { quality: 0.9 } },
responseCallback: (res) => {
if (res.type === "openCamera") {
console.log(res.data.uri); // 타입 체크됨
}
},
});Strict mode는 응답이 없을 때의 동작을 제어합니다.
Native (WebviewWithBridge)
// strictMode: true (기본값) - 에러 throw
<WebviewWithBridge
strictMode={true}
onBridgeMessage={(message) => {
// 응답을 반드시 반환해야 함
return { success: true };
}}
/>
// strictMode: false - 경고만 출력
<WebviewWithBridge
strictMode={false}
onBridgeMessage={(message) => {
// 응답 없어도 에러 발생 안 함 (콘솔 경고만)
if (message.type === 'ping') {
return { pong: true };
}
// undefined 반환 시 경고만
}}
/>Web (BridgeRequestListener)
// strictMode: false (기본값) - 경고만
<BridgeRequestListener
strictMode={false}
onRequest={(message) => {
// 응답 선택적
}}
/>
// strictMode: true - 에러 throw
<BridgeRequestListener
strictMode={true}
onRequest={(message) => {
// 반드시 응답 반환
return { received: true };
}}
/>메시지 수신 시 공통 로직을 실행할 수 있습니다:
<WebviewWithBridge
middleware={(message) => {
// 로깅
console.log('[Bridge] Received:', message);
// 분석
analytics.track('bridge_message', { type: message.type });
// 주의: middleware는 응답을 반환하지 않습니다
// 응답은 onBridgeMessage에서만 처리됩니다
}}
onBridgeMessage={(message) => {
return handleMessage(message);
}}
/>웹에서 요청 메시지의 유효성을 검증할 수 있습니다:
<BridgeRequestListener
requestValidator={(message) => {
// 필수 필드 검증
if (!message?.type) return false;
// 타입 검증
const validTypes = ['getLocation', 'openCamera', 'saveData'];
if (!validTypes.includes(message.type)) return false;
// 페이로드 검증
if (message.type === 'saveData') {
return message.payload?.key !== undefined;
}
return true;
}}
onRequest={(message) => {
// 유효성 검증을 통과한 메시지만 도착
return handleValidMessage(message);
}}
/>onBridgeMessage는 동기/비동기 응답을 모두 지원합니다:
<WebviewWithBridge
onBridgeMessage={async (message) => {
if (message.type === 'getPhoto') {
// 비동기 작업
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 1,
});
if (!result.canceled) {
return {
success: true,
uri: result.assets[0].uri
};
}
return { success: false };
}
// 동기 응답
return { success: true };
}}
/>개발 중 브리지가 언제·어느 방향으로·무엇을 주고받는지, 요청↔응답이 얼마나 걸리는지(RTT), 무엇이 응답 없이 타임아웃되는지를 관찰할 수 있습니다.
- 런타임 토글:
enableBridgeDebug()한 줄로 켜고disableBridgeDebug()로 끕니다. 프로덕션 빌드를 강제로 바꾸지 않습니다. - 평상시 오버헤드 0: 구독자가 하나도 없으면 계측 지점은 즉시 반환합니다. 관찰을 켜지 않으면 성능에 영향이 없습니다.
- 통신을 깨지 않음: 관찰 로직의 예외는 격리되어 브리지 통신에 영향을 주지 않습니다.
- Native/Web 대칭: 동일한 API가 양쪽 엔트리에서 export됩니다. 각 side는 자신의 송신(out)과 수신(in)을 모두 관찰하므로, 한쪽 로그만으로도 대화 전체를 재구성할 수 있습니다.
// Native (React Native)
import { enableBridgeDebug } from '@geongyu/react-native-bridge/native';
// Web (Next.js 등)
import { enableBridgeDebug } from '@geongyu/react-native-bridge/web';가장 간단한 사용법입니다. 개발 빌드에서만 켜세요.
// Native (React Native)
if (__DEV__) {
enableBridgeDebug();
}
// Web (Next.js 등)
if (process.env.NODE_ENV !== 'production') {
enableBridgeDebug();
}
// 끄기
disableBridgeDebug();콘솔에 방향(→ 송신 / ← 수신)·종류(kind)·RTT·body가 출력됩니다:
→ [web] handshake:syn #a1b2
← [web] handshake:syn-ack #s3c4 (rtt 12ms)
→ [web] handshake:ack #b5d6
→ [web] request #c7e8 { type: 'getLocation' }
← [web] response #r9f0 (rtt 34ms) { lat: 37.5, lng: 127 }
→ [web] request:timeout #d1a2 (rtt 1000ms) { type: 'openCamera' } ← 무응답 감지
enableBridgeDebug(options)로 세부 동작을 조절합니다.
interface BridgeDebugOptions {
// 콘솔 대신 원하는 싱크로 이벤트 전달
logger?: (event: BridgeEvent) => void;
// 민감한 body를 로깅 전에 마스킹
redactBody?: (body: unknown, event: BridgeEvent) => unknown;
// 응답이 오지 않는 요청을 감지할 시간(ms). 0이면 비활성화. 기본 1000
timeoutMs?: number;
}커스텀 싱크 — 콘솔 대신 인앱 패널·파일·원격 등 원하는 곳으로 이벤트를 보냅니다.
enableBridgeDebug({
logger: (event) => {
myLogBuffer.push(event);
},
});body 마스킹 — 토큰·개인정보 등 민감 필드를 로깅 전에 가립니다.
enableBridgeDebug({
redactBody: (body) => {
if (body && typeof body === 'object' && 'token' in body) {
return { ...body, token: '***' };
}
return body;
},
});타임아웃 감지 — 지정 시간 내 응답이 없으면 request:timeout 이벤트가 발화합니다.
enableBridgeDebug({ timeoutMs: 3000 }); // 3초 내 무응답이면 경고
enableBridgeDebug({ timeoutMs: 0 }); // 타임아웃 감지 비활성화콘솔 없이 이벤트만 직접 구독합니다. 성능 메트릭 수집, 에러 리포팅, 인앱 오버레이/외부 devtools 같은 커스텀 도구의 기반입니다. 반환값을 호출하면 구독을 해제합니다.
import { onBridgeEvent } from '@geongyu/react-native-bridge/web';
const unsubscribe = onBridgeEvent((event) => {
// 응답 왕복시간을 메트릭으로
if (event.kind === 'response' && event.rttMs != null) {
metrics.timing('bridge.rtt', event.rttMs);
}
// 무응답 타임아웃을 에러로 리포팅
if (event.kind === 'request:timeout') {
reportError(`Bridge timeout: ${event.id}`, event.body);
}
});
// 정리 (예: useEffect의 cleanup)
unsubscribe();
enableBridgeDebug와onBridgeEvent는 독립적입니다. 콘솔 로깅을 켜지 않아도onBridgeEvent만으로 이벤트를 받을 수 있고, 함께 써도 됩니다. 구독자가 하나라도 있으면 관찰이 활성화됩니다.
구독 콜백과 커스텀 logger가 받는 이벤트 형태입니다. 원시 와이어 포맷({ _id, ack, flag, body })을 요청↔응답 상관으로 승격해 의미 있는 이벤트로 만든 것입니다.
interface BridgeEvent {
side: 'native' | 'web'; // 이 이벤트를 관찰한 주체 (엔트리에 따라 고정)
direction: 'out' | 'in'; // out=이 쪽에서 보냄, in=이 쪽에서 받음
kind: BridgeEventKind; // 아래 표 참조
id: string; // 메시지 고유 id (_id)
ack: string | null; // 응답 대상 요청의 _id (요청이면 null)
syn: 0 | 1; // 핸드셰이크 SYN 플래그
body?: unknown; // 실제 페이로드
at: number; // 타임스탬프(ms)
rttMs?: number; // 응답/타임아웃에만: 대응 요청 이후 경과 시간(ms)
raw: WebviewBridgeMessage; // 원본 메시지 전체
}kind |
의미 |
|---|---|
handshake:syn / handshake:syn-ack / handshake:ack |
3-way Handshake의 각 단계 |
request |
요청 전송/수신 |
response |
요청에 대한 응답 (rttMs 포함) |
request:timeout |
timeoutMs 내 응답이 오지 않은 요청 (rttMs는 timeoutMs값) |
unknown |
분류할 수 없는 메시지 |
Handshake가 완료되었는지 확인하세요
// Native
<WebviewWithBridge
onReadyToMessage={() => {
console.log('Handshake 완료 - 이제 메시지 전송 가능');
}}
/>Handshake가 완료되기 전에 전송된 메시지는 무시됩니다. onReadyToMessage 콜백 이후에 메시지를 전송하세요.
어느 단계에서 막혔는지 눈으로 확인하려면 통신 관찰(Inspector)을 켜세요. handshake:syn → handshake:syn-ack → handshake:ack가 순서대로 찍히는지, 요청이 request:timeout으로 끝나는지 바로 보입니다.
enableBridgeDebug();RWindow는 최대 20개의 대기 메시지만 추적할 수 있습니다. 응답을 받지 못한 메시지가 20개 이상 쌓이면 발생합니다.
해결 방법:
- 응답이 필요 없는 메시지는 콜백 없이 전송
- 타임아웃 처리 구현
- 메시지 전송 빈도 조절
어떤 요청이 응답을 못 받고 쌓이는지는 Inspector의 request:timeout 이벤트로 추적할 수 있습니다. onBridgeEvent로 무응답 요청을 모아 원인을 좁혀 보세요.
// 콜백 없이 전송 (RWindow에 등록 안 됨)
message.send();
// 콜백 있이 전송 (RWindow에 등록됨)
message.send((response) => {
console.log(response);
});제네릭 타입을 명시적으로 지정하세요:
// Bad
const { request } = useBridge();
request({ requestMessage: { type: "test" } }); // 타입 any
// Good
interface Request {
type: string;
}
interface Response {
success: boolean;
}
const { request } = useBridge<Request, Response>();
request({
requestMessage: { type: "test" }, // 타입 체크됨
responseCallback: (res) => {
console.log(res.success); // 자동완성 지원
},
});Android는 document.addEventListener('message')를 사용하고, iOS는 window.addEventListener('message')를 사용합니다.
BridgeRequestListener와 useBridge는 이를 자동으로 처리하므로, 직접 리스너를 등록하지 마세요.
Strict mode를 비활성화하거나, 모든 경우에 응답을 반환하세요:
// 옵션 1: Strict mode 비활성화
<WebviewWithBridge strictMode={false} />
// 옵션 2: 모든 경우에 응답 반환
<WebviewWithBridge
onBridgeMessage={(message) => {
switch (message.type) {
case 'ping':
return { pong: true };
default:
return { error: 'Unknown message type' };
}
}}
/>- 최대 대기 메시지: RWindow는 최대 20개의 미응답 메시지를 추적합니다
- Handshake 필수: 웹뷰가 로드되고 Handshake가 완료되어야 통신 가능합니다
- JSON 직렬화: 모든 메시지는 JSON으로 직렬화되므로, 함수나 Symbol은 전달할 수 없습니다
- 단일 WebView: 하나의 Bridge 인스턴스는 하나의 WebView와만 통신합니다
MIT © geongyu