썸네일 생성했는데 왜 느리지? Next.js 병목 현상 해결기
썸네일 생성했는데 왜 느리지? Next.js 병목 현상 해결기
moseoh
aws nextjs

썸네일 생성했는데 왜 느리지? Next.js 병목 현상 해결기

moseoh · 2025년 07월 30일

이전 포스팅에서 파일 업로드 서비스에 썸네일 생성 시스템을 도입했는데, 기대와 달리 이미지 로딩 속도가 여전히 느린 문제가 있었습니다.

구체적인 증상:

  • 254KB 크기의 이미지를 불러오는데 1초 이상의 TTFB(Time To First Byte) 발생

  • 동일한 환경에서 13KB 이미지는 100ms 이내로 빠르게 응답

  • 썸네일을 생성했는데도 왜 이미지 로딩이 느릴까? 환경 정보:

  • 접속 위치: 서울

  • AWS 리전: ap-northeast-2 (서울)

  • S3 Storage Class: Standard

  • ECS 서버 리소스: 0.25 CPU 0.5RAM 처음엔 당연히 S3의 응답 속도 문제라고 생각했습니다. 썸네일을 만들어뒀는데도 느리다니, S3에서 뭔가 일어나고 있는 게 분명했죠.

원인 찾아보기

먼저 테스트하기 위한 이미지를 준비해서 제가 생각할 수 있는 문제를 하나씩 검증해 가며 원인을 찾아가 보려고 합니다.

준비된 이미지:

  • 작은 객체: 13KB
  • 큰 객체: 254KB

가설 1: 용량이 크기 때문에 응답이 느리다?

작은 객체(13KB)와 큰 객체(254KB)의 응답 시간을 비교해보니 확실히 차이가 있었습니다:

작은 객체

큰 객체

하지만 AWS 공식 문서에 따르면 S3 Standard Class는 첫 바이트까지 밀리초 단위 액세스를 보장합니다. 254KB 정도의 파일이 1초나 걸린다는 건 뭔가 이상했죠.

가설 2: S3 암호화(SSE-KMS) 때문에 복호화 시간이 오래 걸린다?

S3 버킷 설정을 확인해보니 SSE-S3 암호화를 사용 중이었습니다. SSE-KMS와 달리 SSE-S3는 성능에 거의 영향이 없다는 걸 확인하고 이 가설도 기각했습니다.

가설 3: S3 Cold Start 또는 객체 손상?

혹시 객체 자체에 문제가 있을까 싶어 동일한 파일을 복사해서 테스트했습니다:

큰 객체 (원본)

큰 객체 (사본)

추가 문제

테스트하는 도중 발견한 것인데, 새로고침을 여러 번 하니 응답 속도가 더 길어지고 있었습니다.

Image

작은 객체 마저 큰 객체를 불러올때와 비슷해졌습니다.

작은 객체 (91ms → 1370ms)

큰 객체

이쯤 되니 S3가 원인이 아닐 수도 있다는 의심이 들기 시작했습니다.

가설 4: S3 Hot Spotting?

새로고침하는 과정에서 S3 문제가 아니라는 느낌이 들었지만, 마지막으로 파일 경로를 버킷 루트로 옮겨봤습니다:

  • AS-IS: <bucket-name>/some/prefix/file.webp
  • TO-BE: <bucket-name>/file.webp 큰 객체

결과는 동일했습니다. 사실 저희 버킷에는 객체가 3천 개 이하라 Hot Spotting이 발생하기엔 너무 적었죠.

문제 원인 발견

S3가 아니라면 뭐가 문제일까? 브라우저 개발자 도구를 다시 열어보니 힌트가 있었습니다:

Request URL: /_next/image?url=https://...&w=256&q=75

처음에는 S3의 응답 속도 문제로 생각했으나, 진짜 원인은 Next.js 서버의 이미지 최적화 기능이었습니다.

  • 동작 방식: 브라우저는 Next.js 서버(/_next/image)에 이미지를 요청합니다. 그러면 Next.js 서버는 S3에서 원본 이미지를 다운로드한 후, 자체 CPU를 사용해 이미지를 최적화(리사이징, 압축)하여 브라우저에 전송합니다.
  • 병목 지점: 사양이 낮은 ECS 서버에서 이 최적화 과정에 과도한 CPU 자원이 소모되면서, 서버 응답 시간(TTFB)이 길어지는 지연 현상이 발생했습니다. Next.js 이미지 최적화 동작 방식
  1. 브라우저가 /_next/image 엔드포인트로 이미지 요청
  2. Next.js 서버가 S3에서 원본 이미지 다운로드
  3. 서버 CPU를 사용해 이미지 리사이징/압축 수행
  4. 최적화된 이미지를 브라우저에 전송

재현 및 검증

ECS 서버의 저사양 환경을 로컬에서 재현하기 위해 Docker의 리소스 제한 기능을 사용했습니다. 또한 <Image> 컴포넌트에 unoptimized 설정을 통해 Next.js 의 이미지 최적화 성능을 on/off 하여 성능을 비교해보았습니다.

테스트 환경

