diff --git a/docs/superpowers/plans/2026-06-06-read-model-projection-ordering-plan.md b/docs/superpowers/plans/2026-06-06-read-model-projection-ordering-plan.md new file mode 100644 index 00000000..6d1a1cb8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-read-model-projection-ordering-plan.md @@ -0,0 +1,547 @@ +# Read Model Projection 순서 안정화 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. 저장소 `AGENTS.md`가 우선이므로 AI는 commit, push, merge, rebase를 수행하지 않는다. + +**Goal:** 범용 확장 가능한 Domain Event Outbox 구조는 유지하면서, 첫 consumer인 Doc List Read Model projection이 wakeup 순서 역전, 중복 이벤트, 삭제 이후 오래된 이벤트를 견딜 수 있게 만든다. + +**Architecture:** Wakeup은 특정 outbox row 처리 명령이 아니라 relay를 깨우는 신호로만 사용한다. Relay는 OPEN 이벤트를 생성 순서대로 처리하고, 현재 consumer인 `DocListReadModel`은 필드별 projection marker와 삭제 terminal 정책으로 out-of-order 이벤트를 방어한다. 추후 다른 consumer가 추가될 수 있으므로 outbox 저장 구조 자체는 Projection 전용으로 축소하지 않는다. + +**Tech Stack:** Spring Boot, Spring Data JPA, Spring Data MongoDB, Domain Event Outbox, JUnit 5, Mockito, AssertJ + +--- + +## 파일 구조 + +- Modify: `src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxWakeUpListener.java` + - wakeup 수신 시 `relay.run()`을 호출한다. +- Modify: `src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelay.java` + - `run(Long outboxId)`를 `run()` 위임 메서드로 낮추고 deprecated 처리한다. +- Modify: `src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java` + - 필드별 projection marker를 추가한다. + - 삭제 terminal guard와 `updatedAt` 단조 증가 규칙을 추가한다. +- Modify: `src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorUnitTest.java` + - 역순 이벤트, 같은 필드 오래된 이벤트, 삭제 terminal, `updatedAt` 단조 증가 테스트를 추가한다. + - 기존 `lastProjectedEventId` 중심 assertion을 새 marker 정책에 맞게 수정한다. +- Modify: `src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java` + - `run(outboxId)` 호출이 특정 row만 처리하지 않고 OPEN 이벤트를 생성 순서대로 처리하는지 검증한다. +- Modify: `src/test/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelayIntegrationTest.java` + - 필요하면 relay public behavior 테스트를 `run()` 중심으로 정리한다. + +## Task 1: Wakeup 정책 실패 테스트 작성 + +**Files:** +- Modify: `src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java` + +- [ ] **Step 1: 통합 테스트 추가** + +`relayProjector_success_retryAfterReadModelCreated()`와 별도로 다음 테스트를 추가한다. 이 테스트는 현재 코드에서 실패해야 한다. 현재 `run(titleOutbox.getId())`가 title outbox만 먼저 처리해 retry가 발생하기 때문이다. + +```java +@Test +@DisplayName("run(outboxId)는 특정 row만 처리하지 않고 OPEN 이벤트를 생성 순서대로 처리한다") +void relayProjector_processOpenEventsInCreatedOrderWhenWakeUpWithSpecificId() 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 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()).isEqualTo(OutboxStatus.DONE); + assertThat(doneTitle.getRetryCount()).isEqualTo(0); + assertThat(readModel.getTitle()).isEqualTo("변경 제목"); + assertThat(readModel.getUpdatedAt()).isEqualTo(titleUpdatedAt); +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: + +```bash +bash ./gradlew test --tests io.ejangs.docsa.domain.doc.readmodel.app.DocListProjectorIntegrationTest +``` + +Expected: + +- 새 테스트가 실패한다. +- 실패 이유는 title event가 먼저 처리되어 read model missing retry가 발생하거나, title outbox가 바로 DONE이 되지 않는 형태여야 한다. + +## Task 2: Wakeup을 relay 깨우기 신호로 변경 + +**Files:** +- Modify: `src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxWakeUpListener.java` +- Modify: `src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelay.java` + +- [ ] **Step 1: Listener 수정** + +`DomainEventOutboxWakeUpListener.handle()`을 다음처럼 바꾼다. + +```java +@Async(AsyncConfig.OUTBOX_WAKE_UP_EXECUTOR) +@TransactionalEventListener( + phase = TransactionPhase.AFTER_COMMIT +) +public void handle(DomainEventOutboxWakeUpEvent event) { + relay.run(); +} +``` + +- [ ] **Step 2: `run(Long outboxId)` 호환 wrapper로 변경** + +`DomainEventOutboxRelay.run(Long outboxId)`를 다음처럼 바꾼다. + +```java +@Deprecated +public void run(Long outboxId) { + run(); +} +``` + +이 메서드는 기존 테스트나 호출부 호환을 위해 남긴다. 실제 의미는 특정 row 처리 명령이 아니라 relay 실행 요청이다. + +- [ ] **Step 3: 통합 테스트 통과 확인** + +Run: + +```bash +bash ./gradlew test --tests io.ejangs.docsa.domain.doc.readmodel.app.DocListProjectorIntegrationTest +``` + +Expected: + +- Task 1에서 추가한 테스트가 통과한다. +- 기존 `relayProjector_success_retryAfterReadModelCreated()`는 기대가 달라질 수 있으므로 다음 Task에서 새 정책에 맞게 정리한다. + +## Task 3: Projection marker 실패 테스트 작성 + +**Files:** +- Modify: `src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorUnitTest.java` + +- [ ] **Step 1: 독립 필드의 오래된 이벤트 허용 테스트 추가** + +```java +@Test +@DisplayName("다른 필드의 오래된 이벤트는 누락된 projection이면 반영한다") +void project_applyOlderActivityEventWhenActivityFieldWasNotProjected() throws Exception { + DocListReadModel model = existingModel(1L); + DocThumbnailChangedPayload thumbnailPayload = + new DocThumbnailChangedPayload(docId, "thumbnail-2", ThumbnailStatus.READY); + DocActivityChangedPayload activityPayload = + new DocActivityChangedPayload(docId, 20L, LocalDateTime.of(2026, 1, 4, 10, 0)); + + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + docListProjector.project(message(10L, DomainEventType.DOC_THUMBNAIL_CHANGED, thumbnailPayload)); + + docListProjector.project(message(9L, DomainEventType.DOC_ACTIVITY_CHANGED, activityPayload)); + + assertThat(model.getThumbnailObjectKey()).isEqualTo("thumbnail-2"); + assertThat(model.getRecentSaveId()).isEqualTo(20L); + assertThat(model.getUpdatedAt()).isEqualTo(LocalDateTime.of(2026, 1, 4, 10, 0)); +} +``` + +- [ ] **Step 2: 같은 필드의 오래된 이벤트 무시 테스트 추가** + +```java +@Test +@DisplayName("같은 activity 필드의 오래된 이벤트는 recentSaveId를 되돌리지 않는다") +void project_ignoreOlderActivityEventWhenActivityFieldAlreadyProjected() throws Exception { + DocListReadModel model = existingModel(1L); + DocActivityChangedPayload latestPayload = + new DocActivityChangedPayload(docId, 30L, LocalDateTime.of(2026, 1, 5, 10, 0)); + DocActivityChangedPayload olderPayload = + new DocActivityChangedPayload(docId, 20L, LocalDateTime.of(2026, 1, 4, 10, 0)); + + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + docListProjector.project(message(12L, DomainEventType.DOC_ACTIVITY_CHANGED, latestPayload)); + docListProjector.project(message(11L, DomainEventType.DOC_ACTIVITY_CHANGED, olderPayload)); + + assertThat(model.getRecentSaveId()).isEqualTo(30L); + assertThat(model.getUpdatedAt()).isEqualTo(LocalDateTime.of(2026, 1, 5, 10, 0)); +} +``` + +- [ ] **Step 3: `updatedAt` 단조 증가 테스트 추가** + +```java +@Test +@DisplayName("오래된 독립 이벤트가 나중에 반영되어도 updatedAt은 과거로 되돌아가지 않는다") +void project_keepUpdatedAtMonotonicWhenOlderIndependentEventArrives() throws Exception { + DocListReadModel model = existingModel(1L); + DocTitleChangedPayload titlePayload = + new DocTitleChangedPayload(docId, "변경 제목", LocalDateTime.of(2026, 1, 5, 10, 0)); + DocActivityChangedPayload activityPayload = + new DocActivityChangedPayload(docId, 20L, LocalDateTime.of(2026, 1, 4, 10, 0)); + + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + docListProjector.project(message(12L, DomainEventType.DOC_TITLE_CHANGED, titlePayload)); + docListProjector.project(message(11L, DomainEventType.DOC_ACTIVITY_CHANGED, activityPayload)); + + assertThat(model.getTitle()).isEqualTo("변경 제목"); + assertThat(model.getRecentSaveId()).isEqualTo(20L); + assertThat(model.getUpdatedAt()).isEqualTo(LocalDateTime.of(2026, 1, 5, 10, 0)); +} +``` + +- [ ] **Step 4: 삭제 terminal 테스트 추가** + +```java +@Test +@DisplayName("삭제 이후 오래된 title 이벤트는 문서를 되살리지 않는다") +void project_ignoreOlderTitleEventAfterDelete() throws Exception { + DocListReadModel model = existingModel(1L); + DocDeletedPayload deletedPayload = new DocDeletedPayload(docId); + DocTitleChangedPayload titlePayload = + new DocTitleChangedPayload(docId, "삭제 전 변경 제목", LocalDateTime.of(2026, 1, 4, 10, 0)); + + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + docListProjector.project(message(20L, DomainEventType.DOC_DELETED, deletedPayload)); + docListProjector.project(message(19L, DomainEventType.DOC_TITLE_CHANGED, titlePayload)); + + assertThat(model.isDeleted()).isTrue(); + assertThat(model.getTitle()).isEqualTo("초기 제목"); +} +``` + +- [ ] **Step 5: 실패 확인** + +Run: + +```bash +bash ./gradlew test --tests io.ejangs.docsa.domain.doc.readmodel.app.DocListProjectorUnitTest +``` + +Expected: + +- Step 1 테스트는 현재 단일 `lastProjectedEventId` 때문에 activity가 무시되어 실패한다. +- Step 3 테스트는 `updatedAt`이 과거로 돌아가거나 activity가 무시되어 실패한다. +- Step 4 테스트는 현재 `changeTitle()`이 `deleted=false`를 세팅하므로 실패한다. + +## Task 4: `DocListReadModel` projection 정책 구현 + +**Files:** +- Modify: `src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java` + +- [ ] **Step 1: 필드별 marker 추가** + +`lastProjectedEventId` 아래에 다음 필드를 추가한다. + +```java +private Long titleProjectedEventId; +private Long activityProjectedEventId; +private Long thumbnailProjectedEventId; +private Long deletedEventId; +``` + +- [ ] **Step 2: 생성 projection marker 초기화** + +`create()`에서 다음 값을 설정한다. + +```java +model.lastProjectedEventId = eventId; +model.titleProjectedEventId = eventId; +model.activityProjectedEventId = eventId; +model.thumbnailProjectedEventId = eventId; +model.deletedEventId = null; +``` + +- [ ] **Step 3: backfill marker는 null 유지** + +`backfill()`에서는 marker를 null로 둔다. 백필된 문서는 이후 들어오는 이벤트가 각 필드별로 처음 처리될 수 있어야 한다. + +- [ ] **Step 4: 공통 helper 추가** + +클래스 하단에 다음 helper를 추가한다. + +```java +private boolean isDeletedTerminal() { + return this.deleted; +} + +private boolean isAlreadyProjected(Long projectedEventId, Long eventId) { + return projectedEventId != null && projectedEventId >= eventId; +} + +private void touchLastProjectedEventId(Long eventId) { + if (this.lastProjectedEventId == null || this.lastProjectedEventId < eventId) { + this.lastProjectedEventId = eventId; + } +} + +private LocalDateTime maxUpdatedAt(LocalDateTime nextUpdatedAt) { + if (this.updatedAt == null) { + return nextUpdatedAt; + } + if (nextUpdatedAt == null) { + return this.updatedAt; + } + return this.updatedAt.isAfter(nextUpdatedAt) ? this.updatedAt : nextUpdatedAt; +} +``` + +- [ ] **Step 5: `changeTitle()` 수정** + +```java +public boolean changeTitle(DocTitleChangedPayload payload, Long eventId) { + if (isDeletedTerminal() || isAlreadyProjected(this.titleProjectedEventId, eventId)) { + return false; + } + + this.title = payload.title(); + this.updatedAt = maxUpdatedAt(payload.updatedAt()); + this.titleProjectedEventId = eventId; + touchLastProjectedEventId(eventId); + return true; +} +``` + +- [ ] **Step 6: `changeActivity()` 수정** + +```java +public boolean changeActivity(DocActivityChangedPayload payload, Long eventId) { + if (isDeletedTerminal() || isAlreadyProjected(this.activityProjectedEventId, eventId)) { + return false; + } + + this.recentSaveId = payload.recentSaveId(); + this.updatedAt = maxUpdatedAt(payload.updatedAt()); + this.activityProjectedEventId = eventId; + touchLastProjectedEventId(eventId); + return true; +} +``` + +- [ ] **Step 7: `changeThumbnail()` 수정** + +```java +public boolean changeThumbnail(DocThumbnailChangedPayload payload, Long eventId) { + if (isDeletedTerminal() || isAlreadyProjected(this.thumbnailProjectedEventId, eventId)) { + return false; + } + + this.thumbnailObjectKey = payload.thumbnailObjectKey(); + this.thumbnailStatus = payload.thumbnailStatus(); + this.thumbnailProjectedEventId = eventId; + touchLastProjectedEventId(eventId); + return true; +} +``` + +- [ ] **Step 8: `markDeleted()` 수정** + +```java +public boolean markDeleted(Long eventId) { + if (isAlreadyProjected(this.deletedEventId, eventId)) { + return false; + } + + this.deleted = true; + this.deletedEventId = eventId; + touchLastProjectedEventId(eventId); + return true; +} +``` + +- [ ] **Step 9: 기존 `isAlreadyProjected(Long eventId)` 제거** + +단일 `lastProjectedEventId`만 보는 기존 helper는 삭제한다. + +- [ ] **Step 10: 단위 테스트 통과 확인** + +Run: + +```bash +bash ./gradlew test --tests io.ejangs.docsa.domain.doc.readmodel.app.DocListProjectorUnitTest +``` + +Expected: + +- `DocListProjectorUnitTest` 전체 통과 + +## Task 5: 기존 테스트를 새 정책에 맞게 정리 + +**Files:** +- Modify: `src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorUnitTest.java` +- Modify: `src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java` + +- [ ] **Step 1: `lastProjectedEventId` 단일 guard 테스트 수정** + +기존 테스트명: + +```java +@DisplayName("이미 처리한 eventId 이하의 이벤트는 무시한다") +``` + +이 테스트는 삭제 이벤트 또는 같은 필드 이벤트 기준으로 바꾼다. 예를 들어 삭제 이벤트 중복 방지 테스트로 수정한다. + +```java +@Test +@DisplayName("이미 처리한 삭제 eventId 이하의 삭제 이벤트는 무시한다") +void project_ignore_alreadyProjectedDeleteEvent() throws Exception { + DocListReadModel model = existingModel(1L); + DocDeletedPayload payload = new DocDeletedPayload(docId); + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + + docListProjector.project(message(10L, DomainEventType.DOC_DELETED, payload)); + docListProjector.project(message(9L, DomainEventType.DOC_DELETED, payload)); + + verify(docListReadModelRepository).save(model); + assertThat(model.isDeleted()).isTrue(); +} +``` + +- [ ] **Step 2: `relayProjector_success_retryAfterReadModelCreated()` 정책 변경** + +`run(Long outboxId)`가 `run()`으로 위임되면 title event가 먼저 retry되지 않는다. 기존 테스트는 다음 둘 중 하나로 정리한다. + +추천: 기존 테스트를 삭제하지 말고, “수동으로 title을 먼저 claim해야 retry된다”는 테스트로 바꾸지 않는다. 새 wakeup 정책에서는 그 시나리오를 public behavior로 유지할 필요가 약하다. 대신 Task 1의 테스트가 새 정책을 대표하게 둔다. + +- [ ] **Step 3: 통합 테스트 통과 확인** + +Run: + +```bash +bash ./gradlew test --tests io.ejangs.docsa.domain.doc.readmodel.app.DocListProjectorIntegrationTest +``` + +Expected: + +- `DocListProjectorIntegrationTest` 전체 통과 + +## Task 6: 문서와 백필 경계 정리 + +**Files:** +- Modify: `docs/superpowers/specs/2026-06-06-read-model-projection-ordering-design.md` +- Optionally Modify: `ai/ai-assisted-development-workflow.md` + +- [ ] **Step 1: 설계 문서에 수행 결과 갱신** + +구현 후 설계 문서 하단에 다음 내용을 추가한다. + +```markdown +## 구현 결과 + +- Wakeup은 특정 outbox row 처리 명령이 아니라 relay 실행 신호로 변경했다. +- `DocListReadModel`은 필드별 projection marker로 중복과 역순 이벤트를 방어한다. +- 삭제 이벤트는 terminal event로 처리한다. +- `updatedAt`은 과거로 되돌아가지 않도록 max 정책을 적용했다. +- 백필은 일반 런타임 기능이 아니라 운영 복구용 기능으로 유지한다. +``` + +- [ ] **Step 2: 필요하면 AI workflow 문서에는 기록만 남긴다** + +이 변경은 이력서/포트폴리오에 “AI 활용” 증거로 사용할 수 있다. 단, 원본 프롬프트나 비밀 정보는 커밋하지 않는다. + +## Task 7: 전체 검증 + +**Files:** +- No production file edits in this task. + +- [ ] **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 --tests io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxRelayIntegrationTest +``` + +Expected: + +- 세 테스트 클래스 모두 통과 + +- [ ] **Step 2: 컴파일 검증** + +Run: + +```bash +bash ./gradlew compileJava compileTestJava +``` + +Expected: + +- main/test 컴파일 성공 + +- [ ] **Step 3: 변경 파일 확인** + +Run: + +```bash +git status --short +``` + +Expected: + +- 코드 변경 파일과 문서 파일만 표시된다. +- AI는 commit과 push를 하지 않는다. + +## Self-review + +- Spec coverage: wakeup 의미 변경, projection marker 분리, 삭제 terminal, `updatedAt` 단조 증가, 백필 유지 정책을 모두 task로 연결했다. +- Placeholder scan: `TBD`, `TODO`, `나중에 구현` 같은 빈 항목은 없다. +- Type consistency: 현재 코드의 class, method, record 이름을 기준으로 작성했다. +- Scope check: broker, CDC, aggregate version, consumer checkpoint는 이번 범위에서 제외했다. + +## 진행 결과 + +- Task 1 완료: wakeup이 특정 row를 직접 처리하는 기존 문제를 실패 테스트로 재현했다. +- Task 2 완료: wakeup을 relay 실행 신호로 변경했고 통합 테스트를 통과시켰다. +- Task 3 완료: projector의 단일 `lastProjectedEventId` 문제가 독립 필드 이벤트를 누락시키는 실패 테스트를 추가했다. +- Task 4 완료: `DocListReadModel`에 필드별 projection marker, 삭제 terminal guard, `updatedAt` 단조 증가 정책을 구현했다. marker가 없는 기존 Mongo 문서는 `lastProjectedEventId`를 기준선으로 사용하는 호환 정책도 추가했다. +- Task 5 완료: 기존 테스트를 새 projection 정책에 맞게 정리했다. 삭제 이후 title, activity, thumbnail 이벤트가 모두 문서를 되살리지 못하는 테스트도 추가했다. +- Task 6 완료: 설계 문서에 구현 결과와 검증 결과를 기록했다. +- Task 7 완료: 핵심 단위 테스트, 통합 테스트, main/test 컴파일 검증을 수행했다. +- 리뷰 반영 완료: Domain Event Outbox 조회 순서를 `createdAt ASC, id ASC`로 변경하고, `createdAt` 동률 시 id 순서 dispatch 테스트를 추가했다. +- 리뷰 반영 완료: 삭제 marker가 없는 경우 다른 필드보다 오래된 삭제 이벤트라도 terminal event로 반영되도록 테스트와 구현을 보강했다. + +검증 명령: + +```bash +bash ./gradlew test --tests io.ejangs.docsa.domain.doc.readmodel.app.DocListProjectorUnitTest +bash ./gradlew test --tests io.ejangs.docsa.domain.doc.readmodel.app.DocListProjectorIntegrationTest --tests io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxRelayIntegrationTest +bash ./gradlew test --tests io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxRelayIntegrationTest +bash ./gradlew compileJava compileTestJava +``` diff --git a/docs/superpowers/specs/2026-06-06-read-model-projection-ordering-design.md b/docs/superpowers/specs/2026-06-06-read-model-projection-ordering-design.md new file mode 100644 index 00000000..edb0fffe --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-read-model-projection-ordering-design.md @@ -0,0 +1,197 @@ +# Read Model Projection 순서 안정화 설계 + +## 문제 + +`domain_event_outbox`는 앞으로 여러 consumer가 구독할 수 있는 범용 Domain Event Outbox로 확장할 수 있는 기반이다. 다만 현재 구현에서 실제 consumer는 `DocListProjector` 하나이며, 첫 번째 사용처는 문서 목록 Read Model 갱신이다. + +따라서 이번 문제는 outbox 자체를 Projection 전용으로 축소하자는 의미가 아니다. 범용 outbox 기반은 유지하되, 첫 consumer인 `DocListProjector`가 이벤트 중복과 역순을 어떻게 처리할지 정책이 부족하다는 점을 보완하는 작업이다. + +- `DomainEventOutboxWakeUpListener`는 특정 `outboxId`를 relay에 전달한다. +- `DomainEventOutboxRelay.run(Long outboxId)`는 전달받은 row 하나를 바로 처리한다. +- `DocListReadModel`은 `lastProjectedEventId` 하나로 모든 이벤트를 중복 처리한다. + +이 조합에서는 같은 문서의 이벤트가 생성 순서와 다르게 처리될 수 있다. 예를 들어 썸네일 이벤트가 먼저 반영되면 `lastProjectedEventId`가 커지고, 그보다 오래된 activity 이벤트가 나중에 도착했을 때 `recentSaveId` 갱신이 버려질 수 있다. + +반대로 오래된 이벤트를 무조건 허용하면 더 큰 문제가 생긴다. 오래된 title/activity 이벤트가 삭제된 문서를 다시 `deleted=false`로 되살리거나, `updatedAt`을 과거로 되돌릴 수 있다. + +## 목표 + +이번 변경의 목표는 이벤트 시스템 전체를 새로 설계하는 것이 아니다. 현재 프로젝트 규모에 맞게 Doc List Read Model projection을 안전하게 만든다. + +- Wakeup은 정확성 보장이 아니라 처리 지연 감소용 신호로 제한한다. +- Relay는 특정 outbox row를 건너뛰어 처리하지 않고 OPEN 이벤트를 생성 순서대로 처리한다. +- Projector는 중복 이벤트와 역순 이벤트를 견딘다. +- 삭제 이벤트는 terminal event로 취급한다. +- `updatedAt`은 과거로 되돌아가지 않게 한다. +- 백필은 제거하지 않고 운영 복구용으로 유지한다. + +## 비목표 + +- Kafka, RabbitMQ, SQS 같은 message broker를 도입하지 않는다. +- Debezium 같은 CDC 구조로 전환하지 않는다. +- `Doc` aggregate version을 새로 도입하지 않는다. +- consumer별 checkpoint 테이블을 새로 만들지 않는다. +- 문서 목록 API 응답 계약을 바꾸지 않는다. +- 기존 백필 기능을 삭제하지 않는다. + +## 선택한 설계 + +### 1. Wakeup 의미 변경 + +현재 의미: + +```text +이 outboxId를 지금 처리한다. +``` + +변경 후 의미: + +```text +새 이벤트가 생겼으니 worker를 깨워 OPEN 이벤트를 순서대로 처리한다. +``` + +`DomainEventOutboxWakeUpListener`는 `relay.run(event.outboxId())` 대신 `relay.run()`을 호출한다. `DomainEventOutboxRelay.run(Long outboxId)`는 호환성을 위해 남기되, 내부에서 `run()`으로 위임하고 deprecated 처리한다. + +이렇게 하면 wakeup은 latency optimization으로 남고, 실제 처리 순서는 `createdAt ASC, id ASC`로 OPEN 이벤트를 조회하는 relay에 맡긴다. `createdAt`이 같은 row는 DB 반환 순서에 기대지 않고 id를 tie-breaker로 사용한다. + +### 2. Projection marker 분리 + +`DocListReadModel`의 단일 `lastProjectedEventId`는 projection 판단 기준으로 부적합하다. 이벤트가 서로 다른 필드를 갱신하기 때문이다. + +필드 그룹별 projection marker를 둔다. + +```java +private Long titleProjectedEventId; +private Long activityProjectedEventId; +private Long thumbnailProjectedEventId; +private Long deletedEventId; +``` + +`lastProjectedEventId`는 기존 Mongo 문서 호환성과 관찰용으로 유지할 수 있지만, projection 적용 여부의 핵심 판단은 필드별 marker가 담당한다. + +기존 Mongo Read Model 문서에는 새 marker 필드가 없을 수 있다. 이 경우 marker가 null이고 `lastProjectedEventId`가 존재하면 legacy 문서로 보고 `lastProjectedEventId`를 해당 필드의 기준선으로 사용한다. 반면 backfill로 새로 만든 문서는 `lastProjectedEventId`도 null이므로 이후 이벤트를 필드별로 받을 수 있다. + +### 3. 이벤트 적용 규칙 + +`DOC_CREATED` + +- Read Model이 이미 있으면 멱등하게 무시한다. +- 새 Read Model 생성 시 title, activity, thumbnail marker를 생성 이벤트 id로 초기화한다. +- 삭제 marker는 null로 둔다. + +`DOC_TITLE_CHANGED` + +- 문서가 삭제 상태면 무시한다. +- `titleProjectedEventId`보다 최신 이벤트만 title을 갱신한다. +- `updatedAt`은 현재 값과 payload 값 중 더 최신 값으로 유지한다. + +`DOC_ACTIVITY_CHANGED` + +- 문서가 삭제 상태면 무시한다. +- `activityProjectedEventId`보다 최신 이벤트만 `recentSaveId`를 갱신한다. +- `updatedAt`은 현재 값과 payload 값 중 더 최신 값으로 유지한다. + +`DOC_THUMBNAIL_CHANGED` + +- 문서가 삭제 상태면 무시한다. +- `thumbnailProjectedEventId`보다 최신 이벤트만 썸네일 필드를 갱신한다. + +`DOC_DELETED` + +- `deletedEventId`보다 최신 이벤트면 `deleted=true`로 변경한다. +- 삭제 marker가 아직 없다면 다른 필드 marker보다 오래된 삭제 이벤트라도 terminal event로 반영한다. +- 삭제 이후 title, activity, thumbnail 이벤트는 문서를 되살릴 수 없다. + +핵심 정책은 다음이다. + +```text +같은 필드의 오래된 이벤트는 막는다. +다른 필드의 누락 이벤트는 제한적으로 허용한다. +삭제 이후 이벤트는 문서를 되살리지 못한다. +updatedAt은 단조 증가한다. +``` + +## 검토한 대안 + +### Wakeup 제거 + +스케줄러만 사용하면 순서 문제는 줄지만, Read Model 반영 지연이 커진다. 사용자 요청 직후 목록이 오래 stale하게 보일 수 있다. 현재 프로젝트에서는 wakeup을 제거하기보다 의미를 낮추는 쪽이 낫다. + +### Aggregate version 도입 + +정석적으로는 `Doc` aggregate version을 두고 이벤트에 `aggregateVersion`을 저장하는 방법이 더 좋다. 그러나 이 방식은 테이블 변경, 이벤트 저장 구조 변경, aggregate별 순서 처리 정책까지 필요하다. 현재 P1 범위에는 과하다. + +### Consumer checkpoint 분리 + +여러 consumer가 생긴다면 `consumer_name`, `last_processed_event_id` 같은 checkpoint 저장소가 필요하다. 하지만 현재 consumer는 `DocListProjector` 하나다. 지금 단계에서는 YAGNI다. + +## 테스트 전략 + +단위 테스트는 `DocListProjectorUnitTest`를 중심으로 작성한다. + +필수 테스트: + +- 썸네일 이벤트가 먼저 반영된 뒤 더 오래된 activity 이벤트가 도착해도 `recentSaveId`는 갱신된다. +- 같은 activity 필드의 오래된 이벤트는 `recentSaveId`와 `updatedAt`을 되돌리지 않는다. +- title 이벤트 이후 더 오래된 activity 이벤트가 도착해도 `updatedAt`은 과거로 되돌아가지 않는다. +- 삭제 이벤트가 반영된 뒤 더 오래된 title/activity/thumbnail 이벤트는 문서를 되살리지 않는다. +- `DOC_CREATED`는 새 marker를 초기화한다. + +통합 테스트는 `DocListProjectorIntegrationTest` 또는 `DomainEventOutboxRelayIntegrationTest`에서 확인한다. + +- `run(outboxId)`를 호출해도 특정 row만 처리하지 않고 OPEN 이벤트를 생성 순서대로 처리한다. +- 생성 이벤트와 변경 이벤트가 OPEN 상태로 같이 있을 때, 변경 이벤트 id로 wakeup되어도 생성 이벤트가 먼저 처리된다. + +## 백필 정책 + +백필은 이미 수행됐더라도 삭제하지 않는다. 백필은 일반 런타임 기능이 아니라 운영 복구용 기능이다. + +사용 목적: + +- Read Model 컬렉션 재생성 +- projection 버그 수정 후 재구축 +- Mongo 데이터 손상 복구 + +추후 문서에는 다음처럼 명시한다. + +```text +Doc List Read Model backfill은 일반 요청 흐름에서 사용하지 않는다. +운영 복구 또는 projection 재생성 상황에서 전용 프로필로 실행한다. +``` + +## 리스크 + +이번 설계는 aggregate별 완전한 순서 보장을 제공하지 않는다. 여러 서버가 동시에 relay를 실행하면 여전히 완벽한 per-aggregate ordering은 보장되지 않는다. 대신 projector가 중복과 역순을 견디도록 만들어 현재 구조에서 발생 가능한 손상을 줄인다. + +진짜 범용 이벤트 아키텍처가 필요해지는 시점에는 aggregate version, consumer checkpoint, broker 또는 CDC 도입을 별도 설계해야 한다. + +## 완료 기준 + +- Wakeup은 특정 outboxId 직접 처리 명령이 아니게 된다. +- Relay는 OPEN 이벤트를 생성 순서대로 처리한다. +- Projector는 필드별 멱등성을 가진다. +- 삭제 이후 오래된 이벤트가 문서를 되살리지 못한다. +- `updatedAt`은 과거로 되돌아가지 않는다. +- 테스트가 현재 문제가 실제로 재현되고 수정 후 통과함을 보여준다. +- AI는 commit과 push를 수행하지 않는다. + +## 구현 결과 + +- `DomainEventOutboxWakeUpListener`는 특정 outbox row를 처리하지 않고 relay 실행만 요청하도록 변경했다. +- `DomainEventOutboxRelay.run(Long outboxId)`는 호환성을 위해 남기되 `run()`으로 위임하고 deprecated 처리했다. +- Domain Event Outbox 조회 순서는 `createdAt ASC, id ASC`로 변경해 `createdAt` 동률에서도 결정적인 처리 순서를 갖도록 했다. +- `DocListReadModel`은 title, activity, thumbnail, deleted 필드 그룹별 projection marker를 갖도록 변경했다. +- marker가 없는 기존 Mongo 문서는 `lastProjectedEventId`를 기준선으로 사용해 오래된 이벤트가 기존 값을 덮어쓰지 않도록 했다. +- 삭제 이벤트는 terminal event이므로 삭제 marker가 없는 경우 `lastProjectedEventId` fallback을 사용하지 않도록 분리했다. +- title, activity, thumbnail 변경 이벤트는 삭제된 문서를 되살리지 못하도록 terminal guard를 적용했다. +- title/activity 이벤트의 `updatedAt`은 현재 값과 payload 값 중 더 최신 값을 유지하도록 변경했다. +- `DOC_CREATED`는 새 Read Model 생성 시 필드별 marker를 생성 이벤트 id로 초기화한다. +- backfill로 생성한 Read Model은 marker를 null로 유지해 운영 복구 후 이벤트를 받을 수 있게 둔다. +- 백필 기능은 삭제하지 않고 운영 복구용 기능으로 유지한다. + +## 검증 결과 + +- `bash ./gradlew test --tests io.ejangs.docsa.domain.doc.readmodel.app.DocListProjectorUnitTest` +- `bash ./gradlew test --tests io.ejangs.docsa.domain.doc.readmodel.app.DocListProjectorIntegrationTest --tests io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxRelayIntegrationTest` +- `bash ./gradlew test --tests io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxRelayIntegrationTest` +- `bash ./gradlew compileJava compileTestJava` diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java index e9c77a33..b24a6632 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java @@ -45,6 +45,10 @@ public class DocListReadModel { private boolean deleted; private Long lastProjectedEventId; + private Long titleProjectedEventId; + private Long activityProjectedEventId; + private Long thumbnailProjectedEventId; + private Long deletedEventId; public static DocListReadModel create(DocCreatedPayload payload, Long eventId) { DocListReadModel model = new DocListReadModel(); @@ -58,6 +62,10 @@ public static DocListReadModel create(DocCreatedPayload payload, Long eventId) { model.thumbnailStatus = payload.thumbnailStatus(); model.deleted = false; model.lastProjectedEventId = eventId; + model.titleProjectedEventId = eventId; + model.activityProjectedEventId = eventId; + model.thumbnailProjectedEventId = eventId; + model.deletedEventId = null; return model; } @@ -86,51 +94,79 @@ public static DocListReadModel backfill( } public boolean changeTitle(DocTitleChangedPayload payload, Long eventId) { - if (isAlreadyProjected(eventId)) { + if (isDeletedTerminal() || isAlreadyProjected(this.titleProjectedEventId, eventId)) { return false; } this.title = payload.title(); - this.updatedAt = payload.updatedAt(); - this.deleted = false; - this.lastProjectedEventId = eventId; + this.updatedAt = maxUpdatedAt(payload.updatedAt()); + this.titleProjectedEventId = eventId; + touchLastProjectedEventId(eventId); return true; } public boolean changeActivity(DocActivityChangedPayload payload, Long eventId) { - if (isAlreadyProjected(eventId)) { + if (isDeletedTerminal() || isAlreadyProjected(this.activityProjectedEventId, eventId)) { return false; } this.recentSaveId = payload.recentSaveId(); - this.updatedAt = payload.updatedAt(); - this.deleted = false; - this.lastProjectedEventId = eventId; + this.updatedAt = maxUpdatedAt(payload.updatedAt()); + this.activityProjectedEventId = eventId; + touchLastProjectedEventId(eventId); return true; } public boolean changeThumbnail(DocThumbnailChangedPayload payload, Long eventId) { - if (isAlreadyProjected(eventId)) { + if (isDeletedTerminal() || isAlreadyProjected(this.thumbnailProjectedEventId, eventId)) { return false; } this.thumbnailObjectKey = payload.thumbnailObjectKey(); this.thumbnailStatus = payload.thumbnailStatus(); - this.lastProjectedEventId = eventId; + this.thumbnailProjectedEventId = eventId; + touchLastProjectedEventId(eventId); return true; } public boolean markDeleted(Long eventId) { - if (isAlreadyProjected(eventId)) { + if (isAlreadyProjectedWithoutFallback(this.deletedEventId, eventId)) { return false; } this.deleted = true; - this.lastProjectedEventId = eventId; + this.deletedEventId = eventId; + touchLastProjectedEventId(eventId); return true; } - private boolean isAlreadyProjected(Long eventId) { - return this.lastProjectedEventId != null && this.lastProjectedEventId >= eventId; + private boolean isDeletedTerminal() { + return this.deleted; + } + + private boolean isAlreadyProjected(Long projectedEventId, Long eventId) { + Long effectiveProjectedEventId = + projectedEventId != null ? projectedEventId : this.lastProjectedEventId; + return effectiveProjectedEventId != null && effectiveProjectedEventId >= eventId; + } + + private boolean isAlreadyProjectedWithoutFallback(Long projectedEventId, Long eventId) { + return projectedEventId != null && projectedEventId >= eventId; + } + + private void touchLastProjectedEventId(Long eventId) { + if (this.lastProjectedEventId == null || this.lastProjectedEventId < eventId) { + this.lastProjectedEventId = eventId; + } + } + + private LocalDateTime maxUpdatedAt(LocalDateTime nextUpdatedAt) { + if (this.updatedAt == null) { + return nextUpdatedAt; + } + if (nextUpdatedAt == null) { + return this.updatedAt; + } + return this.updatedAt.isAfter(nextUpdatedAt) ? this.updatedAt : nextUpdatedAt; } } diff --git a/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelay.java b/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelay.java index 121d7184..b86da4da 100644 --- a/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelay.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelay.java @@ -52,23 +52,16 @@ public void run() { } } + @Deprecated public void run(Long outboxId) { - if (!running.compareAndSet(false, true)) { - return; - } - - try { - processOne(outboxId); - } finally { - running.set(false); - } + run(); } private void doRun() { recoverTimedOutProcessing(); List events = - repository.findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus.OPEN); + repository.findTop100ByStatusOrderByCreatedAtAscIdAsc(OutboxStatus.OPEN); for (DomainEventOutbox event : events) { processOne(event.getId()); diff --git a/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxWakeUpListener.java b/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxWakeUpListener.java index 8d504dc5..db09f456 100644 --- a/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxWakeUpListener.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxWakeUpListener.java @@ -19,6 +19,6 @@ public class DomainEventOutboxWakeUpListener { phase = TransactionPhase.AFTER_COMMIT ) public void handle(DomainEventOutboxWakeUpEvent event) { - relay.run(event.outboxId()); + relay.run(); } } diff --git a/src/main/java/io/ejangs/docsa/global/outbox/event/dao/DomainEventOutboxRepository.java b/src/main/java/io/ejangs/docsa/global/outbox/event/dao/DomainEventOutboxRepository.java index 1685933d..464af360 100644 --- a/src/main/java/io/ejangs/docsa/global/outbox/event/dao/DomainEventOutboxRepository.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/event/dao/DomainEventOutboxRepository.java @@ -12,7 +12,7 @@ public interface DomainEventOutboxRepository extends JpaRepository { - List findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus status); + List findTop100ByStatusOrderByCreatedAtAscIdAsc(OutboxStatus status); List findTop100ByStatusAndUpdatedAtBeforeOrderByUpdatedAtAsc( OutboxStatus status, @@ -31,4 +31,4 @@ List findTop100ByStatusAndUpdatedAtBeforeOrderByUpdatedAtAsc( and status = 'OPEN' """, nativeQuery = true) int claimOpenById(@Param("outboxId") Long outboxId); -} \ No newline at end of file +} 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 19977205..acbddcfd 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 @@ -139,8 +139,9 @@ void relayProjector_retry_whenReadModelMissingForUpdateEvent() throws Exception } @Test - @DisplayName("먼저 실패한 변경 이벤트는 read model 생성 후 재시도되어 반영된다") - void relayProjector_success_retryAfterReadModelCreated() throws Exception { + @SuppressWarnings("deprecation") + @DisplayName("변경 이벤트 id로 relay를 깨워도 생성 이벤트부터 순서대로 처리된다") + void relayProjector_success_processCreatedEventBeforeChangeEventWhenWakeUpWithChangeEventId() 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); @@ -185,17 +186,68 @@ void relayProjector_success_retryAfterReadModelCreated() throws Exception { 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"); + 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(0); + assertThat(doneTitle.getLastError()).isNull(); + assertThat(readModel.getTitle()).isEqualTo("변경 제목"); + assertThat(readModel.getUpdatedAt()).isEqualTo(titleUpdatedAt); + assertThat(readModel.getLastProjectedEventId()).isEqualTo(titleOutbox.getId()); + } + + @Test + @SuppressWarnings("deprecation") + @DisplayName("run(outboxId)는 특정 row만 처리하지 않고 OPEN 이벤트를 생성 순서대로 처리한다") + void relayProjector_processOpenEventsInCreatedOrderWhenWakeUpWithSpecificId() 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(createdOutbox.getId()); domainEventOutboxRelay.run(titleOutbox.getId()); DomainEventOutbox doneCreated = domainEventOutboxRepository.findById(createdOutbox.getId()).orElseThrow(); @@ -203,13 +255,9 @@ void relayProjector_success_retryAfterReadModelCreated() throws Exception { 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(doneTitle.getStatus()).isEqualTo(OutboxStatus.DONE); + assertThat(doneTitle.getRetryCount()).isEqualTo(0); 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 2aa66033..9f969c29 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 @@ -4,6 +4,7 @@ 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.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -223,19 +224,146 @@ void project_fail_docDeletedWhenReadModelMissing() throws Exception { } @Test - @DisplayName("이미 처리한 eventId 이하의 이벤트는 무시한다") - void project_ignore_alreadyProjectedEvent() throws Exception { - DocListReadModel model = existingModel(10L); + @DisplayName("이미 처리한 삭제 eventId 이하의 삭제 이벤트는 무시한다") + void project_ignore_alreadyProjectedDeleteEvent() throws Exception { + DocListReadModel model = existingModel(1L); DocDeletedPayload payload = new DocDeletedPayload(docId); when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + docListProjector.project(message(10L, DomainEventType.DOC_DELETED, payload)); docListProjector.project(message(9L, DomainEventType.DOC_DELETED, payload)); - verify(docListReadModelRepository, never()).save(any()); - assertThat(model.isDeleted()).isFalse(); + verify(docListReadModelRepository, times(1)).save(model); + assertThat(model.isDeleted()).isTrue(); assertThat(model.getLastProjectedEventId()).isEqualTo(10L); } + @Test + @DisplayName("다른 필드의 오래된 이벤트는 누락된 projection이면 반영한다") + void project_applyOlderActivityEventWhenActivityFieldWasNotProjected() throws Exception { + DocListReadModel model = existingModel(1L); + DocThumbnailChangedPayload thumbnailPayload = + new DocThumbnailChangedPayload(docId, "thumbnail-2", ThumbnailStatus.READY); + DocActivityChangedPayload activityPayload = + new DocActivityChangedPayload(docId, 20L, LocalDateTime.of(2026, 1, 4, 10, 0)); + + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + + docListProjector.project(message(10L, DomainEventType.DOC_THUMBNAIL_CHANGED, thumbnailPayload)); + docListProjector.project(message(9L, DomainEventType.DOC_ACTIVITY_CHANGED, activityPayload)); + + assertThat(model.getThumbnailObjectKey()).isEqualTo("thumbnail-2"); + assertThat(model.getRecentSaveId()).isEqualTo(20L); + assertThat(model.getUpdatedAt()).isEqualTo(LocalDateTime.of(2026, 1, 4, 10, 0)); + } + + @Test + @DisplayName("같은 activity 필드의 오래된 이벤트는 recentSaveId를 되돌리지 않는다") + void project_ignoreOlderActivityEventWhenActivityFieldAlreadyProjected() throws Exception { + DocListReadModel model = existingModel(1L); + DocActivityChangedPayload latestPayload = + new DocActivityChangedPayload(docId, 30L, LocalDateTime.of(2026, 1, 5, 10, 0)); + DocActivityChangedPayload olderPayload = + new DocActivityChangedPayload(docId, 20L, LocalDateTime.of(2026, 1, 4, 10, 0)); + + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + + docListProjector.project(message(12L, DomainEventType.DOC_ACTIVITY_CHANGED, latestPayload)); + docListProjector.project(message(11L, DomainEventType.DOC_ACTIVITY_CHANGED, olderPayload)); + + assertThat(model.getRecentSaveId()).isEqualTo(30L); + assertThat(model.getUpdatedAt()).isEqualTo(LocalDateTime.of(2026, 1, 5, 10, 0)); + } + + @Test + @DisplayName("오래된 독립 이벤트가 나중에 반영되어도 updatedAt은 과거로 되돌아가지 않는다") + void project_keepUpdatedAtMonotonicWhenOlderIndependentEventArrives() throws Exception { + DocListReadModel model = existingModel(1L); + DocTitleChangedPayload titlePayload = + new DocTitleChangedPayload(docId, "변경 제목", LocalDateTime.of(2026, 1, 5, 10, 0)); + DocActivityChangedPayload activityPayload = + new DocActivityChangedPayload(docId, 20L, LocalDateTime.of(2026, 1, 4, 10, 0)); + + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + + docListProjector.project(message(12L, DomainEventType.DOC_TITLE_CHANGED, titlePayload)); + docListProjector.project(message(11L, DomainEventType.DOC_ACTIVITY_CHANGED, activityPayload)); + + assertThat(model.getTitle()).isEqualTo("변경 제목"); + assertThat(model.getRecentSaveId()).isEqualTo(20L); + assertThat(model.getUpdatedAt()).isEqualTo(LocalDateTime.of(2026, 1, 5, 10, 0)); + } + + @Test + @DisplayName("삭제 이후 오래된 title 이벤트는 문서를 되살리지 않는다") + void project_ignoreOlderTitleEventAfterDelete() throws Exception { + DocListReadModel model = existingModel(1L); + DocDeletedPayload deletedPayload = new DocDeletedPayload(docId); + DocTitleChangedPayload titlePayload = + new DocTitleChangedPayload(docId, "삭제 전 변경 제목", LocalDateTime.of(2026, 1, 4, 10, 0)); + + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + + docListProjector.project(message(20L, DomainEventType.DOC_DELETED, deletedPayload)); + docListProjector.project(message(19L, DomainEventType.DOC_TITLE_CHANGED, titlePayload)); + + assertThat(model.isDeleted()).isTrue(); + assertThat(model.getTitle()).isEqualTo("초기 제목"); + } + + @Test + @DisplayName("삭제 이후 오래된 activity 이벤트는 문서를 되살리지 않는다") + void project_ignoreOlderActivityEventAfterDelete() throws Exception { + DocListReadModel model = existingModel(1L); + DocDeletedPayload deletedPayload = new DocDeletedPayload(docId); + DocActivityChangedPayload activityPayload = + new DocActivityChangedPayload(docId, 20L, LocalDateTime.of(2026, 1, 4, 10, 0)); + + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + + docListProjector.project(message(20L, DomainEventType.DOC_DELETED, deletedPayload)); + docListProjector.project(message(19L, DomainEventType.DOC_ACTIVITY_CHANGED, activityPayload)); + + assertThat(model.isDeleted()).isTrue(); + assertThat(model.getRecentSaveId()).isEqualTo(10L); + assertThat(model.getUpdatedAt()).isEqualTo(updatedAt); + } + + @Test + @DisplayName("삭제 이후 오래된 thumbnail 이벤트는 문서를 되살리지 않는다") + void project_ignoreOlderThumbnailEventAfterDelete() throws Exception { + DocListReadModel model = existingModel(1L); + DocDeletedPayload deletedPayload = new DocDeletedPayload(docId); + DocThumbnailChangedPayload thumbnailPayload = + new DocThumbnailChangedPayload(docId, "thumbnail-2", ThumbnailStatus.READY); + + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + + docListProjector.project(message(20L, DomainEventType.DOC_DELETED, deletedPayload)); + docListProjector.project(message(19L, DomainEventType.DOC_THUMBNAIL_CHANGED, thumbnailPayload)); + + assertThat(model.isDeleted()).isTrue(); + assertThat(model.getThumbnailObjectKey()).isEqualTo("thumbnail-1"); + assertThat(model.getThumbnailStatus()).isEqualTo(ThumbnailStatus.READY); + } + + @Test + @DisplayName("삭제 marker가 없으면 다른 필드보다 오래된 삭제 이벤트도 terminal로 반영한다") + void project_applyOlderDeleteEventWhenDeleteFieldWasNotProjected() throws Exception { + DocListReadModel model = existingModel(1L); + DocThumbnailChangedPayload thumbnailPayload = + new DocThumbnailChangedPayload(docId, "thumbnail-2", ThumbnailStatus.READY); + DocDeletedPayload deletedPayload = new DocDeletedPayload(docId); + + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + + docListProjector.project(message(21L, DomainEventType.DOC_THUMBNAIL_CHANGED, thumbnailPayload)); + docListProjector.project(message(20L, DomainEventType.DOC_DELETED, deletedPayload)); + + assertThat(model.isDeleted()).isTrue(); + assertThat(model.getLastProjectedEventId()).isEqualTo(21L); + } + private DocListReadModel existingModel(Long eventId) { return DocListReadModel.create(createdPayload(), eventId); } diff --git a/src/test/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelayIntegrationTest.java b/src/test/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelayIntegrationTest.java index 972ddd3f..d23dad12 100644 --- a/src/test/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelayIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelayIntegrationTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import io.ejangs.docsa.global.outbox.OutboxStatus; @@ -12,12 +13,17 @@ import io.ejangs.docsa.global.outbox.event.entity.DomainEventOutbox; import io.ejangs.docsa.global.outbox.event.model.AggregateType; import io.ejangs.docsa.global.outbox.event.model.DomainEventType; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -31,6 +37,9 @@ class DomainEventOutboxRelayIntegrationTest { @Autowired private DomainEventOutboxRepository domainEventOutboxRepository; + @Autowired + private JdbcTemplate jdbcTemplate; + @MockitoBean private DomainEventDispatcher domainEventDispatcher; @@ -54,6 +63,30 @@ void relayDone() { verify(domainEventDispatcher).dispatch(any(DomainEventMessage.class)); } + @Test + @DisplayName("Domain event relay는 createdAt이 같으면 id 오름차순으로 처리한다") + void relayDispatchesByCreatedAtAndId() { + DomainEventOutbox firstOutbox = createOpenOutbox(); + DomainEventOutbox secondOutbox = createOpenOutbox(); + LocalDateTime sameCreatedAt = LocalDateTime.of(2026, 1, 1, 10, 0); + jdbcTemplate.update( + "update domain_event_outbox set created_at = ? where id in (?, ?)", + Timestamp.valueOf(sameCreatedAt), + firstOutbox.getId(), + secondOutbox.getId() + ); + + domainEventOutboxRelay.run(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DomainEventMessage.class); + verify(domainEventDispatcher, times(2)).dispatch(captor.capture()); + + List dispatchedEventIds = captor.getAllValues().stream() + .map(DomainEventMessage::eventId) + .toList(); + assertThat(dispatchedEventIds).containsExactly(firstOutbox.getId(), secondOutbox.getId()); + } + @Test @DisplayName("Domain event relay 처리 중 예외가 발생하면 retryCount 증가 후 OPEN 상태로 복귀한다") void relayRetryToOpen() {