웹 성능 최적화 #3 클라이언트

Explanation

웹 성능 최적화 세 번째 시간!
오늘은 웹 서비스의 클라이언트 영역에서의 성능 최적화에 대해서 알아볼게요!

이전 글에서 다루었던 네트워크와 관련된 부분이나 Tree Shaking, Code Splitting과 같은 내용을 제외하였습니다. 해당 부분은 이전의 글을 확인해주세요!
링크: https://falsy.me/웹-성능-최적화-1-네트워크/
링크: https://falsy.me/웹-성능-최적화-2-모듈-번들러/

글 끝에 잠깐 나오는 “택배 배송 조회” 프로젝트는 아래의 리포지토리에서 모든 코드를 확인할 수 있습니다.
링크: https://github.com/falsy/delivery-tracker-for-whale

1. 이미지

웹 서비스를 개발하다보면 많은 이미지를 사용하게 되는데요.
첫 번째로 이미지와 관련해서 서비스의 성능을 최적화 할 수 있는 방법들에 대해서 알아볼게요.

1-1. 포맷

일반적으로 불투명한 이미지에서는 JPEG 포맷이나 투명도가 필요한 이미지는 PNG 포맷을 많이 사용하는데요. 아무래도 안정적으로 대부분의 플랫폼에서 지원하기 때문이 가장 큰 이유일 거 같아요. 그런데 이제는 WebP 포맷도 대부분의 최신 브라우저에서 지원하고 AVIF 포맷도 하나 둘 지원을 시작하였기 때문에 html의 picture 태그와 함께 사용하면 더 효과적으로 이미지를 제공할 수 있습니다.

간단하게, WebP 포맷은 PNG 처럼 투명도를 지원하면서 JPEG 보다 최대 30% 까지도 저 작은 크기로 유사한 화질을 제공하며, 대부분의 주요 브라우저에서 지원합니다. 그리고 AVIF는 아직 모든 주요 브라우저에서 모든 기능을 지원하지는 않지만 2019년에 공개된 최신 포맷으로 WebP 보다도 높은 압축 효율을 제공합니다.

1-2. 반응형

웹 서비스가 다양한 브라우저의 크기나 디바이스의 해상도에 대응해야 한다면, 앞서 이야기한 picture 태그를 사용해서 반응형 이미지로 설계할 수 있습니다.

예를 들어 해상도에 따라서 1.5x와 1x(기본값)에 따라 다른 이미지를 로드할 수 있습니다.

그리고 아래와 같이 브라우저의 크기에 따라 다른 이미지를 로드할 수도 있습니다.

1-3. 사전 로드와 지연 로드

예를 들어 웹 페이지에서 첫 화면에 노출되는 이미지의 경우에는 preload를 설정하여 빠르게 로드되어 출력되도록 하고 첫 화면 밖에 위치한 이미지의 경우에는 지연 로드하도록 해서 페이지 로드 성능을 높일 수 있습니다.

“/above.png” 라는 경로의 이미지가 첫 화면에 노출되는 이미지라면,

위와 같이 link 태그의 preload 속성을 사용하여 가능한 빨리 다운로드하도록 지시할 수 있습니다.
그리고 “/outside.png” 라는 첫 화면 밖의 이미지가 있다면,

위와 같이 img 태그에 loading 어트리뷰트에 lazy 값을 주어서 이미지가 브라우저 화면에 가까워지거나 보일 때 로드하도록 지연할 수 있습니다.

지연 로드는 초기 페이지 로드 성능에 도움이 될 수 있지만, 시각적으로 사용자 경험에 좋지 않을 수도 있기 때문에 충분한 테스트를 거치고 적용하는 것이 좋을 것 같습니다.

1-4. CLS(Cumulative Layout Shift)

CLS는 동적으로 추가된 컨텐츠에 의해서 레이아웃이 뒤로 밀리는 현상을 이야기 하는데요. 꼭 이미지가 로드되면서 레이아웃이 밀리는 현상만을 이야기 하는 것은 아니지만, 일반적으로 이미지에서 발생하는 경우가 많아서 이미지 섹션에 추가해보았습니다.

https://web.dev/articles/cls의 첫 영상이 가장 직관적으로 이해하기 쉬운 예시인 거 같아요.

