지난번 쇼핑몰 프로젝트를 진행할 때의 일입니다. 테스트할 때 고화질 상품컷을 몇 십 장씩 업로드하면서 메인 페이지 접속 속도가 터무니없이 느려지는 현상이 발생했습니다.

크롬 개발자 도구를 켜서 네트워크(Network) 탭을 확인해 보니, 메인 페이지 전체 용량 8MB 중 무려 6.5MB가 고용량 PNG/JPEG 이미지였습니다. 메인 배너 이미지 하나 용량이 3MB에 달하다 보니, 모바일 환경에서는 화면이 뜨는 데 4~5초 이상 걸려 사용자 이탈률이 치솟고 있었죠.

그때 깨달았습니다. 수백 줄의 자바스크립트 로직을 줄이는 것보다 이미지 한 장의 용량을 3MB에서 100KB로 압축하는 것이 훨씬 더 파괴적인 성능 개선을 가져온다는 사실을요. 오늘은 제가 직접 프로젝트에 적용해 보고 효과를 크게 봤던 이미지 최적화 3대 전략을 공유해 봅니다.

1. 차세대 포맷(WebP, AVIF)으로 변환: 용량이 반토막 나는 마법

가장 먼저 한 작업은 수십 십 개의 JPEG, PNG 파일들을 차세대 포맷인 WebP와 AVIF로 변환하는 것이었습니다.

  • WebP: PNG처럼 투명도(Alpha)를 지원하면서도, JPEG 대비 용량이 25~35% 이상 감소합니다.
  • AVIF: 현존 최강의 압축률을 자랑하며, WebP보다도 20% 더 가볍습니다.

구형 브라우저 분기 처리를 위해 <picture> 태그를 활용해 코드를 변경했습니다.

HTML
 
<!-- 브라우저가 지원하는 최신 포맷을 알아서 골라 로드하도록 분기 처리 -->
<picture>
  <source srcset="banner.avif" type="image/avif" />
  <source srcset="banner.webp" type="image/webp" />
  <img src="banner.jpg" alt="메인 기획전 배너" />
</picture>

이렇게 포맷만 바꿔줬는데도 이미지 전체 용량이 6.5MB에서 1.8MB로 대폭 줄어드는 것을 눈으로 확인했습니다.

squoosh.app을 이용해 이미지 용량 대폭 감소

2. 지연 로딩(Lazy Loading)과 나의 실수: LCP 이미지는 건드리지 마라!

화면을 아래로 스크롤하기 전까지는 보이지도 않는 하단 상품 이미지들까지 한 번에 로드할 필요가 전혀 없었습니다. 그래서 HTML5 표준 속성인 loading="lazy"를 도입했습니다.

HTML
 
<!-- 화면 뷰포트에 가까워지면 그때 다운로드를 시작합니다 -->
<img src="product-detail.webp" loading="lazy" alt="상품 상세 설명" />

🚨 내가 겪었던 삽질: 메인 배너에 lazy를 걸었다가 점수가 깎이다?

여기서 욕심을 내서 메인 최상단 배너 이미지에도 loading="lazy"를 적용했었는데, 오히려 구글 Lighthouse의 LCP(Largest Contentful Paint - 가장 큰 콘텐츠가 뜨는 시간) 점수가 더 떨어지는 기현상이 일어났습니다.

알고 보니 첫 화면에 바로 보여야 하는 핵심 이미지에는 절대 lazy를 걸면 안 되는 것이었습니다. 브라우저가 로딩을 오히려 미루기 때문이죠. 상단 배너에는 loading="eager"와 함께 fetchpriority="high" 속성을 주어 우선순위를 높여주는 것이 정답이었습니다.

3. responsive images (srcset): 모바일 사용자 데이터 아껴주기

데스크톱용 1920px 너비의 이미지를 모바일 사용자(너비 390px)에게 전송하는 건 명백한 데이터 낭비였습니다. 기기 해상도에 맞춰 최적화된 크기를 전달하기 위해 srcset 속성을 적용했습니다.

HTML
 
<img
  src="banner-small.webp"
  srcset="banner-small.webp 500w, banner-medium.webp 1000w, banner-large.webp 2000w"
  sizes="(max-width: 600px) 480px, 800px"
  alt="메인 배너"
/>

이렇게 설정해 두니 브라우저가 알아서 현재 모바일 기기인지, 데스크톱인지 파악하여 가장 딱 맞는 크기의 이미지만 쏙 골라서 다운로드하더군요.

Lighthouse Performance 점수

4. 실무 이미지 최적화 체크리스트

프로젝트를 진행하며 정리한 저만의 최적화 루틴입니다.

  1. 상세 페이지 이미지 용량 제어: 웹용 이미지는 무조건 장당 200KB 이하로 맞추기
  2. Layout Shift(CLS) 방지: <img> 태그에 width와 height 속성을 명시해서 이미지가 늦게 떠도 화면이 덜컹거리지 않게 영역 미리 잡기
  3. EXIF 메타데이터 제거: 카메라 촬영 정보, 위치 정보 등 불필요한 메타데이터는 압축 툴로 싹 지워주기

5. 글을 마치며

이미지 최적화는 단순히 기술적인 도전을 넘어, 우리 서비스를 이용하는 사용자의 데이터와 시간을 아껴주는 작업이었습니다.

실제로 이미지 다이어트 작업을 마친 뒤 메인 페이지 체감 로딩 속도가 3초 이상 단축되었고, 팀 내부에서도 만족도가 매우 높았습니다. 만약 지금 운영 중인 사이트나 블로그가 답답하게 느껴진다면, 자바스크립트 코드를 건드리기 전에 먼저 이미지들부터 가볍게 다이어트시켜 보시는 걸 강력히 추천합니다!

웹사이트의 성능은 단순히 '데이터가 얼마나 빨리 도착하는가'에 달려 있지 않습니다. 더 중요한 것은 '도착한 데이터를 얼마나 빨리 화면에 그려내는가'입니다. 브라우저가 HTML, CSS, JavaScript를 받아서 화면에 픽셀로 변환하는 일련의 과정을 중요 렌더링 경로(Critical Rendering Path, CRP)라고 부릅니다. 이 경로를 단축하는 것이 곧 성능 최적화의 정석입니다.


1. 렌더링의 5단계 공정 (CRP)

브라우저는 화면을 그리기 위해 크게 5가지 단계를 거칩니다.

1.1 DOM 트리 구축 (Parsing)

브라우저가 HTML 문서를 읽어 내려가며 태그들을 트리 구조의 노드들로 변환합니다. 이것이 우리가 잘 아는 DOM(Document Object Model)입니다.

1.2 CSSOM 트리 구축

HTML을 읽다가 <link>나 <style> 태그를 만나면 CSS를 파싱하여 CSSOM(CSS Object Model) 트리를 만듭니다. CSS는 상속 구조를 가지기 때문에 이 트리가 완성되어야 다음 단계로 넘어갈 수 있습니다.

1.3 렌더 트리(Render Tree) 형성

DOM과 CSSOM이 결합하여 실제 화면에 '보이는' 노드들로만 구성된 렌더 트리가 만들어집니다. display: none이 설정된 요소는 이 트리에서 제외됩니다.

1.4 레이아웃(Layout / Reflow)

렌더 트리의 각 노드가 화면의 정확히 어느 위치에, 어느 정도 크기로 배치될지 계산하는 과정입니다. 상대적인 수치(%, em)가 절대적인 픽셀(px)로 변환되는 시점입니다.

1.5 페인트(Paint)

계산된 위치를 바탕으로 실제 화면에 픽셀을 채워 넣습니다. 텍스트, 색상, 이미지, 효과 등이 시각적으로 나타나는 단계입니다.


2. 성능의 적: 리플로우(Reflow)와 리페인트(Repaint)

사용자의 인터랙션(클릭, 스크롤, 애니메이션)으로 화면의 요소가 변경되면 위 과정이 다시 발생합니다.

  • Reflow: 요소의 크기나 위치가 바뀌어 레이아웃 단계부터 다시 실행되는 것을 의미합니다. 매우 비용이 많이 드는 작업입니다. (예: width, height, margin 변경)
  • Repaint: 위치는 그대로고 색상이나 가시성만 바뀌어 레이아웃 계산 없이 페인트만 다시 수행하는 것입니다. (예: color, background-color 변경)

