Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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://<account_id>.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
28 changes: 27 additions & 1 deletion docs/conventions/coding-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@
public record S3FileStorageProperties(
String bucket,
String region,
String endpoint,
String accessKey,
String secretKey,
long uploadUrlExpirySeconds
) {}
Original file line number Diff line number Diff line change
@@ -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()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}