Repository: Team-useMemo/Jugger-Backend
Pull request #23: feat : S3 버킷 등록
Author: login: minhyuk2
Labels: name: FEATURE
description: 기능구현
name: V1
description: v1개발
Created: 2025-04-04T19:54:35Z | Merged: 2025-04-06T08:39:24Z

## #️⃣ Issue Number

- #20 

## 📝 요약(Summary)

S3를 통해서 이미지를 등록하기 위해서 S3 버킷에 대한 설정을 마쳤습니다.

## 💬 공유사항 to 리뷰어

UUID로 업로드한 url를 구성하였습니다.
또한 파일의 위치를 global로 두어서 어떤 domain에서든 사용할 수 있게 해두었습니다.
이건 추후에 조정해도 좋을 것 같습니다.
또한 application.yml 파일은 삭제해두었습니다. gihub secret에 설정들을 넣어두어서 배포 시에 문제 없게 설정해둘 예정입니다.

## ✅ PR Checklist

PR이 다음 요구 사항을 충족하는지 확인하세요.

- [x] 커밋 메시지 컨벤션에 맞게 작성했습니다.
- [x] 변경 사항에 대한 테스트를 했습니다.(버그 수정/기능에 대한 테스트).


Diff:
diff --git a/.gitignore b/.gitignore
index c2065bc..5be91ed 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,3 +35,11 @@ out/
 
 ### VS Code ###
 .vscode/
