리액트 19와 서스펜스 - 3화로 이루어진 드라마


지난주는 정말 롤러코스터 같은 한 주였습니다 🎢. 어떤 일이 밝혀지고, 어떤 일이 벌어지기도 했죠. 그리고 그 중심에는 세계에서 가장 큰 리액트 컨퍼런스인 리액트 정상 회담(React Summit)이 있었습니다.
무슨 일이 일어났는지 가능한 올바른 순서대로 파헤쳐 보고, 우리가 배울 수 있는 점을 모두 이야기해 보려고 합니다. 그러려면 올해 4월로 돌아가야 합니다.
1화: 리액트 19 릴리스 후보
4월 25일은 뜻깊은 날이었습니다. 리액트가 리액트 19 RC를 발표했기 때문입니다. 이는 다음 메이저 버전의 리액트를 준비하고 피드백을 수집하는 릴리스였습니다.
저는 정말 기대했습니다. 이번 릴리스에는 정말 좋은 기능들이 많이 포함되어 있었기 때문이죠. 새로운 훅부터 use
연산자까지, 서버 액션부터 ref
속성까지. 더 나은 하이드레이션 오류 처리부터 ref의 클린업 함수까지. 더 나은 useRef
타입부터 useLayoutEffect
가 드디어 서버에서 경고를 내지 않게 된 것까지. 그리고 물론 실험적인 리액트 컴파일러도 포함되어 있었습니다. 🚀
이 릴리스에는 정말 많은 유용한 기능들이 담겨 있었고, 저는 리액트 쿼리를 업그레이드하여 문제가 없는지 확인하는 것이 무척 기대되었습니다. 당시에는 업무와 🔮 query.gg 강의 마무리로 꽤 바빴지만, 약 한 달 후 리액트 19와 호환되는 리액트 쿼리 5.39.0 버전을 출시했습니다.
특별한 문제는 발견되지 않았기에, 저는 이번 릴리스가 훅이 도입된 이후 최고의 리액트 릴리스가 될 거라고 생각했습니다. 서스펜스와 관련해 이상한 점을 발견하기 전까지는요.
2화: 서스펜스의 진상을 밝혀내다
먼저 솔직히 말하자면, 제가 이 문제를 처음 발견한 것은 아닙니다. 제가 아는 한 Gabriel Valfridsson이 RC 발표 후 하루 만에 새로운 동작을 처음 발견했습니다. 그에게 감사를 표합니다.
재미있는 점은 저도 그 트윗을 보고 댓글까지 달았지만, 그때는 그 문제에 대해 별로 깊이 생각하지 않았다는 것입니다. 앞서 말했듯이, 저는 정말 바빴고 리액트 19는 나중에 살펴볼 계획이었죠.
그렇게 리액트 쿼리를 리액트 19로 업그레이드한 후, 저는 계속해서 서스펜스 관련 강의 작업을 진행하고 있었습니다. 그 강의에는 콘텐츠를 동시에 노출하면서도 모든 요청을 병렬로 가져오는 방법을 보여주는 예제가 하나 있습니다. 리액트 공식 문서에서 설명하듯이, 두 개의 컴포넌트를 같은 서스펜스 경계(Suspense Boundary) 안에서 형제 관계(siblings)로 배치하면 되었죠. 대략 아래와 같은 코드 구조입니다.
export default function App() {
return (
<Suspense fallback={<p>...</p>}>
<RepoData name="tanstack/query" />
<RepoData name="tanstack/table" />
</Suspense>
)
}
리액트가 이를 처리하는 방식은 다음과 같습니다. 첫 번째 자식 컴포넌트가 서스펜스를 발생시키면, 리액트는 대체 UI(fallback)
를 보여주어야 한다는 것을 알게 됩니다. 하지만 다른 형제 컴포넌트들도 서스펜스를 발생시킬 수 있으므로, 계속해서 렌더링을 진행하면서 모든 프로미스를 "수집"합니다.
이 기능은 매우 유용합니다. 각 형제 컴포넌트가 비동기 작업을 트리거하더라도, 우리는 이들이 여전히 병렬로 데이터를 가져오도록 구성할 수 있습니다. 이렇게 하면 화면의 여러 부분이 하나씩 순차적으로 나타나는 🍿 "팝콘 UI" 🍿 현상을 피할 수 있습니다.
보다 완성된 예제는 아래와 같습니다.
export default function App() {
return (
<Suspense fallback={<p>...</p>}>
<Header />
<Navbar />
<main>
<Content />
</main>
<Footer />
</Suspense>
)
}
이 컴포넌트들 중 일부 또는 전체가 중요한 데이터 가져오기를 시작할 수 있으며, 모든 요청이 완료되면 UI가 한 번에 표시됩니다.
또 다른 장점은 나중에 새로운 데이터 요청을 추가하더라도, 대기 상태(pending state)를 어떻게 처리할지 고민할 필요가 없다는 점입니다. <Footer />
컴포넌트가 지금 당장은 데이터를 가져오지 않지만, 나중에 추가하더라도 자연스럽게 동작할 것입니다. 그리고 만약 데이터가 중요하지 않다고 판단되면, 해당 컴포넌트를 자체적인 서스펜스 경계로 감쌀 수 있습니다.
export default function App() {
return (
<Suspense fallback={<p>...</p>}>
<Header />
<Navbar />
<main>
<Content />
</main>
<Suspense fallback={<p>...</p>}>
<Footer />
</Suspense>
</Suspense>
);
}
이제 푸터에서 데이터를 가져오는 것이 메인 콘텐츠의 렌더링을 차단하지 않습니다. 이 방식은 매우 강력하며, 리액트가 무엇보다 컴포넌트 합성(Component Composition)을 중요하게 여기는 철학과도 잘 맞아떨어집니다.
리액트 19에서 서스펜스의 동작이 달라졌다는 내용을 트위터에서 얼핏 본 기억이 났습니다. 그래서 혹시 몰라서, 강의에서 다루고 있는 예제를 새로운 RC 버전에서 직접 실행해 보기로 했습니다. 그리고 놀랍게도 완전히 다르게 동작했습니다. 두 형제 컴포넌트의 데이터를 병렬로 가져오는 대신, 이제는 순차적으로 가져오고 있었습니다. 💦
이 동작에 너무 놀라서 저는 그 순간 제가 할 수 있는 유일한 일을 했습니다. 즉시 트위터로 가서 리액트 핵심 팀 멤버들을 태그했죠.
말할 필요도 없이, 이 트윗은 큰 반향을 일으켰고 다소 격렬한 토론이 시작되었습니다. 곧 우리는 이것이 버그가 아니라 의도적인 변경이라는 것을 알게 되었고, 이는 상당한 분노를 불러일으켰습니다.
왜 이런 변경을 했을까요?
물론 이러한 변경이 이루어진 데에는 이유가 있습니다. 그리고 아이러니하게도 일부 상황에서는 성능을 개선하기 위한 목적으로 도입되었습니다. 이미 서스펜스된 컴포넌트의 형제 컴포넌트들을 계속 렌더링하는 것은 비용이 따릅니다. 그리고 이것이 대체 UI 표시를 지연시킬 수도 있습니다. 예를 들어 아래와 같은 상황을 가정해 봅시다.
export default function App() {
return (
<Suspense fallback={<p>...</p>}>
<SuspendingComponent />
<ExpensiveComponent />
</Suspense>
);
}
<ExpensiveComponent />
가 거대한 하위 트리를 가지고 있어서 렌더링하는 데 시간이 걸리지만, 직접적으로 서스펜스를 발생시키지 않는다고 가정해봅시다. 리액트가 이 트리를 렌더링할 때 <SuspendingComponent />
가 서스펜스를 발생시키는 것을 감지하면, 결국 서스펜스 대체 UI만 표시하면 됩니다. 하지만 렌더링이 완료되어야 대체 UI를 표시할 수 있으므로, <ExpensiveComponent />
의 렌더링이 끝날 때까지 기다려야 합니다. 더욱이, <ExpensiveComponent />
의 렌더링 결과는 버려집니다. 어차피 대체 UI가 표시될 것이기 때문이죠.
이런 관점에서 보면, 서스펜스를 발생시키는 컴포넌트의 형제 컴포넌트들을 미리 렌더링하는 것은 순전히 오버헤드일 뿐입니다. 의미 있는 출력으로 이어지지 않기 때문이죠. 그래서 리액트 19는 즉각적인 로딩 상태를 위해 이 기능을 제거했습니다.
물론, 즉시 서스펜스가 발생하면 형제 컴포넌트들도 서스펜스를 발생시킬 것이라는 점을 알 수 없습니다. 따라서 이러한 형제 컴포넌트들이 데이터 요청을 시작하면(예를 들어 useSuspenseQuery
를 사용하면), 이제는 워터폴 현상이 발생하게 됩니다. 바로 이 점이 논란의 원인이었습니다.
Fetch-on-render vs. Render-as-you-fetch
컴포넌트가 데이터를 직접 요청하는 방식은 흔히 “fetch-on-render”(렌더링 시 데이터 요청)라고 불립니다. 대부분의 개발자들이 일상적으로 사용하는 방식이지만, 최적의 접근 방식은 아닙니다. 사실, 기존 방식에서도 형제 컴포넌트들이 병렬로 사전 렌더링되더라도, 같은 컴포넌트 내에서 useSuspenseQuery
를 두 번 호출하면 여전히 워터폴이 발생합니다. 부모-자식 관계의 컴포넌트라면 더욱 그렇습니다.
이 때문에 리액트 팀이 권장하는 방식은 데이터 요청을 더 일찍 수행하는 것입니다. 예를 들어, 라우트 로더(route loader)나 서버 컴포넌트에서 데이터를 먼저 가져오고, 서스펜스가 단순히 완료된 리소스를 소비하도록 만드는 것이죠. 이 방식은 “render-as-you-fetch”(렌더링하면서 데이터 요청)라고 불립니다.
예를 들어 TanStack Router와 TanStack Query를 사용하면 이렇게 구현할 수 있습니다.
export const Route = createFileRoute('/')({
loader: ({ context: { queryClient } }) => {
queryClient.ensureQueryData(repoOptions('tanstack/query'))
queryClient.ensureQueryData(repoOptions('tanstack/table'))
},
component: () => (
<Suspense fallback={<p>...</p>}>
<RepoData name="tanstack/query" />
<RepoData name="tanstack/table" />
</Suspense>
),
})
여기서 라우트 로더는 컴포넌트가 렌더링되기 전에 두 쿼리에 대한 데이터 요청이 시작되도록 보장합니다. 따라서 리액트가 서스펜스의 자식 컴포넌트들을 렌더링하기 시작할 때, 두 번째 RepoData
컴포넌트를 렌더링하는지 여부는 중요하지 않습니다. 새로운 데이터 요청을 트리거하지 않고 이미 실행 중인 프로미스를 소비하기만 하기 때문입니다. 이러한 상황에서는 리액트 19가 불이익 없이 더 적은 작업만 하면 되므로 앱이 약간 더 빨라질 것입니다.
모든 비동기 작업이 데이터 요청은 아닙니다
서스펜스가 어떻게 동작하든 상관없이 데이터 요구사항을 호이스팅하는 것은 좋은 아이디어이며, 저도 이를 권장합니다. 하지만 제안된 리액트 19의 변경사항으로 인해 이것이 거의 필수가 되어버렸습니다.
게다가 리액트 쿼리를 통해 배운 것이 있다면, 모든 비동기 작업이 데이터 요청(fetch)은 아니라는 점입니다. 예를 들어 코드 스플리팅을 위해 React.lazy
를 사용하는 겅우, 아래와 같은 App 구조에서는 번들이 순차적으로 로드된다는 것을 의미합니다.
const Header = lazy(() => import('./Header.tsx'))
const Navbar = lazy(() => import('./Navbar.tsx'))
const Content = lazy(() => import('./Content.tsx'))
const Footer = lazy(() => import('./Footer.tsx'))
export default function App() {
return (
<Suspense fallback={<p>...</p>}>
<Header />
<Navbar />
<main>
<Content />
</main>
<Footer />
</Suspense>
)
}
네, 기술적으로는 동적 임포트도 미리 로드할 수 있습니다. 하지만 좋은 성능을 위해 이것을 필수로 만드는 것은 리액트 서스펜스와 컴포넌트 구성의 목적을 무력화하는 것도 사실입니다. App
컴포넌트가 모든 자식 컴포넌트에서 일어나는 모든 비동기 작업을 알아야 하기 때문입니다.
3화: 논란 확산과 릴리스 연기
이제 인터넷에서 많은 사람들이 이 변화에 대해 놀라고 걱정하기 시작했습니다. 리액트 18에서는 거의 모든 데이터를 병렬로 가져오던 앱이, 리액트 19에서는 완전한 워터폴 방식으로 동작하는 스크린샷들이 공유되었습니다. react-three-fiber를 관리하는 Poimandres 오픈소스 개발자 단체의 개발자들은 다소 당황했습니다. react-three-fiber의 많은 부분이 비동기 작업을 기반으로 하고 현재의 서스펜스 동작 방식을 활용하고 있기 때문입니다. 심지어 이 변경사항이 실제로 19 버전에 포함된다면 리액트를 포크하는 것까지 논의될 정도였습니다.
그때 저는 이미 리액트 정상 회담을 위해 암스테르담에 있었습니다. 우리는 리액트 생태계 기여자 회담에서 이 변경사항에 대해 이야기를 나누었는데, 모든 사람들이 놀라거나, 걱정하거나, 좌절감을 느끼고 있었습니다. 리액트 코어 팀은 이 변경이 더 나은 트레이드오프이며 성능의 상한선을 높인다고 설명하면서, 우리가 어차피 데이터 요구사항을 호이스팅해야 하고, 클라이언트에서의 서스펜스 지원은 공식적으로 릴리스된 적이 없다고 강조했습니다 (이는 사실이더라도 제가 아는 모든 사람들이 오해했던 부분입니다).
그날 저녁, 저는 리액트 컴파일러와 19 버전 작업을 했던 Sathya Gunasekaran과 이야기를 나눌 기회가 있었습니다.
그는 리액트 팀이 커뮤니티를 매우 중요하게 생각하고 있으며, 이 변경이 클라이언트 사이드 서스펜스 상호작용에 미치는 영향을 과소평가했을 수 있다고 저를 안심시켰습니다.
다음 날, 리액트 팀이 모여서 릴리스를 보류하기로 결정했습니다.
이 단계에서 리액트 팀이 피드백을 수용하는 것은 매우 안심이 되는 일입니다. 이미 발표되고 컨퍼런스에서 소개된 릴리스를 연기한다는 것은 큰 결정이었고, 관련된 모든 사람들이 정말 감사하게 생각합니다. 저는 이 문제에 대해 좋은 타협점을 찾기 위해 리액트 팀과 최선을 다해 협력하겠습니다.
배운 점들
이 모든 일을 통해 몇 가지 교훈을 얻었습니다. 첫째로, 최종 버전이 나오기 전에 초기 릴리스를 시험해보는 것은 매우 좋은 생각입니다. 특히 팀이 피드백을 받아들이고 그에 따라 행동할 준비가 되어 있을 때는 더욱 그렇습니다. 리액트 팀에게 박수를 보냅니다. 다만 제가 이 피드백을 더 일찍 제공했더라면 하는 아쉬움이 있습니다.
둘째로, 저와 다른 메인테이너들에게 분명해진 것은 리액트 팀과 소통할 수 있는 더 나은 채널이 필요하다는 점입니다. 리액트 18 워킹 그룹은 이런 면에서 우리가 가진 최고의 것이었고, 이번 사태는 리액트 19 (그리고 향후 리액트 릴리스)를 위해 비슷한 것이 있으면 좋겠다는 것을 보여줍니다. 아마도 상설 워킹 그룹 같은 것이 필요할까요?
또한, 당연한 이야기지만 언급할 가치가 있는 것은 트위터에서 서로 소리 지르는 것은 도움이 되지 않는다는 점입니다. 저는 차분하고 객관적이지 못했던 제 커뮤니케이션 방식에 대해 반성하며, 소피의 소통 방식과 상황 처리 방식에 깊이 감사드립니다. 🙏
저보다 앞서 많은 사람들이 깨달았듯이 직접 만나서 하는 소통이 항상 훨씬 좋습니다. 앞으로 컨퍼런스에서 더 많은 좋은 대화를 나누게 되기를 기대합니다. 🎉
Subscribe to my newsletter
Read articles from 윤정민 directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
