From 198afff5d8ea82d6f9a3dffa7aebc7f234be6adf Mon Sep 17 00:00:00 2001 From: minibr Date: Thu, 18 Dec 2025 01:24:55 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=EB=A7=81=ED=81=AC=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD=20=EC=9B=8C=EC=BB=A4=20=EC=9D=B8=ED=94=84=EB=9D=BC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../link/config/SummaryWorkerProperties.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/main/java/com/sofa/linkiving/domain/link/config/SummaryWorkerProperties.java diff --git a/src/main/java/com/sofa/linkiving/domain/link/config/SummaryWorkerProperties.java b/src/main/java/com/sofa/linkiving/domain/link/config/SummaryWorkerProperties.java new file mode 100644 index 00000000..8b12135a --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/config/SummaryWorkerProperties.java @@ -0,0 +1,14 @@ +package com.sofa.linkiving.domain.link.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "summary.worker") +public record SummaryWorkerProperties( + long sleepMs +) { + public SummaryWorkerProperties { + if (sleepMs <= 0) { + throw new IllegalArgumentException("sleepMs must be positive"); + } + } +} \ No newline at end of file From 3636d6737c6dd44628053ab7708d0508dfec4429 Mon Sep 17 00:00:00 2001 From: minibr Date: Thu, 18 Dec 2025 01:25:07 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=EB=A7=81=ED=81=AC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20=EC=9A=94=EC=95=BD=20=ED=81=90=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sofa/linkiving/domain/link/service/LinkService.java | 5 +++++ .../sofa/linkiving/domain/link/service/LinkServiceTest.java | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/src/main/java/com/sofa/linkiving/domain/link/service/LinkService.java b/src/main/java/com/sofa/linkiving/domain/link/service/LinkService.java index c11bc521..34642d28 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/service/LinkService.java +++ b/src/main/java/com/sofa/linkiving/domain/link/service/LinkService.java @@ -8,6 +8,7 @@ import com.sofa.linkiving.domain.link.dto.response.LinkRes; import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.link.error.LinkErrorCode; +import com.sofa.linkiving.domain.link.worker.SummaryQueue; import com.sofa.linkiving.domain.member.entity.Member; import com.sofa.linkiving.global.error.exception.BusinessException; @@ -21,6 +22,7 @@ public class LinkService { private final LinkCommandService linkCommandService; private final LinkQueryService linkQueryService; + private final SummaryQueue summaryQueue; public LinkRes createLink(Member member, String url, String title, String memo, String imageUrl) { if (linkQueryService.existsByUrl(member, url)) { @@ -30,6 +32,9 @@ public LinkRes createLink(Member member, String url, String title, String memo, Link link = linkCommandService.saveLink(member, url, title, memo, imageUrl); log.info("Link created - id: {}, memberId: {}, url: {}", link.getId(), member.getId(), url); + // 요약 대기 큐에 추가 + summaryQueue.addToQueue(link.getId()); + return LinkRes.from(link); } diff --git a/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java b/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java index 0626a91e..8b0b40bd 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java @@ -21,6 +21,7 @@ import com.sofa.linkiving.domain.link.dto.response.LinkRes; import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.link.error.LinkErrorCode; +import com.sofa.linkiving.domain.link.worker.SummaryQueue; import com.sofa.linkiving.domain.member.entity.Member; import com.sofa.linkiving.global.error.exception.BusinessException; @@ -37,6 +38,9 @@ class LinkServiceTest { @Mock private LinkQueryService linkQueryService; + @Mock + private SummaryQueue summaryQueue; + @Test @DisplayName("링크를 생성할 수 있다") void shouldCreateLink() { From 7fed9ad1371f0b3ef0b7c8c39af2ae56c995a34e Mon Sep 17 00:00:00 2001 From: minibr Date: Thu, 18 Dec 2025 01:25:19 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=EB=A7=81=ED=81=AC=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD=20=EB=8C=80=EA=B8=B0=20=ED=81=90=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B0=B1=EA=B7=B8=EB=9D=BC=EC=9A=B4=EB=93=9C=20=EC=9B=8C?= =?UTF-8?q?=EC=BB=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../link/config/SummaryWorkerProperties.java | 2 +- .../domain/link/worker/SummaryQueue.java | 31 ++++++++ .../domain/link/worker/SummaryWorker.java | 73 +++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/sofa/linkiving/domain/link/worker/SummaryQueue.java create mode 100644 src/main/java/com/sofa/linkiving/domain/link/worker/SummaryWorker.java diff --git a/src/main/java/com/sofa/linkiving/domain/link/config/SummaryWorkerProperties.java b/src/main/java/com/sofa/linkiving/domain/link/config/SummaryWorkerProperties.java index 8b12135a..ce34cd47 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/config/SummaryWorkerProperties.java +++ b/src/main/java/com/sofa/linkiving/domain/link/config/SummaryWorkerProperties.java @@ -11,4 +11,4 @@ public record SummaryWorkerProperties( throw new IllegalArgumentException("sleepMs must be positive"); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryQueue.java b/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryQueue.java new file mode 100644 index 00000000..510bf396 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryQueue.java @@ -0,0 +1,31 @@ +package com.sofa.linkiving.domain.link.worker; + +import java.util.Optional; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class SummaryQueue { + + private final Queue summaryQueue = new ConcurrentLinkedQueue<>(); + + /** + * 요약 대기 큐에 링크 ID 추가 + */ + public void addToQueue(Long linkId) { + summaryQueue.offer(linkId); + log.info("Link added to summary queue - linkId: {}", linkId); + } + + /** + * 요약 대기 큐에서 링크 ID 꺼내기 + */ + public Optional pollFromQueue() { + return Optional.ofNullable(summaryQueue.poll()); + } +} diff --git a/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryWorker.java b/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryWorker.java new file mode 100644 index 00000000..fa264314 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryWorker.java @@ -0,0 +1,73 @@ +package com.sofa.linkiving.domain.link.worker; + +import java.util.Optional; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Component; + +import com.sofa.linkiving.domain.link.config.SummaryWorkerProperties; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@EnableConfigurationProperties(SummaryWorkerProperties.class) +@RequiredArgsConstructor +public class SummaryWorker { + + private final SummaryQueue summaryQueue; + private final SummaryWorkerProperties properties; + private volatile boolean running = true; + private Thread workerThread; + + @PostConstruct + public void startWorker() { + workerThread = new Thread(() -> { + log.info("Summary worker thread started"); + while (running) { + try { + processQueue(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.info("Summary worker thread interrupted"); + break; + } catch (Exception e) { + log.error("Error in summary worker thread", e); + } + } + log.info("Summary worker thread stopped"); + }); + workerThread.setName("summary-worker"); + workerThread.setDaemon(true); + workerThread.start(); + } + + @PreDestroy + public void stopWorker() { + log.info("Stopping summary worker thread"); + running = false; + if (workerThread != null) { + workerThread.interrupt(); + } + } + + private void processQueue() throws InterruptedException { + Optional linkIdOpt = summaryQueue.pollFromQueue(); + + if (linkIdOpt.isEmpty()) { + // 큐가 비어있으면 대기 + Thread.sleep(properties.sleepMs()); + return; + } + + Long linkId = linkIdOpt.get(); + log.info("Processing link for summary - linkId: {}", linkId); + + // TODO: 링크 정보 조회 후 RAG 서버에 요약 요청 + // 일단은 로그만 출력 + log.debug("TODO: Send to RAG server - linkId: {}", linkId); + } +} From ce09761ac5fada170590a67e42693ba056fbc305 Mon Sep 17 00:00:00 2001 From: minibr Date: Thu, 18 Dec 2025 02:29:57 +0900 Subject: [PATCH 4/9] =?UTF-8?q?test:=20=EC=9A=94=EC=95=BD=20=EC=9B=8C?= =?UTF-8?q?=EC=BB=A4=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/SummaryWorkerPropertiesTest.java | 48 ++++++ .../domain/link/worker/SummaryQueueTest.java | 91 +++++++++++ .../domain/link/worker/SummaryWorkerTest.java | 146 ++++++++++++++++++ 3 files changed, 285 insertions(+) create mode 100644 src/test/java/com/sofa/linkiving/domain/link/config/SummaryWorkerPropertiesTest.java create mode 100644 src/test/java/com/sofa/linkiving/domain/link/worker/SummaryQueueTest.java create mode 100644 src/test/java/com/sofa/linkiving/domain/link/worker/SummaryWorkerTest.java diff --git a/src/test/java/com/sofa/linkiving/domain/link/config/SummaryWorkerPropertiesTest.java b/src/test/java/com/sofa/linkiving/domain/link/config/SummaryWorkerPropertiesTest.java new file mode 100644 index 00000000..68618e25 --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/link/config/SummaryWorkerPropertiesTest.java @@ -0,0 +1,48 @@ +package com.sofa.linkiving.domain.link.config; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("SummaryWorkerProperties 단위 테스트") +class SummaryWorkerPropertiesTest { + + @Test + @DisplayName("양수 값으로 Properties를 생성할 수 있다") + void shouldCreatePropertiesWithPositiveValue() { + // when + SummaryWorkerProperties properties = new SummaryWorkerProperties(1000); + + // then + assertThat(properties.sleepMs()).isEqualTo(1000); + } + + @Test + @DisplayName("0 이하의 값으로 Properties 생성 시 예외가 발생한다 - 0") + void shouldThrowExceptionWhenSleepMsIsZero() { + // when & then + assertThatThrownBy(() -> new SummaryWorkerProperties(0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("sleepMs must be positive"); + } + + @Test + @DisplayName("0 이하의 값으로 Properties 생성 시 예외가 발생한다 - 음수") + void shouldThrowExceptionWhenSleepMsIsNegative() { + // when & then + assertThatThrownBy(() -> new SummaryWorkerProperties(-100)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("sleepMs must be positive"); + } + + @Test + @DisplayName("최소 양수 값으로 Properties를 생성할 수 있다") + void shouldCreatePropertiesWithMinimumPositiveValue() { + // when + SummaryWorkerProperties properties = new SummaryWorkerProperties(1); + + // then + assertThat(properties.sleepMs()).isEqualTo(1); + } +} diff --git a/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryQueueTest.java b/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryQueueTest.java new file mode 100644 index 00000000..7c993763 --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryQueueTest.java @@ -0,0 +1,91 @@ +package com.sofa.linkiving.domain.link.worker; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("SummaryQueue 단위 테스트") +class SummaryQueueTest { + + private SummaryQueue summaryQueue; + + @BeforeEach + void setUp() { + summaryQueue = new SummaryQueue(); + } + + @Test + @DisplayName("큐에 링크 ID를 추가할 수 있다") + void shouldAddLinkIdToQueue() { + // given + Long linkId = 123L; + + // when + summaryQueue.addToQueue(linkId); + + // then + Optional result = summaryQueue.pollFromQueue(); + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(123L); + } + + @Test + @DisplayName("큐에서 링크 ID를 꺼낼 수 있다") + void shouldPollLinkIdFromQueue() { + // given + summaryQueue.addToQueue(456L); + + // when + Optional result = summaryQueue.pollFromQueue(); + + // then + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(456L); + } + + @Test + @DisplayName("빈 큐에서 poll 시 empty를 반환한다") + void shouldReturnEmptyWhenQueueIsEmpty() { + // when + Optional result = summaryQueue.pollFromQueue(); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("여러 링크 ID를 FIFO 순서로 처리한다") + void shouldProcessLinksInFifoOrder() { + // given + summaryQueue.addToQueue(1L); + summaryQueue.addToQueue(2L); + summaryQueue.addToQueue(3L); + + // when & then + assertThat(summaryQueue.pollFromQueue().get()).isEqualTo(1L); + assertThat(summaryQueue.pollFromQueue().get()).isEqualTo(2L); + assertThat(summaryQueue.pollFromQueue().get()).isEqualTo(3L); + assertThat(summaryQueue.pollFromQueue()).isEmpty(); + } + + @Test + @DisplayName("동일한 링크 ID를 여러 번 추가할 수 있다") + void shouldAddSameLinkIdMultipleTimes() { + // given + Long linkId = 999L; + + // when + summaryQueue.addToQueue(linkId); + summaryQueue.addToQueue(linkId); + + // then + assertThat(summaryQueue.pollFromQueue().get()).isEqualTo(999L); + assertThat(summaryQueue.pollFromQueue().get()).isEqualTo(999L); + assertThat(summaryQueue.pollFromQueue()).isEmpty(); + } +} + diff --git a/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryWorkerTest.java b/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryWorkerTest.java new file mode 100644 index 00000000..6c722e65 --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryWorkerTest.java @@ -0,0 +1,146 @@ +package com.sofa.linkiving.domain.link.worker; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.AfterEach; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.sofa.linkiving.domain.link.config.SummaryWorkerProperties; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SummaryWorker 단위 테스트") +class SummaryWorkerTest { + + @Mock + private SummaryQueue summaryQueue; + + private SummaryWorker summaryWorker; + private SummaryWorkerProperties properties; + + @BeforeEach + void setUp() { + properties = new SummaryWorkerProperties(100); // 테스트용 짧은 sleep 시간 + summaryWorker = new SummaryWorker(summaryQueue, properties); + } + + @AfterEach + void tearDown() { + if (summaryWorker != null) { + summaryWorker.stopWorker(); + } + } + + @Test + @DisplayName("워커 시작 시 백그라운드 쓰레드가 생성된다") + void shouldStartWorkerThread() throws InterruptedException { + // given + given(summaryQueue.pollFromQueue()).willReturn(Optional.empty()); + + // when + summaryWorker.startWorker(); + Thread.sleep(50); // 워커 쓰레드가 시작될 시간 대기 + + // then + verify(summaryQueue, atLeastOnce()).pollFromQueue(); + } + + @Test + @DisplayName("큐에 데이터가 있으면 처리한다") + void shouldProcessLinkFromQueue() throws InterruptedException { + // given + given(summaryQueue.pollFromQueue()) + .willReturn(Optional.of(123L)) + .willReturn(Optional.empty()); + + // when + summaryWorker.startWorker(); + Thread.sleep(150); // 처리 시간 대기 + + // then + verify(summaryQueue, atLeast(2)).pollFromQueue(); + } + + @Test + @DisplayName("큐가 비어있으면 설정된 시간만큼 대기한다") + void shouldSleepWhenQueueIsEmpty() throws InterruptedException { + // given + given(summaryQueue.pollFromQueue()).willReturn(Optional.empty()); + + // when + summaryWorker.startWorker(); + long startTime = System.currentTimeMillis(); + Thread.sleep(250); // sleep-ms(100) * 2회 이상 호출될 시간 대기 + long endTime = System.currentTimeMillis(); + + // then + long elapsed = endTime - startTime; + assertThat(elapsed).isGreaterThanOrEqualTo(200); // 최소 2번의 sleep(100ms) + verify(summaryQueue, atLeast(2)).pollFromQueue(); + } + + @Test + @DisplayName("워커 종료 시 쓰레드가 정상적으로 중단된다") + void shouldStopWorkerThread() throws InterruptedException { + // given + given(summaryQueue.pollFromQueue()).willReturn(Optional.empty()); + summaryWorker.startWorker(); + Thread.sleep(50); // 워커 시작 대기 + + // when + summaryWorker.stopWorker(); + Thread.sleep(50); // 종료 대기 + + // then + int invocationsBefore = mockingDetails(summaryQueue).getInvocations().size(); + Thread.sleep(150); // 추가 대기 + int invocationsAfter = mockingDetails(summaryQueue).getInvocations().size(); + + // 워커가 중단되었으므로 추가 호출이 없어야 함 + assertThat(invocationsAfter).isEqualTo(invocationsBefore); + } + + @Test + @DisplayName("여러 링크를 순차적으로 처리한다") + void shouldProcessMultipleLinks() throws InterruptedException { + // given + given(summaryQueue.pollFromQueue()) + .willReturn(Optional.of(1L)) + .willReturn(Optional.of(2L)) + .willReturn(Optional.of(3L)) + .willReturn(Optional.empty()); + + // when + summaryWorker.startWorker(); + Thread.sleep(200); // 여러 링크 처리 시간 대기 + + // then + verify(summaryQueue, atLeast(4)).pollFromQueue(); + } + + @Test + @DisplayName("에러 발생 시에도 워커는 계속 동작한다") + void shouldContinueWorkingAfterError() throws InterruptedException { + // given + given(summaryQueue.pollFromQueue()) + .willThrow(new RuntimeException("Test exception")) + .willReturn(Optional.of(123L)) + .willReturn(Optional.empty()); + + // when + summaryWorker.startWorker(); + Thread.sleep(200); // 에러 발생 및 복구 시간 대기 + + // then + // 에러가 발생해도 워커가 계속 동작하여 다음 pollFromQueue 호출 + verify(summaryQueue, atLeast(3)).pollFromQueue(); + } +} + From b1d021dd0b54ffd36874bde9d61e52d0065f132c Mon Sep 17 00:00:00 2001 From: minibr Date: Thu, 18 Dec 2025 21:52:35 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EC=BB=A4=EB=B0=8B=20=ED=9B=84=20=EC=9A=94=EC=95=BD?= =?UTF-8?q?=20=ED=81=90=20=EC=B6=94=EA=B0=80=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/link/event/LinkCreatedEvent.java | 10 ++++ .../domain/link/event/LinkEventListener.java | 32 +++++++++++ .../domain/link/service/LinkService.java | 9 +-- .../link/event/LinkEventListenerTest.java | 57 +++++++++++++++++++ .../domain/link/service/LinkServiceTest.java | 4 +- 5 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/sofa/linkiving/domain/link/event/LinkCreatedEvent.java create mode 100644 src/main/java/com/sofa/linkiving/domain/link/event/LinkEventListener.java create mode 100644 src/test/java/com/sofa/linkiving/domain/link/event/LinkEventListenerTest.java diff --git a/src/main/java/com/sofa/linkiving/domain/link/event/LinkCreatedEvent.java b/src/main/java/com/sofa/linkiving/domain/link/event/LinkCreatedEvent.java new file mode 100644 index 00000000..11668f3c --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/event/LinkCreatedEvent.java @@ -0,0 +1,10 @@ +package com.sofa.linkiving.domain.link.event; + +/** + * 링크 생성 완료 이벤트 + * 트랜잭션 커밋 이후 발행되는 이벤트 + */ +public record LinkCreatedEvent( + Long linkId +) { +} diff --git a/src/main/java/com/sofa/linkiving/domain/link/event/LinkEventListener.java b/src/main/java/com/sofa/linkiving/domain/link/event/LinkEventListener.java new file mode 100644 index 00000000..f1a210e9 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/event/LinkEventListener.java @@ -0,0 +1,32 @@ +package com.sofa.linkiving.domain.link.event; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.sofa.linkiving.domain.link.worker.SummaryQueue; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 링크 도메인 이벤트 리스너 + * 트랜잭션 커밋 후 이벤트를 처리하여 데이터 일관성 보장 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class LinkEventListener { + + private final SummaryQueue summaryQueue; + + /** + * 링크 생성 완료 이벤트 처리 + * 트랜잭션 커밋 후에만 실행되어 롤백 시 큐에 추가되지 않음 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLinkCreated(LinkCreatedEvent event) { + log.debug("Link created event received - linkId: {}", event.linkId()); + summaryQueue.addToQueue(event.linkId()); + } +} diff --git a/src/main/java/com/sofa/linkiving/domain/link/service/LinkService.java b/src/main/java/com/sofa/linkiving/domain/link/service/LinkService.java index 34642d28..198e8f36 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/service/LinkService.java +++ b/src/main/java/com/sofa/linkiving/domain/link/service/LinkService.java @@ -1,5 +1,6 @@ package com.sofa.linkiving.domain.link.service; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -8,7 +9,7 @@ import com.sofa.linkiving.domain.link.dto.response.LinkRes; import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.link.error.LinkErrorCode; -import com.sofa.linkiving.domain.link.worker.SummaryQueue; +import com.sofa.linkiving.domain.link.event.LinkCreatedEvent; import com.sofa.linkiving.domain.member.entity.Member; import com.sofa.linkiving.global.error.exception.BusinessException; @@ -22,7 +23,7 @@ public class LinkService { private final LinkCommandService linkCommandService; private final LinkQueryService linkQueryService; - private final SummaryQueue summaryQueue; + private final ApplicationEventPublisher eventPublisher; public LinkRes createLink(Member member, String url, String title, String memo, String imageUrl) { if (linkQueryService.existsByUrl(member, url)) { @@ -32,8 +33,8 @@ public LinkRes createLink(Member member, String url, String title, String memo, Link link = linkCommandService.saveLink(member, url, title, memo, imageUrl); log.info("Link created - id: {}, memberId: {}, url: {}", link.getId(), member.getId(), url); - // 요약 대기 큐에 추가 - summaryQueue.addToQueue(link.getId()); + // 트랜잭션 커밋 후 요약 대기 큐에 추가되도록 이벤트 발행 + eventPublisher.publishEvent(new LinkCreatedEvent(link.getId())); return LinkRes.from(link); } diff --git a/src/test/java/com/sofa/linkiving/domain/link/event/LinkEventListenerTest.java b/src/test/java/com/sofa/linkiving/domain/link/event/LinkEventListenerTest.java new file mode 100644 index 00000000..d5a4391e --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/link/event/LinkEventListenerTest.java @@ -0,0 +1,57 @@ +package com.sofa.linkiving.domain.link.event; + +import static org.mockito.BDDMockito.*; + +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 com.sofa.linkiving.domain.link.worker.SummaryQueue; + +@ExtendWith(MockitoExtension.class) +@DisplayName("LinkEventListener 단위 테스트") +class LinkEventListenerTest { + + @InjectMocks + private LinkEventListener linkEventListener; + + @Mock + private SummaryQueue summaryQueue; + + @Test + @DisplayName("링크 생성 이벤트 수신 시 큐에 추가한다") + void shouldAddToQueueWhenLinkCreatedEventReceived() { + // given + Long linkId = 123L; + LinkCreatedEvent event = new LinkCreatedEvent(linkId); + + // when + linkEventListener.handleLinkCreated(event); + + // then + verify(summaryQueue, times(1)).addToQueue(linkId); + } + + @Test + @DisplayName("여러 링크 생성 이벤트를 순차적으로 처리한다") + void shouldHandleMultipleLinkCreatedEvents() { + // given + LinkCreatedEvent event1 = new LinkCreatedEvent(1L); + LinkCreatedEvent event2 = new LinkCreatedEvent(2L); + LinkCreatedEvent event3 = new LinkCreatedEvent(3L); + + // when + linkEventListener.handleLinkCreated(event1); + linkEventListener.handleLinkCreated(event2); + linkEventListener.handleLinkCreated(event3); + + // then + verify(summaryQueue, times(1)).addToQueue(1L); + verify(summaryQueue, times(1)).addToQueue(2L); + verify(summaryQueue, times(1)).addToQueue(3L); + } +} + diff --git a/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java b/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java index 8b0b40bd..77e454df 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java @@ -12,6 +12,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -21,7 +22,6 @@ import com.sofa.linkiving.domain.link.dto.response.LinkRes; import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.link.error.LinkErrorCode; -import com.sofa.linkiving.domain.link.worker.SummaryQueue; import com.sofa.linkiving.domain.member.entity.Member; import com.sofa.linkiving.global.error.exception.BusinessException; @@ -39,7 +39,7 @@ class LinkServiceTest { private LinkQueryService linkQueryService; @Mock - private SummaryQueue summaryQueue; + private ApplicationEventPublisher eventPublisher; @Test @DisplayName("링크를 생성할 수 있다") From 3b74fc908047f42eb2a73680d2585c6a521f6b5f Mon Sep 17 00:00:00 2001 From: minibr Date: Fri, 19 Dec 2025 01:11:05 +0900 Subject: [PATCH 6/9] =?UTF-8?q?chore:=20summary=20worker=20sleep=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9D=84=20Duration=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../link/config/SummaryWorkerProperties.java | 8 +++++--- .../domain/link/worker/SummaryWorker.java | 2 +- .../config/SummaryWorkerPropertiesTest.java | 18 ++++++++++-------- .../domain/link/worker/SummaryWorkerTest.java | 6 +++--- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/sofa/linkiving/domain/link/config/SummaryWorkerProperties.java b/src/main/java/com/sofa/linkiving/domain/link/config/SummaryWorkerProperties.java index ce34cd47..b0ab6a0f 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/config/SummaryWorkerProperties.java +++ b/src/main/java/com/sofa/linkiving/domain/link/config/SummaryWorkerProperties.java @@ -1,14 +1,16 @@ package com.sofa.linkiving.domain.link.config; +import java.time.Duration; + import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "summary.worker") public record SummaryWorkerProperties( - long sleepMs + Duration sleepDuration ) { public SummaryWorkerProperties { - if (sleepMs <= 0) { - throw new IllegalArgumentException("sleepMs must be positive"); + if (sleepDuration == null || sleepDuration.isZero() || sleepDuration.isNegative()) { + throw new IllegalArgumentException("sleepDuration must be positive"); } } } diff --git a/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryWorker.java b/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryWorker.java index fa264314..a017bf7e 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryWorker.java +++ b/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryWorker.java @@ -59,7 +59,7 @@ private void processQueue() throws InterruptedException { if (linkIdOpt.isEmpty()) { // 큐가 비어있으면 대기 - Thread.sleep(properties.sleepMs()); + Thread.sleep(properties.sleepDuration().toMillis()); return; } diff --git a/src/test/java/com/sofa/linkiving/domain/link/config/SummaryWorkerPropertiesTest.java b/src/test/java/com/sofa/linkiving/domain/link/config/SummaryWorkerPropertiesTest.java index 68618e25..34239084 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/config/SummaryWorkerPropertiesTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/config/SummaryWorkerPropertiesTest.java @@ -2,6 +2,8 @@ import static org.assertj.core.api.Assertions.*; +import java.time.Duration; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -12,37 +14,37 @@ class SummaryWorkerPropertiesTest { @DisplayName("양수 값으로 Properties를 생성할 수 있다") void shouldCreatePropertiesWithPositiveValue() { // when - SummaryWorkerProperties properties = new SummaryWorkerProperties(1000); + SummaryWorkerProperties properties = new SummaryWorkerProperties(Duration.ofSeconds(1)); // then - assertThat(properties.sleepMs()).isEqualTo(1000); + assertThat(properties.sleepDuration()).isEqualTo(Duration.ofSeconds(1)); } @Test @DisplayName("0 이하의 값으로 Properties 생성 시 예외가 발생한다 - 0") void shouldThrowExceptionWhenSleepMsIsZero() { // when & then - assertThatThrownBy(() -> new SummaryWorkerProperties(0)) + assertThatThrownBy(() -> new SummaryWorkerProperties(Duration.ZERO)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("sleepMs must be positive"); + .hasMessage("sleepDuration must be positive"); } @Test @DisplayName("0 이하의 값으로 Properties 생성 시 예외가 발생한다 - 음수") void shouldThrowExceptionWhenSleepMsIsNegative() { // when & then - assertThatThrownBy(() -> new SummaryWorkerProperties(-100)) + assertThatThrownBy(() -> new SummaryWorkerProperties(Duration.ofMillis(-100))) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("sleepMs must be positive"); + .hasMessage("sleepDuration must be positive"); } @Test @DisplayName("최소 양수 값으로 Properties를 생성할 수 있다") void shouldCreatePropertiesWithMinimumPositiveValue() { // when - SummaryWorkerProperties properties = new SummaryWorkerProperties(1); + SummaryWorkerProperties properties = new SummaryWorkerProperties(Duration.ofMillis(1)); // then - assertThat(properties.sleepMs()).isEqualTo(1); + assertThat(properties.sleepDuration()).isEqualTo(Duration.ofMillis(1)); } } diff --git a/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryWorkerTest.java b/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryWorkerTest.java index 6c722e65..dbaf296c 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryWorkerTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryWorkerTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; +import java.time.Duration; import java.util.Optional; import org.junit.jupiter.api.AfterEach; @@ -27,7 +28,7 @@ class SummaryWorkerTest { @BeforeEach void setUp() { - properties = new SummaryWorkerProperties(100); // 테스트용 짧은 sleep 시간 + properties = new SummaryWorkerProperties(Duration.ofMillis(100)); // 테스트용 짧은 sleep 시간 summaryWorker = new SummaryWorker(summaryQueue, properties); } @@ -77,7 +78,7 @@ void shouldSleepWhenQueueIsEmpty() throws InterruptedException { // when summaryWorker.startWorker(); long startTime = System.currentTimeMillis(); - Thread.sleep(250); // sleep-ms(100) * 2회 이상 호출될 시간 대기 + Thread.sleep(250); // sleep(100ms) * 2회 이상 호출될 시간 대기 long endTime = System.currentTimeMillis(); // then @@ -143,4 +144,3 @@ void shouldContinueWorkingAfterError() throws InterruptedException { verify(summaryQueue, atLeast(3)).pollFromQueue(); } } - From afd3e3971e5c165be155782108797e8d5ec37a80 Mon Sep 17 00:00:00 2001 From: minibr Date: Fri, 19 Dec 2025 01:44:27 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20=EB=A7=81=ED=81=AC=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EB=A6=AC=EC=8A=A4=EB=84=88=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/link/event/LinkEventListener.java | 36 ++++++++++++- .../link/event/LinkEventListenerTest.java | 54 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/sofa/linkiving/domain/link/event/LinkEventListener.java b/src/main/java/com/sofa/linkiving/domain/link/event/LinkEventListener.java index f1a210e9..0cda5c6c 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/event/LinkEventListener.java +++ b/src/main/java/com/sofa/linkiving/domain/link/event/LinkEventListener.java @@ -23,10 +23,44 @@ public class LinkEventListener { /** * 링크 생성 완료 이벤트 처리 * 트랜잭션 커밋 후에만 실행되어 롤백 시 큐에 추가되지 않음 + * + *

AFTER_COMMIT 단계에서 실행되므로 메인 트랜잭션은 이미 커밋됨. + * 큐 추가 실패 시에도 링크는 정상 저장되며, 재시도 로직으로 안정성 보장

*/ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleLinkCreated(LinkCreatedEvent event) { log.debug("Link created event received - linkId: {}", event.linkId()); - summaryQueue.addToQueue(event.linkId()); + + int maxRetries = 3; + int retryCount = 0; + boolean success = false; + + while (retryCount < maxRetries && !success) { + try { + summaryQueue.addToQueue(event.linkId()); + success = true; + } catch (Exception e) { + retryCount++; + log.warn("Failed to add link to summary queue (attempt {}/{}): linkId={}, error={}", + retryCount, maxRetries, event.linkId(), e.getMessage()); + + if (retryCount >= maxRetries) { + // 최종 실패 시 에러 로그 및 모니터링 알림 + log.error("Failed to add link to summary queue after {} retries - linkId: {}. " + + "Summary generation will be skipped for this link.", + maxRetries, event.linkId(), e); + // TODO: 관리자 알림 또는 실패 큐에 저장하여 수동 처리 가능하도록 개선 필요 + } else { + // 재시도 전 짧은 대기 + try { + Thread.sleep(100L * retryCount); // 100ms, 200ms, 300ms + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + log.error("Retry interrupted for linkId: {}", event.linkId()); + break; + } + } + } + } } } diff --git a/src/test/java/com/sofa/linkiving/domain/link/event/LinkEventListenerTest.java b/src/test/java/com/sofa/linkiving/domain/link/event/LinkEventListenerTest.java index d5a4391e..436dd7fb 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/event/LinkEventListenerTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/event/LinkEventListenerTest.java @@ -53,5 +53,59 @@ void shouldHandleMultipleLinkCreatedEvents() { verify(summaryQueue, times(1)).addToQueue(2L); verify(summaryQueue, times(1)).addToQueue(3L); } + + @Test + @DisplayName("큐 추가 실패 시 최대 3번까지 재시도한다") + void shouldRetryWhenAddToQueueFails() { + // given + Long linkId = 123L; + LinkCreatedEvent event = new LinkCreatedEvent(linkId); + + // 첫 2번 실패, 3번째 성공 + willThrow(new RuntimeException("Queue full")) + .willThrow(new RuntimeException("Queue full")) + .willDoNothing() + .given(summaryQueue).addToQueue(linkId); + + // when + linkEventListener.handleLinkCreated(event); + + // then + verify(summaryQueue, times(3)).addToQueue(linkId); + } + + @Test + @DisplayName("큐 추가가 3번 모두 실패하면 재시도를 중단하고 에러 로그를 남긴다") + void shouldStopRetryingAfterMaxAttempts() { + // given + Long linkId = 123L; + LinkCreatedEvent event = new LinkCreatedEvent(linkId); + + // 3번 모두 실패 + willThrow(new RuntimeException("Queue full")) + .given(summaryQueue).addToQueue(linkId); + + // when + linkEventListener.handleLinkCreated(event); + + // then + verify(summaryQueue, times(3)).addToQueue(linkId); // 최대 3번 시도 + } + + @Test + @DisplayName("첫 번째 시도에서 성공하면 재시도하지 않는다") + void shouldNotRetryWhenFirstAttemptSucceeds() { + // given + Long linkId = 123L; + LinkCreatedEvent event = new LinkCreatedEvent(linkId); + + willDoNothing().given(summaryQueue).addToQueue(linkId); + + // when + linkEventListener.handleLinkCreated(event); + + // then + verify(summaryQueue, times(1)).addToQueue(linkId); // 1번만 시도 + } } From 89f2a3a5173deaadf058ffc9783fd72d8fe307f1 Mon Sep 17 00:00:00 2001 From: minibr Date: Sat, 20 Dec 2025 00:11:58 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=EC=A3=BC=EC=84=9D=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 --- .../sofa/linkiving/domain/link/event/LinkEventListener.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/com/sofa/linkiving/domain/link/event/LinkEventListener.java b/src/main/java/com/sofa/linkiving/domain/link/event/LinkEventListener.java index 0cda5c6c..bf2f5506 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/event/LinkEventListener.java +++ b/src/main/java/com/sofa/linkiving/domain/link/event/LinkEventListener.java @@ -23,9 +23,6 @@ public class LinkEventListener { /** * 링크 생성 완료 이벤트 처리 * 트랜잭션 커밋 후에만 실행되어 롤백 시 큐에 추가되지 않음 - * - *

AFTER_COMMIT 단계에서 실행되므로 메인 트랜잭션은 이미 커밋됨. - * 큐 추가 실패 시에도 링크는 정상 저장되며, 재시도 로직으로 안정성 보장

*/ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleLinkCreated(LinkCreatedEvent event) { From fd3d8d5a0b98efe19e3498d439c306f17492f94b Mon Sep 17 00:00:00 2001 From: minibr Date: Sun, 21 Dec 2025 01:53:21 +0900 Subject: [PATCH 9/9] =?UTF-8?q?=20feat:=20RAG=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=ED=95=98=EC=97=AC=20=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD=20=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/link/event/LinkEventListener.java | 2 +- .../domain/link/worker/SummaryWorker.java | 61 ++++++++++++++++++- .../linkiving/infra/feign/AiServerClient.java | 19 ++++++ .../infra/feign/GlobalFeignConfig.java | 2 +- .../infra/feign/dto/SummaryRequest.java | 13 ++++ .../infra/feign/dto/SummaryResponse.java | 6 ++ .../domain/link/worker/SummaryWorkerTest.java | 15 ++++- 7 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/sofa/linkiving/infra/feign/AiServerClient.java create mode 100644 src/main/java/com/sofa/linkiving/infra/feign/dto/SummaryRequest.java create mode 100644 src/main/java/com/sofa/linkiving/infra/feign/dto/SummaryResponse.java diff --git a/src/main/java/com/sofa/linkiving/domain/link/event/LinkEventListener.java b/src/main/java/com/sofa/linkiving/domain/link/event/LinkEventListener.java index bf2f5506..e138444e 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/event/LinkEventListener.java +++ b/src/main/java/com/sofa/linkiving/domain/link/event/LinkEventListener.java @@ -26,7 +26,7 @@ public class LinkEventListener { */ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleLinkCreated(LinkCreatedEvent event) { - log.debug("Link created event received - linkId: {}", event.linkId()); + log.info("Link created event received (after commit) - linkId: {}", event.linkId()); int maxRetries = 3; int retryCount = 0; diff --git a/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryWorker.java b/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryWorker.java index a017bf7e..18afee09 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryWorker.java +++ b/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryWorker.java @@ -4,8 +4,17 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import com.sofa.linkiving.domain.link.config.SummaryWorkerProperties; +import com.sofa.linkiving.domain.link.entity.Link; +import com.sofa.linkiving.domain.link.entity.Summary; +import com.sofa.linkiving.domain.link.enums.Format; +import com.sofa.linkiving.domain.link.repository.LinkRepository; +import com.sofa.linkiving.domain.link.repository.SummaryRepository; +import com.sofa.linkiving.infra.feign.AiServerClient; +import com.sofa.linkiving.infra.feign.dto.SummaryRequest; +import com.sofa.linkiving.infra.feign.dto.SummaryResponse; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; @@ -20,6 +29,9 @@ public class SummaryWorker { private final SummaryQueue summaryQueue; private final SummaryWorkerProperties properties; + private final LinkRepository linkRepository; + private final SummaryRepository summaryRepository; + private final AiServerClient aiServerClient; private volatile boolean running = true; private Thread workerThread; @@ -66,8 +78,51 @@ private void processQueue() throws InterruptedException { Long linkId = linkIdOpt.get(); log.info("Processing link for summary - linkId: {}", linkId); - // TODO: 링크 정보 조회 후 RAG 서버에 요약 요청 - // 일단은 로그만 출력 - log.debug("TODO: Send to RAG server - linkId: {}", linkId); + try { + generateAndSaveSummary(linkId); + } catch (Exception e) { + log.error("Failed to generate summary for linkId: {}", linkId, e); + } + } + + @Transactional + public void generateAndSaveSummary(Long linkId) { + // 1. Link 조회 + Link link = linkRepository.findById(linkId) + .orElseThrow(() -> new IllegalArgumentException("Link not found: " + linkId)); + + log.debug("Link found - url: {}, title: {}", link.getUrl(), link.getTitle()); + + // 2. RAG 서버에 요약 요청 + SummaryRequest request = SummaryRequest.of( + link.getId(), + link.getMember().getId(), + link.getUrl(), + link.getTitle(), + link.getMemo() + ); + log.info("Requesting summary to AI server - linkId: {}, userId: {}", request.linkId(), request.userId()); + SummaryResponse[] responses = aiServerClient.generateSummary(request); + if (responses == null || responses.length == 0) { + log.warn("AI server returned empty summary response - linkId: {}", linkId); + return; + } + if (responses.length > 1) { + log.warn("AI server returned multiple summaries, using the first - linkId: {}, size: {}", linkId, + responses.length); + } + SummaryResponse response = responses[0]; + + log.info("Summary generated for linkId: {}", linkId); + + // 3. Summary 엔티티 생성 및 저장 + Summary summary = Summary.builder() + .link(link) + .format(Format.CONCISE) + .content(response.summary()) + .build(); + + summaryRepository.save(summary); + log.info("Summary saved for linkId: {}", linkId); } } diff --git a/src/main/java/com/sofa/linkiving/infra/feign/AiServerClient.java b/src/main/java/com/sofa/linkiving/infra/feign/AiServerClient.java new file mode 100644 index 00000000..893b4552 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/infra/feign/AiServerClient.java @@ -0,0 +1,19 @@ +package com.sofa.linkiving.infra.feign; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import com.sofa.linkiving.infra.feign.dto.SummaryRequest; +import com.sofa.linkiving.infra.feign.dto.SummaryResponse; + +@FeignClient( + name = "aiServerClient", + url = "${ai.server.url}", + configuration = GlobalFeignConfig.class +) +public interface AiServerClient { + + @PostMapping("/webhook/summary-initial") + SummaryResponse[] generateSummary(@RequestBody SummaryRequest request); +} diff --git a/src/main/java/com/sofa/linkiving/infra/feign/GlobalFeignConfig.java b/src/main/java/com/sofa/linkiving/infra/feign/GlobalFeignConfig.java index 64a1dac9..2a7af62b 100644 --- a/src/main/java/com/sofa/linkiving/infra/feign/GlobalFeignConfig.java +++ b/src/main/java/com/sofa/linkiving/infra/feign/GlobalFeignConfig.java @@ -22,6 +22,6 @@ public Logger.Level feignLoggerLevel() { @Bean public Request.Options feignRequestOptions() { - return new Request.Options(3000, 5000); + return new Request.Options(5000, 60000); } } diff --git a/src/main/java/com/sofa/linkiving/infra/feign/dto/SummaryRequest.java b/src/main/java/com/sofa/linkiving/infra/feign/dto/SummaryRequest.java new file mode 100644 index 00000000..42fcc000 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/infra/feign/dto/SummaryRequest.java @@ -0,0 +1,13 @@ +package com.sofa.linkiving.infra.feign.dto; + +public record SummaryRequest( + Long linkId, + Long userId, + String url, + String title, + String memo +) { + public static SummaryRequest of(Long linkId, Long userId, String url, String title, String memo) { + return new SummaryRequest(linkId, userId, url, title, memo); + } +} diff --git a/src/main/java/com/sofa/linkiving/infra/feign/dto/SummaryResponse.java b/src/main/java/com/sofa/linkiving/infra/feign/dto/SummaryResponse.java new file mode 100644 index 00000000..2eba886d --- /dev/null +++ b/src/main/java/com/sofa/linkiving/infra/feign/dto/SummaryResponse.java @@ -0,0 +1,6 @@ +package com.sofa.linkiving.infra.feign.dto; + +public record SummaryResponse( + String summary +) { +} diff --git a/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryWorkerTest.java b/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryWorkerTest.java index dbaf296c..a5917bc8 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryWorkerTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryWorkerTest.java @@ -15,6 +15,9 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.sofa.linkiving.domain.link.config.SummaryWorkerProperties; +import com.sofa.linkiving.domain.link.repository.LinkRepository; +import com.sofa.linkiving.domain.link.repository.SummaryRepository; +import com.sofa.linkiving.infra.feign.AiServerClient; @ExtendWith(MockitoExtension.class) @DisplayName("SummaryWorker 단위 테스트") @@ -23,13 +26,23 @@ class SummaryWorkerTest { @Mock private SummaryQueue summaryQueue; + @Mock + private LinkRepository linkRepository; + + @Mock + private SummaryRepository summaryRepository; + + @Mock + private AiServerClient aiServerClient; + private SummaryWorker summaryWorker; private SummaryWorkerProperties properties; @BeforeEach void setUp() { properties = new SummaryWorkerProperties(Duration.ofMillis(100)); // 테스트용 짧은 sleep 시간 - summaryWorker = new SummaryWorker(summaryQueue, properties); + summaryWorker = new SummaryWorker(summaryQueue, properties, linkRepository, summaryRepository, + aiServerClient); } @AfterEach