From 8d23ee2e84d1fe83652f8c592310de83ab5c1394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Mon, 26 Jan 2026 21:20:24 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20API=20?= =?UTF-8?q?=EB=AA=85=EC=84=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../upload/controller/ImageUploadApi.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/upload/controller/ImageUploadApi.java diff --git a/src/main/java/gg/agit/konect/domain/upload/controller/ImageUploadApi.java b/src/main/java/gg/agit/konect/domain/upload/controller/ImageUploadApi.java new file mode 100644 index 00000000..302f5be4 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/upload/controller/ImageUploadApi.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) Image Upload: 이미지 업로드", description = "이미지 업로드 API") +@RequestMapping("/image") +public interface ImageUploadApi { + + @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 = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + ResponseEntity upload( + @UserId Integer userId, + @RequestPart("file") MultipartFile file + ); +} From 919fc1b5af95f68455b183cdf7c91a3f24f12905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Mon, 26 Jan 2026 23:59:26 +0900 Subject: [PATCH 02/13] =?UTF-8?q?build:=20S3=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20AWS=20SDK=20v2=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 ++++ 1 file changed, 4 insertions(+) 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' } From b57844a9f589d06c954e2651c48616a646d90fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Mon, 26 Jan 2026 23:59:33 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20S3=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EB=B0=8F=20?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EB=A6=AC=EC=A7=80=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../konect/global/config/S3ClientConfig.java | 20 +++++++++++++++++++ .../global/config/S3StorageProperties.java | 13 ++++++++++++ .../global/config/StorageCdnProperties.java | 10 ++++++++++ 3 files changed, 43 insertions(+) create mode 100644 src/main/java/gg/agit/konect/global/config/S3ClientConfig.java create mode 100644 src/main/java/gg/agit/konect/global/config/S3StorageProperties.java create mode 100644 src/main/java/gg/agit/konect/global/config/StorageCdnProperties.java 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 +) { + +} From 4690cca727eb977aa4a43ffdedc530b1c09a9e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Tue, 27 Jan 2026 00:09:29 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20S3=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ImageUploadController.java | 21 +++ .../upload/dto/ImageUploadResponse.java | 16 ++ .../upload/service/ImageUploadService.java | 149 ++++++++++++++++++ .../konect/global/code/ApiResponseCode.java | 3 + 4 files changed, 189 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/upload/controller/ImageUploadController.java create mode 100644 src/main/java/gg/agit/konect/domain/upload/dto/ImageUploadResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/upload/service/ImageUploadService.java diff --git a/src/main/java/gg/agit/konect/domain/upload/controller/ImageUploadController.java b/src/main/java/gg/agit/konect/domain/upload/controller/ImageUploadController.java new file mode 100644 index 00000000..49b7611c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/upload/controller/ImageUploadController.java @@ -0,0 +1,21 @@ +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.ImageUploadService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class ImageUploadController implements ImageUploadApi { + + private final ImageUploadService imageUploadService; + + @Override + public ResponseEntity upload(Integer userId, MultipartFile file) { + return ResponseEntity.ok(imageUploadService.upload(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..fdafcb81 --- /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/ImageUploadService.java b/src/main/java/gg/agit/konect/domain/upload/service/ImageUploadService.java new file mode 100644 index 00000000..e66b7683 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/upload/service/ImageUploadService.java @@ -0,0 +1,149 @@ +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.transaction.annotation.Transactional; +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.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ImageUploadService { + + 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 upload(MultipartFile file) { + validateS3Configuration(); + validateFile(file); + + String contentType = requireContentType(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 (IOException e) { + log.warn("파일 업로드중 문제가 발생했습니다. file: {} \n message: {}", file, e.getMessage()); + throw CustomException.of(ApiResponseCode.FAILED_UPLOAD_FILE); + } + + String fileUrl = trimTrailingSlash(storageCdnProperties.baseUrl()) + "/" + key; + return new ImageUploadResponse(key, fileUrl); + } + + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY); + } + + String contentType = file.getContentType(); + if (contentType == null || !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); + } + } + + private String requireContentType(MultipartFile file) { + String contentType = file.getContentType(); + if (contentType == null || contentType.isBlank()) { + throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); + } + 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; From a1bdca9ed658fe4e413c10d49efee2832a603646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Tue, 27 Jan 2026 00:25:30 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20=EB=88=84=EB=9D=BD=EB=90=9C=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../konect/domain/upload/controller/ImageUploadController.java | 3 ++- src/main/java/gg/agit/konect/global/config/S3ClientConfig.java | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/gg/agit/konect/domain/upload/controller/ImageUploadController.java b/src/main/java/gg/agit/konect/domain/upload/controller/ImageUploadController.java index 49b7611c..564b4ce8 100644 --- a/src/main/java/gg/agit/konect/domain/upload/controller/ImageUploadController.java +++ b/src/main/java/gg/agit/konect/domain/upload/controller/ImageUploadController.java @@ -6,6 +6,7 @@ import gg.agit.konect.domain.upload.dto.ImageUploadResponse; import gg.agit.konect.domain.upload.service.ImageUploadService; +import gg.agit.konect.global.auth.annotation.UserId; import lombok.RequiredArgsConstructor; @RestController @@ -15,7 +16,7 @@ public class ImageUploadController implements ImageUploadApi { private final ImageUploadService imageUploadService; @Override - public ResponseEntity upload(Integer userId, MultipartFile file) { + public ResponseEntity upload(@UserId Integer userId, MultipartFile file) { return ResponseEntity.ok(imageUploadService.upload(file)); } } diff --git a/src/main/java/gg/agit/konect/global/config/S3ClientConfig.java b/src/main/java/gg/agit/konect/global/config/S3ClientConfig.java index 6b3278e1..d8e8bfb9 100644 --- a/src/main/java/gg/agit/konect/global/config/S3ClientConfig.java +++ b/src/main/java/gg/agit/konect/global/config/S3ClientConfig.java @@ -2,6 +2,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; import software.amazon.awssdk.regions.Region; @@ -11,6 +12,7 @@ public class S3ClientConfig { @Bean + @Lazy public S3Client s3Client(S3StorageProperties s3StorageProperties) { return S3Client.builder() .region(Region.of(s3StorageProperties.region())) From 81ab8a294e957bd5fc3405aedda695b1ff30fe39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Tue, 27 Jan 2026 11:12:02 +0900 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/{ImageUploadApi.java => UploadApi.java} | 10 +++++----- ...mageUploadController.java => UploadController.java} | 10 +++++----- .../{ImageUploadService.java => UploadService.java} | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) rename src/main/java/gg/agit/konect/domain/upload/controller/{ImageUploadApi.java => UploadApi.java} (82%) rename src/main/java/gg/agit/konect/domain/upload/controller/{ImageUploadController.java => UploadController.java} (55%) rename src/main/java/gg/agit/konect/domain/upload/service/{ImageUploadService.java => UploadService.java} (98%) diff --git a/src/main/java/gg/agit/konect/domain/upload/controller/ImageUploadApi.java b/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java similarity index 82% rename from src/main/java/gg/agit/konect/domain/upload/controller/ImageUploadApi.java rename to src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java index 302f5be4..4b32db74 100644 --- a/src/main/java/gg/agit/konect/domain/upload/controller/ImageUploadApi.java +++ b/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java @@ -12,9 +12,9 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -@Tag(name = "(Normal) Image Upload: 이미지 업로드", description = "이미지 업로드 API") -@RequestMapping("/image") -public interface ImageUploadApi { +@Tag(name = "(Normal) Upload: 업로드", description = "업로드 API") +@RequestMapping("/upload") +public interface UploadApi { @Operation(summary = "이미지 파일을 업로드한다.", description = """ 서버가 multipart 파일을 받아 S3에 업로드합니다. @@ -28,8 +28,8 @@ public interface ImageUploadApi { - INVALID_FILE_SIZE (400): 파일 크기가 제한을 초과한 경우 - FAILED_UPLOAD_FILE (500): S3 업로드에 실패한 경우 """) - @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - ResponseEntity upload( + @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/ImageUploadController.java b/src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java similarity index 55% rename from src/main/java/gg/agit/konect/domain/upload/controller/ImageUploadController.java rename to src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java index 564b4ce8..1e7018b3 100644 --- a/src/main/java/gg/agit/konect/domain/upload/controller/ImageUploadController.java +++ b/src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java @@ -5,18 +5,18 @@ import org.springframework.web.multipart.MultipartFile; import gg.agit.konect.domain.upload.dto.ImageUploadResponse; -import gg.agit.konect.domain.upload.service.ImageUploadService; +import gg.agit.konect.domain.upload.service.UploadService; import gg.agit.konect.global.auth.annotation.UserId; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor -public class ImageUploadController implements ImageUploadApi { +public class UploadController implements UploadApi { - private final ImageUploadService imageUploadService; + private final UploadService uploadService; @Override - public ResponseEntity upload(@UserId Integer userId, MultipartFile file) { - return ResponseEntity.ok(imageUploadService.upload(file)); + 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/service/ImageUploadService.java b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java similarity index 98% rename from src/main/java/gg/agit/konect/domain/upload/service/ImageUploadService.java rename to src/main/java/gg/agit/konect/domain/upload/service/UploadService.java index e66b7683..f809ee95 100644 --- a/src/main/java/gg/agit/konect/domain/upload/service/ImageUploadService.java +++ b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java @@ -25,7 +25,7 @@ @Slf4j @RequiredArgsConstructor @Transactional(readOnly = true) -public class ImageUploadService { +public class UploadService { private static final Set ALLOWED_CONTENT_TYPES = Set.of( "image/png", @@ -37,7 +37,7 @@ public class ImageUploadService { private final S3StorageProperties s3StorageProperties; private final StorageCdnProperties storageCdnProperties; - public ImageUploadResponse upload(MultipartFile file) { + public ImageUploadResponse uploadImage(MultipartFile file) { validateS3Configuration(); validateFile(file); From 381cb7637be94aaeab0aa40fe55a212038561468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 28 Jan 2026 11:26:43 +0900 Subject: [PATCH 07/13] =?UTF-8?q?fix:=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=ED=8C=8C=EC=9D=BC=20=EB=A9=94?= =?UTF-8?q?=ED=83=80=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A1=9C=EA=B9=85?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agit/konect/domain/upload/service/UploadService.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 index f809ee95..4a79775e 100644 --- a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java +++ b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java @@ -54,7 +54,13 @@ public ImageUploadResponse uploadImage(MultipartFile file) { try (InputStream inputStream = file.getInputStream()) { s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, file.getSize())); } catch (IOException e) { - log.warn("파일 업로드중 문제가 발생했습니다. file: {} \n message: {}", file, e.getMessage()); + log.warn( + "파일 업로드중 문제가 발생했습니다. fileName: {}, fileSize: {}, contentType: {} \n message: {}", + file.getOriginalFilename(), + file.getSize(), + file.getContentType(), + e.getMessage() + ); throw CustomException.of(ApiResponseCode.FAILED_UPLOAD_FILE); } From a9c3cb05f1d16283a5eb8acaaaeaba09b7cd2dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 28 Jan 2026 11:48:10 +0900 Subject: [PATCH 08/13] =?UTF-8?q?fix:=20S3=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A1=9C=EA=B9=85=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/upload/service/UploadService.java | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) 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 index 4a79775e..3c7ba7a4 100644 --- a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java +++ b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java @@ -17,9 +17,11 @@ 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 @@ -53,13 +55,38 @@ public ImageUploadResponse uploadImage(MultipartFile file) { 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.warn( - "파일 업로드중 문제가 발생했습니다. fileName: {}, fileSize: {}, contentType: {} \n message: {}", + "파일 업로드 중 문제가 발생했습니다. fileName: {}, fileSize: {}, contentType: {}, message: {}", file.getOriginalFilename(), file.getSize(), file.getContentType(), - e.getMessage() + e.getMessage(), + e ); throw CustomException.of(ApiResponseCode.FAILED_UPLOAD_FILE); } From c4edea276e2841658e2a18a418a4d5531e3e3701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 28 Jan 2026 11:56:07 +0900 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gg/agit/konect/domain/upload/service/UploadService.java | 2 -- 1 file changed, 2 deletions(-) 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 index 3c7ba7a4..aa0be920 100644 --- a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java +++ b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java @@ -7,7 +7,6 @@ import java.util.UUID; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import gg.agit.konect.domain.upload.dto.ImageUploadResponse; @@ -26,7 +25,6 @@ @Service @Slf4j @RequiredArgsConstructor -@Transactional(readOnly = true) public class UploadService { private static final Set ALLOWED_CONTENT_TYPES = Set.of( From eb5fd6b0d70d79eff19d7d83994d14117d45ab41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 28 Jan 2026 11:56:57 +0900 Subject: [PATCH 10/13] =?UTF-8?q?fix:=20=EB=AA=85=EC=84=B8=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gg/agit/konect/domain/upload/dto/ImageUploadResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index fdafcb81..02036b53 100644 --- a/src/main/java/gg/agit/konect/domain/upload/dto/ImageUploadResponse.java +++ b/src/main/java/gg/agit/konect/domain/upload/dto/ImageUploadResponse.java @@ -5,7 +5,7 @@ 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", + @Schema(description = "S3 object key", example = "konect/2026-01-26-550e8400-e29b-41d4-a716-446655440000.png", requiredMode = REQUIRED) String key, From bfd42f2b36a81b0a8057fa86ea0c9dc7e832182a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 28 Jan 2026 12:44:15 +0900 Subject: [PATCH 11/13] =?UTF-8?q?refactor:=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/upload/service/UploadService.java | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) 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 index aa0be920..160e7893 100644 --- a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java +++ b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java @@ -39,9 +39,7 @@ public class UploadService { public ImageUploadResponse uploadImage(MultipartFile file) { validateS3Configuration(); - validateFile(file); - - String contentType = requireContentType(file); + String contentType = validateFile(file); String extension = extensionFromContentType(contentType); String key = buildKey(extension); @@ -93,13 +91,13 @@ public ImageUploadResponse uploadImage(MultipartFile file) { return new ImageUploadResponse(key, fileUrl); } - private void validateFile(MultipartFile file) { + private String validateFile(MultipartFile file) { if (file == null || file.isEmpty()) { throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY); } String contentType = file.getContentType(); - if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) { + if (contentType == null || contentType.isBlank() || !ALLOWED_CONTENT_TYPES.contains(contentType)) { throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); } @@ -107,13 +105,7 @@ private void validateFile(MultipartFile file) { if (maxUploadBytes != null && file.getSize() > maxUploadBytes) { throw CustomException.of(ApiResponseCode.INVALID_FILE_SIZE); } - } - private String requireContentType(MultipartFile file) { - String contentType = file.getContentType(); - if (contentType == null || contentType.isBlank()) { - throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); - } return contentType; } From 6c555ac7132a71b0eab001ca19d3251d008e06a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 28 Jan 2026 13:30:47 +0900 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20IO=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EB=B0=9C=EC=83=9D=20=EC=8B=9C=20=EB=A1=9C=EA=B7=B8=20=EB=A0=88?= =?UTF-8?q?=EB=B2=A8=20error=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gg/agit/konect/domain/upload/service/UploadService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 160e7893..423aeae7 100644 --- a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java +++ b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java @@ -76,7 +76,7 @@ public ImageUploadResponse uploadImage(MultipartFile file) { ); throw CustomException.of(ApiResponseCode.FAILED_UPLOAD_FILE); } catch (IOException e) { - log.warn( + log.error( "파일 업로드 중 문제가 발생했습니다. fileName: {}, fileSize: {}, contentType: {}, message: {}", file.getOriginalFilename(), file.getSize(), From 7a41de050d16f3108a239dc32beecaf08ecb0702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 28 Jan 2026 13:38:15 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix:=20=EB=B9=88=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EC=A6=89=EC=8B=9C=20=EB=A1=9C=EB=94=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gg/agit/konect/global/config/S3ClientConfig.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/gg/agit/konect/global/config/S3ClientConfig.java b/src/main/java/gg/agit/konect/global/config/S3ClientConfig.java index d8e8bfb9..6b3278e1 100644 --- a/src/main/java/gg/agit/konect/global/config/S3ClientConfig.java +++ b/src/main/java/gg/agit/konect/global/config/S3ClientConfig.java @@ -2,7 +2,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Lazy; import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; import software.amazon.awssdk.regions.Region; @@ -12,7 +11,6 @@ public class S3ClientConfig { @Bean - @Lazy public S3Client s3Client(S3StorageProperties s3StorageProperties) { return S3Client.builder() .region(Region.of(s3StorageProperties.region()))