들어가며

따숨(DDASOOM)은 React Native 앱 안에 Next.js 웹을 WebView로 임베드해 사용하는 하이브리드 구조의 서비스다.

앱을 테스트하는 과정에서 Access Token 만료로 갑자기 로그아웃되거나 화면이 멈추는 문제가 있었다.

일반적인 웹 애플리케이션이라면 Refresh Token으로 새 토큰을 받아 자동으로 갱신하면 되지만 WebView 구조에서는 이 방식이 그대로 동작하지 않았다.

그 원인과 해결 과정을 다음과 같은 순서로 정리해 보았다.

  1. WebView에서 앱 토큰 접근 불가 → postMessage 기반 요청-응답 구조
  2. 동시 요청으로 인한 토큰 갱신 충돌 → Promise 공유 패턴
  3. 토큰 갱신 실패 후 로그인 화면 미전환 → LOGOUT 메시지 위임

참고로 이 프로젝트에서 React Native 앱과 Next.js 웹을 나누어 담당했고, 웹(Next.js) 부분을 맡아 WebView에서 앱과 통신하는 로직과 토큰 갱신 구조를 구현했다. 메시지 프로토콜({ title, content } 구조, 메시지 타입 명세)은 앱 담당 팀원과 합의해 설계했다. 따라서 이 글은 웹 기준 구현을 중심으로 설명하고, 앱 측 코드는 흐름 이해에 필요한 범위만 다룬다.


문제 1: WebView에서 앱 토큰 접근 불가

WebView는 앱과 다른 JavaScript 런타임에서 동작한다

React Native WebView는 앱 내부에서 웹 페이지를 렌더링하는 컴포넌트다.
웹 코드는 WebView 안에서 실행되고, 앱 코드는 React Native 런타임에서 실행된다. 즉 앱과 웹은 서로 다른 JavaScript 런타임에서 동작하기 때문에 직접적인 함수 호출이나 변수 접근이 불가능하다.

따숨 앱에서는 토큰을 React Native 앱 내부 상태(네이티브 레이어) 에서 관리하고 있었는데, 웹에서는 접근할 수 없어 결과적으로 토큰이 만료되어도 웹은 스스로 토큰을 갱신할 수 없는 구조였다.

WebView는 웹 페이지를 렌더링한다는 점에서 iframe과 비슷해 보이지만 동작 위치가 다르다.

  • iframe : 브라우저 안에서 실행되는 웹 기술
  • WebView : 네이티브 앱 내부에서 웹 엔진을 띄우는 컴포넌트

iframe은 동일한 브라우저 JavaScript 환경을 공유하지만, WebView는 앱 런타임과 웹 런타임이 분리되어 있다.

해결 전략: postMessage 기반 브릿지 통신

대안 검토

  1. URL 파라미터
  • 구현은 가장 단순하지만 URL은 브라우저 히스토리, 서버 로그, 네트워크 로그 등에 그대로 남는다.
  • 특히 JWT는 탈취되면 서버에서 즉시 무효화하기 어렵기 때문에 URL에 노출하는 방식은 위험하다고 판단했다.
  1. localStorage
  • 웹 애플리케이션에서는 가장 일반적으로 사용되는 방식이지만 WebView의 localStorage웹 런타임에만 존재하는 저장소라 앱과 공유되지 않는다.
  • React Native 쪽에서 injectJavaScriptevaluateJavaScript로 localStorage를 직접 조작하는 방법도 있지만 웹 내부 구현에 앱 코드가 의존하게 되어 결합도가 지나치게 높아진다.
  1. Deep Link
  • 토큰 발급 후 Deep Link로 웹을 다시 여는 방식도 가능하지만 웹 → 앱 → 다시 웹처럼 화면 전환이 발생해 토큰 갱신 과정에서 UX가 끊기는 문제가 있다.

여러 방법을 검토한 결과 React Native WebView에서 웹과 앱 사이의 통신을 위해 제공하는 postMessage 브릿지를 사용하기로 했다.
이 방식은 웹에서 메시지를 보내고 앱이 이를 처리한 뒤 다시 응답하는 구조를 만들 수 있어 앱에서 토큰을 관리하면서 필요할 때 웹에 전달할 수 있다.

