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

저 역시 초기 웹 프로젝트를 배포한 뒤 모바일 환경에서 테스트를 하다가 동일한 문제를 겪었습니다. 텍스트가 먼저 렌더링 된 후 용량이 큰 배너 이미지가 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를 배치할 것

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

이번에는 지금까지 다룬 주제들(Next.js 최적화, WebP/AVIF, 배열 메서드, TS 타입 에러, VS Code 확장, useEffect, Git 잔디, CORS)과 완전히 겹치지 않으면서 프론트엔드 최적화의 핵심이자 검색 노출(SEO) 점수에도 매우 유리한 주제로 준비했습니다!

주제는 [Web] 스크롤 이벤트 버벅거림 해결기: 디바운스(Debounce)와 스로틀(Throttle) 실무 적용법입니다. 생생한 개발 경험담과 고민 서사, 본문, 그리고 사진 첨부 가이드 1, 2번용 코드 및 눈속임 테스트 팁까지 한 번에 담아 드립니다.

[Web] 스크롤 한 번에 함수가 500번 실행된다고? 디바운스와 스로틀로 브라우저 렉 잡은 후기

웹 사이트에 무한 스크롤(Infinite Scroll)이나 상단 고정 헤더(Sticky Header), 실시간 검색창 자동완성 기능을 구현하다가 화면이 뚝뚝 끊기는 버벅거림(Jank) 현상을 겪어보신 적 있으신가요?

저 역시 예전 프로젝트에서 스크롤 이벤트(window.addEventListener('scroll', ...))를 붙여두고 아무 생각 없이 개발자 도구 콘솔을 열었다가 경악을 금치 못했습니다. 마우스 휠을 단 1초 굴렸을 뿐인데 이벤트 핸들러가 무려 300~500번 넘게 폭주하며 호출되고 있었던 것입니다.

이로 인해 모바일 기기나 저사양 PC에서는 스크롤이 심하게 버벅거렸고, 브라우저 성능 탭에서는 프레임 드랍(Frame Drop) 경고가 쏟아졌습니다.

오늘은 과도한 이벤트 호출로 인한 브라우저 과부하를 막고 성능을 극적으로 개선해 준 디바운스(Debounce)와 스로틀(Throttle)의 차이점 및 실무 적용 노하우를 공유해 보려고 합니다.

1. 내가 마주친 문제: "이벤트 폭주로 인한 브라우저 과부하"

웹 브라우저의 scroll, resize, mousemove 같은 이벤트는 사용자가 조작하는 동안 밀리초(ms) 단위로 매우 빈번하게 발생합니다.

JavaScript
 
// ❌ 휠 한 번에 수백 번 실행되어 성능을 갉아먹는 단순 스크롤 리스너
window.addEventListener('scroll', () => {
  console.log('현재 스크롤 위치 계산 중...'); // 1초에 수백 번 실행!
  // 만약 여기에 무거운 DOM 조작이나 API 호출이 들어있다면 브라우저는 바로 렉 발생
});

이벤트가 발생할 때마다 복잡한 연산이나 리렌더링, API 요청이 일어난다면 브라우저는 렌더 트리를 다시 계산(Reflow / Repaint)하느라 화면을 매끄럽게 그리지 못하게 됩니다.

이 문제를 해결하기 위해 이벤트 실행 주기를 제어하는 디바운스와 스로틀 기법을 적용했습니다.

2. 해결책 1: 마지막 입력 후 딱 한 번만 실행하는 '디바운스(Debounce)'

디바운스(Debounce)는 연이어 호출되는 이벤트 중 마지막 이벤트가 발생하고 일정 시간이 지난 후에 딱 한 번만 콜백 함수를 실행하는 기술입니다.

  • 가장 적합한 곳: 검색창 자동완성(실시간 검색 API 호출), 입력 폼 유효성 검사, 브라우저 창 크기 조절(resize) 완료 시점 감지.
JavaScript
 
