프로메사 프로젝트를 진행하던 중, 원래 계획에는 없었던 Presigned URL을 도입하게 되었습니다. 이 글에서는 해당 기술을 왜 선택하게 되었는지 먼저 설명하고 이어서 Presigned URL의 개념을 간단히 소개하겠습니다.
그 다음, 공식 가이드 문서의 코드를 실제 프로젝트에 맞게 어떻게 개선했는지 그 과정을 공유하고, 구현 과정에서 들었던 의문과 이에 대한 답변도 함께 작성해보겠습니다.
마지막으로 이 글을 따라서 적용해보는 분들을 위해 오류 발생 시 점검해보면 좋은 체크리스트를 정리하며 글을 마무리하겠습니다.
🍒코드 개선 필요성
기존에는 S3 버킷을 아래 사진처럼 모든 퍼블릭 액세스 차단 설정을 해제해두었습니다. 그래서 누구나 객체 URL을 통해서 이미지에 바로 접근할 수 있었고, 홈 화면에 띄울 이미지를 반환해주는 `/brand-info` API에서는 이미지의 S3 객체 URL을 하드코딩하여 직접 응답으로 반환해주는 방식으로 처리했습니다.

그러나 배포 과정에서 S3 버킷을 프라이빗으로 설정하게 되었고, 기존 코드에서 반환하던 객체 URL에 접속할 시 `Access Denied`를 받았습니다. 따라서 프라이빗 버킷에 접근할 수 있는 새로운 방법이 필요했습니다.

🍒Presigned URL이란
"미리 서명된 URL을 통해 객체 다운로드 및 업로드" 공식 문서에서는 Presigned URL이 "미리 서명된 url"이라고 번역하고 있습니다.
미리 서명된 URL을 사용하여 버킷 정책을 업데이트하지 않고도 Amazon S3의 객체에 시간 제한 액세스를 부여할 수 있습니다.또한 미리 서명된 URL을 사용하여 다른 사람이 특정 객체를 Amazon S3 버킷에 업로드하도록 허용할 수도 있습니다.
Presigned URL은 S3 버킷에 직접적인 권한이 없는 사용자라도 일정 시간 동안 객체를 조회하거나 업로드할 수 있도록 허용하는 URL입니다. 단, 지정한 만료시간이 지나면 해당 URL로는 더 이상 접근할 수 없습니다.
그렇다면 Presigned URL의 권한은 어디서 오는 걸까요?
미리 서명된 URL에서 사용하는 자격 증명은 URL을 생성한 AWS 사용자의 자격 증명입니다.
URL을 생성한 AWS 사용자의 자격 증명(credentials)을 기반으로 만들어집니다. 즉 해당 사용자가 권한을 일시적으로 Presigned URL에 담아서 빌려주는 것이라고 이해하면 됩니다.
만약 Presigned URL을 생성한 사용자가 `s3:GetObject` 권한은 가지고 있으나 `s3:PutObject`권한은 가지고 있지 않다면 발급받은 Presigned URL을 통해서 객체를 조회(다운로드)하는 것은 가능하지만 객체를 업로드하는 것은 불가능합니다.
또한 Presigned URL은 만료 날짜 및 시간이 다 될때까지 여러 번 사용할 수 있다는 특징이 있습니다. 즉 같은 URL로 여러 번 GET 요청을 보내는 것이 가능합니다.
미리 서명된 URL의 만료 시간
Presigned Url을 생성할 때 만료 시간을 직접 지정할 수 있으며 해당 URL은 지정된 기간 동안만 유효합니다. Amazon S3 콘솔을 통해 Presigned URL을 생성하는 경우, 만료 시간은 1분~12시간 사이로 설정할 수 있으며 AWS CLI또는 AWS SDK를 사용하는 경우 만료 시간을 최대 7일로 설정할 수 있습니다. 또한 만료시간이 남았음에도 불구하고 해당 Presigned URL을 발급한 AWS 계정의 자격 증명이 취소, 삭제 또는 비활성화 되면 URL 즉시 만료됩니다.
Amazon S3는 HTTP 요청이 들어올 때마다 URL에 포함된 만료 날짜 및 시간을 확인하여 유효한 요청인지 검증합니다. 예를 들어 클라이언트가 만료 시간 직전에 대용량 파일을 다운로드하기 시작한 경우, 다운로드 중에 만료 시간이 경과해도 다운로드는 계속 진행됩니다. 단, 연결이 끊어진 경우 클라이언트가 만료 시간 이후에 다운로드를 다시 시작하는 것은 불가능합니다.
이처럼 Presigned URL의 동작 방식을 이해하고 실제로 구현해보려 검색해본 결과, 많은 블로그 글이 AWS SDK v1을 기준으로 작성되어 있다는 것을 알게 되었습니다.
🍒SDK v1과 SDK v2

