WebView 요청 식별값: 역순으로 온 응답을 어느 요청에 연결하는가
동시에 기다리는 WebView 요청마다 식별값을 부여하고, 응답 순서가 뒤바뀌어도 원래 요청의 화면 항목을 정확히 갱신한다.
목차
표준·구현·측정·해석 표시는 무엇인가요?
- 표준웹 표준이나 언어 명세가 정한 동작
- 구현특정 기술이나 브라우저가 실제로 구현한 동작
- 측정명시한 환경에서 직접 실행해 관찰한 결과
- 해석앞선 근거에서 도출한 설계 판단
- 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용
30초 요약
앱이 WebView에 여러 작업을 연달아 요청하면 응답은 요청 순서와 다르게 도착할 수 있다. 영어
Identifier의 줄임말인 ID는 요청 하나를 다른 요청과 구분하는 식별값이다.
표준 요청 requestId=standard-1 ─┐
특급 요청 requestId=express-1 ─┼→ WebView 처리
├← express-1 응답이 먼저 도착
└← standard-1 응답이 나중 도착
앱은 도착 순서가 아니라 requestId로 화면 항목을 찾는다.반복해서 기억할 문장은 이것이다.
응답 위치를 추측하지 말고, 요청에서 보낸 식별값을 응답으로 돌려받아 연결한다.
이 글은 요청과 응답을 연결하는 한 가지 문제만 다룬다. 받은 문자열의 구조와 값이 안전한지는 16편에서 별도로 검사한다.
이 글을 관통하는 상황: 특급 요금이 표준 배송 칸에 들어갔다
14편에서 앱 → WebView 명령을 보냈고 13편에서 WebView → 앱 응답을 받았다. 이제 사용자가 배송비 두 개를 한 번에 조회한다.
표준 배송 예상 요금 5000원
특급 배송 예상 요금 18000원앱은 표준, 특급 순서로 요청한다. 고정된 연습 페이지는 두 요청을 모두 받은 뒤 의도적으로 특급 응답을 먼저, 표준 응답을 나중에 보낸다. 네트워크 속도를 기다리지 않고도 역순 도착을 매번 같은 방식으로 관찰하기 위한 fixture다. fixture는 학습에서 같은 실패를 반복하도록 미리 만든 입력과 동작을 뜻한다.
다음 App.tsx는 실행 가능한 실패 예제다. originWhitelist의 *는 모든 웹 출처를 허용하지만 이
코드는 다른 주소로 이동하지 않는 인라인 HTML fixture에만 사용한다. 실제 URL을 여는 WebView에서는
허용할 출처와 다른 문서로 이동하는 정책을 따로 좁혀야 한다.
코드의 requestGroup은 버튼을 한 번 눌러 함께 보낸 표준·특급 요청 묶음의 번호다. 새 묶음을 보낼
때마다 이 번호를 늘려 이전 묶음의 식별값과도 겹치지 않게 한다.
import React, { useRef, useState } from 'react'
import { Button, SafeAreaView, StyleSheet, Text, View } from 'react-native'
import { WebView } from 'react-native-webview'
type Service = 'standard' | 'express'
type RowStatus = 'idle' | 'pending' | 'done'
type QuoteRequest = {
type: 'shipping.quote.request'
requestId: string
service: Service
}
type QuoteResponse = {
type: 'shipping.quote.result'
requestId: string
service: Service
amount: number
}
type QuoteRow = {
requestId: string
service: Service
status: RowStatus
responseService: Service | null
amount: number | null
}
const INITIAL_ROWS: QuoteRow[] = [
{
requestId: '-',
service: 'standard',
status: 'idle',
responseService: null,
amount: null,
},
{
requestId: '-',
service: 'express',
status: 'idle',
responseService: null,
amount: null,
},
]
const PAGE_HTML = `<!doctype html>
<html lang="ko">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<body>
<p>배송비 계산 페이지</p>
<p id="received">웹 요청 수신: 0</p>
<script>
const pendingRequests = []
function sendResult(request, amount) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'shipping.quote.result',
requestId: request.requestId,
service: request.service,
amount,
}))
}
function receiveRequest(event) {
const request = JSON.parse(event.data)
pendingRequests.push(request)
document.querySelector('#received').textContent =
'웹 요청 수신: ' + String(pendingRequests.length)
if (pendingRequests.length !== 2) return
const express = pendingRequests.find(
(item) => item.service === 'express',
)
const standard = pendingRequests.find(
(item) => item.service === 'standard',
)
sendResult(express, 18000)
sendResult(standard, 5000)
pendingRequests.length = 0
}
document.addEventListener('message', receiveRequest)
window.addEventListener('message', (event) => {
if (event.target === window) {
receiveRequest(event)
}
})
window.ReactNativeWebView.postMessage('web.ready')
</script>
</body>
</html>`
function serviceLabel(service: Service) {
return service === 'standard' ? '표준' : '특급'
}
function resultLabel(row: QuoteRow) {
if (row.status === 'idle') return '아직 요청하지 않음'
if (row.status === 'pending') return '응답 대기'
if (row.responseService === null || row.amount === null) {
return '응답 표시 오류'
}
return (
serviceLabel(row.responseService) +
' 응답 · ' +
String(row.amount) +
'원'
)
}
export default function App() {
const webViewRef = useRef<WebView>(null)
const nextRequestGroupRef = useRef(1)
const [webReady, setWebReady] = useState(false)
const [rows, setRows] = useState<QuoteRow[]>(INITIAL_ROWS)
const [arrivalOrder, setArrivalOrder] = useState<Service[]>([])
function requestQuotes() {
if (!webReady || !webViewRef.current) return
const requestGroup = nextRequestGroupRef.current
nextRequestGroupRef.current += 1
const requests: QuoteRequest[] = [
{
type: 'shipping.quote.request',
requestId: 'standard-' + String(requestGroup),
service: 'standard',
},
{
type: 'shipping.quote.request',
requestId: 'express-' + String(requestGroup),
service: 'express',
},
]
setArrivalOrder([])
setRows(
requests.map((request) => ({
requestId: request.requestId,
service: request.service,
status: 'pending',
responseService: null,
amount: null,
})),
)
requests.forEach((request) => {
webViewRef.current?.postMessage(JSON.stringify(request))
})
}
function receiveWebMessage(raw: string) {
if (raw === 'web.ready') {
setWebReady(true)
return
}
const response: QuoteResponse = JSON.parse(raw)
setArrivalOrder((currentOrder) => [
...currentOrder,
response.service,
])
// 잘못된 판단: 첫 번째 pending 행이 이번 응답의 주인일 것이다.
setRows((currentRows) => {
const firstPendingIndex = currentRows.findIndex(
(row) => row.status === 'pending',
)
if (firstPendingIndex < 0) return currentRows
return currentRows.map((row, index) =>
index === firstPendingIndex
? {
...row,
status: 'done',
responseService: response.service,
amount: response.amount,
}
: row,
)
})
}
const waiting = rows.some((row) => row.status === 'pending')
return (
<SafeAreaView style={styles.screen}>
<Text>웹 준비: {webReady ? '완료' : '대기'}</Text>
<Button
title="표준·특급 배송비 함께 요청"
disabled={!webReady || waiting}
onPress={requestQuotes}
/>
<Text>
응답 도착 순서:{' '}
{arrivalOrder.length === 0
? '아직 없음'
: arrivalOrder.map(serviceLabel).join(' → ')}
</Text>
{rows.map((row) => (
<View key={row.service} style={styles.row}>
<Text>
{serviceLabel(row.service)} 요청 {row.requestId}
</Text>
<Text>{resultLabel(row)}</Text>
</View>
))}
<WebView
ref={webViewRef}
originWhitelist={['*']} // navigation 없는 인라인 fixture 전용
source={{ html: PAGE_HTML }}
onLoadStart={() => setWebReady(false)}
onMessage={(event) => receiveWebMessage(event.nativeEvent.data)}
style={styles.webview}
/>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16, gap: 12 },
row: { borderWidth: 1, padding: 12, gap: 4 },
webview: { flex: 1, borderWidth: 1 },
})실행 전 결과를 예측한다.
- 앱은 표준과 특급 중 어느 요청을 먼저 보낼까?
- 웹은 어느 응답을 먼저 돌려줄까?
- 첫 번째 대기 행을 갱신하면 최종 두 행은 어떤 요청·응답 조합을 보여 줄까?
표준 요청 standard-1
특급 응답 · 18000원
특급 요청 express-1
표준 응답 · 5000원
응답 도착 순서: 특급 → 표준
웹 요청 수신: 2두 응답은 모두 도착했고 금액도 사라지지 않았다. 하지만 각 금액이 잘못된 요청 행에 들어갔다. 성공 횟수만 세면 놓치기 쉬운 데이터 귀속 오류다.
도착 순서와 요청의 정체성을 분리한다
요청 순서는 “언제 보냈는가”를 나타낸다. 요청 식별값은 “어느 작업인가”를 나타낸다. 둘은 같은 정보가 아니다.
보낸 순서 1번째 standard, 2번째 express
도착 순서 1번째 express, 2번째 standard
연결 기준 response.requestId서버 처리 시간, WebView 내부 작업과 외부 도구가 결과를 돌려주는 시점이 달라지면 순서는 언제든 바뀔 수 있다. 이 글은 그 가능성을 말로만 두지 않고 페이지가 두 결과를 역순으로 보내게 해 항상 재현한다.
식별값을 요청과 응답에 왕복시킨다
앱 요청 객체
{ requestId: 'express-1', service: 'express' }
→ JSON 요청 문자열
→ WebView 처리
→ JSON 응답 문자열
{ requestId: 'express-1', amount: 18000 }식별값 생성과 사용 규칙을 함께 고정한다.
- 앱이 아직 끝나지 않은 요청 사이에서 겹치지 않는
requestId를 만든다. - 페이지는 해석한 요청의
requestId를 결과에 그대로 복사한다. - 앱은 현재 대기 중인 같은
requestId의 행만 완료한다. - 이미 완료했거나 모르는
requestId는 다른 행에 대신 넣지 않는다.
여기서는 한 번에 보낸 요청 묶음을 구분하는 번호를 실행할 때마다 늘리고, 서비스 이름과 합쳐
standard-1, express-1처럼 만든다. 이 값은 이 앱 실행 안의 대기 요청을 구분하려는 fixture
계약이다. 모든 사용자·기기·서버에서 영원히 유일한 값이라고 주장하지 않는다.
응답 식별값으로 정확한 행만 교체한다
첫 App.tsx에서 receiveWebMessage 함수만 다음 코드로 교체한다. 나머지 타입, PAGE_HTML, UI와
WebView 설정은 그대로 유지한다.
function receiveWebMessage(raw: string) {
if (raw === 'web.ready') {
setWebReady(true)
return
}
const response: QuoteResponse = JSON.parse(raw)
setArrivalOrder((currentOrder) => [
...currentOrder,
response.service,
])
setRows((currentRows) => {
const matchingPendingRow = currentRows.some(
(row) =>
row.requestId === response.requestId && row.status === 'pending',
)
if (!matchingPendingRow) return currentRows
return currentRows.map((row) => {
if (
row.requestId !== response.requestId ||
row.status !== 'pending'
) {
return row
}
return {
...row,
status: 'done',
responseService: response.service,
amount: response.amount,
}
})
})
}실행 전에 다시 예측한다.
- 특급 응답이 먼저 와도 어느 행의
requestId와 같을까? - 표준 응답이 나중에 오면 이미 끝난 특급 행을 바꿀까?
- 배열에서 일치하는 대기 행이 없으면 어떤 행도 바꾸지 않는가?
첫 실패를 관찰한 뒤 함수를 교체한다. 개발자 메뉴 Reload 또는 앱 재시작 뒤 웹 준비: 완료와 두 행의
아직 요청하지 않음을 확인하고 버튼을 한 번 누른다.
표준 요청 standard-1
표준 응답 · 5000원
특급 요청 express-1
특급 응답 · 18000원
응답 도착 순서: 특급 → 표준
웹 요청 수신: 2웹은 여전히 특급 결과를 먼저 보낸다. 교정은 순서를 바꾸지 않았다. 앱이 응답의 주인을 찾는 기준만
도착 위치에서 requestId로 바꿨다.
일치하지 않는 응답을 억지로 끼워 넣지 않는다
식별값을 도입한 뒤에도 일치 항목이 없을 때 다른 위치를 고르는 다음 대체 규칙을 두면 같은 오류가 되살아난다.
같은 requestId를 찾음 → 그 대기 항목만 갱신
같은 requestId가 없음 → 기록하고 무시
가장 오래된 대기 항목 사용 → 금지
첫 번째 빈 항목 사용 → 금지모르는 식별값은 이전 화면에서 늦게 온 응답, 이미 처리한 중복 응답, 다른 WebView 문서의 응답 또는
메시지 규약 오류일 수 있다. 이 글은 도착 순서에는 응답을 기록하되, 일치 항목이 없으면 요청 행을
담은 rows state는 그대로 둔다. 실제 제품에서는 unknown_response_id처럼 원문을 제외한 안전한
진단 신호를 남겨 조사할 수 있다.
식별값은 page load 경계도 포함해야 한다. 14편처럼 현재 페이지가 준비됐다는 web.ready 뒤에 요청하고,
새 문서를 load하면 이전 load의 대기 요청을 취소하거나 어느 load에 속했는지 구분해야 한다. 이 글은
버튼이 모든 응답을 받은 뒤에만 다시 활성화되는 단일 load fixture로 그 범위를 좁혔다.
연결 성공과 입력 검증을 분리한다
요청 식별값이 같다는 사실은 응답 전체가 올바르다는 뜻이 아니다.
| 확인 | 이 글이 하는가 | 다음 경계 |
|---|---|---|
| 응답을 원래 대기 요청에 연결 | 예 | requestId 일치 |
type, service, amount 구조 검사 | 아니오 | 16편 입력 검증 |
| 허용한 WebView 문서가 보냈는지 확인 | 아니오 | 출처·navigation 정책 |
| 결제·권한 같은 민감 동작 허용 | 아니오 | 인증·권한 계약 |
고정 fixture의 JSON.parse 결과를 QuoteResponse로 사용한 것은 입력 모양을 이미 알고 있는 실험
전제다. 외부 페이지나 변경 가능한 페이지에서는 먼저 문자열을 안전하게 해석하고 구조·값을 검사해야
한다. 아는 requestId가 들어 있다는 이유로 나머지 필드를 신뢰하지 않는다.
iOS와 Android에서 무엇을 각각 확인할까
| 관찰 | Android emulator | iOS Simulator | 실기기 조건 |
|---|---|---|---|
| 웹이 특급 응답을 먼저 보냄 | 확인 | 확인 | 불필요 |
| 잘못된 코드가 두 행을 뒤바꿈 | 확인 | 확인 | 불필요 |
requestId 교정 뒤 정확한 귀속 | 확인 | 확인 | 불필요 |
| 실제 결제·장치 SDK 응답 연결 | 가상 환경 제공 범위 | 가상 환경 제공 범위 | 장치 기능이 판단 대상이면 필요 |
한 플랫폼의 성공으로 다른 플랫폼을 대신하지 않는다. 이 fixture는 14편에서 확인한 Apple의 Window
사건과 새 React Native 구조용 Android의 Document 사건을 모두 받는 페이지 수신 함수를 유지한다.
pull request에서 달라져야 할 행동
WebView 요청·응답을 추가할 때 다음 계약을 먼저 적는다.
요청 종류 type과 요청 의미
식별값 소유자 어느 쪽이 requestId를 만드는가
유일성 범위 어느 대기 요청 집합에서 겹치지 않아야 하는가
응답 규칙 같은 requestId를 그대로 돌려주는가
대기 저장소 어떤 state·map에 요청 문맥을 보관하는가
일치 규칙 어느 필드로 정확한 대기 항목을 찾는가
불일치 처리 알 수 없음·중복·이미 끝난 id를 어떻게 기록하는가
load 종료 페이지 교체 때 남은 요청을 어떻게 끝내는가
관찰 증거 요청 순서와 다른 응답 순서로 무엇을 검증하는가
입력 검증 식별값 연결 뒤 어떤 구조·값을 검사하는가검토자는 다음을 확인한다.
- 배열 위치나 “먼저 보냄”을 요청의 정체성으로 사용하지 않았는가?
- 아직 끝나지 않은 요청 사이에서
requestId가 겹치지 않는가? - 응답자가 요청의 식별값을 바꾸지 않고 돌려주는가?
- 앱이 같은 식별값의 대기 항목만 갱신하는가?
- 모르는 식별값을 다른 대기 항목에 억지로 넣지 않는가?
- 역순 응답 fixture로 잘못된 구현이 실패하고 교정이 통과하는가?
- 식별값 일치와 메시지 구조·출처 신뢰를 분리했는가?
한 장으로 다시 보기
문제 특급 18000원이 표준 행에, 표준 5000원이 특급 행에 표시
오판 첫 번째 응답은 첫 번째 대기 행의 결과라고 가정
관찰 요청은 standard→express, 응답은 express→standard
원인 도착 순서를 요청의 정체성으로 사용
행동 앱이 requestId 생성 → 웹이 같은 값 응답 → 앱이 같은 행 탐색
검증 역순 응답을 유지해도 standard=5000, express=18000
경계 requestId 일치는 응답 구조·값·출처의 신뢰를 증명하지 않음요청 순서는 실행 기록일 뿐 응답의 주인을 보장하지 않는다. 동시에 기다리는 요청마다 식별값을 만들고, 응답자가 그 값을 그대로 돌려주며, 요청자가 같은 식별값의 대기 항목만 갱신해야 역순 완료가 정상 동작이 된다.
스스로 확인할 질문
- 먼저 보낸 요청이 먼저 끝난다는 가정이 왜 안전하지 않은가?
- 요청 식별값은 어느 쪽에서 만들고 응답에서는 어떻게 다뤄야 하는가?
- 응답 순서가 뒤집혀도 정확한 React state 행을 찾는 기준은 무엇인가?
- 모르는 식별값이 도착했을 때 첫 대기 항목을 갱신하면 안 되는 이유는 무엇인가?
- 요청 식별값이 일치해도 메시지 입력 검증이 별도로 필요한 이유는 무엇인가?
Active recall
기억에서 꺼내 보기
답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.
01표준 배송 요청 다음에 특급 배송 요청을 보냈다면 첫 번째 응답도 표준 배송 결과라고 보아도 될까?
정답
아니다. 동시에 끝나기를 기다리는 작업의 응답은 요청 순서와 다르게 도착할 수 있다. 도착 위치가 아니라 응답에 돌아온 요청 식별값으로 원래 항목을 찾는다.
관련 설명 다시 읽기왜 그런가
배열 순서와 응답 도착 순서는 요청의 정체성이 아니다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
02앱에서 요청 식별값을 만들기만 하면 웹이 응답에 돌려주지 않아도 연결할 수 있을까?
정답
아니다. 앱이 요청에 식별값을 넣고 웹이 같은 값을 응답에 복사하며 앱이 그 값으로 기다리던 항목을 찾아야 왕복 계약이 닫힌다.
관련 설명 다시 읽기왜 그런가
한쪽의 로컬 번호가 아니라 요청과 응답이 공유하는 연결 값이다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
03응답의 요청 식별값이 현재 기다리는 항목에 없으면 가장 비슷한 항목이나 첫 대기 항목에 넣어도 될까?
정답
아니다. 알 수 없거나 이미 끝난 식별값은 일치하지 않는 응답으로 기록하고 화면 항목을 임의로 바꾸지 않는다.
관련 설명 다시 읽기왜 그런가
일치 항목이 없을 때 다른 위치를 고르는 대체 규칙은 식별자의 의미를 다시 없앤다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
04요청 식별값이 현재 대기 항목과 같으면 응답의 나머지 필드도 안전하다고 보아도 될까?
정답
아니다. 식별값은 어느 요청의 후보 응답인지 연결할 뿐이다. 메시지 구조·값·출처와 허용 동작 검사는 16편의 별도 경계다.
관련 설명 다시 읽기왜 그런가
응답 연결과 입력 신뢰는 서로 다른 책임이다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
출처와 검증 범위
아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.
- JSON-RPC 2.0 SpecificationJSON-RPC Working Group · 표준 명세 · 확인 2026-08-09
- React Native WebView 14.0.1 ReferenceReact Native WebView · 공식 문서 · 확인 2026-08-09
- React Native WebView 14.0.1 package root type declarationsReact Native WebView · 소스 코드 · 확인 2026-08-09
- React Native WebView 14.0.1 WebView event typesReact Native WebView · 소스 코드 · 확인 2026-08-09
- React Native WebView 14.0.1 Apple postMessage implementationReact Native WebView · 소스 코드 · 확인 2026-08-09
- React Native WebView 14.0.1 Android New Architecture managerReact Native WebView · 소스 코드 · 확인 2026-08-09
- Updating Arrays in StateReact · 공식 문서 · 확인 2026-08-09
- useRefReact · 공식 문서 · 확인 2026-08-09
- ECMAScript Language Specification — JSON.parse and JSON.stringifyEcma International · 표준 명세 · 확인 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