// ⭕ 디바운스 유틸 함수 직접 구현
function debounce(func, delay = 300) {
  let timerId = null;

  return (...args) => {
    // 이전 타이머가 남아있다면 취소하고 새 타이머 시작
    if (timerId) clearTimeout(timerId);
    
    timerId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
}

// 실시간 검색창 입력 이벤트에 적용
const handleSearch = debounce((query) => {
  console.log(`서버 API 요청 날리기: ${query}`);
}, 500);

사용자가 '타이핑을 완전히 멈췄을 때' 비로소 API를 1번만 호출하므로, 불필요한 서버 트래픽을 90% 이상 아낄 수 있었습니다.

useDebounce 훅

3. 해결책 2: 정해진 주기마다 주기적으로 실행하는 '스로틀(Throttle)'

스로틀(Throttle)은 이벤트가 수없이 발생하더라도, 설정한 시간 주기(예: 200ms)마다 최대 1번만 실행되도록 제한(Throttling)을 거는 기술입니다.

  • 가장 적합한 곳: 무한 스크롤 페이지 바닥 감지, 스크롤 애니메이션 진행률 계산, 마우스 드래그 이벤트.
JavaScript
 
// ⭕ 스로틀 유틸 함수 직접 구현
function throttle(func, limit = 200) {
  let inThrottle = false;

  return (...args) => {
    if (!inThrottle) {
      func.apply(this, args);
      inThrottle = true;
      
      // 설정한 시간이 지난 후에야 다음 실행을 허용
      setTimeout(() => {
        inThrottle = false;
      }, limit);
    }
  };
}

// 스크롤 이벤트에 스로틀 적용
window.addEventListener(
  'scroll',
  throttle(() => {
    console.log('200ms마다 안정적으로 스크롤 위치 계산');
  }, 200)
);

스로틀을 적용하니 초당 수백 번 발생하던 스크롤 이벤트가 초당 단 5번(200ms 기준)으로 제한되면서, 부드러운 60fps 프레임을 유지할 수 있게 되었습니다.

4. 직접 비교해 본 디바운스 vs 스로틀 핵심 요약

구분 디바운스 (Debounce) 스로틀 (Throttle)
작동 원리 연속된 이벤트가 끝나고 일정 시간 후 1회 실행 연속된 이벤트 중 일정한 주기마다 1회씩 실행
핵심 목적 마지막 최종 결과물만 필요할 때 중간 진행 상태를 지속적으로 확인해야 할 때
추천 활용처 실시간 검색창 입력, Resize 창 크기 변경 무한 스크롤, 스크롤 위치 감지, 드래그

 

[Throttle] 스크롤 이벤트 실행

5. 결론 및 느낀 점

이전에는 브라우저 버벅임이 발생하면 무조건 React의 useMemo나 복잡한 리팩토링부터 고민했었습니다.

하지만 이벤트 제어 기법인 디바운스와 스로틀을 적재적소에 배치하는 것만으로도 브라우저 CPU 점유율을 대폭 낮추고 체감 렌더링 성능을 극대화할 수 있었습니다.

사용자 입력이나 스크롤 이벤트가 잦은 프론트엔드 프로젝트를 개발 중이시라면, 오늘 꼭 불필요한 이벤트 호출이 폭주하고 있지는 않은지 점검해 보시길 강력히 추천합니다!

웹 사이트를 만들 때 보통 디자인 도구(Figma 등)에서 만든 이미지들을 그냥 PNG나 JPEG로 뽑아서 아무 생각 없이 프로젝트에 넣곤 했습니다.

그러다 최근 진행했던 웹 서비스에서 큰 문제를 하나 만났습니다. 메인 화면에 들어가는 고화질 배너 이미지 몇 개를 PNG 파일 그대로 넣었더니, 이미지 파일 몇 개 용량만 합쳐도 5MB가 훌쩍 넘어가는 참사가 일어난 것입니다.

모바일로 접속했을 때 이미지가 위에서부터 천천히 스캔되듯이 지저분하게 내려오면서 로딩되는 모습을 보고 "아, 이미지 포맷부터 당장 바꿔야겠다"는 결심을 하게 되었습니다.

오늘은 구시대 포맷인 PNG/JPEG를 버리고 차세대 포맷인 WebP와 AVIF로 전환하면서 겪었던 과정과, 실무에서 브라우저 깨짐 없이 안전하게 적용했던 노하우를 공유해 봅니다.

1. 내가 부딪힌 문제: "화질은 포기하기 싫고, 용량은 너무 크다"

프로젝트 배너에 글씨와 그래픽이 섞여 있다 보니, 화질이 깨지는 JPEG 대신 깨끗하게 나오는 PNG 포맷을 사용했습니다. 하지만 PNG는 무손실 압축 방식이라 파일 용량이 어마무시하게 컸습니다.

  • 메인 배너 PNG 파일 1개 용량: 무려 2.4 MB
  • 문제점: 접속할 때마다 유저의 데이터와 시간을 엄청나게 갉아먹고 있었고, 네트워크가 조금만 느려도 화면이 텅 비어 보이는 현상 발생.

화질을 떨어뜨리지 않으면서 용량만 획기적으로 줄일 수 있는 방법을 찾다가 구글이 만든 WebP와 차세대 비디오 코덱 기반의 AVIF 포맷을 알게 되었습니다.

2. 해결책 1: HTML <picture> 태그로 구형 브라우저 호환성 지키기

WebP나 AVIF가 아무리 용량이 작고 좋아도, "혹시 최신 포맷을 지원하지 않는 구형 브라우저에서는 이미지가 엑박(X)으로 깨지면 어쩌지?" 하는 걱정이 들었습니다.

이 문제를 구원해 준 것이 바로 HTML5의 <picture> 태그였습니다. 브라우저에게 "네가 AVIF를 지원하면 AVIF를 보여주고, 안 되면 WebP를, 그것도 안 되면 기존 JPEG를 보여줘" 하고 우선순위를 정해주는 방식입니다.

HTML
 
<!-- ⭕ 내 프로젝트에 직접 적용한 크로스 브라우징 picture 태그 -->
<picture>
  <!-- 1. 가장 가볍고 최신인 AVIF 포맷을 최우선으로 시도 -->
  <source srcset="/images/main-banner.avif" type="image/avif" />
  
  <!-- 2. AVIF 미지원 시 WebP 포맷 사용 -->
  <source srcset="/images/main-banner.webp" type="image/webp" />
  
  <!-- 3. 최신 포맷을 둘 다 지원하지 않는 구형 브라우저용 기본 이미지 -->
  <img 
    src="/images/main-banner.jpg" 
    alt="메인 프로모션 배너" 
    width="1200" 
    height="600"
    loading="lazy"
  />
</picture>

이렇게 코드를 짜두니 에러 걱정 없이 마음 편하게 최신 이미지 포맷을 도입할 수 있었습니다.

<picture>, <source>, <img> 구조 예시

3. 해결책 2: Node.js(Sharp)로 자동 변환 스크립트 만들어 버리기

이미지가 한두 개가 아니다 보니 일일이 변환 사이트에 업로드해서 다운로드하는 작업이 너무 귀찮았습니다. 그래서 Node.js의 이미지 처리 라이브러리인 sharp를 활용해, 명령어 하나만 치면 프로젝트 내 모든 PNG 파일이 WebP와 AVIF로 자동 변환되는 스크립트를 직접 짜서 해결했습니다.

JavaScript
 
// convert.js (내가 직접 만든 이미지 자동 변환 스크립트)
const sharp = require('sharp');
const path = require('path');

const inputPath = path.join(__dirname, 'public/images/main-banner.png');

// PNG를 WebP로 자동 변환 (품질 80% 설정)
sharp(inputPath)
  .webp({ quality: 80 })
  .toFile(path.join(__dirname, 'public/images/main-banner.webp'))
  .then(() => console.log('✅ WebP 변환 성공!'));

// PNG를 AVIF로 자동 변환 (품질 75% 설정)
sharp(inputPath)
  .avif({ quality: 75 })
  .toFile(path.join(__dirname, 'public/images/main-banner.avif'))
  .then(() => console.log('🚀 AVIF 변환 성공!'));

터미널에서 node convert.js만 실행하면 몇 초 만에 고용량 PNG가 초경량 파일들로 쓱 변환되어 나와서 개발 생산성이 엄청나게 올라갔습니다.

4. 직접 눈으로 확인한 용량 비교: "2.4MB가 210KB가 되는 기적"

변환을 마치고 폴더 안에 있는 동일한 이미지의 포맷별 파일 용량을 직접 확인해 보았습니다.

  • 기존 원본 PNG: 2,400 KB (2.4MB)
  • WebP 변환 후: 420 KB (약 82% 용량 감소!)
  • AVIF 변환 후: 210 KB (약 91% 용량 감소!)

화질은 눈으로 봤을 때 원본 PNG와 차이를 구분할 수 없을 정도로 선명한데, 파일 용량은 무려 10분의 1 수준으로 줄어들었습니다. 그 결과 메인 페이지에 진입할 때 이미지가 버벅거리지 않고 즉시 뜨는 쾌적한 환경을 만들 수 있었습니다.

Node.js 환경에서 sharp 라이브러리로 PNG 이미지를 WebP와 AVIF로 자동 변환해 주는 스크립트 코드

 

5. 깨달은 점 및 요약

이전에는 프론트엔드 성능 최적화라고 하면 무조건 복잡한 코드 리팩토링이나 상태 관리 최적화만 생각했었습니다.

하지만 이번 경험을 통해 이미지 포맷을 WebP나 AVIF로 바꾸는 것 같은 단순한 시도가 웹사이트 성능에 얼마나 파격적인 영향을 주는지 깨달았습니다.

혹시 지금 만들고 계신 프로젝트의 로딩 속도가 답답하다면, 소스 코드보다 먼저 '내가 넣은 이미지 파일의 포맷과 용량'부터 점검해 보시면 좋을 것 같습니다!

이번에 개인 프로젝트를 새로 런칭하고 설레는 마음으로 크롬 개발자 도구의 Lighthouse 성능 검사를 돌려보았습니다.

결과는 참담하게도 성능 점수 62점, LCP(최초 대형 콘텐츠 도출) 속도 3.2초.

스마트폰 모바일 LTE 환경으로 접속해 보니 메인 페이지 배너 이미지가 위에서부터 턱-턱-턱 둔하게 렌더링되면서 아래쪽에 있던 텍스트들이 밑으로 밀려나는 현상(CLS)까지 발생하고 있었습니다.

"요즘 같은 세상에 로딩에 3초나 걸리는 웹사이트를 누가 기다려줄까?" 하는 위기감이 들었고, 원인을 분석해 보니 아무 생각 없이 넣어둔 고용량 메인 배너 이미지(PNG, 3.8MB)가 범인이었습니다.

오늘은 기본 HTML <img> 태그를 버리고 Next.js의 Image 컴포넌트(next/image)를 도입해 로딩 속도를 0.8초까지 끌어올렸던 생생한 최적화 과정을 공유해 봅니다.

1. 내가 범했던 실수: "원본 PNG 이미지를 그냥 태그에 꽂아 넣었다"

처음엔 그냥 디자인 도구에서 추출한 3.8MB짜리 고해상도 PNG 배너를 public/images 폴더에 넣고 아래처럼 코드를 짰었습니다.

TypeScript
 
// ❌ 처음 내가 작성했던 최적화 안 된 코드
export default function HeroBanner() {
  return (
    <div className="banner-container">
      <img src="/images/main-banner.png" alt="메인 배너" />
    </div>
  );
}

이 코드가 왜 문제였냐면:

  1. 모바일 단말기에서도 3.8MB 원본을 통째로 다운로드받고 있었습니다.
  2. 이미지가 로딩되기 전까지 div 높이가 0px이었다가, 이미지 다운로드가 끝나는 순간 갑자기 400px로 커지며 아래 요소들을 강제로 밀어내는 불쾌한 현상이 일어났습니다.
  3. 브라우저가 PNG 확장자 그대로 다운로드하느라 용량이 전혀 줄어들지 않았습니다.

2. 구원투수: Next.js Image 컴포넌트로 리팩토링하기

Next.js 공식 문서를 뒤져가며 next/image 패키지를 적용해 코드를 고쳐나갔습니다.

TypeScript
 
import Image from 'next/image';

// ⭕ 내 경험을 바탕으로 수정한 최적화 코드
export default function HeroBanner() {
  return (
    <div className="relative w-full h-[400px] overflow-hidden rounded-2xl shadow-lg">
      <Image
        src="/images/main-banner.png"
        alt="메인 프로모션 배너 이미지"
        fill
        priority // 1. LCP 핵심 이미지이므로 지연 로딩을 끄고 최우선 다운로드!
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
        className="object-cover"
        placeholder="blur" // 2. 이미지가 다 떠오르기 전 부드러운 블러 미리보기 적용
        blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU58EUg=="
      />
    </div>
  );
}

💡 직접 적용해보며 깨달은 꿀팁 2가지

  • priority 속성의 중요성: 메인 페이지 상단에 바로 보이는 배너 이미지에는 반드시 priority를 붙여주어야 합니다. 이걸 안 붙이면 Next.js가 기본적으로 lazy loading(스크롤 내릴 때 로드)을 적용해버려서 오히려 초기 로딩이 더 늦어집니다.
  • fill과 부모 relative 관계: 이미지 크기를 반응형으로 고정할 때 fill을 쓰면 편리한데, 이때 반드시 감싸고 있는 부모 div에 relative 포지션과 height 값이 지정되어 있어야 배너가 깨지지 않고 예쁘게 잡힙니다.

Image 컴포넌트 사용 코드

3. 외부 이미지(Unsplash/S3) 불러올 때 만난 에러와 해결

개발하다가 유저 프로필 사진을 외부 서버 URL로 불러왔을 때 Invalid src prop ... hostname is not configured under images 라는 빨간색 에러 화면을 만났습니다.

Next.js는 보안과 악성 이미지 서버 공격을 막기 위해 허용된 도메인의 외부 이미지만 가져오도록 제한하고 있었습니다. next.config.js 파일에 제가 사용하는 외부 이미지 도메인을 명시해 주니 깔끔하게 해결되었습니다.

JavaScript
 
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.unsplash.com', // 사용 중인 외부 이미지 서버 도메인 추가
      },
    ],
  },
};

