jjenzz - 2024년 11월 13일
애플리케이션은 결국 스켈레톤(Skeletons), 스피너(Spinners), 쉬머 효과(Shimmer effects), 로딩 오버레이, 서스펜스 폴백(Suspense fallbacks), 그리고 데이터가 나타나야 할 공간을 차지하는 것이 유일한 목적인 온갖 종류의 UI들로 가득 차게 됩니다.
우리는 모두 똑같은 문제를 해결하는 데 놀라울 정도로 많은 시간을 소비하고 있으며, 그중 어느 것도 실제 제품 작업이라고 할 수 없습니다. 그래서 저는 로딩 상태 자체를 완전히 피할 수 있는 방법에 대해 고민을 멈출 수 없었습니다.
웹의 역사를 되돌아보면, 우리는 이미 꽤 좋은 해답을 가지고 있었다고 생각합니다. 단지 잠시 잊고 있었을 뿐입니다.
웹은 이미 답을 알고 있었습니다
SPA(단일 페이지 애플리케이션) 이전부터 웹사이트를 만들어 오셨다면, 로딩 상태가 항상 어디에나 있지는 않았다는 것을 기억하실 것입니다.
사용자가 링크를 클릭하면 브라우저는 요청을 보내고, 서버가 응답할 때까지 기다린 다음, 다음 페이지를 렌더링했습니다. 브라우저가 대기를 처리했기 때문에 사용자는 절반만 렌더링된 페이지로 이동하는 일이 없었습니다. 절반만 렌더링된 페이지라는 개념 자체가 없었기 때문입니다. 다음 페이지는 아직 준비되지 않았거나, 준비되었거나 둘 중 하나였습니다.
그 모델이 완벽하지는 않았지만, 한 가지 정말 좋은 장점이 있었습니다. 로딩이 컴포넌트 수준이 아니라 앱 수준에서 처리되었다는 점입니다. 브라우저가 이미 이를 조율하고 있었기 때문에 코드베이스 곳곳에 흩어져 있는 로딩 상태를 유지 관리할 필요가 없었습니다.
그러다 SPA가 등장했고 내비게이션은 즉각적으로 이루어졌으며, 이는 엄청난 개선처럼 느껴졌습니다. 새 페이지가 도착하기를 기다리는 대신, 즉시 경로를 전환하고 바로 렌더링을 시작할 수 있게 되었습니다.
하지만 그 대가로 데이터가 준비되기 전에 내비게이션이 발생하는 경우가 많아졌고, 그 시점에서 우리는 해결해야 할 새로운 문제에 직면했습니다. 데이터가 로드되는 동안 빈 공간을 어떻게 채울 것인가?
그 해답은 스켈레톤, 스피너, 쉬머 효과, 서스펜스 폴백, 로딩 오버레이, 그리고 동일한 아이디어의 수많은 변형이 되었습니다. 우리는 한 형태의 기다림을 다른 형태의 기다림과 맞바꾼 셈입니다.
내비게이션 전에 기다리는 것을 멈추고, 내비게이션 후에 기다리기 시작한 것입니다.
경로 전환(Route Transitions)이 구원해 줄까요?
경로 전환에 대해 제가 흥미롭게 생각하는 점은, 이것이 우리를 원래의 웹 모델에 더 가깝게 만들어 준다는 것입니다. 클라이언트 사이드 내비게이션의 모든 이점을 여전히 누릴 수 있기 때문에 완전히 똑같지는 않지만, 멘탈 모델(mental model)은 놀라울 정도로 비슷해집니다.
제가 말하는 "경로 전환"은 애니메이션을 의미하는 것이 아닙니다. 라우터가 내비게이션을 시작하고, 백그라운드에서 데이터를 로드하며, 다음 화면에 필요한 모든 것이 준비될 때까지 경로 변경의 확정(Commit)을 지연시킬 수 있는 능력을 말합니다.
내비게이션을 하고, 렌더링을 하고, 데이터를 가져온 다음, 데이터가 도착함에 따라 페이지를 점진적으로 채우는 대신, 링크 클릭 시 데이터를 로딩하기 시작하고, 이를 기다린 다음, 완성된 페이지로 이동할 수 있습니다.
처음에는 이것이 퇴보처럼 들릴 수도 있습니다. 결국 우리가 로딩을 컴포넌트 내부로 옮긴 데에는 타당한 이유가 있었기 때문입니다. 바로 체감 성능(Perceived performance)입니다.
사용자가 링크를 클릭했을 때 앱이 즉시 반응하면, 무언가 일이 일어났기 때문에 빠르다고 느껴집니다. 사용자는 피드백을 받았고 앱은 반응성이 좋아 보입니다.
그것은 합리적인 목표이지만, 사용자는 종종 똑같이 긴 시간을 기다리고 있습니다. 우리는 기다림을 제거한 것이 아니라 옮겼을 뿐입니다.
경로 전환은 우리에게 또 다른 선택지를 줍니다. 사용자가 클릭하기 전에 로딩을 시작하고, 정말로 필요할 때만 기다리며, 앱이 느려지게 만들지 않으면서도 애플리케이션 전체에 스켈레톤과 로딩 상태를 추가하는 것을 피할 수 있습니다.
이 글 전반에 걸쳐 TanStack Router 예시를 사용하겠습니다. 이 라이브러리의 로더(Loader)와 프리로딩(Preloading) API가 이 패턴을 시연하기 쉽게 만들어 주기 때문입니다. 하지만 근본적인 아이디어는 React 경로 전환을 지원하는 모든 라우팅 솔루션에 적용됩니다. 모든 것을 프리로드(Preload)하세요
이 방식이 작동하게 만드는 핵심은 프리로딩입니다.
사용자가 곧 어떤 데이터를 필요로 할 가능성이 높다면, 요청하기 전에 로딩을 시작하세요. 링크 위에 마우스를 올리거나(Hover) 링크가 뷰포트(Viewport)에 들어오는 것 모두 데이터를 미리 로딩할 수 있는 기회입니다.
TanStack Router를 예로 들면, 경로 로더는 경로의 데이터 요구 사항을 정의하는 자연스러운 장소가 됩니다.
export const Route = createFileRoute('/users/$userId')({
loader: async ({ context, params }) => {
const queryOptions = userQueryOptions(params.userId);
await context.queryClient.ensureQueryData(queryOptions);
},
component: UserProfile,
});
중요한 것은 로더 그 자체가 아닙니다. 라우터가 이제 경로에 필요한 데이터가 무엇인지 미리 알고 있다는 점이며, 이는 내비게이션이 발생하기 전에 해당 데이터를 로딩하기 시작할 수 있음을 의미합니다. 이런 방식으로 작업하기 시작하면 내비게이션 동작은 놀라울 정도로 많은 정보를 제공하게 됩니다.
UI가 무엇이 누락되었는지 알려주게 하세요
이 접근 방식에서 제가 좋아하는 점 중 하나는 매우 명확한 피드백 루프를 생성한다는 것입니다. 데이터 페칭(Fetching)이 경로 로더에 위치하고 링크들이 해당 로더를 프리로딩하게 되면, UI의 동작이 무엇이 잘못되었는지 알려주기 시작합니다.
대부분의 사람들은 비어 있는 UI를 버그로 보고 즉시 스피너를 찾습니다. 저는 그것을 피드백으로 봅니다.
내비게이션 후에 페이지의 일부가 갑자기 나타난다면(Pop in), 그것은 제가 무언가 프리로드하는 것을 잊었다고 앱이 저에게 말해주는 것입니다. 데이터가 올바르게 프리로드되었다면, 경로 전환은 내비게이션을 확정하기 전에 데이터를 기다렸을 것이므로 나중에 나타날 것이 남아있지 않아야 합니다.
다시 말해, 내비게이션이 완료되었는데도 UI가 여전히 채워지고 있다면, 이는 프리로드 경로 밖에서 데이터 페칭이 일어나고 있다는 신호입니다.
또 다른 신호는 경로 전환이 눈에 띄게 오래 걸릴 때입니다.
링크가 교차하거나 마우스를 올릴 때 프리로딩이 시작되면, 클릭이 발생하기 전에 데이터를 가져올 수 있는 놀라울 정도로 유용한 윈도우(window)가 생깁니다. 이는 종종 프리로드가 완료되고 내비게이션이 즉각적으로 느껴지기에 충분한 시간입니다. 하지만 경로 전환이 여전히 대기 중이라면, 이는 사용자가 프리로드가 끝날 만큼 충분한 시간을 갖기 전에 클릭했거나, 데이터 로딩에 정말로 오랜 시간이 걸리고 있음을 의미합니다.
첫 번째 경우는 특별히 흥미롭지 않습니다. 하지만 두 번째 경우는 대개 흥미롭습니다.
수백 밀리초의 여유 시간을 주었음에도 데이터가 여전히 로드되지 않았다면, 성능 문제를 개선하기 위해 원인을 파헤쳐 볼 가치가 있습니다. 만약 기다림이 불가피하다면, 그때가 바로 전역 로딩 인디케이터를 노출할 시점입니다.
테이블, 사이드바, 차트 및 데이터를 가져오는 다른 모든 기능에 대해 별도의 로딩 상태를 두는 대신, 앱 전체에 대해 단 하나의 로딩 상태만 두는 것입니다. 그리고 더 중요한 것은, 그것이 항상 같은 위치에 나타난다는 점입니다. 사용자는 모든 기능에 대해 서로 다른 로딩 경험을 해석할 필요 없이, 그것이 무엇을 의미하는지 배우게 됩니다.
GitHub의 로딩 바는 제가 말하는 종류의 경험에 대한 좋은 예입니다. 내비게이션이 예상보다 조금 더 오래 걸리면, 전환이 완료될 때까지 일관된 위치에 작은 로딩 인디케이터가 나타납니다. 라우팅 라이브러리들은 대개 이를 간단하게 구현할 수 있도록 일종의 전환 상태(Transition state)를 노출합니다. 예를 들어 TanStack Router의 useRouterState 훅이 있습니다. 프리로딩이 이미 작업을 완료했기 때문에 사용자가 이를 보는 일은 드물어야 하지만, 데이터를 충분히 빨리 가져올 수 없는 경우를 위한 폴백으로서 존재합니다.
저는 심지어 spin-delay 같은 것을 사용하여 인디케이터 표시를 약간 지연시킵니다. 그래서 전환이 비교적 빨리 완료되면 인디케이터는 전혀 나타나지 않습니다. 사용자는 인지하지 못할 정도의 기다림에 대해서는 피드백을 필요로 하지 않습니다. 이 덕분에 우리는 컴포넌트 내부에서 로딩 상태를 만드는 것을 멈출 수 있습니다. 쿼리는 단순히 데이터를 반환합니다. 데이터가 존재하면 렌더링하고, 존재하지 않으면 아무것도 렌더링하지 않습니다.
const user = useQuery(userQueryOptions(userId));
if (!user.data) return null;
return <UserProfile user={user.data} />;
저는 이것을 의도적으로 하고 있습니다.
목표는 사용자에게 빈 UI를 보여주는 것이 아닙니다. 목표는 개발 중에 신호를 증폭시키는 것입니다. 스켈레톤은 문제를 숨기지만, null을 반환하면 문제가 명확해집니다.
어딘가로 이동했는데 페이지의 일부가 비어 있다면, 저는 즉시 무언가 올바르게 프리로드되지 않았음을 알 수 있습니다. UI의 그러한 공백은 진단 도구가 됩니다.
또 다른 로딩 상태를 디자인하는 대신, 문제가 완전히 사라질 때까지 프리로드 전략을 개선합니다.
https://www.youtube.com/watch?v=SAhl2Op0GAA
설명된 접근 방식을 사용하여 구축된 애플리케이션. 컴포넌트 수준의 로딩 상태, 스켈레톤 또는 쉬머 효과가 없습니다.
페이지를 새로고침할 때는 어떻게 하나요?
프리로딩은 사용자가 애플리케이션 내에서 내비게이션할 때만 작동하지만, 새로고침은 처음부터 시작됩니다. 호버 이벤트도, 프리로드도, 경로 전환도 없습니다. 그래서 새로고침에 대해서도 저는 동일한 접근 방식을 취합니다.
개별 컴포넌트가 로딩이 어떻게 보일지 결정하게 하는 대신, 애플리케이션이 여전히 안정화 중인지 추적하고 안정화될 때까지 단일 전체 화면 오버레이를 보여줍니다.
저는 쿼리 라이브러리를 감싸는 작은 추상화를 사용하여 로딩 활동을 프로바이더(Provider)에 보고함으로써 이를 수행합니다. 프로바이더는 마운트된 쿼리들이 여전히 로딩 중인지 추적하고, 초기 페이지 로드가 안정화되는 동안 전체 화면 오버레이를 렌더링합니다.
애플리케이션은 여전히 그 아래에 마운트되고 쿼리들도 정상적으로 실행되지만, 모든 것이 로딩되는 동안 사용자는 부분적으로 렌더링된 상태를 보지 못합니다. 모든 활성 쿼리가 완료되면 오버레이가 사라지고 애플리케이션이 보이게 됩니다.
이것이 완벽하지는 않습니다. 작은 요청 폭포수(Request waterfalls)로 인해 페이지 로드 직후 콘텐츠가 갑자기 튀어나오는 것을 방지하기 위해 오버레이 제거를 약간 디바운스(Debounce) 처리하지만, 디바운스 윈도우보다 더 길게 데이터 폭포수가 발생할 수 있는 경우도 여전히 있습니다. 하지만 저는 그것을 로딩 상태의 문제로 보지 않습니다. 대신 조사해 볼 만한 폭포수가 있다는 신호로 봅니다. 실제로 대부분의 데이터 페칭이 경로 경계로 끌어올려지면(즉, 로더로 이동하면), 이런 경우는 점점 더 드물어진다는 것을 발견했습니다. 비어 있는 섹션이 저에게 무언가 프리로드하는 것을 잊었다고 말해주는 것과 마찬가지로, 새로고침 시 지연되는 콘텐츠는 종종 데이터 의존성이 더 잘 구조화될 수 있음을 알려줍니다.
최종 목표
여기서 멈출 수도 있습니다. 로딩은 이미 컴포넌트의 관심사가 아닌 애플리케이션의 관심사가 되었고, 사용자는 훨씬 적은 로딩 상태를 보고 있습니다. 하지만 솔직히 말해서, 전체 화면 오버레이조차도 타협처럼 느껴집니다.
단점은 사용자가 새로고침할 때마다 오버레이를 보게 된다는 것인데, 이상적으로는 로딩 상태를 전혀 보지 않는 것이 좋습니다.
오버레이가 존재하는 주된 이유는 새로고침이 처음부터 시작되고 애플리케이션이 상호작용 가능해지기 전에 모든 것을 다시 가져와야 하기 때문입니다. 하지만 데이터를 로컬에 영속화(Persist)할 수 있다면 이야기가 달라집니다.
그것이 TanStack DB이든, Zero이든, 혹은 완전히 다른 접근 방식이든, 아이디어는 동일합니다. 애플리케이션이 즉시 렌더링할 수 있을 만큼 충분한 데이터를 기기에 보관하는 것입니다. 첫 방문에는 네트워크 요청이 필요하고 오버레이가 보일 수 있지만, 두 번째 방문은 그렇지 않은 경우가 많습니다. 특히 사용자가 애플리케이션을 탐색하기 시작하고 로컬 캐시가 채워질 기회를 가졌다면 더욱 그렇습니다.
그 시점이 되면 전체 화면 오버레이 또한 일반적인 경험의 일부가 아닌 폴백이 되며, 아키텍처는 동일하게 유지되고 기다림은 점점 더 드물어집니다.
프리로드 덕분에 내비게이션은 즉각적이며, 로컬 영속성은 새로고침도 똑같이 느껴지게 만듭니다.
결론
로딩 상태를 구축하는 데 놀라울 정도로 많은 시간을 쓰고 있다면, 한 걸음 물러나 다른 질문을 던져볼 가치가 있습니다. 컴포넌트가 로딩을 어떻게 처리해야 하는지 묻는 대신, 애초에 로딩 중이어야 하는지 물어볼 수 있습니다.
경로 전환이 로딩을 없애지는 않지만, 로딩을 제가 개인적으로 믿는 원래의 위치, 즉 컴포넌트 수준이 아닌 앱 수준으로 되돌려 놓을 수 있는 방법을 제공합니다.
데이터를 공격적으로 프리로딩하기 시작하면 흥미로운 일이 일어납니다. 기다림이 거의 사라집니다. 그 시점에서 우리는 더 이상 더 나은 로딩 상태를 디자인하려는 것이 아니라, 로딩 상태를 불필요하게 만들려고 노력하게 됩니다.
무언가 늦게 나타나거나, 멈추거나, 깜빡이며 나타난다면 즉시 스켈레톤을 찾지 마세요. 그것을 피드백으로 받아들이세요. 앱은 우리에게 무언가를 말하고 있으며, 대개의 경우 앱이 옳습니다.