[table-now] S3 이미지 업로드 - Presigned Url 방식으로 구현하기

2025. 5. 23. 16:57·Dev Projects

🔍 개요

이번 글에서는 우리가 개발한 실시간 식당 예약 관리 시스템 `table-now`에서
사용자 프로필 이미지와 식당 이미지를 어떻게 효율적으로 업로드할 수 있을지 고민했고,  
그 결과 도입한 Presigned URL 방식의 설계 및 구현 과정을 이번 글에서 공유합니다.

 

AWS S3에 이미지를 업로드할 때 일반적으로 사용하는 두 가지 방식은 다음과 같습니다:

🔹 MultipartFile 방식

  • 서버가 클라이언트로부터 MultipartFile을 받고, 이를 S3로 직접 업로드
  • 장점: 간단한 구현
  • 단점:
    • 서버 트래픽 부담 증가
    • 대용량 파일의 경우 서버 리소스 과다 사용
    • 업로드 처리 시간이 서버 응답 시간에 포함됨

🔹 Presigned URL 방식

  • 클라이언트가 S3에 직접 파일을 업로드하고, 서버는 그 URL만 발급
  • 장점:
    • 서버 부하 감소
    • 대용량 파일 업로드에 적합
  • 단점:
    • Presigned URL이 외부에 유출될 경우 보안 이슈 발생 가능
    • 이를 방지하기 위해 URL 만료 시간 설정이 필수

 

우리 서비스에서는 사용자 프로필 이미지 및 식당 이미지 업로드 기능이 포함되어 있으며, 업로드된 이미지는 사용자와 일반 방문자가 바로 확인할 수 있어야 하기 때문에 Presigned URL 방식으로 업로드를 처리하고, 다운로드는 퍼블릭 URL 방식으로 접근 가능하도록 설정하였습니다.

 


 

✅ 전체 흐름 요약

🟢 1단계: 클라이언트 → 서버

클라이언트: “S3에 업로드할 URL 주세요!”

  • 도메인(user 또는 store)은 path variable로 전달
  • 서버는 사용자 ID와 함께 파일 경로를 안전하게 생성

 

🟡 2단계: 서버 → 클라이언트

서버: “이 URL로 S3에 업로드하세요. 업로드되면 이 주소로 접근 가능해요.”

  • `uploadUrl`: 프론트가 PUT 요청으로 파일을 업로드할 수 있는 URL (Presigned)
  • `fileUrl`: 업로드가 완료되면 이 주소로 이미지 접근 가능함
    • 이건 백엔드가 미리 구성한 경로: `디렉토리/사용자ID/UUID.확장자`
    • 업로드가 완료된 후 접근하게 될 예상 경로. presigned URL은 아님!

 

🔵 3단계: 클라이언트 → S3

클라이언트: “uploadUrl에 이미지를 PUT 요청으로 전송할게요.”

 

 

  • 백엔드랑 상관없이 프론트가 직접 S3에 업로드
  • uploadUrl은 유효시간(예: 3분)이 지나면 못 쓰게 됨
  • `PUT` 요청을 위한 URL이므로, `GET`으로 접근하거나 다른 용도로는 사용할 수 없

 

🟣 4단계: 클라이언트 → 서버

클라이언트: “S3에 이미지 업로드 다 했어요! 이 URL을 제 프로필로 저장해주세요.”

PATCH /api/v1/users
Content-Type: application/json
{
  "imageUrl": "https://your-bucket.s3.amazonaws.com/profile/42/uuid.png"
}
  • 이 요청을 통해, 최종적으로 DB의 user.imageUrl 필드가 채워짐

 


 

⚙️ Presigned URL 발급 구현 코드

1. build.gradle S3 의존성 추가

// S3
implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.3.0")
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3'
  • Spring Cloud AWS 3.3.0 brings compatibility with Spring Boot 3.4

 

2. .env 환경 설정

AWS_ACCESS_KEY=...
AWS_SECRET_KEY=...
AWS_REGION=ap-northeast-2
AWS_S3_BUCKET=your-bucket-name
AWS_S3_PRESIGNED_EXPIRATION=3600

 

3. application.yml 

