"코드를 서로 나누고 피드백을 주고받는 공간이 있었으면 좋겠다!"는 생각으로 Share it! 이라는 커뮤니티를 개발했는데, 어떻게 하면 더 빠른 로드 시간을 달성할 수 있을지 고민했습니다.

Share it!

서비스 링크

메인 페이지포스트 페이지

코드를 자유롭게 나누고 리뷰와 질문을 남기는 공간이 있으면 좋겠다는 생각에 개발한 간단한 커뮤니티입니다. TypeScript, React, styled-components 등을 기술 스택으로 사용했습니다. React Helmet 모듈을 사용해 SEO도 최대한 챙겨보고자 했습니다.

백엔드는 Firebase와 Express로 개발했습니다. (댓글을 남기면 글 작성자에게 메일을 보내는 기능을 위해 Express를 사용했습니다. node-mailer 모듈을 썼습니다.) 배포는 간편하게 Vercel로 했습니다.

테스트를 해봤는데요

개발한건 좋은데, 페이지 로드가 빠르지 않다는 느낌이 들었습니다.

  • 가장 처음 배포했던 버전 링크

성능을 객관적으로 측정해보고 어느 부분이 문제일지 확인하기 위해 Lighthouse로 검사해봤습니다. 외부의 영향을 최소화하기 위해 크롬 시크릿 모드에서 테스트를 진행했습니다.

첫 버전 Lighthouse 점수

보시다시피 성능 점수가 개선이 필요하다고 평가되는 점수대인 70점대를 기록하고 있었습니다. 보고서의 내용을 자세히 보니 이런 문제가 있었습니다.

첫 버전 Lighthouse 구체적인 점수

성능 점수가 90점보다 높으면 '좋음' 평가를 받습니다. 이를 목표로 조금씩 개선해봤습니다.

Time to Interactive

말 그대로 사용자가 웹페이지와 상호 작용이 가능한 시점까지 걸린 시간을 의미합니다. TTI 시간이 0.36초로 보통 수준이 나왔습니다.

TTI에 특히 큰 영향을 미칠 수 있는 한 가지 개선 사항은 불필요한 JavaScript 작업을 연기하거나 제거하는 것입니다. JavaScript를 최적화할 수 있는 기회를 찾아보세요. 특히, 코드 분할로 JavaScript 페이로드를 줄이고 PRPL 패턴을 적용하는 방법을 고려하세요. 타사 JavaScript를 최적화해도 일부 사이트에서 상당한 개선이 이루어집니다.

- Web.dev 상호 작용까지의 시간 내용중 발췌

이를 개선하기 위해서는 코드 분할을 활용해 JS 페이로드를 줄여야 합니다.

사용하지 않는 JS 줄이기

보고서에서도 이 방법을 추천해주고 있습니다.

Lazy Loading

이를 위해 저는 React에서 제공하는 React.lazy를 사용하기로 했습니다. 리액트 18부터 정식 런칭된 기능으로, 다이나믹 import를 일반적인 컴포넌트처럼 쓸 수 있도록 해줍니다.

// 일반적인 import문
import OtherComponent from "./OtherComponent"
 
// 레이지 로딩
const OtherComponent = React.lazy(() => import("./OtherComponent"))
// 일반적인 import문
import OtherComponent from "./OtherComponent"
 
// 레이지 로딩
const OtherComponent = React.lazy(() => import("./OtherComponent"))
import React, { Suspense } from "react"
 
const OtherComponent = React.lazy(() => import("./OtherComponent"))
 
function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  )
}
import React, { Suspense } from "react"
 
const OtherComponent = React.lazy(() => import("./OtherComponent"))
 
function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  )
}

공식 문서에 있는 샘플 코드입니다. 이렇게 하면 해당 컴포넌트가 필요할 때가 돼서야 그 컴포넌트가 사용하는 코드를 로드하기 때문에 최초 접속 시의 로드 시간을 줄일 수 있습니다. (자세한 내용은 공식 문서의 내용을 참고해주세요.)

이를 활용해 아주 간편하게 lazy loading을 구현할 수 있었습니다. Router에서 아래처럼 사용했습니다.

