웹 서핑을 하다가 기사나 상품 페이지를 읽는 도중, 상단 이미지가 뒤늦게 다운로드되면서 화면 전체가 덜컹 내려앉아 엉뚱한 버튼을 잘못 클릭해 본 경험이 다들 한 번쯤 있으실 겁니다.

저 역시 초기 웹 프로젝트를 배포한 뒤 모바일 환경에서 테스트를 하다가 동일한 문제를 겪었습니다. 텍스트가 먼저 렌더링 된 후 용량이 큰 배너 이미지가 0.5초 뒤에 늦게 로딩되면서, 사용자가 클릭하려던 '장바구니 담기' 버튼이 아래로 쑥 밀려 내려가 '구매 취소' 버튼을 누르게 만드는 치명적인 UI 버그가 발생한 것입니다.

구글 라이트하우스(Lighthouse) 성능 점수를 측정해 보니 CLS(Cumulative Layout Shift, 누적 레이아웃 이동) 점수가 빨간색 경고(0.35 이상)를 기록하고 있었습니다.

오늘은 화면 깜빡임과 덜컹거림을 완벽히 잡고 부드러운 사용자 경험을 만들어 준 CLS 개선 노하우와 최신 CSS aspect-ratio 및 스켈레톤 UI 적용법을 공유해 보려고 합니다.

1. 내가 마주친 문제: "왜 화면이 덜컹거리며 밀려날까?"

브라우저가 HTML을 파싱할 때, 이미지 태그에 가로/세로 크기 정보가 미리 지정되어 있지 않으면 브라우저는 해당 이미지의 크기를 0 x 0 픽셀로 간주하고 아래 요소들을 먼저 위로 바짝 끌어올려 배치합니다.

HTML
 
<!-- ❌ 브라우저가 이미지 크기를 미리 알 수 없어 CLS를 유발하는 위험한 태그 -->
<img src="https://example.com/banner.jpg" alt="이벤트 배너" />

그 후 네트워크를 통해 실제 이미지 파일이 다운로드되면, 브라우저는 그제야 이미지의 진짜 높이(예: 400px)를 인식하고 아래에 있던 콘텐츠들을 강제로 밀어내는 재배치(Reflow / Relayout) 작업을 수행합니다. 이것이 바로 레이아웃 덜컹거림(Layout Shift)의 원인입니다.

2. 해결 방법 1: width와 height 속성으로 종횡비(Aspect Ratio) 미리 알려주기

가장 기본적이면서 강력한 해결법은 HTML <img> 태그에 반드시 원본 이미지의 width와 height 속성을 명시해 주는 것입니다.

HTML
 
<!-- ⭕ 브라우저가 이미지 비율을 미리 계산하여 공간을 확보함 -->
<img 
  src="https://example.com/banner.jpg" 
  alt="이벤트 배너" 
  width="800" 
  height="450" 
  style="width: 100%; height: auto;" 
/>

최신 브라우저는 태그에 적힌 width와 height 값을 기반으로 이미지가 로딩되기 전부터 종횡비(Aspect Ratio: 16/9)를 미리 계산해 정확한 높이의 빈 공간을 미리 예약해 둡니다. 따라서 이미지가 늦게 다운로드되어도 주변 레이아웃이 1픽셀도 흔들리지 않습니다.

3. 해결 방법 2: 최신 CSS aspect-ratio 속성 활용하기

동적으로 불러오는 외부 이미지나 반응형 카드 컴포넌트에는 최신 CSS 속성인 aspect-ratio를 사용하면 훨씬 유연하게 공간을 확보할 수 있습니다.

CSS
 
/* ⭕ 이미지가 다운로드되기 전에도 16:9 비율 공간을 강제로 유지 */
.banner-image-container {
  width: 100%;
  aspect-ratio: 16 / 9;
  background-color: #1e293b; /* 로딩 전 빈 공간에 어두운 배경색 지정 */
  border-radius: 12px;
  overflow: hidden;
}

.banner-image-container img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

과거에 padding-top: 56.25% 같은 복잡하고 난해한 핵(Hack)을 쓰던 방식에서 벗어나, 단 한 줄의 CSS로 완벽한 반응형 종횡비 공간을 예약할 수 있게 되었습니다.

📸 [사진 첨부 가이드 1]

여기에 VS Code에서 aspect-ratio: 16 / 9; 및 이미지 컨테이너 CSS 코드가 작성된 에디터 스크린샷을 넣으세요!

(캡처 팁: CSS 속성 중 aspect-ratio와 object-fit: cover 부분이 한눈에 들어오도록 에디터 화면을 스크린샷 찍어 첨부해 주세요.)

이미지 컨테이너 css

4. 해결 방법 3: 스켈레톤 UI(Skeleton)로 로딩 체감 속도 극대화

API 통신으로 데이터를 받아오는 동안에는 빈 화면 대신 스켈레톤(Skeleton UI)을 띄워 공간을 유지하면서 사용자에게 "데이터를 불러오는 중"이라는 시각적 피드백을 주는 것이 좋습니다.

CSS
 
/* 은은하게 반짝이는 스켈레톤 애니메이션 */
@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}

.skeleton-box {
  width: 100%;
  aspect-ratio: 16 / 9;
  background-color: #334155;
  animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
  border-radius: 8px;
}

스켈레톤 UI를 적용하면 레이아웃 밀림 방지는 물론, 사용자가 대기 시간을 훨씬 짧게 체감하게 만드는 효과가 있습니다.

이미지 로드 전 스켈레톤
이미지 로드 후 상태

5. 결론 및 느낀 점

구글 검색 엔진은 이제 단순히 텍스트 내용뿐만 아니라 사용자가 화면을 조작할 때 시각적 안정성을 유지하는지(Core Web Vitals)를 중요한 랭킹 요소로 평가합니다.

  1. 모든 <img> 태그에 종횡비 정보(width/height 또는 CSS aspect-ratio)를 명시할 것
  2. 동적 콘텐츠 로딩 구간에는 미리 높이를 확보한 스켈레톤 UI를 배치할 것

이 두 가지 원칙만 지켜도 화면이 덜컹거리는 불쾌한 사용자 경험을 없애고 웹 사이트의 전문성과 완성도를 한 단계 끌어올릴 수 있습니다!

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

크롬 개발자 도구를 켜서 네트워크(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초 이상 단축되었고, 팀 내부에서도 만족도가 매우 높았습니다. 만약 지금 운영 중인 사이트나 블로그가 답답하게 느껴진다면, 자바스크립트 코드를 건드리기 전에 먼저 이미지들부터 가볍게 다이어트시켜 보시는 걸 강력히 추천합니다!

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

사용자를 당황하게 만드는 이 현상을 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