Skip to content

Refactor: 문서 목록 CQRS Read Model 전환 및 Domain Event Outbox 추가#203

Merged
lunarbae628 merged 28 commits into
stagingfrom
refactor/200-doc-list-n+1
May 14, 2026
Merged

Refactor: 문서 목록 CQRS Read Model 전환 및 Domain Event Outbox 추가#203
lunarbae628 merged 28 commits into
stagingfrom
refactor/200-doc-list-n+1

Conversation

@lunarbae628
Copy link
Copy Markdown
Collaborator

@lunarbae628 lunarbae628 commented May 13, 2026

🛰️ Issue Number

🪐 작업 내용

개요

문서 목록 조회 흐름을 기존 SQL 조합 방식에서 DocListReadModel 기반 CQRS 조회로 전환했습니다.

기존 문서 목록 API는 docs 조회 이후 branch/save/thumbnail 상태를 매번 조합해서 응답을 만들었습니다. 이번 변경에서는 문서 목록 화면에 필요한 값을 MongoDB read model로 유지하고, write model 변경은 DomainEventOutbox를 통해 projector로 전달하도록 구성했습니다.

이번 PR의 핵심은 다음입니다.

  • 문서 목록용 DocListReadModel 추가
  • DomainEventOutbox 기반 projection 흐름 추가
  • 문서 생성/수정/삭제/활동/썸네일 변경 이벤트 발행 연결
  • 문서 목록 조회 API를 read model 기반으로 전환
  • 기존 데이터 초기화를 위한 backfill job 추가
  • DocCommandService / DocQueryService 분리
  • 기존 QueryService 명명 혼동 정리

주요 변경 사항

1. Domain Event Outbox 추가

문서 목록 read model 갱신을 위해 domain event outbox 구조를 추가했습니다.

추가된 주요 구성은 다음과 같습니다.

  • DomainEventOutbox
  • DomainEventOutboxRepository
  • DomainEventOutboxPublisher
  • DomainEventOutboxRelay
  • DomainEventOutboxLifecycleService
  • DomainEventOutboxWakeUpListener
  • DomainEventDispatcher
  • LocalDomainEventDispatcher
  • DomainEventMessage

쓰기 트랜잭션 안에서는 outbox row만 저장하고, commit 이후 wake-up listener가 relay를 깨우도록 했습니다. wake-up은 지연을 줄이기 위한 신호이고, 실제 복구 가능성은 outbox row와 scheduler fallback이 담당합니다.

relay는 다음 흐름으로 동작합니다.

  1. OPEN outbox 조회
  2. PROCESSING 상태로 claim
  3. DomainEventMessage로 변환
  4. dispatcher 호출
  5. 성공 시 DONE
  6. 실패 시 retry 증가
  7. max retry 도달 시 FAILED

2. 문서 목록 read model 추가

MongoDB에 문서 목록 조회 전용 read model을 추가했습니다.

주요 필드:

  • id: docId
  • userId
  • title
  • createdAt
  • updatedAt
  • recentSaveId
  • thumbnailObjectKey
  • thumbnailStatus
  • deleted
  • lastProjectedEventId

lastProjectedEventId를 통해 이미 처리한 이벤트 이하의 projection은 무시하도록 했습니다.

3. 이벤트별 payload 분리

초기 단일 payload 구조 대신 이벤트별 payload record로 분리했습니다.

  • DocCreatedPayload
  • DocTitleChangedPayload
  • DocActivityChangedPayload
  • DocThumbnailChangedPayload
  • DocDeletedPayload

각 이벤트가 필요한 값만 갖도록 분리했고, payload 생성 책임은 DocPayloadFactory로 모았습니다.

4. 문서 목록 갱신 이벤트 발행 연결

문서 목록 read model에 영향을 주는 쓰기 유스케이스에서 domain event outbox를 발행하도록 연결했습니다.

