이미지 리사이징과 WebP 포맷 변환

아더에러·2026년 3월 4일

프론트엔드

목록 보기
5/15
post-thumbnail

서비스 운영 초기에는 데이터의 수가 적기때문에, LCP가 낮게 나왔지만 데이터가 늘어나면서 메인페이지의 체감 속도가 점점 느려졌고, Lighthouse 점수도 기대 이하로 나오면서 이미지 최적화를 고려하게 되었다.

문제 상황

Lighthouse를 통해 문제를 분석해보니, 이미지 전송 개선이 필요하다는 안내를 받을 수 있었다.

표시 크기: 300×169
실제 파일 크기: 8192×4608
최신 포맷(WebP, AVIF) 사용 권장

실제 S3에 저장된 이미지 상태는 다음과 같았다.

  • 대부분 PNG 또는 고해상도 JPG
  • 파일 크기 4MB ~ 8MB 이상
  • 썸네일 영역에서는 300px 정도로만 표시
  • 백엔드는 원본 URL을 그대로 프론트에 전달

즉, 브라우저는 화면에는 300px만 표시하면서도 8192px 크기의 8MB 원본을 다운로드하고 있었다.

이미지 하나정도는 괜찮을 수 있지만, 메인페이지에는 이미지가 수십 개 있었다.

  • LCP: 4.5초
  • Lighthouse Performance: 76점
  • 네트워크 탭에서 이미지 리소스가 대부분 차지

Lighthouse를 통해 문제 해결을 위한 힌트를 얻을 수 있었다.

  1. 화면에 실제로 표시되는 크기(약 300px)에 맞는 이미지 리소스를 요청하도록 변경한다.
    → 썸네일 영역에 300px로만 노출된다면, 원본(수천 px) 대신 해당 해상도에 최적화된 이미지를 내려받도록 한다.
  2. 이미지 포맷을 WebP로 변환해 전송 용량을 줄인다.
    → 동일한 화질을 유지하면서도 PNG, JPG 대비 더 작은 파일 크기로 최적화한다.

해결 목표 설정

이미지 최적화를 위한 구체적인 목표를 세웠다.

  1. 기존 S3 이미지 496장 일괄 최적화
  2. WebP 도입
  3. 이미지 리사이즈 적용 (300 / 600 / 1200)
  4. 기존 백엔드 로직 수정 최소화
  5. AWS 비용 증가 없이 해결

최대한 인프라 복잡도를 높이지 않으며, 백엔드 로직을 수정하지 않고 해결하려고 했다.

전략 선택

항목CloudFront 동적 리사이징S3 + Lambda 트리거로컬 배치 스크립트
처리 시점요청 시점업로드 시점일괄 실행 시점
인프라 추가CloudFront + Lambda@EdgeLambda + S3 Event없음
기존 이미지 대응별도 마이그레이션 필요별도 마이그레이션 필요즉시 가능
자동화 수준높음높음낮음
설정 복잡도높음중간낮음
운영 난이도높음중간낮음
비용요청당 비용 발생실행당 비용 발생추가 비용 없음
현재 규모 적합성과함과함적절

현재 서비스 규모와 긴급도를 고려해 가장 비용 대비 효율적인 전략인 Node.js + sharp 기반 로컬 배치 스크립트를 선택했다.

설계 구조

전체 흐름은 단순하다.

로컬 실행 → S3 다운로드 → sharp 리사이즈 + WebP 변환 → S3 재업로드

기존 원본은 유지하고,
variants/ 경로에 파생본을 저장하도록 설계했다.

variants/300/xxx.webp
variants/600/xxx.webp
variants/1200/xxx.webp

이렇게 하면:

  • 원본 보존 가능
  • 점진적 전환 가능
  • 롤백 가능

아키텍처를 크게 건드리지 않으면서 개선할 수 있다.

구현 과정

1) Node.js 설치 (버전 18 이상 권장)

node -v

해당 명령어로 Node.js의 버전을 확인할 수 있다.

2) AWS CLI 설치 및 자격 증명 설정

로컬에서 S3에 접근하기 위해 AWS CLI를 설정한다.

aws configure

명령어 실행 후, 이미지가 저장된 S3 버킷이 속한 AWS 계정의 자격 증명을 입력한다.

  • AWS Access Key ID
  • AWS Secret Access Key
  • Default region name (예: ap-northeast-2)
  • Default output format (json)

region은 S3 버킷이 생성된 리전을 기준으로 설정해야 하며,
리전이 다를 경우 요청이 실패할 수 있다.

3) 작업 폴더 만들기

mkdir image-batch
cd image-batch

4) 필요한 파일 생성

이제 실제 이미지 변환을 수행할 스크립트를 구성한다.
작업 폴더(image-batch) 내부에 아래 3개의 파일을 생성한다.

파일 1: package.json

Node.js 환경에서 필요한 의존성을 정의한다.

{
  "name": "image-batch",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "aws-sdk": "^2.1400.0",
    "dotenv": "^17.2.3",
    "sharp": "^0.32.0"
  }
}

