diff --git a/.gitignore b/.gitignore index 3fb8f8d0..af5fb265 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ out/ /newrelic .secrets .env +.env.local .env.dev .env.redis .env.redis.dev diff --git a/src/main/java/com/newzet/api/article/domain/Article.java b/src/main/java/com/newzet/api/article/domain/Article.java index 08a924da..71fb8cb5 100644 --- a/src/main/java/com/newzet/api/article/domain/Article.java +++ b/src/main/java/com/newzet/api/article/domain/Article.java @@ -101,5 +101,9 @@ public Article share() { this.deletedAt ); } + + public boolean isSaveInStorage() { + return contentUrl.endsWith(".html"); + } } diff --git a/src/main/java/com/newzet/api/article/orchestrator/ArticleOrchestrator.java b/src/main/java/com/newzet/api/article/orchestrator/ArticleOrchestrator.java index 14891260..9dea1e23 100644 --- a/src/main/java/com/newzet/api/article/orchestrator/ArticleOrchestrator.java +++ b/src/main/java/com/newzet/api/article/orchestrator/ArticleOrchestrator.java @@ -8,7 +8,7 @@ import com.newzet.api.article.business.service.ArticleService; import com.newzet.api.article.controller.dto.ArticleContentResponse; import com.newzet.api.article.domain.Article; -import com.newzet.api.common.s3.S3Service; +import com.newzet.api.common.storage.StorageService; import lombok.RequiredArgsConstructor; @@ -18,11 +18,11 @@ public class ArticleOrchestrator { private final ArticleService articleService; - private final S3Service s3Service; + private final StorageService storageService; public ArticleContentResponse getSharedArticle(UUID articleId) { Article sharedArticle = articleService.getSharedArticle(articleId); - String content = s3Service.getContentAsString(sharedArticle.getContentUrl()); + String content = storageService.getContent(sharedArticle.getContentUrl(), sharedArticle.isSaveInStorage()); return new ArticleContentResponse(sharedArticle.getTitle(), content, sharedArticle.isLike()); } @@ -30,7 +30,7 @@ public ArticleContentResponse getSharedArticle(UUID articleId) { @Transactional public ArticleContentResponse getArticle(UUID articleId) { Article article = articleService.getArticle(articleId); - String content = s3Service.getContentAsString(article.getContentUrl()); + String content = storageService.getContent(article.getContentUrl(), article.isSaveInStorage()); return ArticleContentResponse.of(article.getTitle(), content, article.isLike()); } diff --git a/src/main/java/com/newzet/api/common/s3/S3Service.java b/src/main/java/com/newzet/api/common/s3/S3Service.java deleted file mode 100644 index 8634dd79..00000000 --- a/src/main/java/com/newzet/api/common/s3/S3Service.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.newzet.api.common.s3; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; - -import org.springframework.stereotype.Service; - -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.model.AmazonS3Exception; -import com.amazonaws.services.s3.model.GetObjectRequest; -import com.amazonaws.services.s3.model.S3Object; -import com.amazonaws.services.s3.model.S3ObjectInputStream; -import com.newzet.api.common.exception.InternalErrorException; -import com.newzet.api.config.s3.S3Config; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Service -@RequiredArgsConstructor -@Slf4j -public class S3Service { - private final AmazonS3 amazonS3; - private final S3Config s3Config; - - public String getContentAsString(String key) { - GetObjectRequest getObjectRequest = new GetObjectRequest(s3Config.getContentBucketName(), key); - - try (S3Object s3Object = amazonS3.getObject(getObjectRequest); - S3ObjectInputStream inputStream = s3Object.getObjectContent()) { - - byte[] contentBytes = inputStream.readAllBytes(); - return new String(contentBytes, StandardCharsets.UTF_8); - - } catch (AmazonS3Exception e) { - log.error("S3에서 객체를 가져오는 중 오류가 발생했습니다. Key: {}", key, e); - throw new InternalErrorException("아티클을 불러오는 과정에서 에러가 발생하였습니다."); - } catch (IOException e) { - log.error("S3 파일 내용을 읽는 중 I/O 오류가 발생했습니다.", e); - throw new InternalErrorException("아티클을 불러오는 과정에서 에러가 발생하였습니다."); - } - } -} diff --git a/src/main/java/com/newzet/api/common/storage/StorageService.java b/src/main/java/com/newzet/api/common/storage/StorageService.java new file mode 100644 index 00000000..4bbd2715 --- /dev/null +++ b/src/main/java/com/newzet/api/common/storage/StorageService.java @@ -0,0 +1,63 @@ +package com.newzet.api.common.storage; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.AmazonS3Exception; +import com.amazonaws.services.s3.model.GetObjectRequest; +import com.amazonaws.services.s3.model.S3Object; +import com.amazonaws.services.s3.model.S3ObjectInputStream; +import com.newzet.api.common.exception.InternalErrorException; +import com.newzet.api.config.storage.StorageConfig; + +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +public class StorageService { + + private final AmazonS3 awsS3Client; + private final AmazonS3 supabaseS3Client; + private final StorageConfig storageConfig; + + public StorageService(@Qualifier("awsS3Client") AmazonS3 awsS3Client, + @Qualifier("supabaseS3Client") AmazonS3 supabaseS3Client, + StorageConfig storageConfig) { + this.awsS3Client = awsS3Client; + this.supabaseS3Client = supabaseS3Client; + this.storageConfig = storageConfig; + } + + public String getContent(String key, boolean isSaveInStorage) { + AmazonS3 client; + String bucketName; + + if (isSaveInStorage) { + client = supabaseS3Client; + bucketName = storageConfig.getSupabaseBucketName(); + } else { + client = awsS3Client; + bucketName = storageConfig.getAwsBucketName(); + } + + GetObjectRequest getObjectRequest = new GetObjectRequest(bucketName, key); + + try (S3Object s3Object = client.getObject(getObjectRequest); + S3ObjectInputStream inputStream = s3Object.getObjectContent()) { + + byte[] contentBytes = inputStream.readAllBytes(); + return new String(contentBytes, StandardCharsets.UTF_8); + + } catch (AmazonS3Exception e) { + log.error("Storage에서 객체를 가져오는 중 오류가 발생했습니다. Key: {}", key, e); + throw new InternalErrorException("아티클을 불러오는 과정에서 에러가 발생하였습니다."); + } catch (IOException e) { + log.error("Storage 파일 내용을 읽는 중 I/O 오류가 발생했습니다.", e); + throw new InternalErrorException("아티클을 불러오는 과정에서 에러가 발생하였습니다."); + } + } +} diff --git a/src/main/java/com/newzet/api/config/s3/S3Config.java b/src/main/java/com/newzet/api/config/s3/S3Config.java deleted file mode 100644 index 48aab511..00000000 --- a/src/main/java/com/newzet/api/config/s3/S3Config.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.newzet.api.config.s3; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; - -import lombok.Getter; - -@Configuration -@Getter -public class S3Config { - - @Value("${cloud.aws.s3.bucket}") - private String contentBucketName; - - @Value("${cloud.aws.region.static}") - private String region; - - @Value("${cloud.aws.credentials.access-key}") - private String accessKey; - - @Value("${cloud.aws.credentials.secret-key}") - private String secretKey; - - @Bean - public AmazonS3 amazonS3() { - BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey); - return AmazonS3ClientBuilder.standard() - .withRegion(region) - .withCredentials(new AWSStaticCredentialsProvider(awsCreds)) - .build(); - } -} diff --git a/src/main/java/com/newzet/api/config/storage/StorageConfig.java b/src/main/java/com/newzet/api/config/storage/StorageConfig.java new file mode 100644 index 00000000..0feeca9a --- /dev/null +++ b/src/main/java/com/newzet/api/config/storage/StorageConfig.java @@ -0,0 +1,65 @@ +package com.newzet.api.config.storage; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; + +import lombok.Getter; + +@Configuration +@Getter +public class StorageConfig { + + // AWS S3 Credentials + @Value("${cloud.aws.s3.bucket}") + private String awsBucketName; + @Value("${cloud.aws.region.static}") + private String awsRegion; + @Value("${cloud.aws.credentials.access-key}") + private String awsAccessKey; + @Value("${cloud.aws.credentials.secret-key}") + private String awsSecretKey; + + // Supabase S3-compatible Storage Credentials + @Value("${cloud.supabase.storage.bucket}") + private String supabaseBucketName; + @Value("${cloud.supabase.storage.region}") + private String supabaseRegion; + @Value("${cloud.supabase.storage.endpoint-url}") + private String supabaseEndpointUrl; + @Value("${cloud.supabase.storage.credentials.access-key}") + private String supabaseAccessKey; + @Value("${cloud.supabase.storage.credentials.secret-key}") + private String supabaseSecretKey; + + @Bean + @Qualifier("awsS3Client") + public AmazonS3 awsS3Client() { + AWSCredentials credentials = new BasicAWSCredentials(awsAccessKey, awsSecretKey); + return AmazonS3ClientBuilder.standard() + .withRegion(awsRegion) + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .build(); + } + + @Bean + @Qualifier("supabaseS3Client") + public AmazonS3 supabaseS3Client() { + AWSCredentials credentials = new BasicAWSCredentials(supabaseAccessKey, supabaseSecretKey); + AwsClientBuilder.EndpointConfiguration endpointConfig = + new AwsClientBuilder.EndpointConfiguration(supabaseEndpointUrl, supabaseRegion); + + return AmazonS3ClientBuilder.standard() + .withEndpointConfiguration(endpointConfig) + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .build(); + } +} diff --git a/src/main/java/com/newzet/api/welcome/api/WelcomeApi.java b/src/main/java/com/newzet/api/welcome/api/WelcomeApi.java index 4ba9cbc5..9cba2284 100644 --- a/src/main/java/com/newzet/api/welcome/api/WelcomeApi.java +++ b/src/main/java/com/newzet/api/welcome/api/WelcomeApi.java @@ -4,6 +4,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import com.newzet.api.common.auth.annotation.Login; +import com.newzet.api.common.auth.annotation.RequireAuth; import com.newzet.api.common.auth.domain.AuthUser; import com.newzet.api.common.response.SuccessResponse; @@ -15,6 +16,7 @@ public interface WelcomeApi { @PostMapping("/welcome") + @RequireAuth @Operation(summary = "welcome 메일 전송", description = "welcome 메일을 전송한다.") SuccessResponse sendWelcomeMail(@Login AuthUser authUser); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 453f4fcc..4e764b56 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -70,4 +70,13 @@ cloud: static: ${S3_REGION} credentials: access-key: ${S3_ACCESS_KEY} - secret-key: ${S3_SECRET_KEY} \ No newline at end of file + secret-key: ${S3_SECRET_KEY} + + supabase: + storage: + bucket: ${SUPABASE_BUCKET_NAME} + endpoint-url: ${SUPABASE_ENDPOINT_URL} + region: ${SUPABASE_REGION} + credentials: + access-key: ${SUPABASE_ACCESS_KEY} + secret-key: ${SUPABASE_SECRET_KEY} \ No newline at end of file diff --git a/src/test/java/com/newzet/api/common/s3/S3ServiceTest.java b/src/test/java/com/newzet/api/common/s3/S3ServiceTest.java deleted file mode 100644 index 81f686b1..00000000 --- a/src/test/java/com/newzet/api/common/s3/S3ServiceTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.newzet.api.common.s3; - -import static org.junit.Assert.*; -import static org.mockito.Mockito.*; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.model.AmazonS3Exception; -import com.amazonaws.services.s3.model.GetObjectRequest; -import com.amazonaws.services.s3.model.S3Object; -import com.amazonaws.services.s3.model.S3ObjectInputStream; -import com.newzet.api.common.exception.InternalErrorException; -import com.newzet.api.config.s3.S3Config; - -@ExtendWith(MockitoExtension.class) -class S3ServiceTest { - - private final String BUCKET_NAME = "test-bucket"; - private final String OBJECT_KEY = "test-file.txt"; - @InjectMocks - private S3Service s3Service; - @Mock - private AmazonS3 amazonS3; // S3Service가 사용하는 SDK v1 클라이언트 - @Mock - private S3Config s3Config; - - @Nested - @DisplayName("성공 케이스") - class SuccessCases { - @Test - @DisplayName("S3 객체를 성공적으로 읽어와 문자열로 반환한다") - void getContentAsString_Success() { - // given - String expectedContent = "S3 Object Content"; - byte[] contentBytes = expectedContent.getBytes(StandardCharsets.UTF_8); - - S3Object mockS3Object = new S3Object(); - S3ObjectInputStream inputStream = new S3ObjectInputStream( - new ByteArrayInputStream(contentBytes), null); - mockS3Object.setObjectContent(inputStream); - - when(s3Config.getContentBucketName()).thenReturn(BUCKET_NAME); - when(amazonS3.getObject(any(GetObjectRequest.class))).thenReturn(mockS3Object); - - // when - String actualContent = s3Service.getContentAsString(OBJECT_KEY); - - // then - assertEquals(expectedContent, actualContent); - } - } - - @Nested - @DisplayName("실패 케이스") - class FailureCases { - @Test - @DisplayName("S3에 해당 Key의 객체가 없으면 InternalErrorException을 던진다") - void getContentAsString_ThrowsNoSuchKeyException() { - // given - when(s3Config.getContentBucketName()).thenReturn(BUCKET_NAME); - when(amazonS3.getObject(any(GetObjectRequest.class))) - .thenThrow(new AmazonS3Exception("The specified key does not exist.")); - - // when & then - assertThrows(InternalErrorException.class, () -> { - s3Service.getContentAsString(OBJECT_KEY); - }); - } - - @Test - @DisplayName("스트림을 읽는 중 IOException이 발생하면 InternalErrorException을 던진다") - void getContentAsString_ThrowsIOException() throws IOException { - // given - S3Object mockS3Object = mock(S3Object.class); - S3ObjectInputStream mockInputStream = mock(S3ObjectInputStream.class); - - when(s3Config.getContentBucketName()).thenReturn(BUCKET_NAME); - when(amazonS3.getObject(any(GetObjectRequest.class))).thenReturn(mockS3Object); - when(mockS3Object.getObjectContent()).thenReturn(mockInputStream); - - when(mockInputStream.readAllBytes()).thenThrow(new IOException("스트림 읽기 실패")); - - // when & then - assertThrows(InternalErrorException.class, () -> { - s3Service.getContentAsString(OBJECT_KEY); - }); - } - } -} diff --git a/src/test/java/com/newzet/api/common/storage/StorageServiceTest.java b/src/test/java/com/newzet/api/common/storage/StorageServiceTest.java new file mode 100644 index 00000000..40925923 --- /dev/null +++ b/src/test/java/com/newzet/api/common/storage/StorageServiceTest.java @@ -0,0 +1,132 @@ +package com.newzet.api.common.storage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.AmazonS3Exception; +import com.amazonaws.services.s3.model.GetObjectRequest; +import com.amazonaws.services.s3.model.S3Object; +import com.amazonaws.services.s3.model.S3ObjectInputStream; +import com.newzet.api.common.exception.InternalErrorException; +import com.newzet.api.config.storage.StorageConfig; + +@ExtendWith(MockitoExtension.class) +class StorageServiceTest { + + private final String AWS_BUCKET_NAME = "aws-test-bucket"; + private final String SUPABASE_BUCKET_NAME = "supabase-test-bucket"; + private final String OBJECT_KEY = "test-file.txt"; + + // @InjectMocks를 제거하고 수동으로 주입 + private StorageService storageService; + + @Mock + private AmazonS3 awsS3Client; + + @Mock + private AmazonS3 supabaseS3Client; + + @Mock + private StorageConfig storageConfig; + + @BeforeEach + void setUp() { + // 수동으로 StorageService 인스턴스 생성 및 Mock 주입 + storageService = new StorageService(awsS3Client, supabaseS3Client, storageConfig); + } + + private void mockS3Object(AmazonS3 client, String expectedContent) { + byte[] contentBytes = expectedContent.getBytes(StandardCharsets.UTF_8); + S3Object mockS3Object = new S3Object(); + S3ObjectInputStream inputStream = new S3ObjectInputStream( + new ByteArrayInputStream(contentBytes), null); + mockS3Object.setObjectContent(inputStream); + when(client.getObject(any(GetObjectRequest.class))).thenReturn(mockS3Object); + } + + @Nested + @DisplayName("성공 케이스") + class SuccessCases { + + @Test + @DisplayName("isSaveInStorage가 false일 때 AWS S3 객체를 성공적으로 읽어와 문자열로 반환한다") + void getContent_FromAwsS3_Success() { + // given + String expectedContent = "AWS S3 Object Content"; + when(storageConfig.getAwsBucketName()).thenReturn(AWS_BUCKET_NAME); + mockS3Object(awsS3Client, expectedContent); + + // when + String actualContent = storageService.getContent(OBJECT_KEY, false); + + // then + assertEquals(expectedContent, actualContent); + } + + @Test + @DisplayName("isSaveInStorage가 true일 때 Supabase S3 객체를 성공적으로 읽어와 문자열로 반환한다") + void getContent_FromSupabaseS3_Success() { + // given + String expectedContent = "Supabase S3 Object Content"; + when(storageConfig.getSupabaseBucketName()).thenReturn(SUPABASE_BUCKET_NAME); + mockS3Object(supabaseS3Client, expectedContent); + + // when + String actualContent = storageService.getContent(OBJECT_KEY, true); + + // then + assertEquals(expectedContent, actualContent); + } + } + + @Nested + @DisplayName("실패 케이스") + class FailureCases { + + @Test + @DisplayName("S3에 해당 Key의 객체가 없으면 InternalErrorException을 던진다") + void getContent_ThrowsNoSuchKeyException() { + // given + when(storageConfig.getAwsBucketName()).thenReturn(AWS_BUCKET_NAME); + when(awsS3Client.getObject(any(GetObjectRequest.class))) + .thenThrow(new AmazonS3Exception("The specified key does not exist.")); + + // when & then + assertThrows(InternalErrorException.class, () -> { + storageService.getContent(OBJECT_KEY, false); + }); + } + + @Test + @DisplayName("스트림을 읽는 중 IOException이 발생하면 InternalErrorException을 던진다") + void getContent_ThrowsIOException() throws IOException { + // given + S3Object mockS3Object = mock(S3Object.class); + S3ObjectInputStream mockInputStream = mock(S3ObjectInputStream.class); + + when(storageConfig.getAwsBucketName()).thenReturn(AWS_BUCKET_NAME); + when(awsS3Client.getObject(any(GetObjectRequest.class))).thenReturn(mockS3Object); + when(mockS3Object.getObjectContent()).thenReturn(mockInputStream); + when(mockInputStream.readAllBytes()).thenThrow(new IOException("스트림 읽기 실패")); + + // when & then + assertThrows(InternalErrorException.class, () -> { + storageService.getContent(OBJECT_KEY, false); + }); + } + } +} diff --git a/src/test/java/com/newzet/api/config/S3TestConfig.java b/src/test/java/com/newzet/api/config/S3TestConfig.java index fcf3df4f..ccf5b357 100644 --- a/src/test/java/com/newzet/api/config/S3TestConfig.java +++ b/src/test/java/com/newzet/api/config/S3TestConfig.java @@ -5,16 +5,32 @@ public class S3TestConfig implements BeforeAllCallback { + // AWS S3 Test Properties private static final String S3_REGION = "ap-northeast-2"; private static final String S3_ACCESS_KEY= "Aaaaaaaa"; private static final String S3_SECRET_KEY = "Aaaaaaaa"; private static final String S3_CONTENT_BUCKET_NAME="aaaaaa"; + // Supabase Test Properties + private static final String SUPABASE_BUCKET_NAME = "supabase-test-bucket"; + private static final String SUPABASE_ENDPOINT_URL = "https://example.supabase.co"; + private static final String SUPABASE_REGION = "ap-northeast-2"; + private static final String SUPABASE_ACCESS_KEY = "supabase_test_access_key"; + private static final String SUPABASE_SECRET_KEY = "supabase_test_secret_key"; + @Override public void beforeAll(ExtensionContext context) throws Exception { + // Set AWS Properties System.setProperty("cloud.aws.region.static", S3_REGION); System.setProperty("cloud.aws.credentials.access-key", S3_ACCESS_KEY); System.setProperty("cloud.aws.credentials.secret-key", S3_SECRET_KEY); System.setProperty("cloud.aws.s3.bucket", S3_CONTENT_BUCKET_NAME); + + // Set Supabase Properties + System.setProperty("cloud.supabase.storage.bucket", SUPABASE_BUCKET_NAME); + System.setProperty("cloud.supabase.storage.endpoint-url", SUPABASE_ENDPOINT_URL); + System.setProperty("cloud.supabase.storage.region", SUPABASE_REGION); + System.setProperty("cloud.supabase.storage.credentials.access-key", SUPABASE_ACCESS_KEY); + System.setProperty("cloud.supabase.storage.credentials.secret-key", SUPABASE_SECRET_KEY); } }