AWS SDK for Java 2.x 는 기존 1.x 버전을 재작성한 것으로, Java 8 이상의 환경을 기반으로 설계되었고 요청이 많았던 기능들이 추가되었습니다.

The AWS SDK for Java 2.x provides asynchronous clients to enable non-blocking I/O.
SDK v2는 비차단(non-blocking) I/O를 지원하는 비동기 클라이언트를 제공하며, 이는 높은 동시성 처리가 가능합니다. 동기 메서드는 클라이언트가 서비스로부터 응답을 받을 때까지 스레드 실행을 차단하는 반면, 비동기 메서드는 즉시 반환되므로 응답을 기다리지 않고 호출한 스레드로 제어권을 되돌려줍니다.
SDK v2 offers enhanced HTTP client implementations including Netty, Apache, and URLConnection clients.
또한 SDK v2는 향상된 HTTP 클라이언트 구현체들을 제공합니다.
장기적인 유지보수성과 성능을 고려하여 SDK v2 버전으로 작업하였습니다.
더 많은 차이점은 "AWS SDK for Java 1.x와 2.x의 차이점" 공식 문서에서 확인할 수 있습니다.
🍒SDK v2를 기반으로 하는 Presigned Url 구현
AWS는 "Amazon S3 미리 서명된 URLs 작업" 가이드 문서를 통해 객체를 조회(다운로드)하는 Presigned Url을 생성하는 코드를 제공하고 있습니다. 해당 문서의 `createPresignedGetUrl` 개선해보겠습니다.
/* Create a pre-signed URL to download an object in a subsequent GET request. */
public String createPresignedGetUrl(String bucketName, String keyName) {
try (S3Presigner presigner = S3Presigner.create()) {
GetObjectRequest objectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(keyName)
.build();
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(10)) // The URL will expire in 10 minutes.
.getObjectRequest(objectRequest)
.build();
PresignedGetObjectRequest presignedRequest = presigner.presignGetObject(presignRequest);
logger.info("Presigned URL: [{}]", presignedRequest.url().toString());
logger.info("HTTP method: [{}]", presignedRequest.httpRequest().method());
return presignedRequest.url().toExternalForm();
}
}
먼저, 해당 코드를 어떤 계층에 작성할지 고민해보았습니다. 이 로직은 실제로 비즈니스 로직으로 활용될 예정이므로 서비스 계층에 포함시키는 것이 적절하다고 판단했습니다.
`S3Service` 클래스를 생성하여 위의 `createPresignedGetUrl`메서드를 우선 복사/붙여넣기 합니다. 그리고 이 클래스에`@Service` 어노테이션을 달아 Spring의 컴포넌트 스캔 대상이 되도록 설정합니다.
@Service // ➕추가!
public class S3Service {
/* Create a pre-signed URL to download an object in a subsequent GET request. */
public String createPresignedGetUrl(String bucketName, String keyName) {
try (S3Presigner presigner = S3Presigner.create()) {
GetObjectRequest objectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(keyName)
.build();
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(10)) // The URL will expire in 10 minutes.
.getObjectRequest(objectRequest)
.build();
PresignedGetObjectRequest presignedRequest = presigner.presignGetObject(presignRequest);
logger.info("Presigned URL: [{}]", presignedRequest.url().toString());
logger.info("HTTP method: [{}]", presignedRequest.httpRequest().method());
return presignedRequest.url().toExternalForm();
}
}
}
이제 이 비즈니스 로직에서 사용하는 의존 객체를 빈(Bean)으로 등록하겠습니다. 여기서 의존 객체는 `S3Presigner`입니다. `S3Presigner` 객체는 AWS SDK v2에서 Presigned URL을 생성할 때 사용하는 전용객체입니다.
Bean으로 등록한다는 것은 Spring의 IoC 컨테이너가 해당 객체의 생명주기를 관리하도록 설정하는 것을 의미합니다. 이를 통해 재사용성, 성능 최적화, 유지보수 및 테스트에서 유리합니다.
Bean으로 등록하지 않은 현재 코드는 매번 객체를 새로 생성하는 방식입니다. 예를 들어 커피 주문 100건이 들어왔을 때 커피 머신 100대를 각각 가동시켜서 커피 머신 한 대당 커피 한 잔을 뽑아내는 상황입니다. 매번 자원을 새로 할당하고 초기화하기 때문에 비효율적입니다.
반면 Bean으로 등록한다면, 하나의 커피 머신(객체 인스턴스)을 재사용하여 커피 100잔을 뽑는 것입니다. 즉 같은 인스턴스를 여러 컴포넌트나 요청에서 공유해서 사용하므로 메모리 사용량을 줄이고 객체 생성 비용을 절약할 수 있고 동일한 설정 상태를 유지할 수 있습니다.
`S3Presigner` 객체를 Bean으로 등록하기 위해 `S3Config` 클래스를 생성하고 `S3Presigner` 인스턴스를 명시적으로 빈으로 선언하겠습니다.
package com.promesa.promesa.common.config.S3;
@Configuration
@RequiredArgsConstructor
@Slf4j
public class S3Config {
@Bean
public S3Presigner amazonS3Presigner(){
return S3Presigner.builder()
.region(Region.AP_NORTHEAST_2)
.credentialsProvider(DefaultCredentialsProvider.create())
.build();
}
}
이제 `S3Service`에서 `S3Presigner`를 직접 생성하지 않고, Bean으로 등록된 인스턴스를 주입받는 방식으로 리팩토링해줍니다.
@Slf4j // ➕추가!
@Service
@RequiredArgsConstructor // ➕추가!
public class S3Service {
private final S3Presigner s3Presigner; // ➕추가!
public String createPresignedGetUrl(String bucketName, String keyName) {
try { // ➖제거!
GetObjectRequest objectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(keyName)
.build();
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(10)) // The URL will expire in 10 minutes.
.getObjectRequest(objectRequest)
.build();
PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); // ✔️변경!
log.info("Presigned URL: [{}]", presignedRequest.url().toString()); // ✔️변경!
log.info("HTTP method: [{}]", presignedRequest.httpRequest().method()); // ✔️변경!
return presignedRequest.url().toExternalForm();
}
}
}
` S3Service` 클래스 상단에는 `@RequiredArgsConstructor` 어노테이션을 추가하고 필드에 `private final S3Presigner s3Presigner`를 선언하여 Bean으로 등록된 `S3Presigner` 인스턴스를 생성자 주입 방식으로 주입받도록 수정했습니다. 그리고 기존에 `try` 블록 내부에서 `S3Presigner` 객체를 직접 생성하던 코드가 제거되었습니다.
`@Slf4j` 어노테이션을 사용하기 위해서 `logger`를 `log`로 변경하였습니다.
이렇게 수정하면 `catch`문이 누락되어서 오류가 뜹니다. `try-catch`문의 예외처리를 커스텀 예외 처리를 하겠습니다.
기존의 try(...) { }은 try-with-resources 문법으로 try-catch 문의 변형 문법이었습니다.
try-with-resources 문은 try 블록의 괄호()를 통해 파일을 열거나 자원을 할당하는 명령문을 명시하면, 해당 try 블록이 끝나자마자 자동으로 파일을 닫거나 할당된 자원을 해제해 줍니다.
그러나 try()안이 비어있으면 try-with-resources 문법 에러가 됩니다. 따라서 괄호까지 지우고 try 블록만 있고 catch/finally가 없으면 예외 처리 누락 컴파일 에러가 발생하게 됩니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class S3Service {
private final S3Presigner s3Presigner;
public String createPresignedGetUrl(String bucketName, String keyName) {
try {
GetObjectRequest objectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(keyName)
.build();
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(10)) // The URL will expire in 10 minutes.
.getObjectRequest(objectRequest)
.build();
PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest);
log.info("Presigned URL: [{}]", presignedRequest.url().toString());
log.info("HTTP method: [{}]", presignedRequest.httpRequest().method());
return presignedRequest.url().toExternalForm();
}catch (Exception e){ // ➕추가!
log.error("Presigned URL 생성 실패: {}/{}", bucketName, keyName, e);
throw InternalServerError.EXCEPTION;
}
}
}
`InternalServerError`는 프로메사 프로젝트에서 직접 정의한 커스텀 예외로, 예외 처리 방식은 프로젝트마다 다르므로 각 프로젝트 상황에 맞게 적용시키면 됩니다.
마지막으로 `@Value`어노테이션을 활용하여 만료 시간을 메서드 안에 하드코딩하는 대신 외부 설정 파일에서 주입받는 방식으로 변경하겠습니다. 이렇게 외부에서 설정하면 나중에 만료시간을 수정할 때 코드를 변경하지 않고 편하게 조정할 수 있습니다. `application.yml`에 다음 설정을 추가하겠습니다.
aws:
s3:
presigned:
expire-minutes: 15 // 만료시간 15분
마지막으로 `S3Service`를 수정하겠습니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class S3Service {
private final S3Presigner s3Presigner;
@Value("${aws.s3.presigned.expire-minutes}") // ➕추가!
private long expireMinutes;
public String createPresignedGetUrl(String bucketName, String keyName) {
try {
GetObjectRequest objectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(keyName)
.build();
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(expireMinutes)) // ✔️변경!
.getObjectRequest(objectRequest)
.build();
PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest);
log.info("Presigned URL: [{}]", presignedRequest.url().toString());
log.info("HTTP method: [{}]", presignedRequest.httpRequest().method());
return presignedRequest.url().toExternalForm();
}catch (Exception e){
log.error("Presigned URL 생성 실패: {}/{}", bucketName, keyName, e);
throw InternalServerError.EXCEPTION;
}
}
}
이로써 비즈니스 로직 작성이 끝났습니다.
로컬에서 AWS 계정에 접속하는 방법
그런데 코드를 작성하면서 한 가지 의문이 들었습니다. `Presigned URL`의 개념에서 해당 URL을 발급해주는 AWS 계정의 자격 증명을 따른다고 했습니다. 그런데 로컬에서 어떻게 AWS 계정을 알고 접속하는 걸까요?
우리는 기본 자격 증명 공급자 체인 방식을 사용하고 있었습니다.
`S3Config` 클래스에 있는
package com.promesa.promesa.common.config.S3;
@Configuration
@RequiredArgsConstructor
@Slf4j
public class S3Config {
@Bean
public S3Presigner amazonS3Presigner(){
return S3Presigner.builder()
.region(Region.AP_NORTHEAST_2)
.credentialsProvider(DefaultCredentialsProvider.create()) // ✅주목!
.build();
}
}
`DefaultCredentialsProvider` 클래스에 의해서 구현되고 있었습니다.
`DefaultCredentialsProvider` 클래스는 AWS 자격 증명을 자동으로 찾아주는 기본 설정 도구입니다. 임시 자격 증명 제공을 위한 기본 구성을 설정할 수 있는 각 위치를 순차적으로 확인하면서 사용 가능한 자격 증명이 있는 곳을 찾아 자동으로 가져옵니다.
Java 2.x용 SDK는 다음과 같은 순서로 검색합니다.
- Java 시스템 속성
- 환경 변수
- AWS Security Token Service
- 공유 `credentials` 및 `config` 파일
- Amazon ECS 컨테이너 자격 증명
- Amazon EC2 인스턴스 IAM 역할 제공 자격 증명
이 중에서 저는 공유 `credentials` 및 `config`파일을 통해서 임시 자격 증명을 로드하고 있었습니다.