cloud:
  aws:
    credentials:
      accessKey: ${AWS_ACCESS_KEY}
      secretKey: ${AWS_SECRET_KEY}
    region:
      static: ${AWS_REGION}
    s3:
      bucket: ${AWS_S3_BUCKET}
      presigned-url-expiration: ${AWS_S3_PRESIGNED_EXPIRATION}

 

4. 컨트롤러 (ImageController)

@PostMapping("/v1/images/upload/{domain}")
public ResponseEntity<PresignedUrlResponse> generatePresignedUrl(
        @AuthenticationPrincipal AuthUser authUser,
        @PathVariable("domain") String domain,
        @Valid @RequestBody PresignedUrlRequest request
) {
    ImageDomain imageDomain = ImageDomain.of(domain);
    return ResponseEntity.ok(imageService.generatePresignedUrl(authUser, imageDomain, request));
}

 

5. Presigned URL 생성 (ImageService)

@Slf4j
@RequiredArgsConstructor
@Service
public class ImageService {

    private final S3Presigner presigner;
    private final S3Client s3Client;
    private final S3Properties s3Properties;

    private static final String S3_URL_PREFIX = "https://";
    private static final String S3_URL_SUFFIX = ".s3.amazonaws.com/";

    public PresignedUrlResponse generatePresignedUrl(AuthUser authUser, ImageDomain imageDomain, PresignedUrlRequest request) {
        try {
            Long userId = authUser.getId();
            String fileExtension = extractExtension(request.getFileName());
            String key = generateKey(imageDomain, userId, fileExtension);

            PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                    .bucket(s3Properties.getBucket())
                    .key(key)
                    .contentType(request.getFileType().getMimeType())
                    .build();

            PresignedPutObjectRequest presignedRequest = presigner.presignPutObject(
                    PutObjectPresignRequest.builder()
                            .signatureDuration(Duration.ofMinutes(s3Properties.getPresignedUrlExpiration()))
                            .putObjectRequest(putObjectRequest)
                            .build()
            );

            String uploadUrl = presignedRequest.url().toString();
            String fileUrl = getFileUrl(key);

            return PresignedUrlResponse.builder()
                    .uploadUrl(uploadUrl)
                    .fileUrl(fileUrl)
                    .build();
        } catch (S3Exception | SdkClientException | IllegalArgumentException e) {
            log.error("[S3] Presigned URL 생성 중 예외 발생", e);
            throw new HandledException(ErrorCode.S3_PRESIGNED_URL_CREATION_FAILED);
        }
    }

    public void delete(String objectPath) {
        try {
            DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
                    .bucket(s3Properties.getBucket())
                    .key(getImageKey(objectPath))
                    .build();
            s3Client.deleteObject(deleteObjectRequest);
        } catch (SdkServiceException e) {
            log.error(String.valueOf(e.getCause()));
        }
    }

    private String generateKey(ImageDomain imageDomain, Long userId, String fileExtension) {
        return imageDomain.name().toLowerCase() + "/" + userId + "/" + UUID.randomUUID() + fileExtension;
    }

    private String getFileUrl(String key) {
        return S3_URL_PREFIX + s3Properties.getBucket() + S3_URL_SUFFIX + key;
    }

    private String extractExtension(String fileName) {
        return fileName.substring(fileName.lastIndexOf('.')); // 예: ".png"
    }

    private String getImageKey(String objectPath) {
        try {
            URL url = new URL(objectPath);
            return url.getPath().substring(1);
        } catch (MalformedURLException e) {
            throw new HandledException(ErrorCode.INVALID_IMAGE_URL);
        }
    }
}

 

S3 퍼블릭 접근 설정

파일 접근(다운로드)을 위해 S3 객체는 퍼블릭 읽기(Public Read) 권한을 가져야 한다.

