현대 프론트엔드 애플리케이션에서의 데이터 페칭 전략
Ethan Miller
Product Engineer · Leapcell

소개
끊임없이 발전하는 현대 웹 개발 환경에서 빠르고 반응성이 뛰어나며 사용자 친화적인 애플리케이션을 만드는 것이 무엇보다 중요합니다. 이러한 목표를 달성하는 데 있어 핵심적인 요소는 데이터 검색 및 렌더링을 얼마나 효과적으로 관리하느냐입니다. 전통적인 접근 방식은 종종 워터폴, 불안정한 인터페이스, 특히 데이터 집약적인 애플리케이션에서 좌절감을 주는 사용자 경험으로 이어지곤 했습니다. 프레임워크가 성숙하고 새로운 브라우저 기능이 등장함에 따라 개발자는 이러한 문제를 해결하기 위한 보다 정교한 패턴을 갖추게 되었습니다. 이 글에서는 최신 프론트엔드 프레임워크의 세 가지 주요 데이터 페칭 패러다임인 Fetch-on-Render, Fetch-then-Render, Render-as-you-Fetch를 자세히 살펴보고, 각 패러다임의 작동 방식, 실제 구현, 적합한 사용 사례를 분석하여 보다 성능이 뛰어나고 매력적인 웹 애플리케이션을 구축하도록 돕겠습니다.
핵심 데이터 페칭 패턴 설명
각 패턴의 구체적인 내용으로 들어가기 전에, 프론트엔드 애플리케이션에서 데이터 페칭 및 렌더링과 관련된 핵심 개념에 대한 공통된 이해를 구축해 보겠습니다. 본질적으로 데이터 페칭은 UI를 채우기 위해 외부 소스(API 등)에서 필요한 정보를 검색하는 프로세스입니다. 렌더링은 사용자 인터페이스의 보이는 요소를 생성하는 프로세스를 의미합니다. 이 두 작업 간의 상호 작용은 인지된 성능과 사용자 경험에 상당한 영향을 미칩니다.
Fetch-on-Render
원칙: 이것은 아마도 가장 간단하고 역사적으로 가장 일반적인 데이터 페칭 패턴일 것입니다. Fetch-on-Render를 사용하면 데이터가 준비되기 전에 컴포넌트가 렌더링을 시작합니다. 각 컴포넌트는 렌더링 후 일반적으로 자체 데이터 페치를 시작합니다. UI는 데이터 도착을 기다리는 동안 로딩 표시기 또는 대체 콘텐츠를 표시하는 경우가 많습니다.
구현 (React useEffect
사용):
import React, { useState, useEffect } from 'react'; function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { async function fetchUser() { try { const response = await fetch(`/api/users/${userId}`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); setUser(data); } catch (e) { setError(e); } finally { setLoading(false); } } fetchUser(); }, [userId]); // userId가 변경되면 다시 페치 if (loading) return <div>사용자 프로필 로딩 중...</div>; if (error) return <div>오류: {error.message}</div>; if (!user) return null; // 또는 빈 상태 처리 return ( <div> <h2>{user.name}</h2> <p>이메일: {user.email}</p> {/* 사용자 세부 정보 추가 */} </div> ); }
적용 시나리오:
- 데이터 종속성이 최소화된 빠른 프로토타이핑 및 간단한 애플리케이션.
- 컴포넌트가 모든 데이터를 즉시 사용하지 않고도 의미 있는 UI를 렌더링할 수 있는 상황 (예: 스켈레톤 로더 표시).
- 부모 데이터가 자식 데이터의 전제 조건이 아닌 중첩된 컴포넌트 (단, 이는 워터폴 문제를 야기할 수 있습니다).
장점:
- 이해하고 구현하기 쉽습니다.
- 프로세싱 렌더링을 허용하여 일부 UI를 빠르게 표시합니다.
단점:
- 워터폴: 컴포넌트가 부모의 데이터가 필요하고 해당 부모의 데이터 자체도 페치되어야 하는 경우, 여러 개의 순차적 요청이 발생하여 총 로딩 시간이 길어질 수 있습니다.
- 로딩 상태 과부하: 여러 컴포넌트가 독립적으로 데이터를 페치하는 경우 '스피너 팜' UI로 이어질 수 있습니다.
- 클라이언트 측 의존성: 모든 페칭은 JavaScript 번들이 로드되고 실행된 후에 발생합니다.
Fetch-then-Render
원칙: 이 패턴에서는 특정 뷰 또는 컴포넌트에 필요한 모든 데이터가 해당 뷰의 콘텐츠 렌더링이 시작되기 전에 페치됩니다. UI는 일반적으로 모든 데이터가 해결될 때까지 전체 페이지 로딩 표시기를 표시합니다.
구현 (React Router 데이터 로딩/사전 페칭 사용):
개별 컴포넌트의 경우 덜 일반적이지만, 이 패턴은 경로 수준에서 자주 보이며 라우터 라이브러리 또는 서버 측 렌더링(SSR) 메커니즘을 통해 종종 지원됩니다. 일반적인 데이터 로딩 함수를 사용하여 이를 시뮬레이션해 보겠습니다.
// 이 함수가 라우터에 의해 HomePage 컴포넌트가 렌더링되기 전에 호출된다고 가정합니다. async function loadHomePageData() { const [usersResponse, productsResponse] = await Promise.all([ fetch('/api/users'), fetch('/api/products') ]); const users = await usersResponse.json(); const products = await productsResponse.json(); return { users, products }; } function HomePage({ initialData }) { // initialData는 라우터에 의해 전달될 것입니다. const { users, products } = initialData; // 모든 데이터를 미리 구조 분해합니다. // 사용 가능한 데이터를 사용하여 전체 페이지를 렌더링합니다. return ( <div> <h1>저희 스토어에 오신 것을 환영합니다!</h1> <section> <h2>사용자</h2> <ul> {users.map(user => <li key={user.id}>{user.name}</li>)} </ul> </section> <section> <h2>제품</h2> <ul> {products.map(product => <li key={product.id}>{product.name}</li>)} </ul> </section> </div> ); } // 라우터가 이를 사용하는 방법에 대한 의사 코드: // const router = createBrowserRouter([ // { // path: "/", // element: <HomePage />, // loader: loadHomePageData, // // loader 함수는 HomePage가 렌더링되기 전에 데이터가 사용 가능하도록 보장합니다. // // `HomePage` 컴포넌트는 props 또는 hook을 통해 로더 데이터를 받게 됩니다. // } // ]);
적용 시나리오:
- 서버가 초기 HTML을 보내기 전에 모든 데이터를 페치하여 완전히 채워진 첫 번째 페인트를 제공하는 서버 측 렌더링(SSR).
- 새 경로에 필요한 모든 데이터가 탐색 전에 병렬로 페치되어 로딩 깜박임을 방지하는 클라이언트 측 경로 변경.
- 모든 데이터가 엄격하게 상호 의존적이며 부분 UI 렌더링이 의미가 없는 뷰.
장점:
- 단일 뷰 내의 워터폴을 제거합니다.
- 첫 번째 렌더링 시 완전히 채워진 UI를 보장하여 (특히 SSR에서) 인지된 성능을 향상시킵니다.
- 간단한 로딩 상태 관리 (하나의 전역 스피너).
단점:
- 더 긴 총 로딩 시간: 모든 데이터가 페치될 때까지 사용자가 기다리므로, 초기 빈 화면 또는 로딩 스피너가 더 길어질 수 있습니다.
- SSR에서 데이터 페칭이 느린 경우 최초 바이트 시간(TTFB) 증가.
- 각 컴포넌트에 대한 로딩 상태 제어 기능이 덜 세분화됩니다.
Render-as-You-Fetch
원칙: 이것은 가장 발전되고 종종 가장 성능이 뛰어난 패턴으로, 이전 두 패턴의 장점을 모두 결합하는 것을 목표로 합니다. 핵심 아이디어는 컴포넌트가 렌더링을 시작하기 전 또는 동안에 가능한 한 빨리 데이터 페치를 시작하는 것입니다. 데이터 페치는 일반적으로 데이터 캐시 또는 Suspense 지원 유틸리티와 같은 상위 수준 메커니즘에 의해 시작되고 관리되어 컴포넌트가 데이터를 읽으려고 할 때 데이터를 사용할 수 있게 합니다. 컴포넌트는 <Suspense>
를 사용하여 로딩 대체 요소를 정의하여, 특정 데이터가 해결되기를 기다리는 동안 UI가 즉시 렌더링할 수 있는 것을 렌더링하도록 합니다.
구현 (React Suspense 및 Relay, React Query 또는 수동 사전 로딩과 같은 데이터 페칭 솔루션 사용):
간소화된 수동 접근 방식을 사용하여 설명합니다.
import React, { Suspense } from 'react'; // 데이터 페칭 및 상태를 관리하는 "리소스" 추상화 function createResource(promise) { let status = "pending"; let result; let suspender = promise.then( r => { status = "success"; result = r; }, e => { status = "error"; result = e; } ); return { read() { if (status === "pending") { throw suspender; // Suspense가 이 promise를 포착합니다. } else if (status === "error") { throw result; } else if (status === "success") { return result; } } }; } // 실제 앱에서는 이것이 전역 캐시 또는 라우터의 데이터 로더의 일부일 수 있습니다. let userResource = null; let productResource = null; // 사전에 데이터 페치를 시작하는 함수 function preloadAppData(userId, productId) { // 컴포넌트가 렌더링을 시도하기 *전*에 페칭 시작 userResource = createResource(fetch(`/api/users/${userId}`).then(res => res.json())); productResource = createResource(fetch(`/api/products/${productId}`).then(res => res.json())); } // 이것을 가능한 한 빨리 호출합니다. 예: 초기 페이지 로드 시, 또는 경로 변경 시 preloadAppData(1, 101); // 예: 사용자 1 및 제품 101 페치 function UserDetails() { const user = userResource.read(); // 데이터가 준비되지 않으면 이 코드는 suspend됩니다. return <h3>사용자: {user.name}</h3>; } function ProductDetails() { const product = productResource.read(); // 데이터가 준비되지 않으면 이 코드는 suspend됩니다. return <h3>제품: {product.name} (가격: ${product.price})</h3>; } function App() { return ( <div> <h1>환영합니다!</h1> <Suspense fallbackLoading="사용자 데이터 로딩 중..."> <UserDetails /> </Suspense> <Suspense fallback="제품 데이터 로딩 중..."> <ProductDetails /> </Suspense> <p>데이터에 직접 의존하지 않는 다른 콘텐츠는 즉시 렌더링될 수 있습니다.</p> </div> ); }
적용 시나리오:
- 인지된 성능이 매우 중요한 고도로 상호 작용적인 단일 페이지 애플리케이션(SPA).
- 데이터 페칭을 위해 React의 Concurrent Mode 및 Suspense를 활용하는 애플리케이션.
- UI의 일부가 데이터 도착 시 독립적으로 로드되어 보다 유동적인 사용자 경험을 제공하는 시나리오.
- Suspense와 깊이 통합되는 프레임워크 및 라이브러리 (예: Next.js의 데이터 페칭, Relay).
장점:
- 최적의 성능: 워터폴을 제거하고 렌더링과 병렬로 데이터를 페치하여 총 로딩 시간을 최소화합니다.
- 부드러운 사용자 경험: 빈 화면을 피하고, 세분화된 로딩 상태를 허용하며, 컴포넌트는 종속성이 충족되는 즉시 렌더링될 수 있습니다.
- 더 나은 개발자 경험: Suspense는 데이터 페칭에 대한 조건부 렌더링 및 오류 경계를 단순화합니다.
단점:
- 복잡성: 정교한 데이터 관리 레이어 (리소스, 캐시)와 Suspense와의 통합이 필요합니다.
- 학습 곡선: Suspense 및 오류 경계와 같은 개념을 완전히 이해하는 데 시간이 걸릴 수 있습니다.
- 최신 프레임워크 기능과 종종 특정 데이터 페칭 라이브러리가 필요합니다.
결론
올바른 데이터 페칭 패턴을 선택하는 것은 프론트엔드 애플리케이션의 성능과 사용자 경험에 지대한 영향을 미치는 근본적인 결정입니다. Fetch-on-render는 간단하지만 워터폴이 발생하기 쉽습니다. Fetch-then-render는 완전한 UI를 보장하지만 전반적으로 더 긴 대기 시간으로 이어질 수 있습니다. Render-as-you-fetch는 더 복잡하지만, 데이터 페칭을 렌더링 논리에서 분리하고 동시 기능을 활용하여 가장 유동적이고 성능이 뛰어난 사용자 경험을 제공합니다. 최신 애플리케이션은 고도로 반응성 있는 인터페이스를 제공하기 위해 'Render-as-you-fetch'에 점점 더 의존하고 있습니다. 이러한 뚜렷한 접근 방식을 이해함으로써 개발자는 데이터 전달을 전략적으로 최적화하여 더 빠르고, 더 탄력적이며, 궁극적으로 더 만족스러운 사용자 상호 작용을 만들 수 있습니다.