S3 이미지 업로드 성능 트러블슈팅 ‐ Presigned URL 도입 - fitpassTeam/fitpass GitHub Wiki
S3 이미지 업로드 성능 트러블슈팅 - Presigned URL 도입
문제 상황
발생한 문제
유저 이미지를 업로드 및 체육관 이미지 업로드하는 로직에서 이미지 업로드 성능 저하 및 서버 리소스 비효율성 문제가 발생
증상 및 현상
- 업로드 속도 저하: 클라이언트 → 서버 → S3의 이중 전송으로 인한 지연
- 서버 리소스 과다 사용: CPU, 메모리, 네트워크 대역폭의 불필요한 소모
- 확장성 문제: 동시 업로드 시 서버 부하 급증
- 병목 현상: 서버 인스턴스의 대역폭이 업로드 속도 제한 요소로 작용
발생 시점
사용자 증가와 함께 이미지 업로드 빈도가 높아지면서 성능 이슈가 점진적으로 드러남
원인 분석
기존 아키텍처의 문제점
주요 원인:
- 불필요한 중계 구조: 서버가 단순히 이미지 데이터를 S3로 전달하는 역할만 수행
- 이중 네트워크 전송: 클라이언트→서버, 서버→S3 두 번의 전송 과정
- 서버 리소스 낭비: 파일 버퍼링과 전송을 위한 CPU, 메모리 사용
- 동시성 한계: 서버의 처리 능력에 따른 업로드 수 제한
성능 측정 결과 (문제 상황)
- 서버 처리 시간: 210ms (파일 수신 + S3 업로드)
- HTTP 요청 시간: 126ms
- 서버 부하: 높음 (CPU, 메모리, 네트워크 대역폭)
해결 과정
1단계: 문제 정의 및 가설 수립
가설: "S3 Presigned URL을 사용하면 서버 부하를 줄이면서 업로드 성능을 개선할 수 있을 것이다"
2단계: 해결 방안 검토
선택한 기술: S3 Presigned URL
선택 이유:
- AWS Native Solution으로 추가 인프라 불필요
- 임시 URL을 통한 보안적 업로드
- 클라이언트의 AWS 글로벌 네트워크 직접 접근
- 기존 시스템과의 호환성 유지
3단계: 구현
아키텍처 변경:
핵심 구현 코드:
S3Service 확장
// 새로운 Presigned URL 방식 추가
public String generatePresignedUrl(String originalFilename, String contentType) {
// 파일 형식 검증
if (!isAllowedFileType(originalFilename)) {
throw new BaseException(ExceptionCode.INVALID_FILE_TYPE);
}
String fileName = UUID.randomUUID().toString() + getExtension(originalFilename);
Date expiration = new Date(System.currentTimeMillis() + 5 * 60 * 1000); // 5분
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucketName, fileName)
.withMethod(HttpMethod.PUT)
.withExpiration(expiration)
.withContentType(contentType);
return amazonS3.generatePresignedUrl(request).toString();
}
private boolean isAllowedFileType(String filename) {
String[] allowedExtensions = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"};
return Arrays.stream(allowedExtensions)
.anyMatch(filename.toLowerCase()::endsWith);
}
Controller API 추가
@GetMapping("/presigned-url")
public ResponseEntity<ResponseMessage<Map<String, String>>> getPresignedUrl(
@RequestParam("filename") String filename,
@RequestParam("contentType") String contentType
) {
String presignedUrl = s3Service.generatePresignedUrl(filename, contentType);
String fileName = s3Service.extractFileNameFromUrl(presignedUrl);
Map<String, String> response = new HashMap<>();
response.put("presignedUrl", presignedUrl);
response.put("fileName", fileName);
response.put("contentType", contentType);
response.put("expiresIn", "300");
return ResponseEntity.status(SuccessCode.S3_PRESIGNED_URL_GENERATED.getHttpStatus())
.body(ResponseMessage.success(SuccessCode.S3_PRESIGNED_URL_GENERATED, response));
}
S3 CORS 설정
[{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
"AllowedOrigins": ["*"],
"ExposeHeaders": ["ETag"]
}]
4단계: 테스트 및 검증
- HTML 테스트 페이지를 통한 직접 비교 테스트
- 브라우저 네트워크 탭과 서버 로그를 통한 성능 측정
- 기존 API와의 호환성 확인
해결 결과
성능 개선 결과
측정 관점 | 기존 방식 | Presigned URL | 개선율 |
---|---|---|---|
서버 처리 시간 | 210ms | 3ms | 70배 빠름 |
HTTP 요청 시간 | 126ms | 12ms | 10배 빠름 |
서버 부하 | 높음 | 매우 낮음 | 대폭 감소 |
실제 서버 로그
- 기존 방식: 210ms (파일 수신 + S3 업로드 포함)
- Presigned URL 생성: 3ms (URL 생성만)
실제 네트워크 로그
주요 개선 효과
-
성능 향상
- 서버 처리 시간: 210ms → 3ms
- 클라이언트가 AWS 글로벌 네트워크로 직접 업로드
-
서버 리소스 절약
- CPU: 이미지 처리 부하 제거
- 메모리: 파일 버퍼링 불필요
- 네트워크: 서버 대역폭 사용량 최소화
-
확장성 개선
- 동시 업로드 수 제한 해제
- 서버 확장 없이도 더 많은 업로드 처리 가능
-
시스템 안정성
- 이미지 업로드가 다른 API에 미치는 영향 최소화
- 전체 시스템 응답성 개선
트러블슈팅 과정에서 겪은 어려움
발생한 이슈들
-
CORS 설정 문제
- 문제: S3 CORS 설정에서 PUT 메서드 누락으로 업로드 실패
- 해결: CORS 설정에 PUT 메서드 추가 및 적절한 헤더 설정
-
파일 형식 검증 누락
- 문제: 초기 구현에서 파일 형식 검증 부재
- 해결:
isAllowedFileType()
메서드로 허용된 이미지 확장자만 업로드 가능하도록 제한
-
Presigned URL 만료 시간 설정
- 문제: 적절한 만료 시간 설정의 고민
- 해결: 5분으로 설정하여 보안과 사용성의 균형 확보
추가 개선 방안
현재 미흡한 부분
에러 처리 강화
- 네트워크 실패, S3 장애 등 예외 상황 대응 로직 보완 필요
- 클라이언트 측 업로드 실패 시 재시도 메커니즘 구현
핵심 학습 포인트
문제 해결 접근법
- 아키텍처 관점에서의 접근: 단순 튜닝보다 시스템 구조 개선이 더 효과적
- 점진적 도입 전략: 기존 API 유지하며 새 방식 병행 적용으로 안정성 확보
- AWS 관리형 서비스 활용: 기존 인프라 내에서 새로운 기능 발견 및 활용
성능 최적화 원칙
- 불필요한 중계 제거: "이 작업을 정말 서버가 해야 할까?" 라는 근본적 질문
- 측정 기반 개선: 체감이 아닌 실제 수치로 개선 효과 검증
- 호환성 고려: 기존 시스템을 건드리지 않는 안전한 개선 방식 선택
향후 적용 가능한 패턴
- 다른 파일 업로드 기능에도 동일한 패턴 적용 검토
- 대용량 파일 처리 시 서버 부하 분산 방안으로 활용
- 마이크로서비스 아키텍처에서 각 서비스의 책임 분리 원칙 적용