[FE]

🚀 Next.js 이미지 최적화: LCP 개선을 위한 전체 설계

juncci 2026. 3. 31. 19:54

프로필 이미지 하나 때문에 성능이 무너지는 경험을 했다.

페이지 자체는 가볍다고 생각했지만,
Lighthouse를 확인해보니 LCP가 비정상적으로 길게 측정되고 있었다.

원인을 추적해보니 문제는 단순했다.

화면에는 작게 보이는 이미지가,
실제로는 수백 KB에 달하는 원본 이미지 그대로 전달되고 있었다.

이 문제를 해결하기 위해 이미지 최적화를 적용하게 되었고,
단순히 용량을 줄이는 수준이 아니라

업로드 → 전달 → 렌더링까지
이미지 흐름 전체를 다시 설계하게 되었다.

 

⭐ 이미지 최적화는 단순히 “용량을 줄이는 작업”이 아니다.
사용자가 페이지를 얼마나 빠르게 인지하고 상호작용할 수 있는지를 결정하는 중요한 사용자 경험 요소다. ⭐

 

 

실제 사용자 경험에 영향을 주는 요소는 다음과 같다.

  • 얼마나 작은 이미지인가
  • 얼마나 빠르게 요청되는가
  • 얼마나 빠르게 렌더링되는가

이미지 최적화는 다음 3가지 축으로 정리해 볼 수 있을 것 같다.

 

 

📌 이미지 최적화 5단계

업로드 원본 비용 감소
전달 네트워크 효율 개선
렌더링 체감 속도 개선
분기 처리 상황별 최적화 전략 적용
캐싱 재요청 제거 및 재사용

기존 문제 구조

기존 구조에서는 브라우저가 이미지 원본(S3)을 직접 요청하는 방식이었다.

이 구조의 문제는 화면에 표시되는 크기와 상관없이,
항상 원본 이미지가 그대로 다운로드된다는 점이다.

100px 크기로 표시되는 이미지라도
수백 KB 이상의 데이터를 그대로 받아야 했다.

 

이로 인해 다음과 같은 문제가 발생했다.

- 불필요하게 큰 네트워크 트래픽 발생
- 이미지 디코딩 비용 증가
- 렌더링 지연

결과적으로 LCP가 증가하고,
이미지 크기가 늦게 확정되면서 CLS까지 발생할 수 있는 구조였다.

(LCP : 최대 컨텐츠 렌더링되는 시간)
(CLS: 누적 레이아웃 변경)

 


개선된 전체 구조

이 문제를 해결하기 위해 이미지 전달 구조 자체를 변경했다.

기존에는 브라우저가 원본 이미지를 직접 요청했다면,
이제는 Next.js의 이미지 최적화 레이어를 거쳐 전달되도록 구성했다.

이 중간 레이어는 단순한 프록시가 아니라
이미지를 상황에 맞게 변환하고 최적화하는 역할을 한다.


핵심 변화

브라우저 → Next Image Optimizer → S3

이 구조를 통해 브라우저는 더 이상 원본 이미지를 직접 받지 않고,
필요한 크기와 포맷으로 가공된 이미지를 받게 된다.

👉 원본을 직접 전달하지 않고 중간 최적화 레이어를 추가

 

이제 최적화된 상태에서 업로드, 전달, 렌더링 과정에서 어떤 작업을 구체적으로 했는지 정리해보고자 한다!!


 1️⃣ 업로드 단계 최적화

🎯 목적

모든 비용의 출발점인 “원본 이미지” 자체를 줄인다

 

업로드 단계는 이후 모든 비용의 출발점이 되는 단계다.

이 단계에서 이미지 크기를 줄이지 않으면,
전달 단계와 렌더링 단계에서 아무리 최적화를 해도
불필요한 비용이 계속 발생하게 된다.

사용자가 업로드한 이미지는 다음 과정을 거쳐 전처리된다.

- 필요한 크기로 리사이즈

- WebP 포맷으로 변환

- 적절한 품질로 압축

이 과정을 통해 저장되는 이미지 자체를 가볍게 만들었다.

 

이때 모든 이미지가 항상 최적화되는 것은 아니다.

변환 과정에서 오히려 용량이 증가하거나,

처리에 실패하는 경우도 존재하기 때문에 다음과 같은 조건을 추가했다.

- 변환 실패 시 → 원본 유지

- 변환 후 용량 증가 시 → 원본 유지

이를 통해 최적화 과정이 항상 이득이 되도록 보장했다.

 


적용 내용

  • 리사이즈 (예: 256x256) // 프로젝트 이미지 크기에 맞추어 설정한 값
  • WebP 변환
  • 품질 조정

처리 구조 


💡 핵심 포인트

