From 92738c81f882d2bb06d0cd6af706f4925f358948 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Fri, 22 May 2026 14:39:57 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat=20:=20r2=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=97=B0=EA=B2=B0=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +- .../valanse/common/config/R2Config.java | 26 ++++++ .../valanse/common/config/R2Properties.java | 64 +++++++++++++++ .../valanse/common/config/SecurityConfig.java | 1 + .../valanse/controller/StorageController.java | 32 ++++++++ .../dto/Storage/ImageUploadResponse.java | 6 ++ .../StorageService/R2StorageService.java | 82 +++++++++++++++++++ .../StorageService/StorageService.java | 7 ++ .../StorageService/R2StorageServiceTest.java | 61 ++++++++++++++ src/test/resources/application-test.yml | 9 ++ 10 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/valanse/valanse/common/config/R2Config.java create mode 100644 src/main/java/com/valanse/valanse/common/config/R2Properties.java create mode 100644 src/main/java/com/valanse/valanse/controller/StorageController.java create mode 100644 src/main/java/com/valanse/valanse/dto/Storage/ImageUploadResponse.java create mode 100644 src/main/java/com/valanse/valanse/service/StorageService/R2StorageService.java create mode 100644 src/main/java/com/valanse/valanse/service/StorageService/StorageService.java create mode 100644 src/test/java/com/valanse/valanse/service/StorageService/R2StorageServiceTest.java diff --git a/build.gradle b/build.gradle index 245813d..9936c0a 100644 --- a/build.gradle +++ b/build.gradle @@ -25,6 +25,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'software.amazon.awssdk:s3:2.25.40' // Swagger 설정 implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' @@ -75,4 +76,4 @@ sourceSets { tasks.named('test') { useJUnitPlatform() // JUnit5 명시적 활성화 -} \ No newline at end of file +} diff --git a/src/main/java/com/valanse/valanse/common/config/R2Config.java b/src/main/java/com/valanse/valanse/common/config/R2Config.java new file mode 100644 index 0000000..8577b4d --- /dev/null +++ b/src/main/java/com/valanse/valanse/common/config/R2Config.java @@ -0,0 +1,26 @@ +package com.valanse.valanse.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +import java.net.URI; + +@Configuration +public class R2Config { + + @Bean + public S3Client s3Client(R2Properties properties) { + return S3Client.builder() + .region(Region.of("auto")) + .endpointOverride(URI.create(properties.getEndpoint())) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()) + )) + .forcePathStyle(true) + .build(); + } +} diff --git a/src/main/java/com/valanse/valanse/common/config/R2Properties.java b/src/main/java/com/valanse/valanse/common/config/R2Properties.java new file mode 100644 index 0000000..9cbb93d --- /dev/null +++ b/src/main/java/com/valanse/valanse/common/config/R2Properties.java @@ -0,0 +1,64 @@ +package com.valanse.valanse.common.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "cloudflare.r2") +public class R2Properties { + + private String accountId; + private String endpoint; + private String bucket; + private String accessKey; + private String secretKey; + private String publicUrl; + + public String getAccountId() { + return accountId; + } + + public void setAccountId(String accountId) { + this.accountId = accountId; + } + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getBucket() { + return bucket; + } + + public void setBucket(String bucket) { + this.bucket = bucket; + } + + public String getAccessKey() { + return accessKey; + } + + public void setAccessKey(String accessKey) { + this.accessKey = accessKey; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + public String getPublicUrl() { + return publicUrl; + } + + public void setPublicUrl(String publicUrl) { + this.publicUrl = publicUrl; + } +} diff --git a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java index 3551cbc..241cd70 100644 --- a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java +++ b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java @@ -62,6 +62,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(HttpMethod.POST, "/votes/*/vote-options/*").authenticated() .requestMatchers(HttpMethod.POST, "/votes/*/comments").authenticated() .requestMatchers(HttpMethod.POST, "/comments/*/like").authenticated() + .requestMatchers(HttpMethod.POST, "/storage/images").authenticated() // PUT - 인증 필요 (수정) .requestMatchers(HttpMethod.PUT, "/votes/*").authenticated() diff --git a/src/main/java/com/valanse/valanse/controller/StorageController.java b/src/main/java/com/valanse/valanse/controller/StorageController.java new file mode 100644 index 0000000..ec5f4ab --- /dev/null +++ b/src/main/java/com/valanse/valanse/controller/StorageController.java @@ -0,0 +1,32 @@ +package com.valanse.valanse.controller; + +import com.valanse.valanse.dto.Storage.ImageUploadResponse; +import com.valanse.valanse.service.StorageService.StorageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +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.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "파일 업로드 API", description = "이미지 업로드 관련 기능") +@RestController +@RequestMapping("/storage") +@RequiredArgsConstructor +public class StorageController { + + private final StorageService storageService; + + @Operation( + summary = "이미지 업로드", + description = "이미지 파일을 Cloudflare R2에 업로드하고 공개 URL을 반환합니다." + ) + @PostMapping(value = "/images", consumes = "multipart/form-data") + public ResponseEntity uploadImage(@RequestPart("file") MultipartFile file) { + String imageUrl = storageService.uploadImage(file, "images"); + return ResponseEntity.ok(new ImageUploadResponse(imageUrl)); + } +} diff --git a/src/main/java/com/valanse/valanse/dto/Storage/ImageUploadResponse.java b/src/main/java/com/valanse/valanse/dto/Storage/ImageUploadResponse.java new file mode 100644 index 0000000..b9ad372 --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Storage/ImageUploadResponse.java @@ -0,0 +1,6 @@ +package com.valanse.valanse.dto.Storage; + +public record ImageUploadResponse( + String imageUrl +) { +} diff --git a/src/main/java/com/valanse/valanse/service/StorageService/R2StorageService.java b/src/main/java/com/valanse/valanse/service/StorageService/R2StorageService.java new file mode 100644 index 0000000..3e30859 --- /dev/null +++ b/src/main/java/com/valanse/valanse/service/StorageService/R2StorageService.java @@ -0,0 +1,82 @@ +package com.valanse.valanse.service.StorageService; + +import com.valanse.valanse.common.api.ApiException; +import com.valanse.valanse.common.config.R2Properties; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +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; + +import java.io.IOException; +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class R2StorageService implements StorageService { + + private static final long MAX_IMAGE_SIZE = 5 * 1024 * 1024; + private static final Set ALLOWED_IMAGE_TYPES = Set.of( + "image/jpeg", + "image/png", + "image/webp", + "image/gif" + ); + + private final S3Client s3Client; + private final R2Properties properties; + + @Override + public String uploadImage(MultipartFile file, String directory) { + validateImage(file); + + String objectKey = buildObjectKey(file, directory); + PutObjectRequest request = PutObjectRequest.builder() + .bucket(properties.getBucket()) + .key(objectKey) + .contentType(file.getContentType()) + .contentLength(file.getSize()) + .build(); + + try { + s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + } catch (IOException e) { + throw new ApiException("이미지 파일을 읽을 수 없습니다.", HttpStatus.BAD_REQUEST); + } catch (S3Exception e) { + throw new ApiException("이미지 업로드에 실패했습니다.", HttpStatus.BAD_GATEWAY); + } + + return properties.getPublicUrl().replaceAll("/+$", "") + "/" + objectKey; + } + + private void validateImage(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ApiException("업로드할 이미지 파일을 선택해주세요.", HttpStatus.BAD_REQUEST); + } + + if (file.getSize() > MAX_IMAGE_SIZE) { + throw new ApiException("이미지는 5MB 이하만 업로드할 수 있습니다.", HttpStatus.BAD_REQUEST); + } + + if (!ALLOWED_IMAGE_TYPES.contains(file.getContentType())) { + throw new ApiException("jpg, png, webp, gif 이미지 파일만 업로드할 수 있습니다.", HttpStatus.BAD_REQUEST); + } + } + + private String buildObjectKey(MultipartFile file, String directory) { + String cleanDirectory = StringUtils.hasText(directory) + ? directory.replaceAll("^/+|/+$", "") + : "images"; + String extension = StringUtils.getFilenameExtension(file.getOriginalFilename()); + String filename = extension == null + ? UUID.randomUUID().toString() + : UUID.randomUUID() + "." + extension.toLowerCase(); + + return cleanDirectory + "/" + filename; + } +} diff --git a/src/main/java/com/valanse/valanse/service/StorageService/StorageService.java b/src/main/java/com/valanse/valanse/service/StorageService/StorageService.java new file mode 100644 index 0000000..2d7f562 --- /dev/null +++ b/src/main/java/com/valanse/valanse/service/StorageService/StorageService.java @@ -0,0 +1,7 @@ +package com.valanse.valanse.service.StorageService; + +import org.springframework.web.multipart.MultipartFile; + +public interface StorageService { + String uploadImage(MultipartFile file, String directory); +} diff --git a/src/test/java/com/valanse/valanse/service/StorageService/R2StorageServiceTest.java b/src/test/java/com/valanse/valanse/service/StorageService/R2StorageServiceTest.java new file mode 100644 index 0000000..0736dfd --- /dev/null +++ b/src/test/java/com/valanse/valanse/service/StorageService/R2StorageServiceTest.java @@ -0,0 +1,61 @@ +package com.valanse.valanse.service.StorageService; + +import com.valanse.valanse.common.api.ApiException; +import com.valanse.valanse.common.config.R2Properties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class R2StorageServiceTest { + + private S3Client s3Client; + private R2StorageService storageService; + + @BeforeEach + void setUp() { + s3Client = mock(S3Client.class); + + R2Properties properties = new R2Properties(); + properties.setBucket("test-bucket"); + properties.setPublicUrl("https://cdn.example.com/"); + + storageService = new R2StorageService(s3Client, properties); + } + + @Test + void 이미지를_R2에_업로드하고_공개_URL을_반환한다() { + MockMultipartFile file = new MockMultipartFile( + "file", + "sample.png", + "image/png", + "image".getBytes() + ); + + String imageUrl = storageService.uploadImage(file, "images"); + + assertThat(imageUrl).startsWith("https://cdn.example.com/images/"); + assertThat(imageUrl).endsWith(".png"); + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + void 이미지가_아닌_파일은_업로드하지_않는다() { + MockMultipartFile file = new MockMultipartFile( + "file", + "sample.txt", + "text/plain", + "text".getBytes() + ); + + assertThrows(ApiException.class, () -> storageService.uploadImage(file, "images")); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 55457c4..ac9c63a 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -26,3 +26,12 @@ oauth: kakao: client-id: test-client-id redirect-uri: http://localhost + +cloudflare: + r2: + account-id: test-account-id + endpoint: http://localhost:9000 + bucket: test-bucket + access-key: test-access-key + secret-key: test-secret-key + public-url: http://localhost:9000/test-bucket From 80e327c653dafda4383d5d46303f29e75bb4a174 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Fri, 22 May 2026 14:42:42 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix=20:=20=EA=B8=B0=EC=A1=B4=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20cors=20=EC=84=A4=EC=A0=95=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/valanse/valanse/common/config/SecurityConfig.java | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java index 241cd70..762a436 100644 --- a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java +++ b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java @@ -102,13 +102,6 @@ public CorsConfigurationSource corsConfigurationSource() { "https://valan-se-web.vercel.app", "https://valanse.kr", "https://develop.valanse.kr", - "https://backendbase.store", - "http://backendbase.store:8080", - "http://backendbase.store:8081", - "http://backendbase.store:8082", - "https://backendbase.store:8080", - "https://backendbase.store:8081", - "https://backendbase.store:8082", "http://valanserver.store", "http://valanserver.store:8080", "http://valanserver.store:8081", From 55b6e4ecc656babc377527c81e46babfe375ed81 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Fri, 22 May 2026 16:37:28 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix=20:=20CORS=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/valanse/valanse/common/config/SecurityConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java index 762a436..400cec9 100644 --- a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java +++ b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java @@ -102,6 +102,7 @@ public CorsConfigurationSource corsConfigurationSource() { "https://valan-se-web.vercel.app", "https://valanse.kr", "https://develop.valanse.kr", + "https://valanserver.store", "http://valanserver.store", "http://valanserver.store:8080", "http://valanserver.store:8081",