postMessage 기반 메시지 프로토콜

react-native-webview의 postMessage는 문자열만 전달할 수 있기 때문에 객체 데이터는 JSON.stringify()로 직렬화해 전달한다.

따숨에서는 GPS, 녹음 제어, 호흡 설정, 비상연락처 관리 등 여러 기능이 WebView 통신을 사용하고 있었다.
메시지 종류가 늘어나면서 단순 문자열만으로는 구분하기 어려웠고, 추가 데이터를 함께 전달하기도 힘들어졌다.

그래서 Envelope 패턴을 적용해 메시지를 { title, content } 형태로 감싸 전달하고, title 값을 기준으로 메시지 종류를 구분하도록 했다.

// 웹 → 앱
{ title: "GETTOKEN", content: null }
 
// 앱 → 웹
{ title: "TOKEN", content: { token, userId, userName } }
title방향설명
GETTOKEN웹 → 앱토큰 요청
TOKEN앱 → 웹토큰 응답
const MESSAGE_TYPES = {
  GETTOKEN: "GETTOKEN",
  TOKEN: "TOKEN",
} as const;

구현 과정

앞에서 정의한 메시지 프로토콜을 기반으로 웹이 토큰을 요청하면 앱이 응답하는 구조를 구현했다.

1. 앱: 토큰 응답 처리

앱에서는 WebView의 onMessage 이벤트로 웹에서 보낸 메시지를 수신한다.
웹이 GETTOKEN 메시지를 보내면 앱 내부 상태에서 토큰을 읽어 WebView로 전달한다.

const useSendToken = (webViewRef: React.RefObject<WebViewType>) => {
  const { token, userId, userName } = useAuthStore();
 
  const sendMessageToWeb = () => {
    const data = JSON.stringify({
      title: "TOKEN",
      content: { token, userId, userName },
    });
 
    webViewRef.current?.injectJavaScript(`window.postMessage(${data});`);
  };
 
  return sendMessageToWeb;
};

injectJavaScript에 전달한 문자열은 WebView 내부 JavaScript 컨텍스트에서 실행된다. 즉 앱 코드가 웹 환경에서 실행될 스크립트를 직접 주입하는 방식이다.

앱의 메시지 핸들러는 다음과 같이 동작한다.

const handleMessage = async (event: WebViewMessageEvent) => {
  const { title } = JSON.parse(event.nativeEvent.data);
 
  if (title === "GETTOKEN") {
    sendTokenToWeb();
  }
};

웹이 GETTOKEN 메시지를 보내면 앱은 토큰을 읽어 TOKEN 메시지로 응답한다.

2. 웹: 토큰 요청을 Promise로 래핑

WebView의 postMessage 통신은 이벤트 기반 비동기 구조로, 메시지를 보내면 응답이 언제 올지 알 수 없다.

토큰 요청은 "토큰 요청 → 토큰 수신 → 이후 API 요청 진행" 과정이 필요하기 때문에 메시지 응답을 Promise로 감싸 이 흐름을 유지하도록 했다.

export const requestTokenFromApp = async (): Promise<string> => {
  return new Promise((resolve, reject) => {
    const handleMessage = (event: MessageEvent) => {
      const messageData =
        typeof event.data === "string" ? JSON.parse(event.data) : event.data;
 
      // ② 앱 → TOKEN 메시지 응답 수신
      if (messageData.title === "TOKEN") {
        const { token } = messageData.content;
 
        store.dispatch(setAuthData({ token }));
        window.removeEventListener("message", handleMessage);
 
        // ③ Promise resolve → 이후 API 요청 진행
        resolve(token);
      }
 
      // TOKEN이 아닌 다른 메시지가 들어오면 실패 처리
      else {
        window.removeEventListener("message", handleMessage);
        reject(new Error("토큰 요청 실패"));
      }
    };
 
    // TOKEN 메시지를 기다리기 위해 이벤트 리스너 등록
    window.addEventListener("message", handleMessage);
 
    // ① 웹 → 앱 : 토큰 요청
    window.ReactNativeWebView.postMessage(
      JSON.stringify({ title: "GETTOKEN", content: null }),
    );
  });
};

