WebView 메시지 입력 검증: 식별값이 맞아도 왜 바로 state에 넣으면 안 되는가
WebView 응답의 요청 식별값과 TypeScript 선언만 믿어 합계가 깨지는 실패를 재현하고, 실제 문자열을 스키마로 검증한 뒤에만 앱 state에 반영한다.
목차
표준·구현·측정·해석 표시는 무엇인가요?
- 표준웹 표준이나 언어 명세가 정한 동작
- 구현특정 기술이나 브라우저가 실제로 구현한 동작
- 측정명시한 환경에서 직접 실행해 관찰한 결과
- 해석앞선 근거에서 도출한 설계 판단
- 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용
30초 요약
WebView의 onMessage는 event.nativeEvent.data에 문자열을 전달한다. TypeScript 응답 타입은 그 문자열
안의 실제 값을 검사하지 않는다.
WebView 원문 문자열
→ JSON.parse 결과를 unknown으로 둠
→ 스키마 검증 실패: 오류로 끝내고 state 유지
→ 스키마 검증 성공: requestId 연결 → state 반영반복해서 기억할 문장은 이것이다.
메시지를 먼저 증명하고, 증명한 값만 요청과 연결해 앱 state에 넣는다.
이 글은 값의 구조와 종류를 검사하는 한 가지 문제만 다룬다. 메시지를 보낸 문서의 인증과 민감한 앱 동작의 권한은 17편 이후의 별도 경계다.
이 글을 관통하는 상황: 15900원이어야 할 합계가 129003000원이 됐다
15편의 요청 식별값이 적용된 배송비 흐름을 이어 간다. 앱은 상품 소계 12900원과 배송비 3000원을
더해 15900원을 표시해야 한다. 그러나 연습 페이지의 한 응답은 소계를 숫자 12900이 아니라 문자열
"12900"으로 보낸다.
먼저 12편에서 만든 WebBoundaryApp 루트에서 검증 라이브러리를 정확한 버전으로 설치한다.
npm install [email protected]Zod는 실제 값이 선언한 규칙과 맞는지 실행 중 검사하는 라이브러리다. 첫 실패 코드는 아직 Zod를 사용하지 않지만, 뒤의 교정과 같은 설치 상태에서 비교한다.
다음은 실행 가능한 실패 App.tsx다. originWhitelist의 *는 주소 이동이 없는 인라인 HTML fixture에만
사용한다. 실제 URL을 여는 WebView의 허용 출처와 이동 정책은 별도로 좁혀야 한다.
import React, { useRef, useState } from 'react'
import { Button, SafeAreaView, StyleSheet, Text } from 'react-native'
import { WebView } from 'react-native-webview'
type ResponseMode = 'invalid' | 'valid'
type CheckoutTotalRequest = {
type: 'checkout.total.request'
requestId: string
responseMode: ResponseMode
}
type CheckoutTotalResponse = {
type: 'checkout.total.result'
version: 1
requestId: string
subtotal: number
shipping: number
}
const PAGE_HTML = `<!doctype html>
<html lang="ko">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<body>
<p>주문 합계 계산 페이지</p>
<p id="sent">웹 응답 전송: 0</p>
<script>
let sentCount = 0
function receiveRequest(event) {
const request = JSON.parse(event.data)
const subtotal =
request.responseMode === 'invalid' ? '12900' : 12900
sentCount += 1
document.querySelector('#sent').textContent =
'웹 응답 전송: ' + String(sentCount)
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'checkout.total.result',
version: 1,
requestId: request.requestId,
subtotal,
shipping: 3000,
}))
}
document.addEventListener('message', receiveRequest)
window.addEventListener('message', (event) => {
if (event.target === window) {
receiveRequest(event)
}
})
window.ReactNativeWebView.postMessage('web.ready')
</script>
</body>
</html>`
export default function App() {
const webViewRef = useRef<WebView>(null)
const nextRequestRef = useRef(1)
const pendingRequestIdRef = useRef<string | null>(null)
const [webReady, setWebReady] = useState(false)
const [requesting, setRequesting] = useState(false)
const [status, setStatus] = useState('아직 요청하지 않음')
const [total, setTotal] = useState<number | null>(null)
function requestTotal(responseMode: ResponseMode) {
if (!webReady || !webViewRef.current || requesting) return
const request: CheckoutTotalRequest = {
type: 'checkout.total.request',
requestId: 'total-' + String(nextRequestRef.current),
responseMode,
}
nextRequestRef.current += 1
pendingRequestIdRef.current = request.requestId
setRequesting(true)
setStatus(
responseMode === 'invalid'
? '문자열 subtotal 응답 대기'
: '숫자 subtotal 응답 대기',
)
webViewRef.current.postMessage(JSON.stringify(request))
}
function receiveWebMessage(raw: string) {
if (raw === 'web.ready') {
setWebReady(true)
return
}
const response: CheckoutTotalResponse = JSON.parse(raw)
if (response.requestId !== pendingRequestIdRef.current) return
pendingRequestIdRef.current = null
setTotal(response.subtotal + response.shipping)
setStatus('합계 반영 완료')
setRequesting(false)
}
return (
<SafeAreaView style={styles.screen}>
<Text>웹 준비: {webReady ? '완료' : '대기'}</Text>
<Text>상태: {status}</Text>
<Text>앱 주문 합계: {total === null ? '아직 없음' : total + '원'}</Text>
<Button
title="문자열 subtotal 응답 요청"
disabled={!webReady || requesting}
onPress={() => requestTotal('invalid')}
/>
<Button
title="숫자 subtotal 응답 요청"
disabled={!webReady || requesting}
onPress={() => requestTotal('valid')}
/>
<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 },
webview: { flex: 1, borderWidth: 1 },
})실행 전에 예측한다.
CheckoutTotalResponse가subtotal: number라고 적혀 있으므로 문자열"12900"이 자동으로 거절될까?- JavaScript에서 문자열
"12900"과 숫자3000을+로 더하면 어떤 값이 될까? - 요청 식별값이
pendingRequestIdRef와 같으면 나머지 필드도 안전할까?
문자열 subtotal 응답 요청을 한 번 누른다.
상태: 합계 반영 완료
앱 주문 합계: 129003000원
웹 응답 전송: 1requestId는 정확히 일치했고 앱은 완료 상태가 됐다. 그러나 실행 중 실제 subtotal은 문자열이므로
+가 숫자 덧셈이 아니라 문자열 이어 붙이기로 동작했다. 화면은 올바른 응답처럼 잘못된 값을 보존한다.
타입 이름과 실제 메시지 검사를 분리한다
실패 코드의 const response: CheckoutTotalResponse에서 콜론 뒤 이름은 변수의 사용법을 코드를
실행하기 전에 확인하는 TypeScript 검사기에 알리는 **타입 표시(type annotation)**다. 다음 타입 단언은
값 뒤에 as CheckoutTotalResponse를 붙여 개발자가 더 구체적인 타입을 안다고 알리는 별도 문법이다.
코드에 적은 타입 subtotal: number
실제 원문 "subtotal":"12900"
JSON.parse 결과 subtotal은 string
실행 결과 "12900" + 3000 → "129003000"unknown은 값을 자동으로 안전하게 만드는 기능이 아니다. 아직 속성을 사용해도 된다는 근거가 없음을
TypeScript에 표시해, 검사 함수를 거치기 전 접근을 막는 타입이다.
스키마는 허용할 메시지를 실행 규칙으로 적는다
이 응답에서 확인할 계약은 다음과 같다.
| 필드 | 실행 규칙 | 이유 |
|---|---|---|
type | 정확히 checkout.total.result | 다른 메시지 종류와 구분 |
version | 숫자 1 | 해석할 계약 버전 고정 |
requestId | 비어 있지 않은 문자열 | 대기 요청 연결 후보 |
subtotal | 0 이상인 안전한 정수 | 소계 계산 |
shipping | 0 이상인 안전한 정수 | 배송비 계산 |
| 그 밖의 필드 | 허용하지 않음 | 선언한 계약에서 입력 모양이 달라진 사실을 드러냄 |
여기서 안전한 정수는 JavaScript Number가 오차 없이 정확하게 나타낼 수 있는 정수 범위 안의 값을
뜻한다. z.number().int()는 이 범위 밖의 숫자도 거절한다.
마지막 규칙은 모든 제품의 정답이 아니다. 서버와 앱을 한꺼번에 바꾸지 않고 차례로 내보내는 배포에서는
새 버전과 이전 버전이 함께 동작하도록 알 수 없는 필드를 버릴지, 허용할지 처리 규칙을 정할 수 있다.
이 fixture는 버전 1의 정확한 모양을 학습하기 위해 엄격한 객체를 선택한다.
검증한 값만 요청과 연결하고 state에 반영한다
첫 코드의 import에 Zod를 추가하고, 수동 CheckoutTotalResponse 타입을 다음 스키마와 추론 타입으로
교체한다. z.infer는 스키마가 검증에 성공했을 때 돌려줄 값 모양에서 TypeScript 타입을 만들어 수동
타입 선언의 중복을 줄인다. 실행 중 검증은 z.infer가 아니라 뒤에서 호출할 safeParse가 맡는다.
CheckoutTotalRequest, ResponseMode와 나머지 코드는 그대로 둔다.
import * as z from 'zod'
const CheckoutTotalResponseSchema = z.strictObject({
type: z.literal('checkout.total.result'),
version: z.literal(1),
requestId: z.string().min(1),
subtotal: z.number().int().nonnegative(),
shipping: z.number().int().nonnegative(),
})
type CheckoutTotalResponse = z.infer<
typeof CheckoutTotalResponseSchema
>이어서 receiveWebMessage 함수만 다음 코드로 교체한다.
function receiveWebMessage(raw: string) {
if (raw === 'web.ready') {
setWebReady(true)
return
}
let input: unknown
try {
input = JSON.parse(raw)
} catch {
pendingRequestIdRef.current = null
setStatus('응답 JSON 해석 오류')
setRequesting(false)
return
}
const parsed = CheckoutTotalResponseSchema.safeParse(input)
if (!parsed.success) {
const paths = parsed.error.issues
.map((issue) => issue.path.join('.'))
.filter((path) => path.length > 0)
pendingRequestIdRef.current = null
setStatus(
'응답 계약 오류: ' +
(paths.length === 0 ? '메시지 전체' : paths.join(', ')),
)
setRequesting(false)
return
}
const response: CheckoutTotalResponse = parsed.data
if (response.requestId !== pendingRequestIdRef.current) {
setStatus('다른 요청의 응답 무시')
return
}
pendingRequestIdRef.current = null
setTotal(response.subtotal + response.shipping)
setStatus('합계 반영 완료')
setRequesting(false)
}issues는 어떤 규칙이 맞지 않았는지 담은 목록이고 path는 subtotal처럼 실패한 필드 위치다. 실제
제품 화면에는 원문이나 내부 스키마 전체를 노출하지 않고 사용자가 다시 시도할 수 있는 문구를 보여
준다. 개발 진단에는 개인정보를 제외한 메시지 종류, 계약 버전과 실패 경로를 남길 수 있다.
검증 순서가 중요하다.
원문 문자열
1. JSON 해석 성공 확인
2. type·version·requestId·금액 스키마 확인
3. 검증된 requestId가 현재 대기 요청과 같은지 확인
4. 검증된 숫자로 합계 계산
5. state 반영실패는 부분 성공으로 바꾸지 않는다
첫 실패를 관찰한 뒤 스키마와 수신 함수를 교체한다. Reload 또는 앱 재시작 뒤 기준선을 만들고 먼저
문자열 subtotal 응답 요청을 한 번 누른다.
상태: 응답 계약 오류: subtotal
앱 주문 합계: 아직 없음
웹 응답 전송: 1subtotal 검증이 실패했으므로 shipping, requestId가 올바르게 보여도 합계를 일부 반영하지 않는다.
그다음 숫자 subtotal 응답 요청을 누른다.
상태: 합계 반영 완료
앱 주문 합계: 15900원
웹 응답 전송: 2같은 UI, 같은 요청 경로와 같은 합계 계산이지만 실행 중 실제 숫자를 받은 경우에만 state가 바뀐다.
구조 검증과 발신자 신뢰를 분리한다
스키마 검증은 객체 모양과 값 규칙을 증명한다. 다음 항목까지 자동으로 증명하지는 않는다.
| 별도 질문 | 스키마만으로 답할 수 있는가 |
|---|---|
| 현재 허용한 웹 문서가 보냈는가? | 아니오 |
| 로그인한 사용자에게 이 주문 권한이 있는가? | 아니오 |
| 결제·파일·권한 동작을 실행해도 되는가? | 아니오 |
같은 requestId 응답을 이미 처리했는가? | 아니오 |
메시지의 event.nativeEvent.url 같은 WebView 정보와 실제 navigation 정책, 앱 인증 상태, 서버 권한 검사를
별도 계약으로 결합해야 한다. 특히 결제와 권한 변경은 WebView가 보낸 금액이나 허용 여부만으로 최종
결정하지 않고 신뢰하는 네이티브·서버 경계에서 다시 확인한다.
iOS와 Android에서 무엇을 각각 확인할까
| 관찰 | Android emulator | iOS Simulator | 실기기 조건 |
|---|---|---|---|
| 문자열 subtotal이 계약 오류가 됨 | 확인 | 확인 | 불필요 |
| 실패 뒤 합계 state가 유지됨 | 확인 | 확인 | 불필요 |
| 숫자 subtotal만 15900원으로 반영 | 확인 | 확인 | 불필요 |
| 실제 결제·권한·장치 기능 실행 | 가상 환경 제공 범위 | 가상 환경 제공 범위 | 해당 기능이 판단 대상이면 필요 |
한 플랫폼의 통과로 다른 플랫폼을 대신하지 않는다. 공통 TypeScript 코드여도 실제 onMessage 입구와
WebView load는 각 플랫폼 앱에서 실행해 확인한다.
pull request에서 달라져야 할 행동
WebView 메시지를 앱 동작에 연결할 때 다음 계약을 먼저 적는다.
원문 입구 어느 onMessage의 data인가
메시지 종류 허용할 type과 version
필수 필드 이름, 중첩 구조와 값 종류
값 규칙 숫자 범위, 문자열 길이, 허용 후보
추가 필드 거절 / 제거 / 보존 중 어느 정책인가
실패 처리 state 유지, 사용자 문구와 재시도
진단 정보 원문·개인정보를 제외하고 무엇을 기록하는가
요청 연결 검증된 requestId를 어느 대기 항목과 비교하는가
민감 동작 출처·인증·서버 권한을 어디서 다시 확인하는가
검증 fixture 각 필드 누락·잘못된 종류·미지원 version검토자는 다음을 확인한다.
- JSON.parse 결과에 타입 이름만 붙이고 외부 입력 검증을 건너뛰지 않았는가?
type,version, 요청 식별값과 업무 값을 사용 전에 모두 검사하는가?- 실패한 메시지가 앱 state나 민감 동작을 일부 실행하지 않는가?
- 검증 실패와 JSON 해석 실패를 관찰 가능한 상태로 구분하는가?
- 알 수 없는 추가 필드의 호환성 정책을 의도적으로 선택했는가?
- 스키마 통과를 발신자 신뢰나 권한 승인으로 확대하지 않았는가?
- Android와 iOS에서 실패·성공 fixture를 각각 실행했는가?
한 장으로 다시 보기
문제 합계 15900원 대신 129003000원 표시
오판 CheckoutTotalResponse 타입과 맞는 requestId면 안전하다고 판단
관찰 실제 subtotal은 string, + 연산이 문자열 이어 붙이기로 실행
원인 WebView 원문을 검사하기 전에 앱 계산과 state에 사용
행동 unknown → JSON 해석 → 스키마 → requestId 연결 → state
검증 문자열 subtotal은 오류·state 유지, 숫자 subtotal만 15900원
경계 구조 검증은 발신 문서의 신뢰·인증·권한을 증명하지 않음TypeScript 선언은 개발 중 코드 사용법을 도와주지만 WebView가 실행 중 보낸 문자열을 검사하지 않는다. 메시지를 신뢰 영역으로 들이는 입구에서 실제 값을 검증하고, 성공한 값만 요청과 연결하며, 그 뒤에만 계산과 state 변경을 실행해야 한다.
스스로 확인할 질문
CheckoutTotalResponse타입을 붙여도 문자열 subtotal이 실행 중 통과한 이유는 무엇인가?- 메시지를
unknown으로 두는 것은 어떤 검증 누락을 막는가? - 스키마 검증과 요청 식별값 비교는 어떤 순서로 실행해야 하는가?
- 한 필드가 실패했을 때 합계 state를 부분 반영하지 않는 이유는 무엇인가?
- 스키마를 통과한 메시지에도 발신자 신뢰와 권한 검사가 별도로 필요한 이유는 무엇인가?
Active recall
기억에서 꺼내 보기
답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.
01event.nativeEvent.data를 CheckoutTotalResponse 타입 변수에 넣으면 실제 문자열의 subtotal도 숫자인지 검사될까?
정답
아니다. TypeScript의 타입 정보는 실행 중 문자열을 읽지 않는다. JSON.parse 결과는 아직 검증하지 않은 입력으로 두고 별도 런타임 검증을 통과시켜야 한다.
관련 설명 다시 읽기왜 그런가
정적 타입은 작성한 코드의 사용법을 검사하고, WebView가 실제 보낸 값은 앱 실행 중에 도착한다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
02아는 requestId가 문자열 안에 보이면 나머지 필드를 먼저 state에 반영하고 나중에 검사해도 될까?
정답
아니다. 메시지 전체의 구조와 값 규칙을 먼저 검사하고, 성공한 값의 requestId만 대기 요청과 비교한 뒤 state를 갱신한다.
관련 설명 다시 읽기왜 그런가
검증 전에는 requestId 필드 자체도 존재 여부와 값 종류가 증명되지 않았다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
03subtotal만 잘못된 응답이라면 나머지 정상 필드는 먼저 화면에 반영해도 될까?
정답
아니다. 이 계약은 응답 전체를 하나의 단위로 검사하고 실패하면 합계 state를 그대로 둔 채 구분 가능한 계약 오류를 표시한다.
관련 설명 다시 읽기왜 그런가
부분 반영은 화면에 서로 다른 응답 시점과 신뢰 수준의 값을 섞는다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
04메시지가 스키마를 통과하면 허용한 페이지가 보냈고 결제 같은 민감 동작도 실행해도 된다는 뜻일까?
정답
아니다. 스키마는 값의 구조와 규칙을 확인한다. 현재 문서의 출처·인증 상태와 그 메시지에 허용할 동작은 별도 정책이다.
관련 설명 다시 읽기왜 그런가
올바른 모양의 데이터도 권한 없는 발신자가 만들 수 있다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
출처와 검증 범위
아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.
- React Native WebView 14.0.1 ReferenceReact Native WebView · 공식 문서 · 확인 2026-08-09
- React Native WebView 14.0.1 WebView event typesReact Native WebView · 소스 코드 · 확인 2026-08-09
- Everyday Types - Type AssertionsTypeScript · 공식 문서 · 확인 2026-08-09
- More on Functions - unknownTypeScript · 공식 문서 · 확인 2026-08-09
- Zod 4 - Basic usageZod · 공식 문서 · 확인 2026-08-09
- Zod 4 - Defining schemasZod · 공식 문서 · 확인 2026-08-09
- Zod 4.4.3 releaseZod · 소스 코드 · 확인 2026-08-09
- ECMAScript Language Specification — JSON.parse and JSON.stringifyEcma International · 표준 명세 · 확인 2026-08-09
- useStateReact · 공식 문서 · 확인 2026-08-09
- useRefReact · 공식 문서 · 확인 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