들어가며
최근 회사에서 LCP 지표를 개선하는 과정에서 흥미로운 시도를 하게 되었습니다.
기존에는 이미지 최적화나 JS 청크 분리처럼 흔히 떠올릴 수 있는 기법들 위주로 접근했지만, 이번에는 API 호출 시점을 앞당겨서 LCP 자체를 단축시키는 시도를 해보았고, 약 1초의 수치 개선을 얻을 수 있었습니다.
오늘은 이 과정을 통해 어떻게 API 호출 타이밍을 조정했고, 어떤 구조로 개선했는지를 공유해 보려고 합니다.
배경
미리캔버스는 Next.js를 활용해 서버 사이드 렌더링(SSR) 기반으로 페이지를 프리페치하고 프리렌더링하는 방식으로 운영해왔습니다.
하지만 불규칙하게 발생하는 서버 부하 이슈로 인해 SSR을 일시적으로 비활성화하고, 일부 페이지는 클라이언트 사이드 렌더링(CSR)으로 동작하도록 구성해야 할 때가 있었습니다.
이런 상황에서 가장 먼저 체감된 문제는 LCP 수치가 눈에 띄게 악화된다는 것이었습니다.
처음에는 이미지 최적화나 자바스크립트 코드 다이어트, 청크 분리 등 흔히 말하는 퍼포먼스 개선 기법들을 먼저 떠올렸습니다. 하지만 실제로 적용했을 때 효과는 제한적이었고, 저는 더 빠르고 눈에 띄는 개선을 원했습니다(욕심 😋).
관점 전환: “데이터가 빨리 오면 페이지도 빨리 뜬다”
문제를 다른 시각에서 보기 시작했습니다.
“LCP라고 해서 꼭 렌더링 속도나 이미지 최적화에만 매몰될 필요가 있을까?”
생각해보니 LCP 요소에 포함되는 대부분의 콘텐츠는 API 응답 데이터에 의존하고 있었습니다. 즉, API 응답을 더 빠르게 받아올 수 있다면, 렌더링도 자연스럽게 앞당겨질 것이고, LCP 역시 개선될 수 있습니다.
처음에는 서버와의 handshake 시간을 줄이기 위해 쉽고, 빠르고, 효과 좋은 API preload를 시도했지만, 이미 사내 환경에는 해당 최적화가 적용되어 있었고 더 이상 개선할 여지가 없었습니다.
그러던 중 떠오른 것이 바로 react-router
의 clientLoader
개념이었습니다.
clientLoader
는 브라우저 라우팅 시, 해당 페이지 컴포넌트가 렌더링되기 전에 데이터를 미리 가져올 수 있는 메커니즘입니다.
이 개념을 차용해, 각 페이지의 루트 컴포넌트를 HOC로 감싸고 그 내부에서 API 요청을 사전 실행해보는 시도를 시작했습니다.
시도 1. 페이지 단위 HOC로 API 요청 시점 앞당기기
Next.js에서 각 페이지는 독립적인 React 컴포넌트로 존재하며, 해당 컴포넌트를 감싸는 HOC를 만들어 초기 렌더 이전에 API 요청을 수행할 수 있습니다.
구조
// withPrefetch.tsx
export function withPrefetch<T>(PageComponent: React.ComponentType<T>) {
return function Wrapper(props: T) {
// 주요 API 요청 수행
return <PageComponent {...props} data={/** 데이터 전달 */} />
}
}
// page.tsx
const HomePage = ({ data }) => {
return ...
}
export default withPrefetch(HomePage)
결과적으로 이 방식은 간단하면서도 효과적이었고, 실제로 약 200ms 정도의 LCP 단축 효과가 있었습니다.
문제점 1. API 라이프사이클 및 데이터 전달
API 요청이 일어나는 동안 페이지 전체가 블락되지 않기 위해선 API의 라이프사이클을 추적해야합니다. 또한 이렇게 받아온 데이터는 페이지 루트 컴포넌트로 전달되는데, 이를 실제로 사용하는 컴포넌트는 더 깊은 하위 컴포넌트입니다.
단순히 props
로 넘기기에는 프롭 드릴링 문제가 생겼고, 유지보수성이 크게 떨어졌습니다.
해결: TanStack Query를 통한 API 라이프사이클 관리 및 데이터 공유
전역 queryClient
인스턴스를 통해 HOC 내에서 데이터를 미리 캐싱하고, 이후 하위 컴포넌트에서는 동일한 키로 데이터에 접근하여 두 가지 문제를 해결했습니다.
// HOC 내부에서
queryClient.prefetchQuery(["main"], fetchMainData) // 실제 요청 시점
// 하위 컴포넌트에서
const { data } = useQuery(["main"], fetchMainData) // 응답 거의 도착
문제점 2. 실행 시점
페이지 단위의 HOC는 결국 React가 실행된 이후, 그리고 해당 페이지 컴포넌트가 import
되고 나서야 실행됩니다.
즉, 다음 흐름을 보면 여전히 늦은 시기에 API 요청이 시작되는 것을 알 수 있습니다
HTML 서빙 → JS 서빙 → React 실행 → 루트 컴포넌트 실행 → API 요청 → 페이지 컴포넌트 실행
시도 2: App 루트 레벨에서 API 프리페치
더 빠르게 실행하려면 아예 React가 실행되기 전에 요청을 보내야 했습니다.
생각해낸 방법은 App 컴포넌트 자체를 감싸는 HOC를 만드는 것이었습니다.
구조
// app-with-prefetch.tsx
export function withAppPrefetch<T>(PageComponent: React.ComponentType<T>) {
return function WrappedApp(props: T) {
const queryClient = new QueryClient()
// API 프리페치
queryClient.prefetchQuery(["main"], fetchMainData)
return <PageComponent {...props} queryClient={queryClient} />
}
}
// _app.tsx
export default withAppPrefetch(function MyApp({
Component,
pageProps,
queryClient,
dehydratedState,
}) {
return (
// 전역 인스턴스로 사용
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={dehydratedState}>
<Component {...pageProps} />
</HydrationBoundary>
</QueryClientProvider>
)
})
이 구조에서는 앱 전체의 초기화보다 앞선 시점에서 API 요청이 실행되므로, 리액트 렌더링 이전에 API 요청이 시작됩니다.
또한 여전히 전역에서 하나의 queryClient
인스턴스를 사용하고 있으므로, SSR 환경에서도 상태 일관성을 유지하며 문제 없이 동작합니다.
파이프라인 변화 및 결과
실제 적용 효과를 확인하기 위해, 캐싱을 제거하고 쿼리 파라미터를 일부 수정한 뒤 측정을 진행해 보았습니다.
아래는 API 프리페치 적용 전과 적용 후의 네트워크 타이밍 비교입니다.
적용 전에는 해당 API 요청이 다른 리소스들보다 상대적으로 늦게 시작되며, 전체 네트워크 파이프라인을 살펴보면 몇몇 JS 청크들이 로드된 이후에야 API 요청이 시작되는 것을 확인할 수 있습니다.
반면, 적용 후에는 JS 청크 로드와 병렬로 요청이 시작되어 전체 흐름이 훨씬 빨라진 것을 확인할 수 있었습니다.
지금까지의 변화를 요약하자면 다음과 같습니다.
1. 기존 방식
HTML 서빙 → JS 서빙 → React 실행 → 루트 컴포넌트 실행 → ... → API 요청이 있는 컴포넌트 실행 → 렌더링(커밋) → useEffect에서 API 요청❗️ → 데이터 수신 → 렌더링 → LCP
2. HOC 적용 (페이지 루트 기준)
HTML 서빙 → JS 서빙 → React 실행 → HOC 실행 + API 요청❗️ → 루트 컴포넌트 실행 → 렌더링 → LCP
3. HOC 적용 (앱 루트 기준)
HTML 서빙 → JS 서빙 → HOC 실행 + API 요청❗️ → React 실행 → 렌더링 → LCP
1번 방식에서는 상당히 늦은 시점에 API 요청이 시작됩니다.
Suspense
를 활용하면 렌더링 단계로 요청 타이밍을 앞당길 수는 있지만, 만족하기엔 여전히 느린 타이밍입니다.
2번 방식은 이전보다 개선되었지만, React가 실행된 이후에야 요청이 시작되므로, 상당량의 자바스크립트 코드가 먼저 실행되어야 한다는 단점이 있습니다.
3번 방식은 React가 실행되기 전에 API 요청을 시작할 수 있어, 상대적으로 매우 빠르게 요청을 시작할 수 있었습니다.
결과적으로, 네트워크 탭 및 라이트하우스 분석 결과 다음과 같이 명확한 수치 감소를 확인할 수 있었습니다.
- API 요청 시점이 약 1200ms 앞당김
- LCP 수치 역시 동일 수준으로 감소
단점 및 고려사항
이번 최적화는 분명 눈에 띄는 성능 향상을 이끌어냈지만, 당연히 여러가지 단점들 또한 존재합니다.
저 역시 이러한 이유로 일부 페이지만 제한적으로 적용한 상태이며, 실제 운영 환경에서는 다음과 같은 문제점들을 함께 고려해보시길 권장드립니다.
단점 1. 대역폭(Bandwidth) 소모로 인한 리스크
API 프리페치는 결국 리소스를 더 일찍 요청한다는 것이고, 이는 곧 브라우저의 네트워크 대역폭을 선점한다는 의미이기도 합니다.
미리캔버스는 주요 기능 단위로 정교하게 코드 스플리팅되어 있으며, 특히 에디터 영역처럼 서비스의 핵심 청크들은 빠르게 로딩되는 것이 중요합니다. 그런데 프리페치된 API 요청이 이들과 동시에 전송되면, 브라우저는 네트워크 리소스를 병렬 분할하게 되고, 그 결과로 중요한 JS 청크의 로드 속도가 지연될 수 있습니다.
실제로 XHR
이나 fetch
요청은 기본적으로 fetchPriority: "High"
로 처리되기 때문에, JS 청크보다 대부분 우선적으로 처리됩니다. 따라서 청크의 지연 시간 등을 고려하여 적절한 트레이드오프를 설계하는 것이 중요합니다.
자세한 내용은 브라우저 요청 우선순위 표를 참고해주세요!
이러한 이유로, 실제 운영에서는 모든 API를 무작정 프리페치하기보다는, 페이지의 LCP에 실질적으로 기여하는 핵심 API만 프리페치 대상으로 삼는 것이 좋습니다.
Chrome DevTools의 Network 탭에서 요청의 Priority 컬럼을 확인하면 브라우저가 해당 리소스에 부여한 우선순위를 확인할 수 있습니다.
청크들의 Priority에 변화가 있다면 프리페치 API의 선점이 영향을 준 것일 수 있습니다.
단점 2. 중앙집중화된 로딩 로직의 구조적 한계
현재 구조에서는 각 페이지에서 필요한 API 요청을 앱의 최상단 HOC 내부에서 판단하고 실행하고 있습니다. 즉, 현재 어떤 라우트에 접속해 있는지를 기준으로, 해당 경로에 매핑된 API 요청들을 실행하는 형태입니다.
수도 코드는 다음과 같습니다
const apiMap = {
'/home': [fetchMainData],
'/profile': [fetchUserData],
// ...
};
const route = getCurrentPath(); // 예: /profile/123
const matched = matchDynamicRoute(route); // 다이나믹 라우트 처리 포함
apiMap[matched].forEach((fn) => queryClient.prefetchQuery(...));
이 방식은 단순한 만큼 몇 가지 문제가 있습니다.
첫 번째는 다이나믹 라우트 처리 복잡도가 높다는 것입니다
/profile/[userId]
같은 다이나믹 라우트에서 현재 접속 경로와 일치하는 페이지를 찾기 위해 별도의 라우트 매칭 로직이 필요하고, 이는 점점 관리 비용을 증가시킵니다.
두 번째는 관심사가 명확히 분리되지 않는다는 점입니다.
원래는 각 페이지 컴포넌트가 getServerSideProps
처럼 자신이 사용하는 API만 명시적으로 export
하거나 선언하고, 상위에서 이를 참조하는 방식이 바람직합니다. 다만 아직까지는 이를 자연스럽게 구현할 수 있는 깔끔한 인터페이스가 떠오르지 않아 곤란을 겪고 있습니다 😥
예를 들어, 제가 원하는 수도 인터페이스는 다음과 같습니다
// pages/home.tsx
export const preloadApis = [fetchMainData];
export default function HomePage() { ... }
// HOC 내부에서
const Component = await import(routeToComponentPath)
const apis = Component.preloadApis
현재는 프리페치를 적용한 페이지와 API의 수가 적어 유지보수 부담이 크지 않지만, 향후 적용 범위가 확대되면 점차 관리 비용이 증가할 것으로 예상됩니다.
맺으며
이번 작업을 통해 “LCP는 결국 데이터가 언제 오는지에 따라 달라진다“는 관점을 다시 한번 체감할 수 있었습니다.
또한 이 방식 덕분에 서버에 부하가 생기는 상황에서, SSR 코드를 빠르게 걷어내면서도 최소한의 LCP 성능을 유지할 수 있었던 점이 꽤 실용적이었습니다.
전통적인 이미지 최적화나 JS 코드 경량화만으로는 해결되지 않는 퍼포먼스 병목들이 존재하며, 현상을 리액트 내부가 아닌 브라우저 렌더링 파이프라인 시점으로 바라봄으로써 이와 같은 개선 효과를 얻을 수 있습니다.
물론 적용 과정에서 새로운 복잡도가 유입되기도 했고, 아직 다듬어야 할 부분들도 남아 있습니다. 하지만 성능을 한 단계 더 끌어올리기 위해 구조와 실행 타이밍까지 고민해볼 수 있었다는 점에서, 개인적으로 매우 흥미롭고 의미 있는 시도였다고 생각합니다.
참고
-
fetchPriority: https://web.dev/articles/fetch-priority?hl=ko#resource-priority
-
react-router clientLoader: https://reactrouter.com/start/framework/data-loading#client-data-loading