② 단계에서 TOKEN 메시지만 처리하는데, 따숨에서는 10 종류 이상의 WebView 메시지를 사용하고 있었기 때문에 토큰 요청을 기다리는 동안 다른 메시지가 들어오면 토큰 요청이 실패하는 문제가 발생했다.

이 문제는 다음 섹션에서 해결한다.

3. Axios 인터셉터에 통합

토큰 요청 로직은 Axios 인터셉터에 연결하여 모든 API 요청에서 토큰 주입과 재발급을 자동으로 처리하도록 했다.

요청 인터셉터에서는 Redux Toolkit 스토어에 저장된 토큰을 읽어 Authorization 헤더에 주입한다.

axiosInstance.interceptors.request.use((config) => {
  const { token } = store.getState().auth;
 
  if (token) {
    config.headers["Authorization"] = `Bearer ${token}`;
  }
 
  return config;
});

응답 인터셉터에서는 401 Unauthorized가 발생하면 토큰을 다시 요청한 후 원래 요청을 재시도한다.

axiosInstance.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;
 
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
 
      const newToken = await requestTokenFromApp();
      originalRequest.headers["Authorization"] = `Bearer ${newToken}`;
 
      return axiosInstance(originalRequest);
    }
 
    return Promise.reject(error);
  },
);

_retry 플래그는 동일 요청이 계속 401을 반환할 때 무한 재시도 루프에 빠지는 것을 방지하기 위해 사용했다.

Axios 인터셉터는 React 컴포넌트 외부에서 실행된다. 때문에 훅 기반 상태 관리(Zustand, Jotai 등)는 인터셉터 내부에서 직접 사용할 수 없다.

Redux Toolkitstore.getState()store.dispatch()를 모듈 레벨에서 호출할 수 있어 컴포넌트 트리 밖에서도 상태를 읽고 갱신할 수 있다.

4. 토큰 재발급 전체 흐름

이 구조를 통해 웹은 토큰을 직접 관리하지 않고도 필요할 때마다 앱에서 토큰을 받아 API 요청을 재시도할 수 있었다.


문제 2: 동시 요청으로 인한 토큰 갱신 충돌

파생된 두 가지 문제

① 토큰 갱신 중복 요청 발생

각 401 응답마다 requestTokenFromApp()이 독립적으로 호출되었기 때문에 앱으로 GETTOKEN 메시지가 여러 번 전송되었다.

② 토큰 요청 과정에서 WebView 메시지 충돌

토큰 응답을 기다리는 동안 BREATH, GPS 등 다른 WebView 메시지가 먼저 도착하면 requestTokenFromApp의 메시지 핸들러가 이를 실패로 처리하면서 진행 중이던 토큰 요청이 종료되는 문제가 발생했다.

_retry 플래그는 동일 요청의 무한 재시도를 막는 역할만 할 뿐 이러한 메시지 충돌 문제 자체를 해결하지는 못했다.

해결 1: Promise 공유 패턴

여러 요청이 동시에 401을 반환하더라도 실제 토큰 갱신은 한 번만 수행되어야 한다.

이를 위해 토큰 갱신 중임을 나타내는 tokenRefreshPromise를 두고, 첫 번째 요청만 실제 토큰 갱신을 수행하도록 했다. 나머지 요청들은 해당 Promise가 완료될 때까지 기다리는 구조다.

let tokenRefreshPromise: Promise<string> | null = null;
 
axiosInstance.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;
 
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
 
      // 이미 갱신 중이면 같은 Promise를 기다림
      if (!tokenRefreshPromise) {
        tokenRefreshPromise = requestTokenFromApp().finally(() => {
          tokenRefreshPromise = null;
        });
      }
 
      const newToken = await tokenRefreshPromise;
      originalRequest.headers["Authorization"] = `Bearer ${newToken}`;
 
      return axiosInstance(originalRequest);
    }
 
    return Promise.reject(error);
  },
);

