문제 발생
프로그래머스 데브코스 팀프로젝트에서 음식점 사장이 가게 외부, 내부, 음식 사진, 메뉴판 등 여러 사진을 한번에 업로드 하는 부분을 구현하게 되었다. 처음에는 클라이언트가 주는 이미지 파일을 백엔드 서버가 전송받고 AWS S3에 업로드 하는 방식으로 구현했지만, 고용량에 사진을 여러명이 한 번에 백엔드 서버에서 S3로 업로드 하다보니 속도가 매우 저하되는 문제가 발생하였다.
원인 분석
1. 고용량 이미지 문제
- 요즘 스마트폰으로 찍는 사진이 4k~8k 해상도로 찍기가 가능하여 사진 용량이 매우 커짐
- 클라이언트에게 받은 사진을 서버가 바로 S3로 업로드하지 않고 Downscale/Resize 작업을 수행해 이미지 용량을 줄여 로딩 속도를 줄여야 함
2. 서버 부하 및 추가비용 발생
- 이미지를 최적화 하기 위해서는 JAVA에서 지원하는 java.awt.Graphics2D, Image.getScaledInstance 또는 WebP 등을 통해 리사이징 필요 -> 여전히 서버 측에서 처리하니 서버 부하 발생
- 경우에 따라 이미지를 리사이징하는 서버만 추가로 두기도 함 -> 서버 증설 비용 추가
서버 비용만 충분하다면 서버에서 리사이징하는 방법을 사용해도 되지만, 프로그래머스 측에서 EC2 서버 유형 중 프리티어를 사용하라 하여 여러 사진을 한 번에 올리는 우리 상황과 맞지 않다고 생각해 서버측 부하를 줄일 수 있는 방법을 찾아보게 되었다.
해결책
AWS S3의 Pre-Signed URL를 사용해 클라이언트측에서 S3로 이미지 업로드
장점
- 백엔드 서버측에선 이미지 업로드 요청 시 단순히 권한이 담긴 URL만 발급하기 때문에 부하가 적음
- 발급한 URL은 설정한 일정 시간이 지나가면 자동으로 만료되어 안전
단점
- URL 발급 이후 서버 측에서는 클라이언트가 올린 사진이 S3에 잘 들어갔는지 아닌지 확인하는 추가 구현이 필요
Code
1. build.gradle에 AWS SDK 의존성 추가
implementation platform("software.amazon.awssdk:bom:2.25.4")
implementation "software.amazon.awssdk:s3"
2. application.yml
aws:
credentials:
AWS_ACCESS_KEY: ${AWS_ACCESS_KEY}
AWS_SECRET_KEY: ${AWS_SECRET_KEY}
AWS_BASE_URL: ${AWS_BASE_URL} // 사진 업로드 후 경로를 DB에 저장 시 사용
region: ${AWS_REGION} // AWS 데이터 센터 지역
s3:
bucket: ${AWS_S3_BUCKET} // 어떤 버켓이 올릴 건지
path: ${AWS_S3_PATH} // 업로드 경로 지정용으로 사용함
exp-min: ${AWS_S3_PRESIGN_EXP} // Presigned URL 만료 시간(분 단위)
3. AwsConfig
@Configuration
public class AwsConfig {
@Value("${aws.credentials.AWS_ACCESS_KEY}")
private String accessKey;
@Value("${aws.credentials.AWS_SECRET_KEY}")
private String secretKey;
@Value("${aws.region}")
private String region;
@Bean // Presigned URL을 생성하는 AWS SDK 객체
public S3Presigner s3Presigner() {
return S3Presigner.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)))
.build();
}
// S3에 서버가 파일 업로드, 다운로드, 삭제, 조회 등을 할 수 있는 객체로 서버 측에서 파일 확인 가능
@Bean
public S3Client s3Client() {
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)))
.serviceConfiguration(S3Configuration.builder().build())
.build();
}
}
4. PreSignedProvider
@Component
@RequiredArgsConstructor
public class PreSignedProvider {
private final S3Presigner s3Presigner;
private final S3Client s3Client;
private final ImageRepository imageRepository;
@Value("${aws.s3.bucket}")
private String bucketName;
@Value("${aws.s3.exp-min}")
private int expMin;
@Transactional
public PreSignedUrlListResponse createShopUploadUrls(int count, long shopId) {
List<PreSignedUrlResponse> items = new ArrayList<>();
for (int i = 0; i < count; i++) {
PreSignedUrlResponse preSignedUrlResponse = buildItem(ImageType.SHOP_IMG, shopId,
"img_" + i);
items.add(preSignedUrlResponse);
String s3Key = preSignedUrlResponse.key();
Image imageValue = ImageMapper.UrlResponseToShopImage(s3Key, i, shopId);
}
return new PreSignedUrlListResponse(items, shopId);
}
private PreSignedUrlResponse buildItem(ImageType type, long id, String subPath) {
String key = buildKey(type, id, subPath);
URL url = presignPutUrl(key);
return new PreSignedUrlResponse(key, url.toString());
}
// S3 객체 경로(key) 생성
private String buildKey(ImageType type, long id, String subPath) {
return "%s/%d/%d_%s".formatted(type.folder(), id, id, subPath);
}
// 경로(key)를 가지고 URL 생성
private URL presignPutUrl(String key) {
PutObjectRequest request = PutObjectRequest.builder()
.bucket(bucketName)
.key(key)
/*
사진이 수정 또는 삭제가 일어났을 때 클라이언트가 GET을 하면 이전에 캐쉬 된 사진을
가져오기 때문에 캐싱하지 않게 설정
*/
.cacheControl("no-cache,no-store,must-revalidate")
.build();
PutObjectPresignRequest presignReq = PutObjectPresignRequest.builder()
.putObjectRequest(request)
.signatureDuration(Duration.ofMinutes(expMin))
.build();
PresignedPutObjectRequest res = s3Presigner.presignPutObject(presignReq);
return res.url();
}
}
5. ShopService
@Transactional
public PreSignedUrlListResponse newShop(long userId, ShopCreateRequest shopCreateRequest) {
User user = userFind(userId);
Shop newShop = ShopMapper.createToShop(shopCreateRequest, user);
// 점주가 한명이며 한명이 식당 여러개 등록이 가능해도 식당 주소는 다 달라야 하며 or 사업자 등록번호가 이미 있으면 에러
shopRepository.findByBusinessCodeOrRoadAddressAndDetailAddress(newShop.getBusinessCode(),
newShop.getRoadAddress(), newShop.getDetailAddress()).ifPresent(shop -> {
throw new BusinessException(ErrorCode.DUPLICATE_SHOP);});
shopRepository.save(newShop);
// URL 링크 반환
return preSignedProvider.createShopUploadUrls(shopCreateRequest.imageCount(), newShop.getId());
}
결과
클라이언트가 올리길 원하는 이미지 개수에 따라 URL이 발급된다.

