글 · 이시온 · Studio Sion 파운더 · 13년차 프론트엔드 엔지니어
API 상태 흐름을 정리하면 장애가 줄어든다
트래픽이 집중되는 화면의 장애는 대부분 상태의 출처가 불분명한 곳에서 시작됩니다.

트래픽이 집중되는 화면의 장애는 대부분 상태의 출처가 불분명한 곳에서 시작됩니다.
장애 회고를 여러 번 하다 보면 이상한 패턴이 눈에 들어옵니다. 정작 서비스를 멈추는 건 화려한 버그가 아니라, “이 데이터는 어디서 오고 누가 갱신하는가?”라는 단순한 질문에 아무도 즉답하지 못하는 지점이라는 것. 상태에 집이 없으면, 그 틈에 장애가 둥지를 틉니다. 그리고 그 틈은 트래픽이 몰리는 화면에서 가장 먼저, 가장 크게 벌어집니다.
장애는 '상태의 출처가 불분명한 곳'에서 자란다
익숙한 장면들이 있습니다.
좋아요를 눌렀는데, 목록으로 돌아가니 반영이 안 되어 있습니다. 뮤테이션은 성공했는데 목록이 들고 있던 데이터는 갱신되지 않은 거죠. 장바구니엔 재고가 남아 있다고 떠서 결제를 눌렀더니 품절이라고 합니다. 두 화면이 재고를 서로 다른 출처에서, 서로 다른 시점에 읽고 있었던 겁니다. 결제 버튼을 두 번 눌렀더니 주문이 두 개 들어갑니다. 같은 요청이 중복으로 나가는 걸 막는 장치가 없었던 거고요.
이 사고들의 공통점은 로직의 복잡함이 아닙니다. 같은 서버 데이터가 여러 곳에 흩어져 있고, 각자가 자기가 진실이라고 믿는다는 것. 트래픽이 몰리는 화면은 이 어긋남을 매 순간 드러냅니다. 동시 접속이 많고, 재요청이 잦고, 낙관적 업데이트가 오가고, 경쟁 상태가 실제로 발생하니까요. 트래픽 낮은 관리자 페이지는 이 버그를 숨겨 줍니다. 돈이 도는 화면이 그걸 폭로할 뿐입니다.
가장 먼저 그어야 할 선: 서버 상태 ≠ 클라이언트 상태
이 문제의 많은 부분은, 의외로 하나의 구분에서 풀립니다. 서버 상태와 클라이언트 상태는 다른 종류의 물건이다.
서버 상태는 서버가 소유하고, 나는 그 사본을 잠깐 빌려온 것입니다. 비동기로 도착하고, 도착하는 순간 이미 낡았을 수 있고, 나 아닌 다른 사용자가 언제든 바꾸며, 그래서 주기적으로 다시 검증해야 합니다. 상품 정보, 재고, 주문 목록, 사용자 프로필 같은 것들이죠.
반면 클라이언트 상태는 내가 온전히 소유합니다. 모달이 열렸는지, 어떤 탭이 선택됐는지, 폼에 뭘 입력 중인지, 어떤 필터가 켜져 있는지 같은 것들입니다.
가장 흔한 실수는 서버 상태를 클라이언트 상태처럼 다루는 것입니다. API 응답을 useState나 Redux, Zustand에 담아 두고 손으로 동기화하는 방식. 이 '손으로 하는 동기화'가 바로 버그가 사는 자리입니다. 재고가 바뀌면 누군가 다시 불러와 상태를 갈아 끼워야 하는데, 그 갈아 끼우는 코드는 언젠가 반드시 한 곳에서 빠집니다.
// 이렇게 하면 API 응답이 '내 것'이 되어 버립니다 — 낡아도 아무도 모릅니다
const [stock, setStock] = useState<number>();
useEffect(() => {
fetch(`/api/products/${id}/stock`)
.then((r) => r.json())
.then((d) => setStock(d.count)); // 이 시점의 스냅샷에 그대로 굳음
}, [id]);// 서버 상태는 '빌려온 캐시'로 다룹니다 — 신선도·재검증·중복 제거를 라이브러리에 맡김
const { data } = useQuery({
queryKey: ["product", id, "stock"],
queryFn: () => fetchStock(id),
});두 코드의 차이는 문법이 아니라 소유권의 태도입니다. 위는 데이터를 내 것으로 착각하고, 아래는 서버 것을 잠깐 들고 있을 뿐임을 인정합니다.
하나의 데이터, 하나의 출처
서버 상태를 클라이언트로 복사하기 시작하면 곧바로 다중 진실의 함정에 빠집니다. 같은 재고 수량이 Redux 슬라이스에도, 어느 컴포넌트의 로컬 상태에도, prop으로 내려온 값에도 존재합니다. 그럼 어느 게 맞는 걸까요? 하나가 갱신될 때 나머지가 따라오지 못하면, 화면의 두 부분이 서로 다른 숫자를 말하는 유령 상태가 생깁니다.
규칙은 단순합니다. 모든 서버 데이터는 집이 하나여야 합니다. 하나의 쿼리 키가 그 데이터의 정체성이고, 화면 어디서든 그 출처를 읽되, 로컬 상태로 복사하지 않습니다. 같은 데이터가 필요한 다른 컴포넌트는 같은 키로 다시 읽으면 됩니다. 캐시가 그걸 공유해 주니까요.
파생값도 마찬가지입니다. 합계 = 항목들의 합이라면, 합계를 상태에 저장하고 동기화하지 말고 그때그때 계산하세요. 저장된 파생값은 원본과 어긋나기 시작하는 두 번째 진실이 됩니다. 서버 데이터에서 이름 하나를 로컬로 빼내 보관하는 것도 같은 실수고요. 값은 저장하지 말고, 그리는 자리에서 읽어야 합니다.
서버 상태는 '캐시'다 — 진짜 문제는 무효화
“컴퓨터 과학에서 어려운 건 두 가지, 캐시 무효화와 이름 짓기”라는 오래된 농담이 있습니다. 서버 상태를 캐시로 인정하는 순간, 우리는 그 어려운 문제 한복판에 서게 됩니다.
핵심 질문은 이겁니다. 이 데이터는 언제 낡고, 누가 다시 불러오는가?
이 질문엔 반드시 명시적인 답이 있어야 합니다. 앞서 본 '좋아요가 반영 안 되는' 장애가 정확히 여기서 옵니다. 뮤테이션은 성공했지만, 그 성공이 목록 캐시를 낡은 것으로 표시하고 다시 불러오게 만들지 않았던 거죠. 그래서 원칙은 하나 더 늘어납니다. 모든 뮤테이션은 자기가 무엇을 낡게 만드는지 선언해야 한다.
const queryClient = useQueryClient();
const like = useMutation({
mutationFn: () => likePost(postId),
onSuccess: () => {
// 이 변경이 낡게 만드는 것들을 명시적으로 무효화한다
queryClient.invalidateQueries({ queryKey: ["posts"] });
queryClient.invalidateQueries({ queryKey: ["post", postId] });
},
});그리고 신선도를 운에 맡기지 마세요. 데이터가 얼마나 오래 신선한 것으로 간주될지(staleTime)를 데이터 성격에 맞게 명시하면, “왜 갱신이 안 되지” 혹은 “왜 이렇게 자꾸 다시 부르지” 같은 문제가 추측이 아니라 설정의 영역으로 들어옵니다.
2026년의 지형: RSC가 바꾼 것, 바꾸지 않은 것
도구 이야기를 잠깐 정리하면. 2026년 React 데이터 계층의 핵심 흐름은 서버 상태와 클라이언트 상태의 분리, 그리고 전역 상태의 최소화입니다.
서버 상태 관리는 TanStack Query가 여전히 강력한 선택지입니다. 쿼리 키, 무효화, 낙관적 업데이트, 프리페치, DevTools를 통해 서버 데이터를 캐시로 다루는 방식을 제공합니다. 더 단순한 읽기 중심 화면이라면 SWR처럼 작은 API와 자동 재검증, 요청 중복 제거를 제공하는 도구도 잘 맞습니다. GraphQL이 주력이라면 정규화 캐시를 가진 Apollo가 자연스러운 선택지가 될 수 있습니다.
여기에 Next.js App Router와 서버 컴포넌트(RSC)가 계산을 바꿨습니다. 서버에서 가져올 수 있는 읽기 데이터는 클라이언트 데이터 페칭 라이브러리 없이 서버 컴포넌트 쪽으로 옮길 수 있습니다. 덕분에 클라이언트로 내려오는 상태 자체가 줄어듭니다.
다만 RSC가 캐시 설계를 없애 주는 것은 아닙니다. 어떤 데이터는 매 요청마다 새로 가져와야 하고, 어떤 데이터는 일정 시간 캐시해도 되며, 어떤 데이터는 mutation 이후 명시적으로 다시 검증해야 합니다. Next.js에서도 캐싱은 use cache, cache, revalidate, tag 기반 revalidation처럼 의도를 드러내는 방식으로 설계해야 합니다.
클라이언트 서버 상태 관리가 여전히 빛나는 자리도 분명합니다. 사용자 상호작용에 따라 바뀌는 데이터, 낙관적 UI, 실시간 업데이트, 화면 이동 후에도 유지되어야 하는 캐시, 그리고 Supabase Realtime 같은 구독 이벤트를 받아 특정 캐시 키를 무효화해야 하는 흐름이 그렇습니다.
핵심은 이겁니다. RSC는 “이 데이터는 어디서 오고 언제 낡는가”라는 질문을 없앤 게 아닙니다. 그 답이 사는 위치를 서버 쪽으로 더 많이 옮겼을 뿐, 질문 자체는 그대로 남아 있습니다. 그러니 도구를 바꿔도, 출처를 정하는 규율이 없으면 장애의 자리도 그대로입니다.
트래픽 화면을 위한 방어선
앞서 트래픽 낮은 화면은 버그를 숨긴다고 했습니다. 반대로 트래픽 화면에서는 아래 것들이 반드시 필요합니다.
첫째, 요청 중복 제거(dedup). 같은 데이터를 동시에 요청하는 인플라이트 요청은 하나로 합쳐야 합니다. TanStack Query와 SWR 모두 이런 흐름을 도와주지만, 직접 useEffect로 짜면 이 안전장치가 사라지기 쉽습니다.
둘째, 경쟁 상태 처리. 빠르게 바뀌는 파라미터로 요청이 연달아 나갈 때는 마지막 응답만 반영하거나 이전 요청을 취소해야 합니다. 검색어가 바뀌었는데 이전 검색 결과가 늦게 도착해 화면을 덮어쓰는 상황이 대표적입니다.
셋째, 페이지 전환 중 이전 데이터 유지. 페이지네이션에서는 placeholderData나 keepPreviousData helper로 이전 데이터를 유지해 화면 깜빡임과 순간적인 빈 상태를 줄일 수 있습니다. 사용자는 새 데이터를 기다리는 동안에도 맥락을 잃지 않습니다.
넷째, 낙관적 업데이트와 롤백. 즉시 화면에 반영하되, 서버가 실패하면 되돌립니다. 낙관적 업데이트를 롤백 없이 쓰면, 실패한 순간부터 화면이 거짓말을 시작합니다.
다섯째, 중복 제출 방어. 버튼을 비활성화하는 것만으로는 부족합니다. 트래픽이 몰리는 결제, 주문, 예약 화면에서는 서버 쪽 멱등키까지 갖춰야 두 번 클릭이 두 개의 주문이 되지 않습니다.
배포 전 스스로에게 묻는 것들
- 화면 위의 모든 데이터에 대해: 이건 서버 상태인가, 클라이언트 상태인가?
- 서버 데이터는 출처가 하나인가?
- 같은 서버 데이터를 로컬 상태로 복사해 두지 않았는가?
- 서버 상태와 클라이언트 상태를 같은 스토어에 섞지 않았는가?
- 파생값을 저장하지 않고 계산하고 있는가?
- 모든 뮤테이션이 무엇을 무효화하는지 선언하는가?
staleTime을 명시해 신선도를 운이 아니라 설계로 다루는가?- 트래픽 화면에서 dedup, 경쟁 상태, 낙관적 업데이트와 롤백, 중복 제출을 방어하는가?
- 결제, 주문, 예약처럼 돈이나 재고가 걸린 요청에 멱등성 처리가 되어 있는가?
마치며
좋은 데이터 계층의 조건은 결국 하나로 요약됩니다. 화면 위 모든 데이터에 대해, 그것이 어디서 오고 누가 소유하는지를 손가락으로 가리킬 수 있는가.
그걸 가리킬 수 있는 화면은 트래픽이 몰려도 쉽게 무너지지 않습니다. 반대로 어느 데이터의 출처를 “음… 아마 여기 어딘가”라고밖에 말할 수 없다면, 그곳이 다음 장애의 좌표가 됩니다.
이건 화려한 라이브러리의 문제가 아닙니다. 서버 것과 내 것을 구분하고, 하나의 데이터에 하나의 집을 주고, 언제 낡는지를 명시하는 규율의 문제죠. 상태의 출처가 분명할수록, 장애가 자랄 틈은 줄어듭니다. 트래픽이 집중되는 화면일수록, 그 분명함이 곧 안정성입니다.
(다른 글)