연결된 이벤트:

  • 문서 생성: DOC_CREATED
  • 문서 제목 변경: DOC_TITLE_CHANGED
  • 문서 삭제: DOC_DELETED
  • 브랜치 생성: DOC_ACTIVITY_CHANGED
  • 병합: DOC_ACTIVITY_CHANGED
  • 커밋 생성: DOC_ACTIVITY_CHANGED
  • 저장 수정: DOC_ACTIVITY_CHANGED
  • 썸네일 확정: DOC_THUMBNAIL_CHANGED

현재 문서 목록 projection 이벤트는 모두 AggregateType.DOC을 사용하고, aggregateId에는 docId를 저장합니다.

5. 문서 목록 조회 API를 read model 기반으로 전환

기존 DocService 중심 조회 구조를 분리했습니다.

  • DocCommandService: 문서 생성, 제목 변경, 삭제 등 write model 변경 담당
  • DocQueryService: 문서 목록/사이드바/검색/그래프 조회 담당

문서 목록, 사이드바, 검색 조회는 DocListReadModelRepository 기반으로 전환했습니다.

조회 흐름:

  • getSimplePage

    • DocListReadModelRepository.findByUserIdAndDeletedFalse
    • DocListReadModelMapper.toListSimpleResponse
  • getPage

    • DocListReadModelRepository.findByUserIdAndDeletedFalse
    • DocListReadModelMapper.toListResponse
  • searchList

    • DocListReadModelRepository.findByUserIdAndDeletedFalseAndTitleContainingIgnoreCase
    • DocListReadModelMapper.toListResponse

그래프 조회는 문서 목록 read model 대상이 아니므로 기존 DocReader, BranchReader, CommitReader, EdgeService 조합을 유지했습니다.

6. 기존 QueryService 명명 정리

일부 도메인에서 QueryService라는 이름을 사용하고 있었지만, 실제 역할은 CQRS read model query service가 아니라 repository 접근 helper에 가까웠습니다.

CQRS 문맥에서의 DocQueryService와 혼동을 줄이기 위해 다음과 같이 정리했습니다.

  • CommitQueryServiceCommitReader / CommitWriter
  • BranchQueryServiceBranchReader / BranchWriter
  • SaveQueryServiceSaveReader / SaveWriter
  • ThumbnailQueryServiceThumbnailStore
  • ImageQueryServiceImageReader

7. Backfill job 추가

기존 문서 데이터를 doc_list_read_models로 초기화하기 위한 backfill job을 추가했습니다.

추가된 구성:

  • DocListReadModelBackfillService
  • DocListReadModelBackfillJob
  • application-backfill.yml
  • infra/scripts/backfill-doc-list-local.sh

backfill은 batch 단위로 MySQL docs를 읽고, 최신 save id와 썸네일 상태를 조합해 Mongo read model을 생성합니다.

저장은 Mongo $setOnInsert 기반 upsert를 사용했습니다.

이유:

  • 운영 API 서버가 떠 있는 상태에서 별도 job으로 실행할 수 있어야 함
  • backfill 조회와 저장 사이에 projector가 read model을 먼저 만들 수 있음
  • 이 경우 backfill 초기값이 최신 projection 결과를 덮어쓰면 안 됨

따라서 이미 read model이 존재하면 어떤 필드도 수정하지 않고, 없는 경우에만 insert합니다.

8. Local/Test Mongo cleanup 추가

local/test 환경에서 MongoDB 데이터 정리가 가능하도록 cleanup bean을 추가했습니다.

단, backfill profile에서는 기존 Mongo 데이터를 보존해야 하므로 다음 initializer들은 backfill profile에서 제외했습니다.

  • LocalMongoCleanup
  • PerfDataInitializer
  • TestUserInitializer

운영 및 배포 메모

Backfill 실행 방식

Backfill은 API 서버 요청으로 실행하지 않고, 별도 one-off job 프로세스로 실행하는 것을 전제로 합니다.

운영 예시:

SPRING_PROFILES_ACTIVE=prod,backfill \
DOC_LIST_READ_MODEL_BACKFILL_BATCH_SIZE=1000 \
DDL_AUTO=validate \
java -jar app.jar

테스트

