Skip to content

Repository files navigation

Bridge

TCP 3-way Handshake 프로토콜 기반의 웹뷰-네이티브 양방향 통신 라이브러리입니다.

🔗 blog post

설치

npm install @geongyu/react-native-bridge
# 또는
yarn add @geongyu/react-native-bridge

Native(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)

빠른 시작

Native에서 사용하기

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('웹뷰 준비 완료');
      }}
    />
  );
}

Web에서 사용하기

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>
  );
}

API Reference

Native API

usePostMessageBridge<ReqType, ResType>()

웹뷰로 메시지를 전송하는 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); // 타입 안전
  },
});

<WebviewWithBridge />

브릿지 기능이 통합된 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}
/>

Web API

useBridge<ReqBody, ResBody>()

네이티브로 메시지를 전송하는 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);
  },
});

<BridgeRequestListener />

네이티브로부터의 요청을 수신하는 컴포넌트입니다.

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}
/>

아키텍처

TCP 3-way Handshake

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 (Receive Window)

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();

고급 사용법

TypeScript 제네릭 활용

완전한 타입 안전성을 위해 메시지 타입을 명시하세요:

// 메시지 타입 정의
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

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 };
  }}
/>

Middleware 사용

메시지 수신 시 공통 로직을 실행할 수 있습니다:

<WebviewWithBridge
  middleware={(message) => {
    // 로깅
    console.log('[Bridge] Received:', message);

    // 분석
    analytics.track('bridge_message', { type: message.type });

    // 주의: middleware는 응답을 반환하지 않습니다
    // 응답은 onBridgeMessage에서만 처리됩니다
  }}
  onBridgeMessage={(message) => {
    return handleMessage(message);
  }}
/>

Request Validator

웹에서 요청 메시지의 유효성을 검증할 수 있습니다:

<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 };
  }}
/>

디버깅 / 통신 관찰 (Inspector)

개발 중 브리지가 언제·어느 방향으로·무엇을 주고받는지, 요청↔응답이 얼마나 걸리는지(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';

콘솔 로깅 — enableBridgeDebug() / disableBridgeDebug()

가장 간단한 사용법입니다. 개발 빌드에서만 켜세요.

// 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' }   ← 무응답 감지

옵션 — 커스텀 싱크 / body 마스킹 / 타임아웃

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 });    // 타임아웃 감지 비활성화

저수준 이벤트 구독 — onBridgeEvent()

콘솔 없이 이벤트만 직접 구독합니다. 성능 메트릭 수집, 에러 리포팅, 인앱 오버레이/외부 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();

enableBridgeDebugonBridgeEvent는 독립적입니다. 콘솔 로깅을 켜지 않아도 onBridgeEvent만으로 이벤트를 받을 수 있고, 함께 써도 됩니다. 구독자가 하나라도 있으면 관찰이 활성화됩니다.

BridgeEvent 모델

구독 콜백과 커스텀 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 내 응답이 오지 않은 요청 (rttMstimeoutMs값)
unknown 분류할 수 없는 메시지

문제 해결

메시지가 전달되지 않아요

Handshake가 완료되었는지 확인하세요

// Native
<WebviewWithBridge
  onReadyToMessage={() => {
    console.log('Handshake 완료 - 이제 메시지 전송 가능');
  }}
/>

Handshake가 완료되기 전에 전송된 메시지는 무시됩니다. onReadyToMessage 콜백 이후에 메시지를 전송하세요.

어느 단계에서 막혔는지 눈으로 확인하려면 통신 관찰(Inspector)을 켜세요. handshake:synhandshake:syn-ackhandshake:ack가 순서대로 찍히는지, 요청이 request:timeout으로 끝나는지 바로 보입니다.

enableBridgeDebug();

"RWND_BUFFER is already full" 에러

RWindow는 최대 20개의 대기 메시지만 추적할 수 있습니다. 응답을 받지 못한 메시지가 20개 이상 쌓이면 발생합니다.

해결 방법:

  • 응답이 필요 없는 메시지는 콜백 없이 전송
  • 타임아웃 처리 구현
  • 메시지 전송 빈도 조절

어떤 요청이 응답을 못 받고 쌓이는지는 Inspectorrequest: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에서 메시지가 수신되지 않아요

Android는 document.addEventListener('message')를 사용하고, iOS는 window.addEventListener('message')를 사용합니다.

BridgeRequestListeneruseBridge는 이를 자동으로 처리하므로, 직접 리스너를 등록하지 마세요.

응답이 없는데 에러가 발생해요

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

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages