저희 서비스는 사용자가 저장한 이미지를 본인만 조회할 수 있도록 S3 Presigned URL 방식을 사용하고 있었습니다. 인증된 사용자에게만 만료 기한이 있는 URL을 발급해 보안을 유지하는 구조였습니다. 그런데 코드를 살펴보니 이미지 조회 과정에서 비효율이 있었습니다. 사용자가 이미지를 조회할 때마다 보유한 이미지 수만큼 서버에서 Presigned URL을 생성하고 있었고, 동일한 이미지를 다시 조회해도 매번 새로운 URL을 발급하고 있었습니다.
이번 글에서는 이 문제를 해결하기 위해 기존 S3 기반 이미지 조회 구조를 CloudFront Signed Cookie, Lambda 리사이징, CloudFront 캐싱 구조로 개선한 과정을 정리합니다. S3로만 이미지를 조회하다가 CloudFront와 Lambda를 도입하려는 분들에게, 구조 변경의 이유와 적용 과정, 그리고 개선 결과를 함께 공유하고자 합니다.
1. 기존 구조 및 문제점
1.1 기존 구조 및 문제점
이미지 조회 기존 구조는 다음과 같았습니다.

- 클라이언트 조회 요청
- 서버: DB 조회 + presigned URL 생성 (이미지 N장 × N회)
- 클라이언트: presigned URL로 S3에서 이미지 직접 다운로드
여기서 두 가지 문제가 있었습니다.
1. 문제 1 - 조회마다 presigned URL 재생성
기존 구조에서는 이미지를 조회할 때마다 서버에서 S3 Presigned URL을 새로 발급했습니다. 이미지 접근 권한을 제한하기 위해 만료 시간이 있는 URL을 사용하는 방식이었지만 같은 이미지를 다시 조회해도 매번 서버 호출과 서명 연산이 반복되는 문제가 있었습니다.
특히 그리드 화면처럼 여러 이미지를 한 번에 조회하는 경우, 이미지 개수만큼 Presigned URL을 생성해야 하므로 서버 부담이 커질 수 있었습니다.
2. 문제 2 - 그리드 조회 시 원본 크기 이미지 전체 다운로드
클라이언트는 Presigned URL로 S3 원본 이미지를 다운로드해 화면에 표시했습니다. 즉, 수 MB 크기의 원본 이미지가 그대로 전달되고 있었습니다. 이로 인해 이미지 크기가 클수록 로딩 시간이 길어졌고, 이미지마다 표시 시점이 달라져 사용자 경험이 일정하지 않은 문제가 발생했습니다.
1.2 문제 2번에 대한 측정
이미지 크기에 따른 S3 조회 속도를 정확히 확인하기 위해 Flutter에서 presigned URL로 이미지를 로드하는 시간을 직접 측정했습니다.
측정 결과
| 이미지 크기 | p50 | p95 | p99 |
| Small (1.0MB) | 317ms | 775ms | 969ms |
| Medium (2.3MB) | 745ms | 1,738ms | 1,842ms |
| Large (10.1MB) | 677ms | 2,185ms | 2,875ms |
p50 기준으로 이미지 크기가 클수록 다운로드 시간이 늘어났습니다. 더 주목할 점은 p95입니다. Small은 p50 317ms에서 p95 775ms로 2.4배 벌어지고, Large는 p50 677ms에서 p95 2,185ms로 3.2배 벌어집니다. 원본 크기가 클수록 응답 시간의 분산도 함께 커지는 구조를 정확하게 알 수 있었습니다.
2. 개선 1 - 조회할 때마다 서버를 거치지 않고 최적화된 이미지 제공
저는 이미지 조회 과정에서 발생하는 두 가지 비효율을 줄이고 싶었습니다. 첫 번째는 이미지 개수만큼 Presigned URL을 반복 발급해야 하는 서버 부담이고 두 번째는 화면에는 썸네일만 필요함에도 원본 이미지를 그대로 다운로드하는 클라이언트 측 부담이었습니다.
그래서 문제를 단순히 “이미지를 어떻게 가져올 것인가”가 아니라, “서버의 서명 연산을 줄이면서도 화면 크기에 맞는 이미지만 전달할 수 있는 구조”로 다시 생각해보았습니다.
이를 해결하기 위해 먼저 문제를 두 방향으로 나누어 접근했습니다.
- 이미지 접근 권한을 유지하면서도 서버 호출을 줄이는 것이었습니다. 기존처럼 이미지별로 URL을 매번 발급하는 방식이 아니라, 한 번 인증된 사용자가 일정 시간 동안 허용된 이미지 경로에 접근할 수 있는 구조가 필요하다고 판단했습니다. 다만, 이미지가 외부에 공개되어서는 안 되기 때문에 URL을 알고 있더라도 인증되지 않은 사용자는 접근할 수 없는 방식이어야 했습니다.
- 이미지 자체의 용량을 줄이고, 화면 크기에 맞는 이미지만 전달해 로딩 속도를 개선하는 것이었습니다. 기존에는 썸네일 화면에서도 원본 이미지를 그대로 다운로드했기 때문에, 실제 화면에 필요한 것보다 훨씬 큰 이미지가 전달되고 있었습니다. 이미지를 화면 크기에 맞게 리사이즈하고, 전송 용량도 줄여 더 빠르게 응답할 수 있는 구조가 필요했습니다.
먼저 기존 S3 Presigned URL 방식 안에서 해결할 수 있는 방법을 검토했습니다.
- 클라이언트에서 발급받은 Presigned URL을 일정 시간 캐싱해두는 방법도 생각했습니다. 하지만 Presigned URL은 만료 시간이 있고 다시 발급될 때마다 URL 값이 달라질 수 있습니다. 결국 클라이언트는 URL이 유효한지 확인하거나 만료된 경우 다시 서버에 요청해야 했습니다. 따라서 클라이언트 캐싱만으로는 이미지 조회 전에 서버를 거쳐야 하는 구조를 근본적으로 해결하기 어렵다고 판단했습니다.
- 또한 S3에 썸네일 이미지를 미리 생성해두는 방법도 생각했습니다. 이 방식은 원본 이미지 다운로드 문제를 줄일 수 있지만, 사용되지 않을 크기의 이미지까지 미리 생성해야 하고 저장 용량도 증가한다는 단점이 있었습니다.
결국 S3 Presigned URL 방식만으로는 서버 호출을 줄이면서도 필요한 크기의 이미지만 효율적으로 전달하기 어렵다고 판단했습니다.
그래서 이미지 조회의 진입점을 S3가 아니라 CDN으로 옮기는 방향을 생각했습니다.
- CDN을 사용하여 반복적으로 조회되는 이미지를 사용자와 가까운 위치에서 빠르게 전달할 수 있습니다. 이를 통해 이미지 로딩 시간뿐만 아니라 사용자가 화면에서 느끼는 대기 시간도 줄일 수 있다고 판단했습니다.
- 또한 CDN은 단순히 이미지를 빠르게 전달하는 캐시 계층이 아니라, 사용자에게 이미지를 제공하는 새로운 진입점이 됩니다. 따라서 이미지 접근 제어도 S3가 아닌 CDN 계층에서 처리할 수 있다고 판단했습니다.
또한 이미지 용량을 줄이고 화면 크기에 맞게 변환하는 작업은 서버에서 직접 처리하기보다, 필요할 때만 실행되는 서버리스 방식으로 분리하는 것이 적합하다고 판단했습니다.
- 이미지 리사이즈는 모든 요청에서 항상 필요한 작업이 아니라, 캐시된 결과가 없을 때만 수행되면 된다고 생각을 하였습니다. 항상 실행 중인 서버에 리사이즈 부담을 주기보다는, 캐시 미스 시점에만 별도의 함수가 실행되어 이미지를 변환하는 구조가 더 효율적이라고 생각했습니다.
이러한 기준을 바탕으로 AWS에서 제공하는 CloudFront와 Lambda를 활용하는 방향을 검토했습니다.
- CloudFront는 이미지 조회의 진입점 역할을 하면서 캐싱을 통해 반복 요청을 빠르게 처리할 수 있고, Lambda는 캐시 미스 시점에만 실행되어 원본 이미지를 필요한 크기로 변환할 수 있습니다.
- 또한 CloudFront에서 제공하는 Signed Cookie를 사용하면, 이미지 URL을 알고 있더라도 인증되지 않은 사용자는 접근할 수 없도록 제한할 수 있습니다. 이를 통해 등록된 사용자와 허용된 기기만 CloudFront를 통해 이미지를 조회할 수 있는 구조를 만들 수 있다고 판단했습니다.
→ 이 구조를 사용함으로써 운영 서버에 비효율적인 요청을 없앨 수 있고, 이미지 크기와 사용자의 기기 화면에 맞게 줄여 속도가 개선될 것이라고 생각했습니다.
아키텍처를 만들어보았습니다.

