-
Notifications
You must be signed in to change notification settings - Fork 1
feat: S3 이미지 업로드 API 추가 #147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8d23ee2
919fc1b
b57844a
4690cca
a1bdca9
81ab8a2
381cb76
a9c3cb0
c4edea2
eb5fd6b
bfd42f2
6c555ac
7a41de0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ImageUploadResponse> uploadImage( | ||
| @UserId Integer userId, | ||
| @RequestPart("file") MultipartFile file | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ImageUploadResponse> uploadImage(@UserId Integer userId, MultipartFile file) { | ||
dh2906 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return ResponseEntity.ok(uploadService.uploadImage(file)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) { | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String> 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); | ||
|
Comment on lines
+90
to
+91
|
||
| } | ||
|
|
||
| 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() | ||
| ); | ||
dh2906 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 설정이 필요합니다."); | ||
| } | ||
|
Comment on lines
+149
to
+152
|
||
|
|
||
| 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 설정이 필요합니다."); | ||
| } | ||
| } | ||
|
Comment on lines
+163
to
+171
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) { | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) { | ||
|
|
||
| } |
Uh oh!
There was an error while loading. Please reload this page.