Projection

  • DocListProjectorUnitTest

    • DOC_CREATED
    • DOC_TITLE_CHANGED
    • DOC_ACTIVITY_CHANGED
    • DOC_THUMBNAIL_CHANGED
    • DOC_DELETED
    • 이미 처리한 eventId 이하 이벤트 무시
  • DocListProjectorIntegrationTest

    • DomainEventOutboxRelay -> LocalDomainEventDispatcher -> DocListProjector -> Mongo DocListReadModel 연결 검증

Relay

  • DomainEventOutboxRelayIntegrationTest
    • dispatch 성공 시 DONE
    • dispatch 실패 시 retry 증가 후 OPEN
    • max retry 도달 시 FAILED

Query

  • DocQueryServiceUnitTests
    • read model 기반 목록 응답 변환
    • read model 기반 사이드바 응답 변환
    • read model 기반 검색 응답 변환
    • thumbnail object key가 없거나 blank일 때 thumbnailUrl=null
    • thumbnail object key가 있으면 CDN URL 조합
  • DocListReadModelRepositoryIntegrationTest
    • 목록 조회에서 deleted=true read model 제외
    • 검색 조회에서 deleted=true read model 제외

Backfill

  • DocListReadModelBackfillServiceUnitTest
    • 기존 Doc 상태를 read model 초기값으로 변환
    • 이미 read model이 있으면 insert count에 포함하지 않음
    • Mongo $setOnInsert upsert 사용 검증
    • keyset batch 반복 처리
    • 잘못된 batch size 방어

리스크 및 후속 작업

리스크

  • CQRS read model은 최종 일관성을 전제로 하므로, write 성공 직후 read model 반영까지 약간의 지연이 있을 수 있습니다.
  • payload schema가 바뀌면 과거 outbox payload 역직렬화 이슈가 생길 수 있습니다.
  • domain event outbox 상태 관찰이 부족하면 projection 실패를 늦게 발견할 수 있습니다.

후속 작업

  • staging에서 backfill job 실행 후 read model 수와 목록 API 응답 샘플 확인
  • domain_event_outbox의 OPEN, PROCESSING, FAILED 상태 관찰 보강
  • 기존 문서목록과 성능비교
  • 필요 시 Prometheus/Grafana 지표화
  • 문서 목록 외 read model이 늘어날 경우 Kafka dispatcher/consumer 구조 검토

📚 Reference

✅ Check List

  • 코드가 정상적으로 컴파일되나요?
  • 테스트 코드를 통과했나요?
  • merge할 브랜치의 위치를 확인했나요?
  • Label을 지정했나요?

…스케이스 메서드화로 호출부에서의 의미 들어나게 변경

- flyway 마이그레이션 sql문 생성(originType 삭제)
 - Factory -> JobEnqueuer
 - DeleteService -> DeleteExecutor
 - MongoDeleteOutboxCreateService를 제거하고 JobEnqueuer에서 saveAndFlush를 직접 호출
 - 변경된 이름과 중복 충돌 처리 방식에 맞춰 테스트 수정
