웹 포폴 성능 개선기 2탄입니다.
모든 성능 측정은 CPU: 4x 감속 및 Network: Slow 4g, Disable network cache 를 기반으로 측정했습니다.
그간 메인 페이지에만 신경을 쓰다가, 이제야 Feed 페이지를 개선하는 중이다. 골치 아픈 게 하나 더 있었는데, Feed 페이지에만 들어오면 포스트 목록이 너무 늦게 떴다.
성능 탭을 확인해보니 6개의 CSS 청크 6개가 보였다.
모서리에 빨간색으로 뜨는 이유는 CSS 자체가 렌더링 블로킹 리소스라는 표시다.
https://oilater.com/_next/static/css/14bd0e0e6b479f25.css (1.1KB)
파일을 보니 문제가 보였다.
당시에 토스 피드의 개발자 도구를 까보면서 참고해 정의했던 색상 토큰이 있었는데
// src/tokens/palette.css.ts
export const palette = {
white: '#FFFFFF',
black: '#17171c',
opacity50: 'rgba(0, 23, 51, 0.02)',
opacity100: 'rgba(2, 32, 71, 0.05)',
opacity200: 'rgba(0, 27, 55, 0.1)',
opacity300: 'rgba(0, 29, 58, 0.18)',
opacity400: 'rgba(0, 25, 54, 0.31)',
opacity500: 'rgba(3, 24, 50, 0.46)',
opacity600: 'rgba(0, 19, 43, 0.58)',
opacity700: 'rgba(3, 18, 40, 0.7)',
opacity800: 'rgba(0, 12, 30, 0.8)',
opacity900: 'rgba(2, 9, 19, 0.91)',
grey50: '#f9fafb',
grey100: '#f2f4f6',
grey200: '#e5e8eb',
grey300: '#d1d6db',
grey400: '#b0b8c1',
grey500: '#8b95a1',
grey600: '#6b7684',
grey700: '#4e5968',
grey800: '#333d4b',
grey900: '#191f28',
// ...
};
실제로 사용하는 시멘틱 토큰이 아닌 이 palette를 한번에 root에 CSS 변수로 설정해놓고 있었다.
// src/tokens/theme.css.ts
import { palette } from './color/palette';
export const global = createGlobalTheme(':root', {
colors: palette, // 이거
});
palette는 색상 팔레트일 뿐, 프로젝트에서 실제로 참조되는 색상이 아니기 때문에 globalTheme에 등록할 이유가 없었다. 해당 코드를 제거하고 시멘틱 토큰을 참조하도록 변경해주었다.
네트워크 탭을 보니 더 이상 테마 관련 CSS 외에 불필요한 CSS 변수들이 포함되지 않아 양이 많이 줄었다.
사실 처음엔 'CSS 청크 개수가 줄지 않아서 서버에서 받아오는 시간이 기니까 LCP가 그대론가?' 이런 생각도 해봤다. 하지만, 그래프에서 나오듯이 HTTP/2의 멀티플렉싱으로 인해 병렬로 요청된다.
갑자기 생각나서 정리해본다. 지금 내 포폴은 큰 프로젝트가 아니라 어떤 방식이든 크게 상관은 없겠지만 두 방식의 장단점은 이렇다.
장점
캐싱에 유리하다. 재방문 유저가 있을 경우, 어떤 컴포넌트의 CSS가 바뀌어도 다른 CSS 파일은 이미 캐싱되어 다시 네트워크 요청을 하지 않는다. 조금 지저분해보이지만 하나의 긴 파일보다는 리소스들이 효율적으로 들어오고 있다는 의미이기도 하다.
단점
어쨌든 각 파일이 로드될 때 네트워크 대역폭을 차지한다. 내용도 별로 없는데 말이다.
그 반대 + 성능탭 Main 그래프에서 CSS 파일들을 보기가 싫음
결론: 번들링을 공부하자
또 한 가지 이상한 점을 찾았다.
Velog 포스트를 요청하기 위한 script가 2개가 있었다. 심지어 하나는 진작 실행되었는데, 나머지는 시간 차를 두고 낮은 우선순위와 함께 훨씬 늦게 실행됐다. 화살표로 연결된 page-820e...와 posts 파일이다.
왜 그런가 궁금해서 코드를 둘러보니, Feed List 페이지를 클라이언트 컴포넌트로 만들었고 CORS 오류를 피하기 위해 api routes를 거쳐 graphQL로 요청을 보내고 있던 게 원인이었다.
List 페이지를 서버 컴포넌트와 클라이언트 컴포넌트를 분리해줬다. 그리고 Tanstack Query의 queryClient.prefetchInfiniteQuery와 HydrationBoundary를 이용했다.
prefetchInfiniteQuery로 서버에서 데이터를 가져와 캐시에 저장하고,
HydrationBoundary를 통해 클라이언트가 데이터를 그대로 물려받는 방식이다.
// app/feed/(list)/page.tsx
export default async function Feed() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
});
await queryClient.prefetchInfiniteQuery({
queryKey: ['posts'],
queryFn: async () => {
const posts = await getPosts();
const nextCursor = posts.length >= 10 ? posts.at(-1).id : null,
return {
posts,
nextCursor,
};
},
initialPageParam: undefined,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<FeedList />
</HydrationBoundary>
);
}
하지만 좋은 방법이 아니라고 생각했다. 첫 포스트 batch data만 받아서 전달해줘도 충분하기 때문이다.
그래서 prefetchInfiniteQuery, HydrationBoundary 를 제거하고, FeedList라는 클라이언트 컴포넌트에는 fetchPosts로 받아온 첫 포스트 묶음만 넘겨주었다.
LCP가 3.21s → 1.27s로 ** 약 60.4% ** 개선됐다.
이 방식은 처음 데이터를 가져올 때는 효과적이지만, 무한 스크롤은 어찌됐든 클라이언트 컴포넌트에서 해야 한다.
그래서 현재 포트폴리오에서 아래로 내리면 늦게 패칭된다. 더 효율적으로 불러오는 방법에 대해 알아보고, 다음 글로 작성해봐야겠다. Tanstack Query에 대해서도 더 자세히 공부할 필요성을 느꼈다.
는 개선했다!
next의 rewrites를 통해 해결할 수 있었다. rewrites는 URL 프록시 기능으로, source에 해당하는 요청을 destination으로 바꿔준다. 다음과 같이 설정하면 클라이언트 컴포넌트에서도 CORS 에러를 피해갈 수 있다.
// next.config.ts
import withBundleAnalyzerPkg from '@next/bundle-analyzer';
import { createVanillaExtractPlugin } from '@vanilla-extract/next-plugin';
const withBundleAnalyzer = withBundleAnalyzerPkg({
enabled: process.env.ANALYZE === 'true',
});
const withVanillaExtract = createVanillaExtractPlugin();
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
async rewrites() {
return [
{
source: '/api/velog/graphql',
destination: 'https://v2.velog.io/graphql',
},
];
},
};
export default withBundleAnalyzer(withVanillaExtract(nextConfig));
Feed 페이지 LCP 3.21s → 1.27s (60.4%) 개선 🎉
무한스크롤 시 rewrites 프록시를 통해 느린 패칭 문제 해결
WebPageTest 등 성능 분석도구와 함께 Performance, Coverage 탭에 많이 익숙해졌다.
번들 분석을 통해 Feed 이외에도 불필요한 GSAP ScrollTrigger, 애니메이션 등을 제거했다. 개선 전에 비해 번들 크기를 120KB 이상 줄일 수 있었다. 그리고 interaction 패키지의 의존성에 react가 들어있길래;; 제거해줬다.
shape 라이브러리를 통해 작은 크기의 Blur 이미지를 생성하는 유틸 함수를 만들어 주요 이미지들에 자연스러운 blur-up 효과를 적용했다.
이쯤에서 기존 환경에 시크릿 창 + 모바일까지 설정하고 다시 Performance 탭에서 검사해봤다.
양호하다! LCP로 측정되는 이미지에는 priority를 설정해줬고, 나머지 두개는 loading="eager" 정도만 추가해줬다.
Lighthouse도 데스크탑, 모바일 모두 100점이 나왔다 👍
배운 내용을 토대로 성능 문제의 원인을 찾고, 개선하는 과정이 너무 재밌었다. 특히 평소보다 자세히 들여다보면서 성능 탭에도 좀 더 익숙해질 수 있었다.
'CSS 파일을 하나로 합칠 수 있을까?', '얼마나 많은 리소스가 병렬로 다운로드 되어도 괜찮은 걸까?' 등 궁금한 것들을 계속 생각해보고 고민해본 것도 좋았다. 1월 이후에는 번들링을 공부하고 web.dev를 더 찾아보면서 크롬 DevTools와 더 친해져야겠다.
읽어주셔서 감사합니다!