가장 보편적으로 사용되는 방법은 아래와 같이 이미지의 너비와 높이를 명시적으로 추가해서 로드 전에도 미리 공간을 예약하도록 하는 방법입니다.

만약, 브라우저의 크기에 따라 이미지의 사이즈가 변경되어야 할 때도

위와 같이 이미지의 width와 height 값이 설정되어 있다면, 브라우저는 해당 속성에 비율을 계산하고 이미지가 로드되기 전에 미리 공간을 예약합니다.

만약(2), 직접적으로 이미지의 width와 height 값을 설정할 수 없다면, 아래와 같이 이미지의 비율을 가지고 공간을 미리 예약할 수 있습니다. (이미지의 비율이 3:1일 때)

만약(3), CSS의 aspect-ratio 속성을 지원하지 않은 오래된 브라우저까지 지원해야 한다면, 아래와 같이 padding을 사용해서 미리 공간을 예약하는 방법도 있습니다.

1-5. 아이콘

예전에는 아이콘과 같은 작은 이미지들이 많은 네트워크 요청을 하게 되는 문제 때문에, 하나의 이미지 파일로 만들어서 사용하는 “Image Sprite” 기법을 사용하기도 했었는데요. 하지만 이제 HTTP/2와 HTTP/3가 많이 사용되고 HTTP/2 버전 부터는 멀티플렉싱을 지원하여 여러 리소스를 병렬로 요청할 수 있어서 여러 요청에도 성능 저하가 적습니다.

그리고 처음에 당장 많이 사용되지 않는 이미지의 정보까지 모두 가져와야 하고 또, 이미지 변경에 있어서 유지 보수의 어려움도 있기 때문에 요즘은 많이 사용되지 않고 있습니다.

그리고, 작은 아이콘의 경우에는 base64로 변환해서 사용하면 추가의 네트워크 요청 없이 간단하게 인라인에 포함하여 사용하는 방법도 고려해 볼 수 있습니다.

2. 웹 폰트

서비스의 톤앤매너나 사용자 경험을 위해 웹폰트도 많이 사용하곤 하는데요, 웹 폰트가 로드될 때까지 브라우저가 텍스트를 숨긴 상태로 유지하는 FOIT(Flash of Invisible Text)문제와 브라우저가 기본 시스템 폰트를 우선 적용했다가, 이후 웹 폰트가 로드되면 웹 폰트로 폰트가 전환되는 FOUT(Flash of Unstyled Text)문제가 있습니다.

일반적으로는 사용자가 정보를 바로 확인할 수 없는 FOIT를 더 치명적인 문제로 간주합니다.

문제의 현상으로 소개했지만, 웹 폰트를 사용한다면 어느 정도 불가피한 현상인데요.
몇 가지 방법으로 위 현상을 최소화 할 수 있습니다.

2-1. 사전 로드(preload)

우선, 첫 번째로 이미지에서 첫화면에 보이는 이미지를 사전 로드 했던 것처럼 웹 폰트도 브라우저가 미리 로드하도록 하는 방법입니다. 아래와 같이 head에서 link를 통해 미리 로드하면 FOIT, FOUT 현상을 줄일 수 있습니다.

2-2. swap

다음으로 폰트 스타일 속성 font-display을 “swap”으로 설정하여 기본 폰트로 즉시 렌더링하고 웹 폰트 로드 후 교체되도록 설정할 수 있습니다. (FOIT 방지)

그 밖의 font-display의 값
fallback: 웹 폰트 로드 시간이 길면 기본 폰트를 유지합니다.
optional: 웹 폰트 로드가 느리거나 실패하면 기본 폰트를 사용합니다.

구글 웹 폰트를 사용한다면, 쿼리 스트링 파라미터로 display=swap 속성을 추가하여 설정할 수 있습니다.

2-3. CLS(Cumulative Layout Shift)

swap 속성을 사용하면 FOIT를 방지할 수는 있지만, 폰트가 교체되는 과정에서 이미지 섹션에서 이야기 했던 것처럼 CLS 현상이 발생할 수 있습니다. 가장 간단한 방법은 line-height을 폰트의 크기보다 넉넉하게 큰 값으로 설정한다면 폰트가 변경되는 과정에서 텍스트가 가지는 영역이 변하지 않기 때문에 CLS 현상을 방지할 수 있습니다.

