Pre-Signed URL 도입 (Spring Boot)

2025. 8. 31. 17:06·Spring Boot

문제 발생

프로그래머스 데브코스 팀프로젝트에서 음식점 사장이 가게 외부, 내부, 음식 사진, 메뉴판 등 여러 사진을 한번에 업로드 하는 부분을 구현하게 되었다. 처음에는 클라이언트가 주는 이미지 파일을 백엔드 서버가 전송받고 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

 

https://medium.com/daangn/lambda-edge%EB%A1%9C-%EA%B5%AC%ED%98%84%ED%95%98%EB%8A%94-on-the-fly-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%A6%AC%EC%82%AC%EC%9D%B4%EC%A7%95-f4e5052d49f3

 

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
'Spring Boot' 카테고리의 다른 글
  • 🚚 허브 간 최단 경로 탐색 알고리즘 구현 + Kakao Map Api 연동 (Spring Boot)
  • DDD의 페이징 로직 (Spring Boot)
  • 특정 이벤트 발생 시 Discord 실시간 알림 시스템 (Spring Boot)
  • Redis로 Refresh Token 검증 (Spring Boot)
kimfishes
kimfishes
kimfishes 님의 블로그 입니다.
  • kimfishes
    kimfishes 님의 블로그
    kimfishes
  • 전체
    오늘
    어제
    • 전체 (19) N
      • Infra (5)
        • AWS (0)
        • LogBack (4)
      • Spring Boot (14) N
        • LLM (5) N
      • 일상 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Discord 알림 연동
    Qdrant
    Pre-Signed URL
    실시간 알림 시스템
    ollama
    Redis
    LLM
    UUID v7
    Spring boot
    pgvector
    ELK
    rag
    spring ai
    스프링 알림 시스템
    로깅
    cache stampede
    traceId
    캐시 스탬피드
    loging
    retrieval-augmented generation
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
kimfishes
Pre-Signed URL 도입 (Spring Boot)
상단으로

티스토리툴바