유저와 FE 팀원들을 위한 나만의 LCP 최적화

민호·2025년 5월 5일
17

deepdive

목록 보기
2/9
post-thumbnail



디프만 16기 CRITIX 프로젝트를 만들며 나름 나만의 비즈니스적 관점을 발현해 보고 싶었다.

첫번째 시도는 유저 테스트를 통해 받은 피드백을 개선하기 위해 보안 사항을 적용한 것이었고

두번째 시도는 팀원의 생산성을 위해 나만의 공통 컴포넌트 경계선을 정립한 것이었다.


이제 세번째 시도로, 유저 이탈을 막기 위한 랜딩 페이지에 최적화를 진행해 보려 한다.

다만, 최적화의 목표 달성 과정에서 팀의 개발 생산성이 희생되지 않도록 여러 자동화 사이클을 함께 도입했다.








현재 프로덕션 배포 환경에서 Lighthouse 지표는 다음과 같다.


여기서 내가 중요하게 여긴 것은 LCP이다.

LCP는 사용자가 “이 페이지가 떴다”고 인식하는 순간을 측정하는 지표이며, 이 시점을 빠르게 만드는 것이 곧 “빠른 웹사이트”라는 인상을 좌우하기 때문이다.

실제로 우수한 사용자 환경을 제공하기 위해 사이트의 Largest Contentful Paint가 2.5초 이하를 권장한다고 한다.

출처: https://web.dev/articles/lcp?hl=ko






최적화 대상 지정

LCP지표에 악영향을 미치는 원인이 무엇인지 알 방도가 없으므로 performace 탭을 무작정 열어보았다.

자세히 살펴보니 LCP 이전에 발생하는 여러 작업들이 눈에 띄였고, 이게 원인이라고 생각했다.

  1. 폰트의 로드가 매우 길다. 네트워크 요청 자체는 빠르지만 그게 적용되는데 많은 시간이 걸린다.
    (아마 폰트의 용량이라던가 글꼴이 실제로 디코딩 되는 과정이 긴 것 같았다.)

  2. 최초 렌더링 시 뷰포트에 보이지 않는 이미지들까지 동시다발적으로 불러오고 있다.


LCP를 타겟으로 위와 같은 요소들을 최적화 대상으로 정했고 다음과 같은 개선작업을 진행했다.











🧪 이미지 최적화


참고로 우리 팀은 모든 정적 에셋을 public폴더가 아닌 src 폴더에 두고 사용중이다.



1. 팀원들을 위한 webp 이미지 리사이징 자동화


현재 우리 팀은 src/asset 경로에 정적 이미지들을 png 형태로 보관하고 있다. 이를 webp로 변환하려고 했다.

여러 방법이 있지만, 나는 sharp 를 사용해서 이미지 리사이징을 진행했다.

어떻게 보면 현재 프로젝트에 사용되는 이미지들만 webp로 저장해주면 매우 간단히 끝낼 수 있다.

❗ 그러나, 나는 항상 팀원의 편의성이 프로젝트의 생산성과 직결된다고 생각하기에 여기서 자동화를 도입해보고 싶었다.

  • '현재까지 사용되는 이미지들만 webp로 저장하고 끝'이 아니라
  • 앞으로도 팀원이 그 어떠한 png 파일을 가져다 놓아도, 자동으로 sharp를 통해 webp로 변환되는 자동화 싸이클을 적용해놓고 싶은 것이다.

대략적인 전체 개요는 다음과 같다.

  1. 최초에는 다른 팀원이 아무런 이미지 파일(.png , .jpg 등)을 /src에 두고 개발을 진행한다.

    => 이땐 당연하게도 해당 이미지를 그대로 사용한다

  2. origin에 push를 하게 되면, 빌드가 진행된다.

  3. 이 빌드 타임에 optimize-images.ts 스크립트를 진행시킨다.

    해당 스크립트는 src/asset에 존재하는 정적 이미지들을 sharp 라이브러리를 통해 .webp 형태로 리사이징 하는 것이다.

  4. 이후 CI/CD가 진행되며 .webp 파일이 S3에 같이 업로드가 된다.

  5. 만약 다음 빌드 시, 추가로 변경할 이미지가 없다면 해당 과정은 생략된다.

