From 5614a34c20b4b7d6d010e8d4b977a056de6ea6b6 Mon Sep 17 00:00:00 2001 From: minibr Date: Wed, 21 Jan 2026 18:44:28 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20OG=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=20=EB=B0=8F=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EB=B3=B4=ED=98=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../link/abstraction/ImageUploader.java | 6 +++ .../domain/link/facade/LinkFacade.java | 18 ++++++- .../linkiving/infra/s3/S3ImageUploader.java | 54 ++++++++++++++++--- .../domain/link/facade/LinkFacadeTest.java | 12 ++++- .../integration/LinkApiIntegrationTest.java | 1 + .../infra/s3/S3ImageUploaderTest.java | 23 ++++++++ 6 files changed, 103 insertions(+), 11 deletions(-) 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 index 869140c..241be39 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/abstraction/ImageUploader.java +++ b/src/main/java/com/sofa/linkiving/domain/link/abstraction/ImageUploader.java @@ -6,4 +6,10 @@ public interface ImageUploader { * 실패 시 null 값을 반환한다 (Soft Fail). */ String uploadFromUrl(String originalUrl); + + /** + * 이미지 URL이 이미 스토리지에 저장되어 있다면 해당 URL을 반환한다. + * 저장된 URL이 아니거나 확인할 수 없으면 null을 반환한다. + */ + String resolveStoredUrl(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 2bd0607..69d4651 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 @@ -33,7 +33,10 @@ public class LinkFacade { private final ImageUploader imageUploader; public LinkRes createLink(Member member, String url, String title, String memo, String imageUrl) { - String storedImageUrl = imageUploader.uploadFromUrl(imageUrl); + String storedImageUrl = imageUploader.resolveStoredUrl(imageUrl); + if (storedImageUrl == null) { + storedImageUrl = imageUploader.uploadFromUrl(imageUrl); + } Link link = linkService.createLink(member, url, title, memo, storedImageUrl); return LinkRes.from(link); } @@ -96,6 +99,17 @@ public RecreateSummaryResponse recreateSummary(Member member, Long linkId, Forma @Transactional(readOnly = true) public MetaScrapeRes scrapeMetadata(String url) { OgTagDto ogTag = ogTagCrawler.crawl(url); - return MetaScrapeRes.from(ogTag); + String imageUrl = ogTag.image(); + String uploadedImageUrl = imageUrl == null || imageUrl.isBlank() + ? null + : imageUploader.uploadFromUrl(imageUrl); + + String responseImageUrl = uploadedImageUrl != null ? uploadedImageUrl : imageUrl; + return new MetaScrapeRes( + ogTag.title(), + ogTag.description(), + responseImageUrl, + ogTag.url() + ); } } diff --git a/src/main/java/com/sofa/linkiving/infra/s3/S3ImageUploader.java b/src/main/java/com/sofa/linkiving/infra/s3/S3ImageUploader.java index 8790add..0058cf8 100644 --- a/src/main/java/com/sofa/linkiving/infra/s3/S3ImageUploader.java +++ b/src/main/java/com/sofa/linkiving/infra/s3/S3ImageUploader.java @@ -9,6 +9,7 @@ import org.springframework.stereotype.Component; import com.sofa.linkiving.domain.link.abstraction.ImageUploader; +import com.sofa.linkiving.domain.link.util.UrlValidator; import io.awspring.cloud.s3.S3Template; import lombok.RequiredArgsConstructor; @@ -21,6 +22,7 @@ public class S3ImageUploader implements ImageUploader { private final S3Template s3Template; private final UrlConnectionFactory urlConnectionFactory; + private final UrlValidator urlValidator; @Value("${spring.cloud.aws.s3.bucket}") private String bucketName; @@ -35,14 +37,15 @@ public String uploadFromUrl(String originalUrl) { } try { - String s3Key = generateUniqueKeyFromUrl(originalUrl); + String cachedUrl = resolveStoredUrl(originalUrl); + if (cachedUrl != null) { + return cachedUrl; + } - String s3Url = buildS3Url(s3Key); + urlValidator.validateSafeUrl(originalUrl); - if (s3Template.objectExists(bucketName, s3Key)) { - log.info("Image already exists (Cache Hit): {} -> {}", originalUrl, s3Key); - return s3Url; - } + String s3Key = generateUniqueKeyFromUrl(originalUrl); + String s3Url = buildS3Url(s3Key); URLConnection connection = urlConnectionFactory.createConnection(originalUrl); connection.setConnectTimeout(3000); @@ -61,7 +64,35 @@ public String uploadFromUrl(String originalUrl) { } } catch (Exception e) { - log.warn("Upload failed, falling back to original URL: {}", e.getMessage()); + log.warn("Image upload failed: {}", e.getMessage()); + return null; + } + } + + @Override + public String resolveStoredUrl(String originalUrl) { + if (originalUrl == null || originalUrl.isBlank()) { + return null; + } + + try { + String storedKey = extractStoredKey(originalUrl); + if (storedKey != null) { + if (s3Template.objectExists(bucketName, storedKey)) { + return originalUrl; + } + return null; + } + + String s3Key = generateUniqueKeyFromUrl(originalUrl); + if (s3Template.objectExists(bucketName, s3Key)) { + log.info("Image already exists (Cache Hit): {} -> {}", originalUrl, s3Key); + return buildS3Url(s3Key); + } + + return null; + } catch (Exception e) { + log.warn("Image cache lookup failed: {}", e.getMessage()); return null; } } @@ -70,6 +101,15 @@ private String buildS3Url(String key) { return String.format("https://%s.s3.%s.amazonaws.com/%s", bucketName, region, key); } + private String extractStoredKey(String url) { + String prefix = String.format("https://%s.s3.%s.amazonaws.com/", bucketName, region); + if (!url.startsWith(prefix)) { + return null; + } + String key = url.substring(prefix.length()); + return key.isBlank() ? null : key; + } + private String generateUniqueKeyFromUrl(String url) { try { String extension = "jpg"; 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 9280a7b..d962a5c 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 @@ -72,14 +72,17 @@ void setUp() { void shouldReturnMetaScrapeResWhenCrawlSucceeds() { // given String url = "https://velog.io/@jjeongdong/%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4"; + String originalImageUrl = "https://velog.io/images/thumbnail.png"; + String storedImageUrl = "https://s3-bucket.com/links/uuid.png"; OgTagDto mockOgTag = OgTagDto.builder() .title("동시성 제어") .description("동시성 제어에 대한 설명") - .image("https://velog.io/images/thumbnail.png") + .image(originalImageUrl) .url(url) .build(); given(ogTagCrawler.crawl(url)).willReturn(mockOgTag); + given(imageUploader.uploadFromUrl(originalImageUrl)).willReturn(storedImageUrl); // when MetaScrapeRes result = linkFacade.scrapeMetadata(url); @@ -88,9 +91,10 @@ void shouldReturnMetaScrapeResWhenCrawlSucceeds() { assertThat(result).isNotNull(); assertThat(result.title()).isEqualTo("동시성 제어"); assertThat(result.description()).isEqualTo("동시성 제어에 대한 설명"); - assertThat(result.image()).isEqualTo("https://velog.io/images/thumbnail.png"); + assertThat(result.image()).isEqualTo(storedImageUrl); assertThat(result.url()).isEqualTo(url); verify(ogTagCrawler, times(1)).crawl(url); + verify(imageUploader, times(1)).uploadFromUrl(originalImageUrl); } @Test @@ -151,6 +155,8 @@ void shouldReturnEmptyMetaScrapeResWhenCrawlFails() { assertThat(result.image()).isEmpty(); assertThat(result.url()).isEmpty(); verify(ogTagCrawler, times(1)).crawl(url); + verify(imageUploader, never()).uploadFromUrl(any()); + verify(imageUploader, never()).resolveStoredUrl(any()); } @Test @@ -164,6 +170,7 @@ void shouldCreateLink() { String originalImageUrl = "https://original.com/image.jpg"; String storedImageUrl = "https://s3-bucket.com/stored-image.jpg"; + given(imageUploader.resolveStoredUrl(originalImageUrl)).willReturn(null); given(imageUploader.uploadFromUrl(originalImageUrl)).willReturn(storedImageUrl); Link savedLink = Link.builder() @@ -185,6 +192,7 @@ void shouldCreateLink() { assertThat(result.imageUrl()).isEqualTo(storedImageUrl); // Verify + verify(imageUploader, times(1)).resolveStoredUrl(originalImageUrl); verify(imageUploader, times(1)).uploadFromUrl(originalImageUrl); verify(linkQueryService, times(1)).existsByUrl(member, url); verify(linkCommandService, times(1)).saveLink(member, url, title, memo, storedImageUrl); 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 159d347..6cf2490 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 @@ -96,6 +96,7 @@ void shouldCreateLinkSuccessfully() throws Exception { String originalImageUrl = "https://original.com/image.jpg"; String uploadedS3Url = "https://s3.amazonaws.com/bucket/links/uuid.jpg"; + given(imageUploader.resolveStoredUrl(originalImageUrl)).willReturn(null); given(imageUploader.uploadFromUrl(originalImageUrl)).willReturn(uploadedS3Url); LinkCreateReq req = new LinkCreateReq( diff --git a/src/test/java/com/sofa/linkiving/infra/s3/S3ImageUploaderTest.java b/src/test/java/com/sofa/linkiving/infra/s3/S3ImageUploaderTest.java index dc88e83..12adabd 100644 --- a/src/test/java/com/sofa/linkiving/infra/s3/S3ImageUploaderTest.java +++ b/src/test/java/com/sofa/linkiving/infra/s3/S3ImageUploaderTest.java @@ -18,6 +18,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import com.sofa.linkiving.domain.link.util.UrlValidator; + import io.awspring.cloud.s3.S3Template; @ExtendWith(MockitoExtension.class) @@ -34,6 +36,8 @@ public class S3ImageUploaderTest { private UrlConnectionFactory urlConnectionFactory; @Mock private URLConnection mockConnection; + @Mock + private UrlValidator urlValidator; @BeforeEach void setUp() { @@ -50,6 +54,8 @@ void shouldUploadImageWhenUrlIsValid() throws IOException { // 1. 중복 검사 통과 (S3에 파일 없음) given(s3Template.objectExists(eq(BUCKET_NAME), anyString())).willReturn(false); + willDoNothing().given(urlValidator).validateSafeUrl(originalUrl); + // 2. Factory가 Mock Connection 반환 given(urlConnectionFactory.createConnection(originalUrl)).willReturn(mockConnection); @@ -97,6 +103,7 @@ void shouldReturnDefaultImageUrlWhenNotImage() throws IOException { String originalUrl = "https://example.com/document.pdf"; given(s3Template.objectExists(eq(BUCKET_NAME), anyString())).willReturn(false); + willDoNothing().given(urlValidator).validateSafeUrl(originalUrl); given(urlConnectionFactory.createConnection(originalUrl)).willReturn(mockConnection); // ContentType을 이미지가 아닌 것으로 설정 @@ -119,6 +126,7 @@ void shouldReturnDefaultImageUrlWhenExceptionOccurs() throws IOException { String originalUrl = "https://example.com/image.jpg"; given(s3Template.objectExists(eq(BUCKET_NAME), anyString())).willReturn(false); + willDoNothing().given(urlValidator).validateSafeUrl(originalUrl); // 연결 생성 시 예외 발생하도록 설정 given(urlConnectionFactory.createConnection(originalUrl)).willThrow(new IOException("Connection Refused")); @@ -141,4 +149,19 @@ void shouldReturnNullWhenUrlIsEmpty() { assertThat(resultNull).isNull(); assertThat(resultEmpty).isNull(); } + + @Test + @DisplayName("저장소 URL이면 캐시 확인 후 바로 반환한다") + void shouldResolveStoredUrlWhenS3UrlProvided() { + // given + String s3Url = "https://" + BUCKET_NAME + ".s3." + REGION + ".amazonaws.com/links/test.jpg"; + + given(s3Template.objectExists(BUCKET_NAME, "links/test.jpg")).willReturn(true); + + // when + String result = s3ImageUploader.resolveStoredUrl(s3Url); + + // then + assertThat(result).isEqualTo(s3Url); + } } From cad198d7175d43ddec09e1ddd7426a65be2581ad Mon Sep 17 00:00:00 2001 From: minibr Date: Wed, 21 Jan 2026 18:54:55 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=95=88=EC=A0=84=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=80=20URL=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../linkiving/infra/s3/S3ImageUploader.java | 14 ++++++++++++ .../infra/s3/S3ImageUploaderTest.java | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/main/java/com/sofa/linkiving/infra/s3/S3ImageUploader.java b/src/main/java/com/sofa/linkiving/infra/s3/S3ImageUploader.java index 0058cf8..667a6c0 100644 --- a/src/main/java/com/sofa/linkiving/infra/s3/S3ImageUploader.java +++ b/src/main/java/com/sofa/linkiving/infra/s3/S3ImageUploader.java @@ -10,6 +10,7 @@ import com.sofa.linkiving.domain.link.abstraction.ImageUploader; import com.sofa.linkiving.domain.link.util.UrlValidator; +import com.sofa.linkiving.global.error.exception.BusinessException; import io.awspring.cloud.s3.S3Template; import lombok.RequiredArgsConstructor; @@ -30,6 +31,9 @@ public class S3ImageUploader implements ImageUploader { @Value("${spring.cloud.aws.region.static}") private String region; + @Value("${app.link.default-image-url:}") + private String defaultImageUrl; + @Override public String uploadFromUrl(String originalUrl) { if (originalUrl == null || originalUrl.isBlank()) { @@ -63,6 +67,9 @@ public String uploadFromUrl(String originalUrl) { return s3Url; } + } catch (BusinessException e) { + log.warn("Unsafe image URL blocked: {}", e.getMessage()); + return normalizedDefaultImageUrl(); } catch (Exception e) { log.warn("Image upload failed: {}", e.getMessage()); return null; @@ -110,6 +117,13 @@ private String extractStoredKey(String url) { return key.isBlank() ? null : key; } + private String normalizedDefaultImageUrl() { + if (defaultImageUrl == null || defaultImageUrl.isBlank()) { + return null; + } + return defaultImageUrl; + } + private String generateUniqueKeyFromUrl(String url) { try { String extension = "jpg"; diff --git a/src/test/java/com/sofa/linkiving/infra/s3/S3ImageUploaderTest.java b/src/test/java/com/sofa/linkiving/infra/s3/S3ImageUploaderTest.java index 12adabd..d5bc6f2 100644 --- a/src/test/java/com/sofa/linkiving/infra/s3/S3ImageUploaderTest.java +++ b/src/test/java/com/sofa/linkiving/infra/s3/S3ImageUploaderTest.java @@ -19,6 +19,8 @@ import org.springframework.test.util.ReflectionTestUtils; import com.sofa.linkiving.domain.link.util.UrlValidator; +import com.sofa.linkiving.domain.link.error.LinkErrorCode; +import com.sofa.linkiving.global.error.exception.BusinessException; import io.awspring.cloud.s3.S3Template; @@ -28,6 +30,7 @@ public class S3ImageUploaderTest { private static final String BUCKET_NAME = "test-bucket"; private static final String REGION = "ap-northeast-2"; + private static final String DEFAULT_IMAGE_URL = "https://example.com/default-image.jpg"; @InjectMocks private S3ImageUploader s3ImageUploader; @Mock @@ -43,6 +46,7 @@ public class S3ImageUploaderTest { void setUp() { ReflectionTestUtils.setField(s3ImageUploader, "bucketName", BUCKET_NAME); ReflectionTestUtils.setField(s3ImageUploader, "region", REGION); + ReflectionTestUtils.setField(s3ImageUploader, "defaultImageUrl", DEFAULT_IMAGE_URL); } @Test @@ -150,6 +154,24 @@ void shouldReturnNullWhenUrlIsEmpty() { assertThat(resultEmpty).isNull(); } + @Test + @DisplayName("검증 실패 시 기본 이미지 URL을 반환한다") + void shouldReturnDefaultImageUrlWhenValidationFails() { + // given + String originalUrl = "http://127.0.0.1/image.jpg"; + + given(urlValidator.validateSafeUrl(originalUrl)) + .willThrow(new BusinessException(LinkErrorCode.INVALID_URL_PRIVATE_IP)); + + // when + String result = s3ImageUploader.uploadFromUrl(originalUrl); + + // then + assertThat(result).isEqualTo(DEFAULT_IMAGE_URL); + verify(urlConnectionFactory, never()).createConnection(anyString()); + verify(s3Template, never()).upload(anyString(), anyString(), any(InputStream.class), any()); + } + @Test @DisplayName("저장소 URL이면 캐시 확인 후 바로 반환한다") void shouldResolveStoredUrlWhenS3UrlProvided() { From f0a7e2fc4efc92c2690474a617fe86d825658a76 Mon Sep 17 00:00:00 2001 From: minibr Date: Wed, 21 Jan 2026 19:37:47 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=EA=B2=80=EC=A6=9D=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EB=AA=A9=ED=82=B9=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/sofa/linkiving/infra/s3/S3ImageUploaderTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/sofa/linkiving/infra/s3/S3ImageUploaderTest.java b/src/test/java/com/sofa/linkiving/infra/s3/S3ImageUploaderTest.java index d5bc6f2..f03e8c4 100644 --- a/src/test/java/com/sofa/linkiving/infra/s3/S3ImageUploaderTest.java +++ b/src/test/java/com/sofa/linkiving/infra/s3/S3ImageUploaderTest.java @@ -160,8 +160,8 @@ void shouldReturnDefaultImageUrlWhenValidationFails() { // given String originalUrl = "http://127.0.0.1/image.jpg"; - given(urlValidator.validateSafeUrl(originalUrl)) - .willThrow(new BusinessException(LinkErrorCode.INVALID_URL_PRIVATE_IP)); + willThrow(new BusinessException(LinkErrorCode.INVALID_URL_PRIVATE_IP)) + .given(urlValidator).validateSafeUrl(originalUrl); // when String result = s3ImageUploader.uploadFromUrl(originalUrl);