diff --git a/.env.example b/.env.example index 9bcd8c1..7932998 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,16 @@ JWT_ACCESS_TOKEN_EXPIRY=3600000 DB_URL=jdbc:mysql://localhost:3306/stream?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 DB_USERNAME=stream DB_PASSWORD=replace-with-db-password + +### infrastructure:client — 파일 스토리지 (local | s3) ### +FILE_STORAGE_TYPE=local + +# type=local일 때 +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 +S3_UPLOAD_URL_EXPIRY_SECONDS=600 diff --git a/api/admin-api/build.gradle.kts b/api/admin-api/build.gradle.kts index b649533..c014858 100644 --- a/api/admin-api/build.gradle.kts +++ b/api/admin-api/build.gradle.kts @@ -7,6 +7,7 @@ description = "운영진 콘솔 — ADMIN, /v1/admin/**" dependencies { implementation(project(":api:common-api")) implementation(project(":core:common")) + implementation(project(":core:domain:internal")) implementation(project(":gateway:auth")) implementation(project(":gateway:logging")) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminFileController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminFileController.java new file mode 100644 index 0000000..2d9d4a9 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminFileController.java @@ -0,0 +1,35 @@ +package kr.ac.kookmin.stream.internal; + +import jakarta.validation.Valid; +import kr.ac.kookmin.stream.ApiResponse; +import kr.ac.kookmin.stream.common.PrincipalProvider; +import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult; +import kr.ac.kookmin.stream.internal.domain.file.service.FileService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/files") +@RequiredArgsConstructor +public class AdminFileController { + + private final FileService fileService; + private final PrincipalProvider principalProvider; + + @PostMapping("/presigned-url") + public ApiResponse issuePresignedUrl(@Valid @RequestBody FileUploadUrlIssueRequest request) { + FileUploadUrlIssueResult result = fileService.issuePresignedUrl(request.toCommand(principalProvider.userId())); + return ApiResponse.success(FileUploadUrlIssueResponse.from(result)); + } + + @DeleteMapping("/{fileId}") + public ApiResponse delete(@PathVariable Long fileId) { + fileService.delete(fileId); + return ApiResponse.success(); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminLocalFileUploadController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminLocalFileUploadController.java new file mode 100644 index 0000000..475b788 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminLocalFileUploadController.java @@ -0,0 +1,37 @@ +package kr.ac.kookmin.stream.internal; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import kr.ac.kookmin.stream.ApiResponse; +import kr.ac.kookmin.stream.internal.domain.file.service.FileService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * S3 연동 전 임시 컨트롤러. presigned-url 발급 응답의 uploadUrl이 이 경로를 가리킨다. + * S3로 전환하면 이 클래스와 LocalFileStorageClient의 로컬 구현을 함께 제거한다. + */ +@RestController +@RequestMapping("/v1/admin/files") +@RequiredArgsConstructor +public class AdminLocalFileUploadController { + + private static final String LOCAL_UPLOAD_PATH = "/local-upload/"; + + private final FileService fileService; + + @PutMapping("/local-upload/**") + public ApiResponse receiveLocalUpload(HttpServletRequest request) throws IOException { + String fileKey = extractFileKey(request); + fileService.receiveUpload(fileKey, request.getInputStream()); + return ApiResponse.success(); + } + + private String extractFileKey(HttpServletRequest request) { + String uri = request.getRequestURI(); + int index = uri.indexOf(LOCAL_UPLOAD_PATH); + return uri.substring(index + LOCAL_UPLOAD_PATH.length()); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/FileUploadUrlIssueRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/FileUploadUrlIssueRequest.java new file mode 100644 index 0000000..837f83d --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/FileUploadUrlIssueRequest.java @@ -0,0 +1,25 @@ +package kr.ac.kookmin.stream.internal; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import kr.ac.kookmin.stream.internal.domain.file.domain.FileCategory; +import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueCommand; + +public record FileUploadUrlIssueRequest( + @NotBlank(message = "파일명은 필수 입력값입니다.") + String originalName, + + @NotBlank(message = "MIME 타입은 필수 입력값입니다.") + String contentType, + + @Positive(message = "파일 크기는 0보다 커야 합니다.") + long fileSize, + + @NotNull(message = "파일 카테고리는 필수 입력값입니다.") + FileCategory category +) { + public FileUploadUrlIssueCommand toCommand(Long uploaderId) { + return new FileUploadUrlIssueCommand(originalName, contentType, fileSize, category, uploaderId); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/FileUploadUrlIssueResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/FileUploadUrlIssueResponse.java new file mode 100644 index 0000000..b476b8b --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/FileUploadUrlIssueResponse.java @@ -0,0 +1,15 @@ +package kr.ac.kookmin.stream.internal; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult; + +public record FileUploadUrlIssueResponse( + Long fileId, + String uploadUrl, + String fileKey, + LocalDateTime expiresAt +) { + public static FileUploadUrlIssueResponse from(FileUploadUrlIssueResult result) { + return new FileUploadUrlIssueResponse(result.fileId(), result.uploadUrl(), result.fileKey(), result.expiresAt()); + } +} diff --git a/bootstrap/src/main/resources/application.yaml b/bootstrap/src/main/resources/application.yaml index 73cc461..3d7c783 100644 --- a/bootstrap/src/main/resources/application.yaml +++ b/bootstrap/src/main/resources/application.yaml @@ -5,3 +5,4 @@ spring: import: - classpath:application-gateway-auth.yml - classpath:application-infrastructure-db.yml + - classpath:application-infrastructure-client.yml diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/client/FileStorageClient.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/client/FileStorageClient.java new file mode 100644 index 0000000..2ccc9ff --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/client/FileStorageClient.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.internal.domain.file.client; + +import java.io.InputStream; +import kr.ac.kookmin.stream.internal.domain.file.domain.UploadUrl; + +public interface FileStorageClient { + UploadUrl issuePresignedUrl(String fileKey, String contentType); + void write(String fileKey, InputStream content); + void deleteObject(String fileKey); +} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/File.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/File.java index e3bc2d2..d2c6cc2 100644 --- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/File.java +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/File.java @@ -18,6 +18,17 @@ public class File { private String contentType; private Long uploaderId; + public static File create( + String fileKey, + FileCategory category, + String originalName, + long fileSize, + String contentType, + Long uploaderId + ) { + return new File(null, fileKey, category, originalName, fileSize, contentType, uploaderId); + } + public static File of( Long id, String fileKey, diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileErrorCode.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileErrorCode.java new file mode 100644 index 0000000..f279a08 --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileErrorCode.java @@ -0,0 +1,20 @@ +package kr.ac.kookmin.stream.internal.domain.file.domain; + +import kr.ac.kookmin.stream.common.ErrorCode; +import kr.ac.kookmin.stream.common.ErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +@AllArgsConstructor +public enum FileErrorCode implements ErrorCode { + + FILE_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 파일입니다."), + UNSUPPORTED_FILE_EXTENSION(ErrorStatus.BAD_REQUEST, "지원하지 않는 파일 형식입니다."), + FILE_SIZE_EXCEEDED(ErrorStatus.BAD_REQUEST, "파일의 최대 업로드 용량을 초과했습니다."); + + private final int status; + private final String message; +} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueCommand.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueCommand.java new file mode 100644 index 0000000..8c2bbc5 --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueCommand.java @@ -0,0 +1,9 @@ +package kr.ac.kookmin.stream.internal.domain.file.domain; + +public record FileUploadUrlIssueCommand( + String originalName, + String contentType, + long fileSize, + FileCategory category, + Long uploaderId +) {} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueResult.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueResult.java new file mode 100644 index 0000000..6e2fe91 --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueResult.java @@ -0,0 +1,14 @@ +package kr.ac.kookmin.stream.internal.domain.file.domain; + +import java.time.LocalDateTime; + +public record FileUploadUrlIssueResult( + Long fileId, + String uploadUrl, + String fileKey, + LocalDateTime expiresAt +) { + public static FileUploadUrlIssueResult of(File file, UploadUrl uploadUrl) { + return new FileUploadUrlIssueResult(file.getId(), uploadUrl.url(), file.getFileKey(), uploadUrl.expiresAt()); + } +} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/UploadUrl.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/UploadUrl.java new file mode 100644 index 0000000..86a331f --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/UploadUrl.java @@ -0,0 +1,5 @@ +package kr.ac.kookmin.stream.internal.domain.file.domain; + +import java.time.LocalDateTime; + +public record UploadUrl(String url, LocalDateTime expiresAt) {} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileRepository.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileRepository.java new file mode 100644 index 0000000..a56b7ff --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileRepository.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.internal.domain.file.repository; + +import java.util.Optional; +import kr.ac.kookmin.stream.internal.domain.file.domain.File; + +public interface FileRepository { + Optional findById(Long id); + Optional findByFileKey(String fileKey); + File save(File file); + void deleteById(Long id); +} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/FileService.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/FileService.java new file mode 100644 index 0000000..e075fb0 --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/FileService.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.internal.domain.file.service; + +import java.io.InputStream; +import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueCommand; +import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult; + +public interface FileService { + FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand command); + void receiveUpload(String fileKey, InputStream content); + void delete(Long fileId); +} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileServiceImpl.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileServiceImpl.java new file mode 100644 index 0000000..9c82c60 --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileServiceImpl.java @@ -0,0 +1,70 @@ +package kr.ac.kookmin.stream.internal.domain.file.service.impl; + +import java.io.InputStream; +import java.util.UUID; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.internal.domain.file.domain.File; +import kr.ac.kookmin.stream.internal.domain.file.domain.FileErrorCode; +import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueCommand; +import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult; +import kr.ac.kookmin.stream.internal.domain.file.domain.UploadUrl; +import kr.ac.kookmin.stream.internal.domain.file.client.FileStorageClient; +import kr.ac.kookmin.stream.internal.domain.file.repository.FileRepository; +import kr.ac.kookmin.stream.internal.domain.file.service.FileService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +class FileServiceImpl implements FileService { + + private static final String FILE_KEY_PREFIX = "files/"; + + private final FileRepository fileRepository; + private final FileStorageClient fileStorageClient; + + @Override + @Transactional + public FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand command) { + FileUploadPolicy.validate(command.category(), command.originalName(), command.fileSize()); + + String fileKey = generateFileKey(command.originalName()); + UploadUrl uploadUrl = fileStorageClient.issuePresignedUrl(fileKey, command.contentType()); + + File file = File.create( + fileKey, + command.category(), + command.originalName(), + command.fileSize(), + command.contentType(), + command.uploaderId() + ); + File saved = fileRepository.save(file); + + return FileUploadUrlIssueResult.of(saved, uploadUrl); + } + + @Override + @Transactional(readOnly = true) + public void receiveUpload(String fileKey, InputStream content) { + fileRepository.findByFileKey(fileKey) + .orElseThrow(() -> new BusinessException(FileErrorCode.FILE_NOT_FOUND)); + fileStorageClient.write(fileKey, content); + } + + @Override + @Transactional + public void delete(Long fileId) { + File file = fileRepository.findById(fileId) + .orElseThrow(() -> new BusinessException(FileErrorCode.FILE_NOT_FOUND)); + fileStorageClient.deleteObject(file.getFileKey()); + fileRepository.deleteById(fileId); + } + + private String generateFileKey(String originalName) { + String extension = FileUploadPolicy.extractExtension(originalName); + String key = UUID.randomUUID().toString(); + return extension.isEmpty() ? FILE_KEY_PREFIX + key : FILE_KEY_PREFIX + key + "." + extension; + } +} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileUploadPolicy.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileUploadPolicy.java new file mode 100644 index 0000000..851084b --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileUploadPolicy.java @@ -0,0 +1,59 @@ +package kr.ac.kookmin.stream.internal.domain.file.service.impl; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.internal.domain.file.domain.FileCategory; +import kr.ac.kookmin.stream.internal.domain.file.domain.FileErrorCode; + +final class FileUploadPolicy { + + private static final Set IMAGE_EXTENSIONS = Set.of("jpg", "jpeg", "png", "webp"); + private static final Set ATTACHMENT_EXTENSIONS = Set.of("pdf", "doc", "docx", "hwp", "zip"); + + private static final long IMAGE_MAX_FILE_SIZE = 10 * 1024 * 1024; + private static final long ATTACHMENT_MAX_FILE_SIZE = 20 * 1024 * 1024; + + private static final Map> ALLOWED_EXTENSIONS = Map.of( + FileCategory.TEMP, union(IMAGE_EXTENSIONS, ATTACHMENT_EXTENSIONS), + FileCategory.NOTICE_ATTACHMENT, ATTACHMENT_EXTENSIONS, + FileCategory.NOTICE_IMAGE, IMAGE_EXTENSIONS, + FileCategory.EVENT_IMAGE, IMAGE_EXTENSIONS, + FileCategory.ARCHIVE_IMAGE, IMAGE_EXTENSIONS + ); + + private static final Map MAX_FILE_SIZE = Map.of( + FileCategory.TEMP, ATTACHMENT_MAX_FILE_SIZE, + FileCategory.NOTICE_ATTACHMENT, ATTACHMENT_MAX_FILE_SIZE, + FileCategory.NOTICE_IMAGE, IMAGE_MAX_FILE_SIZE, + FileCategory.EVENT_IMAGE, IMAGE_MAX_FILE_SIZE, + FileCategory.ARCHIVE_IMAGE, IMAGE_MAX_FILE_SIZE + ); + + private FileUploadPolicy() {} + + static void validate(FileCategory category, String originalName, long fileSize) { + String extension = extractExtension(originalName); + if (!ALLOWED_EXTENSIONS.get(category).contains(extension)) { + throw new BusinessException(FileErrorCode.UNSUPPORTED_FILE_EXTENSION); + } + if (fileSize <= 0 || fileSize > MAX_FILE_SIZE.get(category)) { + throw new BusinessException(FileErrorCode.FILE_SIZE_EXCEEDED); + } + } + + static String extractExtension(String originalName) { + int dotIndex = originalName.lastIndexOf('.'); + if (dotIndex == -1 || dotIndex == originalName.length() - 1) { + return ""; + } + return originalName.substring(dotIndex + 1).toLowerCase(); + } + + private static Set union(Set first, Set second) { + return Stream.concat(first.stream(), second.stream()) + .collect(Collectors.toUnmodifiableSet()); + } +} diff --git a/docs/conventions/00-index.md b/docs/conventions/00-index.md index fd4011e..4a4b172 100644 --- a/docs/conventions/00-index.md +++ b/docs/conventions/00-index.md @@ -12,7 +12,7 @@ Java 21 + Spring Boot 4.1 + Spring Modulith 기반, 단일 학생회 플랫폼 | 문서 | 다루는 내용 | 언제 참조하는가 | | --- | --- | --- | | [`architecture.md`](./architecture.md) | 모듈 구조, 의존 방향, Modulith 경계 규칙, 레이어, 도메인 간 통신(UseCase·이벤트·아웃박스) | 새 모듈/도메인 설계, 의존성 리뷰 | -| [`coding-style.md`](./coding-style.md) | 네이밍, 도메인 객체(record)/DTO/Command/Entity/Repository/Service/UseCase 패턴, 정적 팩토리·Lombok, Validation | 실제 코드 작성/리뷰 | +| [`coding-style.md`](./coding-style.md) | 네이밍, 도메인 객체(record)/DTO/Command/Entity/Repository/Client/Service/UseCase 패턴, 정적 팩토리·Lombok, Validation | 실제 코드 작성/리뷰 | | [`error-handling.md`](./error-handling.md) | `ErrorCode`/`BusinessException`, `GlobalExceptionHandler`, `@ApiErrorCode` Swagger 문서화 | 에러 코드 추가, 예외 처리 | | [`config-and-auth.md`](./config-and-auth.md) | 설정 바인딩, 2계층 권한 모델(role + 부서), `PrincipalProvider`, `DepartmentAccessChecker` | 설정값 추가, 인증·인가 작업 | | [`logging.md`](./logging.md) | MDC 요청 추적, `MdcFilter`/`LoggingFilter`, 로그 레벨, JSON 로깅 | 로깅 코드, MDC 필드 추가 | @@ -29,6 +29,7 @@ Java 21 + Spring Boot 4.1 + Spring Modulith 기반, 단일 학생회 플랫폼 - "부서 권한으로 승인 제한" → `config-and-auth.md` 4-4절 (`DepartmentAccessChecker`) - "soft delete 컬럼 인덱스/유니크" → `flyway-migration.md` 3-4절 - "객체를 어떻게 생성하지 / Lombok 어디까지" → `coding-style.md` 2-10·2-11절 +- "외부 클라이언트 구현체가 여러 개인데 일부 메서드가 특정 구현체에서 의미 없음" → `coding-style.md` 2-12절 (`UnsupportedOperationException` + `@ConditionalOnProperty` 스위칭) - "에러 코드 추가" → `error-handling.md` - "도메인 내부 구현 숨기기 / 패키지 구조" → `architecture.md` 4-3절 (`domain/{도메인}/{domain|repository|service|service.impl}`, OPEN 모듈 + ArchUnit) diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index ccc5d9b..8dedcde 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -420,6 +420,53 @@ config.stopBubbling = true lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier ``` +### 2-12. Client (외부 스토리지·API 클라이언트) + +Repository(2-6절)와 같은 구조다 — `core:domain`에 인터페이스(공개), `infrastructure:client`에 구현체. + +```java +// core:domain:internal — domain/file/client (공개) +public interface FileStorageClient { + UploadUrl issuePresignedUrl(String fileKey, String contentType); + void write(String fileKey, InputStream content); + void deleteObject(String fileKey); +} +``` + +- **구현체가 여러 개이고 그중 일부 메서드가 특정 구현체에서 의미가 없으면, 인터페이스를 쪼개지 않고 그 구현체에서 `UnsupportedOperationException` + 사유 주석으로 막는다.** 인터페이스 분리는 그 구현체가 계속 쓰일 때만 이득이 크다 — 임시 구현체처럼 나중에 통째로 걷어낼 코드라면 지금 쪼개봤자 걷어낼 때 그 분리도 같이 없어진다. + +```java +// infrastructure:client — S3FileStorageClient +// S3는 클라이언트가 presigned URL로 직접 업로드하므로 서버가 파일 바이트를 받을 일이 없다 +@Override +public void write(String fileKey, InputStream content) { + throw new UnsupportedOperationException("S3는 클라이언트가 presigned URL로 직접 업로드하므로 서버가 파일을 받지 않는다"); +} +``` + +- **한 포트에 구현체가 여러 개면 `@ConditionalOnProperty`로 하나만 Bean으로 띄운다**(`@Profile`이 아니라 — 로컬/운영을 나누는 게 아니라 같은 환경 안에서 설정값으로 고르는 것이므로). + +```java +@Component +@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "s3") +public class S3FileStorageClient implements FileStorageClient { ... } + +@Component +@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "local", matchIfMissing = true) +public class LocalFileStorageClient implements FileStorageClient { ... } +``` + +- **임시 구현체(추후 다른 구현체로 완전히 교체될 코드)에는 "무엇으로 전환하면 이 코드를 지운다"는 클래스 주석을 남긴다.** 그 임시 구현체에 딸린 전용 엔드포인트·메서드(예: 로컬 전용 업로드 수신 API)도 같은 문구로 표시해서, 실제 전환 작업을 할 때 검색 한 번으로 같이 지울 대상을 찾을 수 있게 한다. + +```java +/** + * 로컬 디스크 기반 임시 구현체. S3 연동 시 이 클래스와 "임시 로컬 업로드 엔드포인트"를 함께 제거한다. + */ +@Component +@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "local", matchIfMissing = true) +public class LocalFileStorageClient implements FileStorageClient { ... } +``` + --- ## 3. Validation diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 968f0d2..da3dff5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,12 +5,14 @@ jjwtVersion = "0.12.6" archunitVersion = "1.3.0" junitBomVersion = "6.0.0" lombokVersion = "1.18.36" +awsSdkVersion = "2.54.15" [libraries] # BOMs — import as `platform(...)` in modules that don't apply the Boot plugin directly springBootDependenciesBom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "springBootVersion" } springModulithBom = { module = "org.springframework.modulith:spring-modulith-bom", version.ref = "springModulithVersion" } junitBom = { module = "org.junit:junit-bom", version.ref = "junitBomVersion" } +awsSdkBom = { module = "software.amazon.awssdk:bom", version.ref = "awsSdkVersion" } # Spring Boot starters (version managed by springBootDependenciesBom / the Boot plugin) springBootStarter = { module = "org.springframework.boot:spring-boot-starter" } @@ -40,6 +42,8 @@ jacksonDatabind = { module = "com.fasterxml.jackson.core:jackson-databind" } flywayMysql = { module = "org.flywaydb:flyway-mysql" } mysqlConnectorJ = { module = "com.mysql:mysql-connector-j" } +awsS3 = { module = "software.amazon.awssdk:s3" } + jjwtApi = { module = "io.jsonwebtoken:jjwt-api", version.ref = "jjwtVersion" } jjwtImpl = { module = "io.jsonwebtoken:jjwt-impl", version.ref = "jjwtVersion" } jjwtJackson = { module = "io.jsonwebtoken:jjwt-jackson", version.ref = "jjwtVersion" } diff --git a/infrastructure/client/build.gradle.kts b/infrastructure/client/build.gradle.kts index 9db7ed1..f7205cb 100644 --- a/infrastructure/client/build.gradle.kts +++ b/infrastructure/client/build.gradle.kts @@ -6,11 +6,16 @@ description = "외부 API 클라이언트 구현체" dependencies { implementation(project(":core:common")) + implementation(project(":core:domain:internal")) implementation(platform(libs.springBootDependenciesBom)) + implementation(libs.springBootStarter) implementation(libs.springWeb) implementation(libs.jacksonDatabind) + implementation(platform(libs.awsSdkBom)) + implementation(libs.awsS3) + testImplementation(platform(libs.springBootDependenciesBom)) testImplementation(libs.springBootStarterTest) testRuntimeOnly(libs.junitPlatformLauncher) diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java new file mode 100644 index 0000000..8aaf241 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java @@ -0,0 +1,58 @@ +package kr.ac.kookmin.stream.client.file.local; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.internal.domain.file.domain.UploadUrl; +import kr.ac.kookmin.stream.internal.domain.file.client.FileStorageClient; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * 로컬 디스크 기반 임시 구현체. S3 연동 시 이 클래스와 "임시 로컬 업로드 엔드포인트"를 함께 제거한다. + */ +@Component +@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "local", matchIfMissing = true) +@RequiredArgsConstructor +public class LocalFileStorageClient implements FileStorageClient { + + private static final String LOCAL_UPLOAD_PATH = "/v1/admin/files/local-upload/"; + + private final LocalFileStorageProperties properties; + + @Override + public UploadUrl issuePresignedUrl(String fileKey, String contentType) { + String url = properties.baseUrl() + LOCAL_UPLOAD_PATH + fileKey; + LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(properties.uploadUrlExpirySeconds()); + return new UploadUrl(url, expiresAt); + } + + @Override + public void write(String fileKey, InputStream content) { + try { + Path path = resolvePath(fileKey); + Files.createDirectories(path.getParent()); + Files.copy(content, path, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public void deleteObject(String fileKey) { + try { + Files.deleteIfExists(resolvePath(fileKey)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private Path resolvePath(String fileKey) { + return Path.of(properties.basePath()).resolve(fileKey).normalize(); + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageProperties.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageProperties.java new file mode 100644 index 0000000..e0fbde8 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageProperties.java @@ -0,0 +1,12 @@ +package kr.ac.kookmin.stream.client.file.local; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Profile; + +@ConfigurationProperties(prefix = "file.storage.local") +@Profile("!prod") +public record LocalFileStorageProperties( + String basePath, + String baseUrl, + long uploadUrlExpirySeconds +) {} 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 new file mode 100644 index 0000000..b57b6c1 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java @@ -0,0 +1,80 @@ +package kr.ac.kookmin.stream.client.file.s3; + +import java.io.InputStream; +import java.time.Duration; +import java.time.LocalDateTime; +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 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; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +/** + * S3 기반 구현체. presigned URL은 클라이언트가 S3에 직접 PUT하는 용도라, 로컬 구현체와 달리 + * 서버가 파일 바이트를 직접 받는 write(...)는 지원하지 않는다. + * 자격증명은 AWS 기본 자격증명 체인({@link DefaultCredentialsProvider})을 사용한다 — + * EC2에 배포하면 인스턴스 프로필(IAM 역할)에서 자동으로 자격증명을 가져오므로 액세스 키를 앱에 직접 넣지 않는다. + */ +@Component +@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "s3") +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() + .bucket(properties.bucket()) + .key(fileKey) + .contentType(contentType) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofSeconds(properties.uploadUrlExpirySeconds())) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + LocalDateTime expiresAt = LocalDateTime.ofInstant(presignedRequest.expiration(), ZoneId.systemDefault()); + return new UploadUrl(presignedRequest.url().toString(), expiresAt); + } + + @Override + public void write(String fileKey, InputStream content) { + throw new UnsupportedOperationException("S3는 클라이언트가 presigned URL로 직접 업로드하므로 서버가 파일을 받지 않는다"); + } + + @Override + public void deleteObject(String fileKey) { + s3Client.deleteObject(DeleteObjectRequest.builder() + .bucket(properties.bucket()) + .key(fileKey) + .build()); + } +} 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 new file mode 100644 index 0000000..d676c08 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageProperties.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.client.file.s3; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "file.storage.s3") +public record S3FileStorageProperties( + String bucket, + String region, + long uploadUrlExpirySeconds +) {} diff --git a/infrastructure/client/src/main/resources/application-infrastructure-client.yml b/infrastructure/client/src/main/resources/application-infrastructure-client.yml new file mode 100644 index 0000000..0deba86 --- /dev/null +++ b/infrastructure/client/src/main/resources/application-infrastructure-client.yml @@ -0,0 +1,11 @@ +file: + storage: + type: ${FILE_STORAGE_TYPE:local} + local: + base-path: ${LOCAL_STORAGE_BASE_PATH:./local-storage} + 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:} + upload-url-expiry-seconds: ${S3_UPLOAD_URL_EXPIRY_SECONDS:600} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileJpaRepository.java new file mode 100644 index 0000000..cc054aa --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileJpaRepository.java @@ -0,0 +1,8 @@ +package kr.ac.kookmin.stream.db.internal; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FileJpaRepository extends JpaRepository { + Optional findByFileKey(String fileKey); +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileRepositoryImpl.java new file mode 100644 index 0000000..6fbd852 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileRepositoryImpl.java @@ -0,0 +1,34 @@ +package kr.ac.kookmin.stream.db.internal; + +import java.util.Optional; +import kr.ac.kookmin.stream.internal.domain.file.domain.File; +import kr.ac.kookmin.stream.internal.domain.file.repository.FileRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class FileRepositoryImpl implements FileRepository { + + private final FileJpaRepository fileJpaRepository; + + @Override + public Optional findById(Long id) { + return fileJpaRepository.findById(id).map(FileJpaEntity::toDomain); + } + + @Override + public Optional findByFileKey(String fileKey) { + return fileJpaRepository.findByFileKey(fileKey).map(FileJpaEntity::toDomain); + } + + @Override + public File save(File file) { + return fileJpaRepository.save(FileJpaEntity.from(file)).toDomain(); + } + + @Override + public void deleteById(Long id) { + fileJpaRepository.deleteById(id); + } +}