1MB 이미지 하나로도 전체 저장 흐름이 최대 8초 지연되며, 여기에 스토리 저장 등 다른 플로우도 한 단계로 함께 묶여 있어 사용자 입장에서는 "버튼을 눌러도 아무 반응이 없는" 듯한 심각한 체감 지연으로 이어졌다.
기존에는 NCP의 Object Storage를 활용해, 클라이언트의 이미지 업로드 요청을 서버가 직접 처리하는 구조였다. 서버는 요청이 들어올 때마다 이미지를 메모리에 올리고, 그걸 Object Storage로 업로드하는 방식이었는데, 개발 환경이나 테스트 단계에서는 큰 무리가 없었다.
하지만 런칭 당일, 사용자 유입이 폭발적으로 늘면서 이미지 업로드 요청이 동시에 몰리는 상황이 발생했고, 서버의 메모리 사용량과 IO 부하가 기하급수적으로 증가했다. 특히 썸네일 이미지를 서버 메모리에 올려 crop하는 작업은 리소스를 과도하게 소모했고, 이로 인해 전체 저장 흐름에 큰 병목이 발생했다.
우리 서비스는 영상 편집 후 저장 버튼을 누르면 썸네일 이미지 저장 뿐만 아니라 시도 등록, 스토리 등록 등 다양한 플로우가 묶여있어, 이 병목 현상은 단순한 지연이 아니라 전체 저장 로직의 지연으로 사용자에게 체감되었다
(실제 런칭 피드백에서도 "저장이 느리다"는 지적이 가장 많이 들어왔다.)
이에 따라 우리는 이미지 저장소를 NCP Object Storage에서 AWS S3로 이전했으며, 더불어 이미지 업로드와 처리 방식을 전면 개편했다.
S3는 글로벌 인프라를 기반으로 한 높은 처리 성능과 안정성을 제공하며, Presigned URL을 통한 클라이언트 직접 업로드 방식을 공식적으로 지원한다. 여기에 AWS CloudFront를 연동하면 이미지 제공 속도도 획기적으로 개선할 수 있어, 성능과 사용자 경험을 모두 고려했을 때 더 나은 선택이라고 판단했다.
업로드 방식도 다음과 같이 최적화했다:
즉, 서버는 인증과 Presigned URL 발급만 담당하고, 실제 이미지 데이터 처리와 업로드는 모두 클라이언트에서 수행되도록 구조를 전면 개편한 것이다.
이를 통해 서버 부하를 획기적으로 줄이고, 업로드 속도는 물론 전체 저장 흐름까지 크게 개선할 수 있었다.
특히 썸네일 crop 작업을 클라이언트로 분산시킴으로써, 서버의 메모리 사용과 CPU 연산 부담을 과감하게 줄이는 데 성공했다.
런칭 당일, 이미지 업로드 요청이 폭주하면서 서버가 급격히 느려졌던 이유는 단순히 업로드 트래픽 때문이 아니었다.
서버가 이미지 데이터를 직접 핸들링하고, 추가로 썸네일 crop 작업까지 수행하고 있었기 때문에 병목이 복합적으로 발생한 것이다.
클라이언트가 이미지를 업로드하면, Spring 서버가 그걸 MultipartFile
로 받는다
이때 이미지 크기에 따라 다음과 같이 처리된다:
이미지 크기 | 처리 방식 |
---|---|
작으면 (1~2MB) | 메모리에 바로 저장 (byte[] ) |
크면 | 임시 파일로 저장 (/tmp ) → 디스크 사용 |
이 기준은 spring.servlet.multipart.file-size-threshold
로 설정되며, 별도 설정이 없다면 기본값은 0 → 즉, 모든 파일을 임시 디스크에 저장하게 된다.
BufferedImage
형태로 decode이 과정은 CPU와 메모리 연산을 동시에 많이 잡아먹는다.
특히 동시 업로드가 몰리면, 이미지 하나당 수십~수백 MB의 Heap이 잠식되며,
GC 빈도가 증가하고 응답 속도는 급격히 느려진다.
Spring Boot 기본 설정에 따라, 모든 업로드 파일이 /tmp
디렉토리에 저장되며
요청이 몰리면 디스크 쓰기 경쟁이 심해지고 응답 지연이 발생한다.
IO 처리량이 낮은 환경에서는 이 현상이 훨씬 심각해짐.
서버가 직접 Object Storage로 이미지를 전송하던 구조라,
업로드 트래픽이 몰리면 서버 Outbound 네트워크가 포화됨.
NIC 대역폭이 꽉 차면, 이미지 업로드뿐 아니라 전체 API 응답이 지연된다.
위의 I/O, 메모리, CPU 병목이 복합적으로 작용하면서
런칭 트래픽이 몰린 시간대 기준으로, 하나의 영상 저장 트랜잭션을 APM에서 추적해 보았다.
이 요청은 단순 업로드뿐만 아니라, 썸네일 이미지 처리(crop) → 저장까지 포함된 전체 흐름이다.
항목 | 소요 시간 | 비율 | 해석 |
---|---|---|---|
S3 putObject | 1386ms | 35.5% | 서버가 직접 S3로 이미지 전송 → 명백한 병목 |
S3 putObjectAcl | 110ms | 2.8% | Presigned 방식이면 불필요한 요청 |
DB 처리 전체 | 56ms | <2% | DB는 병목 없음 |
나머지 시간 (~2300ms) | 약 59% | 이미지 crop 처리, GC, I/O wait | 썸네일 가공(crop), Heap 사용 증가, 디스크 I/O, Thread 블로킹 등이 복합적으로 발생 |
총 트랜잭션 시간은 약 3.9초(3900ms)였고, 이 중 절반 이상이 이미지 처리(crop + encode)와 업로드 작업에 집중되어 있었다.
트랜잭션 병목이 전체 서버 성능에 미친 영향은 다음과 같다:
/tmp
에 저장되며 동시 요청 시 경쟁트래픽 폭주
↓
썸네일 crop + 이미지 업로드 요청 폭주
↓
서버 메모리 + CPU + 네트워크 병목
↓
Thread 블로킹 + GC 증가 + 응답 지연
↓
전체 저장 요청 지연
↓
사용자 체감: "저장이 너무 느리다"
단순히 서버가 S3 업로드를 담당해서 느려진 게 아니라, 썸네일 이미지 처리(crop)까지 서버에서 수행하면서 복합적인 리소스 병목이 발생한 것이다.
이처럼 이미지 업로드와 처리 작업이 서버 리소스를 직접 잠식하는 구조에서는,
단순한 리소스 튜닝이나 서버 스펙 업그레이드만으로는 한계가 있었다.
근본적인 해결책은
👉 "서버가 이미지 파일을 직접 처리하지 않는 구조"로 완전히 바꾸는 것이었다.
기존 구조에서는 다음과 같은 작업이 모두 서버 안에서 일어났다:
/tmp
에 임시 저장BufferedImage
로 decode → crop → re-encode이 과정에서 디스크, 메모리, CPU, 네트워크까지 모든 리소스를 동시에 사용했고,
요청이 몰리는 순간 전체 시스템이 병목에 빠질 수밖에 없는 구조였다.
우리는 이 문제를 구조적으로 해결하기 위해, 업로드 방식을 완전히 새롭게 구성했다.
바로 AWS S3의 Presigned URL 기능을 활용하고, 썸네일 crop 작업까지 클라이언트에서 처리하는 방식이다.
Presigned URL이란 S3 객체에 대해 일시적 업로드 권한이 포함된 URL이다.
이 URL을 통해 서버를 거치지 않고 클라이언트가 직접 S3에 업로드할 수 있다.
서버의 역할은 단 하나. “클라이언트에게 이 URL을 안전하게 발급해주는 것 (인증된 사용자만)”
항목 | 변경 전 | 변경 후 |
---|---|---|
썸네일 처리 위치 | 서버 (메모리에 올려 crop) | 클라이언트에서 crop 후 업로드 |
이미지 업로드 방식 | 클라이언트 → 서버 → S3 | 클라이언트 → S3 (Presigned URL) |
서버 역할 | 이미지 수신 + crop + 저장 + 전송 | Presigned URL 발급만 |
서버 리소스 부담 | 매우 높음 (CPU, 메모리, 디스크, 네트워크) | 매우 낮음 |
/tmp
쓰기 사라짐용도 | 정책 위치 | 설명 |
---|---|---|
읽기 (CloudFront 전용) | S3 버킷 정책 | CloudFront만 S3 객체 접근 허용 (s3:GetObject ) |
업로드 권한 (서버 전용) | IAM 사용자/역할 정책 | 서버(Spring)가 Presigned URL 생성 시 s3:PutObject 사용 가능해야 함 |
클라이언트 업로드 권한 | 없음 | Presigned URL 자체에 서명 포함 → 별도 정책 불필요 |
@Component
class AwsS3PresignedClient(
@Value("\${aws.s3.bucket-name}") private val bucketName: String,
@Value("\${aws.s3.region}") private val region: String,
@Value("\${aws.s3.access-key}") private val accessKey: String,
@Value("\${aws.s3.secret-key}") private val secretKey: String,
@Value("\${aws.cloudfront.domain}") private val cloudFrontDomain: String,
) {
private val s3Client: AmazonS3 = AmazonS3ClientBuilder.standard()
.withRegion(region)
.withCredentials(AWSStaticCredentialsProvider(BasicAWSCredentials(accessKey, secretKey)))
.build()
fun generatePresignedPutUrl(objectKey: String, contentType: String): String {
val expiration = Date(System.currentTimeMillis() + 1000 * 60 * 10)
val request = GeneratePresignedUrlRequest(bucketName, objectKey)
.withMethod(HttpMethod.PUT)
.withExpiration(expiration)
.withContentType(contentType)
return s3Client.generatePresignedUrl(request).toString()
}
fun getCloudFrontUrl(objectKey: String): String {
return "https://$cloudFrontDomain/$objectKey"
}
}
개선 항목 | 효과 |
---|---|
서버 → 클라이언트로 이미지 처리 위임 | 서버 메모리 사용량 급감, CPU 연산 병목 제거 |
서버에서 직접 업로드 제거 | 네트워크 Outbound 부하 제거, NIC 포화 방지 |
/tmp 디스크 사용 제거 | 디스크 I/O 병목 해소, 컨테이너 환경에서도 안정적 |
Presigned URL 도입 | 업로드 흐름 단순화, 인증과 권한 관리 명확화 |
CloudFront 연동 | 전 세계 어디서든 빠른 이미지 로딩 가능 |
사용자 피드백 | "저장 안 됨", "버튼 멈춤" → 완전 해소 |
이번 구조 개편은 단순히 업로드 속도를 높이기 위한 작업이 아니었다. 서버의 병목 원인을 정확히 분석하고, 클라이언트와 클라우드 인프라의 역할을 최적화하는 방식으로 설계를 전환한 결과다.
서버가 처리하던 무거운 작업을 분산시킴으로써 성능, 유지보수, 사용자 경험까지 동시에 개선할 수 있었다.
서버는 Presigned URL만 발급. 썸네일 처리, 업로드, 이미지 가공은 클라이언트가 직접. 보여줄 땐 CloudFront로 빠르게!