+
+# Spring Boot 설정 파일 무시
+#application.properties
+application.yml
+
+/src/main/resources/application.yml
+
+
diff --git a/build.gradle b/build.gradle
index 040c38c..f2a72ae 100644
--- a/build.gradle
+++ b/build.gradle
@@ -27,6 +27,8 @@ dependencies {
     implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'
 //	implementation 'org.springframework.boot:spring-boot-starter-security'
     implementation 'org.springframework.boot:spring-boot-starter-webflux'
+//    implementation 'software.amazon.awssdk:s3:2.31.15'
+    implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.0.2'
     compileOnly 'org.projectlombok:lombok'
     annotationProcessor 'org.projectlombok:lombok'
 
diff --git a/src/main/java/com/usememo/jugger/global/exception/ErrorCode.java b/src/main/java/com/usememo/jugger/global/exception/ErrorCode.java
index f94d067..1569cf5 100644
--- a/src/main/java/com/usememo/jugger/global/exception/ErrorCode.java
+++ b/src/main/java/com/usememo/jugger/global/exception/ErrorCode.java
@@ -9,7 +9,8 @@
 @Getter
 public enum ErrorCode {
 	CATEGORY_ALREADY_EXIST(BAD_REQUEST, 400, "이미 존재하는 카테고리입니다"),
-	CATEGORY_IS_NULL(BAD_REQUEST, 400, "카테고리는 NULL값일 수 없습니다");
+	CATEGORY_IS_NULL(BAD_REQUEST, 400, "카테고리는 NULL값일 수 없습니다"),
+	FILE_UPLOAD_FAIL(INTERNAL_SERVER_ERROR, 500, "S3 파일 업로드에 실패했습니다.");
 
 	private final HttpStatus httpStatus;
 	private final int code;
diff --git a/src/main/java/com/usememo/jugger/global/exception/s3/S3UploadException.java b/src/main/java/com/usememo/jugger/global/exception/s3/S3UploadException.java
new file mode 100644
index 0000000..f267be6
--- /dev/null
+++ b/src/main/java/com/usememo/jugger/global/exception/s3/S3UploadException.java
@@ -0,0 +1,10 @@
+package com.usememo.jugger.global.exception.s3;
+
+import com.usememo.jugger.global.exception.BaseException;
+import com.usememo.jugger.global.exception.ErrorCode;
+
+public class S3UploadException extends BaseException {
+	public S3UploadException() {
+		super(ErrorCode.FILE_UPLOAD_FAIL);
+	}
+}
diff --git a/src/main/java/com/usememo/jugger/global/s3/controller/S3Controller.java b/src/main/java/com/usememo/jugger/global/s3/controller/S3Controller.java
new file mode 100644
index 0000000..82528be
--- /dev/null
+++ b/src/main/java/com/usememo/jugger/global/s3/controller/S3Controller.java
@@ -0,0 +1,27 @@
+package com.usememo.jugger.global.s3.controller;
+
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.codec.multipart.FilePart;
+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 com.usememo.jugger.global.s3.service.S3ServiceImplementation;
+
+import lombok.RequiredArgsConstructor;
+import reactor.core.publisher.Mono;
+
+@RestController
+@RequestMapping("/api/upload")
+@RequiredArgsConstructor
+public class S3Controller {
+	private final S3ServiceImplementation s3ServiceImplementation;
+
+	@PostMapping(value = "/test", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+	public Mono<ResponseEntity<String>> upload(@RequestPart("file") FilePart file) {
+		return s3ServiceImplementation.uploadFile(file)
+			.map(url -> ResponseEntity.ok(url));
+	}
+}
diff --git a/src/main/java/com/usememo/jugger/global/s3/service/S3Service.java b/src/main/java/com/usememo/jugger/global/s3/service/S3Service.java
new file mode 100644
index 0000000..ee221cc
--- /dev/null
+++ b/src/main/java/com/usememo/jugger/global/s3/service/S3Service.java
@@ -0,0 +1,9 @@
+package com.usememo.jugger.global.s3.service;
+
+import org.springframework.http.codec.multipart.FilePart;
+
+import reactor.core.publisher.Mono;
+
+public interface S3Service {
+	Mono<String> uploadFile(FilePart filePart);
+}
diff --git a/src/main/java/com/usememo/jugger/global/s3/service/S3ServiceImplementation.java b/src/main/java/com/usememo/jugger/global/s3/service/S3ServiceImplementation.java
new file mode 100644
index 0000000..f344ebb
--- /dev/null
+++ b/src/main/java/com/usememo/jugger/global/s3/service/S3ServiceImplementation.java
@@ -0,0 +1,42 @@
+package com.usememo.jugger.global.s3.service;
+
+
+import io.awspring.cloud.s3.S3Template;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.http.codec.multipart.FilePart;
+import org.springframework.stereotype.Service;
+
+import reactor.core.publisher.Mono;
+
+import java.io.InputStream;
+import java.util.UUID;
+
+import com.usememo.jugger.global.exception.s3.S3UploadException;
+
+@Service
+@RequiredArgsConstructor
+public class S3ServiceImplementation implements S3Service{
+
+	private final S3Template s3Template;
+	@Value("${spring.cloud.aws.s3.bucket}")
+	private String bucketName;
+
+	public Mono<String> uploadFile(FilePart filePart) {
+		String originalFilename = filePart.filename();
+		String ext = originalFilename.substring(originalFilename.lastIndexOf("."));
+		String newFileName = UUID.randomUUID() + ext;
+
+		return DataBufferUtils.join(filePart.content())
+			.flatMap(dataBuffer -> {
+				try (InputStream inputStream = dataBuffer.asInputStream()) {
+					s3Template.upload(bucketName, newFileName, inputStream);
+					return Mono.just("https://" + bucketName + ".s3.ap-northeast-2.amazonaws.com/" + newFileName);
+				} catch (Exception e) {
+					return Mono.error(new S3UploadException());
+				}
+			});
+	}
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
deleted file mode 100644
index 0297a0d..0000000
--- a/src/main/resources/application.yml
+++ /dev/null
@@ -1,4 +0,0 @@
-spring:
-  data:
-    mongodb:
-      uri: mongodb://localhost:27017/jugger
diff --git a/src/test/java/com/usememo/jugger/global/s3/service/S3ServiceImplementationTest.java b/src/test/java/com/usememo/jugger/global/s3/service/S3ServiceImplementationTest.java
new file mode 100644
index 0000000..08d328c
--- /dev/null
+++ b/src/test/java/com/usememo/jugger/global/s3/service/S3ServiceImplementationTest.java
@@ -0,0 +1,51 @@
+package com.usememo.jugger.global.s3.service;
+
+import io.awspring.cloud.s3.S3Template;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.core.io.buffer.DefaultDataBufferFactory;
+import org.springframework.http.codec.multipart.FilePart;
+import org.springframework.test.util.ReflectionTestUtils;
+import reactor.core.publisher.Flux;
+import reactor.test.StepVerifier;
+
+import java.io.InputStream;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+class S3ServiceImplementationTest {
+
+	@Test
+	@DisplayName("S3 업로드 테스트")
+	void uploadFile_shouldReturnUrl_whenSuccessful() {
+		// given
+		S3Template s3Template = mock(S3Template.class);
+		S3ServiceImplementation s3ServiceImplementation = new S3ServiceImplementation(s3Template);
+
+		String testBucketName = "jugger-bucket";
+		ReflectionTestUtils.setField(s3ServiceImplementation, "bucketName", testBucketName);
+
+		FilePart filePart = mock(FilePart.class);
+		when(filePart.filename()).thenReturn("test-image.jpg");
+
+		DataBuffer dataBuffer = new DefaultDataBufferFactory().wrap("fake-image-content".getBytes());
+		when(filePart.content()).thenReturn(Flux.just(dataBuffer));
+
+		when(s3Template.upload(eq(testBucketName), any(String.class), any(InputStream.class)))
+			.thenReturn(null);
+
+		// when & then
+		StepVerifier.create(s3ServiceImplementation.uploadFile(filePart))
+			.assertNext(url -> {
+				assert url.startsWith("https://" + testBucketName + ".s3.ap-northeast-2.amazonaws.com/");
+				assert url.endsWith(".jpg");
+			})
+			.verifyComplete();
+
+		verify(s3Template, times(1))
+			.upload(eq(testBucketName), any(String.class), any(InputStream.class));
+	}
+}


Reviews:
user: login: JjungminLee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T20:00:25Z
user: login: minhyuk2
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T04:30:02Z
user: login: minhyuk2
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T04:30:23Z
user: login: minhyuk2
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T04:30:36Z
user: login: JjungminLee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T11:51:29Z
user: login: JjungminLee
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-06T07:08:35Z

Review comments:
diff_hunk: @@ -0,0 +1,38 @@
+package com.usememo.jugger.global.s3.service;
+
+import io.awspring.cloud.s3.S3Template;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.http.codec.multipart.FilePart;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+import reactor.core.publisher.Mono;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+public class S3Service {
path: src/main/java/com/usememo/jugger/global/s3/service/S3Service.java
user: login: JjungminLee
body: 해당 Service는 인터페이스로 만들고 구현체를 클래스로 만들어보는거 어떨까요
created_at: 2025-04-04T19:58:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,38 @@
+package com.usememo.jugger.global.s3.service;
+
+import io.awspring.cloud.s3.S3Template;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.http.codec.multipart.FilePart;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+import reactor.core.publisher.Mono;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+public class S3Service {
+
+	private final S3Template s3Template;
+	private static final String BUCKET_NAME = "jugger-bucket";
path: src/main/java/com/usememo/jugger/global/s3/service/S3Service.java
user: login: JjungminLee
body: 얘도 시크릿으로 둬도 좋을것 같아여
created_at: 2025-04-04T19:59:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,38 @@
+package com.usememo.jugger.global.s3.service;
+
+import io.awspring.cloud.s3.S3Template;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.http.codec.multipart.FilePart;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+import reactor.core.publisher.Mono;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+public class S3Service {
+
+	private final S3Template s3Template;
+	private static final String BUCKET_NAME = "jugger-bucket";
+
+	public Mono<String> uploadFile(FilePart filePart) {
+		String originalFilename = filePart.filename();
+		String ext = originalFilename.substring(originalFilename.lastIndexOf("."));
+		String newFileName = UUID.randomUUID() + ext;
+
+		return DataBufferUtils.join(filePart.content())
+			.flatMap(dataBuffer -> {
+				try (InputStream inputStream = dataBuffer.asInputStream()) {
+					s3Template.upload(BUCKET_NAME, newFileName, inputStream);
+					return Mono.just("https://" + BUCKET_NAME + ".s3.ap-northeast-2.amazonaws.com/" + newFileName);
+				} catch (IOException e) {
+					return Mono.error(new RuntimeException("파일 업로드 실패", e));
+				}
path: src/main/java/com/usememo/jugger/global/s3/service/S3Service.java
user: login: JjungminLee
body: 미리 만들어둔 Exception클래스가 있을거에요 그거 상속받아서 파일업로드 실패 에러 클래스와 에러코드를 만들어서 코드 수정해주세여
created_at: 2025-04-04T20:00:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,38 @@
+package com.usememo.jugger.global.s3.service;
+
+import io.awspring.cloud.s3.S3Template;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.http.codec.multipart.FilePart;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+import reactor.core.publisher.Mono;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+public class S3Service {
path: src/main/java/com/usememo/jugger/global/s3/service/S3Service.java
user: login: minhyuk2
body: 좋아요 쩡킹님한테 그렇게 만드는거 좋다하고 그렇게 안해버린 ...ㅠ
created_at: 2025-04-05T04:30:02Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,38 @@
+package com.usememo.jugger.global.s3.service;
+
+import io.awspring.cloud.s3.S3Template;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.http.codec.multipart.FilePart;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+import reactor.core.publisher.Mono;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+public class S3Service {
+
+	private final S3Template s3Template;
+	private static final String BUCKET_NAME = "jugger-bucket";
path: src/main/java/com/usememo/jugger/global/s3/service/S3Service.java
user: login: minhyuk2
body: 네넨 secret해두고,, 이렇게 작성한 나쁜 gpt
created_at: 2025-04-05T04:30:23Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,38 @@
+package com.usememo.jugger.global.s3.service;
+
+import io.awspring.cloud.s3.S3Template;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.http.codec.multipart.FilePart;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+import reactor.core.publisher.Mono;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+public class S3Service {
+
+	private final S3Template s3Template;
+	private static final String BUCKET_NAME = "jugger-bucket";
+
+	public Mono<String> uploadFile(FilePart filePart) {
+		String originalFilename = filePart.filename();
+		String ext = originalFilename.substring(originalFilename.lastIndexOf("."));
+		String newFileName = UUID.randomUUID() + ext;
+
+		return DataBufferUtils.join(filePart.content())
+			.flatMap(dataBuffer -> {
+				try (InputStream inputStream = dataBuffer.asInputStream()) {
+					s3Template.upload(BUCKET_NAME, newFileName, inputStream);
+					return Mono.just("https://" + BUCKET_NAME + ".s3.ap-northeast-2.amazonaws.com/" + newFileName);
+				} catch (IOException e) {
+					return Mono.error(new RuntimeException("파일 업로드 실패", e));
+				}
path: src/main/java/com/usememo/jugger/global/s3/service/S3Service.java
user: login: minhyuk2
body: 알겠습니당
created_at: 2025-04-05T04:30:36Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,38 @@
+package com.usememo.jugger.global.s3.service;
+
+import io.awspring.cloud.s3.S3Template;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.http.codec.multipart.FilePart;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+import reactor.core.publisher.Mono;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+public class S3Service {
+
+	private final S3Template s3Template;
+	private static final String BUCKET_NAME = "jugger-bucket";
path: src/main/java/com/usememo/jugger/global/s3/service/S3Service.java
user: login: JjungminLee
body: ㅋㅋㅋㅋㅋ
created_at: 2025-04-05T11:51:29Z
author_association: CONTRIBUTOR

PR comments:
user: login: JjungminLee
created_at: 2025-04-04T20:02:19Z
body: 
> ## 💬 공유사항 to 리뷰어
> UUID로 업로드한 url를 구성하였습니다. 또한 파일의 위치를 global로 두어서 어떤 domain에서든 사용할 수 있게 해두었습니다. 이건 추후에 조정해도 좋을 것 같습니다. 또한 application.yml 파일은 삭제해두었습니다. gihub secret에 설정들을 넣어두어서 배포 시에 문제 없게 설정해둘 예정입니다.
--> application.yml에는 노션에 꼭 공유해주십셔
author_association: CONTRIBUTOR
user: login: minhyuk2
created_at: 2025-04-06T08:39:14Z
body: 말씀하신 부분들 변경하고 수정해서 merge하겠습니다
코드 변경 후 테스트 성공 확인하였습니다
<img width="821" alt="스크린샷 2025-04-06 오후 5 39 07" src="https://github.com/user-attachments/assets/e80bce16-9362-43ad-9ba5-e0875bcac012" />

author_association: MEMBER