선 하나가 아키텍처를 바꾼다
프론트엔드 개발자라면 한 번쯤 이런 상황을 겪어봤을 겁니다. 열심히 API 응답을 Zustand에 때려넣고, 캐시 무효화 로직을 직접 짜고, 백그라운드 리패칭을 수동으로 구현하다가 문득 "내가 지금 뭘 하고 있지?" 싶은 순간. 혹은 반대로, ReadableStream을 붙잡고 lock을 놓쳤다가 스트림이 영구적으로 잠겨버린 경험. 두 상황 모두 같은 뿌리에서 나옵니다. 경계선을 잘못 그었거나, 애초에 그리지 않은 것입니다.
최근 두 가지 기술 레이어에서 이 경계선에 대한 진지한 재검토가 동시에 이뤄지고 있습니다. 클라이언트 상태 관리 쪽에서는 Redux → Zustand 마이그레이션 실전기(dev.to)가, 스트리밍 데이터 처리 쪽에서는 Cloudflare의 새로운 Streams API 제안(blog.cloudflare.com)이 각각 주목받고 있습니다. 겉으로 보면 전혀 다른 이야기 같지만, 두 글이 공통적으로 지적하는 핵심은 하나입니다. 기존 도구가 복잡성을 잘못된 곳에 쌓아왔다는 것.
Zustand v5가 폭로한 것
dev.to의 마이그레이션 실전기는 단순한 라이브러리 교체 후기가 아닙니다. Zustand v5로 프로덕션 React Native 앱을 마이그레이션하던 중 useShallow 없이 객체 셀렉터를 쓰다가 Maximum update depth exceeded 에러와 함께 컴포넌트 트리 전체가 언마운트된 사건에서 시작합니다. v4에서는 그냥 불필요한 리렌더링 유발 정도로 끝났던 패턴이, v5에서는 무한 렌더 루프로 이어지는 런타임 크래시가 된 겁니다.
이 차이가 왜 발생하냐면, Zustand v5가 React 18의 useSyncExternalStore를 직접 사용하는 구조로 전환했기 때문입니다. 이 API는 셀렉터 참조의 안정성을 전제로 동작하는데, 매 렌더마다 새 객체를 반환하는 셀렉터는 참조가 절대 안정화되지 않아서 React가 배일 아웃하기 전에 무한 재조정 루프에 빠집니다. 구조를 이해하지 못한 채 복붙 코딩으로 가져온 패턴이 프로덕션에서 폭발하는 전형적인 사례죠.
사실 이거, Figma에서 볼 때는 괜찮아 보이는 레이아웃이 실제 브라우저에서 깨지는 것과 비슷한 맥락입니다. 내부 렌더링 메커니즘을 모르면 언제 터질지 모르는 시한폭탄을 안고 사는 셈.
Zustand가 진짜 잘하는 것, 그리고 하면 안 되는 것
이 마이그레이션 경험에서 나온 핵심 메시지는 아키텍처 경계입니다. Zustand는 UI 상태, 네비게이션 컨텍스트, 사용자 설정, 필터 선택값 같은 클라이언트 상태 전용입니다. API에서 가져온 데이터를 Zustand에 넣고 동기화 로직을 직접 짜기 시작하는 순간, React Query나 SWR이 무료로 줬을 캐시 무효화·백그라운드 리패칭·스테일 처리를 전부 손으로 구현하는 지옥이 펼쳐집니다.
"사용자 입장에서는" 화면이 빠르게 응답하는 것만 보이지만, 그 뒤에서 서버 상태를 클라이언트 상태 저장소에 억지로 밀어넣은 팀이 얼마나 많은 야근을 하고 있는지 생각하면... 좀 씁쓸합니다.
반면 Zustand가 빛나는 부분은 getState()와 subscribe()를 통한 React 외부 접근입니다. React Native 환경에서는 특히 유용한데, 네이티브 이벤트 핸들러나 백그라운드 태스크에서 스토어에 직접 접근해야 하는 상황이 자주 발생하거든요. Redux의 미들웨어 보일러플레이트 없이 이걸 깔끔하게 처리할 수 있다는 건 번들 사이즈 절감과 개발 경험 모두에서 실질적인 이점입니다.
Web Streams의 10년 된 기술 부채
한편 Cloudflare는 완전히 다른 레이어에서 비슷한 문제를 지적합니다. WHATWG Streams 표준은 2014~2016년에 설계됐는데, 당시엔 async/await도 없었고 for await...of도 없었습니다. 그래서 reader/writer 모델, 락 관리, BYOB 버퍼라는 복잡한 개념들이 생겼고, 이게 지금까지 그대로 내려오고 있습니다.
결과가 어떻냐면, releaseLock() 하나 빠뜨리면 스트림이 영구적으로 잠기고, tee() 쓰면 무제한 메모리 버퍼링이 발생하고, SSR에서 수천 개의 작은 청크를 처리하면 Promise를 매번 생성하느라 GC 쓰레싱이 터집니다. 각 런타임(Node.js, Deno, Bun, Workers)은 이걸 비표준 최적화로 각자 때우다 보니 호환성은 점점 떨어지고 있고요.
Lighthouse 점수 신경 쓰는 입장에서 보면, SSR 성능이 스트리밍 레이어에서 이렇게 새고 있다는 게 상당히 불편합니다. Core Web Vitals 개선하려고 컴포넌트 레벨에서 최적화를 열심히 해봤자 스트림 버퍼 폭증으로 TTFB가 터지면 말짱 꽝이거든요.
async iterable이 바꾸는 패러다임
Cloudflare의 제안은 단순합니다. 스트림을 그냥 async iterable로 만들자는 겁니다. for await...of로 직접 소비하고, pull 기반 설계로 소비자가 데이터를 요청할 때만 처리하고, 명시적 백프레셔 정책(strict, block, drop-oldest, drop-newest)으로 메모리 폭주를 막는 구조입니다.
성능 벤치마크는 좀 과격합니다. Node.js 기준 최대 80~90배, 3단 변환 체인에서 275GB/s vs 3GB/s라는 수치가 나오는데, 일부 Hacker News 댓글에서는 M1 Pro의 메모리 대역폭(200GB/s)을 초과하는 수치가 나온다며 신뢰성에 의문을 제기하기도 했습니다. 마이크로벤치마크의 한계를 감안해야 하지만, Promise 생성 오버헤드 제거와 배치 처리, pull 기반 설계가 실질적인 성능 향상을 가져온다는 방향성 자체는 설득력 있습니다.
무엇보다 이 접근의 진짜 가치는 단순히 빠른 것보다 Node.js, Deno, Bun, 브라우저 모든 런타임에서 동일하게 동작하는 통합 모델을 제시한다는 점입니다. 런타임마다 다른 최적화 경로를 알아야 하는 현재 상황이 얼마나 개발자를 피곤하게 만드는지는... 직접 겪어본 사람만 알겠죠.
두 변화가 교차하는 지점
이 두 가지 움직임을 나란히 놓으면 흥미로운 그림이 됩니다. Zustand는 클라이언트 상태 관리에서 서버 상태를 건드리지 않는다는 경계를 명확히 하는 방향으로 성숙하고 있고, Streams API는 서버와 클라이언트 사이를 오가는 스트리밍 데이터 처리를 현대 JavaScript 관용구에 맞게 단순화하려는 방향으로 진화하고 있습니다.
사용자 입장에서는 그냥 빠르고 끊기지 않으면 되는 거지만, 그걸 구현하는 입장에서는 클라이언트 상태와 서버 스트리밍 데이터를 각자 적절한 도구로 처리하는 아키텍처 판단이 결국 성능과 유지보수성을 결정합니다. 컴포넌트 경계보다 데이터 흐름의 경계가 더 중요한 설계 결정이 됐다는 거죠.
지금 당장 챙겨야 할 것
Zustand를 이미 쓰고 있다면 v5로 올리기 전에 useShallow 없는 객체 셀렉터를 전수 검사하세요. 그거 스테이징에서 안 잡으면 프로덕션에서 컴포넌트 트리 통째로 날아가는 경험을 하게 됩니다. 그리고 Zustand에 API 응답 데이터가 들어가 있다면, React Query나 TanStack Query로의 분리를 진지하게 고민할 타이밍입니다.
Streams API는 아직 Cloudflare 제안 단계라 당장 프로덕션에 적용할 수는 없지만, jasnell/new-streams 레포를 북마크해두고 방향을 지켜볼 만합니다. SSR 중심 프로젝트를 운영 중이라면 특히, 현재 스트리밍 레이어에서 얼마나 성능이 새고 있는지 Lighthouse와 서버 메트릭으로 한 번 측정해보는 것도 나쁘지 않습니다. 로딩 스켈레톤 열심히 만들어봤자 TTFB가 문제면 체감 속도는 안 나오거든요.
프론트엔드 아키텍처에서 '올바른 경계'를 긋는 일은 언제나 디테일 싸움입니다. 1px 차이가 레이아웃을 망치듯, 경계선 하나가 아키텍처 전체를 뒤흔들 수 있습니다. 두 기술의 변화가 동시에 같은 방향을 가리키고 있다는 건, 그 경계를 다시 그어야 할 시점이 왔다는 신호일지 모릅니다.