-cpus="0.1" -memory="100m" 옵션으로 CPU와 메모리를 매우 낮게 제한한 컨테이너를 실행했습니다.

docker run -p 3000:3000 --env-file .env --cpus="0.1" --memory="100m" web

재현 결과

unoptimized=false (최적화 활성화) 상태에서 254KB 이미지를 요청했을 때, ECS 서버와 유사한 1.27초의 응답 지연을 로컬에서 동일하게 재현하는 데 성공했습니다.

<Image unoptimized={false}/>

<Image unoptimized={true}/>

동일한 리소스 제한 환경에서 응답 시간(TTFB)이 21.79ms** **으로 단축되었습니다. 이는 서버의 작업 없이 S3에서 조회한 결과를 바로 반환했기 때문에 S3 Standard Class가 보장하는 지연시간으로 도달한 결과 입니다.

해결 방안

원인을 찾았으니 여러가지 해결책을 검토하였습니다. 저는 그중 이미지 로드 시점 동적 이미지 변환 아키텍처 도입을 선택하였습니다.

  1. EC2 서버 스펙 업그레이드

    • 장점: 즉시 해결 가능, 별도 개발 없음
    • 단점: 비용 증가, 근본적 해결책 아님, 트래픽 증가 시 또 업그레이드 필요
  2. Next.js 이미지 최적화 비활성화

    • 장점: 서버 부하 완전 제거, 코드 한 줄로 해결
    • 단점: 클라이언트가 원본 이미지 다운로드하여 대역폭 낭비, 원본 이미지 크기에 따라 사용자 경험 저하
  3. Next.js Image Loader 커스터마이징

    • 장점: Next.js 생태계 유지, CDN URL로 직접 연결 가능
    • 단점: Next.js 내부 동작 이해 필요, 버전 업데이트 시 호환성 리스크
  4. 이미지 로드 시점 동적 이미지 변환 아키텍처 도입 ✅

    • 장점: 서버리스로 무한 확장 가능, On-demand 변환으로 효율적, CDN 캐싱 활용
    • 단점: 초기 구축 복잡도, AWS 서비스 조합 필요

이미 시도했던 방법과 실패 이유

저는 이러한 문제를 예상하고 이전 포스팅에서 S3 업로드 시점에 썸네일을 생성하는 방식을 구현했었습니다. Lambda를 통해 여러 크기의 썸네일을 미리 생성해서 S3에 저장하는 방식이었죠. 하지만 완전히 빗나간 설계였습니다.

실패 이유:

  • Next.js의 <Image> 컴포넌트는 미리 생성된 썸네일을 또한번 자체 최적화 수행
  • 다양한 디바이스 크기에 대응하려면 수십 개의 썸네일을 미리 생성해야 함(심지어 한장의 썸네일만 생성했음)
  • 여러장의 썸네일을 생성했더라면 사용되지 않는 파일 관리도 수행했어야 함

최종 선택

여러가지 해결책과 이전 경험을 토대로 적용할 방식을 선택하였습니다. 저는 백엔드 인프라 개발자라 Next.js를 깊게 커스터마이징하기는 부담스러웠습니다. 프론트엔드 프레임워크의 내부 동작을 완벽히 이해하고 수정하는 것보다는, 제가 잘 아는 인프라 레벨에서 해결하는 게 더 안전하다고 판단했죠.

동적 이미지 변환 솔루션을 검토하던 중 두 가지 옵션이 있었습니다

  1. 외부 이미지 최적화 서비스 (Cloudinary, Imgix 등)
  • 월 $89~$299의 구독료 발생
  • 추가적인 인프라 관리 필요
  • 데이터가 외부 서비스를 경유하는 보안 우려
  1. AWS Dynamic Image Transformation for Amazon CloudFront
  • 즉시 사용 가능한 CloudFormation 템플릿 제공 (30분 만에 구축!)
  • Lambda 실행 비용만 발생 (월 $5 미만)
  • 모든 인프라가 우리 AWS 계정 내에서 관리됨 더군다나 저희는 글로벌 서비스 확장을 대비해 CloudFront 도입을 이미 계획하고 있었습니다. CDN을 통한 캐싱과 함께 이미지 최적화까지 한 번에 해결할 수 있으니 일석이조였죠.

Dynamic Image Transformation for Amazon CloudFront

Image

AWS에서 제공하는 CloudFormation 템플릿은 다음과 같은 구성요소들을 포함합니다:

  1. Client → CloudFront Function: 클라이언트 요청을 받아 URL을 파싱하고 변환 파라미터 추출
  2. CloudFront → API Gateway: 캐시 미스 시 오리진으로 요청 전달
  3. API Gateway → Lambda: 실제 이미지 변환 작업 트리거
  4. Lambda → S3: 원본 이미지 조회
  5. Lambda → AWS Secrets Manager: 서명 검증용 비밀키 관리
  6. Lambda → Amazon Rekognition: 스마트 크롭, 얼굴 인식 기반 자동 크롭 (선택사항)
  7. CloudFront Function: 응답 헤더 조작 및 캐싱 정책 적용 이 템플릿은 매우 강력하지만, 저희 서비스에는 과도한 기능들이 포함되어 있었습니다.