이미지 조회 시 서버를 거치지 않고 Secure Storage에 저장된 쿠키를 첨부해 CloudFront에 직접 요청합니다. 쿠키가 만료된 경우 서버에 재발급을 요청한 뒤 자동으로 재시도하며, 캐시 미스 시 Lambda가 S3 원본을 가져와 리사이즈한 결과를 CloudFront가 캐싱합니다.
2.2 AWS 보안
1. S3 직접 접근 차단
- S3 버킷은 퍼블릭 액세스를 차단하고, CloudFront OAC(Origin Access Control)를 통해서만 접근할 수 있도록 구성했습니다. 따라서 사용자가 S3 객체 URL을 직접 알고 있더라도 S3로 직접 요청하면 AccessDenied가 반환됩니다.
- 또한 이미지 조회 요청은 CloudFront를 통해서만 처리되며, CloudFront에서는 Signed Cookie를 검증해 인증된 사용자만 이미지에 접근할 수 있도록 구성했습니다.
2. Lambda 직접 접근 차단
- 리사이즈를 수행하는 Lambda Function URL에는 AWS_IAM 인증을 적용했습니다. 여기에 CloudFront Lambda OAC를 연결해, CloudFront가 SigV4 서명 요청을 보낼 때만 Lambda를 호출할 수 있도록 했습니다.
3. CloudFront Signed Cookie 인증
- CloudFront의 리사이즈 경로, 기본 접근 경로에 Viewer access restriction을 적용했습니다. 따라서 Signed Cookie가 없는 요청은 CloudFront 단계에서 차단됩니다. 사용되는 쿠키는 아래 3개입니다.
CloudFront-Policy → 어떤 경로에, 언제까지 접근 가능한지 적힌 정책
CloudFront-Signature → 위 Policy가 서버가 만든 진짜 정책인지 증명하는 서명값
CloudFront-Key-Pair-Id → 서명 검증에 사용할 공개키 ID
쿠키 운영 정책은 다음과 같이 잡았습니다.
- TTL: 15분
- 저장 위치: Flutter Secure Storage
- 만료 처리: 이미지 요청 시 403이 발생하면 쿠키를 재발급한 뒤 한 번 재시도
- 로그아웃 처리: 저장된 Signed Cookie 삭제
2.3 이미지 다운로드의 문제
조회 구조를 CloudFront와 리사이즈 방식으로 바꾸고 나니, 이번에는 다운로드 쪽에서 문제가 생겼습니다. 기존에는 하나의 presigned URL로 조회와 다운로드를 모두 처리하고 있었습니다. 이미지를 화면에 보여줄 때도, 외부로 공유하거나 저장할 때도 같은 URL을 사용했습니다.
그런데 조회용 URL을 CloudFront 리사이즈 URL로 바꾸면서 이 URL로 다운로드를 시도하면 400px로 리사이즈된 WebP 파일이 내려왔습니다. 화면에 작게 표시할 때는 충분하지만 사용자가 이미지를 외부로 공유하거나 저장할 때는 원본 화질이 필요했습니다.
생각해보니 조회와 다운로드는 애초에 요구사항이 달랐습니다.
- 조회 → 화면 크기에 맞는 작은 이미지면 충분 (리사이즈)
- 다운로드 → 원본 화질 그대로 필요 (원본)
그래서 두 경로를 분리하기로 했습니다. 조회는 Lambda를 거치는 리사이즈 경로를 사용하고, 다운로드는 S3 원본을 그대로 가져오는 경로를 사용하도록 했습니다.
- 조회 → Lambda 리사이즈 → WebP
- 다운로드 → S3 원본 그대로
다운로드 경로 역시 CloudFront를 통하기 때문에 S3에 직접 접근하지 않고 조회와 동일하게 Signed Cookie로 인증하도록 했습니다.
3. 개선 후 재측정
개선 후에도 기존 측정과 동일하게 Small, Medium, Large 3개의 이미지를 기준으로 Flutter에서 이미지 로드 시간을 측정했습니다.
테스트 이미지는 다음과 같습니다.
- Small: 1.0MB 원본 이미지
- Medium: 2.3MB 원본 이미지
- Large: 10.1MB 원본 이미지
CloudFront는 캐시 특성상 첫 요청과 이후 요청의 동작이 다르기 때문에, 두 상태를 구분해서 측정했습니다.
- 캐시 미스: CloudFront에 캐시가 없어 Lambda가 원본을 읽어 리사이즈하는 첫 요청
- 캐시 히트: 리사이즈 결과가 CloudFront에 캐싱된 이후의 요청
3. 측정 결과 및 개선 전 비교
| 구분 | 원본 크기 | 개선 후 전송 크기 | S3 p50 | CF 캐시 히트 p50 | p50 기준 감소율 |
| Small | 1.0MB | 13.4KB | 317ms | 45ms | 85.8% 감소 |
| Medium | 2.3MB | 22.3KB | 745ms | 40ms | 94.6% 감소 |
| Large | 10.1MB | 32.9KB | 677ms | 38ms | 94.4% 감소 |
각 이미지별로 웜업 5회 후 100회 반복 측정한 결과입니다.S3 전체 누적 기준 p50 538ms에서 CloudFront 캐시 히트 p50 45ms로, p50 기준 약 12배 개선됐습니다. 전송 크기도 크게 줄었습니다. 기존에는 1.0~10.1MB 원본 이미지를 그대로 전송했지만, 개선 후에는 Lambda에서 400px 리사이즈 및 WebP 변환을 거쳐 13.4~32.9KB로 전송됩니다.
3.1 분석
가장 두드러진 변화는 응답 시간의 안정성입니다. S3는 이미지 크기가 클수록 p95가 폭발적으로 벌어집니다. Large 기준으로 p50 677ms인데 p95는 2,185ms로 3.2배 차이가 납니다. 반면 CloudFront 캐시 히트는 세 이미지 모두 p50 38~45ms, p95 62~64ms 수준으로 원본 크기와 무관하게 안정적입니다. S3에서는 원본 크기가 클수록 사용자가 체감하는 속도가 느려질 뿐 아니라 예측하기도 어려웠는데, CloudFront 캐시 히트 이후에는 그 분산 자체가 사라졌습니다.
캐시 미스 상태에서는 개선 전보다 오히려 느린 결과가 나왔습니다. CloudFront에 캐시가 없는 첫 요청에서는 Lambda가 S3 원본을 읽고 sharp로 리사이즈 및 WebP 변환을 수행해야 하기 때문입니다. 캐시 미스 측정은 각 이미지의 첫 요청 1회씩을 샘플로 기록했으며, Small 967ms, Medium 980ms, Large 2,320ms였습니다. Large의 캐시 미스가 가장 높은 건 원본 파일 크기(10.1MB)의 영향이고, Small과 Medium이 비슷한 수준으로 나온 건 Lambda가 이미 웜 상태였기 때문으로 보입니다. 즉, 이번 개선은 첫 요청 속도보다 반복 조회 성능을 개선하는 방향입니다. 한 번 리사이즈된 이미지가 CloudFront에 캐싱되면 이후 요청은 원본 크기와 무관하게 안정적인 속도로 제공됩니다. 실제 사용 환경에서는 같은 이미지를 반복 조회하는 경우가 많기 때문에 대부분의 요청이 캐시 히트로 처리됩니다.
3.2 한계점 및 보완 계획
캐시 히트 상태에서는 p50 기준 85~94% 개선 효과가 있었지만, 캐시 미스 상태에서는 Lambda 리사이즈 처리 시간이 추가되어 S3 직접 조회보다 느려지는 한계가 있습니다. 이를 보완하기 위해 Flutter 클라이언트 캐싱을 함께 적용할 계획입니다. CloudFront 캐시가 서버 측 캐시 역할을 한다면, Flutter 로컬 캐시는 사용자 기기에서 반복 조회되는 이미지를 즉시 재사용하는 역할을 합니다. 최종적으로는 다음과 같은 다층 캐시 구조를 목표로 합니다. 이를 통해 최초 요청에서 Lambda 리사이즈가 수행되더라도, 이후 동일 이미지 재조회나 화면 재진입 시에는 Flutter 로컬 캐시 또는 CloudFront 캐시를 통해 더 빠르게 이미지를 표시할 수 있습니다.
'프로젝트 > toIT' 카테고리의 다른 글
| 통합검색 성능 개선기 (0) | 2026.07.02 |
|---|---|
| Spring의 요청 처리 순서 (0) | 2026.07.02 |
| OSIV (0) | 2026.07.02 |
| 톰캣 (0) | 2026.07.02 |
| 이미지 업로드 시 서버 경유 전송 구조를 S3 직접 업로드로 전환 (1) | 2026.05.31 |