WebView 앱에서 로그인 세션의 주인을 다시 정하기 글 표지
React NativeWebViewAuthenticationEncryptedStorageToken RefreshSecurity

WebView 앱에서 로그인 세션의 주인을 다시 정하기

access-token-only로 동작하던 WebView 앱에 refresh session을 도입했습니다. 세션의 원본을 네이티브에 두고 동시 401과 저장 경합을 정리한 기록입니다.

매머드커피 점주앱은 React Native 셸에서 Next.js 업무 화면을 여는 구조입니다. 네이티브가 푸시와 딥링크, 권한, 앱 시작을 맡고 매출·주문·마케팅처럼 자주 바뀌는 기능은 WebView에 둡니다. 화면마다 별도의 WebView를 사용하므로 로그인 상태는 앱 전체에서 같아야 하지만 각 웹 문서의 메모리는 공유되지 않습니다.

기존 인증은 access token 하나로 동작했습니다. 로그인 뒤 토큰을 네이티브의 일반 저장소에 넣었고 일부 화면에서는 응답 헤더로 받은 새 access token을 교체했습니다. 인증 처리가 화면별 callback에 흩어져 있었고 실패한 원 요청을 다시 실행하는 규칙도 제각각이었습니다. WebView URL이나 진단 로그에 토큰이 남을 만한 경로도 걷어내야 했습니다.

서버가 access token과 refresh token을 함께 발급하기 시작하자 저장 위치만 추가해서는 해결되지 않았습니다. 여러 WebView가 동시에 401을 받으면 누가 refresh를 시작할지, 회전된 token은 어디에 저장할지, 로그아웃과 갱신이 겹치면 어느 결과를 남길지부터 정해야 했습니다. 암호화 저장소를 고르는 일보다 세션의 주인을 정하는 일이 먼저였습니다.

refresh token을 새 네이티브 세션에 둔 이유

웹 저장소에 있던 refresh token을 네이티브로 이전한 작업은 아닙니다. 이전 방식에는 refresh token 자체가 없었습니다. 새 인증에서 생긴 refresh session의 영속 저장과 갱신을 처음부터 네이티브 책임으로 정했습니다.

로그인 응답은 WebView의 JavaScript가 받습니다. 이때 refresh token이 웹 메모리에 잠깐 머뭅니다. 응답을 검증한 직후 bridge message로 네이티브에 넘기고 저장 완료 응답을 기다렸으며, localStorage나 URL에는 남기지 않았습니다. 그다음 갱신부터는 네이티브가 refresh token을 사용하고 웹에는 새 access token만 돌려줍니다.

그래서 “refresh token이 WebView에 한 번도 노출되지 않는다”라고 말할 수는 없습니다. 첫 로그인에서 생기는 짧은 노출을 감추기보다, 이후 여러 웹 문서가 refresh token을 계속 보관하고 사용하는 구조를 없애는 데 초점을 맞췄습니다.

새 세션의 원본은 네이티브에 두었다

웹은 로그인 화면과 업무 API 요청을 처리하고 access token을 메모리에 둡니다. bridge는 version marker로 protocol을 판별하고 requestId로 요청과 응답을 연결합니다. session 저장·갱신·삭제 책임은 네이티브 auth layer에 모았습니다.

access token과 refresh token, 만료 시각을 묶은 session 원본은 EncryptedStorage에 저장했습니다. 기존 네이티브 화면과 호환하려고 access token은 일반 저장소에도 mirror했습니다. V2 정상 상태에서는 암호화 session을 원본으로 삼고, 이관 기간에는 fallback 표식이 있는 legacy session만 예외로 인정했습니다.

웹은 업무 요청에 access token을 사용하고 네이티브가 refresh token과 세션 원본을 관리하는 책임 분리도
웹은 인증이 필요한 일을 요청하고, 세션의 영속 저장과 갱신은 네이티브가 맡습니다.

React Native 공식 문서에서도 Async Storage는 암호화되지 않은 저장소이며 token을 보관할 곳으로 적합하지 않다고 설명합니다. refresh token처럼 수명이 긴 값을 네이티브 암호화 저장소에 둔 근거였습니다.

로그인 성공은 저장이 끝난 뒤에만 인정했다

로그인 API가 성공하자마자 메인 화면으로 이동하면 네이티브 저장이 실패했을 때 화면과 실제 session이 어긋납니다. 당장은 로그인된 것처럼 보여도 앱을 다시 열면 session이 없거나 access token만 남고 refresh token은 사라질 수 있습니다.

저장 상태를 pendingcommitted로 나눈 이유입니다. 암호화 저장소에 pending session을 먼저 쓰고 access token mirror와 로그인 metadata를 차례로 갱신합니다. 전부 끝난 뒤에야 같은 session을 committed로 바꿉니다. 읽을 때는 committed session만 정상 값으로 인정했습니다.

아래 코드는 사내 저장 키와 부가 정보를 뺀 개념 예시입니다. 서로 다른 저장소를 하나로 묶는 원자적 transaction은 아닙니다. 중간에 멈춘 상태를 알아보고 다음 실행에서 정리하기 위한 commit protocol에 가깝습니다.

