diff --git a/build.gradle b/build.gradle index b0f56bc1..d6aa4549 100644 --- a/build.gradle +++ b/build.gradle @@ -81,6 +81,10 @@ dependencies { 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' + + //Event + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework:spring-aspects' } dependencyManagement { diff --git a/src/main/java/com/sofa/linkiving/domain/link/controller/LinkApi.java b/src/main/java/com/sofa/linkiving/domain/link/controller/LinkApi.java index b450b43e..63c6b06e 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/controller/LinkApi.java +++ b/src/main/java/com/sofa/linkiving/domain/link/controller/LinkApi.java @@ -43,7 +43,7 @@ BaseResponse checkDuplicate( ); @Operation(summary = "링크 생성", description = "새로운 링크를 저장합니다") - BaseResponse createLink( + BaseResponse createLink( @Valid LinkCreateReq request, Member member ); diff --git a/src/main/java/com/sofa/linkiving/domain/link/controller/LinkController.java b/src/main/java/com/sofa/linkiving/domain/link/controller/LinkController.java index 51a5cdf3..a00cd0a2 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/controller/LinkController.java +++ b/src/main/java/com/sofa/linkiving/domain/link/controller/LinkController.java @@ -61,11 +61,11 @@ public BaseResponse checkDuplicate( @Override @PostMapping - public BaseResponse createLink( + public BaseResponse createLink( @RequestBody LinkCreateReq request, @AuthMember Member member ) { - LinkDetailRes response = linkFacade.createLink( + LinkRes response = linkFacade.createLink( member, request.url(), request.title(), 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 e138444e..02748c46 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 @@ -1,5 +1,9 @@ package com.sofa.linkiving.domain.link.event; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @@ -21,43 +25,27 @@ public class LinkEventListener { private final SummaryQueue summaryQueue; /** - * 링크 생성 완료 이벤트 처리 - * 트랜잭션 커밋 후에만 실행되어 롤백 시 큐에 추가되지 않음 + * 트랜잭션 커밋 후 비동기로 큐 적재 실행 + * 실패 시 100ms 간격으로 최대 3회 재시도 */ + @Async + @Retryable( + value = Exception.class, + maxAttempts = 3, + backoff = @Backoff(delay = 100) + ) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleLinkCreated(LinkCreatedEvent event) { - log.info("Link created event received (after commit) - linkId: {}", 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()); + summaryQueue.addToQueue(event.linkId()); + log.info("Link created event received & queued async - linkId: {}", event.linkId()); + } - 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; - } - } - } - } + /** + * 최대 재시도 횟수 초과 시 최종 실패 처리 로직 + */ + @Recover + public void recover(Exception exception, LinkCreatedEvent event) { + log.error("Final failure to queue link after retries - linkId: {}", event.linkId(), exception); + // TODO: 관리자 알림, 슬랙 발송 또는 실패 큐 적재 등 후속 처리 } } 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 a7dd6394..21a38e6a 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 @@ -1,5 +1,6 @@ package com.sofa.linkiving.domain.link.facade; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,13 +14,13 @@ import com.sofa.linkiving.domain.link.dto.response.LinkDuplicateCheckRes; 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.RagInitialSummaryRes; import com.sofa.linkiving.domain.link.dto.response.RagRegenerateSummaryRes; import com.sofa.linkiving.domain.link.dto.response.RegenerateSummaryRes; import com.sofa.linkiving.domain.link.dto.response.SummaryRes; 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.event.LinkCreatedEvent; import com.sofa.linkiving.domain.link.service.LinkService; import com.sofa.linkiving.domain.link.service.SummaryService; import com.sofa.linkiving.domain.link.util.OgTagCrawler; @@ -36,16 +37,16 @@ public class LinkFacade { private final OgTagCrawler ogTagCrawler; private final SummaryService summaryService; private final ImageUploader imageUploader; + private final ApplicationEventPublisher eventPublisher; private final SummaryClient summaryClient; - public LinkDetailRes createLink(Member member, String url, String title, String memo, String imageUrl) { + public LinkRes createLink(Member member, String url, String title, String memo, String imageUrl) { String storedImageUrl = imageUploader.uploadFromUrl(imageUrl); Link link = linkService.createLink(member, url, title, memo, storedImageUrl); - RagInitialSummaryRes res = summaryClient.initialSummary(link.getId(), member.getId(), - link.getTitle(), link.getUrl(), link.getMemo()); - Summary summary = summaryService.createSummary(link, Format.CONCISE, res.summary()); - return LinkDetailRes.of(link, summary); + eventPublisher.publishEvent(new LinkCreatedEvent(link.getId())); + + return LinkRes.from(link); } public LinkRes updateLink(Long linkId, Member member, String title, String memo) { diff --git a/src/main/java/com/sofa/linkiving/domain/link/service/LinkQueryService.java b/src/main/java/com/sofa/linkiving/domain/link/service/LinkQueryService.java index de28235a..b0ebe4af 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/service/LinkQueryService.java +++ b/src/main/java/com/sofa/linkiving/domain/link/service/LinkQueryService.java @@ -30,6 +30,12 @@ public Link findById(Long linkId, Member member) { .orElseThrow(() -> new BusinessException(LinkErrorCode.LINK_NOT_FOUND)); } + public Link findById(Long linkId) { + return linkRepository.findById(linkId) + .filter(link -> !link.isDeleted()) + .orElseThrow(() -> new BusinessException(LinkErrorCode.LINK_NOT_FOUND)); + } + public LinkDto findByIdWithSummary(Long linkId, Member member) { return linkRepository.findByIdAndMemberWithSummaryAndIsDeleteFalse(linkId, member) .orElseThrow(() -> new BusinessException(LinkErrorCode.LINK_NOT_FOUND)); 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 222a8829..ad06c926 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 @@ -2,14 +2,12 @@ import java.util.Optional; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import com.sofa.linkiving.domain.link.dto.internal.LinkDto; import com.sofa.linkiving.domain.link.dto.internal.LinksDto; import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.link.error.LinkErrorCode; -import com.sofa.linkiving.domain.link.event.LinkCreatedEvent; import com.sofa.linkiving.domain.member.entity.Member; import com.sofa.linkiving.global.error.exception.BusinessException; @@ -23,7 +21,6 @@ public class LinkService { private final LinkCommandService linkCommandService; private final LinkQueryService linkQueryService; - private final ApplicationEventPublisher eventPublisher; public Link createLink(Member member, String url, String title, String memo, String imageUrl) { if (linkQueryService.existsByUrl(member, url)) { @@ -33,8 +30,6 @@ public Link createLink(Member member, String url, String title, String memo, Str Link link = linkCommandService.saveLink(member, url, title, memo, imageUrl); log.info("Link created - id: {}, memberId: {}, url: {}", link.getId(), member.getId(), url); - eventPublisher.publishEvent(new LinkCreatedEvent(link.getId())); - return link; } @@ -72,6 +67,10 @@ public void deleteLink(Long linkId, Member member) { log.info("Link soft deleted - id: {}, memberId: {}", linkId, member.getId()); } + public Link getLink(Long linkId) { + return linkQueryService.findById(linkId); + } + public Link getLink(Long linkId, Member member) { return linkQueryService.findById(linkId, member); } diff --git a/src/main/java/com/sofa/linkiving/domain/link/service/SummaryCommandService.java b/src/main/java/com/sofa/linkiving/domain/link/service/SummaryCommandService.java index 3d392f02..b91b8373 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/service/SummaryCommandService.java +++ b/src/main/java/com/sofa/linkiving/domain/link/service/SummaryCommandService.java @@ -37,5 +37,18 @@ public Summary save(Link link, Format format, String content) { .build() ); } + + public Summary initialSave(Link link, Format format, String content) { + summaryRepository.clearSelectedByLinkId(link.getId()); + + return summaryRepository.save( + Summary.builder() + .link(link) + .format(format) + .content(content) + .selected(true) + .build() + ); + } } diff --git a/src/main/java/com/sofa/linkiving/domain/link/service/SummaryService.java b/src/main/java/com/sofa/linkiving/domain/link/service/SummaryService.java index b6ad6659..e5f0b9d6 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/service/SummaryService.java +++ b/src/main/java/com/sofa/linkiving/domain/link/service/SummaryService.java @@ -25,6 +25,10 @@ public Summary createSummary(Link link, Format format, String summary) { return summaryCommandService.save(link, format, summary); } + public Summary createInitialSummary(Link link, String summary) { + return summaryCommandService.initialSave(link, Format.CONCISE, summary); + } + public void selectSummary(Long linkId, Long summaryId) { summaryCommandService.selectSummary(linkId, summaryId); } 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 cd6f8176..777b5b3b 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,17 +4,13 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; +import com.sofa.linkiving.domain.link.ai.SummaryClient; import com.sofa.linkiving.domain.link.config.SummaryWorkerProperties; +import com.sofa.linkiving.domain.link.dto.response.RagInitialSummaryRes; 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 com.sofa.linkiving.domain.link.service.LinkService; +import com.sofa.linkiving.domain.link.service.SummaryService; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; @@ -29,9 +25,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 final SummaryService summaryService; + private final LinkService linkService; + private final SummaryClient summaryClient; private volatile boolean running = true; private Thread workerThread; @@ -79,52 +75,21 @@ private void processQueue() throws InterruptedException { log.info("Processing link for summary - linkId: {}", linkId); try { - generateAndSaveSummary(linkId); + Link link = linkService.getLink(linkId); + + RagInitialSummaryRes res = summaryClient.initialSummary( + link.getId(), + link.getMember().getId(), + link.getTitle(), + link.getUrl(), + link.getMemo() + ); + + if (res != null) { + summaryService.createInitialSummary(link, res.summary()); + } } 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 엔티티 생성 및 저장 - boolean isFirstSummary = !summaryRepository.existsByLinkIdAndSelectedTrue(linkId); - Summary summary = Summary.builder() - .link(link) - .format(Format.CONCISE) - .content(response.summary()) - .selected(isFirstSummary) - .build(); - - summaryRepository.save(summary); - log.info("Summary saved for linkId: {}", linkId); - } } diff --git a/src/main/java/com/sofa/linkiving/global/config/AsyncConfig.java b/src/main/java/com/sofa/linkiving/global/config/AsyncConfig.java new file mode 100644 index 00000000..1567e74a --- /dev/null +++ b/src/main/java/com/sofa/linkiving/global/config/AsyncConfig.java @@ -0,0 +1,11 @@ +package com.sofa.linkiving.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.scheduling.annotation.EnableAsync; + +@EnableRetry +@EnableAsync +@Configuration +public class AsyncConfig { +} diff --git a/src/main/java/com/sofa/linkiving/global/config/WebSocketConfig.java b/src/main/java/com/sofa/linkiving/global/config/WebSocketConfig.java index d4f7695a..bcce8e87 100644 --- a/src/main/java/com/sofa/linkiving/global/config/WebSocketConfig.java +++ b/src/main/java/com/sofa/linkiving/global/config/WebSocketConfig.java @@ -6,7 +6,6 @@ import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; -import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; @@ -18,7 +17,6 @@ @Configuration @EnableWebSocketMessageBroker -@EnableAsync @RequiredArgsConstructor public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { diff --git a/src/test/java/com/sofa/linkiving/domain/chat/service/RagChatServiceTest.java b/src/test/java/com/sofa/linkiving/domain/chat/service/RagChatServiceTest.java index 93547a9c..af7a5c42 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/service/RagChatServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/service/RagChatServiceTest.java @@ -52,8 +52,8 @@ public class RagChatServiceTest { private Member member; private Chat chat; - private Long chatId = 1L; - private String userMessage = "테스트 질문"; + private final Long chatId = 1L; + private final String userMessage = "테스트 질문"; @BeforeEach void setUp() { 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 436dd7fb..d81deb1f 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 @@ -1,47 +1,60 @@ package com.sofa.linkiving.domain.link.event; -import static org.mockito.BDDMockito.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +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.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; import com.sofa.linkiving.domain.link.worker.SummaryQueue; -@ExtendWith(MockitoExtension.class) -@DisplayName("LinkEventListener 단위 테스트") +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = LinkEventListenerTest.RetryTestConfig.class) +@DisplayName("LinkEventListener 재시도(Retry) 및 복구(Recover) 단위 테스트") class LinkEventListenerTest { - @InjectMocks + @Autowired private LinkEventListener linkEventListener; - - @Mock + @Autowired private SummaryQueue summaryQueue; + @BeforeEach + void setUp() { + reset(summaryQueue); + } + @Test - @DisplayName("링크 생성 이벤트 수신 시 큐에 추가한다") - void shouldAddToQueueWhenLinkCreatedEventReceived() { + @DisplayName("링크 생성 이벤트 수신 시 큐에 추가한다 & 첫 번째 시도에서 성공하면 재시도하지 않는다") + void shouldAddQueueAndNotRetry_WhenFirstAttemptSucceeds() { // given - Long linkId = 123L; - LinkCreatedEvent event = new LinkCreatedEvent(linkId); + LinkCreatedEvent event = new LinkCreatedEvent(1L); + doNothing().when(summaryQueue).addToQueue(anyLong()); // when linkEventListener.handleLinkCreated(event); // then - verify(summaryQueue, times(1)).addToQueue(linkId); + verify(summaryQueue, times(1)).addToQueue(1L); } @Test @DisplayName("여러 링크 생성 이벤트를 순차적으로 처리한다") - void shouldHandleMultipleLinkCreatedEvents() { + void shouldProcessMultipleEventsSequentially() { // given - LinkCreatedEvent event1 = new LinkCreatedEvent(1L); - LinkCreatedEvent event2 = new LinkCreatedEvent(2L); - LinkCreatedEvent event3 = new LinkCreatedEvent(3L); + LinkCreatedEvent event1 = new LinkCreatedEvent(10L); + LinkCreatedEvent event2 = new LinkCreatedEvent(20L); + LinkCreatedEvent event3 = new LinkCreatedEvent(30L); + doNothing().when(summaryQueue).addToQueue(anyLong()); // when linkEventListener.handleLinkCreated(event1); @@ -49,63 +62,71 @@ void shouldHandleMultipleLinkCreatedEvents() { linkEventListener.handleLinkCreated(event3); // then - verify(summaryQueue, times(1)).addToQueue(1L); - verify(summaryQueue, times(1)).addToQueue(2L); - verify(summaryQueue, times(1)).addToQueue(3L); + verify(summaryQueue, times(1)).addToQueue(10L); + verify(summaryQueue, times(1)).addToQueue(20L); + verify(summaryQueue, times(1)).addToQueue(30L); } @Test - @DisplayName("큐 추가 실패 시 최대 3번까지 재시도한다") - void shouldRetryWhenAddToQueueFails() { + @DisplayName("3번 내에 성공하면 오류가 발생하지 않는다 (2번 실패 후 3번째 성공)") + void shouldNotThrowError_WhenSucceedsWithin3Times() { // given - Long linkId = 123L; - LinkCreatedEvent event = new LinkCreatedEvent(linkId); + LinkCreatedEvent event = new LinkCreatedEvent(1L); - // 첫 2번 실패, 3번째 성공 - willThrow(new RuntimeException("Queue full")) - .willThrow(new RuntimeException("Queue full")) - .willDoNothing() - .given(summaryQueue).addToQueue(linkId); + doThrow(new RuntimeException("Queue full")) + .doThrow(new RuntimeException("Queue full")) + .doNothing() + .when(summaryQueue).addToQueue(anyLong()); - // when - linkEventListener.handleLinkCreated(event); + // when & then + assertThatCode(() -> linkEventListener.handleLinkCreated(event)) + .doesNotThrowAnyException(); - // then - verify(summaryQueue, times(3)).addToQueue(linkId); + verify(summaryQueue, times(3)).addToQueue(1L); } @Test - @DisplayName("큐 추가가 3번 모두 실패하면 재시도를 중단하고 에러 로그를 남긴다") - void shouldStopRetryingAfterMaxAttempts() { + @DisplayName("큐 적재 실패 시 최대 3번까지 재시도") + void shouldRetryUpTo3Times_WhenFails() { // given - Long linkId = 123L; - LinkCreatedEvent event = new LinkCreatedEvent(linkId); + LinkCreatedEvent event = new LinkCreatedEvent(2L); - // 3번 모두 실패 - willThrow(new RuntimeException("Queue full")) - .given(summaryQueue).addToQueue(linkId); + doThrow(new RuntimeException("Queue full")).when(summaryQueue).addToQueue(anyLong()); // when linkEventListener.handleLinkCreated(event); // then - verify(summaryQueue, times(3)).addToQueue(linkId); // 최대 3번 시도 + verify(summaryQueue, times(3)).addToQueue(2L); } @Test - @DisplayName("첫 번째 시도에서 성공하면 재시도하지 않는다") - void shouldNotRetryWhenFirstAttemptSucceeds() { + @DisplayName("최대 3번 한 후에 모두 실패하면 최종 오류(Recover) 로직 처리") + void shouldExecuteRecoverLogic_WhenAll3RetriesFail() { // given - Long linkId = 123L; - LinkCreatedEvent event = new LinkCreatedEvent(linkId); + LinkCreatedEvent event = new LinkCreatedEvent(3L); + doThrow(new RuntimeException("Queue full")).when(summaryQueue).addToQueue(anyLong()); - willDoNothing().given(summaryQueue).addToQueue(linkId); + // when & then + assertThatCode(() -> linkEventListener.handleLinkCreated(event)) + .doesNotThrowAnyException(); - // when - linkEventListener.handleLinkCreated(event); + // 최종적으로 3번 호출된 것 검증 + verify(summaryQueue, times(3)).addToQueue(3L); + } - // then - verify(summaryQueue, times(1)).addToQueue(linkId); // 1번만 시도 + @Configuration + @EnableRetry + @EnableAspectJAutoProxy(proxyTargetClass = true) + static class RetryTestConfig { + @Bean + public SummaryQueue summaryQueue() { + return mock(SummaryQueue.class); + } + + @Bean + public LinkEventListener linkEventListener(SummaryQueue summaryQueue) { + return new LinkEventListener(summaryQueue); + } } } - 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 f637781a..ccd99fba 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 @@ -22,15 +22,15 @@ 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.LinkDetailRes; +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.RagInitialSummaryRes; import com.sofa.linkiving.domain.link.dto.response.RagRegenerateSummaryRes; import com.sofa.linkiving.domain.link.dto.response.RegenerateSummaryRes; 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.error.LinkErrorCode; +import com.sofa.linkiving.domain.link.event.LinkCreatedEvent; import com.sofa.linkiving.domain.link.service.LinkCommandService; import com.sofa.linkiving.domain.link.service.LinkQueryService; import com.sofa.linkiving.domain.link.service.LinkService; @@ -71,7 +71,8 @@ public class LinkFacadeTest { @BeforeEach void setUp() { - linkFacade = new LinkFacade(linkService, ogTagCrawler, summaryService, imageUploader, summaryClient); + linkFacade = new LinkFacade(linkService, ogTagCrawler, summaryService, imageUploader, eventPublisher, + summaryClient); } @Test @@ -164,61 +165,49 @@ void shouldReturnEmptyMetaScrapeResWhenCrawlFails() { } @Test - @DisplayName("링크 생성 시 이미지 업로드, 링크 저장, AI 요약 요청, 요약 저장이 순차적으로 수행된다") + @DisplayName("링크 생성 시 이미지 업로드 및 링크가 저장되고, 비동기 요약을 위한 이벤트가 발행된다") void shouldCreateLink() { // given Long linkId = 1L; - Long memberId = 1L; 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"; - String aiSummaryContent = "AI가 요약한 내용입니다."; Member member = mock(Member.class); - when(member.getId()).thenReturn(memberId); + // 1. 이미지 업로드 모킹 given(imageUploader.uploadFromUrl(originalImageUrl)).willReturn(storedImageUrl); + // 2. 링크 저장 모킹 (LinkService 내부의 LinkCommandService 동작) Link savedLink = Link.builder() .url(url) .title(title) .memo(memo) .imageUrl(storedImageUrl) .build(); - ReflectionTestUtils.setField(savedLink, "id", linkId); + given(linkCommandService.saveLink(member, url, title, memo, storedImageUrl)) .willReturn(savedLink); - RagInitialSummaryRes ragRes = new RagInitialSummaryRes(aiSummaryContent); - given(summaryClient.initialSummary(eq(linkId), eq(memberId), eq(title), eq(url), eq(memo))) - .willReturn(ragRes); - - Summary savedSummary = Summary.builder() - .link(savedLink) - .content(aiSummaryContent) - .format(Format.CONCISE) - .build(); - - given(summaryService.createSummary(savedLink, Format.CONCISE, aiSummaryContent)) - .willReturn(savedSummary); - // when - LinkDetailRes result = linkFacade.createLink(member, url, title, memo, originalImageUrl); + LinkRes result = linkFacade.createLink(member, url, title, memo, originalImageUrl); // then assertThat(result).isNotNull(); - assertThat(result.url()).isEqualTo(url); - assertThat(result.imageUrl()).isEqualTo(storedImageUrl); - assertThat(result.summary().content()).isEqualTo(aiSummaryContent); - - // Verify - verify(imageUploader).uploadFromUrl(originalImageUrl); - verify(linkCommandService).saveLink(member, url, title, memo, storedImageUrl); - verify(summaryClient).initialSummary(eq(linkId), eq(memberId), eq(title), eq(url), eq(memo)); - verify(summaryService).createSummary(savedLink, Format.CONCISE, aiSummaryContent); + + // Verify: 기존 로직 정상 호출 확인 + verify(imageUploader, times(1)).uploadFromUrl(originalImageUrl); + verify(linkCommandService, times(1)).saveLink(member, url, title, memo, storedImageUrl); + + // Verify: 핵심 비즈니스 로직인 이벤트 발행이 정상적으로 수행되었는지 확인 + verify(eventPublisher, atLeastOnce()).publishEvent(any(LinkCreatedEvent.class)); + + // Verify: 비동기로 전환되었으므로, 더 이상 파사드에서 요약 클라이언트나 서비스를 호출하지 않음을 확인 + verifyNoInteractions(summaryClient); + verify(summaryService, never()).createSummary(any(), any(), any()); } @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 96923895..e5759ceb 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 @@ -123,8 +123,7 @@ void shouldCreateLinkSuccessfully() throws Exception { .andExpect(jsonPath("$.data.url").value(req.url())) .andExpect(jsonPath("$.data.title").value(req.title())) .andExpect(jsonPath("$.data.memo").value(req.memo())) - .andExpect(jsonPath("$.data.imageUrl").value(uploadedS3Url)) - .andExpect(jsonPath("$.data.summary.content").value("최초 요약")); + .andExpect(jsonPath("$.data.imageUrl").value(uploadedS3Url)); // DB 검증 boolean exists = linkRepository.existsByMemberAndUrlAndIsDeleteFalse(testMember, req.url()); @@ -626,7 +625,6 @@ void shouldRecreateSummarySuccessfully() throws Exception { .title("테스트 링크") .build()); - Format format = Format.DETAILED; Long linkId = savedLink.getId(); summaryRepository.save(Summary.builder() @@ -635,6 +633,8 @@ void shouldRecreateSummarySuccessfully() throws Exception { .selected(true) .build()); + Format format = Format.DETAILED; + RegenerateSummaryReq req = new RegenerateSummaryReq(format); // when & then diff --git a/src/test/java/com/sofa/linkiving/domain/link/service/SummaryCommandServiceTest.java b/src/test/java/com/sofa/linkiving/domain/link/service/SummaryCommandServiceTest.java index 930d7f84..0be13252 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/service/SummaryCommandServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/service/SummaryCommandServiceTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -101,4 +102,39 @@ void shouldSaveLink() { assertThat(save.getContent()).isEqualTo("요약"); verify(summaryRepository, times(1)).save(any(Summary.class)); } + + @Test + @DisplayName("기존 선택된 요약을 초기화하고 새 요약을 선택 상태로 저장함") + void shouldClearSelectedAndSaveNewSummaryAsSelected() { + // given + Long linkId = 1L; + Link link = mock(Link.class); + given(link.getId()).willReturn(linkId); + + Format format = Format.DETAILED; + String content = "새로운 초기 요약 내용"; + + // Repository의 save 호출 시 전달된 객체를 그대로 반환하도록 모킹 설정함 + given(summaryRepository.save(any(Summary.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + Summary savedSummary = summaryCommandService.initialSave(link, format, content); + + // then + // 1. 기존 요약 선택 상태 초기화(clearSelectedByLinkId) 메서드가 정상 호출되었는지 검증함 + verify(summaryRepository, times(1)).clearSelectedByLinkId(linkId); + + // 2. 저장 시 넘겨진 엔티티 값을 캡처하여 정확히 바인딩되었는지 검증함 + ArgumentCaptor captor = ArgumentCaptor.forClass(Summary.class); + verify(summaryRepository, times(1)).save(captor.capture()); + + Summary capturedSummary = captor.getValue(); + assertThat(capturedSummary.getLink()).isEqualTo(link); + assertThat(capturedSummary.getFormat()).isEqualTo(format); + assertThat(capturedSummary.getContent()).isEqualTo(content); + assertThat(capturedSummary.isSelected()).isTrue(); // 반드시 selected(true) 상태여야 함 + + // 3. 리턴된 값이 캡처된 객체와 동일한지 확인함 + assertThat(savedSummary).isEqualTo(capturedSummary); + } } diff --git a/src/test/java/com/sofa/linkiving/domain/link/service/SummaryServiceTest.java b/src/test/java/com/sofa/linkiving/domain/link/service/SummaryServiceTest.java index 5f4d2524..5eef24bd 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/service/SummaryServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/service/SummaryServiceTest.java @@ -78,4 +78,22 @@ void shouldCallGetSummaryWhenGetSummary() { assertThat(result).isEqualTo(mockSummary); verify(summaryQueryService).getSummary(linkId); } + + @Test + @DisplayName("createInitialSummary: Format.CONCISE 형태로 초기 요약을 생성하고 저장한다") + void shouldCreateInitialSummaryWithConciseFormat() { + // given + Link link = mock(Link.class); + String content = "테스트 초기 요약 내용"; + Summary expectedSummary = mock(Summary.class); + + given(summaryCommandService.initialSave(link, Format.CONCISE, content)).willReturn(expectedSummary); + + // when + Summary actualSummary = summaryService.createInitialSummary(link, content); + + // then + assertThat(actualSummary).isEqualTo(expectedSummary); + verify(summaryCommandService, times(1)).initialSave(link, Format.CONCISE, content); + } } 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 a5917bc8..cc49acfd 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 @@ -1,6 +1,7 @@ package com.sofa.linkiving.domain.link.worker; import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.*; import java.time.Duration; @@ -11,13 +12,17 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.sofa.linkiving.domain.link.ai.SummaryClient; 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; +import com.sofa.linkiving.domain.link.dto.response.RagInitialSummaryRes; +import com.sofa.linkiving.domain.link.entity.Link; +import com.sofa.linkiving.domain.link.service.LinkService; +import com.sofa.linkiving.domain.link.service.SummaryService; +import com.sofa.linkiving.domain.member.entity.Member; @ExtendWith(MockitoExtension.class) @DisplayName("SummaryWorker 단위 테스트") @@ -25,135 +30,196 @@ class SummaryWorkerTest { @Mock private SummaryQueue summaryQueue; - @Mock - private LinkRepository linkRepository; - + private SummaryService summaryService; @Mock - private SummaryRepository summaryRepository; - + private LinkService linkService; @Mock - private AiServerClient aiServerClient; + private SummaryClient summaryClient; private SummaryWorker summaryWorker; - private SummaryWorkerProperties properties; @BeforeEach void setUp() { - properties = new SummaryWorkerProperties(Duration.ofMillis(100)); // 테스트용 짧은 sleep 시간 - summaryWorker = new SummaryWorker(summaryQueue, properties, linkRepository, summaryRepository, - aiServerClient); + SummaryWorkerProperties properties = new SummaryWorkerProperties(Duration.ofMillis(10)); + summaryWorker = new SummaryWorker(summaryQueue, properties, summaryService, linkService, summaryClient); } @AfterEach void tearDown() { - if (summaryWorker != null) { - summaryWorker.stopWorker(); - } + summaryWorker.stopWorker(); } @Test - @DisplayName("워커 시작 시 백그라운드 쓰레드가 생성된다") - void shouldStartWorkerThread() throws InterruptedException { + @DisplayName("큐에 링크가 있으면 정상적으로 AI 요약을 요청하고 저장") + void shouldProcessLinkAndSaveSummary() { // given - given(summaryQueue.pollFromQueue()).willReturn(Optional.empty()); + Long linkId = 1L; + given(summaryQueue.pollFromQueue()) + .willReturn(Optional.of(linkId)) + .willReturn(Optional.empty()); + + Link link = mock(Link.class); + Member member = mock(Member.class); + + // Link 엔티티 Mocking + given(link.getId()).willReturn(linkId); + given(link.getMember()).willReturn(member); + given(member.getId()).willReturn(100L); + given(link.getUrl()).willReturn("http://test.com"); + given(link.getTitle()).willReturn("Test Title"); + given(link.getMemo()).willReturn("Test Memo"); + + given(linkService.getLink(linkId)).willReturn(link); + + // AI 클라이언트 응답 Mocking + RagInitialSummaryRes mockRes = mock(RagInitialSummaryRes.class); + given(mockRes.summary()).willReturn("요약된 내용입니다."); + given(summaryClient.initialSummary(linkId, 100L, "Test Title", "http://test.com", "Test Memo")) + .willReturn(mockRes); // when summaryWorker.startWorker(); - Thread.sleep(50); // 워커 쓰레드가 시작될 시간 대기 // then - verify(summaryQueue, atLeastOnce()).pollFromQueue(); + verify(summaryService, timeout(1000).times(1)).createInitialSummary(link, "요약된 내용입니다."); } @Test - @DisplayName("큐에 데이터가 있으면 처리한다") - void shouldProcessLinkFromQueue() throws InterruptedException { + @DisplayName("AI 응답이 null일 경우 요약을 생성하지 않음") + void shouldNotSaveSummary_WhenClientReturnsNull() { // given + Long linkId = 2L; given(summaryQueue.pollFromQueue()) - .willReturn(Optional.of(123L)) + .willReturn(Optional.of(linkId)) .willReturn(Optional.empty()); + Link link = mock(Link.class); + Member member = mock(Member.class); + given(link.getId()).willReturn(linkId); + given(link.getMember()).willReturn(member); + given(member.getId()).willReturn(100L); + + given(linkService.getLink(linkId)).willReturn(link); + + // AI 응답이 null로 반환되는 상황 + given(summaryClient.initialSummary(anyLong(), anyLong(), any(), any(), any())).willReturn(null); + // when summaryWorker.startWorker(); - Thread.sleep(150); // 처리 시간 대기 // then - verify(summaryQueue, atLeast(2)).pollFromQueue(); + // 클라이언트 호출은 일어났으나 + verify(summaryClient, timeout(1000).times(1)).initialSummary(anyLong(), anyLong(), any(), any(), any()); + // 저장은 호출되지 않아야 함 + verify(summaryService, after(200).never()).createInitialSummary(any(Link.class), anyString()); } @Test - @DisplayName("큐가 비어있으면 설정된 시간만큼 대기한다") - void shouldSleepWhenQueueIsEmpty() throws InterruptedException { + @DisplayName("처리 중 예외가 발생해도 워커 쓰레드는 종료되지 않고 다음 큐를 계속 확인") + void shouldContinueWorking_WhenExceptionOccurs() { // given - given(summaryQueue.pollFromQueue()).willReturn(Optional.empty()); + Long linkId = 3L; + given(summaryQueue.pollFromQueue()) + .willReturn(Optional.of(linkId)) + .willReturn(Optional.empty()); + + // Link 조회 중 강제로 RuntimeException 발생 + given(linkService.getLink(linkId)).willThrow(new RuntimeException("DB Connection Error")); // when summaryWorker.startWorker(); - long startTime = System.currentTimeMillis(); - Thread.sleep(250); // sleep(100ms) * 2회 이상 호출될 시간 대기 - long endTime = System.currentTimeMillis(); // then - long elapsed = endTime - startTime; - assertThat(elapsed).isGreaterThanOrEqualTo(200); // 최소 2번의 sleep(100ms) - verify(summaryQueue, atLeast(2)).pollFromQueue(); + verify(linkService, timeout(1000).times(1)).getLink(linkId); + + // 예외를 catch 블록에서 먹고 루프가 계속 도는지 검증 (최소 2번 이상 poll 호출 여부) + verify(summaryQueue, timeout(1000).atLeast(2)).pollFromQueue(); + verify(summaryService, never()).createInitialSummary(any(), any()); } @Test - @DisplayName("워커 종료 시 쓰레드가 정상적으로 중단된다") - void shouldStopWorkerThread() throws InterruptedException { + @DisplayName("큐가 비어있으면 지정된 시간만큼 Sleep 후 다시 확인") + void shouldSleepAndRetry_WhenQueueIsEmpty() { // given given(summaryQueue.pollFromQueue()).willReturn(Optional.empty()); - summaryWorker.startWorker(); - Thread.sleep(50); // 워커 시작 대기 // when - summaryWorker.stopWorker(); - Thread.sleep(50); // 종료 대기 + summaryWorker.startWorker(); // then - int invocationsBefore = mockingDetails(summaryQueue).getInvocations().size(); - Thread.sleep(150); // 추가 대기 - int invocationsAfter = mockingDetails(summaryQueue).getInvocations().size(); - - // 워커가 중단되었으므로 추가 호출이 없어야 함 - assertThat(invocationsAfter).isEqualTo(invocationsBefore); + // 10ms 단위로 대기하므로, 짧은 시간 내에 여러 번 pollFromQueue를 호출하는지 확인 + verify(summaryQueue, timeout(500).atLeast(3)).pollFromQueue(); } @Test - @DisplayName("여러 링크를 순차적으로 처리한다") - void shouldProcessMultipleLinks() throws InterruptedException { + @DisplayName("여러 링크가 큐에 있을 때 들어온 순서대로 처리함") + void shouldProcessQueueSequentially() { // given + Long linkId1 = 10L; + Long linkId2 = 20L; + given(summaryQueue.pollFromQueue()) - .willReturn(Optional.of(1L)) - .willReturn(Optional.of(2L)) - .willReturn(Optional.of(3L)) + .willReturn(Optional.of(linkId1)) + .willReturn(Optional.of(linkId2)) .willReturn(Optional.empty()); + // Link 1 Mocking + Link link1 = mock(Link.class); + Member member1 = mock(Member.class); + lenient().when(link1.getId()).thenReturn(linkId1); + lenient().when(link1.getMember()).thenReturn(member1); + lenient().when(member1.getId()).thenReturn(100L); + + // Link 2 Mocking + Link link2 = mock(Link.class); + Member member2 = mock(Member.class); + lenient().when(link2.getId()).thenReturn(linkId2); + lenient().when(link2.getMember()).thenReturn(member2); + lenient().when(member2.getId()).thenReturn(200L); + + given(linkService.getLink(linkId1)).willReturn(link1); + given(linkService.getLink(linkId2)).willReturn(link2); + + // Client 응답 Mocking + RagInitialSummaryRes mockRes1 = mock(RagInitialSummaryRes.class); + given(mockRes1.summary()).willReturn("Summary 1"); + given(summaryClient.initialSummary(eq(linkId1), anyLong(), any(), any(), any())).willReturn(mockRes1); + + RagInitialSummaryRes mockRes2 = mock(RagInitialSummaryRes.class); + given(mockRes2.summary()).willReturn("Summary 2"); + given(summaryClient.initialSummary(eq(linkId2), anyLong(), any(), any(), any())).willReturn(mockRes2); + // when summaryWorker.startWorker(); - Thread.sleep(200); // 여러 링크 처리 시간 대기 // then - verify(summaryQueue, atLeast(4)).pollFromQueue(); + InOrder inOrder = inOrder(summaryService); + inOrder.verify(summaryService, timeout(1000).times(1)).createInitialSummary(link1, "Summary 1"); + inOrder.verify(summaryService, timeout(1000).times(1)).createInitialSummary(link2, "Summary 2"); } @Test - @DisplayName("에러 발생 시에도 워커는 계속 동작한다") - void shouldContinueWorkingAfterError() throws InterruptedException { + @DisplayName("워커 실행 시 메인 쓰레드가 차단되지 않고 백그라운드 쓰레드(summary-worker)에서 동작함") + void shouldRunInBackgroundThread() { // given - given(summaryQueue.pollFromQueue()) - .willThrow(new RuntimeException("Test exception")) - .willReturn(Optional.of(123L)) - .willReturn(Optional.empty()); + String mainThreadName = Thread.currentThread().getName(); + String[] workerThreadName = new String[1]; + + given(summaryQueue.pollFromQueue()).willAnswer(invocation -> { + workerThreadName[0] = Thread.currentThread().getName(); + return Optional.empty(); // 무한 루프 방지 + }); // when summaryWorker.startWorker(); - Thread.sleep(200); // 에러 발생 및 복구 시간 대기 // then - // 에러가 발생해도 워커가 계속 동작하여 다음 pollFromQueue 호출 - verify(summaryQueue, atLeast(3)).pollFromQueue(); + // 백그라운드 쓰레드가 큐를 확인하는 로직이 호출될 때까지 대기 + verify(summaryQueue, timeout(1000).atLeastOnce()).pollFromQueue(); + + assertThat(workerThreadName[0]).isNotNull(); + assertThat(workerThreadName[0]).isNotEqualTo(mainThreadName); + assertThat(workerThreadName[0]).isEqualTo("summary-worker"); } }