이 과정들을 통해 사용하는 팀원들은 그 어떠한 추가 동작 없이 리사이징된 .webp 이미지를 자동으로 생성할 수 있게 된다.



2. 커스텀 <Image /> 컴포넌트로 이미지 최적화 및 경로 자동화


앞선 시각화 사진에 '커스텀 <Image /> 컴포넌트'가 보일 것이다.

이는 팀원이 자동으로 .webp 이미지를 생성하게끔 해 줬으니, 해당 이미지를 <Image /> 컴포넌트로 자동 적용하는 것이라 보면 된다.


실제로 Next/Image 에서 영감을 받아 React에서도 조금이나마 이미지를 최적화 하기 위해 만든 컴포넌트이기도 하다. 기능은 다음과 같다.


  1. 자동 빌드 에셋 경로 매칭

    팀원이 name props로 로컬 /src 디렉토리에 있는 이미지 이름만 넣어주면, (ex. hero)

    • 'asset/hero-[hash].png'
    • 'asset/hero-[hash].webp'

    이들에 대한 경로를 빌드 환경에서도 매번 고려할 필요 없이

    컴포넌트 내부에서 자동으로 hero.webp => hero.png 순서로 이미지 경로 매칭


  1. 뷰포트 기반 요청

    react-intersection-observer를 통해, 뷰포트에 들어올 때 해당 이미지를 요청


  1. priority props를 통한 LCP 이미지 최적화

    • <img/>loading = ‘eager’fetchpriority=’high’ 적용

    • LCP를 위해 react-intersection-observer에서 무조건 inView처럼 동작


  1. fallback 이미지

    만약 name props로 주입받은 로컬 이미지가 존재하지 않는다면, src 속성으로 주입받은 URL로 이미지 렌더링


즉, <Image /> 컴포넌트는 ‘이미지에 대한 여러 최적화 기능’ + ‘빌드 환경의 .webp 이미지 경로 참조’를
팀원이 신경쓰지 않고 편하게 사용할 수 있도록 추상화 한 컴포넌트인 것이다.


실제 사용 코드는 아래와 같다.

name에 src 디렉토리에 존재하는 이미지 파일 이름만 넣어주게 되면

내부에서 최적화를 진행하는 것이다.





❗Vite환경에서 경로 참조 주의사항


현재 모든 정적 에셋은 public폴더가 아닌 src 폴더에 두고 사용중이라고 했었다. 이것 때문에 발생하는 문제가 존재한다.


Vite에서 정적 에셋을 public 폴더에 두면 빌드 시 경로가 그대로 유지되지만,

/src 폴더에 두면 빌드 시 해당 파일을 assets 폴더로 이동시키고 해시값이 붙은 파일명으로 변환한다.

만약 grade.png 파일을 /public/src 디렉토리에 각각 두고 빌드하면 실제로 아래와 같은 결과가 나타난다.

이렇게 되는 이유는 /public 디렉토리는 Vite가 관여하지 않는 정적 파일 공간이지만, /src 디렉토리는 Vite의 최종 번들에 포함되고,
이 과정에서 /src 디렉토리에 들어있는 정적 에셋들에 대해 고유한 해싱값을 붙임으로써 파일명 기반 캐싱을 사용하는 것이기 때문이다.


어쨌든 이러한 특징 때문에 런타임에 <Image /> 컴포넌트 내부에서 단순히 파일 확장자만 .png → .webp 로 변경하게 되면 빌드 환경에서 이미지를 참조할 수 없는 문제가 생긴다.


이를 해결하기 위해 정적인 URL 맵을 만들어주는 별도의 imageMap을 생성했다

imageMap은 src/assets/images 폴더에 있는 .png, .webp 이미지를

