1. 들어가며
반장노트 모바일 앱은 React Native가 네이티브 기능을 담당하고, 기존 React 웹 애플리케이션을 WebView로 제공하는 하이브리드 구조입니다. 이 구조는 이미 구현된 화면과 비즈니스 로직을 재사용할 수 있다는 장점이 있습니다. 처음에는 소셜 로그인도 웹과 동일하게 WebView 안에서 OAuth 페이지로 이동하면 된다고 생각했습니다. 브라우저에서 정상적으로 동작하던 흐름이었기 때문에 모바일에서도 큰 차이가 없을 것이라고 예상했습니다.
하지만 Google 로그인을 실제 모바일 환경에서 실행하자 403 disallowed_useragent 오류가 발생했습니다. 원인은 OAuth 키나 리다이렉트 URL이 아니라, 인증 화면이 앱이 제어하는 내장 WebView에서 열렸다는 점이었습니다. Google 정책과 RFC 8252는 네이티브 앱의 OAuth 인증에 embedded user-agent를 사용하지 않도록 권고합니다. WebView는 호스트 앱이 콘텐츠와 쿠키에 접근할 가능성이 있으므로 인증 제공자가 차단할 수 있습니다.
문제를 해결하는 가장 단순한 방법은 인증 URL을 외부 브라우저로 여는 것이었습니다. 그러나 반장노트는 로그인 후 기존 웹의 회원 조회, 신규 가입 분기, 역할 선택, 세션 생성 로직을 계속 사용해야 했습니다. 외부 브라우저로 전체 흐름을 옮기면 앱 복귀를 위한 딥 링크와 상태 복원까지 새로 설계해야 합니다. 반대로 React Native에서 인증부터 서비스 세션 생성까지 모두 다시 구현하면 웹과 앱에 같은 도메인 규칙이 중복됩니다.
그래서 이번 작업에서는 책임을 세 부분으로 나눴습니다. React Native는 각 Provider의 네이티브 SDK로 사용자를 인증하고, Bridge는 그 결과를 안전하게 WebView에 전달하며, React 웹은 기존 Gate 검증과 로그인·회원가입 분기를 그대로 수행합니다. 이 글에서는 이 구조를 선택한 배경과 구현 과정에서 부딪힌 문제, 그리고 브리지를 단순 postMessage가 아닌 작은 프로토콜로 다룬 경험을 정리합니다.
2. WebView OAuth를 그대로 사용할 수 없었던 이유
웹 OAuth에서는 사용자가 로그인 버튼을 누르면 Provider의 authorization endpoint로 이동하고, 인증이 끝나면 등록된 redirect URI로 돌아옵니다. 일반 브라우저에서는 이 흐름이 자연스럽지만, 네이티브 앱의 WebView는 인증 제공자 입장에서 embedded user-agent입니다. 특히 Google은 개발자가 제어하는 embedded user-agent로 OAuth 요청을 보내지 않도록 정책에 명시하고 있습니다. 따라서 웹에서 성공한 URL과 Client ID를 그대로 사용해도 모바일 WebView에서는 차단될 수 있습니다.
처음 오류 화면만 보면 Google OAuth 설정이나 키가 잘못된 것처럼 보였습니다. 그러나 동일한 설정으로 데스크톱 브라우저에서는 성공했고, 카카오는 WebView에서도 동작했습니다. Provider마다 허용 정책이 다르다는 점을 분리해서 봐야 했습니다. 한 Provider에서 성공했다는 사실은 전체 소셜 로그인 구조가 올바르다는 증거가 아니었습니다.
검토한 선택지는 세 가지였습니다.
-
WebView OAuth 유지: 웹 코드 재사용은 가장 크지만 Google 정책을 충족할 수 없습니다.
-
외부 시스템 브라우저와 딥 링크 사용: 표준에 가깝지만 앱 복귀, callback 상태, 웹 세션 연결을 새로 설계해야 합니다.
-
Provider 네이티브 SDK + 기존 웹 callback 재사용: 인증 UI만 네이티브로 옮기고 Gate 이후 로직은 웹에 남길 수 있습니다.
반장노트에서는 세 번째 방식을 선택했습니다. 이미 Google, Kakao, Apple 로그인 정책과 Gate의 third-party 검증 API, 가입 분기가 웹에 구현되어 있었기 때문입니다. 핵심은 네이티브 SDK가 받은 credential을 기존 웹 callback이 이해할 수 있는 형태로 전달하는 것이었습니다.
3. RN-Bridge-React로 책임을 나누기
설계 원칙은 ‘네이티브에서만 가능한 일만 React Native로 이동한다’였습니다. Provider SDK 실행, 플랫폼별 설정, SecureStore 저장은 RN이 맡습니다. 회원 존재 여부 판단, Gate credential 검증, SSO 로그인, 신규 가입 화면 이동은 기존 React가 맡습니다. Bridge는 두 영역을 연결하지만 어느 한쪽의 도메인 로직을 대신하지 않습니다.
[그림 1. React Native-Bridge-React-Gate로 이어지는 소셜 인증 흐름]
RN 애플리케이션은 booting, native-login, web의 세 단계로 동작합니다. 앱 시작 시 SecureStore에서 access token과 선택 역할을 확인합니다. 둘 다 있으면 곧바로 WebView를 열고, 하나라도 불완전하면 저장값을 정리한 뒤 네이티브 로그인 화면을 표시합니다. 인증이 성공하면 결과를 pending 상태로 보관하고 WebView 단계로 전환합니다.
웹 애플리케이션은 WebView 안에서 준비가 끝난 뒤 WEB_APP_READY를 보냅니다. RN은 이 신호를 받은 후에만 credential을 전달합니다. React는 결과를 검증하여 sessionStorage에 임시 저장하고, 기존 /oauth/callback/:provider 경로로 이동합니다. 이후에는 브라우저 OAuth callback과 동일하게 Gate 정책 조회와 사용자 확인을 수행합니다.
이 구조에서 중요한 점은 RN이 CitizenUser 존재 여부나 회원가입 여부를 결정하지 않는다는 것입니다. 네이티브 SDK는 Provider 인증 결과만 생성합니다. Gate가 credential을 검증한 뒤 이미 가입된 계정이면 SSO 로그인을 수행하고, 없는 계정이면 registrationToken을 포함한 가입 흐름으로 보냅니다. 플랫폼이 달라져도 서비스 규칙은 한 곳에 유지됩니다.
4. React Native에서는 Provider 인증에 집중하기
RN 로그인 화면이 열리면 먼저 Gate의 공개 로그인 정책을 조회합니다. 앱에 Client ID와 grantType을 고정하지 않고, 활성화된 Provider와 client policy를 기반으로 버튼을 구성했습니다. Google은 현재 Gate 구성에 맞춰 web client profile을 사용하고, Kakao와 Apple은 app client profile을 우선합니다. 정책 응답에서 clientSecret과 같은 서버 전용 필드는 RN 모델에 복사하지 않습니다.
Provider별 SDK 응답은 서로 다릅니다. Google은 access token, ID token, server authorization code와 사용자 프로필을 제공할 수 있습니다. Kakao는 access token 또는 ID token을 사용하고, 프로필 조회가 실패하더라도 Gate가 Provider 토큰으로 다시 확인할 수 있으므로 로그인 자체를 즉시 중단하지 않았습니다. Apple은 ID token 또는 authorization code를 사용하며 state와 nonce를 함께 생성해 응답을 확인했습니다.
이 차이를 호출부까지 노출하면 RN 로그인 화면이 Provider별 조건문으로 복잡해집니다. 그래서 각 SDK adapter가 공통 결과 객체를 만들고, 상위 흐름은 providerCode와 Gate 정책의 grantType만 확인하도록 구성했습니다.
type NativeSocialAuthResultPayload = {
requestId: string;
providerCode: 'google' | 'kakao' | 'apple';
credentials: { accessToken?: string; idToken?: string; authorizationCode?: string };
profile?: { userId?: string; email?: string; displayName?: string };
parameters?: Record<string, string>;
issuedAt: number;
};
SDK가 값을 반환했다고 바로 성공으로 보지 않았습니다. Gate 정책이 AccessToken을 요구하는데 SDK 결과에 ID token만 있다면 다음 단계에서 실패할 가능성이 큽니다. 따라서 RN에서 grantType과 실제 credential 종류가 일치하는지 먼저 확인했습니다. 취소는 오류와 분리했고, 사용자가 로그인 창을 닫은 경우에는 실패 Snackbar 대신 원래 로그인 화면을 유지하도록 했습니다.
5. Bridge를 postMessage가 아닌 계약으로 다루기
React Native WebView의 통신 기능은 기본적으로 문자열 하나를 보내는 postMessage입니다. 문자열만 주고받으면 구현은 빠르지만, action 오타나 payload 변경이 런타임에서야 드러납니다. 인증처럼 여러 단계와 민감정보가 오가는 기능에서는 단순 문자열 통신보다 방향과 타입이 명확한 계약이 필요했습니다.
공용 bridge 패키지에 RN→WebView, WebView→RN 이벤트 맵을 분리했습니다. NATIVE_SOCIAL_AUTH_RESULT는 RN이 인증 결과를 전달하는 이벤트이고, NATIVE_SOCIAL_AUTH_RESULT_RECEIVED는 웹이 결과를 수신했다는 ACK입니다. REQUEST_NATIVE_LOGIN은 웹의 로그인 화면이 다시 네이티브 화면을 요청할 때 사용합니다. 각 action은 auth, session, lifecycle 같은 feature로 분류되고 messageId, correlationId, sentAt 메타데이터를 가집니다.
interface AuthReactNativeToWebViewEventMap {
NATIVE_SOCIAL_AUTH_RESULT: NativeSocialAuthResultPayload;
}
interface AuthWebViewToReactNativeEventMap {
NATIVE_SOCIAL_AUTH_RESULT_RECEIVED: { requestId: string };
REQUEST_NATIVE_LOGIN: void;
WEB_AUTH_LOG: WebAuthLogPayload;
}
타입만 있다고 런타임 입력이 안전해지는 것은 아닙니다. WebView에서 들어온 문자열은 최대 크기를 제한한 뒤 JSON으로 파싱하고, 허용한 action인지와 payload 필드의 타입·길이를 검사했습니다. 알 수 없는 action이나 과도하게 긴 메시지는 처리하지 않습니다. 웹에서 받은 credential도 providerCode, issuedAt, credential 길이, profile 길이, parameter 개수와 크기를 검사합니다.
로그 역시 별도 경계가 필요했습니다. 오류를 추적하려고 payload 전체를 출력하면 access token, ID token, 이메일과 사용자 ID가 개발 로그에 남을 수 있습니다. bridge logger는 key 이름을 기준으로 민감값을 REDACTED 처리하고, 실제 값 대신 hasAccessToken, hasIdToken처럼 존재 여부만 기록하도록 했습니다.
6. READY-재전송-ACK로 전달 손실 줄이기
네이티브 인증 직후 WebView를 열고 곧바로 결과를 보내면 React listener가 등록되기 전에 메시지가 도착할 수 있습니다. 이 문제는 같은 기기에서도 네트워크 속도와 웹 번들 로딩 시간에 따라 재현 여부가 달라집니다. 처음에는 버튼을 눌러도 아무 반응이 없는 것처럼 보였고, 웹 Snackbar도 나타나지 않아 어느 영역에서 멈췄는지 확인하기 어려웠습니다.
이를 해결하기 위해 WebView 준비와 credential 전달을 명시적인 handshake로 만들었습니다. React가 listener를 등록한 뒤 WEB_APP_READY를 보내고, RN은 ready 상태가 된 다음 NATIVE_SOCIAL_AUTH_RESULT를 전송합니다. 웹은 정상 저장 직후 NATIVE_SOCIAL_AUTH_RESULT_RECEIVED를 돌려줍니다. RN은 같은 requestId의 ACK를 받아야 pending 결과를 제거합니다.
WEB_APP_READY
-> NATIVE_SOCIAL_AUTH_RESULT(requestId)
-> NATIVE_SOCIAL_AUTH_RESULT_RECEIVED(requestId)
ACK가 오지 않으면 RN은 2초 간격으로 최대 세 번 결과를 재전송합니다. 웹은 requestId로 이미 처리한 요청을 식별하고 중복 callback 이동을 막습니다. WebView 준비 자체가 일정 시간 안에 끝나지 않거나 세 번 모두 ACK를 받지 못하면 명시적인 오류 화면으로 전환합니다. 이 방식은 완전한 메시지 큐는 아니지만, WebView 생명주기와 listener 등록 시점 차이로 발생하는 일시적인 손실을 줄이는 데 효과적이었습니다.
재전송을 넣을 때는 멱등성도 함께 고려해야 했습니다. 같은 credential이 여러 번 도착할 수 있으므로 웹은 requestId를 기준으로 sessionStorage에 일회성으로 저장하고 callback에서 읽는 즉시 제거합니다. RN도 현재 pending requestId와 ACK의 requestId가 같은 경우에만 완료 처리합니다. 재시도는 전달 신뢰성을 높이지만 중복 처리 방지가 없으면 오히려 로그인 요청을 여러 번 실행할 수 있습니다.
7. React에서는 기존 Gate 인증 흐름을 재사용하기
React는 NATIVE_SOCIAL_AUTH_RESULT를 수신하면 payload를 URL query에 직접 길게 노출하지 않습니다. 먼저 검증된 결과를 requestId 기반 sessionStorage에 저장하고, callback URL에는 native_request_id만 전달합니다. callback 화면은 값을 한 번만 consume하여 URLSearchParams로 변환합니다. access_token, id_token, authorization code 가운데 Gate 정책의 grantType에 맞는 credential을 선택합니다.
이후 흐름은 기존 웹 OAuth와 같습니다. Pavilion과 Provider 정책을 조회하고, useThirdPartyOAuth를 통해 Gate에 credential 검증을 요청합니다. Gate 응답에서 CitizenUser가 존재하면 암호화된 사용자 식별자로 SSO 로그인을 수행합니다. 존재하지 않으면 registrationToken, 이메일, 이름을 가입 상태에 보관하고 약관 동의와 추가 정보 입력 단계로 이동합니다.
이 분리는 기존 기능을 재사용하는 것 이상의 의미가 있었습니다. CitizenUser 중복 판단, Provider 계정과 서비스 계정의 연결 규칙, 신규 가입 분기는 Gate와 React 흐름에 이미 존재합니다. RN에서 같은 판단을 구현하면 웹과 앱이 서로 다른 규칙으로 진화할 수 있습니다. 네이티브 영역은 ‘누가 Provider 인증에 성공했는가’를 전달하고, 서비스 영역은 ‘그 사용자를 우리 서비스에서 어떻게 처리할 것인가’를 결정하도록 경계를 유지했습니다.
callback은 네이티브 요청 여부에 따라 clientType과 redirectUriType을 구분합니다. 현재 구현에서는 Google 네이티브 SDK가 web client ID를 사용하므로 clientType을 web으로 전달하고, Kakao와 Apple은 app으로 전달합니다. 이 부분은 Provider SDK의 종류만 보고 고정할 수 없고, Gate POLISH 정책의 client profile 및 grantType과 함께 맞춰야 합니다.
8. 세션 동기화와 저장 수명
Provider credential 검증과 서비스 로그인에 성공하면 웹에서 access token, 선택 역할, loginProvider를 RN으로 동기화합니다. RN은 자동 로그인 정책에 따라 expo-secure-store에 저장합니다. 앱 재실행 시 token과 role이 모두 존재하면 WebView 단계로 복구하고, 둘 중 하나만 남아 있으면 불완전한 세션으로 보고 정리합니다.
여기서 Provider SDK token과 서비스 access token의 역할을 구분했습니다. Provider token은 Gate가 사용자 신원을 확인하는 짧은 전달 과정에만 사용합니다. 앱의 지속 세션은 Gate 로그인 이후 발급된 서비스 access token을 기준으로 관리합니다. refresh token은 WebView로 전달하지 않으며, bridge payload에도 정의하지 않았습니다.
loginProvider는 사용자가 현재 어떤 방식으로 로그인했는지 계정 관리 화면에 표시하기 위한 정보입니다. 웹 sessionStorage와 RN SecureStore의 수명을 현재 access token 정책에 맞췄습니다. 로그아웃 시에는 token, role, loginProvider를 함께 제거하고 RN 로그인 화면으로 돌아갑니다. 저장 위치를 하나로 합치기보다 각 런타임이 필요한 값을 소유하되 SYNC_USER_CONTEXT와 CLEAR_USER_CONTEXT로 상태 전이를 맞추는 방식입니다.
Apple은 이름과 이메일이 최초 동의 시점에만 제공될 수 있다는 제약도 있습니다. 현재 가입 흐름은 Provider profile과 Gate 응답을 함께 사용하지만, 사용자가 과거에 동의한 계정으로 다시 가입을 시도하면 이름이나 이메일이 비어 있을 수 있습니다. 따라서 운영 단계에서는 최초 응답을 안전하게 보관하는 정책과 정보가 없을 때 사용자에게 직접 입력받는 대체 흐름을 함께 준비해야 합니다.
9. 모바일에서 보이지 않던 오류를 관측 가능하게 만들기
웹 개발에서는 브라우저 Network 탭과 Console을 바로 볼 수 있지만, Expo Development Build나 실기기 WebView에서는 같은 방식으로 확인하기 어렵습니다. 실제 테스트에서도 추가 정보 입력 버튼을 눌렀는데 다음 화면으로 이동하지 않고, Snackbar도 나타나지 않는 현상이 있었습니다. UI만 보면 클릭 이벤트가 실행되지 않은 것인지, WebView 메시지가 누락된 것인지, Gate 요청이 실패한 것인지 구분할 수 없었습니다.
그래서 인증 흐름의 주요 지점을 단계 이벤트로 남겼습니다. RN에는 LOGIN_ATTEMPT_STARTED, SDK_RESULT_RECEIVED, CREDENTIAL_VALIDATED, BRIDGE_AUTH_SENT, BRIDGE_AUTH_RECEIVED를 기록합니다. React는 POLICY_LOAD_STARTED, GATE_VERIFY_STARTED, SSO_LOGIN_STARTED와 각각의 성공·실패·timeout을 WEB_AUTH_LOG로 RN 콘솔에 전달합니다. requestId를 correlationId로 사용하면 네이티브 SDK 호출부터 Gate 검증까지 한 시도를 이어서 볼 수 있습니다.
로그에는 credential 원문을 남기지 않습니다. 대신 grantType, clientType, redirectUriType, hasRegistrationToken, citizenUserExists처럼 원인 분석에 필요한 상태만 기록합니다. 예를 들어 SDK는 ID token을 반환했지만 Gate 정책이 AccessToken인 경우, 실제 토큰을 출력하지 않고도 CREDENTIAL_VALIDATED 이전에 정책 불일치로 실패했다는 사실을 확인할 수 있습니다.
timeout도 영역별로 나눴습니다. RN의 Gate 정책 조회, WebView READY 대기, Bridge ACK 대기, React의 Gate 정책 조회와 credential 검증은 서로 다른 실패 지점입니다. 하나의 ‘로그인 실패’ 메시지로 합치면 사용자 경험은 단순해 보일 수 있지만 개발 단계에서 원인을 찾기 어렵습니다. 내부 로그는 단계를 세분화하고, 사용자 메시지는 재시도 가능한 형태로 단순화했습니다.
10. 적용 결과와 트레이드오프
이번 구조로 Google 인증을 WebView 안에서 수행하지 않으면서도 기존 React/Gate 로그인 흐름을 유지할 수 있었습니다. Kakao와 Apple도 같은 공통 payload로 연결되어 상위 로그인 흐름에서 Provider별 분기가 줄었습니다. 신규 회원과 기존 회원의 분기, 역할 선택과 추가 정보 입력 같은 서비스 규칙을 RN에 복제하지 않은 점이 가장 큰 효과였습니다.
-
Provider SDK와 플랫폼 설정은 RN에 모으고 서비스 도메인 규칙은 React/Gate에 유지했습니다.
-
typed event map과 runtime guard로 메시지 오타·형식 오류를 조기에 발견할 수 있게 했습니다.
-
READY-재전송-ACK와 requestId 기반 일회성 소비로 WebView 로딩 시점의 불확실성을 줄였습니다.
-
민감정보 마스킹과 단계별 correlation log로 Expo 실기기 테스트의 진단 가능성을 높였습니다.
반면 브리지는 이제 하나의 프로토콜이므로 버전 호환성을 관리해야 합니다. 앱과 웹의 배포 시점이 다르면 한쪽이 새 action을 모를 수 있습니다. payload에 필드를 추가할 때 선택값으로 둘지, 앱 최소 버전을 올릴지 결정해야 합니다. 재전송과 ACK도 timeout 값을 무작정 늘리기보다 실제 로딩 시간과 실패율을 측정해 조정해야 합니다.
또한 Expo Go만으로는 모든 네이티브 SDK와 설정을 검증할 수 없습니다. GoogleService-Info.plist, URL scheme, iOS entitlement처럼 네이티브 프로젝트 설정이 필요한 기능은 development build에서 확인해야 합니다. 웹에서 성공한 테스트, Expo Go 테스트, 실제 development build 테스트를 같은 수준으로 간주하면 설정 누락을 늦게 발견할 수 있습니다.
11. 마무리
처음 문제는 ‘Google 로그인이 WebView에서 차단된다’는 단일 오류처럼 보였습니다. 하지만 해결 과정에서는 인증 UI를 어디에서 실행할지, Provider token을 어떤 계약으로 전달할지, 기존 서비스 로그인 흐름을 어떻게 재사용할지, WebView 로딩 중 메시지 손실을 어떻게 다룰지, 실기기에서 오류를 어떻게 추적할지를 함께 설계해야 했습니다.
이번 경험을 통해 하이브리드 앱에서 Bridge는 네이티브 기능을 호출하는 편의 도구가 아니라 두 런타임 사이의 API라는 점을 느꼈습니다. API라면 방향과 payload가 명확해야 하고, 입력을 검증해야 하며, 중복과 timeout을 처리하고, 민감정보가 로그에 남지 않도록 해야 합니다. 단순 postMessage로 시작한 통신도 인증을 다루는 순간 작은 분산 시스템과 비슷한 성격을 갖습니다.
React Native로 모든 화면과 도메인 로직을 옮기는 것이 항상 정답은 아닙니다. 반장노트에서는 Provider 인증처럼 네이티브가 필요한 부분만 RN에 두고, 이미 검증된 React와 Gate의 회원·세션 로직을 재사용하는 것이 변경 범위와 중복을 줄이는 방향이었습니다. 앞으로 계정 연결, 딥 링크 복귀, Provider별 추가 동의처럼 인증 기능이 확장되더라도 같은 경계를 유지하는 것이 중요합니다.
향후에는 앱과 웹의 bridge schema 버전을 명시하고, Provider별 성공·취소·중복 계정·세션 만료 시나리오를 Development Build 기반 E2E 테스트로 자동화할 계획입니다. Apple의 최초 프로필 제공 제약과 앱 외부 복귀 흐름도 운영 시나리오에 맞게 보완해야 합니다. 이번 작업은 웹 자산을 유지하면서 네이티브 인증 정책을 만족시키기 위해, RN-Bridge-React의 책임 경계를 실제 서비스 흐름 속에서 조정한 경험이었습니다.
참고 문헌
Google for Developers, OAuth 2.0 Policies
https://developers.google.com/identity/protocols/oauth2/policies
IETF, RFC 8252: OAuth 2.0 for Native Apps
https://www.rfc-editor.org/rfc/rfc8252.html
React Native WebView, Communicating between JS and Native
https://github.com/react-native-webview/react-native-webview/blob/master/docs/Guide.md
Expo Documentation, AppleAuthentication
https://docs.expo.dev/versions/latest/sdk/apple-authentication/
Kakao Developers, Kakao Login for Android
https://developers.kakao.com/docs/latest/ko/kakaologin/android
Kakao Developers, Kakao Login for iOS
https://developers.kakao.com/docs/latest/ko/kakaologin/ios
jyyou