HomeFeedAbout

블로그 성능 개선 2 - Feed 페이지 LCP 개선하기

김성현 1주 전
LCPPerformance성능 개선

웹 포폴 성능 개선기 2탄입니다.

모든 성능 측정은 CPU: 4x 감속 및 Network: Slow 4g, Disable network cache 를 기반으로 측정했습니다.

그간 메인 페이지에만 신경을 쓰다가, 이제야 Feed 페이지를 개선하는 중이다. 골치 아픈 게 하나 더 있었는데, Feed 페이지에만 들어오면 포스트 목록이 너무 늦게 떴다.

1. 안 쓰는 CSS 줄이기

성능 탭을 확인해보니 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를 하나의 청크로 모아서 가져오기 vs 지금처럼 나눠서 가져오기?

갑자기 생각나서 정리해본다. 지금 내 포폴은 큰 프로젝트가 아니라 어떤 방식이든 크게 상관은 없겠지만 두 방식의 장단점은 이렇다.

1. 지금처럼 각 청크로 나눠서 가져오는 방식

장점
캐싱에 유리하다. 재방문 유저가 있을 경우, 어떤 컴포넌트의 CSS가 바뀌어도 다른 CSS 파일은 이미 캐싱되어 다시 네트워크 요청을 하지 않는다. 조금 지저분해보이지만 하나의 긴 파일보다는 리소스들이 효율적으로 들어오고 있다는 의미이기도 하다.

단점
어쨌든 각 파일이 로드될 때 네트워크 대역폭을 차지한다. 내용도 별로 없는데 말이다.

2. 하나의 청크로 모아서 가져오는 방식

그 반대 + 성능탭 Main 그래프에서 CSS 파일들을 보기가 싫음

결론: 번들링을 공부하자

2. 진짜 문제의 원인을 찾다

또 한 가지 이상한 점을 찾았다.

Velog 포스트를 요청하기 위한 script가 2개가 있었다. 심지어 하나는 진작 실행되었는데, 나머지는 시간 차를 두고 낮은 우선순위와 함께 훨씬 늦게 실행됐다. 화살표로 연결된 page-820e...posts 파일이다.

왜 그런가 궁금해서 코드를 둘러보니, Feed List 페이지를 클라이언트 컴포넌트로 만들었고 CORS 오류를 피하기 위해 api routes를 거쳐 graphQL로 요청을 보내고 있던 게 원인이었다.

시도 1: Not Good

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% ** 개선됐다.

더 개선할 부분

그럼 스크롤 중에는 api route를 안거치고 어떻게 할까?

이 방식은 처음 데이터를 가져올 때는 효과적이지만, 무한 스크롤은 어찌됐든 클라이언트 컴포넌트에서 해야 한다.

그래서 현재 포트폴리오에서 아래로 내리면 늦게 패칭된다. 더 효율적으로 불러오는 방법에 대해 알아보고, 다음 글로 작성해봐야겠다. Tanstack Query에 대해서도 더 자세히 공부할 필요성을 느꼈다.

는 개선했다!

3. Proxy로 무한스크롤 패칭 문제 해결

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));

성과

  1. Feed 페이지 LCP 3.21s → 1.27s (60.4%) 개선 🎉

  2. 무한스크롤 시 rewrites 프록시를 통해 느린 패칭 문제 해결

  3. WebPageTest 등 성능 분석도구와 함께 Performance, Coverage 탭에 많이 익숙해졌다.

  4. 번들 분석을 통해 Feed 이외에도 불필요한 GSAP ScrollTrigger, 애니메이션 등을 제거했다. 개선 전에 비해 번들 크기를 120KB 이상 줄일 수 있었다. 그리고 interaction 패키지의 의존성에 react가 들어있길래;; 제거해줬다.

  5. shape 라이브러리를 통해 작은 크기의 Blur 이미지를 생성하는 유틸 함수를 만들어 주요 이미지들에 자연스러운 blur-up 효과를 적용했다.

재 측정

이쯤에서 기존 환경에 시크릿 창 + 모바일까지 설정하고 다시 Performance 탭에서 검사해봤다.

Home

양호하다! LCP로 측정되는 이미지에는 priority를 설정해줬고, 나머지 두개는 loading="eager" 정도만 추가해줬다.

Lighthouse도 데스크탑, 모바일 모두 100점이 나왔다 👍

Feed

결론

배운 내용을 토대로 성능 문제의 원인을 찾고, 개선하는 과정이 너무 재밌었다. 특히 평소보다 자세히 들여다보면서 성능 탭에도 좀 더 익숙해질 수 있었다.

'CSS 파일을 하나로 합칠 수 있을까?', '얼마나 많은 리소스가 병렬로 다운로드 되어도 괜찮은 걸까?' 등 궁금한 것들을 계속 생각해보고 고민해본 것도 좋았다. 1월 이후에는 번들링을 공부하고 web.dev를 더 찾아보면서 크롬 DevTools와 더 친해져야겠다.

읽어주셔서 감사합니다!