const HomePage = lazy(() => import("./Home"))
const ProfilePage = lazy(() => import("./Profile"))
const PostNewPage = lazy(() => import("./Post/New"))
const LoginPage = lazy(() => import("./Auth"))
const PostByPostIdPage = lazy(() => import("./Post/[postId]"))
const PostEditPage = lazy(() => import("./Post/Edit"))
const MyPostsPage = lazy(() => import("./Profile/Myposts"))
 
const AppRouter = () => {
  return (
    <Router>
      <Navbar />
      <Main>
        <Suspense fallback={<LoadingIndicator />}>{/* ... 라우팅 컴포넌트들 */}</Suspense>
      </Main>
      <Footer />
    </Router>
  )
}
const HomePage = lazy(() => import("./Home"))
const ProfilePage = lazy(() => import("./Profile"))
const PostNewPage = lazy(() => import("./Post/New"))
const LoginPage = lazy(() => import("./Auth"))
const PostByPostIdPage = lazy(() => import("./Post/[postId]"))
const PostEditPage = lazy(() => import("./Post/Edit"))
const MyPostsPage = lazy(() => import("./Profile/Myposts"))
 
const AppRouter = () => {
  return (
    <Router>
      <Navbar />
      <Main>
        <Suspense fallback={<LoadingIndicator />}>{/* ... 라우팅 컴포넌트들 */}</Suspense>
      </Main>
      <Footer />
    </Router>
  )
}

개선 결과

레이지 로딩 적용 전 점수
좌: lazy load 적용 전
레이지 로딩 적용 후 점수
우: lazy load 적용 후
레이지 로딩 적용 전 청크 크기
적용 전
레이지 로딩 적용 후 청크 크기
적용 후
  • Lazy Load 적용 후 배포 버전 링크

개선된 부분

  • 성능 점수가 향상되었습니다. (77점 => 82점)
  • TTI가 20% 개선되었습니다. (3.1s => 2.5s)
  • Largest Contentful Paint 시간이 17% 감소했습니다. (3.6s => 3.0s)
  • 메인 코드의 크기가 32% 감소했습니다. (286.5KiB => 196.0KiB)
  • Total Blocking Time이 20% 감소했습니다. (100ms => 80ms)

Tradeoff

  • 스크립트 요청 수가 1개에서 3개로 늘었습니다. 한 번에 받았던 스크립트 파일을 여러 파일로 나눠 받고 있는 것입니다.

Largest Contentful Paint

개선할 점이 아직 남았습니다.

Largest Contentful Paint가 3초를 기록했습니다. LCP는 Lighthouse에서 점수를 계산할 때 25%라는 큰 가중치를 주는 항목인데, 로드 시작 시점부터 페이지의 메인 콘텐츠가 로드됐을 가능성이 있을 때까지 걸린 시간을 말합니다.

즉 LCP는 사용자가 실질적으로 페이지를 사용할 수 있는 시점까지 얼마나 걸리는지를 측정하는 요소입니다. Lighthouse의 연구에 따르면 2.5초 ~ 4초는 중간 등급인 '개선 필요함'에 해당합니다.

Share it!은 그럼 어떤 요소가 페이지의 로드를 방해하고 있을까요? 보고서를 보니 이 부분들이 눈에 띄었습니다.

렌더링 차단 리소스

폰트 사이즈

중요 요청 체이닝 차단

CSS는 Render Blocking Resource입니다. Share it!은 Pretendard라는 웹 폰트를 불러와 쓰고있는데, 이 폰트의 사이즈가 너무 크기에 폰트를 로드하는 CSS의 동작 시간이 길어졌고, 이게 브라우저가 화면을 그릴(paint) 때 필요한 CSSOM의 생성을 늦춘 것입니다.

개선하려면 네트워크를 더 빠르게 만들거나, 웹 폰트의 용량을 줄여야 하는데 네트워크를 개선할 수는 없으니 폰트의 용량을 줄이는 방향으로 생각해봤습니다.

서브셋 폰트

참고할만한 네이버 D2 글

저는 Pretendard에서 제공하는 서브셋 폰트를 적용했습니다. 서브셋 폰트는 한글의 모든 글자를 담는 대신 불필요한 글자를 제거하고 사용할 글자만 남긴 폰트입니다.