하지만 line-height 설정이 어려워서 line-height가 텍스트의 크기에 의해 동적으로 적용되는 상황이라면, 웹 폰트와 유사한 크기의 시스템 폰트를 사용하여 CLS 현상을 줄일 수 있습니다. 예를 들어, 웹 폰트가 시스템 폰트인 Arial와 유사하다면 아래와 같이 설정하여 CLS 현상을 줄일 수 있습니다.

그리고 위와 같은 설정으로 웹 폰트와 시스템 폰트와 크기 차이가 크다면, font-size-adjust 속성을 사용하여 추가로 조정할 수 있습니다.

font-size-adjust은 시스템 폰트에 “x-height” 비율을 설정해주는 설정인데요. 최신 버전의 주요 브라우저들은 모두 지원을 하지만, 비교적 최근에 지원하기 시작한 브라우저도 많기 때문에 범용적으로 적용되지는 않습니다.
– Chrome 127(24-07-23)부터 지원합니다.
– Edge 127(24-07-25)부터 지원합니다.

x-height: 영어 소문자 “x”의 높이와 전체 폰트의 높이의 비율을 이야기합니다. 예를 들어서 어떤 시스템 폰트의 소문자 “x”가 전체 높이의 절반의 높이를 가지고 있다면 x-height 값은 0.5입니다.
실제 시스템 폰트를 예로 들면, “Verdana” 폰트의 x-height 값은 “0.545”라고 하네요.

웹 폰트의 x-height와 유사하게 font-size-adjust를 설정해주면, line-height 값이 설정되지 않는 상황에서도 CLS 현상을 줄일 수 있습니다.

혹시 헷갈릴 수도 있으니..
“font-size-adjust” 속성은 시스템 폰트에만 적용되는 속성이라 웹 폰트에는 적용되지 않기 때문에, 이 속성을 사용해서 웹 폰트와 시스템 폰트의 높이 차이를 줄이는 것 입니다.

3. CSS 프레임워크

이전에 styled-components나 emotion 같은 CSS-in-JS 방식의 스타일 라이브러리를 사용하고 주로 emotion의 css 속성을 사용했었는데요. 물론 여전히 styled-components나 emotion는 간편하고 유연하게 사용할 수 있어서 좋지만, 아무래도 런타임에서 스타일이 생성되고 동적으로 DOM에 삽입되기 때문에 성능적으로는 아쉬움이 있습니다.

그래서 최근에는 Tailwind CSS와 Panda CSS 같은 정적 CSS 프레임워크를 사용하는 것을 더 선호하는데요. 이 같은 정적 CSS 프레임워크는 빌드 단계에서 CSS를 생성하기 때문에 런타임에서 스타일 생성 비용이 없고 또 전역에서 스타일을 관리하기에 CSS 코드의 중복이 줄어 들고 그만큼 파일의 크기도 작아지는 이점이 있습니다.

정말 유행이 돌고 돌듯이.. 저는 개인적으로 Panda CSS를 만족하며 사용하고 있지만, CSS 프레임워크를 사용하지 않고 그냥 SCSS와 같은 전처리기만 사용하는 것도 좋은 선택지가 될 수 있을 거 같아요.

2025.01.10 추가사항

최근에 Tailwind CSS를 간단한 프로젝트에 사용해 봤는데요. 개인적으로 진행하는, 간단한 프로젝트라면 아직은 익숙한 CSS-in-JS 형태의 Panda CSS를 사용할 것 같지만, 만약 회사나 팀 단위 프로젝트라면 Tailwind CSS를 사용할 것 같아요.
처음 사용해 봐서 익숙하지도 않았는데, 익숙해지면 Tailwind CSS가 정말 편하게 느껴질 거 같아요.
(역시.. 많은 사람들이 이야기하고 사용하는 데는 그만한 이유가 있는 거 같아요.)

4. JavaScript

JavaScript의 최적화는 깊게 이야기하면 분량이 굉장히 많아질 것 같아서 여기에서는 간단한 몇 가지에 대해서만 적어보려 합니다.

4-1. 지연 로드

