diff --git a/build.gradle b/build.gradle index df4d9378..23148834 100644 --- a/build.gradle +++ b/build.gradle @@ -58,6 +58,10 @@ dependencies { // OAuth implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + // AWS SDK v2 + implementation platform('software.amazon.awssdk:bom:2.41.14') + implementation 'software.amazon.awssdk:s3' + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' } diff --git a/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java b/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java new file mode 100644 index 00000000..4b32db74 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java @@ -0,0 +1,36 @@ +package gg.agit.konect.domain.upload.controller; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import gg.agit.konect.domain.upload.dto.ImageUploadResponse; +import gg.agit.konect.global.auth.annotation.UserId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Upload: 업로드", description = "업로드 API") +@RequestMapping("/upload") +public interface UploadApi { + + @Operation(summary = "이미지 파일을 업로드한다.", description = """ + 서버가 multipart 파일을 받아 S3에 업로드합니다. + + - 응답의 fileUrl을 기존 도메인 API의 imageUrl로 사용합니다. + + ## 에러 + - INVALID_SESSION (401): 로그인 정보가 올바르지 않습니다. + - INVALID_REQUEST_BODY (400): 파일이 비어있거나 요청 형식이 올바르지 않은 경우 + - INVALID_FILE_CONTENT_TYPE (400): 지원하지 않는 Content-Type 인 경우 + - INVALID_FILE_SIZE (400): 파일 크기가 제한을 초과한 경우 + - FAILED_UPLOAD_FILE (500): S3 업로드에 실패한 경우 + """) + @PostMapping(value = "/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + ResponseEntity uploadImage( + @UserId Integer userId, + @RequestPart("file") MultipartFile file + ); +} diff --git a/src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java b/src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java new file mode 100644 index 00000000..1e7018b3 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java @@ -0,0 +1,22 @@ +package gg.agit.konect.domain.upload.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import gg.agit.konect.domain.upload.dto.ImageUploadResponse; +import gg.agit.konect.domain.upload.service.UploadService; +import gg.agit.konect.global.auth.annotation.UserId; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class UploadController implements UploadApi { + + private final UploadService uploadService; + + @Override + public ResponseEntity uploadImage(@UserId Integer userId, MultipartFile file) { + return ResponseEntity.ok(uploadService.uploadImage(file)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/upload/dto/ImageUploadResponse.java b/src/main/java/gg/agit/konect/domain/upload/dto/ImageUploadResponse.java new file mode 100644 index 00000000..02036b53 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/upload/dto/ImageUploadResponse.java @@ -0,0 +1,16 @@ +package gg.agit.konect.domain.upload.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ImageUploadResponse( + @Schema(description = "S3 object key", example = "konect/2026-01-26-550e8400-e29b-41d4-a716-446655440000.png", + requiredMode = REQUIRED) + String key, + + @Schema(description = "CloudFront를 통한 접근 URL", requiredMode = REQUIRED) + String fileUrl +) { + +} diff --git a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java new file mode 100644 index 00000000..423aeae7 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java @@ -0,0 +1,172 @@ +package gg.agit.konect.domain.upload.service; + +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDate; +import java.util.Set; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import gg.agit.konect.domain.upload.dto.ImageUploadResponse; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.config.S3StorageProperties; +import gg.agit.konect.global.config.StorageCdnProperties; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +@Service +@Slf4j +@RequiredArgsConstructor +public class UploadService { + + private static final Set ALLOWED_CONTENT_TYPES = Set.of( + "image/png", + "image/jpeg", + "image/webp" + ); + + private final S3Client s3Client; + private final S3StorageProperties s3StorageProperties; + private final StorageCdnProperties storageCdnProperties; + + public ImageUploadResponse uploadImage(MultipartFile file) { + validateS3Configuration(); + String contentType = validateFile(file); + String extension = extensionFromContentType(contentType); + String key = buildKey(extension); + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(s3StorageProperties.bucket()) + .key(key) + .contentType(contentType) + .build(); + + try (InputStream inputStream = file.getInputStream()) { + s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, file.getSize())); + } catch (S3Exception e) { + String awsErrorCode = e.awsErrorDetails() != null ? e.awsErrorDetails().errorCode() : null; + String awsErrorMessage = e.awsErrorDetails() != null ? e.awsErrorDetails().errorMessage() : e.getMessage(); + + log.error( + "S3 업로드 실패. bucket: {}, key: {}, statusCode: {}, errorCode: {}, requestId: {}, message: {}", + s3StorageProperties.bucket(), + key, + e.statusCode(), + awsErrorCode, + e.requestId(), + awsErrorMessage, + e + ); + throw CustomException.of(ApiResponseCode.FAILED_UPLOAD_FILE); + } catch (SdkClientException e) { + log.error( + "S3 업로드 클라이언트 오류(네트워크/자격증명/설정). bucket: {}, key: {}, message: {}", + s3StorageProperties.bucket(), + key, + e.getMessage(), + e + ); + throw CustomException.of(ApiResponseCode.FAILED_UPLOAD_FILE); + } catch (IOException e) { + log.error( + "파일 업로드 중 문제가 발생했습니다. fileName: {}, fileSize: {}, contentType: {}, message: {}", + file.getOriginalFilename(), + file.getSize(), + file.getContentType(), + e.getMessage(), + e + ); + throw CustomException.of(ApiResponseCode.FAILED_UPLOAD_FILE); + } + + String fileUrl = trimTrailingSlash(storageCdnProperties.baseUrl()) + "/" + key; + return new ImageUploadResponse(key, fileUrl); + } + + private String validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY); + } + + String contentType = file.getContentType(); + if (contentType == null || contentType.isBlank() || !ALLOWED_CONTENT_TYPES.contains(contentType)) { + throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); + } + + Long maxUploadBytes = s3StorageProperties.maxUploadBytes(); + if (maxUploadBytes != null && file.getSize() > maxUploadBytes) { + throw CustomException.of(ApiResponseCode.INVALID_FILE_SIZE); + } + + return contentType; + } + + private String extensionFromContentType(String contentType) { + return switch (contentType) { + case "image/png" -> "png"; + case "image/jpeg" -> "jpg"; + case "image/webp" -> "webp"; + default -> throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); + }; + } + + private String buildKey(String extension) { + String prefix = normalizePrefix(s3StorageProperties.keyPrefix()); + LocalDate today = LocalDate.now(); + String datePath = String.format( + "%04d-%02d-%02d", + today.getYear(), + today.getMonthValue(), + today.getDayOfMonth() + ); + String uuid = UUID.randomUUID().toString(); + return prefix + datePath + "-" + uuid + "." + extension; + } + + private String normalizePrefix(String keyPrefix) { + if (keyPrefix == null || keyPrefix.isBlank()) { + return ""; + } + + String normalized = keyPrefix.trim(); + if (!normalized.endsWith("/")) { + normalized += "/"; + } + if (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + return normalized; + } + + private String trimTrailingSlash(String baseUrl) { + if (baseUrl == null || baseUrl.isBlank()) { + throw CustomException.of(ApiResponseCode.ILLEGAL_STATE, "storage.cdn.base-url 설정이 필요합니다."); + } + + String trimmed = baseUrl.trim(); + + if (trimmed.endsWith("/")) { + return trimmed.substring(0, trimmed.length() - 1); + } + + return trimmed; + } + + private void validateS3Configuration() { + if (s3StorageProperties.bucket() == null || s3StorageProperties.bucket().isBlank()) { + throw CustomException.of(ApiResponseCode.ILLEGAL_STATE, "storage.s3.bucket 설정이 필요합니다."); + } + + if (s3StorageProperties.region() == null || s3StorageProperties.region().isBlank()) { + throw CustomException.of(ApiResponseCode.ILLEGAL_STATE, "storage.s3.region 설정이 필요합니다."); + } + } +} diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index 9c9cfb3a..34961e02 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -37,6 +37,8 @@ public enum ApiResponseCode { INVALID_RECRUITMENT_DATE_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "상시 모집인 경우 모집 시작일과 마감일을 지정할 수 없습니다."), INVALID_RECRUITMENT_DATE_REQUIRED(HttpStatus.BAD_REQUEST, "상시 모집이 아닐 경우 모집 시작일과 마감일이 필수입니다."), INVALID_RECRUITMENT_PERIOD(HttpStatus.BAD_REQUEST, "모집 시작일은 모집 마감일보다 이전이어야 합니다."), + INVALID_FILE_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 파일 타입입니다."), + INVALID_FILE_SIZE(HttpStatus.BAD_REQUEST, "파일 크기가 제한을 초과했습니다."), // 401 Unauthorized INVALID_SESSION(HttpStatus.UNAUTHORIZED, "올바르지 않은 인증 정보 입니다."), @@ -88,6 +90,7 @@ public enum ApiResponseCode { // 500 Internal Server Error (서버 오류) CLIENT_ABORTED(HttpStatus.INTERNAL_SERVER_ERROR, "클라이언트에 의해 연결이 중단되었습니다."), + FAILED_UPLOAD_FILE(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."), UNEXPECTED_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에 예기치 못한 에러가 발생했습니다."); private final HttpStatus httpStatus; diff --git a/src/main/java/gg/agit/konect/global/config/S3ClientConfig.java b/src/main/java/gg/agit/konect/global/config/S3ClientConfig.java new file mode 100644 index 00000000..6b3278e1 --- /dev/null +++ b/src/main/java/gg/agit/konect/global/config/S3ClientConfig.java @@ -0,0 +1,20 @@ +package gg.agit.konect.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +public class S3ClientConfig { + + @Bean + public S3Client s3Client(S3StorageProperties s3StorageProperties) { + return S3Client.builder() + .region(Region.of(s3StorageProperties.region())) + .credentialsProvider(DefaultCredentialsProvider.builder().build()) + .build(); + } +} diff --git a/src/main/java/gg/agit/konect/global/config/S3StorageProperties.java b/src/main/java/gg/agit/konect/global/config/S3StorageProperties.java new file mode 100644 index 00000000..0b575dca --- /dev/null +++ b/src/main/java/gg/agit/konect/global/config/S3StorageProperties.java @@ -0,0 +1,13 @@ +package gg.agit.konect.global.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "storage.s3") +public record S3StorageProperties( + String bucket, + String region, + String keyPrefix, + Long maxUploadBytes +) { + +} diff --git a/src/main/java/gg/agit/konect/global/config/StorageCdnProperties.java b/src/main/java/gg/agit/konect/global/config/StorageCdnProperties.java new file mode 100644 index 00000000..cf26057e --- /dev/null +++ b/src/main/java/gg/agit/konect/global/config/StorageCdnProperties.java @@ -0,0 +1,10 @@ +package gg.agit.konect.global.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "storage.cdn") +public record StorageCdnProperties( + String baseUrl +) { + +}