의존성 설명:

  • aws-sdk → S3 접근
  • sharp → 이미지 리사이징 및 WebP 변환
  • dotenv → 환경 변수 로딩

작성한 의존성을 npm install 명령어를 통해 설치한다.

파일 2: .env (환경 변수 설정)

S3 정보와 파생본 사이즈를 외부 설정으로 분리한다.
프로젝트 루트에 .env 파일을 생성한다.

BUCKET=example 
PREFIX=original/
SIZES=300,600,1200

설명:

  • BUCKET → 이미지가 저장된 S3 버킷 이름
  • PREFIX → 원본 이미지가 위치한 폴더 경로
  • SIZES → 생성할 썸네일 width 목록

파일 3: batch.mjs

이 파일이 S3 → 변환 → 재업로드를 수행한다.

import AWS from "aws-sdk";
import sharp from "sharp";
import dotenv from "dotenv";

dotenv.config();

const s3 = new AWS.S3();

const BUCKET = process.env.BUCKET;
const PREFIX = process.env.PREFIX;
const SIZES = process.env.SIZES.split(",").map(Number);

async function listImages() {
  const result = await s3.listObjectsV2({
    Bucket: BUCKET,
    Prefix: PREFIX
  }).promise();

  return result.Contents
    .map(obj => obj.Key)
    .filter(key =>
      key.endsWith(".png") ||
      key.endsWith(".jpg") ||
      key.endsWith(".jpeg")
    );
}

async function downloadImage(key) {
  const res = await s3.getObject({
    Bucket: BUCKET,
    Key: key
  }).promise();
  return res.Body;
}

async function uploadImage(buffer, key) {
  await s3.putObject({
    Bucket: BUCKET,
    Key: key,
    Body: buffer,
    ContentType: "image/webp",
    ACL: "public-read"
  }).promise();
}

async function convertAndUpload(key, buffer) {
  for (const size of SIZES) {
    const outBuffer = await sharp(buffer)
      .resize({ width: size, withoutEnlargement: true })
      .webp({ quality: 75 })
      .toBuffer();

    const fileName = key.replace(PREFIX, "");
    const newKey = `variants/${size}/${fileName}.webp`;

    await uploadImage(outBuffer, newKey);
    console.log(`✓ 업로드 완료: ${newKey}`);
  }
}

async function main() {
  console.log("S3 이미지 목록 조회 중...");
  const keys = await listImages();

  console.log(`총 ${keys.length}개 이미지 발견`);

  for (const key of keys) {
    console.log(`→ 처리 시작: ${key}`);

    const buffer = await downloadImage(key);
    await convertAndUpload(key, buffer);
  }

  console.log("=== 모든 작업 완료 ===");
}

main().catch(console.error);

실행 방법

1) 작업 폴더에서 실행

node batch.mjs

2) 실행 로그 예시

모든 이미지가 순차적으로 처리되며,
중간 실패 시 다시 실행해도 기존 업로드 파일은 덮어쓴다.

스크립트 실행 결과

  1. S3 original/ 경로의 이미지 목록 조회
  2. 각 이미지를 메모리 버퍼로 다운로드
  3. sharp를 사용해
    • width 300
    • width 600
    • width 1200
      로 리사이즈
  4. WebP (quality=75) 변환
  5. variants/{size}/ 경로에 업로드
  6. 전체 이미지 처리 완료 후 종료

트러블슈팅

문제 1 – S3 업로드 실패

AccessControlListNotSupported: The bucket does not allow ACLs

원인

S3 버킷이 BucketOwnerEnforced (ACL 비활성화) 모드였다.

그러나 업로드 코드에 ACL: "public-read"가 포함되어 있었다.
ACL이 비활성화된 버킷에서는 해당 옵션 자체가 허용되지 않는다.

해결

업로드 옵션에서 ACL 제거.

await s3.putObject({
  Bucket,
  Key,
  Body,
  ContentType: "image/webp"
}).promise();

문제 2 – PNG와 WebP가 섞여 표시

일부 이미지는 정상적으로 WebP로 변환되었지만, 프론트에서는 여전히 기존 PNG URL이 호출되는 현상이 발생했다.

S3에는 WebP 파일이 존재했지만, 브라우저는 해당 리소스를 찾지 못하고 PNG로 fallback되고 있었다.

원인

문제는 공백이 포함된 파일명을 srcSet에 그대로 삽입한 것이었다.

원본 이미지 중 파일명에 공백이 포함된 경우가 있었다.

ff1d9d1d-...-스크린샷_홈 (1) - mint.png

기존 코드는 이 파일명을 그대로 URL에 삽입했다.

const optimizedFile = `${basename}.webp`;
// → "ff1d9d1d-...-스크린샷_홈 (1) - mint.webp"

return `${base}/variants/${width}/${optimizedFile}`;
// → ".../variants/300/ff1d9d1d-...-스크린샷_홈 (1) - mint.webp"

이 URL이 <source srcSet=...>에 삽입되면 문제가 발생한다.