발급된 URL에 PUT으로 사진을 넣으면 되며 위에서 설정한 Cache-Control도 잘 넣어줘야 한다.

회고
여러 명의 클라이언트가 여러장의 고용량 이미지를 백엔드 서버에서 리사이징 & 업로드하는 경우 서버에 부하가 집중되며 네트워크 병목 현상이 발생하는 문제가 발생했었다.
이 문제를 해결하기 위해 Pre-Signed URL를 도입해 클라이언트가 업로드 원하는 이미지 개수에 맞춰 백엔드 서버는 S3에 접근 가능한 일회성 URL을 단순히 발급만 하기 때문에 트래픽 부담이 줄어들게 되었다.
하지만 이렇게만 구현 한 경우 원본 사이즈 이미지가 S3에 저장되어 요금이 빠르게 증가하니 Pre-Signed URL 을 사용하더라도 리사이징하는 후처리 작업이 필요하다는 것을 알게 되었다.
1. 클라이언트는 백엔드가 발급해 준 Pre-Signed URL에 이미지를 업로드 한다.
2. 이미지가 S3에 업로드되는 특정 이벤트가 발생 시 트리거를 발생시켜 Lambda 함수를 실행한다.
3. Lambda 함수가 리사이징한 사본 이미지를 S3에 저장
4. 클라이언트가 이미지를 요청하면 원본 이미지 대신 리사이즈 된 이미지를 응답
이미지 리사이징만 수행하는 서버를 따로 두지 않고 AWS Lambda 방식을 사용해 보려고 하며 실질적인 구현은 추후에 진행해 보려고 한다.
참고 자료
https://techblog.woowahan.com/11392/
Spring Boot에서 S3에 파일을 업로드하는 세 가지 방법 | 우아한형제들 기술블로그
Spring Boot에서 S3에 파일을 업로드하는 세 가지 방법 | 안녕하세요. 세일즈서비스팀에서 전자계약서 시스템을 개발하고 있는 박민규입니다. 최근 저는 Spring Boot + Kotlin을 활용한 프로젝트에서
techblog.woowahan.com
AWS Lambda@Edge에서 실시간 이미지 리사이즈 & WebP 형식으로 변환
안녕하세요, 당근마켓에서 백엔드 서버 개발 인턴으로 근무하고 있는 Marco입니다. 저는 이번에 당근마켓 서비스의 썸네일 생성 방식을 On-The-Fly 이미지 리사이징으로 새롭게 구현하였습니다. 이
medium.com
'Spring Boot' 카테고리의 다른 글
| 🚚 허브 간 최단 경로 탐색 캐시 스탬피드 (Cache Stampede) 발생+해결 (1) | 2026.01.19 |
|---|---|
| 🚚 허브 간 최단 경로 탐색 알고리즘 구현 + Kakao Map Api 연동 (Spring Boot) (0) | 2025.12.06 |
| DDD의 페이징 로직 (Spring Boot) (0) | 2025.12.02 |
| 특정 이벤트 발생 시 Discord 실시간 알림 시스템 (Spring Boot) (0) | 2025.11.17 |
| Redis로 Refresh Token 검증 (Spring Boot) (0) | 2025.10.11 |