API 응답과 뷰 모델: 서버 필드가 바뀌어도 화면 계약을 지키려면
상품 응답의 필드·중첩·상태 값 변경이 화면 전체로 번지는 실패를 재현하고, 순수 mapper와 TanStack Query select로 캐시 원본과 화면 모델의 경계를 선택한다.
목차
표준·구현·측정·해석 표시는 무엇인가요?
- 표준웹 표준이나 언어 명세가 정한 동작
- 구현특정 기술이나 브라우저가 실제로 구현한 동작
- 측정명시한 환경에서 직접 실행해 관찰한 결과
- 해석앞선 근거에서 도출한 설계 판단
- 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용
30초 요약
API 응답은 서버가 전송하기 좋은 필드와 구조를 표현한다. 뷰 모델은 한 화면이 표시하고 판단하기 좋은 이름과 값으로 그 응답을 해석한다. 컴포넌트가 응답 필드를 직접 읽으면 서버 변경이 JSX 전체로 번지지만, 순수 mapper를 두면 변환 지점과 화면 계약을 따로 검증할 수 있다.
여기서 mapper는 응답 모양을 화면 모양으로 바꾸는 함수다. 순수 함수는 입력 객체를 바꾸거나 외부 상태를 건드리지 않고 같은 입력에 같은 결과를 반환하는 함수다.
이 글을 관통하는 상황: 상품 응답이 바뀌자 카드가 조용히 틀어진다
상품 API의 이전 응답은 평평했고, 서버가 가격에 통화 정보를 추가하고 재고 모델을 묶으면서 현재
응답으로 바꿨다고 하자. Vite React 앱의 src 아래에 실험용으로 고정한 입력인 두 fixture와
실험 상수를 함께 둔다. in_stock과 available은 정해진 후보 중 하나를 쓰는 서버 상태 값인
열거값이다.
export const PRODUCT_RESPONSE_BEFORE = {
product_id: 'note-1',
title: '불변 업데이트 노트',
price_won: 12900,
availability: 'in_stock',
}
export const PRODUCT_RESPONSE_CURRENT = {
id: 'note-1',
display_name: '불변 업데이트 노트',
price: {
amount: 12900,
currency: 'KRW',
},
inventory: {
status: 'available',
},
}
const RESPONSE_VERSION = 'current' // 'before' | 'current'
export async function fetchProductResponse() {
await Promise.resolve()
return RESPONSE_VERSION === 'before'
? PRODUCT_RESPONSE_BEFORE
: PRODUCT_RESPONSE_CURRENT
}이 글은 Next.js 경계가 아니라 데이터 모양에 집중하도록 일반 Vite React 앱에서 실행한다. React,
TanStack Query와 Vitest가 설치되어 있고 index.html에 <div id="root"></div>가 있다고 가정한다.
진입 파일에서 Query client와 비교할 카드를 연결한다.
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
createRoot(document.getElementById('root')).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
{SHOW_BAD_CARD ? <ProductCardBad /> : <ProductCard />}
</QueryClientProvider>
</StrictMode>,
)화면이 이전 응답 필드를 직접 읽으면 가짜 응답 함수와 Query가 성공 상태여도 결과가 틀어진다.
import { useQuery } from '@tanstack/react-query'
import { fetchProductResponse } from './product-api'
export default function ProductCardBad() {
const productQuery = useQuery({
queryKey: ['product', 'note-1'],
queryFn: fetchProductResponse,
})
if (productQuery.isPending) return <p>불러오는 중</p>
if (productQuery.isError) return <p>상품을 불러오지 못했습니다.</p>
const product = productQuery.data
const priceText = `${Number(product.price_won).toLocaleString('ko-KR')}원`
const canBuy = product.availability === 'in_stock'
return (
<article>
<h2>{product.title}</h2>
<p>{priceText}</p>
<button disabled={!canBuy}>{canBuy ? '구매하기' : '구매 불가'}</button>
</article>
)
}먼저 RESPONSE_VERSION = 'before', SHOW_BAD_CARD = true로 두고 실행하면 이름, 12,900원,
구매하기가 보인다. 그다음 RESPONSE_VERSION = 'current'로 바꾸고 브라우저를 완전히 새로고침한다.
새로고침은 메모리의 QueryClient와 캐시를 새로 만들기 위한 단계다. 제목은 비고, 가격은 NaN원,
버튼은 구매 불가가 된다. 이 가짜 응답 함수와 Query는 성공 상태이기 때문에 로딩·오류 UI도 이 의미
오류를 잡지 못한다. 실제 API에서도 HTTP 요청 성공과 화면 의미 오류는 함께 일어날 수 있다.
응답 형태와 화면 계약은 변경 이유가 다르다
상품 카드의 계약은 다르다. 카드는 서버 필드 이름보다 다음 질문에 답해야 한다.
- 어떤 이름을 표시하는가?
- 가격을 어떤 문자열로 보여 주는가?
- 지금 구매할 수 있는가?
- 구매할 수 없을 때 어떤 문구를 쓰는가?
서버는 여러 소비자를 위해 통화와 재고 원인을 자세히 보낼 수 있고, 화면은 현재 문맥에 필요한 판단만 표시한다. 두 구조가 우연히 같더라도 변경 이유까지 같다는 뜻은 아니다.
순수 mapper로 화면 계약을 한곳에 둔다
현재 응답을 상품 카드 뷰 모델로 바꾸는 함수를 작성한다.
export function toProductCardViewModel(productResponse) {
const canBuy = productResponse.inventory.status === 'available'
return {
id: productResponse.id,
name: productResponse.display_name,
priceText: `${productResponse.price.amount.toLocaleString('ko-KR')}원`,
canBuy,
availabilityText: canBuy ? '구매 가능' : '구매 불가',
}
}availabilityText를 만들어 놓고 버튼에서 다시 삼항 연산으로 문구를 계산하지 않는다. 한 화면 의미의
결정 지점은 하나로 유지한다.
select는 화면이 받는 값만 바꾼다
TanStack Query의 select에 파일 최상위에 둔 안정된 mapper를 전달한다.
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchProductResponse } from './product-api'
import { toProductCardViewModel } from './product-card-view-model'
const PRODUCT_QUERY_KEY = ['product', 'note-1']
export default function ProductCard() {
const queryClient = useQueryClient()
const productQuery = useQuery({
queryKey: PRODUCT_QUERY_KEY,
queryFn: fetchProductResponse,
select: toProductCardViewModel,
})
if (productQuery.isPending) return <p>불러오는 중</p>
if (productQuery.isError) return <p>상품을 불러오지 못했습니다.</p>
const product = productQuery.data
function logModels() {
console.log('캐시 원본:', queryClient.getQueryData(PRODUCT_QUERY_KEY))
console.log('화면 모델:', product)
}
return (
<article>
<h2>{product.name}</h2>
<p>{product.priceText}</p>
<p>{product.availabilityText}</p>
<button disabled={!product.canBuy}>구매하기</button>
<button type="button" onClick={logModels}>캐시와 화면 모델 기록</button>
</article>
)
}따라서 캐시와 화면 모델 기록을 누르면 첫 로그에는 display_name, price, inventory가 남고,
두 번째 로그에는 name, priceText, canBuy, availabilityText가 보인다.
mapper를 캐시 앞과 뒤 중 어디에 둘지 선택한다
select가 언제나 정답은 아니다. 캐시에 무엇을 저장할지 먼저 정한다.
캐시에 API 응답을 유지 화면마다 select로 서로 다른 뷰 모델을 파생
캐시에 공통 모델을 유지 queryFn 또는 데이터 계층에서 검증·변환 후 저장목록과 상세가 서로 다른 표시 모델을 쓰면서 네트워크 응답도 조사해야 한다면 select가 자연스럽다.
반대로 모든 소비자가 같은 공통 의미 모델을 써야 하고 서버 필드 이름을 앱 전체에서 숨기려면 queryFn이
검증된 응답을 공통 모델로 변환한 뒤 반환하도록 설계할 수 있다. 이때는 같은 Query key를 읽는 모든
소비자와 캐시 갱신 코드가 새 캐시 형태를 따르는지 함께 바꿔야 한다.
뷰 모델을 React state에 복제하지 않는다
다음 자식 컴포넌트처럼 응답 prop이 바뀔 때 Effect로 뷰 모델 state를 맞추면, prop이 바뀐 첫 렌더에는 이전 뷰 모델이 남아 원본과 파생값 사이에 잠시 어긋나는 두 번째 진실이 생긴다.
import { useEffect, useState } from 'react'
import { toProductCardViewModel } from './product-card-view-model'
function ProductCardWithDuplicateState({ productResponse }) {
const [viewModel, setViewModel] = useState(() =>
toProductCardViewModel(productResponse),
)
useEffect(() => {
setViewModel(toProductCardViewModel(productResponse))
}, [productResponse])
return <p>{viewModel.name}</p>
}사용자가 편집 중인 초안처럼 원본 변화와 일부러 분리해 보존할 값은 state가 될 수 있다. 서버 응답을 현재 화면 이름으로 바꾼 결과라는 이유만으로 state가 되는 것은 아니다.
mapper와 런타임 입력 검증을 분리한다
toProductCardViewModel은 inventory.status와 price.amount가 약속대로 존재한다고 가정한다. 실제
네트워크 입력이 그 약속을 지키는지는 queryFn에서 응답을 받은 직후 검증한 다음 mapper에 넘겨야 한다.
TypeScript 타입만으로 실행 중 외부 값의 실제 형태를 보장할 수 없는 이유와 검증 구현은 20편에서
다룬다.
Web, Native와 GraphQL 도구에 옮긴다
순수 mapper는 React DOM이나 Native View를 import하지 않으므로 Web과 React Native가 공유할 수 있다. 다만 통화 표시, 접근성 문구와 상호작용 가능 조건이 플랫폼별로 다르면 공통 응답 해석과 플랫폼 표시 mapper를 분리한다.
GraphQL은 클라이언트가 필요한 서버 필드를 요청에 적는 질의 언어이고, Relay는 그 데이터를 React에서 읽고 캐시하는 도구다. fragment는 한 컴포넌트가 필요한 서버 필드 목록이다.
이 방식은 불필요한 필드 의존성을 줄이지만, 서버의 재고 코드와 가격 구조를 최종 화면 문구로 해석하는 책임까지 자동으로 결정하지는 않는다.
실행 결과를 다섯 증거로 확인한다
- 이전 응답에서는 잘못된 카드도 이름,
12,900원, 구매 가능 상태를 표시하는지 본다. - 현재 응답으로 바꾸면 제목 없음,
NaN원, 구매 불가로 조용히 틀어지는지 본다. SHOW_BAD_CARD = false로 바꾸고 브라우저를 완전히 새로고침한다. mapper와select가 이름,12,900원,구매 가능을 다시 표시하는지 확인한다.- Query 캐시에는 현재 API 응답 형태가, 카드에는 뷰 모델 형태가 남는지 각각 기록한다.
- 다음 순수 함수 계약 검사를 실행해 서버 열거값 해석을 고정한다.
import { expect, test } from 'vitest'
import { PRODUCT_RESPONSE_CURRENT } from './product-api'
import { toProductCardViewModel } from './product-card-view-model'
test('현재 상품 응답을 카드 표시 계약으로 바꾼다', () => {
expect(toProductCardViewModel(PRODUCT_RESPONSE_CURRENT)).toEqual({
id: 'note-1',
name: '불변 업데이트 노트',
priceText: '12,900원',
canBuy: true,
availabilityText: '구매 가능',
})
})다음 명령을 실행해 해당 계약 검사 1개가 통과하는지 확인한다.
pnpm exec vitest run src/product-card-view-model.test.js출력에는 test file 1개와 test 1개가 passed로 표시되어야 한다. 테스트 계층을 어떻게 나눌지는 21편에서 다룬다.
문제 API 응답의 필드·중첩·열거값을 JSX가 직접 해석함
잘못된 판단 타입이 있는 응답 객체를 그대로 props로 쓰면 변경에도 안전함
관찰 가짜 응답 함수와 Query 성공 뒤에도 제목 없음, NaN원, 구매 불가가 표시됨
원인 네트워크 전송 계약과 화면 표시 계약의 변경 이유를 하나로 묶음
행동 순수 mapper로 뷰 모델을 만들고 캐시 전후 위치를 명시적으로 선택
검증 API fixture, 뷰 모델 계약, 캐시 원본과 실제 화면을 각각 확인PR에서 바로 확인할 항목
- JSX가 서버 필드 이름, 중첩 경로와 열거값을 여러 곳에서 직접 해석하지 않는가?
- mapper의 입력은 API 응답, 출력은 화면 의미로 이름 붙였는가?
- 같은 원본에서 계산 가능한 뷰 모델을 별도 React state에 복제하지 않았는가?
select를 썼다면 캐시 원본은 바뀌지 않는다는 계약을 알고 있는가?- 모든 소비자가 공통 모델을 원한다면 캐시 앞에서 변환할지 결정했는가?
- 실제 외부 입력 검증과 화면용 mapping을 같은 책임으로 오해하지 않았는가?
기억할 질문은 하나다.
이 필드 이름과 값은 서버 전송 계약 때문에 존재하는가, 이 화면이 판단하기 위해 존재하는가?
Active recall
기억에서 꺼내 보기
답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.
01API 응답 타입이 있으면 화면 컴포넌트가 그 필드를 바로 사용해도 되는가?
정답
항상 그렇지는 않다. 응답은 전송 계약이고 화면은 표시 문구, 활성 조건과 단위를 해석한 뷰 모델이 필요할 수 있다.
관련 설명 다시 읽기왜 그런가
직접 사용하면 display_name, price와 inventory 같은 서버 구조 변경이 JSX 여러 곳으로 번진다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
02TanStack Query의 select로 뷰 모델을 만들면 캐시에도 뷰 모델이 저장되는가?
정답
아니다. select는 해당 observer가 받는 data만 바꾸고 Query 캐시의 원본은 바꾸지 않는다.
관련 설명 다시 읽기왜 그런가
모든 소비자가 같은 모델을 써야 한다면 queryFn이나 데이터 계층에서 변환해 캐시 계약 자체를 바꾸는 선택을 검토한다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
03mapper가 필드를 읽어 뷰 모델을 만들면 외부 응답 검증도 끝난 것인가?
정답
아니다. mapper는 약속된 입력을 화면 의미로 바꾸고, 실제 입력이 약속과 일치하는지는 그 전에 검증해야 한다.
관련 설명 다시 읽기왜 그런가
TanStack Query의 select에서 오류를 던져 Query 실패를 표현하는 방식도 공식 권장 경계가 아니다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
출처와 검증 범위
아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.
- OpenAPI Specification 3.2.0OpenAPI Initiative · 표준 · 확인 2026-08-09
- Choosing the State StructureReact · 공식 문서 · 확인 2026-08-09
- useQueryTanStack · 공식 문서 · 확인 2026-08-09
- Render OptimizationsTanStack · 공식 문서 · 확인 2026-08-09
- Deriving Data with SelectorsRedux · 공식 문서 · 확인 2026-08-09
- useFragmentRelay · 공식 문서 · 확인 2026-08-09