영어는 26개 알파벳으로 이루어져 있다. 영문 폰트에는 대소문자를 포함해 총 72자의 글자가 필요하다. 하지만 한글은 자음, 모음의 조합으로 구성되어 있다. 모든 경우를 조합하면 한글의 글자 수는 11,172자나 된다. 그래서 한글 폰트 파일은 영문 폰트 파일보다 용량이 크다.

(...중략)

불필요한 글자를 폰트에서 제거하고 사용할 글자만 남겨 둔 폰트가 서브셋 폰트다. 글자의 개수가 줄었기 때문에 서브셋 폰트는 용량이 작다.

- 네이버 D2 글 중 발췌

서브셋 폰트를 불러올 때 웹 폰트를 사용하지 않고 직접 웹서버에서 서빙하는 방식으로 변경했으며, 아래의 코드를 적용했습니다.

@font-face {
  font-family: "Pretendard";
  font-weight: 800;
  font-display: swap;
  src: local("Pretendard ExtraBold"),
    url("../fonts/Pretendard/Pretendard-Black.subset.woff2") format("woff2"), url("../fonts/Pretendard/Pretendard-ExtraBold.subset.woff")
      format("woff");
}
/* 똑같이 font-weight에 따라 다 불러옵니다. */
@font-face {
  font-family: "Pretendard";
  font-weight: 800;
  font-display: swap;
  src: local("Pretendard ExtraBold"),
    url("../fonts/Pretendard/Pretendard-Black.subset.woff2") format("woff2"), url("../fonts/Pretendard/Pretendard-ExtraBold.subset.woff")
      format("woff");
}
/* 똑같이 font-weight에 따라 다 불러옵니다. */

font-display 프로퍼티를 swap으로 줘서 폰트 로드가 덜 됐을때도 글자는 보이게 했으며, src 프로퍼티에 fallback 폰트로 woff를 줘 woff2를 지원하지 않는 브라우저에서도 Pretendard 폰트를 쓸 수 있도록 했습니다.

거기에 더해, local을 최우선 src로 줘서 사용자의 기기에 이미 Pretendard가 설치돼 있다면 그걸 불러오도록 했습니다. (참고로 테스트 기기에는 해당 폰트가 설치돼있지 않습니다.)

개선 결과

폰트 최적화 전
좌: 폰트 최적화 전
폰트 최적화 후
우: 폰트 최적화 후
  • 폰트 최적화 후 배포 버전 링크

개선된 부분

  • 성능 점수가 90점 위로 올랐습니다. (82점 => 91점)
  • TTI가 40% 개선되었습니다. (2.5s => 1.5s)
  • LCP가 40% 감소했습니다. (3.0s => 1.8s)
  • FCP가 조금 줄었습니다.
  • Total Blocking Time이 50% 감소했습니다. (80ms => 40ms)
  • Network Payload의 크기가 대폭 감소했습니다. (3736KiB => 1628KiB)

Tradeoff

  • 폰트를 불러오는 Request Chain에서 Maximum Critical Path Latency가 검사 할 때마다 널뛰기를 합니다. Vercel에서 제공하는 무료 웹서버의 한계인 것 같은데, 추후 CDN을 통해 배포하는 방식 등으로 개선할 필요가 있습니다.

결론

최종적으로 원하던 결과였던 90점 이상 맞기는 달성했습니다. 이후 웹 접근성 관련 문제들이나 잘못 작성된 마크업들을 수정해 아래의 결과를 얻을 수 있었습니다.

최종 점수

조금 더 나아가고 싶은 부분

성능과는 별개의 이야기지만, 동적으로 사용자들이 데이터를 올리는 커뮤니티 특성상 sitemap.xml을 어떻게 생성해줘야 할지 고민이 많이 됐습니다.

다른 서비스들은 이런 경우 sitemap을 어떤 식으로 생성하는지 궁금해서 찾아보니 사용자가 글을 올릴 때마다 새로운 sitemap을 생성한다고 하더라고요. 이 부분도 추후 더 개선해보고 싶습니다.