해당 방법은 이 링크를 통해서 쉽게 따라할 수 있습니다.
✅예외 발생 시 체크 포인트
만약 이 글을 따라했을 때 실행이 안될 수도, 또는 `Presigned URL`발급이 안될 수도 있습니다. 이럴 경우 다음 항목들을 점검해보시길 바랍니다.
의존성 주입
앞서 구현 과정에서는 의존성 주입 없이 바로 비즈니스 로직을 작성했습니다. 따라서 `build.gradle`에 다음과 같은 항목이 있는지 확인합니다.
implementation platform('software.amazon.awssdk:bom:2.20.56')
implementation 'software.amazon.awssdk:s3'
AWS 자격 증명
이 경우 두 가지를 점검해봐야 합니다.
우선, `DefaultCredentialsProvider` 클래스에서 로드할 수 있는 임시 자격 증명을 위한 기본 구성이 있는지 확인합니다.
- Java 시스템 속성
- 환경 변수
- AWS Security Token Service
- 공유 `credentials` 및 `config` 파일
- Amazon ECS 컨테이너 자격 증명
- Amazon EC2 인스턴스 IAM 역할 제공 자격 증명
이 중에 한 가지라도 만족해야 합니다. 공유 `credentials` 및 `config`파일 방법을 택했을 경우 C:\Users\USERNAME\.aws\에 경로에 credentials 파일이 존재하는지 확인합니다.

