일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- CI/CD
- Redis
- 이것이 자바다
- LV0
- Lv.0
- LV03
- LV01
- docker
- LV1
- 일정관리프로젝트
- LV02
- 프로그래머스
- JPA
- 디자인 패턴
- 일정관리 프로젝트
- GIT
- mysql
- S3
- 알고리즘
- CoffiesVol.02
- Kafka
- Java
- 코테
- spring boot
- Join
- LV.02
- 포트폴리오
- SQL
- 데이터 베이스
- 연습문제
- Today
- Total
코드 저장소.
첨부파일업로드3-Presignedurl기능 고도화 본문
목차
1. 도입 배경
2. 고도화 구현 사항
3. 성능 비교: 기존 S3 직접 업로드 vs Presigned URL 방식
4. 회고 및 향후 개선점
1. 도입 배경
현재 Presigned URL 기반 구조를 도입하였지만, 다음과 같은 이슈들이 남아있었습니다.
- MIME 타입 우회 가능성
- 클라이언트에서 확장자만 검사하고 서버에서 MIME 검사를 하지 않으면, 악성 파일을 .jpg 등으로 위장하여 업로드할 수 있다는 점이 있습니다.
- 이는 보안적인 리스크로 이어질 수 있습니다. (예: XSS, 다운로드 시 실행 파일 위장 등)
- 고아 객체 문제
- 클라이언트가 Presigned URL로 S3에 파일은 업로드했지만, 최종적으로 등록 API를 호출하지 않고 종료할 경우, S3에는 미사용 파일이 남게 됨.
- 누적되면 저장 공간 낭비 및 관리 이슈가 발생.
- 예외/실패 상황 처리 미흡
- 썸네일 생성 실패, 이미지 리사이징 실패, 등록 누락 시 복원 로직이 없었음.
2. 고도화 구현 사항
이러한 사항으로 고도화를 할 목록은 아래와 같습니다.
- 파일 확장자 필터링을 MIME 타입 기반으로 더 견고하게 만들기.
- 예외가 발생을 하면 재처리 기능 + 고아객체 방지하기.
- PresignedUrl을 적용시와 기존의 S3의 기능으로 했을 경우의 성능을 측정하기.
2-1. 파일 확장자 필터링에 MINE 타입을 기반으로 체크를 하기.
기존의 로직에서는 파일 확장자(.jpg, .png 등)만을 기반으로 이미지 여부를 판별했지만, 이는 보안상 취약할 수 있습니다. 예를 들어, 확장자만 .jpg인 실행파일이나 텍스트 파일이 업로드되어 썸네일 생성 중 오류를 유발할 수 있습니다.
이를 보완하기 위해, 이미지 MIME 타입(image/jpeg, image/png 등)을 기반으로 한 유효성 검사 로직을 추가했습니다. S3에서 가져온 파일 스트림을 분석하여 실제 콘텐츠가 이미지인지 사전에 확인하고, 이미지가 아닐 경우 썸네일 생성 과정을 건너뛰도록 구성하였습니다.
if (!isImageMimeType(inputStream)) {
log.info("[섬네일 건너뜀] MIME 타입으로 확인한 결과 이미지 아님: {}", attachModel.getStoredFileName());
return CompletableFuture.completedFuture(null);
}
위의 코드를 추가해서 얻은 효과는 다음과 같습니다.
- 잘못된 파일 포맷으로 인한 ImageIO.read() 예외를 방지할 수 있습니다.
- 파일 위장 업로드 방지로 보안성을 강화합니다.
- 불필요한 썸네일 처리 및 리소스 낭비 방지할 수 있습니다.
2-2. 고아객체 문제
Presigned URL을 사용하면 클라이언트가 직접 S3에 파일을 업로드할 수 있지만, 업로드 완료 이후 서버 등록 API를 호출하지 않고 종료하거나 실패할 경우 S3에는 실제로 사용되지 않는 "고아 객체(Orphaned Object)"가 남게 됩니다.
이러한 객체들은 지속적으로 S3 스토리지를 차지하게 되어, 저장 비용 증가 및 파일 관리 혼란을 유발하게 됩니다.
이를 방지하기 위해 다음과 같은 구조를 적용하였습니다.
임시 경로 업로드 전략
public List<String> generatePreSignedUrls(List<String> fileNames) {
List<String> urls = new ArrayList<>();
for (String fileName : fileNames) {
String key = "temp/" + fileName; //임시 경로 추가.
........
urls.add(url.toString());
}
return urls;
}
- Presigned URL로 업로드되는 모든 파일은 /temp/ prefix 경로 아래 저장되도록 설정
- 예: temp/uuid_파일이름.jpg
등록 시점 이동 방식 (copy → delete)
for (String tempStoredFileName : uploadedFileNames) {
String finalStoredFileName = tempStoredFileName.replaceFirst("^temp/", "final/");
// 1. temp → final 복사
amazonS3.copyObject(bucketName, tempStoredFileName, bucketName, finalStoredFileName);
// 2. temp 삭제
amazonS3.deleteObject(bucketName, tempStoredFileName);
...........
return savedAttachModels;
}
- 클라이언트가 등록 API를 호출하면, 해당 파일을 /upload/ 또는 실제 사용 경로로 복사한 뒤 원본은 삭제
- AWS SDK의 copyObject, deleteObject 조합으로 처리
S3 Lifecycle Rule 적용
/temp/ 경로에 존재하는 파일 중, 일정 시간(예: 1일) 이상 미사용된 파일은 자동 삭제되도록 S3 Lifecycle 정책을 설정합니다.
우선은 S3로 들어가서 아래의 사진과 같이 합니다.
위의 사진에서 수명 주기 규칙 생성을 클릭
수명 주기 규칙 생성 부분에서 규칙이름, 규칙 범위, 코드에 적용했던 접두사를 작성합니다.
다음은 S3객체를 생성후 경과 일수를 적고 규칙을 생성을 합니다.
화면과 같이 나왔으면 S3에 대한 고아객체에 대한 설정이 적용이 완료가 되었습니다.
이와 같은 방법을 적용을 하면 얻는 이점은 아래와 같습니다.
- 사용되지 않는 S3 객체가 자동 정리되어 스토리지 비용 절감
- 실제 등록된 파일만 운영 경로에 남아 있어 관리 효율성 증가
- 예기치 못한 사용자 중단 상황에서도 시스템 정합성 유지
2-3.재처리 로직
마지막으로 비동기 섬네일을 생성에 실패를 했을 경우 섬네일 실패 엔티티에 저장을 하고 스케줄러를 사용해서 섬네일을 재처리를 하는 방식입니다.
catch (Exception e) {
log.error("[썸네일 생성 실패]", e);
// 예외를 던지는 대신, 실패 기록을 DB에 저장
FailedThumbnailModel failedModel = FailedThumbnailModel.builder()
.storedFileName(attachModel.getStoredFileName())
.errorMessage(e.getMessage())
.build();
failedThumbnailService.save(failedModel);
throw new AttachCustomExceptionHandler(AttachErrorCode.THUMBNAIL_CREATE_FAIL);
}
섬네일에 실패를 했을때 catch블록에서 실패 내역을 failThumbnail부분에 저장을 한 후 FailedThumbnailScheduler에서 재처리를 실행합니다.
@Scheduled(cron = "0 */5 * * * *")
public void retryFailedThumbnails() {
List<FailedThumbnailModel> failedList = failedThumbnailService.findRetryTargets(3, 20);
for (FailedThumbnailModel target : failedList) {
try {
AttachModel attach = attachService.findByStoredFileName(target.getStoredFileName());
attachService.createAndUploadThumbnail(attach);
failedThumbnailService.markResolved(target.getId());
log.info("재처리 성공: {}", target.getStoredFileName());
} catch (Exception e) {
failedThumbnailService.increaseRetryCount(target.getId(), e.getMessage());
log.warn("재처리 실패: {}", target.getStoredFileName(), e);
}
}
log.info("썸네일 재처리 스케줄러 종료 (총 {}건)", failedList.size());
}
우선 섬네일에 실패한 이력을 failThumbnail엔티티에 저장후 5분마다 스케줄러를 사용해서 재시도를 3회이하의 실패이력20개를 가져옵니다. 그 후에 다시 섬내일을 생성시키고 성공이 되면 resolved처리를 하고 만약에 실패를 하게 된 경우에는 재시도 카운터를 1개 올리는 방식으로 합니다.
여기까지 만든 로직은 아래의 사진과 같습니다.
3. 성능 비교: 기존 S3 직접 업로드 vs Presigned URL 방식
이제 마지막으로 미리 구현을 해두었던 PreSignedUrl방식과 일반적인 S3로 업로드를 하는 방식의 성능차이를 비교를 해보겠습니다.
측정을 하는 조건은 아래와 같습니다.
서버 사양 : AWS Lightsail 2vCPU / 2GB RAM
네트워크 : 동일 서버 도메인 기준(HTTPS)
업로드 방식 : Presigned URL 방식 / S3 직접 업로드 방식
테스트 방식 : 각 방식으로 5회 수동 반복 요청(Postman)
업로드 파일: 9.83KB 이미지(PNG)
모니터링 툴 : Prometheus + Grafana( Micrometer 기반 @Timed histograam 수집)
측정 기준 : 평균 응답 시간, 처리량, 95% percentile (Histogram기반)
측정을 한 결과의 이미지는 아래와 같습니다.
S3로 5회 업로드를 한 경우
그리고 다음은 Presigned Url으로 한 경우는 아래와 같습니다.
위의 지표를 가지고 아래와 같은 결과를 알 수 있습니다.
지표 | Presigned URL | 직접 업로드 |
처리량 (5분 기준) | 약 0.016 | 약 0.006 |
평균 처리 시간 (5분 기준) | 약 0.29초 | 약 0.1초 |
histogram 95% 구간 | 약 1.2초 | 약 0.1 |
1. 처리량 (Throughput)
Presigned URL > 직접 업로드
- 설명: 클라이언트가 서버를 거치지 않고 직접 S3에 PUT 요청을 보내므로, 서버 자원이 거의 소모되지 않음.
- 결과: 같은 시간 내 더 많은 요청을 감당할 수 있는 구조.
- 원인: 서버 → 클라이언트 → S3 구조로 서버 부하가 대폭 감소한 덕분.
2. 평균 처리 시간 (Latency)
Presigned URL 평균 처리시간 > 직접 업로드 평균 처리시간
- Presigned 구조:
- 클라이언트 → 서버에 URL 요청
- 클라이언트 → S3에 파일 업로드
→ 왕복 2번
- 직접 업로드 구조:
클라이언트 → 서버 → S3
→ 왕복 1번
- 결과: 평균 처리시간은 Presigned 방식이 더 높게 측정됨 (클라이언트 측 부담)
3. 95% 구간 histogram (histogram_quantile(0.95))
Presigned 방식의 tail latency(95%)가 더 큼
- 대부분 요청은 빠르게 처리되지만, 일부 요청에서 Latency가 튀는 현상이 존재
- 이는 클라이언트의 네트워크 품질, 브라우저 환경, S3 리전 등에 따라 변동될 수 있음
- 즉, 일정 구간 이상에서 불안정성 발생 가능
4. 회고 및 향후 개선점
이번 Presigned URL 고도화 작업은 단순히 기능을 붙이는 수준이 아니라, 보안·안정성·운영 효율성까지 함께 고려한 구조 개선이었다. 처음에는 단순히 “클라이언트가 S3에 직접 올리면 서버 부하를 줄일 수 있겠지” 정도로 접근했지만, 실제로 구현을 진행하면서 세부적인 고민이 훨씬 중요하다는 걸 깨달았다.
우선 가장 크게 느낀 건 보안과 안정성은 별도의 부가기능이 아니라 기본값이라는 점이다. MIME 타입 체크나 고아 객체 정리 같은 부분은 처음에는 사소해 보였지만, 실제 운영 상황에서는 치명적인 보안 사고나 데이터 불일치로 이어질 수 있는 포인트였다. 특히 /temp → /final 이동 구조와 S3 Lifecycle Rule을 함께 적용한 방식은 다른 서비스에도 그대로 참고할 만한 패턴이라고 생각한다.
또 하나의 배움은 성능을 단편적인 숫자로만 해석하면 안 된다는 점이었다. Presigned URL은 처리량은 높았지만, 평균 응답 시간은 직접 업로드보다 길게 나왔다. 겉으로만 보면 “느린 방식” 같지만, 실제로는 서버 부하를 클라이언트 쪽으로 분산해 전체 서비스 확장성을 확보한 셈이다. 이 과정을 통해 단순히 ms 단위 latency만 보고 판단하면 기술 선택을 잘못할 수도 있다는 사실을 실감했다.
마지막으로 이번 작업에서 가장 큰 수확은 관측 가능성과 자동화였다. Micrometer 기반 @Timed 메트릭을 Prometheus + Grafana로 시각화하면서, 성능 병목을 눈으로 확인할 수 있었고 단순 로그 출력과는 비교도 되지 않을 만큼 명확한 판단이 가능했다. 앞으로 어떤 기능을 추가하더라도 “데이터 기반”으로 개선 방향을 잡을 수 있다는 점이 가장 큰 자신감으로 남았다.
앞으로는 GraalVM Native Image 전환 후 메모리/처리량 최적화를 측정하고, JMeter 기반으로 100명 이상 동시 업로드 부하 테스트를 진행해볼 계획이다. 또한 PNG 이미지뿐만 아니라 PDF, 영상 등 다양한 포맷에서도 성능 차이를 검증하고, Presigned URL 만료 처리와 같은 UX적인 개선도 함께 고려할 예정이다.
'포폴 > 일정관리앱' 카테고리의 다른 글
일정추천 기능 고도화- OpenFeign에서 WebClient 전환기 (0) | 2025.07.11 |
---|---|
프로젝트 배포3- CI부분에 캐싱 적용 (1) | 2025.07.06 |
Spring 서비스의 운영 모니터링 환경 구축기2: Prometheus, Grafana, Loki, Kafka/Redis Exporter (0) | 2025.06.20 |
Spring 서비스의 운영 모니터링 환경 구축기1: Prometheus, Grafana, Loki, Kafka/Redis Exporter (0) | 2025.06.17 |
Kafka + Redis + MySQL 환경을 Testcontainers로 통합 테스트하기 (0) | 2025.06.10 |