diff --git a/.env.example b/.env.example index 7932998c..e83f4b92 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,11 @@ LOCAL_STORAGE_BASE_PATH=./local-storage LOCAL_STORAGE_BASE_URL=http://localhost:8080 LOCAL_STORAGE_UPLOAD_URL_EXPIRY_SECONDS=600 -# type=s3일 때. 자격증명은 앱에 넣지 않는다 — EC2 배포 시 인스턴스 프로필(IAM 역할)에서 자동으로 가져온다 -AWS_S3_BUCKET=replace-with-bucket-name -AWS_REGION=ap-northeast-2 +# type=s3일 때 (S3 호환 스토리지 = Cloudflare R2). R2는 IAM 역할이 없어 API 토큰 키를 직접 주입한다 +R2_BUCKET=replace-with-bucket-name +R2_ENDPOINT=https://.r2.cloudflarestorage.com +R2_ACCESS_KEY=replace-with-r2-access-key +R2_SECRET_KEY=replace-with-r2-secret-key +# R2는 region을 auto로 고정한다 (변경 불필요) +R2_REGION=auto S3_UPLOAD_URL_EXPIRY_SECONDS=600 diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 7b1538c4..16f9858f 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -456,7 +456,7 @@ public class UserAuthentication extends AbstractAuthenticationToken { - `@Data`·`@Setter`는 쓰지 않는다. 객체는 불변을 기본으로 하고, 상태 변경은 의도가 드러나는 메서드(`entity.delete()` 등)로 표현한다. - `record`에는 Lombok을 붙이지 않는다. 접근자·`equals`/`hashCode`가 이미 제공된다. -- 스프링 빈의 생성자 주입은 **항상 `@RequiredArgsConstructor`**로 한다. 생성자를 직접 쓰는 경우는 하나뿐이다 — 주입받은 값으로 다른 필드를 초기화해야 할 때. 예: `JwtProperties`로 `SecretKey`를 만드는 `JwtProvider`. +- 스프링 빈의 생성자 주입은 **항상 `@RequiredArgsConstructor`**로 한다. 생성자를 직접 쓰는 경우는 하나뿐이다 — 주입받은 값으로 다른 필드를 초기화해야 할 때. 예: `JwtProperties`로 `SecretKey`를 만드는 `JwtProvider`. 단 초기화할 대상이 외부 SDK 클라이언트라면 생성자에서 만들지 않고 `@Bean`으로 등록한다(2-12절). - 주입할 빈을 지목해야 하면 **필드에** `@Qualifier`를 붙인다. 루트 `lombok.config`의 `lombok.copyableAnnotations`가 이를 생성자 파라미터로 복사한다. ```java @@ -512,6 +512,32 @@ public class S3FileStorageClient implements FileStorageClient { ... } public class LocalFileStorageClient implements FileStorageClient { ... } ``` +- **외부 SDK 클라이언트(`S3Client`, `S3Presigner` 등)는 구현체 생성자에서 만들지 않고, 같은 패키지의 설정 클래스(`@Configuration`)에서 `@Bean`으로 등록해 주입받는다.** 설정 클래스에도 구현체와 같은 `@ConditionalOnProperty`를 붙인다. SDK 클라이언트는 `close()`가 필요한 자원인데, 빈으로 등록하면 종료 시 스프링이 대신 호출한다. + +```java +// infrastructure:client — client/file/s3 +@Configuration +@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "s3") +public class S3StorageConfig { + + @Bean + public S3Client s3Client(S3FileStorageProperties properties) { ... } + + @Bean + public S3Presigner s3Presigner(S3FileStorageProperties properties) { ... } +} + +@Component +@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "s3") +@RequiredArgsConstructor +public class S3FileStorageClient implements FileStorageClient { + + private final S3FileStorageProperties properties; + private final S3Client s3Client; + private final S3Presigner s3Presigner; +} +``` + - **임시 구현체(추후 다른 구현체로 완전히 교체될 코드)에는 "무엇으로 전환하면 이 코드를 지운다"는 클래스 주석을 남긴다.** 그 임시 구현체에 딸린 전용 엔드포인트·메서드(예: 로컬 전용 업로드 수신 API)도 같은 문구로 표시해서, 실제 전환 작업을 할 때 검색 한 번으로 같이 지울 대상을 찾을 수 있게 한다. ```java diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java index b57b6c16..b329be29 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java @@ -6,10 +6,9 @@ import java.time.ZoneId; import kr.ac.kookmin.stream.internal.domain.file.client.FileStorageClient; import kr.ac.kookmin.stream.internal.domain.file.domain.UploadUrl; +import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; -import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; -import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; @@ -18,35 +17,19 @@ import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; /** - * S3 기반 구현체. presigned URL은 클라이언트가 S3에 직접 PUT하는 용도라, 로컬 구현체와 달리 - * 서버가 파일 바이트를 직접 받는 write(...)는 지원하지 않는다. - * 자격증명은 AWS 기본 자격증명 체인({@link DefaultCredentialsProvider})을 사용한다 — - * EC2에 배포하면 인스턴스 프로필(IAM 역할)에서 자동으로 자격증명을 가져오므로 액세스 키를 앱에 직접 넣지 않는다. + * S3 호환 스토리지(Cloudflare R2) 기반 구현체. presigned URL은 클라이언트가 스토리지에 직접 PUT하는 용도라, + * 로컬 구현체와 달리 서버가 파일 바이트를 직접 받는 write(...)는 지원하지 않는다. + * R2 엔드포인트·자격증명 설정은 {@link S3StorageConfig}가 만드는 {@link S3Client}/{@link S3Presigner} 빈에 있다. */ @Component @ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "s3") +@RequiredArgsConstructor public class S3FileStorageClient implements FileStorageClient { private final S3FileStorageProperties properties; private final S3Client s3Client; private final S3Presigner s3Presigner; - public S3FileStorageClient(S3FileStorageProperties properties) { - this.properties = properties; - - DefaultCredentialsProvider credentialsProvider = DefaultCredentialsProvider.create(); - Region region = Region.of(properties.region()); - - this.s3Client = S3Client.builder() - .region(region) - .credentialsProvider(credentialsProvider) - .build(); - this.s3Presigner = S3Presigner.builder() - .region(region) - .credentialsProvider(credentialsProvider) - .build(); - } - @Override public UploadUrl issuePresignedUrl(String fileKey, String contentType) { PutObjectRequest putObjectRequest = PutObjectRequest.builder() diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageProperties.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageProperties.java index d676c085..fb64e8d4 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageProperties.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageProperties.java @@ -6,5 +6,8 @@ public record S3FileStorageProperties( String bucket, String region, + String endpoint, + String accessKey, + String secretKey, long uploadUrlExpirySeconds ) {} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3StorageConfig.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3StorageConfig.java new file mode 100644 index 00000000..25f28d09 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3StorageConfig.java @@ -0,0 +1,44 @@ +package kr.ac.kookmin.stream.client.file.s3; + +import java.net.URI; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +/** + * {@link S3FileStorageClient}가 쓰는 S3 SDK 클라이언트 빈 설정. 빈으로 등록해 두면 종료 시 스프링이 {@code close()}를 호출한다. + * R2는 AWS S3 API와 호환되므로 {@code endpointOverride}로 R2 엔드포인트를 지정하고 region은 {@code auto}를 쓴다. + * R2에는 EC2 인스턴스 프로필 같은 자동 자격증명 체인이 없어, R2 API 토큰의 액세스 키·시크릿 키를 정적으로 주입한다. + */ +@Configuration +@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "s3") +public class S3StorageConfig { + + @Bean + public S3Client s3Client(S3FileStorageProperties properties) { + return S3Client.builder() + .region(Region.of(properties.region())) + .endpointOverride(URI.create(properties.endpoint())) + .credentialsProvider(credentialsProvider(properties)) + .build(); + } + + @Bean + public S3Presigner s3Presigner(S3FileStorageProperties properties) { + return S3Presigner.builder() + .region(Region.of(properties.region())) + .endpointOverride(URI.create(properties.endpoint())) + .credentialsProvider(credentialsProvider(properties)) + .build(); + } + + private StaticCredentialsProvider credentialsProvider(S3FileStorageProperties properties) { + return StaticCredentialsProvider.create( + AwsBasicCredentials.create(properties.accessKey(), properties.secretKey())); + } +} diff --git a/infrastructure/client/src/main/resources/application-infrastructure-client.yml b/infrastructure/client/src/main/resources/application-infrastructure-client.yml index 0deba86e..95f32b32 100644 --- a/infrastructure/client/src/main/resources/application-infrastructure-client.yml +++ b/infrastructure/client/src/main/resources/application-infrastructure-client.yml @@ -6,6 +6,9 @@ file: base-url: ${LOCAL_STORAGE_BASE_URL:http://localhost:8080} upload-url-expiry-seconds: ${LOCAL_STORAGE_UPLOAD_URL_EXPIRY_SECONDS:600} s3: - bucket: ${AWS_S3_BUCKET:} - region: ${AWS_REGION:} + bucket: ${R2_BUCKET:} + region: ${R2_REGION:auto} + endpoint: ${R2_ENDPOINT:} + access-key: ${R2_ACCESS_KEY:} + secret-key: ${R2_SECRET_KEY:} upload-url-expiry-seconds: ${S3_UPLOAD_URL_EXPIRY_SECONDS:600}