들어가며
Moyo의 식당은 사용자가 각자 먹은 음식을 공유하며 자연스럽게 소통할 수 있는 공간이다.
음식 사진을 업로드하면 사이드바와 닉네임 위에 썸네일로 표현되고, 클릭하면 크게 볼 수 있다. 좋아요로 반응을 남길 수도 있다.

문제는 이미지가 많은 화면에서 로딩이 눈에 띄게 느렸다는 것이다.
병목이 어디 있는가
이미지 조회 시

이미지 업로드 시

Network 탭으로 확인해보니 이미지 응답 시간(요청~다운로드 완료 기준)은 평균 2.0초였고, 원인은 세 가지로 분석했다.
- 원인 1. presigned URL: URL이 요청마다 변경 → CDN·브라우저 캐시 무효화
- 원인 2. S3 직접 다운로드: CDN 없이 S3 origin에서 직접 전달
- 원인 3. 원본 파일 크기: JPEG/PNG를 최적화 없이 그대로 저장
presigned URL 생성 비용
Presigned URL은 S3 객체에 임시 접근 권한을 부여하는 서명된 URL이다. 별도 인증 없이 해당 URL만으로 객체에 접근할 수 있으며, 서버는 AWS SDK를 통해 요청마다 서명 연산을 수행해 URL을 생성한다.
이미지 업로드의 경우, presigned URL을 사용하면 서버 메모리를 거치지 않고 클라이언트가 S3로 직접 전송할 수 있다. 덕분에 S3 버킷을 비공개로 유지하면서도 임시 쓰기 권한만 안전하게 열어줄 수 있다. 이 구조는 업로드에는 적합했다.
문제는 조회 경로였다.
클라이언트가 이미지 목록을 요청하면 서버는 이미지 개수만큼 presigned URL을 생성해 응답에 포함시켰다. 이 URL에는 만료 시간이 포함되어 있어 동일한 이미지라도 요청마다 값이 달라진다. URL이 매번 바뀌니 CDN은 물론 브라우저 캐시도 작동하지 않았고, 모든 요청이 항상 S3 원본까지 도달했다.
처음에는 서명 생성 비용이 병목이라고 생각했지만 실제로 느린 원인은 서명 연산 자체가 아니라 캐싱이 전혀 작동하지 않는 구조적 문제였다.
파일 크기
사용자가 업로드한 JPEG/PNG 원본 이미지는 별도의 최적화 없이 그대로 S3에 저장됐다. 최대 업로드 허용 용량은 7MB였고, 실제로 수 MB 단위의 이미지가 그대로 클라이언트로 전달됐다.
즉, 수 MB의 데이터를 내려받으면서 TTFB 이후 다운로드 구간이 길어져 네트워크 전송 비용이 증가하고, 원본 해상도를 그대로 받아 불필요하게 큰 리소스를 제공하고 있는 것이 문제였다.
TTFB(Time To First Byte) 는 클라이언트 요청 후 서버에서 첫 번째 바이트를 받기까지의 시간이다. TTFB가 짧아도 파일 크기가 크면 이후 다운로드 구간이 길어져 전체 응답 시간이 늘어난다.
1단계: presigned URL → public URL
서버 부하를 줄이기 위해 조회(GET)에 고정된 public URL을 쓰는 방식으로 먼저 바꿨다.
- 업로드(PUT): presigned URL 유지 — 쓰기 권한은 인증 필요
- 조회(GET): public URL — 이미지가 공개 콘텐츠이므로 열람 인증 불필요
식당 이미지는 Moyo 공간에 있는 모든 사용자가 볼 수 있는 공개 콘텐츠다. GET에 presigned URL을 쓰는 것은 "열람 자격"을 매번 검증하는 셈인데, 이미 서비스 내에서 공개된 콘텐츠라면 그 검증이 의미 없다고 판단했다.
S3 버킷 정책으로 restaurant-images/ 경로에만 public GET을 허용하고, 다른 경로(프로필 이미지 등 사용자별 접근 제어가 필요한 파일)는 여전히 presigned URL로 보호했다.
// 이전: 요청마다 서버에서 생성 (이미지 수만큼 반복)
const url = await s3.getSignedUrlPromise("getObject", {
Bucket,
Key,
Expires: 3600,
});
// 이후: 고정 URL (동기, 즉시)
const url = `${cloudFrontBaseUrl}/${key}`;URL 생성 오버헤드는 사라졌지만 S3에서 파일을 직접 받아오는 속도는 비슷했다.
2단계: CloudFront CDN 추가
왜 CloudFront인가
1단계로 URL 생성 오버헤드는 제거됐지만, 파일은 여전히 S3에서 직접 내려왔다. 조회 속도를 더 개선하기 위한 방법을 검토했다.
| 방법 | 원리 | 비용·복잡도 | 적합도 |
|---|---|---|---|
| CloudFront CDN | 엣지에서 정적 콘텐츠 캐싱 | 낮음 | ✅ |
| Lambda@Edge | 엣지에서 함수 실행 (동적 리사이즈 등) | 높음 | ✗ |
| S3 Transfer Acceleration | 업로드 경로를 AWS 내부망으로 가속 | 낮음 | ✗ (조회 아님) |
| 서버 사이드 캐싱 | 서버가 presigned URL을 Redis에 캐싱 | 중간 | ✗ |
Lambda@Edge는 CDN 엣지에서 서버리스 함수를 실행한다. 요청마다 함수가 실행되어 동적 리사이즈나 포맷 변환도 가능하지만, 그만큼 호출당 비용이 발생하고 함수 실행 시간이 응답에 더해진다. 이미 클라이언트에서 WebP로 변환해 올리기 때문에 엣지에서 추가 처리가 필요 없었다.
서버 사이드 캐싱은 이미 1단계에서 URL 생성 자체를 없앴으니 여기선 필요 없었다.
CloudFront는 전 세계 엣지 로케이션에 S3 파일을 캐싱하는 CDN이다. 한 번 캐싱되면 이후 요청은 S3 대신 가장 가까운 엣지에서 응답한다. 별도 연산 없이 파일을 전달하기 때문에 빠르고, 서버를 거치지 않아 트래픽 부하도 없다.
현 프로젝트 구조에서 오버 엔지니어링을 하지 않도록 CloudFront를 적용했다.
조회 흐름 변화
개선 전 — presigned URL
2단계 이후 — CloudFront CDN
S3 객체에 Cache-Control: max-age=31536000을 설정해두면 브라우저도 장기 캐싱한다. 같은 이미지를 다시 열면 CloudFront는 물론 네트워크 요청 자체가 발생하지 않는다.
3단계: WebP 전환
왜 WebP인가
병목 분석에서 확인했듯 이미지 한 장이 수 MB에 달하면 다운로드 구간이 길어진다. CDN으로 전송 경로를 단축했지만, 파일 자체가 크면 한계가 있다. 포맷을 바꿔 용량을 줄이는 방법을 검토했다.
| 포맷 | 압축 방식 | 투명도 | 압축 효율 | 특징 |
|---|---|---|---|---|
| JPEG | 손실 압축 | ✗ | 보통 | 오래된 표준, 호환성 최고 |
| PNG | 무손실 압축 | ✅ | 낮음 (파일 큼) | 투명 배경 필요 시 사용 |
| WebP | 손실 + 무손실 | ✅ | JPEG 대비 ~30% 절감 | 구글 개발, 주요 브라우저 지원 |
| AVIF | 손실 + 무손실 | ✅ | WebP보다 더 작음 | 인코딩 느림, 구형 브라우저 미지원 |
AVIF는 압축률이 더 좋지만 인코딩 속도가 느리고 구형 브라우저 지원이 부족하다. 식당 이미지는 실시간으로 업로드되는 콘텐츠라 인코딩이 빠를수록 좋다.
WebP는 JPEG 대비 약 30% 작고, 투명도를 지원하며, Chrome/Firefox/Safari 등 주요 브라우저가 모두 지원한다. 압축 효율과 호환성 사이의 균형점이었다.
서버 변환 대신 클라이언트 변환
서버에서 WebP 변환(Sharp 등)을 하면 두 가지 문제가 생긴다.
- 사용자는 업로드 전 변환된 결과를 미리 볼 수 없다
- 원본이 서버를 한 번 거치므로 네트워크를 두 번 탄다 (클라이언트 → 서버, 서버 → S3)
클라이언트에서 변환하면 이미 WebP가 된 파일이 업로드용 presigned URL을 통해 S3로 바로 간다. 사용자도 업로드 전 최종 결과를 확인할 수 있고, 서버는 메타데이터 저장만 한다.
변환 파이프라인
createImageBitmap → OffscreenCanvas → convertToBlob 세 단계로 구성된다.
File → [createImageBitmap] 비동기 디코딩 → [OffscreenCanvas] 그리기 → [convertToBlob] WebP 인코딩 → File
async function optimizeImage(file: File): Promise<File> {
// 1. 비동기 디코딩 — 메인 스레드를 블로킹하지 않음
const bitmap = await createImageBitmap(file);
// 2. OffscreenCanvas에서 그리기
// 렌더링을 메인 스레드 밖에서 처리한다
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext("2d")!;
ctx.drawImage(bitmap, 0, 0);
// 3. GPU 메모리 즉시 해제 — 누수 방지
bitmap.close();
// 4. WebP 인코딩 (quality 0.75)
const blob = await canvas.convertToBlob({
type: "image/webp",
quality: 0.75,
});
return new File([blob], file.name.replace(/\.[^.]+$/, ".webp"), {
type: "image/webp",
});
}왜 Image 객체 대신 createImageBitmap + OffscreenCanvas인가
new Image()로도 캔버스에 그릴 수 있지만, 디코딩이 메인 스레드에서 동기적으로 실행된다. 파일이 클수록 UI가 잠시 멈추는 현상이 발생한다.
createImageBitmap은 디코딩을 비동기로 처리하고, OffscreenCanvas는 렌더링과 인코딩을 메인 스레드 밖에서 실행한다. 전체 파이프라인이 UI를 건드리지 않고 동작한다.
업로드는 S3에 직접 쓰는 흐름이므로 CDN을 거치지 않는다. CloudFront는 조회(GET) 경로에만 위치한다. 전체 업로드 흐름은 아래와 같다.
파생 문제: 이미지 수정 시 WebP 미전환
파이프라인을 적용한 뒤 수정 플로우에서 버그가 발견됐다. 교체한 이미지가 WebP가 아닌 원본 확장자로 올라가고 있었다.
원인은 두 코드 경로의 차이였다.
- 최초 업로드:
handleFile→validateImageFile()→optimizeImage()→ presigned URL로 업로드 ✅ - 이미지 수정:
handleFileChange→validateImageFile()만 호출 → 원본 파일 그대로 전달 ✗
validateImageFile()은 파일 확장자, MIME 타입, 크기 상한을 검사하는 입력 검증 함수다.
optimizeImage()는 검증을 통과한 파일을 WebP로 변환하는 함수다.
수정 플로우에서 검증만 하고 변환 단계를 빠뜨려 validateImageFile() 이후 optimizeImage()를 추가해 수정했다.
스켈레톤 UI: 체감 속도 개선
CloudFront와 WebP 적용으로 실제 로딩 시간은 크게 줄어들었지만, 이미지가 완전히 표시되기 전까지는 여전히 짧은 공백이 존재했다.
기술적으로는 충분히 빠른 응답이었지만 사용자 입장에서는 그 짧은 지연도 “아직 로딩 중인가?”라는 불확실함으로 인식될 수 있었다.
이를 해결하기 위해 스켈레톤 UI를 적용했다.
이미지 영역 크기만큼 미리 공간을 확보해 CLS를 방지하고, 로딩 중에도 콘텐츠가 곧 나타날 것이라는 신호를 제공해 빈 화면으로 인한 인지적 지연을 줄였다.
실제 PR 리뷰 과정에서도 “지연이 느껴지지 않는다”는 피드백을 받았고, 속도 개선뿐 아니라 사용자 경험 측면에서도 긍정적인 변화를 확인할 수 있었다.
성과
| 지표 | 개선 전 | 개선 후 |
|---|---|---|
| 이미지 조회 시간 | 2.0s (50개 기준) | 110ms (95% 개선) |
| 파일 크기 | 원본 (JPEG/PNG) | 평균 33% 감소 (WebP 변환) |
| 서버 부하 | 요청마다 presigned URL 생성 | 0 (CDN 직접 접근) |
| 캐시 활용 | 불가 (URL 만료·변경) | 브라우저 + CDN 캐시 모두 활용 |
| UX 체감 | 로딩 전 빈 화면 | 스켈레톤으로 즉각적 시각 피드백 |

마치며
이번 최적화를 통해 병목은 코드가 아니라 구조에 있을 수 있다는 걸 배웠다. presigned URL의 생성 비용이 아니라 캐시가 작동하지 않는 설계가 문제였고, 이미지 최적화 역시 단순 압축이 아니라 리소스 제공 방식을 재설계하는 작업이었다.
성능 개선은 수치로 확인할 수 있었지만 더 의미 있었던 건 계측을 기반으로 원인을 좁혀가고 구조를 바꾸는 과정이었다.
참고
PR
Wiki
공식 문서