diff --git a/.gitignore b/.gitignore index a4dcf217..caa35c90 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,4 @@ perf/**/results/* !perf/**/results/.gitkeep # Local documentation and portfolio drafts -docs/ +docs/local/ diff --git a/AGENTS.md b/AGENTS.md index 242cb246..3550bbb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,11 @@ - 예상 리스크와 의도적으로 제외할 범위 - 사용자가 즉시 구현을 명시적으로 요청하지 않았다면 승인 후 파일을 수정한다. +## 문서 작성 언어 + +- 저장소에 추가하는 문서, 설계안, 구현 계획, 리뷰 요약은 한국어로 작성한다. +- 외부 도구나 라이브러리의 고유 용어는 필요한 경우 원문을 병기할 수 있다. + ## 변경 범위 제한 - 승인된 계획에 필요한 파일만 수정한다. diff --git a/docs/superpowers/plans/2026-06-05-read-model-event-retry-plan.md b/docs/superpowers/plans/2026-06-05-read-model-event-retry-plan.md new file mode 100644 index 00000000..6912bd4d --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-read-model-event-retry-plan.md @@ -0,0 +1,429 @@ +# Read Model 이벤트 재시도 구현 계획 + +> **Agent 작업 지침:** 이 계획을 실행할 때는 `superpowers:subagent-driven-development`를 사용한다. 단, 저장소 `AGENTS.md`가 우선이므로 AI는 commit, push, merge, rebase를 수행하지 않는다. 각 단계는 체크박스 문법으로 추적한다. + +**목표:** Read Model이 필요한 변경 이벤트가 생성 projection보다 먼저 도착했을 때 조용히 `DONE` 처리되지 않고 기존 Outbox retry 흐름을 타도록 수정한다. + +**구조:** `DocListProjector`에서 기존 Read Model이 필요한 이벤트를 처리할 때 모델이 없으면 예외를 던진다. `DomainEventOutboxRelay`는 이미 dispatch 예외를 retry로 전환하므로 relay public contract는 바꾸지 않는다. + +**기술 스택:** Spring Boot, JPA, MongoDB, Domain Event Outbox, JUnit 5, Mockito, AssertJ + +--- + +## 파일 구조 + +- 수정: `src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java` + - 변경 이벤트에서 Read Model을 필수로 조회하는 helper를 추가한다. + - `DOC_TITLE_CHANGED`, `DOC_ACTIVITY_CHANGED`, `DOC_THUMBNAIL_CHANGED`, `DOC_DELETED`가 missing read model 상황에서 예외를 던지게 한다. +- 수정: `src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorUnitTest.java` + - `DOC_CREATED` 멱등성 테스트를 추가한다. + - 변경 이벤트별 missing read model 테스트를 추가한다. +- 수정: `src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java` + - missing read model 상황에서 relay가 outbox를 `DONE`이 아니라 retry 가능한 상태로 남기는 통합 테스트를 추가한다. + +## Task 1: Projector 단위 테스트 추가 + +**Files:** +- Modify: `src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorUnitTest.java` + +- [ ] **Step 1: AssertJ 예외 검증 import 추가** + +`DocListProjectorUnitTest.java` 상단 import에 다음 static import를 추가한다. + +```java +import static org.assertj.core.api.Assertions.assertThatThrownBy; +``` + +- [ ] **Step 2: `DOC_CREATED` 멱등성 테스트 추가** + +`project_success_docCreated()` 아래에 다음 테스트를 추가한다. + +```java +@Test +@DisplayName("DOC_CREATED 이벤트는 read model이 이미 존재하면 멱등하게 무시한다") +void project_ignore_docCreatedWhenReadModelAlreadyExists() throws Exception { + DocCreatedPayload payload = createdPayload(); + when(docListReadModelRepository.existsById(docId)).thenReturn(true); + + docListProjector.project(message(1L, DomainEventType.DOC_CREATED, payload)); + + verify(docListReadModelRepository, never()).save(any()); +} +``` + +- [ ] **Step 3: `DOC_TITLE_CHANGED` missing read model 테스트 추가** + +`project_success_docTitleChanged()` 아래에 다음 테스트를 추가한다. + +```java +@Test +@DisplayName("DOC_TITLE_CHANGED 이벤트는 read model이 없으면 재시도 대상 예외를 던진다") +void project_fail_docTitleChangedWhenReadModelMissing() throws Exception { + DocTitleChangedPayload payload = + new DocTitleChangedPayload(docId, "변경 제목", LocalDateTime.of(2026, 1, 3, 10, 0)); + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> docListProjector.project(message(2L, DomainEventType.DOC_TITLE_CHANGED, payload))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Doc list read model is missing") + .hasMessageContaining("DOC_TITLE_CHANGED") + .hasMessageContaining(docId.toString()); + + verify(docListReadModelRepository, never()).save(any()); +} +``` + +- [ ] **Step 4: `DOC_ACTIVITY_CHANGED` missing read model 테스트 추가** + +`project_success_docActivityChanged()` 아래에 다음 테스트를 추가한다. + +```java +@Test +@DisplayName("DOC_ACTIVITY_CHANGED 이벤트는 read model이 없으면 재시도 대상 예외를 던진다") +void project_fail_docActivityChangedWhenReadModelMissing() throws Exception { + DocActivityChangedPayload payload = + new DocActivityChangedPayload(docId, 20L, LocalDateTime.of(2026, 1, 4, 10, 0)); + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> docListProjector.project(message(2L, DomainEventType.DOC_ACTIVITY_CHANGED, payload))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Doc list read model is missing") + .hasMessageContaining("DOC_ACTIVITY_CHANGED") + .hasMessageContaining(docId.toString()); + + verify(docListReadModelRepository, never()).save(any()); +} +``` + +- [ ] **Step 5: `DOC_THUMBNAIL_CHANGED` missing read model 테스트 추가** + +`project_success_docThumbnailChanged()` 아래에 다음 테스트를 추가한다. + +```java +@Test +@DisplayName("DOC_THUMBNAIL_CHANGED 이벤트는 read model이 없으면 재시도 대상 예외를 던진다") +void project_fail_docThumbnailChangedWhenReadModelMissing() throws Exception { + DocThumbnailChangedPayload payload = + new DocThumbnailChangedPayload(docId, "thumbnail-2", ThumbnailStatus.PENDING); + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> docListProjector.project(message(2L, DomainEventType.DOC_THUMBNAIL_CHANGED, payload))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Doc list read model is missing") + .hasMessageContaining("DOC_THUMBNAIL_CHANGED") + .hasMessageContaining(docId.toString()); + + verify(docListReadModelRepository, never()).save(any()); +} +``` + +- [ ] **Step 6: `DOC_DELETED` missing read model 테스트 추가** + +`project_success_docDeleted()` 아래에 다음 테스트를 추가한다. + +```java +@Test +@DisplayName("DOC_DELETED 이벤트는 read model이 없으면 재시도 대상 예외를 던진다") +void project_fail_docDeletedWhenReadModelMissing() throws Exception { + DocDeletedPayload payload = new DocDeletedPayload(docId); + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> docListProjector.project(message(2L, DomainEventType.DOC_DELETED, payload))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Doc list read model is missing") + .hasMessageContaining("DOC_DELETED") + .hasMessageContaining(docId.toString()); + + verify(docListReadModelRepository, never()).save(any()); +} +``` + +- [ ] **Step 7: 단위 테스트 실패 확인** + +Run: + +```bash +bash ./gradlew test --tests io.ejangs.docsa.domain.doc.readmodel.app.DocListProjectorUnitTest +``` + +Expected: + +- 새 missing read model 테스트 4개는 `IllegalStateException`이 발생하지 않아 실패한다. +- `DOC_CREATED` 멱등성 테스트는 통과해야 한다. + +## Task 2: Projector missing read model 처리 구현 + +**Files:** +- Modify: `src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java` + +- [ ] **Step 1: 필수 Read Model 조회 helper 추가** + +`readPayload()` 아래에 다음 helper를 추가한다. + +```java +private DocListReadModel getRequiredModel(Long docId, DomainEventMessage message) { + return docListReadModelRepository.findById(docId) + .orElseThrow(() -> new IllegalStateException( + "Doc list read model is missing. eventType=%s, eventId=%d, docId=%d" + .formatted(message.eventType(), message.eventId(), docId) + )); +} +``` + +- [ ] **Step 2: `changeTitle()` 수정** + +기존 `findById(...).ifPresent(...)` 블록을 다음 코드로 바꾼다. + +```java +private void changeTitle(DomainEventMessage message) { + DocTitleChangedPayload payload = readPayload(message, DocTitleChangedPayload.class); + DocListReadModel model = getRequiredModel(payload.docId(), message); + + if (model.changeTitle(payload, message.eventId())) { + docListReadModelRepository.save(model); + } +} +``` + +- [ ] **Step 3: `changeActivity()` 수정** + +기존 `findById(...).ifPresent(...)` 블록을 다음 코드로 바꾼다. + +```java +private void changeActivity(DomainEventMessage message) { + DocActivityChangedPayload payload = readPayload(message, DocActivityChangedPayload.class); + DocListReadModel model = getRequiredModel(payload.docId(), message); + + if (model.changeActivity(payload, message.eventId())) { + docListReadModelRepository.save(model); + } +} +``` + +- [ ] **Step 4: `changeThumbnail()` 수정** + +기존 `findById(...).ifPresent(...)` 블록을 다음 코드로 바꾼다. + +```java +private void changeThumbnail(DomainEventMessage message) { + DocThumbnailChangedPayload payload = readPayload(message, DocThumbnailChangedPayload.class); + DocListReadModel model = getRequiredModel(payload.docId(), message); + + if (model.changeThumbnail(payload, message.eventId())) { + docListReadModelRepository.save(model); + } +} +``` + +- [ ] **Step 5: `delete()` 수정** + +기존 `findById(...).ifPresent(...)` 블록을 다음 코드로 바꾼다. + +```java +private void delete(DomainEventMessage message) { + DocDeletedPayload payload = readPayload(message, DocDeletedPayload.class); + DocListReadModel model = getRequiredModel(payload.docId(), message); + + if (model.markDeleted(message.eventId())) { + docListReadModelRepository.save(model); + } +} +``` + +- [ ] **Step 6: 단위 테스트 통과 확인** + +Run: + +```bash +bash ./gradlew test --tests io.ejangs.docsa.domain.doc.readmodel.app.DocListProjectorUnitTest +``` + +Expected: + +- `DocListProjectorUnitTest` 전체 통과 + +## Task 3: Relay retry 통합 테스트 추가 + +**Files:** +- Modify: `src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java` + +- [ ] **Step 1: payload import 추가** + +`DocListProjectorIntegrationTest.java` import에 다음 import를 추가한다. + +```java +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocTitleChangedPayload; +``` + +- [ ] **Step 2: missing read model retry 통합 테스트 추가** + +`relayProjector_success_docCreated()` 아래에 다음 테스트를 추가한다. + +```java +@Test +@DisplayName("변경 이벤트가 read model보다 먼저 처리되면 outbox를 DONE 처리하지 않고 재시도 대상으로 남긴다") +void relayProjector_retry_whenReadModelMissingForUpdateEvent() throws Exception { + LocalDateTime updatedAt = LocalDateTime.of(2026, 1, 3, 10, 0); + DocTitleChangedPayload payload = new DocTitleChangedPayload(docId, "변경 제목", updatedAt); + DomainEventOutbox outbox = domainEventOutboxRepository.saveAndFlush( + DomainEventOutbox.open( + DomainEventType.DOC_TITLE_CHANGED, + AggregateType.DOC, + docId.toString(), + objectMapper.writeValueAsString(payload) + ) + ); + jdbcTemplate.update( + "update domain_event_outbox set payload = ? format json where id = ?", + objectMapper.writeValueAsString(payload), + outbox.getId() + ); + + domainEventOutboxRelay.run(); + + DomainEventOutbox retried = domainEventOutboxRepository.findById(outbox.getId()).orElseThrow(); + Optional readModel = docListReadModelRepository.findById(docId); + + assertThat(retried.getStatus()).isEqualTo(OutboxStatus.OPEN); + assertThat(retried.getRetryCount()).isEqualTo(1); + assertThat(retried.getDoneAt()).isNull(); + assertThat(retried.getLastError()).contains("Doc list read model is missing"); + assertThat(readModel).isEmpty(); +} +``` + +- [ ] **Step 3: 통합 테스트 통과 확인** + +- [ ] **Step 3: retry 이후 최종 반영 통합 테스트 추가** + +같은 파일에 다음 테스트를 추가한다. + +```java +@Test +@DisplayName("먼저 실패한 변경 이벤트는 read model 생성 후 재시도되어 반영된다") +void relayProjector_success_retryAfterReadModelCreated() throws Exception { + LocalDateTime createdAt = LocalDateTime.of(2026, 1, 1, 10, 0); + LocalDateTime initialUpdatedAt = LocalDateTime.of(2026, 1, 2, 10, 0); + LocalDateTime titleUpdatedAt = LocalDateTime.of(2026, 1, 3, 10, 0); + DocCreatedPayload createdPayload = new DocCreatedPayload( + docId, + 2L, + "초기 제목", + createdAt, + initialUpdatedAt, + 10L, + "thumbnail-1", + ThumbnailStatus.READY + ); + DomainEventOutbox createdOutbox = domainEventOutboxRepository.saveAndFlush( + DomainEventOutbox.open( + DomainEventType.DOC_CREATED, + AggregateType.DOC, + docId.toString(), + objectMapper.writeValueAsString(createdPayload) + ) + ); + jdbcTemplate.update( + "update domain_event_outbox set payload = ? format json where id = ?", + objectMapper.writeValueAsString(createdPayload), + createdOutbox.getId() + ); + + DocTitleChangedPayload titlePayload = new DocTitleChangedPayload(docId, "변경 제목", titleUpdatedAt); + DomainEventOutbox titleOutbox = domainEventOutboxRepository.saveAndFlush( + DomainEventOutbox.open( + DomainEventType.DOC_TITLE_CHANGED, + AggregateType.DOC, + docId.toString(), + objectMapper.writeValueAsString(titlePayload) + ) + ); + jdbcTemplate.update( + "update domain_event_outbox set payload = ? format json where id = ?", + objectMapper.writeValueAsString(titlePayload), + titleOutbox.getId() + ); + + domainEventOutboxRelay.run(titleOutbox.getId()); + + DomainEventOutbox firstRetry = domainEventOutboxRepository.findById(titleOutbox.getId()).orElseThrow(); + assertThat(firstRetry.getStatus()).isEqualTo(OutboxStatus.OPEN); + assertThat(firstRetry.getRetryCount()).isEqualTo(1); + assertThat(firstRetry.getLastError()).contains("Doc list read model is missing"); + jdbcTemplate.update( + "update domain_event_outbox set payload = ? format json where id = ?", + objectMapper.writeValueAsString(titlePayload), + titleOutbox.getId() + ); + + domainEventOutboxRelay.run(createdOutbox.getId()); + domainEventOutboxRelay.run(titleOutbox.getId()); + + DomainEventOutbox doneCreated = domainEventOutboxRepository.findById(createdOutbox.getId()).orElseThrow(); + DomainEventOutbox doneTitle = domainEventOutboxRepository.findById(titleOutbox.getId()).orElseThrow(); + DocListReadModel readModel = docListReadModelRepository.findById(docId).orElseThrow(); + + assertThat(doneCreated.getStatus()).isEqualTo(OutboxStatus.DONE); + assertThat(doneTitle.getStatus()) + .as("retryCount=%s, lastError=%s", doneTitle.getRetryCount(), doneTitle.getLastError()) + .isEqualTo(OutboxStatus.DONE); + assertThat(doneTitle.getRetryCount()).isEqualTo(1); + assertThat(doneTitle.getLastError()).isNull(); + assertThat(readModel.getTitle()).isEqualTo("변경 제목"); + assertThat(readModel.getUpdatedAt()).isEqualTo(titleUpdatedAt); + assertThat(readModel.getLastProjectedEventId()).isEqualTo(titleOutbox.getId()); +} +``` + +- [ ] **Step 4: 통합 테스트 통과 확인** + +Run: + +```bash +bash ./gradlew test --tests io.ejangs.docsa.domain.doc.readmodel.app.DocListProjectorIntegrationTest +``` + +Expected: + +- `DocListProjectorIntegrationTest` 전체 통과 + +## Task 4: 회귀 검증과 최종 점검 + +**Files:** +- Read: `src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java` +- Read: `src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorUnitTest.java` +- Read: `src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java` + +- [ ] **Step 1: 관련 테스트 전체 실행** + +Run: + +```bash +bash ./gradlew test --tests io.ejangs.docsa.domain.doc.readmodel.app.DocListProjectorUnitTest --tests io.ejangs.docsa.domain.doc.readmodel.app.DocListProjectorIntegrationTest +``` + +Expected: + +- 두 테스트 클래스 모두 통과 + +- [ ] **Step 2: 변경 diff 점검** + +Run: + +```bash +git diff -- src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorUnitTest.java src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java +git diff --check +``` + +Expected: + +- 변경 범위가 projector와 관련 테스트에 한정된다. +- whitespace 오류가 없다. + +- [ ] **Step 3: 커밋 메시지 추천만 제시** + +AI는 commit을 실행하지 않는다. 사용자에게 다음 커밋 메시지를 추천한다. + +```bash +fix: Read Model 누락 시 도메인 이벤트 재시도 처리 +``` diff --git a/docs/superpowers/specs/2026-06-05-read-model-event-retry-design.md b/docs/superpowers/specs/2026-06-05-read-model-event-retry-design.md new file mode 100644 index 00000000..c9e41ba3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-read-model-event-retry-design.md @@ -0,0 +1,92 @@ +# Read Model 이벤트 재시도 설계 + +## 문제 + +Docsa는 문서 목록 응답을 MongoDB Read Model에서 조회한다. 도메인 이벤트는 RDB Outbox에 저장되고, MySQL 트랜잭션이 커밋된 뒤 비동기로 dispatch된다. + +현재 `DocListProjector`는 변경 이벤트를 처리할 때 대상 Read Model이 없으면 조용히 아무 작업도 하지 않는다. 반면 `DomainEventOutboxRelay`는 dispatch 중 예외가 발생하지 않으면 outbox row를 `DONE`으로 완료 처리한다. + +이 때문에 `DOC_CREATED` projection보다 변경 이벤트가 먼저 처리되면, 실제로는 Read Model에 아무것도 반영되지 않았는데 이벤트는 완료 처리되어 영구 유실될 수 있다. + +## 실패 시나리오 + +1. 문서 생성 트랜잭션에서 `DOC_CREATED` outbox row가 저장된다. +2. 생성 이벤트가 아직 projection되기 전에 제목 변경, 저장, 썸네일 변경, 삭제 이벤트가 저장된다. +3. 비동기 wake-up 순서 때문에 나중 이벤트가 먼저 dispatch된다. +4. `DocListProjector`가 Read Model을 찾지 못하고 아무 작업도 하지 않는다. +5. relay는 dispatch 성공으로 판단해 outbox row를 `DONE` 처리한다. +6. 이후 `DOC_CREATED` 이벤트가 projection된다. +7. 먼저 처리됐던 변경 이벤트는 재시도할 수 없으므로 Read Model이 오래된 상태로 남을 수 있다. + +가능한 결과: + +- 문서 목록에 이전 제목이 남을 수 있다. +- 최근 저장 시각이나 최근 저장 ID가 반영되지 않을 수 있다. +- 썸네일 상태가 반영되지 않을 수 있다. +- 삭제 이벤트가 생성 projection보다 먼저 무시되면, 이후 생성 projection으로 삭제된 문서가 목록에 노출될 수 있다. + +## 목표 + +기존 Read Model이 필요한 변경 이벤트는 대상 Read Model이 없을 때 완료 처리되면 안 된다. 기존 Domain Event Outbox의 retry 흐름을 통해 다시 처리 가능한 상태로 남겨야 한다. + +## 제외 범위 + +- pending event buffer를 새로 만들지 않는다. +- domain event relay의 public contract를 바꾸지 않는다. +- 문서 목록 API 응답 계약을 바꾸지 않는다. +- 썸네일 `PENDING` 상태 projection 문제는 이번 변경에 포함하지 않는다. + +## 추천 접근 + +기존 outbox retry 메커니즘을 그대로 사용한다. + +`DocListProjector`가 기존 Read Model이 필요한 이벤트를 받았는데 Read Model을 찾지 못하면 예외를 던진다. `DomainEventOutboxRelay`는 이미 dispatch 예외를 잡아 `retry(outboxId, errorMessage)`를 호출하므로, 해당 이벤트는 `DONE`으로 완료되지 않고 재시도 대상이 된다. + +이벤트별 동작: + +- `DOC_CREATED`: 기존처럼 멱등성을 유지한다. Read Model이 이미 있으면 오류 없이 skip한다. +- `DOC_TITLE_CHANGED`: Read Model이 반드시 필요하다. 없으면 retry 대상이다. +- `DOC_ACTIVITY_CHANGED`: Read Model이 반드시 필요하다. 없으면 retry 대상이다. +- `DOC_THUMBNAIL_CHANGED`: Read Model이 반드시 필요하다. 없으면 retry 대상이다. +- `DOC_DELETED`: Read Model이 반드시 필요하다. 없으면 retry 대상이다. + +## 검토한 대안 + +### Projection 결과를 relay에 반환 + +`DocListProjector.project()`가 `SUCCESS`, `SKIPPED_DUPLICATE`, `RETRYABLE_MISSING_READ_MODEL` 같은 결과를 반환하게 만들 수 있다. + +예외를 제어 흐름으로 쓰지 않는 장점은 있지만, 이번 문제에 비해 dispatcher와 relay의 계약 변경이 커진다. + +### Pending Event Buffer + +`DOC_CREATED`보다 먼저 도착한 변경 이벤트를 별도 저장소에 보관하고, 생성 projection 이후 순서대로 재적용할 수 있다. + +순서 역전을 가장 정교하게 다룰 수 있지만, 새 저장소와 replay 흐름이 필요하다. 이번 P1 수정 범위에는 과하다. + +## 테스트 전략 + +`DocListProjector` 단위 테스트를 중심으로 검증한다. + +필수 테스트: + +- `DOC_CREATED`에서 Read Model이 이미 존재하면 오류 없이 멱등하게 무시한다. +- `DOC_TITLE_CHANGED`에서 Read Model이 없으면 예외가 발생한다. +- `DOC_ACTIVITY_CHANGED`에서 Read Model이 없으면 예외가 발생한다. +- `DOC_THUMBNAIL_CHANGED`에서 Read Model이 없으면 예외가 발생한다. +- `DOC_DELETED`에서 Read Model이 없으면 예외가 발생한다. +- 기존 중복 이벤트 방지 동작은 유지된다. +- 기존 정상 projection 동작은 유지된다. +- Read Model이 없는 update event outbox row가 relay 처리 후 `DONE`이 아니라 retry 가능한 상태로 남는지 확인한다. +- 먼저 실패한 update event outbox row가 `DOC_CREATED` projection 이후 재시도되어 최종 반영되는지 확인한다. + +## 리스크 + +Read Model이 데이터 손상이나 수동 삭제로 영구적으로 없는 경우, 해당 outbox row는 max retry 이후 `FAILED`가 될 수 있다. 하지만 현재처럼 조용히 `DONE` 처리되는 것보다 낫다. `FAILED` 상태는 운영자가 감지하고 보정할 수 있기 때문이다. + +## 완료 기준 + +- 변경 이벤트가 Read Model 없음 상황에서 조용히 성공하지 않는다. +- 기존 relay retry 흐름을 재사용한다. +- 테스트가 missing read model 경로를 증명한다. +- AI는 commit과 push를 수행하지 않는다. diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java index 07af8354..8d5ecc7a 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java @@ -38,6 +38,14 @@ private T readPayload(DomainEventMessage message, Class payloadType) { } } + private DocListReadModel getRequiredModel(Long docId, DomainEventMessage message) { + return docListReadModelRepository.findById(docId) + .orElseThrow(() -> new IllegalStateException( + "Doc list read model is missing. eventType=%s, eventId=%d, docId=%d" + .formatted(message.eventType(), message.eventId(), docId) + )); + } + private void create(DomainEventMessage message) { DocCreatedPayload payload = readPayload(message, DocCreatedPayload.class); @@ -50,45 +58,37 @@ private void create(DomainEventMessage message) { private void changeTitle(DomainEventMessage message) { DocTitleChangedPayload payload = readPayload(message, DocTitleChangedPayload.class); + DocListReadModel model = getRequiredModel(payload.docId(), message); - docListReadModelRepository.findById(payload.docId()) - .ifPresent(model -> { - if (model.changeTitle(payload, message.eventId())) { - docListReadModelRepository.save(model); - } - }); + if (model.changeTitle(payload, message.eventId())) { + docListReadModelRepository.save(model); + } } private void changeActivity(DomainEventMessage message) { DocActivityChangedPayload payload = readPayload(message, DocActivityChangedPayload.class); + DocListReadModel model = getRequiredModel(payload.docId(), message); - docListReadModelRepository.findById(payload.docId()) - .ifPresent(model -> { - if (model.changeActivity(payload, message.eventId())) { - docListReadModelRepository.save(model); - } - }); + if (model.changeActivity(payload, message.eventId())) { + docListReadModelRepository.save(model); + } } private void changeThumbnail(DomainEventMessage message) { DocThumbnailChangedPayload payload = readPayload(message, DocThumbnailChangedPayload.class); + DocListReadModel model = getRequiredModel(payload.docId(), message); - docListReadModelRepository.findById(payload.docId()) - .ifPresent(model -> { - if (model.changeThumbnail(payload, message.eventId())) { - docListReadModelRepository.save(model); - } - }); + if (model.changeThumbnail(payload, message.eventId())) { + docListReadModelRepository.save(model); + } } private void delete(DomainEventMessage message) { DocDeletedPayload payload = readPayload(message, DocDeletedPayload.class); + DocListReadModel model = getRequiredModel(payload.docId(), message); - docListReadModelRepository.findById(payload.docId()) - .ifPresent(model -> { - if (model.markDeleted(message.eventId())) { - docListReadModelRepository.save(model); - } - }); + if (model.markDeleted(message.eventId())) { + docListReadModelRepository.save(model); + } } } diff --git a/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java b/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java index 30ad0848..19977205 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java @@ -6,6 +6,7 @@ import io.ejangs.docsa.domain.doc.readmodel.dao.mongodb.DocListReadModelRepository; import io.ejangs.docsa.domain.doc.readmodel.document.DocListReadModel; import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocCreatedPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocTitleChangedPayload; import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; import io.ejangs.docsa.global.outbox.OutboxStatus; import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxRelay; @@ -105,4 +106,110 @@ void relayProjector_success_docCreated() throws Exception { assertThat(model.getThumbnailStatus()).isEqualTo(ThumbnailStatus.READY); assertThat(model.getLastProjectedEventId()).isEqualTo(outbox.getId()); } + + @Test + @DisplayName("변경 이벤트가 read model보다 먼저 처리되면 outbox를 DONE 처리하지 않고 재시도 대상으로 남긴다") + void relayProjector_retry_whenReadModelMissingForUpdateEvent() throws Exception { + LocalDateTime updatedAt = LocalDateTime.of(2026, 1, 3, 10, 0); + DocTitleChangedPayload payload = new DocTitleChangedPayload(docId, "변경 제목", updatedAt); + DomainEventOutbox outbox = domainEventOutboxRepository.saveAndFlush( + DomainEventOutbox.open( + DomainEventType.DOC_TITLE_CHANGED, + AggregateType.DOC, + docId.toString(), + objectMapper.writeValueAsString(payload) + ) + ); + jdbcTemplate.update( + "update domain_event_outbox set payload = ? format json where id = ?", + objectMapper.writeValueAsString(payload), + outbox.getId() + ); + + domainEventOutboxRelay.run(); + + DomainEventOutbox retried = domainEventOutboxRepository.findById(outbox.getId()).orElseThrow(); + Optional readModel = docListReadModelRepository.findById(docId); + + assertThat(retried.getStatus()).isEqualTo(OutboxStatus.OPEN); + assertThat(retried.getRetryCount()).isEqualTo(1); + assertThat(retried.getDoneAt()).isNull(); + assertThat(retried.getLastError()).contains("Doc list read model is missing"); + assertThat(readModel).isEmpty(); + } + + @Test + @DisplayName("먼저 실패한 변경 이벤트는 read model 생성 후 재시도되어 반영된다") + void relayProjector_success_retryAfterReadModelCreated() throws Exception { + LocalDateTime createdAt = LocalDateTime.of(2026, 1, 1, 10, 0); + LocalDateTime initialUpdatedAt = LocalDateTime.of(2026, 1, 2, 10, 0); + LocalDateTime titleUpdatedAt = LocalDateTime.of(2026, 1, 3, 10, 0); + DocCreatedPayload createdPayload = new DocCreatedPayload( + docId, + 2L, + "초기 제목", + createdAt, + initialUpdatedAt, + 10L, + "thumbnail-1", + ThumbnailStatus.READY + ); + DomainEventOutbox createdOutbox = domainEventOutboxRepository.saveAndFlush( + DomainEventOutbox.open( + DomainEventType.DOC_CREATED, + AggregateType.DOC, + docId.toString(), + objectMapper.writeValueAsString(createdPayload) + ) + ); + jdbcTemplate.update( + "update domain_event_outbox set payload = ? format json where id = ?", + objectMapper.writeValueAsString(createdPayload), + createdOutbox.getId() + ); + + DocTitleChangedPayload titlePayload = new DocTitleChangedPayload(docId, "변경 제목", titleUpdatedAt); + DomainEventOutbox titleOutbox = domainEventOutboxRepository.saveAndFlush( + DomainEventOutbox.open( + DomainEventType.DOC_TITLE_CHANGED, + AggregateType.DOC, + docId.toString(), + objectMapper.writeValueAsString(titlePayload) + ) + ); + jdbcTemplate.update( + "update domain_event_outbox set payload = ? format json where id = ?", + objectMapper.writeValueAsString(titlePayload), + titleOutbox.getId() + ); + + domainEventOutboxRelay.run(titleOutbox.getId()); + + DomainEventOutbox firstRetry = domainEventOutboxRepository.findById(titleOutbox.getId()).orElseThrow(); + assertThat(firstRetry.getStatus()).isEqualTo(OutboxStatus.OPEN); + assertThat(firstRetry.getRetryCount()).isEqualTo(1); + assertThat(firstRetry.getLastError()).contains("Doc list read model is missing"); + jdbcTemplate.update( + "update domain_event_outbox set payload = ? format json where id = ?", + objectMapper.writeValueAsString(titlePayload), + titleOutbox.getId() + ); + + domainEventOutboxRelay.run(createdOutbox.getId()); + domainEventOutboxRelay.run(titleOutbox.getId()); + + DomainEventOutbox doneCreated = domainEventOutboxRepository.findById(createdOutbox.getId()).orElseThrow(); + DomainEventOutbox doneTitle = domainEventOutboxRepository.findById(titleOutbox.getId()).orElseThrow(); + DocListReadModel readModel = docListReadModelRepository.findById(docId).orElseThrow(); + + assertThat(doneCreated.getStatus()).isEqualTo(OutboxStatus.DONE); + assertThat(doneTitle.getStatus()) + .as("retryCount=%s, lastError=%s", doneTitle.getRetryCount(), doneTitle.getLastError()) + .isEqualTo(OutboxStatus.DONE); + assertThat(doneTitle.getRetryCount()).isEqualTo(1); + assertThat(doneTitle.getLastError()).isNull(); + assertThat(readModel.getTitle()).isEqualTo("변경 제목"); + assertThat(readModel.getUpdatedAt()).isEqualTo(titleUpdatedAt); + assertThat(readModel.getLastProjectedEventId()).isEqualTo(titleOutbox.getId()); + } } diff --git a/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorUnitTest.java b/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorUnitTest.java index 4e9ae1df..2aa66033 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorUnitTest.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorUnitTest.java @@ -1,6 +1,7 @@ package io.ejangs.docsa.domain.doc.readmodel.app; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -79,6 +80,17 @@ void project_success_docCreated() throws Exception { assertThat(saved.getLastProjectedEventId()).isEqualTo(1L); } + @Test + @DisplayName("DOC_CREATED 이벤트는 read model이 이미 존재하면 멱등하게 무시한다") + void project_ignore_docCreatedWhenReadModelAlreadyExists() throws Exception { + DocCreatedPayload payload = createdPayload(); + when(docListReadModelRepository.existsById(docId)).thenReturn(true); + + docListProjector.project(message(1L, DomainEventType.DOC_CREATED, payload)); + + verify(docListReadModelRepository, never()).save(any()); + } + @Test @DisplayName("DOC_TITLE_CHANGED 이벤트를 문서 목록 read model에 반영한다") void project_success_docTitleChanged() throws Exception { @@ -96,6 +108,23 @@ void project_success_docTitleChanged() throws Exception { assertThat(model.getLastProjectedEventId()).isEqualTo(2L); } + @Test + @DisplayName("DOC_TITLE_CHANGED 이벤트는 read model이 없으면 재시도 대상 예외를 던진다") + void project_fail_docTitleChangedWhenReadModelMissing() throws Exception { + DocTitleChangedPayload payload = + new DocTitleChangedPayload(docId, "변경 제목", LocalDateTime.of(2026, 1, 3, 10, 0)); + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> docListProjector.project(message(2L, DomainEventType.DOC_TITLE_CHANGED, payload))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Doc list read model is missing") + .hasMessageContaining("DOC_TITLE_CHANGED") + .hasMessageContaining("eventId=2") + .hasMessageContaining(docId.toString()); + + verify(docListReadModelRepository, never()).save(any()); + } + @Test @DisplayName("DOC_ACTIVITY_CHANGED 이벤트를 문서 목록 read model에 반영한다") void project_success_docActivityChanged() throws Exception { @@ -113,6 +142,23 @@ void project_success_docActivityChanged() throws Exception { assertThat(model.getLastProjectedEventId()).isEqualTo(2L); } + @Test + @DisplayName("DOC_ACTIVITY_CHANGED 이벤트는 read model이 없으면 재시도 대상 예외를 던진다") + void project_fail_docActivityChangedWhenReadModelMissing() throws Exception { + DocActivityChangedPayload payload = + new DocActivityChangedPayload(docId, 20L, LocalDateTime.of(2026, 1, 4, 10, 0)); + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> docListProjector.project(message(2L, DomainEventType.DOC_ACTIVITY_CHANGED, payload))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Doc list read model is missing") + .hasMessageContaining("DOC_ACTIVITY_CHANGED") + .hasMessageContaining("eventId=2") + .hasMessageContaining(docId.toString()); + + verify(docListReadModelRepository, never()).save(any()); + } + @Test @DisplayName("DOC_THUMBNAIL_CHANGED 이벤트를 문서 목록 read model에 반영한다") void project_success_docThumbnailChanged() throws Exception { @@ -129,6 +175,23 @@ void project_success_docThumbnailChanged() throws Exception { assertThat(model.getLastProjectedEventId()).isEqualTo(2L); } + @Test + @DisplayName("DOC_THUMBNAIL_CHANGED 이벤트는 read model이 없으면 재시도 대상 예외를 던진다") + void project_fail_docThumbnailChangedWhenReadModelMissing() throws Exception { + DocThumbnailChangedPayload payload = + new DocThumbnailChangedPayload(docId, "thumbnail-2", ThumbnailStatus.PENDING); + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> docListProjector.project(message(2L, DomainEventType.DOC_THUMBNAIL_CHANGED, payload))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Doc list read model is missing") + .hasMessageContaining("DOC_THUMBNAIL_CHANGED") + .hasMessageContaining("eventId=2") + .hasMessageContaining(docId.toString()); + + verify(docListReadModelRepository, never()).save(any()); + } + @Test @DisplayName("DOC_DELETED 이벤트를 문서 목록 read model에 반영한다") void project_success_docDeleted() throws Exception { @@ -143,6 +206,22 @@ void project_success_docDeleted() throws Exception { assertThat(model.getLastProjectedEventId()).isEqualTo(2L); } + @Test + @DisplayName("DOC_DELETED 이벤트는 read model이 없으면 재시도 대상 예외를 던진다") + void project_fail_docDeletedWhenReadModelMissing() throws Exception { + DocDeletedPayload payload = new DocDeletedPayload(docId); + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> docListProjector.project(message(2L, DomainEventType.DOC_DELETED, payload))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Doc list read model is missing") + .hasMessageContaining("DOC_DELETED") + .hasMessageContaining("eventId=2") + .hasMessageContaining(docId.toString()); + + verify(docListReadModelRepository, never()).save(any()); + } + @Test @DisplayName("이미 처리한 eventId 이하의 이벤트는 무시한다") void project_ignore_alreadyProjectedEvent() throws Exception {