이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다. 사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다. 대부분은 clean-architecture-backend-template 의 그림 정본 재배치다 — final/assets/diagrams/<이름>/ 에 있던 것이 CLAUDE.md 가 적은 배치인 final/assets/<이름>/ 로 옮겨졌고 .techviz/<이름>/ 이 함께 들어왔다. 삽입 줄의 대부분(3.15M)이 그 .techviz context.json 이다. 그 밖에 ca-tmpl·document-haness 의 정리, .claude/agents/ 열한 개, writing-practitioner-guides 스킬, .playwright-mcp 세션 산출물, scripts/check-ssot-facts.py 와 그 시험이 들어 있다. 이 커밋의 내용은 내가 만든 것이 아니라 이전 세션이 남긴 것이고 검증하지 않았다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7.5 KiB
id, kind, slug, title, topic, topicName, project, status, studio, sourceRevision, source
| id | kind | slug | title | topic | topicName | project | status | studio | sourceRevision | source | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| bf5f2462-0e94-4723-bdc8-f7dd709b2dbb | REFERENCE | top-n-per-group-selection | Top-N-per-group 선택 기준 | jpa-feed-query-performance | JPA 피드 조회 성능 | Liner N + 1문제 | 게시 전 | https://hyeonworks.com/studio/documents/bf5f2462-0e94-4723-bdc8-f7dd709b2dbb/edit | n+1liner-lab@2026-08 |
|
Top-N-per-group 선택 기준
부모마다 상위 N개를 뽑는 일은 LIMIT으로 표현되지 않는다. 윈도우 함수, LATERAL, 애플리케이션 그룹핑이 부모 20개에서 똑같은 60행을 돌려줬지만, LATERAL은 인덱스에서 부모당 3개만 읽어 buffers가 204였고 나머지 둘은 1,509행을 모두 읽어 430이었다.
관계
- Projection 이후에도 1,509행을 읽은 Row Over-fetch 프로젝션으로 엔티티 로드는 0이 되었지만 페이지 부모 20개의 자식 1,509행은 줄지 않았다. 그 1,509행을 60행으로 깎으려고 이 기준을 만들었다.
- PostgreSQL Query Plan 측정 기준 세 방식의 EXPLAIN (ANALYZE, BUFFERS)를 어떤 조건에서 재고 어느 값을 읽을지는 그 기록이 정한다.
- Fetch Join · Batch · Projection 선택 기준 왕복은 배치가 2,022개에서 23개로, 엔티티 적재는 프로젝션이 1,569개에서 0개로 앞 단계에서 풀었다. 여기서는 남은 자식 1,509행을 60행까지 깎는다.
목적
자식 조회 끝에 LIMIT 3을 붙이면 부모별 그룹이 아니라 최종 결과 집합 전체에 적용된다. 부모 20개를 조회했는데 반환은 3행이었고 하이라이트가 들어간 부모는 1개였다. 그룹당 상한은 다른 표현으로 써야 한다.
세 표현은 모두 부모 20개에서 60행을 돌려주므로 정확성만으로는 고를 수 없다. 갈리는 곳은 그 60행을 만들기까지 읽은 행수와 buffers인데, 윈도우와 2단계는 1,509행을 읽어 430이었고 LATERAL은 204였다.
규칙
1. 자식 쿼리 끝에 붙인 LIMIT은 부모별로 적용되지 않는다
LIMIT은 부모별 그룹이 아니라 최종 결과 집합에 적용되므로, 부모 20개를 조회하면서 LIMIT 3을 붙이면 가장 최신 하이라이트를 가진 부모 하나만 3개를 받고 나머지 부모에는 하이라이트가 들어가지 않는다.
반환이 3행뿐이라 결과가 작아 보일 뿐 오작동인지는 드러나지 않는다. 커버한 부모 수를 같이 세면 20이 아니라 1로 나온다.
2. 윈도우 · LATERAL · 앱 그룹핑이 각각 어디까지 읽는지 적는다
윈도우 함수는 PARTITION BY feed_item_id로 부모마다 순번을 매기고 순번 3 이하만 남긴다. 자르는 곳은 DB지만 순번을 매기려고 파티션 전체인 1,509행을 먼저 읽는다.
LATERAL은 부모마다 상관 서브쿼리를 실행해 부모별 정렬 인덱스에서 K개를 읽고 멈춘다. 실행계획에는 Nested Loop 아래 Index Scan과 Limit 3이 부모 수만큼 반복되어 loops=20으로 찍힌다.
애플리케이션 그룹핑은 자식을 한 번에 모아 오는 쿼리로 가져온 뒤 코드에서 부모별로 자른다. 자르기 전에 1,509행이 모두 전송되고, 실행계획은 윈도우와 같은 Hash Semi Join이다.
3. 그룹이 크고 K가 작으면 LATERAL을 쓴다
부모별 정렬 인덱스가 있으면 LATERAL은 부모마다 K개를 읽고 멈추므로, 그룹이 클수록 읽지 않고 넘어가는 행이 많아진다. 이 랩의 피드는 부모 하나에 하이라이트가 500개인데 화면에 필요한 K는 3이었고, K=3에서 buffers는 LATERAL 114, 윈도우 162였다.
4. K가 그룹 크기에 가까우면 더 단순한 윈도우 함수를 고른다
K를 3 · 50 · 500으로 바꾸자 반환 행수는 60 · 695 · 1,509였다. K가 그룹 크기인 500에 가까워지면 LATERAL도 부모의 하이라이트를 대부분 읽으므로, 이 구간에서는 더 단순한 윈도우 함수를 골라도 된다.
다만 이 랩에서는 세 K 모두 LATERAL의 buffers가 윈도우보다 작았다. 114 대 162, 155 대 216, 171 대 269였고, 어느 K에서 윈도우가 앞서는지는 재지 않았다.
5. LATERAL을 쓰기 전에 부모별 정렬 인덱스가 있는지 확인한다
LATERAL 문법 자체가 빠른 것이 아니라, 부모 키와 정렬 컬럼을 함께 담은 복합 인덱스 ix_highlights_feed_items_created로 부모별 상위 3개를 바로 찾을 수 있어서 빨랐다.
같은 LATERAL 쿼리를 두고 인덱스를 지웠다가 다시 만들며 재자 자식 접근이 Index Scan에서 Seq Scan으로 바뀌었고, Rows Removed by Filter가 부모 한 번마다 2842였다. buffers는 168에서 4,446으로 약 26배, 실행시간은 0.336 ms에서 5.472 ms로 약 16배 늘었다.
이 랩에서 그 인덱스는 마이그레이션 V6__feed.sql부터 있었다. LATERAL을 고르면서 새로 만든 것이 아니라 이미 있는 인덱스에 맞는 표현을 고른 것이다.
6. 애플리케이션 그룹핑은 전송 행수를 줄이지 않는다
코드에서 자르면 부모당 3개라는 결과는 맞지만, DB가 넘긴 행은 60이 아니라 1,509였고 buffers도 윈도우와 같은 430이었다. 줄이려는 것이 전송 행수라면 이 방식으로는 줄지 않는다.
7. 윈도우 함수와 LATERAL은 native SQL로 내려가야 쓸 수 있다
표준 JPQL(Java Persistence Query Language)에는 윈도우 함수도 LATERAL도 없고, Hibernate 6 이상의 HQL(Hibernate Query Language)도 LATERAL은 지원하지 않는다. 두 표현을 쓰려면 native SQL로 내려간다.
이 랩은 native SQL을 JdbcTemplate으로 실행하는 별도 테스트 클래스 FeedTopNIT에만 두어 프로덕션 코드 변경이 0이었다.
8. 같은 결과가 나오는지 먼저 맞춘 뒤 실행계획을 비교한다
세 표현이 같은 결과를 만드는지부터 확인하고 실행계획은 그다음에 비교한다. 반환 행수, 커버한 부모 수, 부모당 최대 개수를 함께 봐야 순진한 LIMIT 3처럼 행수만 작고 부모를 못 채우는 경우가 걸러진다.
이 랩에서는 윈도우와 LATERAL이 60행에 부모 20, 부모당 3으로 같았고, 2단계는 코드에서 자르기 전 1,509행에 부모당 전량이었으며, 순진한 LIMIT 3은 3행에 부모 1이었다. 셋의 결과를 맞춘 뒤 캐시 상태를 맞추려고 같은 테스트 실행 안에서 EXPLAIN (ANALYZE, BUFFERS)를 돌렸다.
적용 조건
- 목록 응답에 부모별 자식 상위 몇 개를 포함해야 할 때
- 부모의 자식을 전부 가져오는 조회가 전송 행수 때문에 문제가 될 때
- 그룹 크기가 크고 화면에 필요한 개수가 작을 때
예외
- 그룹 크기가 작아 전량을 읽어도 부담이 없으면 애플리케이션 그룹핑이 단순하다.
- 부모별 정렬 인덱스를 만들 수 없으면 LATERAL은 부모마다 Seq Scan을 돌고 buffers가 4,446까지 올라간다. 이때는 윈도우 함수와 buffers를 비교해 고른다.
예시
- 순진 LIMIT 3 : 부모 20개 조회에 반환 3행, 채워진 부모 1개
- 윈도우 : 파티션 1,509행을 읽어 순번을 매긴 뒤 60행, buffers 430
- LATERAL : 부모마다 인덱스에서 3개 읽고 멈춰 60행, buffers 204
- 2단계 : 자식 1,509행을 모두 전송한 뒤 코드에서 그룹핑, buffers 430
- 인덱스 없는 LATERAL : 부모마다 Seq Scan, buffers 168에서 4,446으로
- K 곡선 : K가 3 · 50 · 500일 때 반환 60 · 695 · 1,509, LATERAL buffers가 세 K 모두 윈도우보다 작음