interface AuthSession {
  accessToken: string;
  refreshToken: string;
  expiresAt: number | null;
}
 
interface StoredSession {
  state: 'pending' | 'committed';
  session: AuthSession;
}
 
async function commitLogin(session: AuthSession): Promise<void> {
  await secureStorage.write({ state: 'pending', session });
 
  try {
    await writeAccessTokenMirror(session.accessToken);
    await persistLoginMetadata();
    await secureStorage.write({ state: 'committed', session });
  } catch (error: unknown) {
    await clearLocalSession();
    throw error;
  }
}

웹은 네이티브의 성공 응답을 받기 전까지 로그인을 끝내지 않습니다. 저장이 실패하면 로그인 화면에 그대로 남아 다시 시도합니다. 성공 응답과 화면 전환도 message queue에서 순서대로 처리해 저장 message보다 navigation이 먼저 실행되지 않게 했습니다.

로그인 세션을 pending으로 저장한 뒤 관련 정보를 기록하고 committed로 확정하는 흐름
로그인 API 응답이 아니라 네이티브 저장 완료를 로그인 성공의 기준으로 삼았습니다.

동시 401은 refresh 한 번으로 모았다

화면 하나를 처음 열 때도 API 여러 개가 함께 실행됩니다. 이 앱에서는 서로 다른 WebView가 같은 시점에 401을 받을 수도 있습니다. 요청마다 refresh를 보내면 회전된 token끼리 경쟁하고, 늦게 끝난 응답이 더 새로운 session을 덮어쓸 수 있습니다.

웹의 Axios interceptor는 업무 API에서 처음 받은 401만 refresh 대상으로 삼았습니다. 로그인·refresh·logout endpoint와 잘못된 로그인 정보로 발생한 401은 제외합니다. 원 요청도 최대 한 번만 다시 실행합니다. 같은 WebView 안에서는 여러 요청이 하나의 Promise를 기다리고, 네이티브 singleton manager가 다른 WebView에서 들어온 요청까지 다시 한 번 모읍니다.

여기서 single-flight만 구현하면 끝날 줄 알았지만 한 가지 경합이 남았습니다. refresh가 진행되는 동안 사용자가 로그아웃하거나 새로 로그인할 수 있습니다. session 변경은 하나의 queue에서 처리하고 login·logout 때 generation을 올렸습니다. refresh가 기억한 generation과 현재 값이 다르면 늦게 도착한 결과로 보고 버립니다.

아래 refreshOnce 예시의 generation 검사는 로그아웃 전에 이미 시작된 refresh 결과가 늦게 저장되는 일을 막습니다. 로그아웃 뒤에 들어온 새 refresh 요청을 별도로 차단하는 gate는 이 예시에 포함되어 있지 않습니다.

let generation = 0;
interface RefreshFlight {
  generation: number;
  promise: Promise<AuthSession>;
}
 
let refreshInFlight: RefreshFlight | null = null;
 
async function refreshOnce(): Promise<AuthSession> {
  const requestedGeneration = generation;
  if (refreshInFlight?.generation === requestedGeneration) {
    return refreshInFlight.promise;
  }
 
  const promise = refreshSession().then((nextSession) =>
    enqueueSessionMutation(async () => {
      if (generation !== requestedGeneration) {
        throw new Error('STALE_REFRESH');
      }
 
      await saveSession(nextSession);
 
      if (generation !== requestedGeneration) {
        throw new Error('STALE_REFRESH');
      }
      return nextSession;
    })
  );
  refreshInFlight = { generation: requestedGeneration, promise };
 
  try {
    return await promise;
  } finally {
    if (refreshInFlight?.promise === promise) {
      refreshInFlight = null;
    }
  }
}
 
async function logout(): Promise<void> {
  generation += 1;
  await enqueueSessionMutation(clearSession);
}
여러 WebView의 동시 401 요청을 네이티브에서 하나의 refresh로 합치고 원 요청을 한 번씩 다시 실행하는 흐름
여러 요청은 같은 refresh 결과를 기다리며, 로그아웃 전에 시작된 refresh의 늦은 결과는 버립니다.

bridge message도 권한이 필요한 입력으로 봤다

window.ReactNativeWebView.postMessage로 들어온 문자열은 네이티브 함수 호출로 이어집니다. message 모양만 검사해서는 부족했습니다. 앱 안에는 외부 링크를 여는 WebView도 있고 내부 페이지가 다른 origin으로 이동할 가능성도 있었습니다.

session을 다루는 message는 현재 문서가 허용된 내부 origin인지 먼저 확인합니다. 내부의 최상위 문서에만 WebView instance별 random nonce를 가진 wrapper를 주입했고, 네이티브는 같은 nonce로 감싼 payload만 처리했습니다. 외부 WebView에는 wrapper를 넣지 않습니다. nonce를 만들지 못했을 때도 bridge를 닫힌 상태로 뒀습니다. URL은 사용자 정보가 섞이거나 authority 형식이 잘못된 경우를 거부하도록 따로 파싱했습니다.