이미지 처리 작업은 CPU 비용이 높은 작업이다.

이를 메인 스레드에서 수행하면
UI가 멈추거나 입력 지연이 발생할 수 있기 때문에,
Web Worker를 사용해 별도의 스레드에서 처리하도록 구성했다.

또한 OffscreenCanvas를 활용해 Worker 내부에서도
이미지 리사이즈와 인코딩이 가능하도록 했다.

 


 2️⃣ 전달 단계 최적화

🎯 목적

더 작고, 더 적절한 이미지를 전달한다

 

전달 단계의 핵심은
"브라우저가 어떤 이미지를 받느냐"를 결정하는 것이다.

같은 이미지라도 크기, 포맷, 압축률에 따라
네트워크 비용과 로딩 시간이 크게 달라진다.

 

Next.js의 next/image를 사용해
이미지 요청이 최적화 파이프라인을 거치도록 구성했다.

이 과정에서 다음이 자동으로 수행된다.

즉 전달 단계는 이미지가 최적화된 상태를 전달하는 파이프라인 구축 단계 이다.

- 요청 크기에 맞는 이미지 리사이즈
- 브라우저에 맞는 포맷(WebP/AVIF) 변환
- 캐싱 가능한 응답 생성


처리 구조 


핵심 전략

1. next/image 사용

next/image는 Next.js에서 제공하는 이미지 최적화 컴포넌트로,
이미지를 단순히 렌더링하는 것이 아니라 최적화 파이프라인을 거쳐 전달하도록 만든다.

기존 <img> 태그를 사용할 경우 브라우저는 주어진 URL의 이미지를 그대로 다운로드한다.
이 경우 이미지 크기나 포맷이 비효율적이어도 제어할 수 있는 방법이 없다.

반면 next/image를 사용하면

  • 요청 시점에 이미지 크기를 조정할 수 있고
  • 브라우저에 맞는 포맷으로 변환할 수 있으며
  • 동일 요청에 대해 캐시를 활용할 수 있다

next/image는 단순한 UI 컴포넌트가 아니라
이미지 전달 구조 자체를 최적화하는 진입점 역할을 한다.


2. remotePatterns 설정

next/image는 기본적으로 외부 이미지 URL을 바로 최적화하지 않는다.
특정 도메인만 허용하도록 되어 있기 때문이다.

이때 사용하는 설정이 remotePatterns다.

images: {
  remotePatterns: [
    {
      protocol: 'https',
      hostname: 'your-s3-bucket.amazonaws.com',
    },
  ],
}

이 설정의 의미는 단순히 “외부 이미지 허용”이 아니라:

“이 도메인의 이미지는 Next.js 최적화 파이프라인에 포함한다”

라는 선언에 가깝다.

이 설정이 없으면

  • 이미지가 최적화 경로를 타지 못하고
  • 원본 이미지가 그대로 전달될 수 있다

remotePatterns는
원격 이미지를 최적화 대상에 포함시키는 진입 조건이다.


3. 자동 포맷 변환 (WebP / AVIF)

 

이미지 확장자를 비교 분석한 표이다. 

여러한 점을 고려하여 Webp, AVIF 파일을 자동 포맷으로 설정하였다.


4. responsive size 적용 (sizes)

이미지는 “얼마나 큰가”보다
“화면에서 얼마나 크게 보이는가”가 더 중요하다.

예를 들어

  • 실제 이미지: 1000px
  • 화면 표시: 112px

이 경우 1000px 이미지를 내려받는 것은 낭비다.

sizes 속성은 브라우저에게 다음 정보를 전달한다.

“이 이미지는 실제로 이 정도 크기로만 렌더링된다”

<Image
  src={imageUrl}
  sizes="112px"
/>
 

이 정보를 기반으로 브라우저는

  • 더 작은 해상도의 이미지를 요청하고
  • 불필요한 데이터 다운로드를 줄인다

sizes는 단순한 옵션이 아니라
브라우저가 적절한 해상도를 선택하도록 유도하는 힌트다.


💡 중요한 포인트

전달 단계 최적화의 본질은
"이미지를 최적화하는 것"이 아니라

"원본이 그대로 전달되지 않도록 구조를 바꾸는 것"이다.


  3️⃣ 렌더링 단계 최적화

🎯 목적

브라우저가 이미지를 “더 빨리 요청”하도록 만든다

 

전달 단계에서 이미지 크기와 포맷을 최적화했다면, 렌더링 단계에서는 브라우저가 그 이미지를 얼마나 빨리 요청하고 화면에 그릴지를 최적화해야 한다.

