diff --git a/build.gradle b/build.gradle index 85a2681c..b0f56bc1 100644 --- a/build.gradle +++ b/build.gradle @@ -76,6 +76,11 @@ dependencies { // WebFlux implementation 'org.springframework.boot:spring-boot-starter-webflux' + + //s3 + implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1") + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + implementation 'org.apache.httpcomponents:httpclient:4.5.14' } dependencyManagement { diff --git a/src/main/java/com/sofa/linkiving/domain/link/abstraction/ImageUploader.java b/src/main/java/com/sofa/linkiving/domain/link/abstraction/ImageUploader.java new file mode 100644 index 00000000..869140c2 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/abstraction/ImageUploader.java @@ -0,0 +1,9 @@ +package com.sofa.linkiving.domain.link.abstraction; + +public interface ImageUploader { + /** + * 외부 이미지 URL을 입력받아 스토리지에 저장하고, 접근 가능한 URL을 반환한다. + * 실패 시 null 값을 반환한다 (Soft Fail). + */ + String uploadFromUrl(String originalUrl); +} diff --git a/src/main/java/com/sofa/linkiving/domain/link/facade/LinkFacade.java b/src/main/java/com/sofa/linkiving/domain/link/facade/LinkFacade.java index b8fb481c..2bd06075 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/facade/LinkFacade.java +++ b/src/main/java/com/sofa/linkiving/domain/link/facade/LinkFacade.java @@ -3,6 +3,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.sofa.linkiving.domain.link.abstraction.ImageUploader; import com.sofa.linkiving.domain.link.dto.internal.LinkDto; import com.sofa.linkiving.domain.link.dto.internal.LinksDto; import com.sofa.linkiving.domain.link.dto.internal.OgTagDto; @@ -29,9 +30,11 @@ public class LinkFacade { private final LinkService linkService; private final OgTagCrawler ogTagCrawler; private final SummaryService summaryService; + private final ImageUploader imageUploader; public LinkRes createLink(Member member, String url, String title, String memo, String imageUrl) { - Link link = linkService.createLink(member, url, title, memo, imageUrl); + String storedImageUrl = imageUploader.uploadFromUrl(imageUrl); + Link link = linkService.createLink(member, url, title, memo, storedImageUrl); return LinkRes.from(link); } diff --git a/src/main/java/com/sofa/linkiving/infra/s3/DefaultUrlConnectionFactory.java b/src/main/java/com/sofa/linkiving/infra/s3/DefaultUrlConnectionFactory.java new file mode 100644 index 00000000..1994244b --- /dev/null +++ b/src/main/java/com/sofa/linkiving/infra/s3/DefaultUrlConnectionFactory.java @@ -0,0 +1,16 @@ +package com.sofa.linkiving.infra.s3; + +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; + +import org.springframework.stereotype.Component; + +@Component +public class DefaultUrlConnectionFactory implements UrlConnectionFactory { + + @Override + public URLConnection createConnection(String url) throws IOException { + return new URL(url).openConnection(); + } +} diff --git a/src/main/java/com/sofa/linkiving/infra/s3/S3ImageUploader.java b/src/main/java/com/sofa/linkiving/infra/s3/S3ImageUploader.java new file mode 100644 index 00000000..8790adda --- /dev/null +++ b/src/main/java/com/sofa/linkiving/infra/s3/S3ImageUploader.java @@ -0,0 +1,92 @@ +package com.sofa.linkiving.infra.s3; + +import java.io.InputStream; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.sofa.linkiving.domain.link.abstraction.ImageUploader; + +import io.awspring.cloud.s3.S3Template; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class S3ImageUploader implements ImageUploader { + + private final S3Template s3Template; + private final UrlConnectionFactory urlConnectionFactory; + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucketName; + + @Value("${spring.cloud.aws.region.static}") + private String region; + + @Override + public String uploadFromUrl(String originalUrl) { + if (originalUrl == null || originalUrl.isBlank()) { + return null; + } + + try { + String s3Key = generateUniqueKeyFromUrl(originalUrl); + + String s3Url = buildS3Url(s3Key); + + if (s3Template.objectExists(bucketName, s3Key)) { + log.info("Image already exists (Cache Hit): {} -> {}", originalUrl, s3Key); + return s3Url; + } + + URLConnection connection = urlConnectionFactory.createConnection(originalUrl); + connection.setConnectTimeout(3000); + connection.setReadTimeout(3000); + + String contentType = connection.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + log.warn("Not Image: {}", originalUrl); + return null; + } + + try (InputStream inputStream = connection.getInputStream()) { + s3Template.upload(bucketName, s3Key, inputStream, null); + log.info("Image uploaded: {}", s3Key); + return s3Url; + } + + } catch (Exception e) { + log.warn("Upload failed, falling back to original URL: {}", e.getMessage()); + return null; + } + } + + private String buildS3Url(String key) { + return String.format("https://%s.s3.%s.amazonaws.com/%s", bucketName, region, key); + } + + private String generateUniqueKeyFromUrl(String url) { + try { + String extension = "jpg"; + int lastDotIndex = url.lastIndexOf('.'); + if (lastDotIndex > 0 && lastDotIndex < url.length() - 1) { + String ext = url.substring(lastDotIndex + 1); + if (ext.contains("?")) { + ext = ext.substring(0, ext.indexOf("?")); + } + if (ext.length() <= 4 && ext.matches("[a-zA-Z]+")) { + extension = ext; + } + } + UUID uuid = UUID.nameUUIDFromBytes(url.getBytes(StandardCharsets.UTF_8)); + return "links/" + uuid + "." + extension; + } catch (Exception e) { + return "links/" + UUID.randomUUID() + ".jpg"; + } + } +} diff --git a/src/main/java/com/sofa/linkiving/infra/s3/UrlConnectionFactory.java b/src/main/java/com/sofa/linkiving/infra/s3/UrlConnectionFactory.java new file mode 100644 index 00000000..71d8f6ed --- /dev/null +++ b/src/main/java/com/sofa/linkiving/infra/s3/UrlConnectionFactory.java @@ -0,0 +1,8 @@ +package com.sofa.linkiving.infra.s3; + +import java.io.IOException; +import java.net.URLConnection; + +public interface UrlConnectionFactory { + URLConnection createConnection(String url) throws IOException; +} diff --git a/src/test/java/com/sofa/linkiving/domain/link/facade/LinkFacadeTest.java b/src/test/java/com/sofa/linkiving/domain/link/facade/LinkFacadeTest.java index 569bae1f..9280a7ba 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/facade/LinkFacadeTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/facade/LinkFacadeTest.java @@ -15,10 +15,12 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import com.sofa.linkiving.domain.link.abstraction.ImageUploader; import com.sofa.linkiving.domain.link.dto.internal.LinkDto; import com.sofa.linkiving.domain.link.dto.internal.LinksDto; import com.sofa.linkiving.domain.link.dto.internal.OgTagDto; import com.sofa.linkiving.domain.link.dto.response.LinkCardsRes; +import com.sofa.linkiving.domain.link.dto.response.LinkRes; import com.sofa.linkiving.domain.link.dto.response.MetaScrapeRes; import com.sofa.linkiving.domain.link.dto.response.RecreateSummaryResponse; import com.sofa.linkiving.domain.link.entity.Link; @@ -48,18 +50,21 @@ public class LinkFacadeTest { @Mock private LinkQueryService linkQueryService; - @Mock - private ApplicationEventPublisher eventPublisher; - @Mock private SummaryService summaryService; @Mock private OgTagCrawler ogTagCrawler; + @Mock + private ImageUploader imageUploader; + + @Mock + private ApplicationEventPublisher eventPublisher; + @BeforeEach void setUp() { - linkFacade = new LinkFacade(linkService, ogTagCrawler, summaryService); + linkFacade = new LinkFacade(linkService, ogTagCrawler, summaryService, imageUploader); } @Test @@ -93,7 +98,7 @@ void shouldReturnMetaScrapeResWhenCrawlSucceeds() { void shouldReturnRecreateSummaryResponseWhenRecreateSummary() { // given Long linkId = 1L; - Member member = mock(Member.class); // Member 객체 Mock + Member member = mock(Member.class); Format format = Format.DETAILED; String url = "https://example.com"; String existingSummaryBody = "기존 요약 내용입니다."; @@ -149,31 +154,40 @@ void shouldReturnEmptyMetaScrapeResWhenCrawlFails() { } @Test - @DisplayName("링크를 생성하고 Entity를 반환한다") + @DisplayName("이미지 URL을 업로드하고 반환된 저장 경로로 링크를 생성한다") void shouldCreateLink() { // given - Member member = Member.builder().email("test@example.com").build(); - Link link = Link.builder() - .member(member) - .url("https://example.com") - .title("테스트 링크") + Member member = mock(Member.class); + String url = "https://example.com"; + String title = "테스트 제목"; + String memo = "테스트 메모"; + String originalImageUrl = "https://original.com/image.jpg"; + String storedImageUrl = "https://s3-bucket.com/stored-image.jpg"; + + given(imageUploader.uploadFromUrl(originalImageUrl)).willReturn(storedImageUrl); + + Link savedLink = Link.builder() + .url(url) + .title(title) + .memo(memo) + .imageUrl(storedImageUrl) .build(); - given(linkQueryService.existsByUrl(member, "https://example.com")).willReturn(false); - given(linkCommandService.saveLink(any(), any(), any(), any(), any())).willReturn(link); + given(linkCommandService.saveLink(member, url, title, memo, storedImageUrl)) + .willReturn(savedLink); // when - Link createdLink = linkService.createLink( - member, "https://example.com", "테스트 링크", "메모", null - ); + LinkRes result = linkFacade.createLink(member, url, title, memo, originalImageUrl); // then - assertThat(createdLink).isNotNull(); - assertThat(createdLink).isInstanceOf(Link.class); // Entity 반환 확인 - assertThat(createdLink.getUrl()).isEqualTo("https://example.com"); + assertThat(result).isNotNull(); + assertThat(result.url()).isEqualTo(url); + assertThat(result.imageUrl()).isEqualTo(storedImageUrl); - verify(linkQueryService, times(1)).existsByUrl(member, "https://example.com"); - verify(linkCommandService, times(1)).saveLink(any(), any(), any(), any(), any()); + // Verify + verify(imageUploader, times(1)).uploadFromUrl(originalImageUrl); + verify(linkQueryService, times(1)).existsByUrl(member, url); + verify(linkCommandService, times(1)).saveLink(member, url, title, memo, storedImageUrl); } @Test diff --git a/src/test/java/com/sofa/linkiving/domain/link/integration/LinkApiIntegrationTest.java b/src/test/java/com/sofa/linkiving/domain/link/integration/LinkApiIntegrationTest.java index d1a52720..371dba83 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/integration/LinkApiIntegrationTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/integration/LinkApiIntegrationTest.java @@ -21,6 +21,7 @@ import org.springframework.transaction.annotation.Transactional; import com.fasterxml.jackson.databind.ObjectMapper; +import com.sofa.linkiving.domain.link.abstraction.ImageUploader; import com.sofa.linkiving.domain.link.ai.AiSummaryClient; import com.sofa.linkiving.domain.link.dto.request.LinkCreateReq; import com.sofa.linkiving.domain.link.dto.request.LinkMemoUpdateReq; @@ -64,6 +65,9 @@ public class LinkApiIntegrationTest { @MockitoBean private AiSummaryClient aiSummaryClient; + @MockitoBean + private ImageUploader imageUploader; + private Member testMember; private Member otherMember; private UserDetails testUserDetails; @@ -89,7 +93,17 @@ void setUp() { @DisplayName("링크 생성 성공 시 DB에 저장되고 200 OK 응답") void shouldCreateLinkSuccessfully() throws Exception { // given - LinkCreateReq req = new LinkCreateReq("https://example.com", "테스트 링크", "테스트 메모", null); + String originalImageUrl = "https://original.com/image.jpg"; + String uploadedS3Url = "https://s3.amazonaws.com/bucket/links/uuid.jpg"; + + given(imageUploader.uploadFromUrl(originalImageUrl)).willReturn(uploadedS3Url); + + LinkCreateReq req = new LinkCreateReq( + "https://example.com", + "테스트 링크", + "테스트 메모", + originalImageUrl + ); // when & then mockMvc.perform( diff --git a/src/test/java/com/sofa/linkiving/infra/s3/S3ImageUploaderTest.java b/src/test/java/com/sofa/linkiving/infra/s3/S3ImageUploaderTest.java new file mode 100644 index 00000000..dc88e83f --- /dev/null +++ b/src/test/java/com/sofa/linkiving/infra/s3/S3ImageUploaderTest.java @@ -0,0 +1,144 @@ +package com.sofa.linkiving.infra.s3; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URLConnection; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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 org.springframework.test.util.ReflectionTestUtils; + +import io.awspring.cloud.s3.S3Template; + +@ExtendWith(MockitoExtension.class) +@DisplayName("S3ImageUploader 단위 테스트") +public class S3ImageUploaderTest { + + private static final String BUCKET_NAME = "test-bucket"; + private static final String REGION = "ap-northeast-2"; + @InjectMocks + private S3ImageUploader s3ImageUploader; + @Mock + private S3Template s3Template; + @Mock + private UrlConnectionFactory urlConnectionFactory; + @Mock + private URLConnection mockConnection; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(s3ImageUploader, "bucketName", BUCKET_NAME); + ReflectionTestUtils.setField(s3ImageUploader, "region", REGION); + } + + @Test + @DisplayName("정상적인 이미지 URL인 경우 S3에 업로드하고 생성된 S3 URL을 반환한다") + void shouldUploadImageWhenUrlIsValid() throws IOException { + // given + String originalUrl = "https://example.com/image.jpg"; + + // 1. 중복 검사 통과 (S3에 파일 없음) + given(s3Template.objectExists(eq(BUCKET_NAME), anyString())).willReturn(false); + + // 2. Factory가 Mock Connection 반환 + given(urlConnectionFactory.createConnection(originalUrl)).willReturn(mockConnection); + + // 3. Connection 동작 설정 (이미지 타입, 스트림) + given(mockConnection.getContentType()).willReturn("image/jpeg"); + given(mockConnection.getInputStream()).willReturn(new ByteArrayInputStream("dummy-data".getBytes())); + + // when + String result = s3ImageUploader.uploadFromUrl(originalUrl); + + // then + // 예상되는 S3 URL 포맷 확인 + // UUID 생성이 내부 로직이라 정확한 키값 예측은 어려우나, bucket/region/경로 포함 여부 확인 + assertThat(result).startsWith("https://" + BUCKET_NAME + ".s3." + REGION + ".amazonaws.com/links/"); + assertThat(result).endsWith(".jpg"); + + // 실제 업로드가 수행되었는지 검증 + verify(s3Template).upload(eq(BUCKET_NAME), anyString(), any(InputStream.class), isNull()); + } + + @Test + @DisplayName("이미 S3에 동일한 이미지가 존재하면(Cache Hit) 업로드 없이 S3 URL을 반환한다") + void shouldReturnS3UrlWhenImageExists() throws IOException { + // given + String originalUrl = "https://example.com/image.jpg"; + + // 이미 파일이 존재한다고 설정 (Cache Hit) + given(s3Template.objectExists(eq(BUCKET_NAME), anyString())).willReturn(true); + + // when + String result = s3ImageUploader.uploadFromUrl(originalUrl); + + // then + assertThat(result).contains(BUCKET_NAME, REGION, "links/"); + + // Factory 연결 생성 및 Upload가 호출되지 않아야 함 + verify(urlConnectionFactory, never()).createConnection(anyString()); + verify(s3Template, never()).upload(anyString(), anyString(), any(InputStream.class), any()); + } + + @Test + @DisplayName("ContentType이 이미지가 아닌 경우(예: HTML, PDF) Null을 반환한다") + void shouldReturnDefaultImageUrlWhenNotImage() throws IOException { + // given + String originalUrl = "https://example.com/document.pdf"; + + given(s3Template.objectExists(eq(BUCKET_NAME), anyString())).willReturn(false); + given(urlConnectionFactory.createConnection(originalUrl)).willReturn(mockConnection); + + // ContentType을 이미지가 아닌 것으로 설정 + given(mockConnection.getContentType()).willReturn("application/pdf"); + + // when + String result = s3ImageUploader.uploadFromUrl(originalUrl); + + // then + assertThat(result).isNull(); + + // Upload 호출 안 됨 + verify(s3Template, never()).upload(anyString(), anyString(), any(InputStream.class), any()); + } + + @Test + @DisplayName("연결 실패나 업로드 중 예외 발생 시 Null을 반환한다 (Fallback)") + void shouldReturnDefaultImageUrlWhenExceptionOccurs() throws IOException { + // given + String originalUrl = "https://example.com/image.jpg"; + + given(s3Template.objectExists(eq(BUCKET_NAME), anyString())).willReturn(false); + + // 연결 생성 시 예외 발생하도록 설정 + given(urlConnectionFactory.createConnection(originalUrl)).willThrow(new IOException("Connection Refused")); + + // when + String result = s3ImageUploader.uploadFromUrl(originalUrl); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("입력 URL이 null이거나 비어있으면 null을 반환한다") + void shouldReturnNullWhenUrlIsEmpty() { + // when + String resultNull = s3ImageUploader.uploadFromUrl(null); + String resultEmpty = s3ImageUploader.uploadFromUrl(""); + + // then + assertThat(resultNull).isNull(); + assertThat(resultEmpty).isNull(); + } +}