nonce가 message를 암호학적으로 인증하거나 XSS를 막는 것은 아닙니다. 신뢰한 내부 페이지 자체가 공격당하면 그 안의 JavaScript도 wrapper를 사용할 수 있습니다. 이 장치는 외부 문서나 iframe이 session bridge를 그대로 호출하지 못하게 범위를 좁힙니다.

각 요청에는 requestId와 timeout을 붙였습니다. 네이티브가 응답하지 않거나 형식이 틀리면 대기 중인 Promise를 정리하고 오류로 끝냅니다. token과 전체 bridge payload는 console, Sentry breadcrumb, analytics에 남기지 않았습니다.

Splash 경로에서는 session 복원을 먼저 끝냈다

일반적인 앱 시작 경로에서는 Splash가 committed session을 읽어 구조와 만료 시각을 확인합니다. 이미 만료됐다면 메인 화면으로 이동하기 전에 refresh부터 시도합니다. 서버가 인증을 거절하면 session을 지우고 로그인으로 이동합니다. 단순히 네트워크가 끊긴 경우에는 refresh token을 없애지 않고 session을 보존한 채 재시도 화면을 보여줍니다.

웹 역시 네이티브 상태를 받기 전에는 하위 페이지를 렌더링하지 않습니다. bridge가 준비 중이거나 웹 메모리의 access token이 네이티브 snapshot과 다르면 gate를 유지합니다. 그렇지 않으면 page hook과 listener가 먼저 실행돼 빈 token으로 API를 호출하거나, 정상 session이 있는데도 로그인 화면이 잠깐 나타납니다.

기존 access-token-only 사용자는 새 session이 없어서 한 차례 다시 로그인합니다. 이관이 중간에 끊겨 앱을 열 때마다 로그아웃되는 일을 막으려고 fallback 표식도 뒀습니다. 구버전과 신버전 앱을 함께 지원하는 웹을 먼저 배포하도록 순서도 정했습니다. 이 글은 두 클라이언트 저장소에 병합된 코드와 테스트를 기준으로 정리했습니다.

로컬 로그아웃과 서버 폐기를 분리했다

서버 연결이 느리다는 이유로 사용자를 로그아웃 화면에 붙잡아 둘 수는 없었습니다. 현재 session을 snapshot한 뒤 로컬 값을 먼저 지우고 로그인 화면으로 이동했습니다. 서버의 token 폐기와 device 정보 제거는 제한 시간을 둔 best-effort 요청으로 보냈습니다.

로컬 삭제도 늘 성공하지는 않습니다. 삭제 시작 표식을 먼저 남겨 작업 도중 앱이 종료되더라도 다음 실행에서 정리를 이어갑니다. iOS Keychain에 값이 이미 없는 경우와 실제 저장소 오류도 구분했습니다. 암호화 저장소 삭제가 실패했다면 성공 응답을 보내지 않고 다시 시도하게 했습니다.

정상 동작보다 경합을 먼저 검증했다

까다로운 버그는 요청 하나만 실행해서는 좀처럼 드러나지 않았습니다. 다음과 같은 경합과 실패를 중심으로 테스트했습니다.

  • 동시에 들어온 여러 native refresh 호출이 API 한 번으로 합쳐지는가
  • refresh 도중 로그아웃하면 늦게 도착한 session이 최종 값으로 남지 않는가
  • 이전 refresh가 새 로그인의 session을 덮지 않는가
  • pending 저장이나 metadata 기록이 실패하면 부분 session이 남지 않는가
  • 손상된 JSON과 오래된 session을 읽었을 때 안전하게 로그인으로 돌아가는가
  • 로그인 401과 업무 API 401을 서로 다르게 처리하는가
  • bridge timeout 뒤 대기 중인 Promise가 정리되는가
  • session hydration 전 page hook이 실행되지 않는가

코드 테스트와 별도로 실제 기기 점검 항목도 정리했습니다. 신규 로그인과 앱 재실행, 만료 직전 시작, 여러 WebView의 동시 401, 오프라인 시작, refresh 중 네트워크 단절, 로그아웃을 같은 순서로 반복하는 시나리오입니다. URL과 오류 수집 도구에 token이 남지 않는지도 이 단계에서 확인하도록 했습니다.

EncryptedStorage 밖의 문제들

암호화 저장소는 refresh token이 디스크에 남는 방식을 개선합니다. 메모리 탈취나 신뢰한 웹 페이지의 XSS, 탈옥·루팅 기기는 이 범위 밖에 있습니다. iOS Keychain과 Android 보안 저장소의 백업·삭제 정책도 앱 요구사항에 맞춰 따로 확인해야 합니다.

가장 오래 붙잡았던 문제는 저장소가 아니라 비동기 결과의 순서였습니다. 어느 계층이 session을 소유하는지, 어떤 401을 갱신할지, 로그아웃 뒤 도착한 응답을 어떻게 버릴지가 먼저였습니다. EncryptedStorage는 그 규칙 안에 들어간 부품 중 하나였습니다.

참고한 공식 문서