브라우저는 기본적으로 script 태그를 만나면 렌더링을 차단하고(blocking) 스크립트를 다운로드하고 실행하고 완료한 후 이어서 HTML 파싱을 재개하는데요. 스크립트 요소가 렌더링을 차단하지 않도록 script에 async나 defer 속성을 사용하면 초기 로드 성능을 개선할 수 있습니다.

– async: 비동기로 다운로드하며 다운로드가 완료되면 HTML 파싱을 멈추고 바로 실행합니다.
(async는 스크립트가 아주 작거나, 캐싱 처리되어 있거나 또는 HTML 문서가 아주 긴 경우에 HTML 파싱을 멈추고 실행될 수 있습니다.)
– defer: 비동기로 다운로드하며 HTML 파싱이 완료된 후 “순차적”으로 실행합니다.
(defer의 경우는 주로 DOM 전체가 필요하거나 스크립트 실행 순서가 중요한 경우 사용합니다.)

4-2. 웹 워커

만약 웹 서비스에서 무거운 연산을 처리해야 한다면, 메인 스레드가 아닌 Web Worker를 사용해서 비동기로 작업을 처리하면 연산을 수행하는 동안 UI가 원활하게 동작하도록 할 수 있습니다.

아래는 간단하게 만들어본 Vite + React 환경의 예시 코드입니다.

위와 같이, 간단하게 Web Worker를 사용하여 메인 스레드와 상관없이(UI가 중단되지 않고) 오랜 시간이 걸리는 연산을 비동기로 처리할 수 있습니다.

5. 캐싱

해당 내용은 React를 기반으로 작성되었습니다.

클라이언트에서도 캐싱을 통해서 서비스가 보다 효과적으로 동작하도록 설정할 수 있는데요.

5-1. 로컬 스토리지

예를 들면, “택배 배송 조회” 프로젝트는 처음 서비스에 진입하면 네트워크 통신을 통해 택배사 목록(Carriers)에 해당하는 데이터를 받아오고 이 데이터를 포함하여 기존 사용자가 등록한 조회 목록(Trakers)을 조회해서 출력하는데요.

여기에서 Carriers 데이터는 잘 변하지 않는 데이터여서, 처음 한번 받아 온 데이터는 “로컬 스토리지”에 저장해 두었다가, 이후에 다시 진입했을 때에는 useLayoutEffect 시점에서 동기적으로 로컬 스토리지에 캐시 데이터를 “택배사 리스트 상태” 값의 기본 값으로 사용하고 이 값을 가지고 Tracker 리스트를 요청한 후 응답값으로 화면을 그리도록 하였습니다.

그리고 Carriers 값은 변할 수도 있는 데이터이기 때문에 useEffect 시점에서 서버로 Carriers 데이터를 요청하고 응답 값을 “택배사 리스트 상태” 값과 캐시 데이터를 갱신합니다. 그리고 Carriers 값의 변화가 있었다면, 역시 useEffect의 디펜던시를 통해서 Trackers 값도 갱신하여 다시 화면에 그리도록 하였습니다.

Cache-Control을 설정하여 브라우저를 통해 캐시할 수도 있지만, Carriers 값이 변할 수 도 있는 데이터이기 때문에 no-cache 설정으로 요청을 해야하며, 캐시 데이터가 변하지 않았을 때 304 응답으로 상대적으로 빠르게 응답을 받을 수 있겠지만, 결국은 네트워크 통신에 대한 레이턴시와 비동기로 수행해야 하기 때문에 useLayoutEffect 보다 느린 useEffect 시점에서 시작되기 때문에 더 빠른 사용자 경험을 위해서 로컬 스토리지를 활용하였습니다.

이렇게 Carriers에 대한 캐시 데이터가 있고, Carriers 데이터가 캐시 데이터와 다르지 않았다면, Carriers 데이터에 대한 네트워크 통신의 소요 시간만큼 더 빠르게 사용자에게 Trackers 값을 보여줄 수 있고, 또 Carriers 데이터의 변화에도 대응할 수 있습니다.

5-2. 네트워크 상태 관리

그 밖에도 React Query(Tanstack Query)와 같은 라이브러리를 사용하면, 서버에서 가져온 원격 데이터를 키 기반으로 인메모리에 캐싱해 네트워크 요청 상태와 데이터를 효율적으로 관리할 수 있습니다.