- 도메인 이벤트 저장용 outbox 엔티티와 마이그레이션을 추가
- outbox relay, lifecycle service, wake-up listener를 추가
- 로컬 dispatcher를 통해 문서 목록 projector로 이벤트를 전달하도록 구성
- 문서 목록 조회용 Mongo read model과 payload를 추가
- 신규 JPA/Mongo repository 패키지를 스캔 설정에 등록
- 문서 목록 projection payload를 이벤트 타입별 record로 분리
- DocListProjector가 이벤트 타입별 payload를 역직렬화하도록 변경
- DocListReadModel에 생성/제목/활동/썸네일/삭제 이벤트별 반영 메서드를 추가
- DocPayloadFactory를 추가해 이벤트 payload 생성 책임을 분리
- 문서 생성 트랜잭션에서 DOC_CREATED outbox 이벤트를 저장하도록 연결
- 문서 제목 변경 시 DOC_TITLE_CHANGED outbox 이벤트를 저장하도록 연결
- 제목 변경 단위 테스트에 outbox publish 검증을 추가
- @EnableAsync 설정을 AsyncConfig로 분리
- outbox wake-up listener가 전용 executor를 사용하도록 변경
- 트랜잭션 커밋 이후 이벤트만 처리하도록 fallbackExecution을 제거
- 브랜치 생성, 병합, 커밋 생성, 저장 수정, 썸네일 확정, 문서 삭제 흐름에서 문서 목록 read model 갱신용 domain event outbox를 발행 연결
- 브랜치가 save를 가진다는 현재 도메인 규칙에 맞춰 커밋 통합 테스트 fixture를 보정
- DocListProjector의 이벤트별 read model 반영과 eventId 기반 idempotency를 단위 테스트로 검증
- 오래된 DOC_DELETED 이벤트가 최신 read model 상태를 덮어쓰지 않도록 lastProjectedEventId 반영을 보정
- DomainEventOutboxRelay의 DONE/retry/FAILED 상태 전이를 통합 테스트로 검증
- relay, dispatcher, projector, Mongo read model까지 이어지는 DOC_CREATED projection 통합 테스트를 추가
- DocService를 DocCommandService로 분리하고 문서 목록/검색/그래프 조회를 DocQueryService로 이동
- 문서 목록/검색 응답을 DocListReadModel 기반으로 매핑하도록 변경
- 기존 SQL 목록 조립용 DocListAssembler를 제거하고 read model mapper를 추가
- command/query 분리에 맞춰 컨트롤러 및 테스트를 정리
- 컨트롤러 테스트의 MockMvc print 출력을 제거해 테스트 로그 노이즈를 줄임
- test/local/stg/prod 환경의 SQL 및 bind parameter 로그 레벨을 명시
- local 환경에서는 필요 시 환경변수로 SQL 로그를 켤 수 있도록 조정
- 문서 도메인 테스트를 api/app 단위와 통합 테스트 패키지로 정리
- DocQueryService 목록 응답에서 thumbnailObjectKey null/blank 처리 검증 추가
- thumbnailObjectKey가 있으면 CDN URL로 변환되는지 검증
- local 프로필에서 Mongo DB를 시작/종료 시 정리하는 cleanup 컴포넌트 추가
- test 프로필에서 테스트 DB 정리를 위한 cleanup 컴포넌트 추가
- 안전한 DB 이름(-local, -test)에 대해서만 drop 하도록 보호 로직 추가
- cleanup 동작을 검증하는 단위 테스트 추가
- 커밋 조회/검증 책임을 CommitReader로 분리
- 커밋 저장/삭제 책임을 CommitWriter로 분리
- CQRS QueryService와 혼동되는 기존 CommitQueryService 명명을 제거
- 브랜치 조회/검증 책임을 BranchReader로 분리
- 브랜치 생성/저장/삭제 책임을 BranchWriter로 분리
- CQRS QueryService와 혼동되는 기존 BranchQueryService 명명을 제거
- 저장 조회/검증 책임을 SaveReader로 분리
- 저장 생성/갱신 책임을 SaveWriter로 분리
- CQRS QueryService와 혼동되는 기존 SaveQueryService 명명을 제거
- 이미지 조회 전용 서비스를 ImageReader로 변경
- 썸네일 영속성 접근을 ThumbnailStore로 정리
- CQRS QueryService와 혼동되는 기존 QueryService 명명을 제거
- 기존 Doc 데이터를 batch 단위로 조회해 DocListReadModel을 초기 생성
- Mongo setOnInsert upsert로 기존 read model을 덮어쓰지 않도록 처리
- backfill profile에서 실행되는 one-off job과 테스트 설정 추가
- local backfill 실행 스크립트를 추가
- backfill profile에서 local initializer와 cleanup이 실행되지 않도록 조정
- backfill 실행 시 domain event relay bean 의존성은 유지하되 scheduler 실행은 지연
- 목록 조회에서 deleted=true read model이 제외되는지 검증
- 제목 검색 조회에서도 삭제된 read model이 제외되는지 검증
@lunarbae628 lunarbae628 merged commit 965667c into staging May 14, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant