저장 버튼 눌렀는데 멈춘 줄 알았어요...

호기성세균·2025년 4월 10일
0

Project

목록 보기
18/18

1MB 이미지 하나로도 전체 저장 흐름이 최대 8초 지연되며, 여기에 스토리 저장 등 다른 플로우도 한 단계로 함께 묶여 있어 사용자 입장에서는 "버튼을 눌러도 아무 반응이 없는" 듯한 심각한 체감 지연으로 이어졌다.


문제 발생과 해결

기존에는 NCP의 Object Storage를 활용해, 클라이언트의 이미지 업로드 요청을 서버가 직접 처리하는 구조였다. 서버는 요청이 들어올 때마다 이미지를 메모리에 올리고, 그걸 Object Storage로 업로드하는 방식이었는데, 개발 환경이나 테스트 단계에서는 큰 무리가 없었다.

하지만 런칭 당일, 사용자 유입이 폭발적으로 늘면서 이미지 업로드 요청이 동시에 몰리는 상황이 발생했고, 서버의 메모리 사용량과 IO 부하가 기하급수적으로 증가했다. 특히 썸네일 이미지를 서버 메모리에 올려 crop하는 작업은 리소스를 과도하게 소모했고, 이로 인해 전체 저장 흐름에 큰 병목이 발생했다.

우리 서비스는 영상 편집 후 저장 버튼을 누르면 썸네일 이미지 저장 뿐만 아니라 시도 등록, 스토리 등록 등 다양한 플로우가 묶여있어, 이 병목 현상은 단순한 지연이 아니라 전체 저장 로직의 지연으로 사용자에게 체감되었다
(실제 런칭 피드백에서도 "저장이 느리다"는 지적이 가장 많이 들어왔다.)

이에 따라 우리는 이미지 저장소를 NCP Object Storage에서 AWS S3로 이전했으며, 더불어 이미지 업로드와 처리 방식을 전면 개편했다.

S3는 글로벌 인프라를 기반으로 한 높은 처리 성능과 안정성을 제공하며, Presigned URL을 통한 클라이언트 직접 업로드 방식을 공식적으로 지원한다. 여기에 AWS CloudFront를 연동하면 이미지 제공 속도도 획기적으로 개선할 수 있어, 성능과 사용자 경험을 모두 고려했을 때 더 나은 선택이라고 판단했다.

업로드 방식도 다음과 같이 최적화했다:

  • 클라이언트는 서버를 거치지 않고 S3에 직접 이미지 업로드
  • 썸네일 crop 작업 또한 서버가 아닌 클라이언트에서 직접 처리
  • 서버(Spring)는 오직 Presigned URL 발급만 담당
  • 이미지는 S3에 저장되고, CloudFront를 통해 캐싱된 이미지가 사용자에게 전달

즉, 서버는 인증과 Presigned URL 발급만 담당하고, 실제 이미지 데이터 처리와 업로드는 모두 클라이언트에서 수행되도록 구조를 전면 개편한 것이다.

이를 통해 서버 부하를 획기적으로 줄이고, 업로드 속도는 물론 전체 저장 흐름까지 크게 개선할 수 있었다.

특히 썸네일 crop 작업을 클라이언트로 분산시킴으로써, 서버의 메모리 사용과 CPU 연산 부담을 과감하게 줄이는 데 성공했다.


🧐 기존 이미지 업로드 로직, 왜 느려졌을까?

런칭 당일, 이미지 업로드 요청이 폭주하면서 서버가 급격히 느려졌던 이유는 단순히 업로드 트래픽 때문이 아니었다.
서버가 이미지 데이터를 직접 핸들링하고, 추가로 썸네일 crop 작업까지 수행하고 있었기 때문에 병목이 복합적으로 발생한 것이다.

"이미지를 메모리에 올린다"는 게 뭔데?

클라이언트가 이미지를 업로드하면, Spring 서버가 그걸 MultipartFile로 받는다

이때 이미지 크기에 따라 다음과 같이 처리된다:

이미지 크기처리 방식
작으면 (1~2MB)메모리에 바로 저장 (byte[])
크면임시 파일로 저장 (/tmp) → 디스크 사용

이 기준은 spring.servlet.multipart.file-size-threshold로 설정되며, 별도 설정이 없다면 기본값은 0 → 즉, 모든 파일을 임시 디스크에 저장하게 된다.

그리고 문제가 된 썸네일 crop 처리

  • 서버에서는 업로드된 이미지 파일을 메모리에 다시 올려 BufferedImage 형태로 decode
  • 이후 썸네일 영역을 crop하고, 다시 압축(encode) 해서 바이트 배열로 변환
  • 마지막으로 Object Storage(NCP)에 업로드

이 과정은 CPU와 메모리 연산을 동시에 많이 잡아먹는다.

특히 동시 업로드가 몰리면, 이미지 하나당 수십~수백 MB의 Heap이 잠식되며,
GC 빈도가 증가하고 응답 속도는 급격히 느려진다.


🔍 런칭 때 서버가 느려졌던 이유, 이렇게 보면 된다

디스크 I/O 병목

Spring Boot 기본 설정에 따라, 모든 업로드 파일이 /tmp 디렉토리에 저장되며
요청이 몰리면 디스크 쓰기 경쟁이 심해지고 응답 지연이 발생한다.

IO 처리량이 낮은 환경에서는 이 현상이 훨씬 심각해짐.

메모리 + CPU 병목 (썸네일 처리)

  • 썸네일 crop 시, JVM Heap에 이미지를 올리고 처리
  • 병렬 요청이 몰리면 GC + 컨텍스트 스위칭 비용이 증가
  • 결국 서버 전체가 느려지고 다른 API까지 영향을 받게 됨

네트워크 트래픽 포화

서버가 직접 Object Storage로 이미지를 전송하던 구조라,
업로드 트래픽이 몰리면 서버 Outbound 네트워크가 포화됨.

NIC 대역폭이 꽉 차면, 이미지 업로드뿐 아니라 전체 API 응답이 지연된다.

GC 및 Thread 블로킹

위의 I/O, 메모리, CPU 병목이 복합적으로 작용하면서

  • Thread들이 파일 처리/네트워크 대기 중 블로킹 상태 진입
  • GC가 자주 발생하며 CPU 사용률 증가
  • 결국 전체 시스템 처리량이 저하되고, 사용자 응답도 늦어짐

📉 업로드 병목 원인 분석: APM 기반 트랜잭션 해석

런칭 트래픽이 몰린 시간대 기준으로, 하나의 영상 저장 트랜잭션을 APM에서 추적해 보았다.
이 요청은 단순 업로드뿐만 아니라, 썸네일 이미지 처리(crop) → 저장까지 포함된 전체 흐름이다.

항목소요 시간비율해석
S3 putObject1386ms35.5%서버가 직접 S3로 이미지 전송 → 명백한 병목
S3 putObjectAcl110ms2.8%Presigned 방식이면 불필요한 요청
DB 처리 전체56ms<2%DB는 병목 없음
나머지 시간 (~2300ms)약 59%이미지 crop 처리, GC, I/O wait썸네일 가공(crop), Heap 사용 증가, 디스크 I/O, Thread 블로킹 등이 복합적으로 발생

총 트랜잭션 시간은 약 3.9초(3900ms)였고, 이 중 절반 이상이 이미지 처리(crop + encode)와 업로드 작업에 집중되어 있었다.

서버 리소스에 미친 영향

트랜잭션 병목이 전체 서버 성능에 미친 영향은 다음과 같다:

  • 디스크 I/O: 업로드된 이미지가 /tmp에 저장되며 동시 요청 시 경쟁
  • 메모리: 이미지 처리 중 큰 크기의 byte 배열 및 이미지 객체 적재
  • CPU: crop 작업 및 S3 업로드 시 encode 연산이 CPU 사용률 상승 유도
  • GC & Thread 블로킹: Heap 압박으로 인한 GC 증가, 처리 대기 증가

정리하면 이런 흐름이 된다:

트래픽 폭주
  ↓
썸네일 crop + 이미지 업로드 요청 폭주
  ↓
서버 메모리 + CPU + 네트워크 병목
  ↓
Thread 블로킹 + GC 증가 + 응답 지연
  ↓
전체 저장 요청 지연
  ↓
사용자 체감: "저장이 너무 느리다"

단순히 서버가 S3 업로드를 담당해서 느려진 게 아니라, 썸네일 이미지 처리(crop)까지 서버에서 수행하면서 복합적인 리소스 병목이 발생한 것이다.


그러면 서버가 이미지 파일을 다루지 않도록 만들자!

이처럼 이미지 업로드와 처리 작업이 서버 리소스를 직접 잠식하는 구조에서는,
단순한 리소스 튜닝이나 서버 스펙 업그레이드만으로는 한계가 있었다.

근본적인 해결책은
👉 "서버가 이미지 파일을 직접 처리하지 않는 구조"로 완전히 바꾸는 것이었다.

기존 구조에서는 다음과 같은 작업이 모두 서버 안에서 일어났다:

  • 클라이언트로부터 이미지 수신 (Multipart 처리)
  • 디스크 /tmp에 임시 저장
  • BufferedImage로 decode → crop → re-encode
  • NCP에 업로드