module.exports = nextConfig;

4. 최종 결과: 3.8MB ➔ 320KB, 성능 점수 94점 달성!

수정을 마치고 개발 서버를 다시 빌드한 뒤 크롬 개발자 도구 Network 탭을 열어보았습니다.

  • 이미지 포맷 변화: 원본 .png 파일이 브라우저에서 자동으로 최신 압축 포맷인 .webp로 변환되어 들어오고 있었습니다.
  • 용량 감축: 3.8MB ➔ 320KB (약 90% 용량 감소)
  • Lighthouse 점수: 62점 ➔ 94점으로 폭등!

Image 컴포넌트 적용 후 개발자 도구 Network 탭 200 OK 요청 성공 화면

5. 회고 및 느낀 점

이전에는 "이미지는 그냥 img 태그 써서 띄우면 되는 거 아닌가?" 하고 안일하게 생각했었습니다.

하지만 이번에 직접 성능 수치를 측정해 보고 next/image를 도입해 리팩토링해 보면서, 작은 이미지 최적화 하나가 전체 서비스의 품질과 사용자 경험(UX)을 얼마나 크게 좌우하는지 뼈저리게 체감할 수 있었습니다.

앞으로 작성하는 모든 프로젝트에서는 개발 초기 단계부터 이미지 최적화 전략을 적용하면 좋을 것 같습니다!

