Files
document-haness/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/decision/decision-keep-read-model-as-cqrs-lite.md
T
DongHyeonkaandClaude Fable 5.1 62520a4dce docs(n+1liner): adopt the decomposition contract and strip evaluative prose
- 계약 채택 — schema 2 → 4. 독자 질문, 후보 36건(PROMOTE 24 · MERGE_INTO 8 ·
  KEEP_IN_SSOT 4), 종류별 칸, kind:slug 관계. 저장소 github-project/ca-tmpl @ 761384d
  (문서가 인용한 IT 7종이 그 커밋에 있다)
- 재선별 결과 기존 24건이 전부 살아남았다. 측정이 Reference 안에 들어 있었지만 독립성 검사를
  통과하지 못해 Case 로 빼지 않았다
- 평문 칸의 백틱 제거, 「기준선」을 실제 이름으로, frontmatter 에 source·sourceRevision
- 리뷰 39건 반영 — 설명 뒤에 붙은 평가·예고·되풀이·독자 오해 가정을 지웠다. 사실을 담은
  절반이 있는 문장은 평가만 뺐다

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:39:20 +09:00

4.8 KiB

id, kind, slug, title, topic, topicName, topicName, project, status, studio, decisionStatus, sourceRevision, source
id kind slug title topic topicName topicName project status studio decisionStatus sourceRevision source
7f248f68-ce2b-43ec-94ce-82324d0bd1a7 PROJECT_DECISION keep-read-model-as-cqrs-lite 현재 Read Model은 CQRS-lite로 유지한다 jpa-feed-query-performance JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 JPA 피드 조회 성능 Liner N + 1문제 게시 전 https://hyeonworks.com/studio/documents/7f248f68-ce2b-43ec-94ce-82324d0bd1a7/edit PROPOSED n+1liner-lab@2026-08
final/document.md#17-1
final/document.md#17-2

id: 7f248f68-ce2b-43ec-94ce-82324d0bd1a7 kind: PROJECT_DECISION slug: keep-read-model-as-cqrs-lite title: 현재 Read Model은 CQRS-lite로 유지한다 topic: jpa-feed-query-performance topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 topicName: JPA 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/7f248f68-ce2b-43ec-94ce-82324d0bd1a7/edit" decisionStatus: PROPOSED sourceRevision: n+1liner-lab@2026-08 source:

  • final/document.md#17-1
  • final/document.md#17-2

현재 Read Model은 CQRS-lite로 유지한다

CQRS(Command Query Responsibility Segregation, 명령과 조회의 책임 분리)는 쓰기 모델과 읽기 모델을 갈라 두는 설계다. 여기서는 그중 모델만 갈라, 읽기 경로를 쓰기 애그리거트인 FeedItem에서 떼어 내되 저장소는 나누지 않는다. 읽기 전용 조회 포트(FeedReadModelQueryPort)와 읽기 DTO, 읽기 최적 쿼리를 쓰기와 같은 저장소 위에 두고, 별도 물리 읽기 저장소는 에스컬레이션 대상으로 남긴다.

근거

  • feed_visible을 Production CQRS로 승격할 것인가 사용자별 가시성을 미리 계산한 테이블을 상시 유지할지, 그래서 저장소까지 나눌지를 아직 답하지 않았다.
  • 화면 조회는 Read Projection을 사용한다 엔티티를 하이드레이트하지 않고 필요한 스칼라 값만 받기로 해서, 읽기 경로를 모델 수준에서 갈랐다.
  • Feed Visibility Query Pattern 공개·멘션·비공개 세 분기를 단일 OR, UNION 분해, 사전계산으로 비교했다. 사전계산을 상시 유지하면 그때부터 읽기 모델 설계가 된다.

결정문

읽기 경로는 읽기 전용 조회 포트(FeedReadModelQueryPort)와 유스케이스(GetFeedReadModelUseCase), 어댑터(FeedReadModelQueryAdapter)로 분리한다. 읽기 최적 쿼리는 요청 시점에 실행하고 별도 물리 저장소를 두지 않는다.

사전계산 테이블(feed_visible)을 상시 유지하는 구조는 현재 계약의 범위를 넘는 것으로 보고 채택하지 않는다.

판단 이유

쓰기 애그리거트인 FeedItem으로 화면 조회까지 하려던 데서 N+1이 나왔다.

읽기 모델을 분리하는 층은 둘이다. 모델만 분리하면 같은 저장소 위에 읽기 전용 포트와 읽기 최적 쿼리를 두는 것으로 끝나서, 쓰기 변경을 읽기 쪽에 맞추는 동기화가 아예 없다.

저장소까지 분리하면 조회가 뷰어별로 미리 펼친 feed_visible 하나를 읽는 단일 Index Only Scan이 되어 실행계획이 가장 단순해지지만, 쓰기 변경을 그 투영에 반영해야 한다. 가시성은 보안에 걸린 조건이라 투영이 어긋나면 노출 사고가 된다. 동기화 경로, 지연 허용치, 정합성 검증, 복구 절차를 모두 설계해야 한다.

그 설계를 지금 감수할 만한지는 아직 확인하지 않았다. 지금까지 잰 값은 단일 스레드·warm-cache 로컬 비교값이라 고트래픽 처리량도 동시성도 측정 범위 밖이고, 별도 부하 테스트로 확인해야 한다.

모델만 분리한 것으로도 FeedReadModelUseCaseIT의 N=10과 100에서 엔티티 로드가 0, 발행 쿼리가 2개, 부모당 하이라이트가 최대 3개였다. 프로젝션만 했을 때 남아 있던 자식 1,509행도 60행 이하로 줄었다.

발행 쿼리 수는 Hibernate Statistics가 센 값이다. 이 지표에 window 쿼리가 잡히도록 그 쿼리는 JdbcTemplate 대신 Hibernate Session으로 실행했다.

영향

  • 조회 요청마다 읽기 최적 쿼리를 실행한다. 결과를 미리 펼쳐 두는 사전계산과 달리 조회 계산이 매 요청에 들어간다.
  • 가시성은 공개·멘션·비공개 세 분기라 요청 시점에 셋을 다시 판정한다. 단일 OR은 후보 1,500개를 훑어 buffers가 122였고, 뷰어별로 미리 펼친 feed_visible은 1이었다.
  • 읽기 전용 조회 포트(FeedReadModelQueryPort) 계약이 하나 늘어난다. 화면이 요구하는 형태가 바뀌면 이 계약도 같이 바뀐다.
  • 쓰기와 읽기가 같은 저장소를 보므로 투영이 어긋날 일이 없다. 투영 갱신, 지연 허용치, 복구 절차를 설계하지 않아도 된다.
  • 고트래픽 읽기에서 feed_visible 사전계산이 실제로 필요해지면 별도 물리 읽기 저장소와 쓰기→읽기 동기화(도메인 이벤트나 아웃박스)를 추가해야 한다. 이 변경은 현재 계약의 범위를 넘으므로 계약과 가드레일을 함께 개정한다.
  • 조회 포트가 엔티티를 노출하지 않는지와 의존 방향은 ArchUnit 검사(query_ports_do_not_leak…)가 계속 확인한다. 이번 구현에서는 이 검사도 ./gradlew check도 통과했다.