Spring Boot에서 S3: 안전하게 파일 저장하고 권한 관리하기
1. 개요
Amazon S3는 청구서, 프로필 사진, 첨부 파일 같은 파일을 저장하는 일반적인 선택입니다. 업로드 부분은 보통 코드 몇 줄이면 됩니다. 더 어려운 부분은 권한 관리입니다. 누가 읽을 수 있고, 누가 쓸 수 있으며, 버킷을 완전히 공개하지 않으면서 파일을 공유하려면 어떻게 해야 할까요?
이 글에서는 Spring Boot 3.x에서 AWS SDK for Java v2를 사용하여 파일을 저장하고 읽을 것입니다. 그 다음, IAM 정책과 버킷 정책으로 권한을 구성하고, presigned URL을 사용하여 제한된 시간 동안 파일을 공유하는 방법을 알아봅시다. 글 마지막에는 LocalStack으로 실행하는 테스트가 있습니다.
2. 프로젝트 설정
2.1. 의존성
AWS SDK의 BOM을 사용하여 모듈이 항상 같은 버전으로 유지되도록 하는 것이 좋습니다:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>2.31.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
</dependency>
</dependencies>
s3 모듈에는 S3Client와 S3Presigner가 모두 포함되어 있으므로 다른 모듈을 추가할 필요가 없습니다.
2.2. Bean 구성
application.properties에 region과 버킷 이름을 선언합니다:
app.s3.region=ap-southeast-1
app.s3.bucket=nonla-invoices-prod
다음으로, S3Client와 S3Presigner bean을 두 개 만듭니다:
@Configuration
public class S3Config {
@Bean
S3Client s3Client(@Value("${app.s3.region}") String region) {
return S3Client.builder()
.region(Region.of(region))
.build();
}
@Bean
S3Presigner s3Presigner(@Value("${app.s3.region}") String region) {
return S3Presigner.builder()
.region(Region.of(region))
.build();
}
}
위 코드는 자격 증명을 선언하지 않습니다. SDK는 DefaultCredentialsProvider를 사용하고 환경 변수, ~/.aws/credentials 파일, EC2/ECS/EKS의 IAM 역할에서 자격 증명을 순서대로 찾습니다. 프로덕션 환경에서는 애플리케이션이 IAM 역할로 실행되도록 하는 것이 좋습니다.
주의: 코드나
application.properties에 액세스 키를 하드코딩하지 마세요. 키가 Git으로 노출되는 것은 가장 흔한 AWS 사고 중 하나입니다.
3. 파일 저장 및 읽기
이 글의 예제는 고객을 위해 PDF 청구서를 저장하는 서비스입니다. 각 파일은 customerId에 따른 접두사 아래에 있습니다. 이런 식으로 key를 설정하면 5번 항목에서 소유권 확인이 더 쉬워집니다.
@Service
public class InvoiceStorageService {
private final S3Client s3;
private final S3Presigner presigner;
private final String bucket;
public InvoiceStorageService(S3Client s3, S3Presigner presigner,
@Value("${app.s3.bucket}") String bucket) {
this.s3 = s3;
this.presigner = presigner;
this.bucket = bucket;
}
public String upload(long customerId, InputStream content, long size) {
String key = "invoices/%d/%s.pdf".formatted(customerId, UUID.randomUUID());
PutObjectRequest request = PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.contentType("application/pdf")
.serverSideEncryption(ServerSideEncryption.AES256)
.build();
s3.putObject(request, RequestBody.fromInputStream(content, size));
return key;
}
public byte[] download(String key) {
return s3.getObjectAsBytes(b -> b.bucket(bucket).key(key)).asByteArray();
}
public void delete(String key) {
s3.deleteObject(b -> b.bucket(bucket).key(key));
}
}
코드의 주요 포인트:
-
Key는
invoices/42/7f3c...pdf형태입니다. UUID 부분은 이름이 중복되는 것을 방지합니다. 사용자 파일 이름을 key에 넣지 않으므로 이상한 문자와 경로 순회(path traversal)를 피할 수 있습니다. -
contentType은 object와 함께 저장됩니다. 브라우저가 presigned URL을 통해 파일을 다운로드할 때 PDF를 올바르게 표시합니다. -
serverSideEncryption(AES256)는 S3이 저장할 때 파일을 암호화하도록 요청합니다(SSE-S3). 요즘 S3은 기본적으로 이 모드를 활성화합니다. 버킷 정책이 암호화 헤더를 필수로 요구하는 경우 요청에 명시적으로 작성하면 여전히 유용합니다. -
RequestBody.fromInputStream은 사전에size를 알아야 합니다.MultipartFile을 받는 경우file.getInputStream()과file.getSize()를 전달합니다. -
getObjectAsBytes는 전체 파일을 메모리에 읽습니다. 이 방법은 작은 파일에만 적합합니다.
주의: 큰 파일(수백 MB)의 경우
getObjectAsBytes를 사용하지 마세요.s3.getObject(...)를 사용하여ResponseInputStream을 얻고 응답으로 직접 스트림하세요. 다른 방법은 클라이언트가 presigned URL을 통해 직접 다운로드하도록 하는 것입니다(5번 항목).
4. S3의 권한 모델
S3에 대한 요청은 여러 검증 계층을 통과할 때만 수락됩니다:
- Block Public Access: 계정 및 버킷 수준의 스위치입니다. 활성화되면 public 액세스를 허용하는 모든 정책 또는 ACL이 무효화됩니다.
- IAM 정책: 사용자 또는 역할에 연결되어 해당 자격이 무엇을 할 수 있는지 설명합니다.
- 버킷 정책: 버킷에 연결되어 누가 버킷에서 무엇을 할 수 있는지 설명합니다.
- ACL: 이전 메커니즘입니다. 새 버킷은 기본적으로 Object Ownership
BucketOwnerEnforced를 사용하므로 ACL이 비활성화됩니다.
평가 규칙은 매우 간단합니다: 어디서든 명시적인 Deny는 항상 승리합니다. Deny가 없으면 요청은 일치하는 Allow가 하나 이상 필요합니다.
대부분의 애플리케이션에 대한 권장사항:
- 네 가지 옵션 모두에서 Block Public Access를 활성화된 상태로 유지하세요.
- ACL을 비활성화된 상태로 유지하세요(
BucketOwnerEnforced). - IAM 역할을 통해 애플리케이션에 권한을 부여하세요.
- TLS 강제 등 공통 제약 조건에는 버킷 정책을 사용하세요.
4.1. 최소 권한 원칙에 따른 IAM 정책
애플리케이션 역할은 invoices/ 접두사 아래의 object에 대해서만 읽기, 쓰기 및 삭제 권한이 필요합니다:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "InvoiceObjects",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::nonla-invoices-prod/invoices/*"
},
{
"Sid": "ListInvoicePrefix",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::nonla-invoices-prod",
"Condition": {
"StringLike": { "s3:prefix": "invoices/*" }
}
}
]
}
두 가지 다른 ARN 형식에 주목하세요:
- object에 대한 작업은 끝에
/*가 있는 ARN을 사용합니다. s3:ListBucket은 버킷에 대한 작업이므로/*가 없는 ARN을 사용합니다.
이 두 ARN을 혼동하는 것이 AccessDenied 오류의 가장 흔한 원인입니다. 또 다른 세부 사항은 s3:ListBucket이 없으면 object가 없을 때 S3이 404 대신 403을 반환한다는 것입니다. 이는 S3의 의도적인 메커니즘으로 “이 key가 존재하는지 여부” 정보를 노출하지 않기 위한 것입니다.
4.2. TLS를 필수로 하는 버킷 정책
버킷 정책은 admin을 포함한 모든 호출자에게 적용되는 규칙을 설정하는 데 적합합니다:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::nonla-invoices-prod",
"arn:aws:s3:::nonla-invoices-prod/*"
],
"Condition": {
"Bool": { "aws:SecureTransport": "false" }
}
}
]
}
이 statement는 Deny이므로 HTTP를 통한 모든 요청이 차단됩니다. 다른 Allow는 이를 재정의할 수 없습니다. SDK v2는 기본적으로 HTTPS를 사용하므로 애플리케이션이 영향을 받지 않습니다.
5. Presigned URL을 사용한 파일 공유
고객은 청구서를 다운로드해야 하지만 버킷을 공개하거나 모든 파일을 서버를 통해 스트림하고 싶지 않습니다. Presigned URL이 이 문제를 정확히 해결합니다. 이는 애플리케이션의 자격 증명으로 서명되었고 짧은 시간 동안만 사용할 수 있는 URL입니다.
InvoiceStorageService에 다음 두 메서드를 추가합니다:
public URL presignDownload(long customerId, String key, Duration ttl) {
if (!key.startsWith("invoices/%d/".formatted(customerId))) {
throw new ForbiddenFileAccessException(customerId, key);
}
GetObjectPresignRequest request = GetObjectPresignRequest.builder()
.signatureDuration(ttl)
.getObjectRequest(b -> b.bucket(bucket).key(key))
.build();
return presigner.presignGetObject(request).url();
}
public URL presignUpload(long customerId, Duration ttl) {
String key = "invoices/%d/%s.pdf".formatted(customerId, UUID.randomUUID());
PutObjectPresignRequest request = PutObjectPresignRequest.builder()
.signatureDuration(ttl)
.putObjectRequest(b -> b.bucket(bucket).key(key).contentType("application/pdf"))
.build();
return presigner.presignPutObject(request).url();
}
간단한 exception 클래스:
public class ForbiddenFileAccessException extends RuntimeException {
public ForbiddenFileAccessException(long customerId, String key) {
super("Customer %d cannot access %s".formatted(customerId, key));
}
}
코드의 작동 방식:
-
startsWith검사 줄은 애플리케이션 계층에서의 권한 부여입니다. S3은 애플리케이션이invoices/*전체를 읽을 수 있다는 것만 알 뿐입니다. S3은 고객 42가 고객 43의 청구서를 볼 수 없다는 것을 알 수 없습니다. 이 줄을 제거하면 누구든 key를 추측하여 다른 사람의 파일을 다운로드할 수 있습니다. -
signatureDuration은 URL의 만료 시간을 설정합니다. 다운로드 파일의 경우 몇 분이면 보통 충분합니다. -
Presigned PUT의 경우 클라이언트는 정확한
Content-Type: application/pdf헤더를 전송해야 합니다. 그렇지 않으면 서명이 일치하지 않습니다.
생성된 URL의 형태:
https://nonla-invoices-prod.s3.ap-southeast-1.amazonaws.com/invoices/42/7f3c...pdf
?X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Date=20261013T080000Z
&X-Amz-Expires=300
&X-Amz-Credential=...
&X-Amz-Signature=...
클라이언트는 presigned PUT을 사용하여 S3로 직접 업로드합니다:
curl -X PUT -H "Content-Type: application/pdf" \
--upload-file invoice.pdf "<presigned-url>"
주의: Presigned URL은 서명자의 권한을 가집니다. 애플리케이션의 IAM 역할이
s3:GetObject권한을 잃으면 발행된 URL도 유효하지 않습니다. 최대 유효 기간은 7일입니다. IAM 역할의 임시 자격 증명으로 서명할 때 URL은 더 빨리 만료되며, 역할의 세션(보통 몇 시간)과 함께 만료됩니다. 장기간 사용하기 위해 presigned URL을 데이터베이스에 저장하지 마세요. key를 저장하고 필요할 때마다 새 URL을 생성하세요.
6. LocalStack으로 테스트하기
Docker에서 시뮬레이션된 S3을 실행하기 위해 Testcontainers를 추가합니다:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>localstack</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
Spring Boot 3.x는 이미 Testcontainers 버전을 관리하므로 version을 작성할 필요가 없습니다.
@Testcontainers
class InvoiceStorageServiceTest {
@Container
static LocalStackContainer localstack =
new LocalStackContainer(DockerImageName.parse("localstack/localstack:3.8"))
.withServices(LocalStackContainer.Service.S3);
static InvoiceStorageService service;
@BeforeAll
static void setUp() {
var credentials = StaticCredentialsProvider.create(
AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()));
var region = Region.of(localstack.getRegion());
S3Client s3 = S3Client.builder()
.endpointOverride(localstack.getEndpoint())
.credentialsProvider(credentials)
.region(region)
.forcePathStyle(true)
.build();
S3Presigner presigner = S3Presigner.builder()
.endpointOverride(localstack.getEndpoint())
.credentialsProvider(credentials)
.region(region)
.build();
s3.createBucket(b -> b.bucket("test-invoices"));
service = new InvoiceStorageService(s3, presigner, "test-invoices");
}
@Test
void givenUploadedInvoice_whenDownload_thenContentIsSame() {
byte[] pdf = "%PDF-1.7 fake invoice".getBytes(StandardCharsets.UTF_8);
String key = service.upload(42L, new ByteArrayInputStream(pdf), pdf.length);
assertThat(key).startsWith("invoices/42/").endsWith(".pdf");
assertThat(service.download(key)).isEqualTo(pdf);
}
@Test
void givenOwnKey_whenPresign_thenUrlHasExpiry() {
URL url = service.presignDownload(42L, "invoices/42/a.pdf", Duration.ofMinutes(5));
assertThat(url.getQuery()).contains("X-Amz-Expires=300");
}
@Test
void givenOtherCustomersKey_whenPresign_thenForbidden() {
assertThatThrownBy(() ->
service.presignDownload(42L, "invoices/43/b.pdf", Duration.ofMinutes(5)))
.isInstanceOf(ForbiddenFileAccessException.class);
}
}
주목할 만한 몇 가지 세부 사항:
-
forcePathStyle(true)는http://localhost:4566/test-invoices/...형태의 버킷 액세스를 허용합니다. LocalStack은test-invoices.localhost같은 서브도메인을 확인할 수 없기 때문에 이 형태가 필요합니다. -
세 번째 테스트가 보안 측면에서 가장 중요합니다. 고객 42가 고객 43의 파일에 대한 URL을 얻을 수 없도록 보장합니다.
LocalStack의 무료 버전은 IAM 정책을 평가하지 않습니다. 따라서 IAM Policy Simulator를 사용하거나 staging 환경에서 실제 정책을 테스트해야 합니다.
7. 일반적인 오류
-
AccessDenied비록s3:GetObject이 있어도: ARN에/*가 누락되었거나, 버킷 이름이 잘못되었거나, 또는 버킷 정책Deny가 차단하고 있습니다. 버킷이 SSE-KMS를 사용하는 경우 역할에는 해당 KMS key에 대한kms:Decrypt권한도 필요합니다. -
object가 없을 때
404대신403: 역할에s3:ListBucket이 누락되었습니다. 이는 S3의 올바른 동작이지 버그가 아닙니다. -
presigned PUT으로
SignatureDoesNotMatch: 클라이언트가 서명할 때와 다른Content-Type을 보냈거나, presigner가 버킷 region과 다른 region으로 구성되었습니다. -
ACL
public-read로 공개하기:BucketOwnerEnforced의 경우 ACL이 있는 요청이 거부됩니다. 예를 들어 상품 이미지처럼 정말로 공개 콘텐츠가 필요한 경우 버킷 앞에 CloudFront를 배치하고 Origin Access Control을 사용하세요. Block Public Access를 비활성화하지 마세요. -
각 요청마다 새
S3Client만들기: 클라이언트는 connection pool을 유지하므로 공유해야 합니다. 2.2 항목처럼 singleton bean으로 선언하세요.
주의: S3은 파일 시스템이 아닙니다. “rename” 또는 “append” 작업이 없습니다. 이름을 바꾼다는 것은 새 key로 복사한 후 이전 key를 삭제하는 것입니다. 파일에 계속 추가로 쓸 필요가 있으면 S3은 적절한 선택이 아닙니다.
8. 결론
이 글에서는 customerId에 따라 key를 설계하여 Spring Boot에서 AWS SDK v2를 사용하여 S3에 파일을 업로드, 다운로드 및 삭제했습니다. 또한 여러 계층에서 권한을 구성했습니다:
- Block Public Access와 ACL 비활성화로 버킷을 항상 private으로 유지합니다.
- 애플리케이션 역할에 대한 최소 권한의 IAM 정책입니다.
- TLS 강제 같은 공통 규칙에 대한 버킷 정책입니다.
- Presigned URL을 발행하기 전에 애플리케이션 계층에서 소유권 확인입니다.
가장 기억해야 할 점: S3은 애플리케이션이 무엇을 할 수 있는지만 알 뿐입니다. 어떤 사용자가 어떤 파일을 볼 수 있는지는 여전히 작성한 코드의 책임입니다. 이것이 사용자가 다른 사람의 파일에 액세스하는 경우를 다루는 테스트를 작성해야 하는 이유입니다.