웹사이트의 성능은 단순히 '데이터가 얼마나 빨리 도착하는가'에 달려 있지 않습니다. 더 중요한 것은 '도착한 데이터를 얼마나 빨리 화면에 그려내는가'입니다. 브라우저가 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 읽기/쓰기 로직을 최적화하세요.

Next.js App Router의 핵심은 "모든 컴포넌트는 기본적으로 서버 컴포넌트(Server Components)다"라는 선언입니다. 과거 Pages Router 시절에는 모든 컴포넌트가 브라우저로 전송되어 하이드레이션(Hydration) 과정을 거쳐야 했지만, 이제는 서버에서만 실행되고 결과물인 HTML만 브라우저로 전달되는 컴포넌트를 만들 수 있게 되었습니다.

하지만 개발을 하다 보면 인터랙션(클릭, 상태 관리)이 필요한 시점이 오고, 자연스럽게 'use client'를 선언하게 됩니다. 이때 가장 중요한 역량은 "어디까지를 서버 영역으로 두고, 어디서부터 클라이언트 영역으로 나눌 것인가"를 결정하는 설계 능력입니다.


1. 서버 컴포넌트(RSC) vs 클라이언트 컴포넌트(RCC)

먼저 두 컴포넌트의 역할과 제약을 명확히 구분해야 합니다.