파일명 기준으로 그룹화하여 { png, webp } 형태로 등록된 객체이다.

// imageMap
{
  hero: {
    png: '/assets/hero-[hash].png',
    webp: '/assets/hero-[hash].webp'
  },
  chart: {
    png: '/assets/chart-[hash].png'
  }
}

코드는 아래와 같다.

옵션의미
import.meta.glob()지정된 패턴의 파일들을 Vite가 정적으로 분석해서 import
eager: true동적 import가 아니라 즉시 실행(import) → 번들에 즉시 포함되어 정적으로 분석이 가능해진다
query: '?url'• Vite는 해당 이미지를 파일 자체가 아니라 해시가 포함된 최종 URL string으로 처리하고
자동으로 해시된 파일명을 만들어 주게 된다.
import: 'default'해당 URL은 default export


imageMap을 사용하는 이유는 단순한 경로가 아니라 Vite의 빌드 asset 경로와 해시값이 적용된 URL 형태로 사용할 수 있는 것이다.

즉, 매번 빌드마다 경로 및 해시가 달라지더라도 imageMap을 통해 사용하는 컴포넌트는 변경된 파일 경로를 항상 최신으로 참조하게 되는 구조가 되는 것이다.


이러한 내부 동작을 통해 사용자는 오직 외부에서 <Image /> 컴포넌트만 사용하기만 하면
내부에서 동적으로 빌드 타임의 경로에 있는 webp파일을 알아서 찾아서 적용할 수 있게 되는 것이다.






🧪  폰트 최적화


우리 팀은 영문의 경우 GeneralSans 폰트를 사용하고 한글은 Pretendard를 사용중이다.

둘 다 .woff2 파일을 /src 경로에 두고 해당 파일을 import해서 사용하는 형태이다.


처음에는 ".woff2 폰트를 적용했으니 LCP 최적화가 되겠지?" 라고 생각했는데 실제로 점수는 여전히 변동이 없었다.

아무리 이미지 최적화를 해 놓아도 LCP 점수가 특정 지점부터 개선이 안 됐었고, 도저히 그 이유를 몰라서 performance 탭을 수십번 넘게 돌려가며 원인을 찾았었다.


결국 알아낸 바로는, GeneralSans 폰트는 별 문제가 되지 않았지만 Pretendard가 문제였던 것이다.

더 제대로 표현하자면 한글 폰트 자체가 문제였던 것이다.

영어는 대소문자 포함 약 100자 내외 정도만 있으면 충분하지만

한글은 조합형 문자로 만들어 질 수 잇는 글자 수가 11,172자이다.

게다가 Pretendard는 한글 + 영문 + 숫자 + 기호 + 일본어 + 특수문자 등 다양한 범위를 모두 포함하며

가변 폰트일 경우, 1개의 글자에 대해 여러 축의 변형(weight 등)을 포함해야 하므로 정보량이 더 많게 된다.



당시 /src 디렉토리에 PretendardVariable.woff2 파일을 저장해두고 사용하고 있었지만
performance 탭으로 폰트 용량이 2.1MB나 되는 것을 확인할 수 있었다.

이 부분이 LCP 저하를 일으키는 결정적인 원인이라는 생각이 들었고, 이를 개선해보기로 했다.



1. 가변 다이나믹 서브셋 적용

Pretendard 깃허브를 보면 CDN에서 제공되는 Pretendard의 가변 다이나믹 서브셋을 제공해준다.

기존 2.1MBPretendardVariable.woff2 파일을 지우고 가변 다이나믹 서브셋을 적용하기로 했다.

실제로 여러 서브셋 폰트 파일이 병렬적으로 요청됨과 동시에 각 크기가 25KB 정도 밖에 되지 않는것을 확인할 수 있다.
(서브셋이 총 10개라고 가정해도 250KB인 것이다)