여기서 중요한 점은 브라우저가 모든 리소스를 동일한 우선순위로 다루지 않는다는 것이다. 페이지에 들어오면 스크립트, 스타일, 폰트, 이미지 등 다양한 리소스가 동시에 로드되는데, 이 중 어떤 리소스를 먼저 요청해야 하는지 브라우저에게 더 분명하게 알려줄수록 LCP 개선에 유리하다.

그래서 LCP 후보가 되는 이미지를 대상으로 priority, fetchPriority="high", loading="eager", sizes 같은 설정을 적용했다.


적용 옵션

<Image
  src={url}
  priority
  fetchPriority="high"
  loading="eager"
  sizes="112px"
/>

 

핵심 개념

  • priority: 이 이미지가 중요한 리소스라는 신호를 준다.
  • fetchPriority="high": 네트워크 요청 우선순위를 높인다.
  • loading="eager": 지연 로딩 대신 초기 렌더링 시점에 바로 로드한다.
  • sizes: 실제 표시 크기를 알려 불필요하게 큰 이미지를 받지 않게 한다.

4️⃣ 분기 처리 전략

실제 구현에서는 모든 이미지를 하나의 방식으로 처리할 수 없었다.

편집 화면에서 다루는 이미지에는 크게 두 종류가 있었기 때문이다.

 

첫 번째는 사용자가 방금 선택한 로컬 미리보기 이미지다.

이 경우 src는 보통 blob: URL이며, 이미 브라우저 메모리에 올라와 있는 상태다.

 

두 번째는 서버에 저장된 실제 프로필 이미지다. 이 경우 src는 S3 같은 원격 URL이며, 네트워크를 통해 받아와야 한다.

 

겉보기에는 둘 다 이미지처럼 보이지만, 처리 전략은 달라야 한다.

로컬 미리보기는 즉시성이 가장 중요하므로 굳이 최적화 파이프라인을 태울 필요가 없다.

반면 서버 이미지는 네트워크 비용이 존재하므로 next/image를 통한 최적화가 유효하다.


해결

이 분기 처리의 의미는 단순히 예외 처리를 한 것이 아니다. 리소스의 출처와 목적에 따라 가장 적절한 경로를 선택한 것에 가깝다.

덕분에 로컬 미리보기의 즉시성은 유지하면서, 실제 서비스 이미지에는 전달 최적화를 적용할 수 있었다.


5️⃣ 캐싱 전략

🎯 목적

재방문 시 네트워크 요청 제거

 

캐싱 전략의 목적은 재방문 시 동일 이미지를 다시 다운로드하지 않게 하는 것이다.

다만 여기서 주의할 점은, 내가 직접 모든 Cache-Control 헤더를 세밀하게 제어했다기보다, next/image와 그 앞단의 캐시 계층이 제공하는 캐싱 특성을 활용했다는 점이다.

 

따라서 이 부분은 “캐시 헤더를 직접 설계했다”보다는 “이미지 최적화 파이프라인이 제공하는 캐시 이점을 활용했다”고 표현하는 편이 더 정확한 것 같다.

 

이 전략의 핵심은 같은 요청 조건에 대해 이미 생성된 최적화 이미지를 재사용하도록 만드는 것이다.

이렇게 하면 첫 방문에서는 최적화 비용이 들더라도, 이후에는 캐시된 결과를 활용해 더 빠르게 응답할 수 있다. 결과적으로 재방문 성능이 좋아지고, 서버와 네트워크 부담도 줄어든다.

 

캐싱 전략은 “캐시를 오래 건다”가 핵심이 아니라, 이미 한 번 만든 최적화 결과를 반복해서 재사용하게 만드는 것에 가깝다고 생각한다.


설정

Cache-Control: public, max-age=31536000, immutable

동일한 URL에 대해서는 일정 기간 동안 서버에 재요청하지 않고 브라우저 캐시를 그대로 사용하도록 하는 설정이다.  
특히 `immutable` 옵션을 통해 리소스가 변경되지 않는다고 가정하고, 재검증 요청 없이 즉시 캐시를 재사용할 수 있다.


성능 개선 결과

이번 작업을 통해 느낀 점은 이미지 최적화가 단순히 파일 하나를 압축하는 일이 아니라는 것이었다.

업로드 단계에서는 원본의 기본 비용을 줄이고,

전달 단계에서는 원본을 직접 내려보내지 않도록 구조를 바꾸고,

렌더링 단계에서는 브라우저가 중요한 이미지를 더 빨리 요청하도록 조정해야 했다.

 

결국 이미지 최적화의 핵심은 “이미지를 작게 만든다”보다 더 넓다. 이미지가 저장되고, 전달되고, 렌더링되는 전 과정을 설계하는 것에 가깝다. 이번 개선도 바로 그 흐름을 다시 설계하는 과정이었다.