- 계약 채택 — 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>
5.0 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 | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 30a37f34-b406-4061-b924-e22e0be0c3bf | PROJECT_DECISION | read-projection-for-screen-query | 화면 조회는 Read Projection을 사용한다 | jpa-feed-query-performance | JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 | JPA 피드 조회 성능 | Liner N + 1문제 | 게시 전 | https://hyeonworks.com/studio/documents/30a37f34-b406-4061-b924-e22e0be0c3bf/edit | PROPOSED | n+1liner-lab@2026-08 |
|
id: 30a37f34-b406-4061-b924-e22e0be0c3bf kind: PROJECT_DECISION slug: read-projection-for-screen-query title: 화면 조회는 Read Projection을 사용한다 topic: jpa-feed-query-performance topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 topicName: JPA 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/30a37f34-b406-4061-b924-e22e0be0c3bf/edit" decisionStatus: PROPOSED sourceRevision: n+1liner-lab@2026-08 source:
- final/document.md#12-1
- final/document.md#12-5
화면 조회는 Read Projection을 사용한다
화면에 내보내는 조회는 엔티티를 하이드레이트하지 않고 필요한 스칼라 값만 캐리어로 받는다. 엔티티 그래프 조회는 쓰기 경로에 남기고 읽기 경로는 프로젝션으로 분리한다.
근거
- Projection 이후에도 1,509행을 읽은 Row Over-fetch 하이드레이트한 엔티티는 0개가 됐지만 자식 IN 쿼리가 페이지 부모 20개의 하이라이트 1,509행을 모두 읽은 것을 확인한 기록이다.
- Fetch Join · Batch · Projection 선택 기준 배치는 SQL 왕복 횟수를 줄이고 프로젝션은 적재할 엔티티 수를 줄이므로, 어느 조회에 무엇을 쓸지 나눠 둔 기록이다.
- Query Strategy는 FeedQueryPort 뒤에서 소유한다 프로젝션 구현을 퍼시스턴스 어댑터 안에 두고 상위 계층에는 조회 조건과 반환 형태만 드러낸 결정이다.
결정문
화면 조회 경로에서는 필요한 컬럼만 선택해 캐리어 record로 받는다. 영속 엔티티를 만들지 않는다.
부모와 자식을 각각 스칼라로 조회하고 애플리케이션에서 조립한다. 이 구현은 FeedQueryPort 뒤에 둔다.
판단 이유
배치를 적용한 뒤에도 엔티티는 통째로 하이드레이트됐다. seed 1,000의 첫 페이지 20건을 조회하자 FeedItem·User·Page·Highlight를 합해 1,569개가 영속 객체로 올라왔다. 화면에 필요한 것은 사용자 이름, 페이지 제목, URL 같은 일부 컬럼이었다.
SELECT new FeedItemProjectionRow(…) 생성자 표현식은 영속 엔티티 대신 스칼라 값으로 record를 만든다. 영속 엔티티가 없으니 1차 캐시도, 더티체킹도, 지연 프록시도 생기지 않는다. 사용자 이름을 읽으려고 건 join f.user u도 컬럼 값을 가져오는 경로일 뿐이라 User 엔티티를 만들지 않는다.
seed 1,000의 같은 첫 페이지 20건을 loadFeedProjection으로 조회하자 하이드레이트한 엔티티가 1,569개에서 0개로 줄었다. 발행한 PreparedStatement도 23개에서 2개가 됐는데, 부모 스칼라 쿼리 1개와 자식 IN 쿼리 1개다. 엔티티 컬렉션을 초기화하지 않으니 여러 컬렉션을 함께 채우던 조회 연산도 10번에서 0번이 됐다. N을 10, 100, 1,000으로 바꿔 다시 재도 PreparedStatement는 2개였다. 페이지 부모가 최대 20개라 자식 IN 쿼리도 한 번만 실행됐기 때문이다.
위 수치는 loadFeedProjection을 직접 부른 결과다. 프로덕션 경로에서는 이 프로젝션을 FeedQueryPort의 읽기 계약으로 노출하고 부모당 최신 3개를 뽑는 window 쿼리와 묶어 N이 10과 100일 때 다시 쟀다. 엔티티 로드 0과 발행 쿼리 2개는 거기서도 같았다.
배치는 SQL 왕복 횟수를 줄이고 프로젝션은 적재할 엔티티 수를 줄인다. 프로젝션이 엔티티를 만들지 않는 것은 배치 설정 여부와 관계없이 성립하는 동작이다.
영향
- 반환 형태가 화면 요구에 묶이므로, 화면이 바뀌면 캐리어 record와 그것을 채우는 두 쿼리도 함께 바뀐다.
- FeedSummary의 마지막 인자가 HighlightSummary 목록이라 생성자 표현식 한 번으로는 만들 수 없었다. 목록을 담는 응답은 부모와 자식을 각각 스칼라로 조회한 뒤 feedItemId로 묶어 메모리에서 조립해야 한다.
- 프로젝션의 이득은 실행계획에 나타나지 않는다. 필요한 컬럼만 골랐는데도 부모 프로젝션의 width는 2088로 엔티티 조회의 1194보다 컸다. users와 pages 조인의 행폭이 반영되고, PostgreSQL의 width는 실제 전송 바이트가 아니라 컬럼 타입의 평균폭 추정치이기 때문이다. 효과는 Statistics.getEntityLoadCount()가 센 엔티티 로드 수에서 확인한다.
- 자식 IN 쿼리는 페이지 부모의 하이라이트를 전부 가져온다. seed 1,000의 첫 페이지 20건에서 자식 행은 1,509개였고, 화면에 필요한 것은 부모당 최신 3개로 최대 60개였다. 단순한 IN 쿼리의 LIMIT은 부모별로 적용되지 않으므로, 부모당 상한은 윈도우 함수나 LATERAL 같은 별도 SQL로 풀어야 한다.
- 같은 저장소를 쓰더라도 읽기 경로에 조회 전용 포트와 캐리어 record가 따로 생긴다. 쓰기 애그리거트인 FeedItem과 분리된 읽기 모델을 하나 더 유지해야 한다.