추가로 폰트를 늦게 로드할 때 기본 폰트를 렌더링하며 나타날 수 있는 깜빡임 현상을 제거하기 위해,

  • 폰트를 웹사이트에서 우선적으로 다운로드하도록 할 수 있는 preload 속성과
  • 폰트를 받을 CDN 도메인에 사전 연결을 할 수 있도록 하는 preconnect 속성도 같이 적용했다.







🏁 최종 결과

다행히 많은 부분에서 개선점을 가져갈 수 있었다.








번외) 생각보다 LCP 지표에 별 의미가 없었던 것들


1. 이미지 preload


폰트의 경우 preload가 유의미했지만

랜딩페이지의 LCP 이미지의 경우 preload를 하게 되면 LCP지표중 하나인 render delay에 과부하가 걸려서 오히려 더 낮은 점수가 나왔다.


도저히 원인을 모르겠어서 GPT한테 물어봤더니 다음과 같은 답변을 받게 되었다.


preload는 브라우저에게 특정 리소스를 우선적으로 다운로드하도록 지시하는 기능입니다. 

폰트와 같은 리소스에서는 이 방식이 효과적입니다. 폰트는 일반적으로 CSS에 의해 참조되기 때문에 브라우저가 CSS를 파싱한 후에야 필요성을 인식하게 됩니다. 

preload를 사용하면 이 과정을 앞당겨 폰트 로딩으로 인한 텍스트 렌더링 지연을 방지할 수 있습니다.

그러나 LCP(대형 콘텐츠 페인트) 이미지에 preload를 적용하면 의도와 다르게 성능이 저하될 수 있습니다. 그 이유는:

1. 리소스 우선순위 변경: Preload는 해당 리소스의 네트워크 우선순위를 최상위로 올립니다. 이로 인해 CSS나 JavaScript 같은 핵심 렌더링 경로(Critical Rendering Path) 리소스보다 이미지가 먼저 처리될 수 있습니다.
2. 렌더링 지연 발생: 브라우저는 preload된 이미지를 다운로드하지만, 실제 `<img>` 요소가 DOM에 삽입되고 렌더링 준비가 될 때까지 디코딩과 렌더링을 보류합니다. 그런데 이미지가 먼저 다운로드되면 메인 스레드가 이미지 디코딩을 기다리는 상태가 될 수 있습니다.
3. Render Delay 증가: 위 과정으로 인해 'Render Delay'(렌더링 지연)가 발생하여 결과적으로 LCP 성능 지표가 나빠질 수 있습니다. LCP는 페이지에서 가장 큰 콘텐츠 요소가 화면에 렌더링되는 시점을 측정하는데, 이 시점이 늦어지게 됩니다.


2. render block

가끔씩 performance 탭에 보이는 render block이 보이게 된다.

겉으로 보기엔 무조건적으로 이런 것들을 없애야 하나 하고 생각했는데, 모든 render block이 나쁜 것만은 아니다.

렌더 블로킹은 “의도적인 동기 처리”인 경우도 있다.

위 사진에 보이듯이 CSS는 기본적으로 render block 요소다. CRP에서 스타일이 적용되기 전엔 요소를 화면에 그리면 안 되기 때문이다.








아쉬운 점 및 앞으로의 개선 목표


1. 이미지 저장소 변경

Image 컴포넌트로 최적화를 진행하는 이미지들을 지금처럼 로컬 webp 이미지로 저장하는 것보다 S3 같은 별도의 저장소에 둬야 할 것 같다.

프로젝트에 사용되는 이미지가 많아질 수록 로컬에 계속 불필요한 이미지들이 쌓이기 때문이다.


2. 이미지 리사이징 로직을 CI에 위임

이미지를 webp로 리사이징 하는 과정을, 빌드 타임이 아니라 CI 파이프라인에 넣는 것이 더 바람직해 보인다.

빌드 타임에 점점 많은 스크립트가 가중될 수록 그만큼 빌드 타임에 부하가 걸릴 것이기 때문이다.



profile
Magnificent Tree.

1개의 댓글

comment-user-thumbnail
2025년 5월 13일

오호 최적화를 위해 많은 노력을 하셨네요~

답글 달기