만약 해당 파일이 있음에도 `Presigend URL`이 발급되지 않는다면, 발급하는 AWS 사용자의 권한 문제일 수 있습니다. AWS의 IAM에 접속한 뒤 사용자를 클릭합니다.

`Presigned URL`을 발급하고자 하는 사용자를 선택하고 권한 정책을 확인합니다.

아래와 같은 권한을 가질 경우에만 Presigned URL을 발급할 수 있습니다.
- `AdministratorAccess` : 모든 권한을 포함합니다
- `AmazonS3FullAccess` : 모든 S3 작업 권한을 포함합니다
- `AmazonS3ReadOnlyAccess` : S3 객체 조회만 가능합니다. `GET` `Presigend URL`만 발급 가능합니다
S3 버킷 정책
만약 발급 받은 `Presigned URL`로 요청을 보냈을 때 `Access Denied` 또는 `403 Forbidden`을 받는다면 버킷 정책의 문제입니다.
버킷 정책에 따라 발급된 `Presigned URL`로 요청이 들어왔을 때 허용할지 거부할지 결정합니다.
Amazon S3에 접속한 뒤 해당 버킷을 선택합니다.

상단의 권한을 선택한 후 스크롤을 내려 버킷 정책을 확인합니다.
{
"Version": "2012-10-17",
"Id": "Policy1748578409305",
"Statement": [
{
"Sid": "Stmt1748578392624",
"Effect": "Allow",
"Principal": "*",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": "arn:aws:s3:::your-bucket-name/*"
}
]
}
Action에 `s3:GetObject`와 `s3:PutObject`권한이 포함되어 있어야만 `Presigend URL`을 통해서 `GET`과 `PUT`요청을 보낼 수 있습니다.
SDK v2를 이용하는 분들께 많은 도움이 되었기를 바랍니다.
부족한 부분은 알려주시면 감사하겠습니다! (●'◡'●)
'SPRING > HOW-TO' 카테고리의 다른 글
| HeadObject로 S3 객체 존재 여부 확인하기 (SDK 2) (2) | 2025.09.30 |
|---|---|
| Presigned URL로 S3 이미지 업로드 및 삭제 기능 구현하기 (SDK 2) (5) | 2025.06.22 |
| SSH 터널링으로 프라이빗 RDS에 MySQL Workbench 연결하기 (3) | 2025.06.18 |