이 과정에서 디스크, 메모리, CPU, 네트워크까지 모든 리소스를 동시에 사용했고,
요청이 몰리는 순간 전체 시스템이 병목에 빠질 수밖에 없는 구조였다.

❗️ 그래서 도입한 Presigned URL 방식 + 클라이언트 crop

우리는 이 문제를 구조적으로 해결하기 위해, 업로드 방식을 완전히 새롭게 구성했다.
바로 AWS S3의 Presigned URL 기능을 활용하고, 썸네일 crop 작업까지 클라이언트에서 처리하는 방식이다.
Presigned URL이란 S3 객체에 대해 일시적 업로드 권한이 포함된 URL이다.
이 URL을 통해 서버를 거치지 않고 클라이언트가 직접 S3에 업로드할 수 있다.
서버의 역할은 단 하나. “클라이언트에게 이 URL을 안전하게 발급해주는 것 (인증된 사용자만)”


구조 변화 요약

항목변경 전변경 후
썸네일 처리 위치서버 (메모리에 올려 crop)클라이언트에서 crop 후 업로드
이미지 업로드 방식클라이언트 → 서버 → S3클라이언트 → S3 (Presigned URL)
서버 역할이미지 수신 + crop + 저장 + 전송Presigned URL 발급만
서버 리소스 부담매우 높음 (CPU, 메모리, 디스크, 네트워크)매우 낮음
  • 디스크 I/O 제거/tmp 쓰기 사라짐
  • 메모리 사용 감소 → 이미지 객체 decode 안 함
  • 네트워크 트래픽 감소 → 서버가 이미지 바이트 전송 안 함
  • S3 ACL 요청 제거 → Presigned URL에 권한 포함됨

🔧 이제 구현해보자!

S3 버킷 + 업로드 권한 설정

용도정책 위치설명
읽기 (CloudFront 전용)S3 버킷 정책CloudFront만 S3 객체 접근 허용 (s3:GetObject)
업로드 권한 (서버 전용)IAM 사용자/역할 정책서버(Spring)가 Presigned URL 생성 시 s3:PutObject 사용 가능해야 함
클라이언트 업로드 권한없음Presigned URL 자체에 서명 포함 → 별도 정책 불필요

백엔드(Spring) - Presigned URL 발급

  • Amazon SDK 사용
  • URL 발급 시 버킷 이름, 객체 키, Content-Type, 만료 시간 명시
  • Spring 코드 예시:
@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"
    }
}
  • 이 URL을 클라이언트에 응답으로 보내줌

전체 흐름 다시 정리

  1. 사용자가 썸네일 업로드 시도
  2. 서버에 presigned URL 요청 (파일이름, 콘텐츠 타입 담아서 요청)
  3. 서버는 해당 경로로 5분짜리 업로드 URL 발급 후 클라이언트에 응답
    • Presigned URL 생성
    • 이미지 파일명 기반 URL 생성 (예: CloudFront + S3 키)
    • 이 URL을 DB에 선 저장
    • Presigned URL + 이미지 접근 URL을 클라이언트에 응답
  4. 클라이언트는 직접 S3에 PUT 요청
  5. 이미지 출력은 CloudFront URL로

이렇게 개선해서 얻은 효과

개선 항목효과
서버 → 클라이언트로 이미지 처리 위임서버 메모리 사용량 급감, CPU 연산 병목 제거
서버에서 직접 업로드 제거네트워크 Outbound 부하 제거, NIC 포화 방지
/tmp 디스크 사용 제거디스크 I/O 병목 해소, 컨테이너 환경에서도 안정적
Presigned URL 도입업로드 흐름 단순화, 인증과 권한 관리 명확화
CloudFront 연동전 세계 어디서든 빠른 이미지 로딩 가능
사용자 피드백"저장 안 됨", "버튼 멈춤" → 완전 해소

마무리

이번 구조 개편은 단순히 업로드 속도를 높이기 위한 작업이 아니었다. 서버의 병목 원인을 정확히 분석하고, 클라이언트와 클라우드 인프라의 역할을 최적화하는 방식으로 설계를 전환한 결과다.
서버가 처리하던 무거운 작업을 분산시킴으로써 성능, 유지보수, 사용자 경험까지 동시에 개선할 수 있었다.

서버는 Presigned URL만 발급. 썸네일 처리, 업로드, 이미지 가공은 클라이언트가 직접. 보여줄 땐 CloudFront로 빠르게!

profile
공부...열심히...

0개의 댓글