<TimeMap.dmg> WEBP 이미지를 통해 웹 렌더링 시간을 개선해보자
들어가며
나는 포트폴리오 웹사이트를 만들었다. 처음엔 그저 언젠간 나만의 페이지를 만들어야지 싶었는데, 그러지 못했다.
왜냐하면 처음부터 너무나도 완벽한 것을 바랬으니, 디자인이 마음에 들지 않으면 제작하지 않기를 반복하였다.
그렇게 시간이 흘러 웹 개발을 시작한 지 1년이 다 되어갈 무렵, 클린코드에 대한 컨퍼런스를 다녀오며 프로젝트와 코드를 대하는 마음가짐을 어떻게 설정해야 할 지에 대한 생각의 변화가 생겼다.
바로 이 글을 통해 생각이 변했다.
우선 만들어두고, 계속해서 발전시켜나가야겠다. 언제곤 머릿속에서만 아이디어로 남겨둘 수는 없다.
그렇게 생각하는 순간 바로 이 계획을 실행하기로 했다. 간단하게 초안을 만들고, 계속해서 업데이트 해나가는 방식으로
코드를 성장시키기로 했다. 그렇게 만들어진 사이트의 첫 번째 데모영상은 아래에서 볼 수 있다.
꽤 만족스러운 사이트긴 했지만, 그럼에도 불편한 부분들이 분명 존재했고, 그들 중 일부 문제를 해결하기 위한 과정을 이 글에서 깊게 다루기로 했다.
나는 무엇을 개선해볼 것인가?
- 나 자신을 소개하는 whoami 페이지
- 18개의 png 형식의 무손실 압축형 사진파일들 -> 개선의 여지가 있음
현재 시점의 포트폴리오는 약 5~6개의 페이지로 이루어져 있다. 그리고 여기서 가장 크게 느끼고 있는 불편한 점은 아래와 같았다.
- (첫 방문자 기준) 사진이 많이 들어가는 페이지는 처음 로드될 때 1초를 넘게 기다려야 한다.
- 이후 로드 속도는 NextJS와 Vercel의 캐싱 지원으로 괜찮은 편이지만, 첫 방문자가 많은 이 사이트의 특성상 치명적으로 작용할 수 있다.
그리고 사진이 로드되기 전까지 UI 사이즈가 변경되며 약간 정신없어 보이기도 했다.
그렇기에 lighthouse와 네트워크 탭을 통해 지연시간이 어느정도인지 확인해보았다.
lighthouse를 통한 페이지 성능 측정
- Desktop 기준 Navigation 측정
제작된 지 얼마되지 않은 사이트기에 치명적으로 무언가 크게 잘못 짜둔 코드는 없는 것 같았다.
물론 모든 것이 완벽하다고 나와있긴 하지만 직접 눈에 보이는 문제가 있지 않은가.
바로 초기 로드 속도였다. 그렇게 간단한 페이지가 1000ms 전후로 지연 로드되는 현상이 발생하고 있다.
문제상황: 높은 TTFB(Time to First Byte)
- 캐시 비활성상태로 측정했을 때 첫 로드시간이 굉장히 오래 걸리는 상황
- 아무래도 이미지가 많고, "무손실 압축형 사진파일"인 .png로 구성되어있어서 그런 듯 하다
이러한 오류메세지는 초기 로드가 완료되고 다시 측정했을 때 발생하지 않지만, 사용자의 초기 방문 시의 경험이 중요한 포트폴리오 페이지 특성 상 경험에 부정적인 효과를 줄 수 있다고 보았다.
또한, 네트워크 탭에서도 초기 로드마다 view부분을 띄우는 데 많은 시간을 소요하는 것을 볼 수 있기에 더욱이 이미지 파일 최적화를 해보아야겠다고 생각했다. 이 부분에서만 0.7초 정도면 전체 로드 시 걸리는 시간은 더욱 많이 소요된다는 의미이기도 하다.
정확하지는 않겠지만 대략 30%정도의 방문자들이 지연시간으로 인해 이탈하지 않았을까 싶다.
현재 프론트엔드만으로 구성되어있는 프로젝트이긴 하지만 Next.js를 사용하였고, Route Handlers를 통해 이미지들을 불러오고 있기에 서버쪽에서 지연이 걸리고 있는것으로 나타난 듯 하다.
가설: 이미지 확장자 변경해서 확인해보기
나에게는 두 가지 선택지가 있었다.
- Skeleton UI를 적용시켜 UX 개선하기
- 사진 파일 크기를 줄여 로드 시간 줄이기
둘 중 더 빠른 방법으로는 이미지 확장자를 바꿔주는 것이었다.
- 기존 ".png" 파일들을 ".webp"로 변경해서 성능개선을 시도하려고 했다.PNG, WEBP의 차이점은 무엇일까?
- PNG (Portable Network Graphics)
- 손실 없는 압축. 원본 이미지의 품질을 그대로 유지하면서 파일 크기를 줄인다.
- 투명도 지원, 이미지의 특정 부분을 완전히 투명하게 만들 수 있다.
- 주로 로고, 텍스트, 아이콘 같은 고해상도 이미지에 적합하다.
- WEBP
- 구글이 개발한 오픈소스 이미지 포맷으로, 손실 압축과 손실 없는 압축 모두 지원한다.
- PNG보다 더 효율적인 압축을 제공하여, 비슷한 품질의 이미지를 더 작은 파일 크기로 만들 수 있.
- 또한 투명도를 지원하며, 애니메이션 이미지 저장도 가능하다.
- 웹에서 다양한 유형의 이미지를 효율적으로 사용하고자 할 때 적합하다.
사이즈 비교해보기
이미지 변환 툴은 Figma의 커뮤니티 플러그인인 "WebP Exporter"를 사용했다.
- PNG
- WebP
- 품질 비교(250 X 100)
- 현재 웹상에서 띄워지는 크기의 이미지를 기준으로 확인해보았다.
- 웹에서는 기재되어있는 크기보다 약간 더 크거나 약간 더 작은 사이즈로 사용된다.
- 품질 비교(2500 X 1000)
- 사이즈를 늘렸을 때의 차이를 비교하기 위해 크기를 10배 더 늘려서 비교해 보았다.
- 사이즈를 늘렸을 때의 차이를 비교하기 위해 크기를 10배 더 늘려서 비교해 보았다.
- 정리
- webp 파일의 용량은 png에 비해 "33%" 더 작다.
- webp 파일이 아주 약간 흐리게 보였지만 웹 상에서는 크게 차이나는 정도는 아니다.
이런 차이점이 있어 WebP를 적용해보고자 하였고, 웹 상에서는 이미지가 어떻게 렌더링되는 것인지도 궁금해서 알아보기로 했다.
브라우저의 렌더링을 통해 알아보는 이미지 렌더링
웹 브라우저에서 화면을 렌더링하는 과정을 통해 알아보도록 하겠다.
위 사진은 웹상에서 이미지가 렌더링 되는 일련의 과정이다. 좌측부터 추가적인 설명을 붙여가며 알아보겠다.
1. HTML 파싱
- 가장 먼저, 브라우저는 HTML 문서를 읽고 파싱하여 DOM (Document Object Model) 트리를 구성한다.
- 이 과정 중에 이미지 태그(
<img>
)를 만나면, 이미지의 다운로드를 시작한다.
여기서 Next.js를 사용하는 내 프로젝트의 경우에는 <Image>
컴포넌트를 만나면서 다음과 같은 추가적인 기능들이 적용된다.
- 자동 최적화
- Next.js의
Image
컴포넌트는 자동으로 이미지를 최적화하여 로드 시간을 단축한다. - 서버 측에서 이미지를 적절한 크기로 리사이즈하고, 지원 가능한 브라우저의 경우 WebP와 같은 포맷으로 자동 변환한다.
- <<<1. 그런데... Next.js의
<Image>
태그에서 WebP로 자동 변환해준다고 하지 않았었나?>>> - <<<글의 마지막 부분 "궁금증 해소하기" 부근에서 추가로 다루도록 하겠다.>>>
- <<<1. 그런데... Next.js의
- Next.js의
- 지연 로딩 (Lazy Loading)
Image
컴포넌트는 뷰포트에 들어오기 전까지 이미지 로딩을 지연시키는 기능을 내장하고 있다.- 사용자가 스크롤하여 이미지가 화면에 나타날 때까지 로딩을 연기함으로써 초기 페이지 로드 시간을 단축할 수 있다.
- 그렇기에
<Image>
컴포넌트를 사용하면 굳이 레이지 로딩을 적용시킬 필요가 없다. 하지만 알아두면 좋으니 나중에 React에서 연습해볼 예정이다.
- 그렇기에
- 자리 표시자 (Placeholder)
- 이미지가 로딩되는 동안 표시될 자리 표시자를 설정할 수 있다.
- 이는 사용자에게 이미지가 여전히 로딩 중임을 알리고, 페이지의 레이아웃이 이미지 로딩으로 인해 변경되지 않도록 합니다.
- <<<2. 그런데 왜 내 프로젝트에서는 이미지 로드 중에 자리 표시자가 뜨지 않고 레이아웃이 변경되었을까?>>>
- <<<글의 마지막 부분 "궁금증 해소하기" 부근에서 추가로 다루도록 하겠다.>>>
- 서버와의 상호작용
Image
컴포넌트는 Next.js 서버와 상호작용하여 요청된 이미지를 최적화한다.- 이미지 요청이 들어오면, Next.js의 이미지 최적화 API가 해당 이미지를 처리하고 캐시한다.
- 이후 동일한 이미지에 대한 요청은 캐시된 버전을 사용하여 빠르게 제공할 수 있다.
- 따라서 따로 캐싱 처리를 하지 않아도 된다는 소리이기도 한다.
많은 최적화 기능들이 Next.js의 <Image>
컴포넌트를 통해 이루어진다.
그리고 "4. 이미지 로딩"에서 최적화가 이루어진다.
2. CSS 파싱
- 1번과 동시에, CSS 파일 또는
<style>
태그 내부의 스타일 규칙들이 파싱되어 스타일 규칙이 메모리에 저장된다.
3. DOM, 스타일의 결합
- HTML과 CSS 파싱이 끝나면, DOM 트리에 스타일 규칙을 적용한다.
- 그리고 각 요소에 적용되는 스타일이 계산되어 렌더 트리가 구성된다.
4. 이미지 로딩
- 이미지 파일은 서버에서 비동기적으로 다운로드되며, 이 과정은 HTML/CSS 파싱과 병렬로 진행될 수 있다.
- 이미지가 완전히 로드되면, 이미지의 실제 크기와 메타데이터가 파악된다.
5. 렌더 트리 구성
- DOM 트리와 스타일 규칙이 결합되어 렌더 트리가 만들어진다.
- 이 트리는 실제로 화면에 렌더링될 요소들과 그 스타일을 포함합니다.
6. 리플로우(Reflow)
- 이미지 로딩이 완료되고, 그 크기가 결정되면 페이지 레이아웃에 영향을 줄 수 있다.
- 브라우저는 이미지가 차지할 공간을 계산하여 레이아웃을 조정하는데, 이 과정을 리플로우라고 하며, 요소의 크기나 위치가 변경되면 발생다.
7. 페인팅(Painting)
- 렌더 트리에 기반하여 각 요소가 화면에 그려진다.
8. 디스플레이(Display)
- 모든 페인팅 작업이 완료되면 최종적으로 사용자의 화면에 내용이 표시됩니다.
이 과정을 거치고, 처음 브라우저 렌더링을 살짝 수정해본다면?
아래와 같이 변경된 플로우를 볼 수 있다. 이미지 로딩은 비동기적으로 이루어진다.
그리고 이 모든것을 하나의 이야기로 만들어서 이해해보자면?
브라우저 렌더링을 하나의 큰 연회를 준비하는 것으로 이해할 수 있다.
"나" 자신을 웹브라우저라고 생각해보자. 연회장에서 나에게 연회를 위한 준비를 의뢰했다.
- HTML 파싱
- HTML을 파싱하는 것은 연회장에서 보내온 초대장을 열어보는 것과 같다.
- 초대장에는 연회의 모든 이벤트와 참가자들이 어떤 순서로 등장하는지에 대해 적혀 있다.
- 나(웹 브라우저)는 초대장을 읽으며, 무엇이 필요한지 리스트업 한다.
- CSS 파싱
- 연회장을 꾸미기 위한 장식 계획을 세우는 단계이다.
- 장식품들이 어디에 어떻게 배치될지, 색상과 테마는 무엇인지를 결정한다.
- DOM과 스타일 규칙의 결합
- 초대장의 정보와 장식 계획이 합쳐져서, 연회장이 어떻게 꾸며질지의 상세한 인테리어(레이아웃)가 나온다.
- 각 참가자가 어디에 앉을지, 무대는 어떻게 설치될지 등이 결정다.
- 이미지 로딩
- 연회장의 큰 그림이 걸릴 예정이다.
- 이 그림은 연회장 밖의 스튜디오에서 가져와야 하며, 도착하는 대로 벽에 걸릴 것입니다.
<Image>
컴포넌트는 종이에 그려진 그림에 대신 디스플레이를 활용해 벽면을 꾸며주며, 참석자들의 시선이 향해있을 떄만 그림을 띄우도록 하는 등 첨단 기술들을 사용한다.
- 렌더 트리 구성
- 모든 계획이 확정되면, 실제로 연회장을 꾸미기 시작한다.
- 장식품들이 정해진 자리에 배치되고, 의자가 줄지어 세워집니다.
- 리플로우(Reflow)
- 거대한 그림이 도착하고 벽에 걸리면, 그림의 크기와 위치에 따라 주변의 장식품들을 조금씩 조정해야 할 수도 있다.
- 이는 연회장의 전체적인 모습을 가장 좋게 만들기 위해 일어나는 일이다.
- 페인팅(Painting)
- 이제 모든 장식이 완료되고, 연회장의 모든 모습이 완성된다.
- 마치 화가가 마지막 붓터치를 하는 것처럼, 모든 것이 제 자리를 찾는다.
- 디스플레이(Display)
- 마침내, 연회장의 문이 열리고 손님들이 들어오기 시작한다.
- 그들은 꾸며진 연회장을 보며 감탄하고, 연회는 화려하게 시작된다.
검증: 이미지를 모두 변환해서 적용해보기
나는 지금부터 이 파일들을 더 작은 크기의 webp 파일로 바꿔서 다시 적용시켜볼 예정이다.
No. | PNG File | Size (KB) | WEBP File | Size (KB) | Size Change |
---|---|---|---|---|---|
1 | D3.png | 13 | D3.webp | 9 | -4 |
2 | Emotion.png | 15 | Emotion.webp | 8 | -7 |
3 | Figma.png | 6 | Figma.webp | 5 | -1 |
4 | Highcharts.png | 9 | Highcharts.webp | 6 | -3 |
5 | JavaScript.png | 11 | JavaScript.webp | 7 | -4 |
6 | Jira.png | 5 | Jira.webp | 3 | -2 |
7 | NextJS.png | 8 | NextJS.webp | 7 | -1 |
8 | Notion.png | 9 | Notion.webp | 7 | -2 |
9 | Npm.png | 0.98 | Npm.webp | 2 | +1.02 |
10 | React.png | 14 | React.webp | 10 | -4 |
11 | Sass.png | 7 | Sass.webp | 5 | -2 |
12 | Slack.png | 12 | Slack.webp | 8 | -4 |
13 | Svelte.png | 7 | Svelte.webp | 5 | -2 |
14 | SvelteKit.png | 11 | SvelteKit.webp | 7 | -4 |
15 | Tailwind.png | 6 | Tailwind.webp | 4 | -2 |
16 | Three.png | 8 | Three.webp | 6 | -2 |
17 | TypeScript.png | 12 | TypeScript.webp | 8 | -4 |
18 | Yarn.png | 7 | Yarn.webp | 5 | -2 |
- Total Size of PNG Images: 160.98 KB
- Total Size of WEBP Images: 112 KB
- Total Size Reduction: 48.98 KB
- Percentage Total Reduction: Approximately 30.43%
처음 예상대로 대부분의 파일은 사이즈가 감소된 것으로 보이지만 하나의 파일에서만 사이즈가 반대로 늘어난 것을 볼 수 있었다.
결과에 따라서 초기 렌더링 속도가 30% 이상 단축될 것으로 보인다.
적용
- 단순히 이미지만을 교체하여 결과를 확인하였다.
- 적용된 페이지에서 이미지가 깨진다던가 하는 문제는 보이지 않고, 이전과 같은 결과를 보이고 있다.
결론: 성능측정(좌측(이전), 우측(이후))
- 723.08ms -> 472.35ms
- 약 34% 단축
모두 캐시 미적용 옵션으로 측정하였으며, 약 34%의 딜레이 감소가 이루어져 예상했던 결과보다 더 높은 성능 개선을 이루어냈다.
실제로도 체감될 정도로 빠르게 로드되었기에 사용자 경험적인 측면에서도 더욱 좋은 결과를 내었다.
이전에는 캐싱이 되어있었음에도 불구하고 로드되는 시간이 체감되었다면, 이제는 즉시 로딩된다는 느낌을 주고 있다.
이 시도로 성능 개선을 성공적으로 마쳤으니, 다른 파일 또한 webp로 변경하여 전체적인 성능 개선을 이루어낼 예정이다.
작성하면서 추가로 남아있던 궁금증 해소하기
앞서 내용들을 적으면서 무언가 분명히 궁금했던 내용들이 있었다. 그리고 여기에서 알아보는 내용들을 결론까지 모두 보고 알아보면 더 좋을 것 같은 내용이므로 여기서 마무리하기 전에 정리해서 알아보도록 하겠다.
1. 그런데... Next.js의 <Image>
태그에서 WebP로 자동 변환해준다고 하지 않았었나?
그래서 궁금해서 프로젝트에 설치되어있는 Next.js의 코드 중 <Image>
컴포넌트 코드를 뜯어보기로 했다.
들어간 경로는 다음과 같다.
nex/image로 이동, 누르면 image.d.ts 파일로 진입
./dist/shared/lib/image-external로 이동, 누를 경우 image-external.d.ts 파일로 진입
./image-config로 이동, 누를 경우 image-config.d.ts 파일로 이동
- 여기서 "declare"이라는 키워드가 있는데, Typescript로 개발하던 중에 아주 잘 작성된 JS 모듈을 가져와서 쓰고자 한다면, 해당 모듈 내의 함수는 typescript로 정의되어있지 않기 때문에 컴파일 과정에서 오류가 발생한다.
- 모듈이 해당 속성을 가지고 있음에도 type이 정해져있지 않으면 해당 모듈을 가지고 있지 않다고 해석하는 현상이 발생한다.
- declare 키워드를 써서 정의해두면 type이 인식되어 typescript compiler가 해석할 수 있게 된다.
- 간단히 말해서 컴파일러에게 "이건 이미 존재하고 다른 코드에서 참조할 수 있다."라고 설명하는 것이다.
그렇게 3개의 파일을 거쳐가면 webp를 다루고 있는 코드를 볼 수 있다.
여기서 이미 avif 또는 webp로 제한되고 있는 파일의 형식을 볼 수 있으며, 다른 파일의 경우 해당 형식으로 변환해서 출력하는 듯 하다.
하지만 변환 과정 자체가 서버 리소스를 사용하는 일이므로 <기존 이미지 다운로드 + 이미지 최적화(변환)> 시간의 합이 적용되어 더 오래 걸린 것으로 보고 있다. 따라서 처음부터<Image>
컴포넌트에서 지정해둔 format인 webp 파일이 다운로드되고 이미지 변환 과정을 스킵하면서 더욱 빠른 로드 속도를 낼 수 있는 것 같다. 물론 파일 크기가 줄어든 것도 많은 영향을 미친 것으로 보인다.2. 그런데 왜 내 프로젝트에서는 이미지 로드 중에 자리 표시자가 뜨지 않고 레이아웃이 변경되었을까?이미지 로드 중에 자리 표시자가 뜨고 있지는 않는 이유는 아마 프로젝트의 로드 방식으로 인한 것이라고 생각된다.
작성되어있는 코드를 보며 그 이유를 살펴보겠다.
<TechStack>
<div className='flex flex-col gap-2 md:gap-8 md:flex-row'>
{techStackData.map((column, columnIndex) => (
<div key={columnIndex} className='flex flex-col gap-2 md:gap-4'>
{column.map((stack, stackIndex) => (
<StackInfo key={stackIndex} category={stack.category}>
{stack.items.map((item, itemIndex) => (
<TechStackItem key={itemIndex} imgSrc={item.imgSrc} />
))}
</StackInfo>
))}
</div>
))}
</div>
</TechStack>
<TechStack>
라는 컨테이너 역할을 하는 컴포넌트 안에 자식 컴포넌트를 렌더링 하는 방식으로 데이터가 불러와지고 있다.
문제는 여기서 2중 반복문을 통해 하나씩 호출이 되는데, 내부에서 <TechStackItem>
컴포넌트에 이미지가 들어가있기에 다음과 같이 렌더링된다.
<TechStack>
<StackInfo>
<TechStackItem>
<TechStackItem>
<TechStackItem>
- ...
<StackInfo>
<TechStackItem>
<TechStackItem>
<TechStackItem>
- ...
<StackInfo>
<TechStackItem>
<TechStackItem>
<TechStackItem>
- ...
그렇기에 이미지 하나하나가 로딩이 늦어질 수록 이후의 컴포넌트들 또한 로딩이 늦어지기에 이 부분에 대해서는 개선이 되면 좋을 것 같다고 본다.
어쩌면 HTML 코드의 길이가 마냥 짧다고 좋은 것은 아닌 것 같다고 느꼈다. 나중에 구조를 조정하여 개선해봐야겠다.
많은 것들을 배우고 얻어갈 수 있는 시간이었다. 글을 마치겠다.
End