📜 서론
이번 포스팅에서는 지난번 리스트 가상화 기법에 이어 또 한번의 최적화를 달성한 경험을 다뤄보려 한다. 안 그래도 사이트 접근 시 리뷰들을 보려 할 때 발생하는 약간의 딜레이가 상당히 불편했는데, 이번 사례를 통해 렌더링 시간이 대폭 줄었고, UX적인 측면에서도 상당한 개선이 있었다.
🌊 네트워크 폭포수
네트워크 폭포수(Network Waterfall) 는 여러 네트워크 요청이 순차적으로 진행되는 현상이다. 각 요청은 이전 요청의 완료를 기다린 후 시작되어, 마치 폭포가 흐르는 것처럼 보인다. 이러한 순차적 요청 방식은 데이터와 자원이 제때 불러와지지 않아 페이지 렌더링 속도를 늦추며, 사용자 경험에 부정적인 영향을 미친다. 특히 CSR 환경에서 컴포넌트 단위의 데이터 페칭이 지연되면, 전체적인 페이지 로딩 시간이 늘어나게 되는 것이다.
이 문제를 해결하기 위해서는 위와 같이 페이지 진입 시점에 필요한 모든 데이터를 미리 받아오는 방식이 효과적이다. 이를 구현할 수 있는 기술이 바로 React Router의 loader
API다.
🛠️ 기존 구조의 문제점과 개선 구조
📌 기존 구조
사실 loader
API는 진작에 사용하고 있었다. 하지만 의미 있게 사용되지 않고 있었을 뿐이다. 페이지 접근 시 첫 페이지 청크에 포함되는 리뷰들의 ID 리스트만 가져오도록 하고 있었는데, 이건 사실 loader
를 사용하지 않는 것과 마찬가지였다.
개발 초창기에 '첫 페이지 접근 시 데이터를 많이 가져오면 문제가 있지 않을까?'라는 생각과, 여러 리뷰 카드 컴포넌트들에서 각각 병렬적으로 useQuery
를 통해 개별 리뷰 데이터들을 가져와 캐싱하면 효율적일 것 같다는 생각 때문에 첫 화면에 표시될 리뷰 ID 목록만 가져와서 각 컴포넌트에 뿌려주는 구조로 맨 처음에 구현되었고, 현재까지 위와 같은 구조가 유지되고 있었다.
하지만 이러한 구조로 인해 사이트 진입 시 위와 같이 페이지 내의 모든 컨텐츠들이 로딩되는 데에 어느 정도 딜레이가 발생하고 있었다. 좀 더 정확히 말하자면 어떤 리뷰 카드는 컨텐츠 로딩이 완료되었는데, 다른 리뷰 카드는 여전히 스켈레톤 UI 상태로 남아있는 상태가 빈번히 일어날 수밖에 없는 구조였다.
그리고 개발을 진행하며 다음과 같은 사실을 깨닫게 됐다.
- 첫번째 페이지 청크의 리뷰 데이터들(20개)을 통째로 다 가져와도 어차피 텍스트 데이터에 불과하기 때문에 오버헤드가 별로 안 걸린다.
- 그렇게 리뷰 데이터들을 페이지 별로 통째로 가져오게 되면 서버 요청수가 청크 당 21건(리뷰 ID 리스트 데이터 요청 + 20개의 리뷰 데이터 요청)에서 딱 1건으로 줄어들어 요청 수를 줄일 수 있다.
- Tanstack Query의 useQueryClient를 사용하면 가져온 데이터들을 쪼개 개별로 캐싱할 수 있다.
📌 개선된 구조
그래서 위와 같은 구조로 개선했다.
페이지 접근 시 loader
를 통한 리뷰 정보 목록 데이터를 가져온다. 이후 이미 로드된 데이터들을 화면에 렌더링하면 전체 화면 렌더링이 간단하게 끝난다. 동시에 각각의 리뷰 데이터들을 캐싱하는 작업까지 추가로 이뤄져, 세부 리뷰 페이지로 이동 시 추가적인 데이터 요청 없이 캐싱된 데이터를 활용해 페이지가 렌더링되는 기능은 그대로 유지된다.
이를 위해 당연히 백엔드 API도 변경이 필요했다. 기존에는 첫번째 페이지 청크에 해당하는 리뷰 데이터들의 ID만 반환하는 기능이었으나, 리뷰들의 모든 데이터들을 반환하도록 변경했다.
이렇게 loader
API를 적절히 활용하면 CSR에서 SSR로 페이지를 구현한 것과 유사한 효과를 낼 수 있다.
💻 코드
📌 라우터
// 기존 코드
const router = createBrowserRouter([
{
element: <App />,
loader: async () => { // 이런 식으로 loader를 통해 로그인한 유저의 정보도 미리 불러오도록 했다.
const isSignedIn = await checkAuth();
if (isSignedIn) {
const userInfo = await getLoginUserInfo();
return userInfo;
} else {
return null;
}
},
children: [
{
path: "/",
element: <Feed />,
errorElement: <ConnectionError />,
loader: async () => {
const initialData = await fetchLatestFeed(""); // 최신 리뷰 데이터 가져오기
return initialData;
},
},
// 기존 코드
📌 리뷰 데이터 요청 및 캐싱
// ... 기존 코드
const initialData = useLoaderData() as ReviewInfo[];
const queryClient = useQueryClient();
// initialData 캐싱
if (initialData) {
initialData.forEach((item) => {
queryClient.setQueryData(["reviewInfo", item.aliasId], item);
});
}
// ... 기존 코드
return async ({ pageParam }: { pageParam: string }) => {
const data = await fetchLatestFeed(pageParam);
data.forEach((item) => {
queryClient.setQueryData(["reviewInfo", item.aliasId], item);
});
return data;
};
// ... 기존 코드
📌 리뷰 데이터 청크 요청 API
export const getLatestFeed = asyncHandler(async (req, res) => {
//... 기존 코드...
const formattedReviewList = reviewList.map((review) => ({
// 각 리뷰의 모든 정보
}));
res.status(200).json(formattedReviewList);
}, "최신 리뷰 조회");
📏 성능 측정 환경
성능 측정은 로컬 서버 환경에 사이트를 띄운 후, 크롬 시크릿 모드로 접속해 브라우저 개발자 도구의 Performance
탭에서 제공하는 데이터를 사용하였다. 페이지 로딩 과정에서 어떤 단계에서 얼마나 시간이 소요되었는지 보여주기 때문에 제대로 된 성능 측정이 가능할 것이라 판단했다.
또한, 이번 성능 분석은 현실적인 테스트 환경에서의 경향성을 확인하기 위해 10회 측정 후 평균을 냈다. 더 많은 데이터를 수집하면 정밀도가 높아지겠지만, 주요 성능 패턴을 파악하는 데는 10회 측정만으로도 충분하다고 판단했다.
여기서 측정하는 각 성능 지표들에 대해 먼저 간단히 설명하고 넘어가겠다.
📌 로드 중 (Loading)
- 페이지 로딩 과정에서 HTML, CSS, JavaScript 파일을 네트워크에서 가져오는 시간
- 일반적으로
2~3ms
정도, 로드 시간이 길다면 보통 네트워크 상태에 문제가 있는 것
📌 스크립트 (Script Execution)
- JavaScript 코드 실행에 소요된 시간
📌 렌더링 (Rendering)
- 브라우저가 새로운 HTML을 분석하고 DOM을 생성하는 데 걸리는 시간
- React에서는 컴포넌트가 변경될 때 Virtual DOM을 비교(diffing)하고 실제 DOM을 업데이트하는 과정도 포함됨
📌 페인팅 (Painting)
- 브라우저가 실제 화면에 픽셀을 그리는 과정
- CSS 애니메이션이 너무 많거나, 자주 업데이트되는 요소(예: 무거운 이미지, 큰 배경색 변경 등)가 많을 경우 이 시간이 길어짐
📌 시스템 (System)
- 운영체제에서 발생하는 프로세스 처리 시간
- 브라우저 내부적인 프로세스나 OS에서 발생하는 지연이 포함될 수 있음
- 일반적으로 개발자가 직접 최적화하기 어려운 영역이지만,
스크립트 실행
및렌더링
시간이 줄어들면 시스템 시간도 줄어드는 경향이 있음
📌 유휴 상태 (Idle)
- 브라우저가 아무 작업도 하지 않고 대기하는 시간
- 유휴 상태 시간이 많다는 것은 특정 이벤트나 연산, 비동기 요청을 기다리고 있다는 뜻
📊 측정 결과
💡 로컬 서버의 콜드 스타트로 인해 지연된 케이스는 제외하였다.
📌 최적화 전
항목 | 첫 번째 측정 (ms) | 두 번째 측정 (ms) | 세 번째 측정 (ms) | 네 번째 측정 (ms) | 다섯 번째 측정 (ms) | 여섯 번째 측정 (ms) | 일곱 번째 측정 (ms) | 여덟 번째 측정 (ms) | 아홉 번째 측정 (ms) | 열 번째 측정 (ms) | 평균 (ms) |
---|---|---|---|---|---|---|---|---|---|---|---|
로드 중 | 2 | 3 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2.1 |
스크립트 | 885 | 897 | 866 | 824 | 897 | 849 | 972 | 895 | 900 | 863 | 884.8 |
렌더링 | 378 | 361 | 389 | 342 | 395 | 366 | 366 | 359 | 379 | 351 | 368.6 |
페인팅 | 66 | 69 | 62 | 66 | 70 | 69 | 71 | 66 | 69 | 66 | 67.4 |
시스템 | 320 | 300 | 312 | 308 | 354 | 311 | 352 | 310 | 349 | 315 | 323.1 |
합계 | 1999 | 1965 | 1976 | 1904 | 2096 | 1929 | 2042 | 1985 | 2023 | 1935 | 1985.4 |
📌 최적화 후
항목 | 첫 번째 측정 (ms) | 두 번째 측정 (ms) | 세 번째 측정 (ms) | 네 번째 측정 (ms) | 다섯 번째 측정 (ms) | 여섯 번째 측정 (ms) | 일곱 번째 측정 (ms) | 여덟 번째 측정 (ms) | 아홉 번째 측정 (ms) | 열 번째 측정 (ms) | 평균 (ms) |
---|---|---|---|---|---|---|---|---|---|---|---|
로드 중 | 2 | 3 | 2 | 2 | 3 | 2 | 2 | 2 | 2 | 2 | 2.2 |
스크립트 | 786 | 709 | 697 | 692 | 690 | 722 | 727 | 754 | 703 | 747 | 722.7 |
렌더링 | 198 | 182 | 192 | 188 | 188 | 199 | 195 | 189 | 202 | 183 | 191.6 |
페인팅 | 55 | 45 | 46 | 45 | 46 | 51 | 46 | 49 | 48 | 47 | 47.8 |
시스템 | 265 | 260 | 253 | 254 | 252 | 276 | 288 | 271 | 263 | 257 | 263.9 |
유휴 상태 | 278 | 242 | 247 | 238 | 245 | 216 | 269 | 254 | 230 | 259 | 247.8 |
합계 | 1584 | 1442 | 1437 | 1419 | 1425 | 1467 | 1527 | 1519 | 1449 | 1495 | 1476.4 |
📌 최적화 결과
항목 | 최적화 전 평균 소모 시간(ms) | 최적화 전 평균 소모 시간(ms) | 변화량 (ms) | 변화율 (%) |
---|---|---|---|---|
로드 중 | 2.1 | 2.2 | +0.1 | +4.76% |
스크립트 | 884.8 | 722.7 | -162.1 | -18.32% |
렌더링 | 368.6 | 191.6 | -177.0 | -48.02% |
페인팅 | 67.4 | 47.8 | -19.6 | -29.08% |
시스템 | 323.1 | 263.9 | -59.2 | -18.32% |
유휴 상태 | 339.3 | 247.8 | -91.5 | -26.96% |
합계 | 1985.4 | 1476.4 | -509.0 | -25.63% |
- 전체 성능 약 34.5% 향상
- 스크립트 실행 시간 18.3% 감소
- 렌더링 시간이 무려 48% 감소해 가장 큰 개선이 이뤄짐
이외에도 페인팅, 시스템, 유휴 상태 시간이 각각 29%, 18.3%, 27% 감소해 전반적인 성능 최적화 효과를 확인할 수 있었다.
📌 분석
1. 스크립트 실행 시간 (18.32% 감소)
- 원인: React Router의 loader API와 Tanstack Query를 통합적으로 활용하면서 데이터 요청 패턴이 변경됨
- 개선 메커니즘:
- 페이지 접근 시 여러 개별 요청(21건)이 아닌 단일 요청(1건)으로 변경됨
- 컴포넌트별로 개별적인 useQuery 호출을 제거하고 데이터를 한 번에 가져옴
- JavaScript 실행 시간이 줄어든 이유는 여러 비동기 요청 처리 로직이 단순화되었기 때문
2. 렌더링 시간 (48.02% 감소)
- 원인: 가장 큰 개선이 이루어진 부분으로, 데이터 로딩 패턴의 변화가 렌더링 프로세스에 직접적인 영향을 미침
- 개선 메커니즘:
- 기존에는 각 리뷰 컴포넌트마다 스켈레톤 UI → 데이터 로딩 → 리렌더링 과정을 거침
- 최적화 후에는 모든 데이터가 미리 로드되어 컴포넌트가 처음부터 최종 상태로 렌더링됨
- 여러 번의 리렌더링 사이클이 제거되어 Virtual DOM 비교(diffing) 작업이 크게 감소
3. 페인팅 시간 (29.08% 감소)
- 원인: 브라우저가 실제 화면에 픽셀을 그리는 과정이 간소화됨
- 개선 메커니즘:
- 스켈레톤 UI에서 실제 데이터로의 전환 과정이 제거됨
- 여러 번의 레이아웃 변경과 리페인팅 작업이 줄어듦
- 모든 컴포넌트가 거의 동시에 최종 상태로 렌더링되므로 페인팅 작업이 일괄적으로 처리됨
4. 시스템 시간 (18.32% 감소)
- 원인: 브라우저와 운영체제 수준의 처리 시간이 감소
- 개선 메커니즘:
- 스크립트 실행 및 렌더링 시간이 줄어들어 시스템 시간도 자연스럽게 감소
- 여러 네트워크 요청 관리와 비동기 처리에 따른 시스템 오버헤드가 감소
5. 유휴 상태 시간 (26.96% 감소)
- 원인: 브라우저가 대기하는 시간이 감소
- 개선 메커니즘:
- 여러 개별 API 요청을 기다리는 대신 단일 요청으로 모든 데이터를 한 번에 가져옴
- 비동기 요청 간의 대기 시간이 제거됨
- 데이터 캐싱을 통해 추가 페이지 이동 시 API 요청 없이 즉시 데이터를 사용할 수 있게 됨
🏁 마무리
이번 포스팅의 내용을 제대로 이해했다면 알 수 있겠지만, React Router의 loader
는 진짜 혁신이다. 최근에 ChatGPT가 Next.js에서 React Router의 확장 버전인 Remix로 전환했다는 사실도 어쩌면 React Router의 혁신성을 보여주는 사례라 할 수 있겠다.
어쨌든 지금까지 React Router의 loader
API와 Tanstack Query를 활용해 서비스 페이지 렌더링 방식을 어떤 식으로 최적화했는지 살펴봤다. 개인적으로도 개발을 하며 첫 페이지 렌더링 시 마음 속에서 피어오르는 불편함이 한 두가지가 아니었는데, 상당히 만족스러웠던 최적화였다.
'프로젝트 > Re|view' 카테고리의 다른 글
[Re|view] 10. 클라우드 서비스 연동 및 Github Actions로 CI/CD 구축하기 (0) | 2025.02.11 |
---|---|
[Re|view] 9. 리스트 가상화 기법으로 리뷰 리스트 페이지 최적화하기(feat. Tanstack Virtual) (0) | 2025.02.01 |
[Re|view] 8. 무한 스크롤 적용기 (0) | 2025.01.30 |
[Re|view] 7. FSD 구조 적용 실패기 (0) | 2025.01.28 |
[Re|view] 6. 카카오 로그인 적용기 - Redux Toolkit으로 사용자 정보 관리하기 & 로그아웃 (1) | 2024.09.04 |