<source srcSet=".../variants/300/ff1d9d1d-...-스크린샷_홈 (1) - mint.webp" type="image/webp" />

srcSet은 공백을 구분자로 사용하는 속성이다. 브라우저가 srcSet을 파싱할 때 공백을 기준으로 URL을 잘라버리기 때문에, 유효하지 않은 경로가 되어 WebP 로드에 실패하고 <img src>의 PNG로 fallback된다.

해결

공백을 %20으로 치환해 srcSet이 URL을 올바르게 파싱하도록 수정했다.

const optimizedFile = `${basename}.webp`.replaceAll(" ", "%20");

수정 후 모든 이미지가 정상적으로 WebP로 로드되었고, PNG fallback 현상은 완전히 사라졌다. 전체 함수는 다음과 같다.

export const getOptimizedImageUrl = (originalUrl: string, width: number) => {
  if (originalUrl === "") return "";
  if (!Number.isInteger(width) || width <= 0) return originalUrl;

  const filename = originalUrl.split("/").pop() || "";

  const dotIndex = filename.lastIndexOf(".");
  if (dotIndex === -1) return originalUrl;

  const ext = filename.substring(dotIndex).toLowerCase();

  const isOptimizable = [".png", ".jpg", ".jpeg"].includes(ext);
  if (isOptimizable === false) {
    return originalUrl;
  }

  const basename = filename.substring(0, dotIndex);
  const optimizedFile = `${basename}.webp`.replaceAll(" ", "%20");

  const base = process.env.S3_BASE_URL;
  if (base === undefined) return originalUrl;

  return `${base}/variants/${width}/${optimizedFile}`;
};

수정 후 모든 이미지가 정상적으로 WebP로 로드되었고, PNG fallback 현상은 완전히 사라졌다.

파일 시스템의 Key 문자열과 브라우저가 실제로 요청하는 URL 문자열은 동일하지 않다. 특히 공백이나 특수문자가 포함된 파일명을 다룰 때 이 차이를 명확히 이해하지 않으면, S3, CDN, 브라우저 환경에서 예상치 못한 404나 리소스 불일치가 발생할 수 있다.

변환된 이미지 활용

최적화된 WebP 이미지를 우선 제공하되, 지원하지 않는 브라우저에서는 기존 이미지를 fallback 하도록 <picture>를 사용했다.

<S.CardImageBox aria-hidden="true" $isLoaded={isImageLoaded}>
  <picture>
    {thumbnailUrl && !hasError && (
      <source
        srcSet={getOptimizedImageUrl(thumbnailUrl, 300)}
        type="image/webp"
      />
    )}

    <S.CardImage
      src={imageSrc}
      onError={handleImageError}
      onLoad={handleImageLoad}
      alt={`${title} 프로젝트 썸네일`}
      $isLoaded={isImageLoaded}
    />
  </picture>
</S.CardImageBox>

동작 원리

브라우저는 <picture> 내부에서 <source>의 type을 확인하고
현재 브라우저가 해당 포맷을 지원하면 srcSet 사용한다.

지원하지 않으면 <img> 태그의 src 사용한다.

WebP 지원 브라우저 → variants/300/*.webp
미지원 브라우저 → 기존 PNG/JPG

이 방식은 JS 조건 분기 없이 브라우저 네이티브 기능으로 포맷 변환을 수행한다.

최종 결과

성능 개선

이미지 전송 용량 8,608 KiB → 80.9 KiB로 99.1% 감소

항목개선 전개선 후
Lighthouse7693
LCP4.5초1.6초

이미지 최적화 하나만으로도 초기 렌더링 체감 속도가 완전히 달라졌다.

마무리

이번 작업을 통해 이미지 최적화가 사용자 체감 속도에 직결된다는 점을 분명히 느낄 수 있었다.
LCP는 단순 코드 문제가 아니라, 가장 큰 리소스를 얼마나 효율적으로 전달하느냐의 문제라는 것도 배울 수 있었다.

초기에는 큰 문제가 아니라고 생각했던 고해상도 원본 이미지가
서비스가 커지면서 성능 부채로 돌아왔고, 이를 정리하는 과정이었다.

문제 해결 전략이 여러가지 있을 때, 현재 규모에 맞는 선택이 중요하다는 점을 배울 수 있었다.

이번에는 로컬 배치로 기존 이미지를 처리했지만, 앞으로는 새로운 이미지 업로드 시 자동 변환 구조를 적용해보고자 한다.

1개의 댓글

comment-user-thumbnail
2026년 3월 21일

저도 비슷한 경험을 했어서 되게 흥미롭게 읽었네요!
저희 프로젝트는 이미지가 사용자로부터 지속적으로 업로드되는 구조라 AWS + Lamda 자동화 방식을 채택했는데, 글을 읽으면서 로컬 배치스크립트는 어떻게 적용할 수 있는지 많은 인사이트를 얻을 수 있었네요. 다음에 적용하게 된다면 많은 참고가 될 것 같아요! 잘 보고 갑니다 :)

답글 달기