동시 요청을 제어하기 위해 mutex/lock이나 refresh queue 같은 방식도 사용할 수 있다. 하지만 이 프로젝트에서는 별도의 큐나 동기화 객체를 도입하지 않아도 동일한 효과를 낼 수 있는 Promise를 공유하는 방식이 더 적합하다고 판단했다.

  • mutex / lock: 토큰 갱신 로직을 하나의 임계 구역(critical section)으로 두고, 이미 작업이 진행 중이면 다른 요청들은 락이 해제될 때까지 대기하도록 만드는 방식

  • refresh queue: 토큰 갱신이 진행되는 동안 실패한 요청들을 큐에 저장해 두었다가, 새 토큰이 발급되면 순서대로 다시 실행하는 방식

해결 2: 메시지 핸들러 안정성 개선

requestTokenFromApp의 메시지 핸들러를 수정해 TOKEN 메시지가 아닌 경우에는 무시하고 계속 대기하도록 처리했다.

또한 앱이 응답하지 않는 상황을 대비해 토큰 요청에 5초 타임아웃을 추가했다.

위 두 가지 개선을 적용한 토큰 갱신 흐름은 다음과 같다.


문제 3: 토큰 갱신 실패 후 로그인 화면 미전환

앱에서 토큰을 더 이상 제공하지 않는 상황일 땐 TOKEN 메시지가 전달되지 않는다.
웹은 requestTokenFromApp의 응답을 기다리다가 타임아웃으로 Promise가 reject된다.

하지만 인터셉터에서는 requestTokenFromApp이 실패하면 단순히 에러를 던질 뿐 별도의 복구 로직이 없었기 때문에 사용자 입장에서는 API 요청이 실패한 채 화면이 멈춰 보였다.

해결: LOGOUT 메시지로 앱에 위임

토큰 갱신이 실패하면 웹이 직접 라우팅하지 않고 로그아웃 처리를 앱에 위임하도록 수정했다.

try {
  const newToken = await tokenRefreshPromise;
  originalRequest.headers["Authorization"] = `Bearer ${newToken}`;
  return axiosInstance(originalRequest);
} catch (refreshError) {
  // 웹 인증 상태 초기화
  store.dispatch(clearAuthData());
 
  // 앱에 로그아웃 요청
  window.ReactNativeWebView?.postMessage(
    JSON.stringify({ title: "LOGOUT", content: null }),
  );
 
  return Promise.reject(refreshError);
}

앱의 messageHandler에는 LOGOUT 메시지를 수신하면 로그아웃 처리 후 로그인 화면으로 이동하는 로직이 이미 구현되어 있었다. 웹에서는 메시지만 보내면 로그인 복구 흐름이 완성되었다.


마치며

웹앱을 처음 구현했던 프로젝트라 WebView처럼 런타임이 분리된 환경에서 인증을 어떻게 처리해야 하는지 여러 시행착오를 겪었다.

토큰 인증과 갱신은 기본적인 기능처럼 보이지만, 실제로는 생각보다 많은 상황을 고려해야 했다. 특히 토큰 갱신이 실패하거나 지연될 경우 바로 사용자 경험에 영향을 줄 수 있다는 점을 느낄 수 있었다.

이 프로젝트는 2024년에 진행했던 작업인데, 글을 정리하는 과정에서 2025년에 공개된 올리브영 테크 블로그 글을 보게 되었다. iframe 환경에서 토큰 요청이 많았던 사례였는데 통신 채널은 다르지만 문제의 구조는 꽤 비슷했다. 이런 경험을 그때 미리 정리해 두었다면 다른 개발자에게 도움이 되었을 텐데 하는 아쉬움이 남았다.

참고로 해당 글도 함께 첨부해 두었다. 비슷한 구조를 다루고 있다면 이 글과 함께 참고해 보면 좋겠다.

참고