최적화 팁: 가능하다면 레이아웃과 페인트를 건너뛰고 Composite(합성) 단계만 거치는 속성(transform, opacity)을 사용하여 애니메이션을 구현하세요. 이는 GPU를 활용하므로 훨씬 부드럽습니다.


3. CRP를 단축하는 3가지 실무 전략

3.1 리소스 우선순위 지정 (Preload & Preconnect)

중요한 리소스(폰트, 메인 이미지)는 브라우저가 먼저 발견하도록 힌트를 주세요.

HTML
 
<link rel="preload" href="main-font.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preconnect" href="https://api.your-service.com">

3.2 렌더링 차단 리소스 제거

CSS는 렌더링을 차단하고, JS는 파싱을 차단합니다.

  • CSS: 필요한 최소한의 CSS만 상단에 배치하고 나머지는 비동기로 로드하세요.
  • JS: <script> 태그에 async나 defer 속성을 사용하여 HTML 파싱이 멈추지 않게 하세요. (일반적으로 defer가 권장됩니다.)

3.3 콘텐츠 가시성 제어 (content-visibility)

최신 브라우저에서 지원하는 content-visibility: auto 속성을 사용하면 화면 밖에 있는 요소의 렌더링 계산을 브라우저가 자동으로 생략합니다. 긴 랜딩 페이지에서 성능을 획기적으로 높일 수 있습니다.


4. 실무 트러블슈팅: 레이아웃 스래싱(Layout Thrashing) 방지

JS로 스타일을 변경할 때, 쓰기(Write)와 읽기(Read)를 반복하면 브라우저는 정확한 값을 알려주기 위해 매번 레이아웃을 다시 계산해야 합니다.

JavaScript
 
// Bad: 레이아웃 스래싱 발생
for (let i = 0; i < boxes.length; i++) {
  const width = boxes[i].offsetWidth; // 읽기 (Layout 발생)
  boxes[i].style.width = width + 10 + 'px'; // 쓰기 (Invalidate Layout)
}

// Good: 읽기와 쓰기를 분리
const widths = boxes.map(box => box.offsetWidth); // 한꺼번에 읽기
boxes.forEach((box, i) => {
  box.style.width = widths[i] + 10 + 'px'; // 한꺼번에 쓰기
});

5. 결론: 사용자 경험의 0.1초를 잡는 법

렌더링 원리를 이해하는 것은 "어떻게 화면을 띄울 것인가"를 넘어 "어떻게 하면 사용자가 로딩을 느끼지 못하게 할 것인가"에 대한 답을 찾는 과정입니다.

오늘의 요약:

  1. DOM + CSSOM = Render Tree임을 기억하세요.
  2. Reflow를 유발하는 속성 사용을 최소화하고 GPU 가속(transform)을 활용하세요.
  3. defer와 priority 설정을 통해 중요 경로 리소스를 먼저 로드하세요.
  4. Layout Thrashing을 피하기 위해 DOM 읽기/쓰기 로직을 최적화하세요.

웹사이트에 접속했을 때, 처음에는 기본 폰트로 텍스트가 보이다가 갑자기 예쁜 웹 폰트로 바뀌면서 줄 바꿈이 변하거나 문단이 아래로 툭 떨어지는 현상을 겪어보신 적이 있나요?

사용자를 당황하게 만드는 이 현상을 FOUT(Flash of Unstyled Text)라고 하며, 이는 구글의 핵심 성능 지표 중 하나인 CLS(Cumulative Layout Shift) 점수를 크게 갉아먹는 주범입니다. 오늘은 웹 폰트 로딩 최적화를 통해 시각적 안정성을 확보하는 실무 전략을 깊이 있게 알아보겠습니다.


1. CLS의 적, 웹 폰트 로딩의 이해

웹 폰트는 용량이 크기 때문에 브라우저가 HTML과 CSS를 파싱하는 속도보다 늦게 다운로드되는 경우가 많습니다. 이때 브라우저는 두 가지 방식 중 하나를 선택합니다.

  1. FOIT (Flash of Invisible Text): 폰트가 다운로드될 때까지 텍스트를 아예 보여주지 않음. (사용자는 빈 화면을 보게 됨)
  2. FOUT (Flash of Unstyled Text): 시스템 기본 폰트로 먼저 보여준 뒤, 웹 폰트가 로드되면 교체함. (이 과정에서 레이아웃이 어긋남)

