런타임 입력 검증: TypeScript 타입만으로 API 응답을 믿을 수 없는 이유
200 응답의 실제 JSON이 TypeScript 선언과 다른 실패를 재현하고, 확인되지 않은 입력을 Zod 스키마로 검증해 Query 오류와 화면 계약의 경계를 세운다.
목차
표준·구현·측정·해석 표시는 무엇인가요?
- 표준웹 표준이나 언어 명세가 정한 동작
- 구현특정 기술이나 브라우저가 실제로 구현한 동작
- 측정명시한 환경에서 직접 실행해 관찰한 결과
- 해석앞선 근거에서 도출한 설계 판단
- 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용
30초 요약
TypeScript는 코드를 실행하기 전에 우리가 작성한 값 사용법을 검사하지만, 서버가 실제로 보낸 JSON을
대신 검사하지 않는다. response.json() as ProductResponse는 실행 중 확인이 아니라 개발자의 주장이다.
외부 값은 일단 어떤 값인지 아직 증명하지 않았다는 unknown으로 받고, 경계에서 스키마를 통과한
값만 mapper와 컴포넌트에 넘긴다.
이 글을 관통하는 상황: 200 응답인데 상품 카드가 무너진다
19편의 상품 응답과 카드를 이어 간다. 서버 배포 중 가격의 숫자가 문자열로 바뀌고 재고 객체가
null이 되었지만 HTTP 상태는 200이라고 하자.
19편의 JavaScript Vite 앱에서 이 실험을 이어가려면 Zod와 TypeScript를 추가한다. React, TanStack Query, Vite와 Vitest는 19편에서 설치한 상태를 그대로 사용한다.
pnpm add [email protected]
pnpm add -D [email protected] @types/[email protected] @types/[email protected]{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}이 설정은 target과 lib로 현대 JavaScript·브라우저 DOM을 검사하고, module과
moduleResolution로 Vite의 파일 가져오기 방식에 맞춘다. jsx는 React 문법을, strict는 엄격한 타입
검사를 켠다. isolatedModules는 파일별 변환에 맞지 않는 문법을 잡고, skipLibCheck는 설치한 패키지의
타입 선언 검사를 생략할 뿐 src의 앱 코드 검사를 끄지 않는다.
실험이 실제 서버에 의존하지 않도록 두 고정 응답을 만든다. RESPONSE_VERSION만 바꾸면 같은 가짜
요청 함수에서 정상·불일치 입력을 재현할 수 있다.
export const VALID_PRODUCT_PAYLOAD = {
id: 'note-1',
display_name: '불변 업데이트 노트',
price: { amount: 12900, currency: 'KRW' },
inventory: { status: 'available' },
}
const INVALID_PRODUCT_PAYLOAD = {
id: 'note-1',
display_name: '불변 업데이트 노트',
price: { amount: '12900', currency: 'KRW' },
inventory: null,
}
const RESPONSE_VERSION: string = 'invalid'
export interface JsonResponse {
ok: boolean
status: number
json(): Promise<unknown>
}
export async function fakeFetchProduct(): Promise<JsonResponse> {
return {
ok: true,
status: 200,
async json() {
return RESPONSE_VERSION === 'valid'
? VALID_PRODUCT_PAYLOAD
: INVALID_PRODUCT_PAYLOAD
},
}
}다음 코드는 타입 이름을 직접 작성하고 as ProductResponse를 붙인다. 타입 단언은 “이 값의 타입을
내가 더 잘 안다”고 TypeScript에 알려 주는 문법이다.
import { fakeFetchProduct, type JsonResponse } from './fake-product-server'
type ProductResponse = {
id: string
display_name: string
price: { amount: number; currency: 'KRW' }
inventory: { status: 'available' | 'sold_out' }
}
type ProductRequest = () => Promise<JsonResponse>
export async function fetchProductResponseBad(
request: ProductRequest = fakeFetchProduct,
): Promise<ProductResponse> {
const response = await request()
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return (await response.json()) as ProductResponse
}잘못된 queryFn을 실제 카드에 연결한다. 19편의 product-card-bad.jsx는 다음
product-card-bad.tsx로 이름을 바꾸며 내용을 교체하고 이전 .jsx 파일은 남기지 않는다. mapper를
Query의 select가 아니라 Render 중 호출하도록 두어,
요청 성공과 Render 실패를 분리해 관찰한다.
import { useQuery } from '@tanstack/react-query'
import { fetchProductResponseBad } from './product-api-bad'
import { toProductCardViewModel } from './product-card-view-model'
export default function ProductCardBad() {
const productQuery = useQuery({
queryKey: ['product', 'note-1', 'bad'],
queryFn: () => fetchProductResponseBad(),
retry: false,
})
if (productQuery.isPending) return <p>불러오는 중</p>
if (productQuery.isError) return <p>상품을 불러오지 못했습니다.</p>
const product = toProductCardViewModel(productQuery.data)
return <p>{product.name}</p>
}TypeScript 검사는 통과한다. 그러나 fetchProductResponseBad()가 돌려준 값의 inventory는 실제로
null이다. Query는 200 본문을 성공 data로 받지만, 이어지는 Render에서 mapper가
productResponse.inventory.status를 읽는 순간 Cannot read properties of null 오류가 난다.
개발 화면에는 React 오류 표시가 뜨고 일반 Query 오류 문구는 보이지 않는다. 이 결과는 mapper를
Render에서 호출한 위 코드에 한정된다. select 안에서 mapper를 호출하면 구독 결과의 오류 처리 경로가
달라질 수 있으므로 입력 검증을 대신하는 방법으로 사용하지 않는다.
타입은 코드를 실행하기 전에만 검사한다
따라서 타입은 이미 신뢰할 수 있는 코드 안에서 price.amount를 문자열로 잘못 다루는 실수를 잡는 데
유용하다. 반대로 네트워크 응답, 저장소, URL, 딥 링크나 푸시 알림처럼 프로그램 밖에서 들어온 값이
타입 선언을 지켰는지는 실행 중 별도로 확인해야 한다.
외부 값은 증명되기 전까지 unknown이다
TypeScript의 unknown은 “어떤 값이든 들어올 수 있지만 아직 무엇인지 증명하지 않았다”는 타입이다.
any처럼 타입 검사를 끄는 값과 달리, 좁혀지기 전에는 속성을 바로 읽을 수 없다. 여기서 좁히기는
검사 결과를 근거로 더 구체적인 타입으로 다루게 되는 과정이다.
unknown 자체가 값을 검사하거나 고치지는 않는다. 다음 단계에서 실제 규칙을 실행해야 한다.
const input: unknown = await response.json()
// input.inventory.status
// 오류: input은 아직 unknown이다.스키마 하나에서 검사 규칙과 타입을 만든다
스키마는 허용할 필드, 중첩 구조와 값의 종류를 코드로 적은 규칙이다. 프로젝트가 사용하는 Zod 4.3.6으로 19편의 현재 상품 응답 계약을 선언한다.
import * as z from 'zod'
export const ProductResponseSchema = z.object({
id: z.string().min(1),
display_name: z.string().min(1),
price: z.object({
amount: z.number().int().nonnegative(),
currency: z.literal('KRW'),
}),
inventory: z.object({
status: z.enum(['available', 'sold_out']),
}),
})
export type ProductResponse = z.infer<typeof ProductResponseSchema>z.infer는 스키마가 성공했을 때의 TypeScript 타입을 얻는다. 검사 규칙과 별도 수기 타입을 나란히
유지하지 않으므로 둘 중 하나만 바뀌는 위험을 줄인다. 다만 스키마가 서버 구현을 자동으로 바꾸는 것은
아니다. 서버 계약이 바뀌면 스키마, fixture와 소비 코드가 의도한 변경인지 함께 검토해야 한다.
19편의 product-card-view-model.js도 다음 product-card-view-model.ts로 이름을 바꾸며 교체하고
이전 .js 파일은 남기지 않는다. 그래야 확장자 없는 import가 이전 JavaScript mapper를 선택하지 않고
검증된 입력 타입을 연결한다.
import type { ProductResponse } from './product-response'
export type ProductCardViewModel = {
id: string
name: string
priceText: string
canBuy: boolean
availabilityText: string
}
export function toProductCardViewModel(
productResponse: ProductResponse,
): ProductCardViewModel {
const canBuy = productResponse.inventory.status === 'available'
return {
id: productResponse.id,
name: productResponse.display_name,
priceText: `${productResponse.price.amount.toLocaleString('ko-KR')}원`,
canBuy,
availabilityText: canBuy ? '구매 가능' : '구매 불가',
}
}계약 오류를 Query의 오류 상태로 보낸다
스키마 실패를 일반 HTTP 오류와 구분할 오류로 감싼다. 19편의 product-api.js는 다음
product-api.ts로 이름을 바꾸며 교체하고 이전 .js 파일은 남기지 않는다. 두 파일이 함께 있으면
확장자 없는 import가 이전 JavaScript 구현을 선택할 수 있다. 새 오류에는 원본 응답 전체 대신 문제
위치와 종류만 남긴다. 사용자 정보나 토큰이 포함될 수 있는 원본 본문을 로그에 그대로 복사하지 않기
위해서다.
import * as z from 'zod'
import { fakeFetchProduct, type JsonResponse } from './fake-product-server'
import { ProductResponseSchema } from './product-response'
type ProductRequest = () => Promise<JsonResponse>
export class ProductContractError extends Error {
readonly issues: Array<{ path: string; code: string }>
constructor(error: z.ZodError) {
super('상품 응답 형식이 예상과 다릅니다.')
this.name = 'ProductContractError'
this.issues = error.issues.map((issue) => ({
path: issue.path.join('.'),
code: issue.code,
}))
}
}
export async function fetchProductResponse(
request: ProductRequest = fakeFetchProduct,
) {
const response = await request()
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const input: unknown = await response.json()
const result = ProductResponseSchema.safeParse(input)
if (!result.success) {
throw new ProductContractError(result.error)
}
return result.data
}result.data는 이 지점부터 ProductResponse다. 검증 전 값과 검증 후 값이 같은 변수에 섞이지 않아,
mapper와 컴포넌트는 검증된 계약만 입력으로 받는다.
상품 카드에서는 계약 오류만 별도 문구로 표시한다. 19편의 product-card.jsx는 다음
product-card.tsx로 이름을 바꾸며 교체하고 이전 .jsx 파일은 남기지 않는다. 현재 고정 실험에서는
한 번의 결과를 보기 위해 retry: false로 자동 재시도를 끈다.
import { useQuery } from '@tanstack/react-query'
import {
fetchProductResponse,
ProductContractError,
} from './product-api'
import { toProductCardViewModel } from './product-card-view-model'
export default function ProductCard() {
const productQuery = useQuery({
queryKey: ['product', 'note-1'],
queryFn: () => fetchProductResponse(),
select: toProductCardViewModel,
retry: false,
})
if (productQuery.isPending) return <p>불러오는 중</p>
if (productQuery.isError) {
const message = productQuery.error instanceof ProductContractError
? '상품 정보를 확인하고 있습니다. 잠시 후 다시 시도해 주세요.'
: '상품을 불러오지 못했습니다.'
return <p>{message}</p>
}
const product = productQuery.data
return (
<article>
<h2>{product.name}</h2>
<p>{product.priceText}</p>
<p>{product.availabilityText}</p>
<button disabled={!product.canBuy}>구매하기</button>
</article>
)
}이제 불일치 응답에서는 mapper나 Render가 무너지기 전에 Query 오류 UI가 보인다.
retry(failureCount, error) {
if (error instanceof ProductContractError) return false
return failureCount < 2
}이 코드는 모든 요청을 두 번 더 시도하라는 규칙이 아니다. 요청을 반복해도 서버 상태를 추가로 바꾸지 않는지, 서버 부하, 오프라인 상태와 실제 오류 종류를 보고 재시도 대상을 정해야 한다.
검증은 한 번 하고 mapper는 검증된 값만 받는다
응답 본문 unknown
↓ ProductResponseSchema.safeParse
검증된 ProductResponse
↓ toProductCardViewModel
ProductCardViewModel
↓ React Render
화면이 순서는 책임을 구분한다.
- 스키마 실패: 서버 응답과 앱 계약이 다르다.
- mapper 실패: 검증된 응답을 화면 의미로 해석하는 코드에 문제가 있다.
- 렌더 실패: 이미 만들어진 화면 모델을 표시하는 코드에 문제가 있다.
API 클라이언트가 공통 응답을 여러 화면에 제공한다면 검증은 그 클라이언트 경계에 둔다. 특정 화면만 받는 별도 응답이면 해당 queryFn 가까이에 둘 수 있다. 위치는 달라도 “외부 값이 처음 신뢰 영역으로 들어오는 한 지점”이라는 기준은 같다.
세 fixture로 경계를 검증한다
정상 응답, 잘못된 숫자, null 재고를 각각 고정한다. 첫 검사는 스키마 성공을, 나머지는 어느 경로에서
거절했는지를 증명한다.
import { describe, expect, test } from 'vitest'
import {
fetchProductResponse,
ProductContractError,
} from './product-api'
import { ProductResponseSchema } from './product-response'
const validProduct = {
id: 'note-1',
display_name: '불변 업데이트 노트',
price: { amount: 12900, currency: 'KRW' },
inventory: { status: 'available' },
}
describe('ProductResponseSchema', () => {
test('현재 상품 응답을 허용한다', () => {
expect(ProductResponseSchema.safeParse(validProduct).success).toBe(true)
})
test('문자열 가격을 price.amount에서 거절한다', () => {
const result = ProductResponseSchema.safeParse({
...validProduct,
price: { amount: '12900', currency: 'KRW' },
})
expect(result.success).toBe(false)
if (result.success) return
expect(result.error.issues[0]?.path).toEqual(['price', 'amount'])
})
test('null 재고를 inventory에서 거절한다', () => {
const result = ProductResponseSchema.safeParse({
...validProduct,
inventory: null,
})
expect(result.success).toBe(false)
if (result.success) return
expect(result.error.issues[0]?.path).toEqual(['inventory'])
})
test('API 경계에서 안전한 issue 경로와 코드만 남긴다', async () => {
expect.assertions(2)
try {
await fetchProductResponse(async () => ({
ok: true,
status: 200,
async json() {
return {
...validProduct,
price: { amount: '12900', currency: 'KRW' },
inventory: null,
}
},
}))
} catch (error) {
expect(error).toBeInstanceOf(ProductContractError)
if (!(error instanceof ProductContractError)) return
expect(error.issues).toEqual([
{ path: 'price.amount', code: 'invalid_type' },
{ path: 'inventory', code: 'invalid_type' },
])
}
})
})19편의 product-card-view-model.test.js도 다음 TypeScript 검사로 이름을 바꾸며 교체한다. 정상 fixture를
스키마로 검증한 뒤 mapper에 전달하므로 입력 경계와 기존 화면 계약을 함께 보존한다.
import { expect, test } from 'vitest'
import { VALID_PRODUCT_PAYLOAD } from './fake-product-server'
import { toProductCardViewModel } from './product-card-view-model'
import { ProductResponseSchema } from './product-response'
test('검증된 상품 응답을 카드 표시 계약으로 바꾼다', () => {
const result = ProductResponseSchema.safeParse(VALID_PRODUCT_PAYLOAD)
expect(result.success).toBe(true)
if (!result.success) return
expect(toProductCardViewModel(result.data)).toEqual({
id: 'note-1',
name: '불변 업데이트 노트',
priceText: '12,900원',
canBuy: true,
availabilityText: '구매 가능',
})
})두 검사 파일의 실행 명령은 시작 파일 전환까지 마친 뒤 함께 실행한다.
마지막으로 19편의 src/main.jsx를 다음 src/main.tsx로 교체해 bad와 good 카드를 같은 시작 파일에서
선택한다. QueryClientProvider는 카드가 공유할 QueryClient를 React 트리에 제공한다.
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import ProductCardBad from './product-card-bad'
import ProductCard from './product-card'
const queryClient = new QueryClient()
const SHOW_BAD_CARD = true
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('root 요소를 찾지 못했습니다.')
}
createRoot(rootElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
{SHOW_BAD_CARD ? <ProductCardBad /> : <ProductCard />}
</QueryClientProvider>
</StrictMode>,
)index.html이 기존 /src/main.jsx를 가리키고 있었다면 시작 경로도 함께 바꾼다.
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>이제 이전 product-api.js, product-card-view-model.js, product-card-view-model.test.js,
product-card.jsx, product-card-bad.jsx가 남지 않았는지 확인한 뒤 최종 시작 파일까지 타입 검사한다.
이어 두 파일과 검사 5개를 확인하고, 마지막 명령으로 이 Vite 앱의 다른 검사까지 모두 통과하는지 본다.
pnpm exec tsc --noEmit
pnpm exec vitest run src/product-response.test.ts src/product-card-view-model.test.ts
pnpm exec vitest runRESPONSE_VERSION = 'invalid', SHOW_BAD_CARD = true에서는 React 오류 표시를 확인한다. 그다음
SHOW_BAD_CARD = false로 바꾸고 완전히 새로고침하면 계약 오류 UI가 보여야 한다. focused test는
원본 본문을 기록하지 않고 ProductContractError.issues의 price.amount, inventory를 증명한다.
마지막으로 RESPONSE_VERSION = 'valid'로 바꾸고 완전히 새로고침하면 이름, 12,900원, 구매 가능과
활성 구매 버튼이 다시 보여야 한다.
Web과 React Native에서도 경계 원칙은 같다
React Native도 TypeScript 타입을 실행 중 검사로 바꾸지 않는다. 네트워크 JSON뿐 아니라 기기 저장소, 앱의 특정 화면을 여는 링크, 푸시 알림에 실린 실제 데이터 본문과 네이티브 코드가 돌려준 값처럼 앱 밖에서 들어오는 값에 같은 경계를 적용할 수 있다.
다만 모든 내부 객체를 스키마로 검사할 필요는 없다. 같은 TypeScript 빌드 안에서 이미 검증된 함수 반환값과 props까지 반복 검사하면 비용과 규칙 중복만 늘 수 있다. 신뢰할 수 없는 출처와 신뢰 영역이 만나는 지점, 그리고 독립 배포되는 시스템의 계약이 만나는 지점을 우선한다.
문제 200 응답의 실제 JSON이 선언한 ProductResponse와 다름
잘못된 판단 as ProductResponse가 실행 중 형식도 확인해 준다고 생각함
관찰 Query 성공 뒤 mapper가 null의 status를 읽으며 화면이 무너짐
원인 컴파일 전 타입 검사와 실행 중 외부 입력 검증을 같은 보장으로 봄
행동 unknown 입력을 스키마로 검증하고 계약 오류를 queryFn에서 던짐
검증 정상·문자열 가격·null 재고 fixture와 실제 오류 UI를 각각 확인PR에서 바로 확인할 항목
response.json()결과를 바로 구체 타입으로 단언하고 있지 않은가?- 외부 값은 검증 전
unknown, 검증 후 구체 타입으로 경계가 보이는가? - 스키마와 수기 TypeScript 타입이 서로 다른 진실로 중복되지 않는가?
- 입력 검증이 mapper와 Render보다 먼저 한 번 실행되는가?
- 계약 오류가 HTTP·네트워크 오류와 구분되어 Query 오류 상태로 전달되는가?
- 같은 잘못된 응답을 자동 재시도해 서버와 사용자 대기만 늘리지 않는가?
- 로그가 원본 응답 대신 필요한 issue 경로·코드만 남기는가?
기억할 질문은 하나다.
이 값은 TypeScript가 이미 아는 내부 값인가, 실행 중 처음 만난 외부 값인가?
Active recall
기억에서 꺼내 보기
답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.
01response.json() 결과에 as ProductResponse를 붙이면 실제 JSON도 그 형태인지 검사되는가?
정답
아니다. 타입 단언은 컴파일 뒤 사라지며 실행 중 값을 읽거나 검사하지 않는다.
관련 설명 다시 읽기왜 그런가
서버가 amount를 문자열로 보내거나 inventory를 null로 보내도 단언 자체는 성공하고 잘못된 값이 컴포넌트까지 흐른다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
02외부 JSON을 검증하기 전 unknown으로 두는 이유는 무엇인가?
정답
어떤 값이든 들어올 수 있지만 증명 전에는 속성을 바로 읽을 수 없게 해 검증 경계를 건너뛰지 못하게 하기 위해서다.
관련 설명 다시 읽기왜 그런가
any나 즉시 단언을 쓰면 이후 코드가 검증되지 않은 값을 이미 ProductResponse인 것처럼 사용한다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
03런타임 검증과 뷰 모델 mapping은 어느 순서로 실행해야 하는가?
정답
외부 입력을 먼저 스키마로 검증해 신뢰 가능한 응답을 만든 뒤 mapper로 화면 의미를 파생한다.
관련 설명 다시 읽기왜 그런가
mapper가 예외를 던진 뒤에야 형식 불일치를 발견하면 계약 오류와 화면 변환 오류를 구분하기 어렵다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
04스키마가 API 응답 불일치를 발견하면 React 컴포넌트가 직접 처리해야 하는가?
정답
queryFn이 구분 가능한 계약 오류를 던지면 Query는 실패로 처리하고, 재시도하지 않거나 정책을 소진한 뒤 오류 상태와 error 값으로 전달한다.
관련 설명 다시 읽기왜 그런가
같은 응답을 다시 받아도 고쳐지지 않는 계약 오류는 일시적 네트워크 실패와 분리해 자동 재시도를 막을 수 있다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
출처와 검증 범위
아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.
- TypeScript for the New ProgrammerTypeScript · 공식 문서 · 확인 2026-08-09
- Everyday Types - Type AssertionsTypeScript · 공식 문서 · 확인 2026-08-09
- More on Functions - unknownTypeScript · 공식 문서 · 확인 2026-08-09
- Zod 4 - Basic usageZod · 공식 문서 · 확인 2026-08-09
- useQueryTanStack · 공식 문서 · 확인 2026-08-09
- RFC 9110 - 200 OKInternet Engineering Task Force · RFC · 확인 2026-08-09
- Vite Features - TypeScriptVite · 공식 문서 · 확인 2026-08-09
- TSConfig noEmitTypeScript · 공식 문서 · 확인 2026-08-09