실제 적용한 간소화 아키텍처

Image

저는 필요한 핵심 기능만 남기고 아키텍처를 간소화했습니다:

간소화된 구성:

  1. 최초 요청 (Cache Miss)

    • Client → CloudFront → API Gateway → Lambda → S3
    • Lambda가 S3에서 원본을 가져와 실시간으로 리사이징/최적화 수행
    • 변환된 이미지를 CloudFront로 반환 및 캐싱
  2. 이후 요청 (Cache Hit)

    • Client → CloudFront (캐시된 이미지 즉시 반환)
    • 백엔드 호출 없이 엣지에서 직접 응답

제거한 구성요소:

  • CloudFront Function: URL 파싱은 Lambda에서 직접 처리
  • AWS Secrets Manager: 서명 검증 불필요 (Private S3 버킷 사용)
  • Amazon Rekognition: 스마트 크롭 기능 미사용 이렇게 간소화한 이유는:
  1. 복잡도 감소: 관리 포인트를 최소화하여 운영 부담 감소
  2. 비용 절감: 불필요한 서비스 호출 제거
  3. 성능 최적화: 호출 체인을 단순화하여 레이턴시 감소

수행 결과

이제 큰 객체에서도 30ms 내외의 응답속도를 얻을 수 있었습니다. 이 포스팅에서는 300kb 를 큰 객체라고 표현했지만 10mb 이상의 파일에서도 Cache Hit 이후에서 30ms 내외의 동일한 결과를 얻을 수 있었습니다.

큰 객체

이로써 저희 서비스는 아래와 같은 이점을 얻을 수 있었습니다.

  • 사용자 경험 대폭 개선: 이미지 로딩이 거의 즉시 이루어짐
  • 서버 안정성 향상: 월 $5 비용으로, 이미지 처리로 인한 서버 부하가 완전히 제거됨
  • 자동 확장성: 트래픽 증가 시 Lambda가 자동으로 스케일링

끝나지 않은 문제

CloudFront URL은 이미지 뷰어용으로는 완벽했지만, 원본 파일 다운로드 기능에는 사용할 수 없다는 문제를 발견했습니다.

문제 상황:

  • CloudFront의 Dynamic Image Transformation은 뷰어 전용으로 설계됨
  • 항상 변환된 이미지(리사이징, WebP 포맷 등)를 반환
  • 사용자가 “원본 다운로드” 버튼 클릭 시 변환되지 않은 원본 파일이 필요
  • 하나의 URL로 뷰어와 다운로드를 모두 처리할 수 없음 해결 방법:

기존에 사용하던 S3 Presigned URL을 버리지 않고 용도별로 URL을 분리하기로 했습니다:

{
"url": "https://s3-presigned-url", <-- 다운로드 원본 파일 URL
"thumbnailUrl": "https://cloudfront-url" <-- 뷰어 CDN URL
}

이렇게 기존 인프라를 재활용하면서도 각 용도에 최적화된 URL을 제공할 수 있었습니다. S3 Presigned URL 생성 로직은 이미 구현되어 있었기 때문에, 추가 개발 없이 CloudFront URL만 추가하면 되는 효율적인 해결책이었죠.

결과적으로:

  • 뷰어 성능: CloudFront를 통한 빠른 썸네일 로딩
  • 다운로드 기능: 원본 파일 그대로 제공
  • 개발 효율성: 기존 코드 최대한 재활용 한 번에 모든 문제를 해결하려 하지 않고, 각 기능에 맞는 최적의 도구를 선택하여 해결할 수 있었습니다.

마치며

처음엔 “썸네일 생성했는데 왜 느리지?”라는 단순한 의문에서 시작했습니다. S3 설정부터 시작해서 암호화, Cold Start, Hot Spotting까지… 정말 다양한 토끼굴을 파고들었죠. 알고 보니 답은 눈앞에 있었습니다. URL에 /_next/image라고 떡하니 적혀있었는데, S3만 의심하고 있었던 거죠.

이번 경험을 통해 배운 가장 중요한 교훈은:

“문제를 해결하려면 먼저 시스템이 어떻게 동작하는지 정확히 이해해야 한다”

초기 서비스를 배포하면서 나중에 썸네일 생성이나 CDN 도입 같은 기능을 추가하면 당연히 빨라질 거라고 막연히 기대했었습니다. 만약 이번에 “왜?”라는 질문을 던지지 않았다면, 나중에 CDN을 도입하고 서버 리소스를 늘리면서 우연히 문제가 해결되었을 겁니다. 하지만 근본 원인을 모른 채 넘어갔다면, 비슷한 문제를 또 만났을 때 똑같은 삽질을 반복했겠죠.

결과적으로 1,300ms → 13ms라는 극적인 성능 개선을 이뤄냈고, 더 중요한 건 시스템 전체를 깊이 이해하게 되었다는 점입니다. 이 문제는 기능 구현 업무가 바쁜 직장인이라면 못 본 척할 수 있었겠지만, 저는 내일 퇴사자라 여유가 좀 있었네요.”