WebView session 복구: cookie가 없어졌을 때 앱 인증을 어떻게 잇는가
WebView의 session 없음 메시지 두 개가 복구 요청 두 개로 번지는 실패를 재현하고, 진행 중 작업 공유와 재시도 한도로 한 번만 복구하며 네이티브 token을 페이지에 복사하지 않는 경계를 익힌다.
목차
표준·구현·측정·해석 표시는 무엇인가요?
- 표준웹 표준이나 언어 명세가 정한 동작
- 구현특정 기술이나 브라우저가 실제로 구현한 동작
- 측정명시한 환경에서 직접 실행해 관찰한 결과
- 해석앞선 근거에서 도출한 설계 판단
- 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용
30초 요약
WebView의 session cookie는 앱의 장기 로그인 credential과 수명이 다르다. 앱 인증은 유효하지만 WebView session만 사라진 상태가 생길 수 있다.
WebView 서버에 session 상태 확인
session 없음 앱에 검증된 복구 신호 전달
React Native 중복 신호를 진행 중인 한 복구 작업으로 모음
backend 앱 인증 확인 후 짧고 한 번만 쓰는 session 시작값 발급
WebView 시작 URL에서 Secure·HttpOnly cookie를 받고 로그인 상태 재확인반복해서 기억할 문장은 이것이다.
session 없음만 분류해 한 번 복구하고, native token은 WebView에 복사하지 않는다.
이 글은 session을 언제, 몇 번 복구할지를 다룬다. cookie 공유 설정과 플랫폼 저장소를 직접 조작하는 방법을 보편 해법으로 가르치지 않는다.
이 글을 관통하는 상황: 앱은 로그인됐지만 주문 WebView만 로그아웃됐다
17편에서 native token과 WebView session의 소유권을 나눴고, 18편에서 장기 credential을 플랫폼 보안 저장소에 두었다. 이제 사용자가 앱을 다시 열었는데 주문 WebView의 session cookie만 없다고 하자.
잘못된 구현은 WebView가 보낸 session.missing마다 새 session 시작 요청을 만든다. 모든 HTTP 오류에서
무제한으로 복구를 반복하는 것도 해결이 아니다. 이 글의 질문은 하나다.
앱 인증이 남아 있을 때 WebView session만 안전하게 다시 만들고, 중복·오류 loop를 어떻게 막는가?
cookie 만료 시각은 보존 보장이 아니다
따라서 “앱 credential은 남아 있으니 WebView cookie도 남아 있을 것”이라고 추론하지 않는다. 서버가
보호된 session 확인 API의 특정 요청 주소(endpoint)나 page 응답에서 현재 인증 문맥을 확인하고, 그
결과만 복구 입력으로 쓴다.
HttpOnly cookie는 page script에서 원문을 직접 읽지 못하게 하는 경계이므로 document.cookie에 값이
안 보인다는 사실만으로 session 없음을 판정해서도 안 된다.
중복 실패를 가진 실행 기준선을 만든다
16~18편에서 이어 온 React Native 0.86.2 WebBoundaryApp에는
[email protected]과 [email protected]이 이미 설치되어 있다. 새 의존성은 추가하지 않는다.
이 fixture의 fixtureSessionReady는 권한 없는 boolean이고, requestSessionBootstrapFixture는 HTTP 요청을
하지 않는 지연 함수다. 실제 token, cookie, 사용자 정보나 운영 URL은 코드·화면·log에 넣지 않는다.
WebView page는 session이 없을 때 같은 메시지를 의도적으로 두 번 보내 중복 복구를 관찰하게 한다.
App.tsx를 다음 완결 코드로 교체한다. RECOVERY_MODE = 'naive'가 중복 신호마다 요청을 만드는 잘못된
기준선이고, SHOULD_BOOTSTRAP_FAIL은 실패와 사용자 재시도를 따로 관찰하는 fixture 스위치다.
import React, { useRef, useState } from 'react'
import { Button, SafeAreaView, StyleSheet, Text } from 'react-native'
import { WebView } from 'react-native-webview'
import * as z from 'zod'
const RECOVERY_MODE: 'naive' | 'single-flight' = 'naive'
const SHOULD_BOOTSTRAP_FAIL = false
const MAX_RECOVERY_ATTEMPTS = 1
const SessionMessageSchema = z.discriminatedUnion('type', [
z.strictObject({ type: z.literal('session.missing') }),
z.strictObject({ type: z.literal('session.ready') }),
])
const PAGE_HTML = `<!doctype html>
<html lang="ko">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<body>
<p id="session">웹 session: 확인 중</p>
<script>
const raw = window.ReactNativeWebView.injectedObjectJson()
const initial = raw ? JSON.parse(raw) : {}
const ready = initial.fixtureSessionReady === true
const send = (type) => {
window.ReactNativeWebView.postMessage(JSON.stringify({ type }))
}
document.querySelector('#session').textContent =
'웹 session: ' + (ready ? '준비됨' : '없음')
if (ready) {
send('session.ready')
} else {
send('session.missing')
send('session.missing')
}
</script>
</body>
</html>`
function requestSessionBootstrapFixture(shouldFail: boolean): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
reject(new Error('fixture bootstrap failed'))
return
}
resolve()
}, 400)
})
}
export default function App() {
const [phase, setPhase] = useState('session 확인 중')
const [missingMessageCount, setMissingMessageCount] = useState(0)
const [bootstrapRequestCount, setBootstrapRequestCount] = useState(0)
const [generation, setGeneration] = useState(0)
const recoveryRef = useRef<Promise<void> | null>(null)
const attemptCountRef = useRef(0)
function startBootstrapRequest(): Promise<void> {
setBootstrapRequestCount((count) => count + 1)
setPhase('session 시작 요청 중')
return requestSessionBootstrapFixture(SHOULD_BOOTSTRAP_FAIL)
.then(() => {
setGeneration((current) => current + 1)
})
.catch(() => {
setPhase('session 시작 요청 실패')
})
}
function recover() {
if (RECOVERY_MODE === 'naive') {
void startBootstrapRequest()
return
}
if (recoveryRef.current !== null) {
return
}
if (attemptCountRef.current >= MAX_RECOVERY_ATTEMPTS) {
setPhase('session 복구 한도 도달')
return
}
attemptCountRef.current += 1
const task = startBootstrapRequest()
.finally(() => {
if (recoveryRef.current === task) {
recoveryRef.current = null
}
})
recoveryRef.current = task
}
function retryRecovery() {
attemptCountRef.current = 0
recover()
}
return (
<SafeAreaView style={styles.screen}>
<Text style={styles.title}>WebView session 복구</Text>
<Text>mode: {RECOVERY_MODE}</Text>
<Text>상태: {phase}</Text>
<Text>session 없음 메시지: {missingMessageCount}</Text>
<Text>session 시작 요청: {bootstrapRequestCount}</Text>
<Button
title="session 복구 다시 시도"
disabled={phase !== 'session 시작 요청 실패'}
onPress={retryRecovery}
/>
<WebView
key={`session-${generation}`}
originWhitelist={['*']} // navigation 없는 인라인 fixture 전용
source={{ html: PAGE_HTML }}
injectedJavaScriptObject={{
fixtureSessionReady: generation > 0,
}}
onMessage={(event) => {
let input: unknown
try {
input = JSON.parse(event.nativeEvent.data)
} catch {
setPhase('session 메시지 JSON 오류')
return
}
const parsed = SessionMessageSchema.safeParse(input)
if (!parsed.success) {
setPhase('session 메시지 계약 오류')
return
}
if (parsed.data.type === 'session.ready') {
attemptCountRef.current = 0
setPhase('session 준비 완료')
return
}
setMissingMessageCount((count) => count + 1)
setPhase('session 없음')
recover()
}}
style={styles.webview}
/>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16, gap: 12 },
title: { fontSize: 22, fontWeight: '700' },
webview: { flex: 1, borderWidth: 1 },
})실행 전에 예측한다.
- page가
session.missing을 두 번 보내면 화면의 메시지 수는 얼마일까? - 단순 구현은 session 시작 요청을 몇 번 만들까?
- 이 fixture가 실제 cookie나 backend session을 만들었다고 볼 수 있을까?
mode: naive
상태: session 준비 완료
session 없음 메시지: 2
session 시작 요청: 2
웹 session: 준비됨실패는 명확하다. 최종 화면은 준비됐지만 같은 session 없음 신호 두 개가 시작 요청 두 개로 번졌다. 실제 backend라면 시작값 두 개와 WebView navigation 두 번이 경쟁할 수 있다. “마지막에 로그인됐다”는 화면만으로 중복 인증 요청이 없었다고 결론 내릴 수 없다.
중복 session 없음 신호를 한 복구 작업으로 모은다
진행 중인 같은 종류의 비동기 작업을 여러 호출이 공유하게 만드는 방식을 이 글에서는 단일 진행 작업 (single-flight)이라고 부른다. 이는 특정 라이브러리 이름이 아니라 이 fixture의 동시 실행 규칙이다.
첫 session.missing session 시작 요청 1개 생성
둘째 session.missing 이미 진행 중인 같은 Promise를 보고 새 요청을 만들지 않음
성공 새 WebView 문서에서 session 상태 다시 확인
실패 오류 표시, 자동 반복하지 않음첫 실패를 확인한 뒤 mode만 바꾼다.
const RECOVERY_MODE: 'naive' | 'single-flight' = 'single-flight'Reload 또는 앱 재시작 뒤 다음 결과를 확인한다.
mode: single-flight
상태: session 준비 완료
session 없음 메시지: 2
session 시작 요청: 1
웹 session: 준비됨두 session.missing 사이에 응답 순서를 바꾼 것이 아니다. 진행 중인 Promise를 recoveryRef에 기록해
두 번째 신호가 새 요청을 만들지 못하게 했다. MAX_RECOVERY_ATTEMPTS = 1은 실패한 요청이 같은
WebView에서 무한히 반복되는 것도 막는다.
이 결과는 다음 세 가지만 증명한다.
- WebView 문자열 메시지를 구조 검사한 뒤 상태 전이에 쓴다.
- 중복된 session 없음 신호 두 개가 session 시작 함수 호출 한 번으로 모인다.
- 시작 함수가 끝나면 새 WebView 문서가 ready 상태를 다시 보고한다.
fixtureSessionReady는 실제 session cookie가 아니다. 실제 token을 사용하지 않았고 HTTP 요청도 없으므로
서버 인증, cookie 발급·전송, 한 번 사용 보장이나 재사용 거절은 아직 증명하지 않았다.
자동 실패 경계도 같은 화면에서 확인한다. RECOVERY_MODE는 single-flight로 둔 채
SHOULD_BOOTSTRAP_FAIL = true로 바꾸고 Reload한다.
mode: single-flight
상태: session 시작 요청 실패
session 없음 메시지: 2
session 시작 요청: 1잠시 기다려도 요청 수는 1에서 자동으로 늘지 않는다. 이제 session 복구 다시 시도를 한 번 누르면
사용자 행동으로 한도를 새로 열어 두 번째 요청을 만들고, 고정 실패 fixture이므로 다시 실패한다.
상태: session 시작 요청 실패
session 없음 메시지: 2
session 시작 요청: 2실패 실험을 마치면 SHOULD_BOOTSTRAP_FAIL = false로 되돌린다. 이 버튼은 실제 제품의 최종 오류 화면을
정한 것이 아니라, 자동 반복과 사용자가 명시적으로 시작한 재시도를 구분하는 최소 fixture다.
401, 403, network 오류를 같은 복구 신호로 보지 않는다
onHttpError는 WebView가 받은 HTTP 오류의 statusCode와 url 등을 앱에 전달할 수 있지만, 상태 코드
하나만으로 제품 session 의미가 자동 결정되지는 않는다.
| 관찰 | 자동 session 복구 | 이유 |
|---|---|---|
| 보호된 session 확인 endpoint가 제품 계약대로 401 | 한도 안에서 가능 | 현재 인증 자격 정보 없음으로 분류됨 |
검증된 page message session.missing | 한도 안에서 가능 | 서버 확인 결과를 앱 protocol로 전달한 경우 |
| 403 | 자동 복구하지 않음 | 권한 부족·정책 거절은 새 cookie로 해결되지 않을 수 있음 |
| network 연결 실패·timeout | 자동 복구하지 않음 | session 유무를 관찰하지 못함 |
| 5xx | 자동 복구하지 않음 | 서버 장애를 인증 없음으로 바꾸지 않음 |
| 구조가 틀린 message | 자동 복구하지 않음 | 신뢰할 수 있는 상태 입력이 아님 |
실제 backend가 인증 실패를 401이 아닌 별도 응답 body로 표현한다면 그 제품 계약을 검사한다. 중요한 것은 “HTTP 오류”가 아니라 서버가 현재 web session이 없거나 유효하지 않다고 확인한 결과다.
실제 통합에서는 backend가 두 인증 경계를 잇는다
여기서 session 시작값은 이미 인증된 앱 요청과 새 web session을 한 번 연결하는 짧은 수명의 값이다. 보편 웹 표준 이름이 아니라 제품 backend가 설계할 protocol의 한 요소다. 값이 URL에 들어가면 browser history, 앱과 서버 사이에서 요청을 전달하는 중간 서버(proxy), 접근 log 등에 남을 가능성까지 포함해 위협 모델을 세우고, 한 번 사용·짧은 만료·log 가림과 재사용 거절을 검증한다. native access token 자체를 시작값으로 쓰지 않는다.
1. WebView → session 확인 endpoint
server: 현재 cookie 없음 또는 무효 확인
2. WebView → React Native
검증된 session.missing message
3. React Native → 인증 backend
session 시작 API에 유효한 native access token을 Authorization header에만 사용
저장값이 OAuth refresh token이면 먼저 token endpoint에서 access token으로 교환
4. backend → React Native
짧고 한 번만 쓸 수 있는 WebView session 시작 위치 반환
5. WebView → HTTPS session 시작 위치
server가 시작값을 폐기하고 Set-Cookie: web_session=...; Secure; HttpOnly 발급
6. WebView → 보호된 page 또는 session 확인 endpoint
cookie가 적용된 요청을 server가 확인하고 session.ready 결과 반환Apple의 WKHTTPCookieStore와 Android의 CookieManager는 각각 WebView 쪽 HTTP cookie 저장 경계다.
그러나 두 API 이름이 같지 않다고 앱 JavaScript가 session 원문을 직접 복사해야 하는 것은 아니다.
정상 HTTP Set-Cookie와 후속 Cookie 요청으로 닫을 수 있는 구조라면 서버·WebView 경계에 맡긴다.
실제 통합 검증은 다음 증거를 한 흐름에서 모은다.
| 단계 | 반드시 관찰할 증거 |
|---|---|
| session 없음 | 보호된 확인 요청의 제품 계약상 인증 실패, 다른 403·network·5xx와 구분 |
| 앱 인증 | session 시작 API에 유효한 access token을 Authorization header에 사용, refresh token은 직접 보내지 않고 URL·log·page에는 두 token 모두 없음 |
| 시작값 발급 | 짧은 만료·한 번 사용·대상 session 제한, 민감 log 가림 |
| cookie 발급 | HTTPS 응답의 Set-Cookie, Secure·HttpOnly·Domain·Path가 의도와 일치 |
| session 준비 | 후속 적용 요청의 cookie와 server의 사용자·권한 확인, page의 ready 결과 |
| 재사용 방지 | 같은 시작값의 두 번째 사용 거절, 동시 복구에도 session 시작 요청 한 번 |
fixture의 session 시작 요청: 1만으로 이 표의 backend 보안 속성을 증명했다고 쓰지 않는다.
가상 환경과 실기기에서 증명하는 범위를 나눈다
Android emulator / iOS Simulator
중복 message, state 전이, WebView 재생성, test backend의 HTTP/cookie 흐름
지원 실기기
실제 앱·WebView 저장소 정책, 기기 잠금 뒤 secure credential 조회,
운영과 같은 HTTPS 암호화·서버 인증, redirect·cookie 범위, 외부 인증·기기 정책이 포함된 흐름가상 환경에서도 실제 test backend를 붙이면 HTTP header와 cookie 왕복은 검사할 수 있다. 다만 18편의 보안 저장소 하드웨어 수준이나 제품이 의존하는 기기 정책은 simulator·emulator 성공만으로 증명하지 않는다. 실제 기기 능력이 결과를 바꾸는 조건이 있다면 지원 실기기에서 별도 통합 검증한다.
pull request에서 달라져야 할 행동
WebView 로그인 복구 코드를 검토할 때 다음 표를 먼저 채운다.
session 없음 판정 어떤 server 응답·page message를 신뢰하는가
오류 분류 401 / 403 / network / 5xx / 구조 오류
동시 실행 진행 중 복구를 어디에 기록하고 누가 공유하는가
재시도 자동 시도 횟수, 사용자 재시도, 중단 화면
native access token 발급·읽기 시점, 대상 backend, header, 메모리·log 복사본
session 시작값 수명, 한 번 사용, 대상, 전달 위치, log 가림, 재사용 거절
cookie Secure, HttpOnly, Domain, Path, 만료, logout 삭제
성공 판정 server가 확인한 session.ready 증거
플랫폼 검증 Android·iOS cookie store와 지원 실기기 조건검토자는 다음을 확인한다.
document.cookie가 비었다는 이유만으로 HttpOnly session 없음이라고 판단하지 않는가?- 중복
session.missing이 backend 요청·navigation 중복으로 이어지지 않는가? - 403·network·5xx를 새 session으로 고쳐질 401과 분리하는가?
- 자동 복구 횟수에 한도가 있고 실패 뒤 사용자에게 다음 행동을 보여 주는가?
- native access token을 URL, WebView 주입 값, page message나 log에 넣지 않는가?
- backend 시작값의 한 번 사용과 재사용 거절을 통합 테스트하는가?
- cookie 발급만이 아니라 다음 보호 요청의 사용자·권한 확인까지 검증하는가?
한 장으로 다시 보기
문제 앱 인증은 남았지만 WebView session cookie가 없음
오판 오류만 보여 주거나, 모든 오류에서 복구를 무제한 반복함
관찰 단순 구현은 session.missing 2회를 시작 요청 2회로 바꿈
원인 장기 앱 credential과 짧은 web session의 수명·소유권이 다름
행동 검증된 없음 신호 → 단일 진행 작업 → backend session 시작 → WebView 재확인
검증 없음 2회에도 시작 1회, 실제 통합은 Set-Cookie·후속 Cookie·재사용 거절 확인
경계 401·403·network를 분리하고 native token은 page에 복사하지 않음session 복구는 cookie 값을 JavaScript로 옮기는 일이 아니다. 서버가 확인한 session 없음 상태를 제한된 복구 작업으로 바꾸고, 이미 인증된 앱과 WebView 전용 web session 사이를 backend가 좁게 연결하는 일이다.
스스로 확인할 질문
- cookie의 만료 시각이 남아 있어도 session 복구가 필요할 수 있는 이유는 무엇인가?
- 중복된
session.missing을 backend 요청 한 번으로 모아야 하는 이유는 무엇인가? - 401, 403과 network 실패를 같은 복구 입력으로 쓰면 어떤 loop가 생길 수 있는가?
- 실제 backend 통합에서 fixture 밖으로 추가 확인해야 하는 증거는 무엇인가?
Active recall
기억에서 꺼내 보기
답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.
01session cookie의 만료 시각이 아직 남았다면 WebView가 반드시 그 cookie를 가지고 있을까?
정답
아니다. 만료 시각은 최대 수명이며, 사용자 에이전트는 메모리·개인정보 보호나 사용자 설정 때문에 더 일찍 cookie를 지울 수 있다. 서버가 확인한 session 없음 상태를 복구 입력으로 삼아야 한다.
관련 설명 다시 읽기왜 그런가
로그인 기한과 현재 WebView cookie 저장소의 실제 상태는 같은 정보가 아니다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
02같은 WebView가 session 없음 메시지를 연달아 두 번 보내면 세션 시작 요청도 두 번 보내야 할까?
정답
아니다. 같은 복구 구간의 중복 신호는 진행 중인 한 작업을 공유하고, 정한 재시도 한도를 넘으면 멈춰야 한다.
관련 설명 다시 읽기왜 그런가
중복 화면 사건을 그대로 인증 backend 호출 수로 바꾸면 새 session 발급과 navigation이 경쟁할 수 있다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
03WebView에서 HTTP 오류가 발생하면 어떤 상태 코드든 자동 session 복구를 시작해도 될까?
정답
아니다. 제품이 인증 실패로 정의한 401이나 검증된 session 없음 메시지만 복구 입력으로 쓴다. 403 권한 부족, network 오류, 서버 장애는 별도 상태로 처리한다.
관련 설명 다시 읽기왜 그런가
서로 다른 실패를 session 없음 하나로 합치면 고쳐지지 않는 오류에서 복구 loop가 생긴다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
04앱이 이미 로그인돼 있다면 WebView session 복구를 위해 native access token을 URL이나 page script에 전달해도 될까?
정답
아니다. 앱은 session 시작 API에 유효한 native access token을 Authorization header에만 사용하고, backend가 별도 web session 시작값과 Secure·HttpOnly cookie를 발급하게 한다. 저장값이 OAuth refresh token이면 먼저 authorization server에서 access token으로 교환한다.
관련 설명 다시 읽기왜 그런가
같은 로그인 상태를 복구하는 것과 같은 자격 정보 원문을 공유하는 것은 다른 요구다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
출처와 검증 범위
아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.
- RFC 6265 - HTTP State Management MechanismInternet Engineering Task Force · RFC · 확인 2026-08-09
- RFC 9110 - HTTP SemanticsInternet Engineering Task Force · RFC · 확인 2026-08-09
- RFC 6750 - OAuth 2.0 Bearer Token UsageInternet Engineering Task Force · RFC · 확인 2026-08-09
- RFC 6749 - The OAuth 2.0 Authorization FrameworkInternet Engineering Task Force · RFC · 확인 2026-08-09
- React Native WebView 14.0.1 ReferenceReact Native WebView · 공식 문서 · 확인 2026-08-09
- WKHTTPCookieStoreApple Developer · 공식 문서 · 확인 2026-08-09
- CookieManagerAndroid Developers · 공식 문서 · 확인 2026-08-09
- Zod 4 - Basic usageZod · 공식 문서 · 확인 2026-08-09
- Zod 4.4.3 releaseZod · 공식 문서 · 확인 2026-08-09
- Preserving and Resetting StateReact · 공식 문서 · 확인 2026-08-09
- Fast RefreshReact Native · 공식 문서 · 확인 2026-08-09
- Debugging — Accessing the Dev MenuReact Native · 공식 문서 · 확인 2026-08-09
- Extended controls, settings, and helpAndroid Developers · 공식 문서 · 확인 2026-08-09
- Running your app on simulated or physical devicesApple Developer · 공식 문서 · 확인 2026-08-09