1.1 서버 컴포넌트 (Default)

  • 실행 위치: 서버에서만 실행됩니다.
  • 장점: 브라우저로 전송되는 자바스크립트 번들 크기가 줄어들며, 서버 자원(DB, 파일 시스템)에 직접 접근할 수 있습니다. 보안에 민감한 API 키 등을 숨기기에도 유리합니다.
  • 제약: useState, useEffect 같은 훅이나 onClick 같은 이벤트 핸들러를 사용할 수 없습니다. 브라우저 API(window, localStorage)에도 접근할 수 없습니다.

1.2 클라이언트 컴포넌트 ('use client')

  • 실행 위치: 서버에서 프리렌더링된 후 브라우저에서 하이드레이션됩니다.
  • 장점: 사용자와의 인터랙션이 가능하고 브라우저 API를 모두 사용할 수 있습니다.
  • 제약: 자바스크립트 번들 용량을 차지하며, 데이터 패칭 시 서버 컴포넌트보다 네트워크 비용이 더 발생할 수 있습니다.

2. 효율적인 경계 설계 전략 (Component Pattern)

성능을 최적화하기 위해서는 "클라이언트 컴포넌트를 컴포넌트 트리의 가장 말단으로 밀어내는 것"이 핵심입니다.

2.1 리프 컴포넌트 패턴 (Leaf Component Pattern)