구글은 사용자 경험을 위해 텍스트가 아예 안 보이는 것보다 레이아웃이 조금 흔들리더라도 빨리 보이는 것을 권장하지만, 이 '흔들림'을 최소화하는 것이 프론트엔드 개발자의 실력입니다.


2. font-display 속성으로 로딩 제어하기

가장 쉽고 필수적인 해결책은 CSS의 @font-face에서 font-display 속성을 사용하는 것입니다.

CSS
 
@font-face {
  font-family: 'Pretendard';
  src: url('/fonts/Pretendard.woff2') format('woff2');
  font-display: swap; /* 핵심 설정 */
}

2.1 주요 옵션 차이점

  • block: 폰트가 로드될 때까지 텍스트를 숨깁니다(최대 3초).
  • swap (권장): 시스템 폰트로 즉시 텍스트를 보여주고, 웹 폰트 로드가 완료되면 교체합니다. LCP 점수 유지에 유리합니다.
  • fallback: 아주 짧은 시간(약 0.1초) 동안 텍스트를 숨긴 후 로드되지 않으면 기본 폰트를 보여줍니다.
  • optional: 네트워크 상태에 따라 웹 폰트를 쓸지 말지 브라우저가 결정합니다. 성능이 최우선일 때 사용합니다.

3. 가상 폰트(Fallback Font) 최적화: 사이즈 매칭

font-display: swap을 사용하더라도 기본 폰트와 웹 폰트의 글자 크기, 자간, 높이가 다르면 여전히 레이아웃 시프트가 발생합니다. 이를 해결하기 위해 최근에는 가상 폰트(Size Adjust) 기법을 사용합니다.

3.1 size-adjust와 ascent-override

웹 폰트와 최대한 비슷한 부피를 가지도록 시스템 폰트의 수치를 강제로 조정하는 방식입니다.

CSS
 
/* 기본 폰트(Arial)를 웹 폰트와 크기가 비슷하게 조정 */
@font-face {
  font-family: 'Pretendard-Fallback';
  src: local('Arial');
  size-adjust: 95%;        /* 전체 크기 조정 */
  ascent-override: 90%;    /* 글자 윗부분 높이 조정 */
  descent-override: 10%;   /* 글자 아랫부분 높이 조정 */
}

body {
  font-family: 'Pretendard', 'Pretendard-Fallback', sans-serif;
}

이렇게 하면 폰트가 교체될 때 텍스트의 부피 변화가 거의 없어 CLS 점수를 0에 가깝게 유지할 수 있습니다. 최근에는 Capsize 같은 도구를 통해 이 수치를 정교하게 계산할 수 있습니다.


4. Next.js를 사용한다면? next/font 활용

Next.js (v13+) 환경에서 개발 중이라면 위 복잡한 과정이 대폭 자동화됩니다. next/font는 빌드 시점에 폰트를 최적화하고 가상 폰트를 자동으로 생성해 줍니다.

JavaScript
 
// app/layout.tsx
import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  adjustFontFallback: true, // 가상 폰트 자동 생성 옵션
});

export default function RootLayout({ children }) {
  return (
    <html lang="ko" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

next/font를 사용하면 폰트 파일을 구글 서버가 아닌 우리 서버(Self-hosting)에서 직접 제공하므로 개인정보 보호와 성능 면에서 모두 유리합니다.


5. 실무 체크리스트: 웹 폰트 100% 활용법

  1. 포맷 확인: 가장 압축률이 좋은 woff2 포맷을 우선적으로 사용하세요.
  2. Subset 활용: 한글 폰트는 용량이 매우 큽니다. 자주 쓰는 2,350자만 추린 '서브셋(Subset)' 폰트를 사용해 용량을 80% 이상 줄이세요.
  3. Preload: 중요한 폰트는 <link rel="preload">를 통해 미리 로드하세요.
  4. HTML
     
    <link rel="preload" href="/fonts/main-font.woff2" as="font" type="font/woff2" crossorigin>
    
  5. 로컬 폰트 활용: 사용자의 기기에 이미 설치된 폰트가 있다면 그것을 우선 사용하게 설정하세요. (src: local('Pretendard'), url(...))

+ Recent posts