{
  "Effect": "Allow",
  "Principal": "*",
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::your-bucket-name/*"
}
  • 공개 가능한 자료에만 적용
  • ❗ 퍼블릭 설정은 민감 정보(예: 주민등록증, 내부 문서 등)에는 사용해서는 안 되며,  외부 공개가 의도된 이미지(예: 프로필/썸네일)에만 적용해야 함.

 

구현 코드 상세

https://github.com/spring-team-7/table-now/pull/66

 

[feat] 이미지 업로드 기능 구현 by mannaKim · Pull Request #66 · spring-team-7/table-now

🔗 Issue Number close #61 📝 작업 내역 S3 의존성 추가 Presigned PUT Url 발급 API 구현 💡 PR 특이사항 spring-cloud-aws 버전 결정 참고 내용 중: Spring Cloud AWS 3.3.0 brings compatibility with Spring Boot 3.4 업로드 대상

github.com

https://github.com/spring-team-7/table-now/pull/80

 

[feat] 유저 프로필 이미지 저장 및 삭제 기능 추가 by mannaKim · Pull Request #80 · spring-team-7/table-now

🔗 Issue Number close #69 close #76 📝 작업 내역 presigned URL 로직 리팩토링 프로필 이미지 저장 및 삭제 기능 추가 💡 PR 특이사항 ImageService의 bucketName, expirationMinutes는 환경 변수(@Value) 주입 대상이라 f

github.com

 


 

📝 마무리

우리 프로젝트에서는 사용자 프로필 이미지와 식당 이미지를 업로드하는 목적에 맞춰 이 방식을 선택했습니다.  
사용자와 고객이 직접 이미지를 확인할 수 있어야 하므로,

  • 업로드는 Presigned URL 방식으로 처리하고 
  • 다운로드는 퍼블릭 URL 방식으로 구성하여  

실시간 접근성과 서버 자원 효율이라는 두 가지 목표를 모두 충족할 수 있었습니다.

Presigned URL은 특히 대용량 파일 업로드나 클라이언트-서버 간 책임 분리가 필요한 상황에서 유용하며,  
퍼블릭 URL 접근은 공개 가능한 리소스에 대해서만 신중하게 적용해야 합니다.

 

저작자표시 비영리 변경금지 (새창열림)

'Dev Projects' 카테고리의 다른 글

[table-now] Spring Security의 oauth2Login() 대신 WebClient를 선택한 이유  (3) 2025.05.28
[table-now] 의사결정 기록 - 외부 연동 시스템 예외 처리 강화 리팩토링  (0) 2025.05.23
[table-now] AccessToken 블랙리스트 기반 토큰 무효화 기능 구현  (0) 2025.04.22
[table-now] Refresh Token 저장소를 Redis로 교체하며 인증 구조 고도화하기  (0) 2025.04.21
[table-now] 소셜 로그인(Kakao, Naver) 통합 구현기  (1) 2025.04.21
'Dev Projects' 카테고리의 다른 글
  • [table-now] Spring Security의 oauth2Login() 대신 WebClient를 선택한 이유
  • [table-now] 의사결정 기록 - 외부 연동 시스템 예외 처리 강화 리팩토링
  • [table-now] AccessToken 블랙리스트 기반 토큰 무효화 기능 구현
  • [table-now] Refresh Token 저장소를 Redis로 교체하며 인증 구조 고도화하기
기만나🐸
기만나🐸
공부한 내용을 기록합시다 🔥🔥🔥
  • 기만나🐸
    기만나의 공부 기록 🤓
    기만나🐸
  • 전체
    오늘
    어제
    • ALL (147)
      • TIL (Today I Learned) (56)
      • Dev Projects (15)
      • Algorithm Solving (67)
        • Java (52)
        • SQL (15)
      • Certifications (8)
        • 정보처리기사 실기 (8)
  • 인기 글

  • 태그

    join
    HTML
    Firebase
    GROUP BY
    jwt
    백트래킹
    mysql
    greedy
    sql
    programmers
    시뮬레이션
    DFS
    javascript
    다이나믹프로그래밍
    dp
    BFS
    CSS
    프로그래머스
    그리디
    Subquery
    jQuery
    websocket
    java
    백준
    Google Fonts
    bootstrap
    완전탐색
    jpa
    BOJ
    자료구조
  • 최근 글

  • 최근 댓글

  • hELLO· Designed By정상우.v4.10.3
기만나🐸
[table-now] S3 이미지 업로드 - Presigned Url 방식으로 구현하기
상단으로

티스토리툴바