페이지 전체를 'use client'로 만드는 대신, 상태가 필요한 작은 조각만 별도의 클라이언트 컴포넌트로 분리하세요.

  • Bad: 검색 결과 페이지 전체를 클라이언트 컴포넌트로 선언 (검색 결과 리스트까지 모두 JS 번들에 포함됨)
  • Good: 검색어 입력을 받는 SearchBar만 클라이언트 컴포넌트로 만들고, 결과 리스트는 서버 컴포넌트로 유지
TypeScript
 
// SearchPage.tsx (Server Component)
import SearchBar from './SearchBar'; // 'use client'
import SearchResults from './SearchResults'; // Server Component

export default function SearchPage() {
  return (
    <main>
      <h1>검색 페이지</h1>
      {/* 인터랙션이 필요한 부분만 클라이언트 영역 */}
      <SearchBar /> 
      {/* 데이터 렌더링은 서버 영역 */}
      <SearchResults />
    </main>
  );
}

3. 서버 컴포넌트 안에 클라이언트 컴포넌트 넣기 (Composition)

서버 컴포넌트에서 클라이언트 컴포넌트를 불러오는 것은 쉽지만, 그 반대는 불가능하다고 생각하는 경우가 많습니다. 하지만 컴포넌트 합성(Composition)을 이용하면 클라이언트 컴포넌트 '내부'에 서버 컴포넌트를 배치할 수 있습니다.

TypeScript
 
// ClientWrapper.tsx ('use client')
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>토글</button>
      {/* children으로 들어온 서버 컴포넌트는 여전히 서버에서 실행됨 */}
      {isOpen && children}
    </div>
  );
}

// Page.tsx (Server Component)
export default function Page() {
  return (
    <ClientWrapper>
      <HeavyServerComponent /> {/* 클라이언트 컴포넌트 안에서도 서버 컴포넌트의 이점 유지 */}
    </ClientWrapper>
  );
}

이 패턴을 활용하면 레이아웃이나 상태를 관리하는 래퍼(Wrapper)는 클라이언트 쪽에 두면서도, 실제 무거운 데이터 렌더링은 서버 컴포넌트로 처리하는 고도화된 설계가 가능해집니다.


4. 데이터 패칭의 최적 위치

App Router에서는 데이터 패칭을 서버 컴포넌트에서 수행하는 것을 강력히 권장합니다.

  • 이유: 서버에서 직접 DB에 접근하므로 속도가 빠르고, 클라이언트-서버 간의 Waterfall 현상을 방지할 수 있습니다. 또한 패칭된 데이터를 하위 클라이언트 컴포넌트로 Props를 통해 쉽게 전달할 수 있습니다.

5. 실무 체크리스트: 언제 'use client'를 쓸까?

필요한 기능 서버 컴포넌트 클라이언트 컴포넌트
데이터 패칭 (Fetching Data) ✅ ❌ (권장 안 함)
백엔드 자원 직접 접근 (DB, API) ✅ ❌
보안 정보 유지 (API Keys, Tokens) ✅ ❌
상태 관리 및 생명주기 (useState, useEffect) ❌ ✅
브라우저 API 사용 (window, localStorage) ❌ ✅
커스텀 훅 사용 (대부분의 UI 관련 훅) ❌ ✅

6. 결론: "서버가 먼저, 클라이언트는 나중에"

Next.js App Router 설계의 미학은 '자바스크립트 다이어트'에 있습니다. 최대한 많은 로직을 서버로 옮기고, 클라이언트는 오직 사용자의 손가락이 닿는 곳(Click, Type, Swipe)에만 집중하게 하세요.

오늘의 요약:

  • 기본은 서버: 모든 파일은 일단 서버 컴포넌트로 시작하세요.
  • 말단 분리: 상태가 필요한 부분만 핀셋으로 집어내듯 클라이언트 컴포넌트로 만드세요.
  • 합성 활용: 클라이언트 컴포넌트가 서버 컴포넌트를 children으로 받을 수 있음을 잊지 마세요.
  • 데이터는 상단 서버에서: 데이터 패칭은 가능한 트리 상단의 서버 컴포넌트에서 해결하세요.

+ Recent posts