[번역] React 19와 Suspense - 3막극

이춘구·2024년 6월 18일
10

translation

목록 보기
11/12

Photo by Jr Korpa

TkDodoReact 19 and Suspense - A Drama in 3 Acts를 번역한 글입니다.


지난주는 정말 롤러코스터🎢 같았습니다. 어떤 것들은 흐트러지기 시작했고, 어떤 것들은 추락했습니다. 그리고 이 모든 일들이 세계 최대 React 콘퍼런스인 React Summit 중에 일어났죠.

가능한 올바른 순서대로 무슨 일이 있었는지, 그리고 그로부터 우리 모두가 무엇을 배울 수 있는지 분석해 보겠습니다. 그러기 위해서는 이번 4월로 돌아가야 합니다.

1막: React 19 출시 후보

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에서 이상한 점을 발견하기 전까지는 말이죠.

2막: Suspense를 폭로함

사전에 전면 공개하자면, 제가 이 문제를 처음 발견한 것은 아닙니다. 이 새로운 동작을 RC 발표 다음 날에 (제가 아는 한) 처음으로 발견한 Gabriel Valfridsson에게 감사 인사를 전합니다.

엄청난 변경 사항이 가득합니다! 🥳

그런데 이 변경 사항은 주의 사항이 더 크게 있어야 마땅할 것입니다.
https://github.com/facebook/react/pull/26380

react-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-on-render vs. Render-as-you-fetch

컴포넌트가 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가 아무 방해 없이 더 적게 일하므로 앱이 약간 더 빨라집니다.

전부 fetch인 것은 아닙니다

데이터 요청을 끌어올리는 건 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와 컴포넌트 구성의 목적을 무산시킨다고 할 수 있습니다.

3막: 고조, 그리고 출시 연기

지금쯤 인터넷의 많은 사람들이 이러한 변화에 충격과 공포를 느꼈을 것입니다. 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(및 향후 출시될 React)에도 비슷한 게 있으면 좋겠다는 걸 알 수 있었습니다. 영구적인 Working Group이라고 할 수 있을까요?

그리고 당연한 이야기지만 트위터에서 서로에게 소리 지르는 건 아무 도움도 되지 않습니다. 저는 차분하고 객관적이지 못한 소통을 했던 것을 후회하고 있고 Sophie의 소통과 처리 방식을 정말 고맙게 생각합니다. 🙏

직접 만나서 대화하는 것이 훨씬 좋다는 걸 저보다 앞서 많은 사람들이 깨달았고, 콘퍼런스에서 훌륭한 대화를 더 많이 나눌 수 있기를 기대합니다. 🎉

profile
프런트엔드 개발자

0개의 댓글