Photo by Jr Korpa
TkDodo의 React 19 and Suspense - A Drama in 3 Acts를 번역한 글입니다.
지난주는 정말 롤러코스터🎢 같았습니다. 어떤 것들은 흐트러지기 시작했고, 어떤 것들은 추락했습니다. 그리고 이 모든 건 세계 최대 React 콘퍼런스인 React Summit 기간에 일어났죠.
가능한 올바른 순서대로 무슨 일이 있었는지, 그리고 그로부터 우리 모두가 무엇을 배울 수 있는지 분석해 보겠습니다. 그러기 위해서는 이번 4월로 돌아가야 합니다.
4월 25일은 엄청난 하루였습니다. React는 차기 메이저 버전에 대한 피드백 수집과 라이브러리들의 대비를 위한 버전인 React 19 RC를 발표했죠.
이번 출시에는 좋은 게 아주 많아서 정말 기뻤습니다. 새로운 훅부터 use
연산자, 서버 액션, ref
prop, 개선된 하이드레이션 오류, ref
용 클린업 함수, 더 나아진 useRef
의 타입, useLayoutEffect
가 더이상 서버에서 경고를 발생시키지 않는 것까지. 실험적인 React 컴파일러도 당연히 포함이죠. 🚀
이번 출시는 구미가 당기는 것들로 가득했고, 저는 어떤 문제라도 있을까 확인할 목적으로 React Query를 업그레이드할 생각에 흥분됐습니다. 저는 당시 업무와 🔮 query.gg 교육과정을 마무리하느라 꽤 바빴지만, 약 한 달 후 저희는 React 19와 호환되는 v5.39.0을 출시했습니다.
React Query 🤝 React 19
https://github.com/TanStack/query/releases/tag/v5.39.0
누군가 예시에 React 컴파일러를 사용해 보고 알려주시기를 부탁드립니다. eslint-plugin-react-compiler를 활성화해 봤는데 의심스런 보고가 없네요. 저희가 규칙을 잘 지킨다는 뜻이면 좋겠습니다. 😂
파고들 이슈가 정말 하나도 없었기에 이번 React의 출시는 훅 도입 이후 최고의 출시로 향하는 궤도에 올랐다고 생각했습니다. Suspense에서 이상한 점을 발견하기 전까지는 말이죠.
사전에 전면 공개하자면, 제가 이 문제를 처음 발견한 것은 아닙니다. 이 새로운 동작을 RC 발표 다음 날에 (제가 아는 한) 처음으로 발견한 Gabriel Valfridsson에게 감사를 전합니다.
엄청난 변경 사항이 가득합니다! 🥳
그런데 이 변경은 주의 사항이 더 강조되어야 할 것 같아요.
https://github.com/facebook/react/pull/26380react-query같은 라이브러리와 suspense를 함께 사용하면, 전에는 병렬 로딩하던 곳에서 이제는 폭포를 만듭니다.
https://codesandbox.io/p/devbox/react18-pvf36j
https://codesandbox.io/p/devbox/react19-g6n5f7
재밌는 건, 제가 이 트윗을 보고 댓글까지 달았지만 당시에는 대수롭지 않게 생각했다는 겁니다. 앞서 말했듯 저는 꽤 바빠서 React 19를 나중에 살펴볼 계획이었습니다.
그래서 React 19에 맞춰 React Query를 업그레이드한 이후에도 저는 교육과정의 Suspense 강의 제작을 계속했습니다. 그 강의에는 콘텐츠를 동시에 표시하면서도 모든 요청을 병렬 fetch 하는 방법을 보여주는 예시가 하나 있습니다. React 공식 문서에 나오는 것처럼 두 컴포넌트를 같은 Suspense 경계 내에 형제 컴포넌트로 배치하면 되는데요. 그 예시는 대략 이렇게 생겼습니다.
// suspense-with-two-children
1 export default function App() {
2 return (
3 <Suspense fallback={<p>...</p>}>
4 <RepoData name="tanstack/query" />
5 <RepoData name="tanstack/table" />
6 </Suspense>
7 )
8 }
동작 방식을 설명하자면, React는 첫 번째 자식이 suspend될 것을 확인하고, 해당 자식 컴포넌트가 fallback
을 표시해야 한다는 것을 알게 됩니다. 하지만 React는 다른 형제도 suspend 될 경우를 대비해 모든 프로미스를 "수집"할 수 있도록, 계속해서 다른 형제를 렌더링하는 것입니다.
각각의 형제가 어떠한 비동기 동작을 트리거하더라도, 🍿"팝콘 UI"🍿를 유발하지 않으면서 여전히 병렬 fetch 방식으로 컴포넌트를 구성할 수 있게 하므로 상당히 훌륭한 기능입니다.
팝콘 UI: 화면을 구성하는 여러 UI가 팝콘처럼 차례대로 튀어나오며 표시되는 현상
보다 완전한 예시는 이렇습니다.
// app-with-suspense
1 export default function App() {
2 return (
3 <Suspense fallback={<p>...</p>}>
4 <Header />
5 <Navbar />
6 <main>
7 <Content />
8 </main>
9 <Footer />
10 </Suspense>
11 )
12 }
위 컴포넌트의 일부 또는 전부는 주요 데이터 fetch를 시작할 수 있으며, fetch가 resolve 되면 UI가 한꺼번에 표시됩니다.
또 다른 장점은 나중에 fetch를 추가할 때 pending 상태가 어떻게 처리될지 고민해야 할 필요가 없다는 것입니다. <Footer />
컴포넌트가 지금은 데이터를 fetch하지 않을 수도 있지만 나중에 fetch를 추가하면 그냥 바로 동작할 것입니다. 그리고 중요하지 않은 데이터라고 생각하면 언제든 자체적인 Suspense 경계로 컴포넌트를 감쌀 수 있습니다.
// nested-suspense
1 export default function App() {
2 return (
3 <Suspense fallback={<p>...</p>}>
4 <Header />
5 <Navbar />
6 <main>
7 <Content />
8 </main>
> 9 <Suspense fallback={<p>...</p>}>
>10 <Footer />
>11 </Suspense>
12 </Suspense>
13 )
14 }
이제 <Footer />
내부의 데이터 fetch는 <main />
의 <Content />
가 렌더링 되는 것을 차단하지 않습니다. 이건 꽤 강력하며 컴포넌트 구성을 다른 무엇보다 선호하는 React의 방식과 일치합니다.
Suspense가 React 19에서 다르게 동작한다는 글을 트위터에서 봤던 게 희미하게 기억나서, 그저 확인할 목적으로 교육과정의 내용을 새로운 RC에서 사용해 보고 싶었습니다. 그리고 놀랍게도 완전히 다르게 동작했습니다. 두 형제 컴포넌트의 데이터를 병렬로 fetch하는 게 아니라 폭포를 만들었습니다. 💦
저는 이 동작에 너무나 놀랐고, 그 순간에 떠올릴 수 있는 유일한 걸 했습니다. 바로 트위터에 들어가 React 핵심 팀원들을 태그하는 것이었습니다.
제가 지레짐작하는 건가요? 아니면 React 18과 19에서 Suspense가 병렬 fetch를 처리하는 방식에 차이가 있는 건가요? 18은 "컴포넌트별"로 분할되어서, 각자 fetch를 실행하는 두 컴포넌트를 같은 Suspense 경계에 넣어도 병렬로 실행됐습니다.
<Suspense fallback={<p>...</p>}> <RepoData name="tanstack/query" /> <RepoData name="tanstack/table" /> </Suspense>
위 코드는 두 쿼리를 병렬 실행하고 모두 resolve 될 때까지 기다린 뒤에 하위 트리 전체를 보여주죠.
제가 확인하기에 React 19에서는 이 쿼리들이 실행되며 폭포를 만듭니다. 제 기억에 @rickhanlonii이 비슷한 걸 언급한 적 있는 것 같은데 지금은 아무 증거도 찾을 수 없네요.
/cc @acdlite @dan_abramov2
말할 필요도 없이, 이 트윗이 퍼져나가 다소 열띤 토론이 시작되었습니다. 머지않아 이게 버그가 아니라 의도된 변경 사항인 것으로 밝혀졌으며, 많은 사람들의 분노를 이끌었습니다.
물론 이렇게 바뀐 데에는 이유가 있으며, 이상하게 들리겠지만 일부 상황에서의 성능 향상을 위한 것입니다. 이미 suspend 된 컴포넌트에서 멈추지 않고 계속해서 형제 컴포넌트를 렌더링하는 건 공짜가 아니며 fallback 표시를 차단합니다. 예시를 보겠습니다.
// expensive-sibling
1 export default function App() {
2 return (
3 <Suspense fallback={<p>...</p>}>
4 <SuspendingComponent />
5 <ExpensiveComponent />
6 </Suspense>
7 )
8 }
<ExpensiveComponent />
가 거대한 하위 트리라는 등의 이유로 렌더링이 오래 걸리지만, 스스로 suspend 하지 않는다고 가정하겠습니다. 이제 React는 이 트리를 렌더링할 때 <SuspendingComponent />
가 suspend 되는 것을 확인할 것이고, 그러면 결국 표시해야 할 것은 suspense fallback뿐입니다. 하지만 렌더링이 완료되어야 가능하므로 <ExpensiveComponent />
의 렌더링이 완료될 때까지 기다려야 합니다. 게다가 렌더링이 완료되면 fallback을 표시해야 하므로 <ExpensiveComponent />
의 렌더링 결과는 버려지게 될 것입니다.
이렇게 생각하면 suspend 된 컴포넌트의 형제를 사전 렌더링한 결과가 유의미한 출력값이 되는 일이 없으므로 순전히 오버헤드라고 할 수 있습니다. 그래서 React 19는 즉각적인 로딩 상태를 위해 이걸 제거했습니다.
물론 즉시 suspend 한다면 형제도 suspend 될 거라는 걸 확인할 수 없으므로, 형제가 데이터 fetch를 시작하면(예. useSuspenseQuery
) 그때 폭포가 만들어질 것입니다. 바로 이 지점에서 논란이 생기는 거죠.
컴포넌트가 fetch를 시작하도록 하는 걸 보통 fetch-on-render라고 합니다. 대부분이 일상적으로 사용하는 접근 방식이지만 최선은 아니죠. 같은 Suspense 경계 안에 있는 형제 컴포넌트가 병렬로 사전 렌더링 되더라도, 하나의 React 컴포넌트 안에 useSuspenseQuery
호출이 두 개 있거나 컴포넌트들이 부모 자식 관계라면 폭포를 피할 수 없을 것입니다.
이런 이유로 React 팀은 fetch를 route loader나 서버 컴포넌트에서 일찍 시작하고, Suspense는 프로미스를 시작하는 게 아니라 리소스를 소비하기만 하는 방식을 권장하는 겁니다. 이런 방식을 보통 render-as-you-fetch라고 합니다.
TanStack Router와 TanStack Query를 사용하는 예시는 이렇습니다.
// prefetch-in-route-loader
1 export const Route = createFileRoute('/')({
2 loader: ({ context: { queryClient } }) => {
3 queryClient.ensureQueryData(repoOptions('tanstack/query'))
4 queryClient.ensureQueryData(repoOptions('tanstack/table'))
5 },
6 component: () => (
7 <Suspense fallback={<p>...</p>}>
8 <RepoData name="tanstack/query" />
9 <RepoData name="tanstack/table" />
10 </Suspense>
11 ),
12 })
여기서 route loader는 두 쿼리의 fetch가 반드시 컴포넌트의 렌더링 전에 시작되도록 합니다. 따라서 React가 Suspense의 자식을 렌더링하기 시작할 때, 두 번째 RepoData
컴포넌트를 렌더링하는지는 중요하지 않습니다. 왜냐하면 fetch를 트리거하는 게 아니라, 이미 실행 중인 프로미스를 소비하기만 할테니까요. 이런 상황에서는 React 19가 아무 방해 없이 더 적게 일하므로 앱이 약간 더 빨라집니다.
데이터 요청을 끌어올리는 건 suspense의 동작 방식과 무관하게 좋은 생각이며, 저 역시 권장하는 바입니다. 하지만 React 19가 제시한 변경 사항은 이걸 거의 의무로 만들었습니다.
더 나아가, React Query에서 배운 것이 있다면 비동기 연산에 fetch만 있는 건 아니라는 것입니다. 예를 들어, 코드 스플리팅을 위해 React.lazy
를 사용하는 건 번들이 직렬 로딩된다는 뜻이기도 합니다. App이 이렇게 생겼다면 말이죠.
// react.lazy
1 const Header = lazy(() => import('./Header.tsx'))
2 const Navbar = lazy(() => import('./Navbar.tsx'))
3 const Content = lazy(() => import('./Content.tsx'))
4 const Footer = lazy(() => import('./Footer.tsx'))
5
6 export default function App() {
7 return (
8 <Suspense fallback={<p>...</p>}>
9 <Header />
10 <Navbar />
11 <main>
12 <Content />
13 </main>
14 <Footer />
15 </Suspense>
16 )
17 }
예, 기술적으로는 동적 import를 사전 로딩할 수도 있죠. 하지만 그런 걸 좋은 성능을 위한 필수조건으로 만들면, App
컴포넌트가 자식 컴포넌트에서 실행되는 모든 비동기 작업을 알아야 하므로 React Suspense와 컴포넌트 구성의 목적을 무산시킨다고 할 수 있습니다.
지금쯤 인터넷의 많은 사람들이 이러한 변화에 충격과 공포를 느꼈을 것입니다. 18에서는 거의 모든 걸 병렬 fetch 하던 앱이 19에서는 완전히 폭포가 되어버리는 스크린숏이 공유되었거든요. react-three-fiber를 관리하는 오픈 소스 개발자 단체, Poimandres의 개발자들은 react-three-fiber가 수행하는 많은 일들이 비동기 작업에 기반하며 Suspense의 현재 동작 방식을 활용하는 탓에 자제력을 살짝 잃었습니다. 심지어 이 변경 사항이 v19에 실제로 적용된다면 React를 fork 할 것인지가 논의에 오를 정도까지 갔습니다.
그 무렵 저는 이미 React Summit 때문에 암스테르담에 있었습니다. 저희는 React Ecosystem Contributors Summit에서 이런 변화에 관해 이야기하고 있었는데, 모두가 놀라거나 걱정하거나 실망했습니다. React 핵심 팀은 이 변화가 어떻게 더 나은 트레이드 오프이며 어떻게 상한선을 높이는지, 데이터 요청은 어떻게 끌어올려야 하는지, 그리고 클라이언트 Suspense에 대한 공식 지원은 한 번도 출시된 적 없다고(이게 사실이라 해도 제가 아는 모두가 오해하고 있죠) 설명하며 분위기에 박차를 가했습니다.
당일 늦은 저녁, 저는 React 컴파일러와 v19 개발에 참여한 Sathya Gunasekaran과 대화할 수 있었습니다.
React19, Suspense의 변화와 react 컴파일러에 관해 @_gsathya와 훌륭한 토론을 했습니다. 제가 우려하는 걸 진심으로 들어주고 피드백을 받아들이는 게 느껴져서 정말 고마웠어요. 🙏
그는 React 팀이 커뮤니티에 신경을 많이 쓰고 있으며, 이러한 변화가 클라이언트 측 Suspense의 상호작용에 미치는 영향을 과소평가하고 있을 가능성이 높다는 점을 확인시켜 주었습니다.
다음 날, React 팀은 회의했고 출시를 보류하기로 했습니다.
방금 @rickhanlonii, @en_JS, @acdlite와 만났으며 Suspense에 관한 좋은 소식이 있습니다
* 저희는 SPA에 많은 관심이 있으며, 팀은 오늘날 얼마나 많은 사람들이 SPA에 의지하는지 오판했습니다
* 여전히 사전 로딩을 권장하지만, 항상 실용적이진 않음을 인지했습니다
* 적절한 수정안을 찾을 때까지 19.0 출시는 보류할 계획입니다
React 팀이 이런 시기에 피드백에 열려 있다는 점이 굉장히 안심됩니다. 이미 콘퍼런스에서 공개하고 발표를 마친 출시를 연기하는 것은 큰 결정이며, 관련된 모든 사람들이 정말 고마워하고 있습니다. 저는 이 문제에 관한 좋은 절충안을 위해 React 팀과의 협력에 최선을 다할 것입니다.
이 모든 과정을 통해 몇 가지 배운 점이 있습니다. 첫째, 최종 버전이 출시되기 전에 이전 버전을 사용해 보는 건 아주 좋은 생각입니다. 특히 팀이 피드백을 받고 조치를 할 준비가 되어 있다면 더 그렇습니다. React 팀에게 찬사를 보냅니다. 그 피드백을 진작에 제가 했으면 정말 좋았을 텐데 싶네요.
저와 다른 메인테이너들에게 분명해진 또 다른 점은 React 팀과 소통할 수 있는 더 나은 채널이 필요하다는 것입니다. 아마도 이 점에서 React 18 Working Group은 우리가 가진 가장 좋은 것이었을 테고, 이 모든 과정을 통해 React 19(및 이후 버전)에도 비슷한 게 있으면 좋겠다는 걸 알 수 있었습니다. 영구적인 Working Group이라고 할 수 있을까요?
그리고 당연한 이야기지만 트위터에서 서로에게 소리 지르는 건 아무 도움도 되지 않습니다. 저는 차분하고 객관적이지 못한 소통을 했던 것을 후회하고 있고 Sophie의 소통과 처리 방식을 정말 고맙게 생각합니다. 🙏
직접 만나서 대화하는 것이 훨씬 좋다는 걸 저보다 앞서 많은 사람들이 깨달았고, 콘퍼런스에서 훌륭한 대화를 더 많이 나눌 수 있기를 기대합니다. 🎉