feat: 문서 구조 변경 및 tech-visual 스킬 추가
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"version": "1.1",
|
||||
"id": "baseline-schema",
|
||||
"title": "기준선 스키마의 관계",
|
||||
"question": "현재 기준선에서 users, pages, feed_items, highlights는 어떻게 연결되는가?",
|
||||
"type": "erd",
|
||||
"direction": "LR",
|
||||
"audience": [
|
||||
"백엔드 개발자"
|
||||
],
|
||||
"summary": "users와 pages가 feed_items에 연결되고, 각 feed_item은 여러 highlights를 가진다.",
|
||||
"alt": "users와 pages에서 feed_items로 모이고 highlights로 이어지는 기준선 관계도.",
|
||||
"long_description": "왼쪽의 users와 pages가 각각 중앙의 feed_items에 연결된다. feed_items는 오른쪽의 highlights로 이어진다. 간선은 user와 page 각각에 여러 feed_item이 연결되고, 한 feed_item에 여러 highlight가 연결되는 관계를 나타낸다.",
|
||||
"source_context": {
|
||||
"document": "/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md",
|
||||
"document_sha256": "1bb38964ca2a849690f5355646c1aa988111b4cd4e1055c6cb05cf7e5fc35cde",
|
||||
"anchor": {
|
||||
"kind": "marker",
|
||||
"value": "baseline-schema",
|
||||
"line": 39
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"profile": "component-flow",
|
||||
"diagram_only": true,
|
||||
"reference_ids": [
|
||||
"payment-event-flow"
|
||||
],
|
||||
"rationale": "두 시작 엔티티가 feed_items로 모이고 highlights로 이어지는 명시 관계를 왼쪽에서 오른쪽으로 읽는 연결 구조가 가장 직접적이다.",
|
||||
"focus_node": "feed-items"
|
||||
},
|
||||
"groups": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "users",
|
||||
"label": "users",
|
||||
"kind": "entity",
|
||||
"role": "source",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 35,
|
||||
"end_line": 35
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "pages",
|
||||
"label": "pages",
|
||||
"kind": "entity",
|
||||
"role": "source",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 36,
|
||||
"end_line": 36
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "feed-items",
|
||||
"label": "feed_items",
|
||||
"kind": "entity",
|
||||
"role": "store",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 35,
|
||||
"end_line": 35
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "highlights",
|
||||
"label": "highlights",
|
||||
"kind": "entity",
|
||||
"role": "sink",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 37,
|
||||
"end_line": 37
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "users-have-feed-items",
|
||||
"from": "users",
|
||||
"to": "feed-items",
|
||||
"label": "여러 feed_item을 가짐",
|
||||
"kind": "relationship",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 35,
|
||||
"end_line": 35
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "pages-have-feed-items",
|
||||
"from": "pages",
|
||||
"to": "feed-items",
|
||||
"label": "여러 feed_item이 딸림",
|
||||
"kind": "relationship",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 36,
|
||||
"end_line": 36
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "feed-items-have-highlights",
|
||||
"from": "feed-items",
|
||||
"to": "highlights",
|
||||
"label": "여러 highlights를 가짐",
|
||||
"kind": "relationship",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 37,
|
||||
"end_line": 37
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
}
|
||||
],
|
||||
"legend": [],
|
||||
"metadata": {
|
||||
"rationale": "기준선에 명시된 세 관계만 같은 엔티티 추상화 수준에서 표현했다."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
{
|
||||
"version": "1.1",
|
||||
"id": "eager-lazy-query-sequence",
|
||||
"title": "EAGER 2차 조회는 반환 전에, LAZY highlights 조회는 매핑 접근 뒤에 실행된다",
|
||||
"question": "루트 피드 조회부터 EAGER ToOne과 LAZY highlights 조회까지 SQL은 어떤 순서로 발생하는가?",
|
||||
"type": "sequence",
|
||||
"direction": "LR",
|
||||
"audience": [
|
||||
"JPA·Hibernate를 사용하는 백엔드 개발자",
|
||||
"쿼리 성능 분석자"
|
||||
],
|
||||
"summary": "Hibernate는 feed_items를 먼저 조회하고 EAGER user·page를 2차 SELECT로 채운 뒤 반환하며, 매핑 중 getHighlights() 접근이 생긴 다음 LAZY highlights SELECT를 실행한다.",
|
||||
"alt": "loadFeed 매핑, Hibernate, PostgreSQL 사이에서 루트 SELECT, EAGER user·page 2차 SELECT, getHighlights 접근, LAZY highlights SELECT가 차례로 일어나는 시퀀스.",
|
||||
"long_description": "세 참가자를 왼쪽부터 loadFeed DTO 매핑, Hibernate, PostgreSQL 순으로 읽는다. loadFeed가 findAllBy 파생 쿼리를 호출하면 Hibernate가 PostgreSQL에서 feed_items를 먼저 조회한다. 이어 fetch join되지 않은 EAGER user와 page를 별도의 2차 SELECT로 채우고, 반환 시점까지 로딩된 FeedItem을 loadFeed에 돌려준다. 이후 DTO 매핑이 getHighlights()에 접근하면 Hibernate가 해당 아이템의 highlights 컬렉션 SELECT를 실행한다.",
|
||||
"source_context": {
|
||||
"document": "/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md",
|
||||
"document_sha256": "1bb38964ca2a849690f5355646c1aa988111b4cd4e1055c6cb05cf7e5fc35cde",
|
||||
"anchor": {
|
||||
"kind": "marker",
|
||||
"value": "eager-lazy-query-sequence",
|
||||
"line": 324
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"profile": "sequence",
|
||||
"diagram_only": true,
|
||||
"reference_ids": [
|
||||
"payment-approval-sequence"
|
||||
],
|
||||
"rationale": "문서가 루트 조회, EAGER 2차 SELECT, 반환, 매핑 접근, LAZY SELECT의 시간 순서를 명시하므로 참가자별 메시지를 위에서 아래로 배열하는 sequence 구성이 적합하다.",
|
||||
"focus_node": "hibernate"
|
||||
},
|
||||
"groups": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "load-feed-mapping",
|
||||
"label": "loadFeed DTO 매핑",
|
||||
"kind": "participant",
|
||||
"role": "participant",
|
||||
"description": "FeedItem을 순회하며 응답 DTO를 조립하고 highlights 게터에 접근하는 호출자.",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 322,
|
||||
"end_line": 322
|
||||
},
|
||||
{
|
||||
"start_line": 449,
|
||||
"end_line": 451
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "hibernate",
|
||||
"label": "Hibernate",
|
||||
"kind": "participant",
|
||||
"role": "participant",
|
||||
"emphasis": "primary",
|
||||
"description": "파생 쿼리의 루트 조회와 EAGER 2차 SELECT, LAZY 컬렉션 초기화를 수행하는 JPA provider.",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 320,
|
||||
"end_line": 322
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "postgresql",
|
||||
"label": "PostgreSQL",
|
||||
"kind": "participant",
|
||||
"role": "participant",
|
||||
"shape": "database",
|
||||
"description": "Hibernate가 루트 및 연관 SELECT를 실행하는 데이터베이스.",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 417,
|
||||
"end_line": 422
|
||||
},
|
||||
{
|
||||
"start_line": 447,
|
||||
"end_line": 447
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "m1-find-all",
|
||||
"from": "load-feed-mapping",
|
||||
"to": "hibernate",
|
||||
"label": "findAllBy(...)",
|
||||
"kind": "request",
|
||||
"order": 1,
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 320,
|
||||
"end_line": 320
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "m2-root-select",
|
||||
"from": "hibernate",
|
||||
"to": "postgresql",
|
||||
"label": "SELECT feed_items",
|
||||
"kind": "data",
|
||||
"order": 2,
|
||||
"emphasis": "primary",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 320,
|
||||
"end_line": 320
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "m3-eager-secondary-selects",
|
||||
"from": "hibernate",
|
||||
"to": "postgresql",
|
||||
"label": "SELECT user / page · EAGER 2차",
|
||||
"kind": "data",
|
||||
"order": 3,
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 318,
|
||||
"end_line": 321
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "m4-return-eager-loaded-items",
|
||||
"from": "hibernate",
|
||||
"to": "load-feed-mapping",
|
||||
"label": "EAGER 연관이 채워진 FeedItem 반환",
|
||||
"kind": "response",
|
||||
"style": "dashed",
|
||||
"order": 4,
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 318,
|
||||
"end_line": 320
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "m5-access-highlights",
|
||||
"from": "load-feed-mapping",
|
||||
"to": "hibernate",
|
||||
"label": "매핑 중 getHighlights() 접근",
|
||||
"kind": "request",
|
||||
"order": 5,
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 322,
|
||||
"end_line": 322
|
||||
},
|
||||
{
|
||||
"start_line": 449,
|
||||
"end_line": 451
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "m6-lazy-highlights-select",
|
||||
"from": "hibernate",
|
||||
"to": "postgresql",
|
||||
"label": "SELECT highlights WHERE feed_item_id = ?",
|
||||
"kind": "data",
|
||||
"order": 6,
|
||||
"emphasis": "primary",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 322,
|
||||
"end_line": 322
|
||||
},
|
||||
{
|
||||
"start_line": 429,
|
||||
"end_line": 430
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
}
|
||||
],
|
||||
"legend": [],
|
||||
"metadata": {
|
||||
"rationale": "EAGER와 LAZY의 차이를 정적 관계가 아니라 실제 SQL 발생 순서와 접근 시점으로 보여준다."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"version": "1.1",
|
||||
"id": "nplus1-query-fanout",
|
||||
"title": "반환 부모 수 N이 컬렉션 초기화와 자식 SELECT 횟수를 결정한다",
|
||||
"question": "왜 한 번의 피드 요청에서 반환한 FeedItem 수 N이 Highlight 추가 조회 N회로 이어지는가?",
|
||||
"type": "data-flow",
|
||||
"direction": "LR",
|
||||
"audience": [
|
||||
"JPA 기반 피드 조회의 N+1 원인을 진단하는 개발자"
|
||||
],
|
||||
"summary": "한 페이지에서 N개의 FeedItem을 반환하면 각 부모의 Highlight 컬렉션을 한 번씩 초기화해 추가 SELECT도 N회 발생한다.",
|
||||
"alt": "FeedItem N개를 반환하는 loadFeed 요청이 컬렉션 초기화 N회와 Highlight SELECT N회로 이어지는 인과 흐름도.",
|
||||
"long_description": "왼쪽의 loadFeed 요청은 한 페이지에서 N개의 FeedItem을 반환한다. 매핑이 각 부모의 Highlight 컬렉션에 접근하면 컬렉션 초기화가 부모마다 한 번씩 일어나 총 N회가 된다. 현재 기준선에서는 배치나 서브셀렉트가 없어 초기화 한 번마다 Highlight SELECT도 한 번 실행되므로 추가 조회가 N회 발생한다. 각 SELECT는 해당 부모의 Highlight 자식 행을 전부 읽는다.",
|
||||
"source_context": {
|
||||
"document": "/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md",
|
||||
"document_sha256": "1bb38964ca2a849690f5355646c1aa988111b4cd4e1055c6cb05cf7e5fc35cde",
|
||||
"anchor": {
|
||||
"kind": "marker",
|
||||
"value": "nplus1-query-fanout",
|
||||
"line": 380
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"profile": "component-flow",
|
||||
"diagram_only": true,
|
||||
"reference_ids": [
|
||||
"payment-event-flow"
|
||||
],
|
||||
"rationale": "원문은 서로 다른 저장소로 분산되는 라우팅이 아니라 반환 부모 수가 컬렉션 초기화와 반복 SELECT를 차례로 유발하는 인과 경로를 설명하므로 component-flow가 가장 정확하다.",
|
||||
"focus_node": "collection-initializations"
|
||||
},
|
||||
"groups": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "feed-request",
|
||||
"label": "loadFeed(0, N) → FeedItem N개",
|
||||
"kind": "request",
|
||||
"role": "source",
|
||||
"shape": "box",
|
||||
"details": [
|
||||
"page size = 반환 부모 수 N"
|
||||
],
|
||||
"description": "한 요청에서 반환한 부모 수 N이 N+1 증가 계수가 되는 피드 조회.",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 341,
|
||||
"end_line": 341
|
||||
},
|
||||
{
|
||||
"start_line": 398,
|
||||
"end_line": 398
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "collection-initializations",
|
||||
"label": "Highlight 컬렉션 초기화 N회",
|
||||
"kind": "operation",
|
||||
"role": "service",
|
||||
"shape": "box",
|
||||
"details": [
|
||||
"collectionFetches = N"
|
||||
],
|
||||
"emphasis": "primary",
|
||||
"description": "각 FeedItem의 지연 컬렉션 접근이 부모마다 한 번의 초기화를 만든 결과.",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 337,
|
||||
"end_line": 337
|
||||
},
|
||||
{
|
||||
"start_line": 378,
|
||||
"end_line": 384
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "highlight-selects",
|
||||
"label": "Highlight SELECT N회",
|
||||
"kind": "query",
|
||||
"role": "sink",
|
||||
"shape": "box",
|
||||
"details": [
|
||||
"부모별 자식 행 전부 조회"
|
||||
],
|
||||
"description": "배치와 서브셀렉트가 없는 기준선에서 컬렉션 초기화마다 실행되는 자식 SELECT.",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 337,
|
||||
"end_line": 337
|
||||
},
|
||||
{
|
||||
"start_line": 384,
|
||||
"end_line": 385
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "parents-trigger-initialization",
|
||||
"from": "feed-request",
|
||||
"to": "collection-initializations",
|
||||
"label": "아이템마다 컬렉션 접근",
|
||||
"kind": "request",
|
||||
"style": "solid",
|
||||
"emphasis": "primary",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 384,
|
||||
"end_line": 384
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "initialization-runs-select",
|
||||
"from": "collection-initializations",
|
||||
"to": "highlight-selects",
|
||||
"label": "초기화마다 SELECT 1회",
|
||||
"kind": "request",
|
||||
"style": "solid",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 337,
|
||||
"end_line": 337
|
||||
},
|
||||
{
|
||||
"start_line": 384,
|
||||
"end_line": 384
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
}
|
||||
],
|
||||
"legend": [],
|
||||
"metadata": {
|
||||
"rationale": "정량 표는 본문에 남기고, 그림은 부모 수 N이 초기화와 SELECT 횟수 N을 만드는 단일 인과 관계에 집중한다."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"version": "1.1",
|
||||
"id": "query-port-boundary",
|
||||
"title": "조회 전략은 FeedQueryPort 뒤의 퍼시스턴스 어댑터에 격리된다",
|
||||
"question": "GET /feed 조회는 어떤 상위 계층을 거쳐 포트에 도달하며, 실제 조회 전략은 어디에 격리되는가?",
|
||||
"type": "architecture",
|
||||
"direction": "LR",
|
||||
"audience": [
|
||||
"백엔드 개발자",
|
||||
"아키텍처 검토자"
|
||||
],
|
||||
"summary": "FeedController는 조회 유스케이스를 호출하고, 유스케이스는 FeedQueryPort에 의존하며, FeedQueryAdapter가 포트를 구현해 PostgreSQL 조회 전략을 맡는다.",
|
||||
"alt": "GET /feed를 받는 FeedController에서 GetFeedUseCase와 FeedQueryPort로 이어지고 FeedQueryAdapter가 포트를 구현하는 포트·어댑터 구조.",
|
||||
"long_description": "왼쪽의 FeedController가 GET /feed 요청을 받아 중앙의 GetFeedUseCase에 조회를 위임한다. 유스케이스는 오른쪽의 FeedQueryPort에 조회를 의존한다. FeedQueryAdapter는 FeedQueryPort를 구현하는 아웃바운드 어댑터이며 PostgreSQL 조회를 수행한다. Fetch Join, Batch Fetch, DTO Projection, 윈도우 함수 같은 구체 전략은 이 어댑터의 책임이므로 상위 계층은 전략 교체의 영향을 받지 않는다.",
|
||||
"source_context": {
|
||||
"document": "/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md",
|
||||
"document_sha256": "1bb38964ca2a849690f5355646c1aa988111b4cd4e1055c6cb05cf7e5fc35cde",
|
||||
"anchor": {
|
||||
"kind": "marker",
|
||||
"value": "query-port-boundary",
|
||||
"line": 297
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"profile": "ports-adapters",
|
||||
"diagram_only": true,
|
||||
"reference_ids": [
|
||||
"order-ports-adapters"
|
||||
],
|
||||
"rationale": "문서의 핵심은 상위 웹·애플리케이션 계층과 교체 가능한 조회 전략 사이의 포트 의존 및 어댑터 구현 방향이므로 ports-adapters 구성이 직접 답한다.",
|
||||
"focus_node": "get-feed-use-case"
|
||||
},
|
||||
"groups": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "feed-controller",
|
||||
"label": "FeedController",
|
||||
"kind": "adapter",
|
||||
"role": "inbound-adapter",
|
||||
"details": [
|
||||
"GET /feed"
|
||||
],
|
||||
"description": "조회 입력과 FeedSummary 반환 형태만 아는 웹 계층.",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 295,
|
||||
"end_line": 295
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "get-feed-use-case",
|
||||
"label": "GetFeedUseCase",
|
||||
"kind": "application",
|
||||
"role": "core",
|
||||
"shape": "hexagon",
|
||||
"emphasis": "primary",
|
||||
"description": "조회 사용자, 페이지 크기, FeedSummary 계약만 아는 애플리케이션 계층.",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 295,
|
||||
"end_line": 295
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "feed-query-port",
|
||||
"label": "FeedQueryPort",
|
||||
"kind": "interface",
|
||||
"role": "port",
|
||||
"shape": "port",
|
||||
"description": "상위 계층과 구체 조회 전략을 분리하는 조회 포트.",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 295,
|
||||
"end_line": 295
|
||||
},
|
||||
{
|
||||
"start_line": 299,
|
||||
"end_line": 299
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "feed-query-adapter",
|
||||
"label": "FeedQueryAdapter",
|
||||
"kind": "adapter",
|
||||
"role": "outbound-adapter",
|
||||
"description": "FeedQueryPort를 구현하며 구체 조회 전략을 책임지는 퍼시스턴스 어댑터.",
|
||||
"details": [
|
||||
"PostgreSQL 조회",
|
||||
"Fetch Join · Batch Fetch",
|
||||
"DTO Projection · 윈도우 함수"
|
||||
],
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 295,
|
||||
"end_line": 295
|
||||
},
|
||||
{
|
||||
"start_line": 299,
|
||||
"end_line": 299
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "controller-to-use-case",
|
||||
"from": "feed-controller",
|
||||
"to": "get-feed-use-case",
|
||||
"label": "GET /feed 조회 위임",
|
||||
"kind": "request",
|
||||
"emphasis": "primary",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 295,
|
||||
"end_line": 295
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "use-case-to-port",
|
||||
"from": "get-feed-use-case",
|
||||
"to": "feed-query-port",
|
||||
"label": "조회 의존",
|
||||
"kind": "dependency",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 295,
|
||||
"end_line": 295
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "adapter-implements-port",
|
||||
"from": "feed-query-adapter",
|
||||
"to": "feed-query-port",
|
||||
"label": "implements",
|
||||
"kind": "dependency",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 295,
|
||||
"end_line": 295
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
}
|
||||
],
|
||||
"legend": [],
|
||||
"metadata": {
|
||||
"rationale": "구체 조회 기법보다 웹·애플리케이션 계층, FeedQueryPort, 퍼시스턴스 어댑터 사이의 의존 경계를 한 수준에서 보여준다."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"version": "1.1",
|
||||
"id": "skew-profile",
|
||||
"title": "Zipf-like 분포만 무거운 머리와 긴 꼬리를 함께 재현한다",
|
||||
"question": "균일·정규분포와 비교할 때 왜 Zipf-like 분포가 하이라이트 조회의 스트레스 데이터에 적합한가?",
|
||||
"type": "concept",
|
||||
"direction": "LR",
|
||||
"audience": [
|
||||
"백엔드 엔지니어",
|
||||
"성능 실험 설계를 검토하는 독자"
|
||||
],
|
||||
"summary": "균일분포와 정규분포는 극단적으로 많은 소수를 없애지만, 선택한 Zipf-like 합성 분포는 무거운 머리와 긴 꼬리를 만들어 대량 하이라이트와 Top-N 필요성을 재현한다.",
|
||||
"alt": "균일분포, 정규분포, Zipf-like 합성 분포를 분포 형태와 극단적 소수, 스트레스 조건 재현 여부, 선택 결과로 나란히 비교한 도표.",
|
||||
"long_description": "왼쪽부터 균일분포, 정규분포, Zipf-like 합성 분포를 같은 네 기준으로 비교한다. 균일분포는 모든 아이템이 3개이고, 정규분포는 평균 근처에 몰려 둘 다 극단적으로 많은 소수를 만들지 못하므로 제외된다. Zipf-like 분포는 소수의 인기 아이템이 압도적인 무거운 머리와 나머지의 긴 꼬리를 만들며, 지수 s=1.15와 상한 500·하한 1을 사용해 매우 많은 하이라이트 조건과 Top-N 필요성을 재현하는 합성 스트레스 분포로 선택된다.",
|
||||
"source_context": {
|
||||
"document": "/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md",
|
||||
"document_sha256": "1bb38964ca2a849690f5355646c1aa988111b4cd4e1055c6cb05cf7e5fc35cde",
|
||||
"anchor": {
|
||||
"kind": "marker",
|
||||
"value": "skew-profile",
|
||||
"line": 192
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"profile": "comparison",
|
||||
"diagram_only": true,
|
||||
"reference_ids": [
|
||||
"contract-comparison"
|
||||
],
|
||||
"rationale": "문서가 균일·정규분포를 제외하고 Zipf-like 분포를 선택한 근거를 같은 비교 기준으로 직접 대조하므로, 호출 관계를 만들지 않는 정렬된 comparison 구성이 핵심 주장에 가장 적합하다.",
|
||||
"focus_node": "zipf-like"
|
||||
},
|
||||
"groups": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "uniform",
|
||||
"label": "균일분포",
|
||||
"kind": "distribution",
|
||||
"role": "option",
|
||||
"description": "모든 아이템에 하이라이트 3개를 주는 분포로, 매우 많은 하이라이트 조건을 재현하지 못한다.",
|
||||
"details": [
|
||||
"분포 형태: 모두 3개",
|
||||
"극단적 소수: 없음",
|
||||
"스트레스 조건: 재현 못함",
|
||||
"선택 결과: 제외"
|
||||
],
|
||||
"emphasis": "muted",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 177,
|
||||
"end_line": 177
|
||||
},
|
||||
{
|
||||
"start_line": 195,
|
||||
"end_line": 195
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "normal",
|
||||
"label": "정규분포",
|
||||
"kind": "distribution",
|
||||
"role": "option",
|
||||
"description": "평균 근처에 몰려 극단적으로 많은 소수와 무거운 머리를 만들지 못하는 분포다.",
|
||||
"details": [
|
||||
"분포 형태: 평균 근처 집중",
|
||||
"극단적 소수: 없음",
|
||||
"스트레스 조건: 재현 못함",
|
||||
"선택 결과: 제외"
|
||||
],
|
||||
"emphasis": "muted",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 177,
|
||||
"end_line": 177
|
||||
},
|
||||
{
|
||||
"start_line": 196,
|
||||
"end_line": 196
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "zipf-like",
|
||||
"label": "Zipf-like 합성 분포",
|
||||
"kind": "distribution",
|
||||
"role": "option",
|
||||
"description": "순위 기반 지수로 편중 강도를 조절하며 무거운 머리와 긴 꼬리를 재현하는 합성 스트레스 분포다.",
|
||||
"details": [
|
||||
"분포 형태: 무거운 머리 + 긴 꼬리",
|
||||
"극단적 소수: 있음 · 1위 500개",
|
||||
"스트레스 조건: 재현",
|
||||
"선택 결과: s=1.15 합성 분포"
|
||||
],
|
||||
"emphasis": "primary",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 177,
|
||||
"end_line": 177
|
||||
},
|
||||
{
|
||||
"start_line": 181,
|
||||
"end_line": 181
|
||||
},
|
||||
{
|
||||
"start_line": 184,
|
||||
"end_line": 184
|
||||
},
|
||||
{
|
||||
"start_line": 188,
|
||||
"end_line": 190
|
||||
},
|
||||
{
|
||||
"start_line": 197,
|
||||
"end_line": 197
|
||||
},
|
||||
{
|
||||
"start_line": 203,
|
||||
"end_line": 203
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
"legend": [],
|
||||
"metadata": {
|
||||
"rationale": "세 분포를 동일한 네 항목으로 맞춰 비교하고, 문서가 직접 제시한 제외·선택 이유만 포함했다."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
{
|
||||
"version": "1.1",
|
||||
"id": "strategy-journey",
|
||||
"title": "두 형제 문제는 Fetch Join에서 합류한 뒤 단계별 해법으로 최종 구조가 된다",
|
||||
"question": "과제 요구사항에서 시작해 기준선의 N1·N2 분기와 실패·개선을 거쳐 최종 피드 조회 구조는 어떻게 발전하는가?",
|
||||
"type": "flow",
|
||||
"direction": "LR",
|
||||
"audience": [
|
||||
"백엔드 엔지니어",
|
||||
"성능 개선 과정을 검토하는 독자"
|
||||
],
|
||||
"summary": "요구사항과 모델을 거친 기준선에서 N1·N2가 동시에 갈라져 Fetch Join으로 합류하고, 실패 뒤 Batch Fetch·DTO Projection·Top-3·Keyset·가시성 인덱싱을 차례로 거쳐 최종 피드 조회 구조에 도달한다.",
|
||||
"alt": "요구사항과 모델에서 기준선으로 진행한 뒤 N1과 N2로 분기하고 Fetch Join에서 합류해, 실패와 다섯 개선 단계를 거쳐 최종 피드 조회 구조에 이르는 흐름도.",
|
||||
"long_description": "왼쪽에서 과제 요구사항, 도메인·데이터 모델, 최초 피드 조회 기준선 순으로 시작한다. 기준선에서 컬렉션 N+1(N1)과 User·Page 연관의 숨은 쿼리(N2)가 서로 앞뒤가 아닌 형제 문제로 동시에 갈라지고, 두 경로는 Fetch Join 시도에서 합류한다. 이 시도는 다중 컬렉션·페이징 실패로 이어진다. 마지막 노드는 Batch Fetch, DTO Projection, 아이템별 Top-3, Keyset Pagination, 가시성 조건 인덱싱을 거쳐 최종 피드 조회 구조에 도달하는 순서를 담는다.",
|
||||
"source_context": {
|
||||
"document": "/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md",
|
||||
"document_sha256": "1bb38964ca2a849690f5355646c1aa988111b4cd4e1055c6cb05cf7e5fc35cde",
|
||||
"anchor": {
|
||||
"kind": "marker",
|
||||
"value": "strategy-journey",
|
||||
"line": 27
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"profile": "component-flow",
|
||||
"diagram_only": true,
|
||||
"reference_ids": [
|
||||
"payment-event-flow"
|
||||
],
|
||||
"rationale": "요구사항에서 최종 조회 구조까지 한 방향으로 발전하면서 기준선의 두 형제 문제가 분기했다가 Fetch Join 시도에서 합류하므로, 좌측 출발점·중앙의 분기와 합류·우측 종착점을 갖는 component-flow가 전체 여정을 가장 직접적으로 드러낸다.",
|
||||
"focus_node": "fetch-join-failure"
|
||||
},
|
||||
"groups": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "requirements-model-baseline",
|
||||
"label": "요구사항·모델·기준선",
|
||||
"kind": "journey-stage",
|
||||
"role": "source",
|
||||
"description": "전체 여정은 과제 요구사항에서 도메인·데이터 모델을 거쳐 최초 피드 조회 기준선으로 시작한다.",
|
||||
"details": [
|
||||
"1 과제 요구사항",
|
||||
"2 도메인·데이터 모델",
|
||||
"3 최초 피드 조회"
|
||||
],
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 25,
|
||||
"end_line": 25
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "collection-n-plus-one",
|
||||
"label": "컬렉션 N+1 (N1)",
|
||||
"kind": "problem",
|
||||
"role": "service",
|
||||
"description": "기준선에서 N2와 동시에 나타나는 컬렉션 조회 문제다.",
|
||||
"details": [
|
||||
"유형: 컬렉션 N+1",
|
||||
"발생: 기준선과 동시에",
|
||||
"관계: N2와 형제 문제",
|
||||
"진행: Fetch Join으로 합류"
|
||||
],
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 25,
|
||||
"end_line": 25
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "hidden-to-one-queries",
|
||||
"label": "User·Page 숨은 쿼리 (N2)",
|
||||
"kind": "problem",
|
||||
"role": "service",
|
||||
"description": "기준선에서 N1과 동시에 나타나는 User·Page 연관의 숨은 쿼리 문제다.",
|
||||
"details": [
|
||||
"유형: User·Page 숨은 쿼리",
|
||||
"발생: 기준선과 동시에",
|
||||
"관계: N1과 형제 문제",
|
||||
"진행: Fetch Join으로 합류"
|
||||
],
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 25,
|
||||
"end_line": 25
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "fetch-join-failure",
|
||||
"label": "Fetch Join → 실패",
|
||||
"kind": "query-strategy",
|
||||
"role": "service",
|
||||
"description": "N1과 N2가 Fetch Join 시도에서 합류한 뒤 다중 컬렉션·페이징 실패로 이어진다.",
|
||||
"details": [
|
||||
"1 N1·N2 합류",
|
||||
"2 다중 컬렉션·페이징 실패"
|
||||
],
|
||||
"emphasis": "warning",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 25,
|
||||
"end_line": 25
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "improvement-chain",
|
||||
"label": "후속 개선 → 최종 구조",
|
||||
"kind": "query-strategy",
|
||||
"role": "sink",
|
||||
"description": "실패 뒤 다섯 조회 전략 단계가 명시된 순서로 발전해 최종 피드 조회 구조를 만든다.",
|
||||
"details": [
|
||||
"1 Batch Fetch",
|
||||
"2 DTO Projection",
|
||||
"3 아이템별 Top-3",
|
||||
"4 Keyset Pagination",
|
||||
"5 가시성 조건 인덱싱",
|
||||
"6 최종 피드 조회 구조"
|
||||
],
|
||||
"emphasis": "primary",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 25,
|
||||
"end_line": 25
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "baseline-reveals-n1",
|
||||
"from": "requirements-model-baseline",
|
||||
"to": "collection-n-plus-one",
|
||||
"label": "기준선에서 갈라짐",
|
||||
"kind": "problem",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 25,
|
||||
"end_line": 25
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "baseline-reveals-n2",
|
||||
"from": "requirements-model-baseline",
|
||||
"to": "hidden-to-one-queries",
|
||||
"label": "기준선에서 갈라짐",
|
||||
"kind": "problem",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 25,
|
||||
"end_line": 25
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "n1-joins-fetch-join",
|
||||
"from": "collection-n-plus-one",
|
||||
"to": "fetch-join-failure",
|
||||
"label": "Fetch Join으로 합류",
|
||||
"kind": "flow",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 25,
|
||||
"end_line": 25
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "n2-joins-fetch-join",
|
||||
"from": "hidden-to-one-queries",
|
||||
"to": "fetch-join-failure",
|
||||
"label": "Fetch Join으로 합류",
|
||||
"kind": "flow",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 25,
|
||||
"end_line": 25
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "failure-to-improvements",
|
||||
"from": "fetch-join-failure",
|
||||
"to": "improvement-chain",
|
||||
"label": "실패 뒤 단계별 개선",
|
||||
"kind": "improvement",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 25,
|
||||
"end_line": 25
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
}
|
||||
],
|
||||
"legend": [],
|
||||
"metadata": {
|
||||
"rationale": "line 25의 전체 순서를 유지하면서 문서 폭에 맞추기 위해 연속된 출발 세 단계, Fetch Join과 그 실패, 실패 뒤 다섯 개선 단계를 각각 하나의 단계 노드 안에 번호로 묶었다."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"version": "1.1",
|
||||
"id": "target-schema",
|
||||
"title": "목표 스키마에 추가되는 mention 관계",
|
||||
"question": "목표 모델에서 기존 users는 mentioned 사용자 역할로 feed_item_mentions에 어떻게 연결되는가?",
|
||||
"type": "erd",
|
||||
"direction": "LR",
|
||||
"audience": [
|
||||
"백엔드 개발자"
|
||||
],
|
||||
"summary": "기준선 관계에 feed_items와 기존 users를 잇는 feed_item_mentions 관계가 추가된다.",
|
||||
"alt": "기존 users를 mentioned 사용자 역할로 재사용해 feed_item_mentions와 연결한 5노드 목표 관계도.",
|
||||
"long_description": "왼쪽의 users와 pages가 중앙의 feed_items에 연결된다. 오른쪽에는 highlights와 feed_item_mentions가 놓인다. feed_items는 두 엔티티에 각각 연결되고, 기존 users도 mentioned 사용자 역할로 feed_item_mentions에 연결된다.",
|
||||
"source_context": {
|
||||
"document": "/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md",
|
||||
"document_sha256": "1bb38964ca2a849690f5355646c1aa988111b4cd4e1055c6cb05cf7e5fc35cde",
|
||||
"anchor": {
|
||||
"kind": "marker",
|
||||
"value": "target-schema",
|
||||
"line": 45
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"profile": "component-flow",
|
||||
"diagram_only": true,
|
||||
"reference_ids": [
|
||||
"payment-event-flow"
|
||||
],
|
||||
"rationale": "기준선의 연결 경로와 feed_item_mentions를 통한 추가 연결을 하나의 방향성 있는 관계망으로 읽게 하는 구조가 목표 모델의 차이를 직접 드러낸다.",
|
||||
"focus_node": "feed-items"
|
||||
},
|
||||
"groups": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "users",
|
||||
"label": "users",
|
||||
"kind": "entity",
|
||||
"role": "source",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 35,
|
||||
"end_line": 35
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "pages",
|
||||
"label": "pages",
|
||||
"kind": "entity",
|
||||
"role": "source",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 36,
|
||||
"end_line": 36
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "feed-items",
|
||||
"label": "feed_items",
|
||||
"kind": "entity",
|
||||
"role": "store",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 35,
|
||||
"end_line": 35
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "highlights",
|
||||
"label": "highlights",
|
||||
"kind": "entity",
|
||||
"role": "sink",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 37,
|
||||
"end_line": 37
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "feed-item-mentions",
|
||||
"label": "feed_item_mentions",
|
||||
"kind": "entity",
|
||||
"role": "sink",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 43,
|
||||
"end_line": 43
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "users-have-feed-items",
|
||||
"from": "users",
|
||||
"to": "feed-items",
|
||||
"label": "여러 feed_item을 가짐",
|
||||
"kind": "relationship",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 35,
|
||||
"end_line": 35
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "pages-have-feed-items",
|
||||
"from": "pages",
|
||||
"to": "feed-items",
|
||||
"label": "여러 feed_item이 딸림",
|
||||
"kind": "relationship",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 36,
|
||||
"end_line": 36
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "feed-items-have-highlights",
|
||||
"from": "feed-items",
|
||||
"to": "highlights",
|
||||
"label": "여러 highlights를 가짐",
|
||||
"kind": "relationship",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 37,
|
||||
"end_line": 37
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "feed-items-to-mentions",
|
||||
"from": "feed-items",
|
||||
"to": "feed-item-mentions",
|
||||
"label": "피드 아이템을 연결",
|
||||
"kind": "relationship",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 43,
|
||||
"end_line": 43
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
},
|
||||
{
|
||||
"id": "users-to-feed-item-mentions",
|
||||
"from": "users",
|
||||
"to": "feed-item-mentions",
|
||||
"label": "mentioned 사용자로 연결",
|
||||
"kind": "relationship",
|
||||
"evidence": [
|
||||
{
|
||||
"start_line": 43,
|
||||
"end_line": 43
|
||||
}
|
||||
],
|
||||
"assumption": false
|
||||
}
|
||||
],
|
||||
"legend": [],
|
||||
"metadata": {
|
||||
"rationale": "기준선 관계를 유지하면서 line 43의 feed_item_mentions가 기존 users를 mentioned 사용자 역할로 참조하는 관계만 추가했다."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# assets — 발표 슬라이드용 스크린샷
|
||||
|
||||
[../document.md](../document.md)는 실측 수치·실행계획을 텍스트로 담아 그대로 렌더된다. 슬라이드에서 화면 캡처로 보여주고 싶을 때 아래를 여기에 저장한다.
|
||||
|
||||
| 파일명 | 캡처 대상 |
|
||||
|---|---|
|
||||
| `feed-nplus1-sql-log.png` | 피드 조회 시 highlights 조회가 아이템마다 반복되는 SQL 로그 |
|
||||
| `curve-console.png` | `=== L1 N=10/100/1000 ... collectionFetches=10/100/1000 ...` 곡선 콘솔 |
|
||||
| `explain-highlights-index-scan.png` | 반복되는 하이라이트 조회 EXPLAIN(`Index Scan ... Execution Time 0.173ms`) |
|
||||
| `explain-feed-items-seqscan.png` | 목록 쿼리 EXPLAIN(정렬키 인덱스 없어 Seq Scan + Sort) |
|
||||
|
||||
콘솔·실행계획 원문은 `./gradlew :app-bootstrap:test --tests '*FeedPersistenceIT*'` 실행 후 `src/app-bootstrap/build/test-results/test/TEST-*FeedPersistenceIT*.xml`의 system-out에서 뽑을 수 있다.
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
|
||||
<diagram id="baseline-schema" name="기준선 스키마의 관계">
|
||||
<mxGraphModel dx="885" dy="305" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="885" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="n_users" value="users" tooltip="entity | Evidence: L35-L35" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="70.0" y="60.0" width="150.0" height="64.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_pages" value="pages" tooltip="entity | Evidence: L36-L36" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="70.0" y="196.0" width="150.0" height="64.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_feed-items" value="feed_items" tooltip="entity | Evidence: L35-L35" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="380.0" y="128.0" width="150.0" height="64.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_highlights" value="highlights" tooltip="entity | Evidence: L37-L37" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="690.0" y="128.0" width="150.0" height="64.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="e_feed-items-have-highlights" value="여러 highlights를 가짐" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_feed-items" target="n_highlights">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="610.0" y="132.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_pages-have-feed-items" value="여러 feed_item이 딸림" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_pages" target="n_feed-items">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="324.0" y="198.5" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_users-have-feed-items" value="여러 feed_item을 가짐" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_users" target="n_feed-items">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="324.0" y="121.5" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="885" height="305" viewBox="0 0 885 305" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">기준선 스키마의 관계</title>
|
||||
<desc id="diagram-description">왼쪽의 users와 pages가 각각 중앙의 feed_items에 연결된다. feed_items는 오른쪽의 highlights로 이어진다. 간선은 user와 page 각각에 여러 feed_item이 연결되고, 한 feed_item에 여러 highlight가 연결되는 관계를 나타낸다.</desc>
|
||||
<metadata>{"techviz":{"spec_version":"1.1","id":"baseline-schema","profile":"component-flow"},"source_context":{"document":"/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md","document_sha256":"1bb38964ca2a849690f5355646c1aa988111b4cd4e1055c6cb05cf7e5fc35cde","anchor":{"kind":"marker","value":"baseline-schema","line":39}},"evidence_policy":"Each factual element cites source lines or is marked assumption.","diagram_only":true}</metadata>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.canvas { fill: #ffffff; }
|
||||
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||||
.group-label-bg { fill: #ffffff; }
|
||||
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
|
||||
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
|
||||
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
|
||||
.edge.style-dotted { stroke-dasharray: 2 5; }
|
||||
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.edge.emphasis-muted { stroke: #9ca3af; }
|
||||
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
|
||||
.edge-label-bg { fill: #ffffff; }
|
||||
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
|
||||
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
|
||||
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
|
||||
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
|
||||
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
|
||||
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
|
||||
.node-shape.assumption { stroke-dasharray: 4 4; }
|
||||
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
|
||||
.controller-led { fill: #4b5563; }
|
||||
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
|
||||
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
|
||||
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
|
||||
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
|
||||
.node-detail { font-size: 11px; fill: #374151; }
|
||||
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
|
||||
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
|
||||
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
|
||||
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
|
||||
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
|
||||
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
|
||||
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
|
||||
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
|
||||
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
|
||||
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="885" height="305" />
|
||||
<polyline class="edge kind-relationship style-solid emphasis-normal" points="530.0,160.0 610.0,160.0 610.0,160.0 690.0,160.0" data-evidence="37-37" />
|
||||
<rect class="edge-label-bg" x="544.0" y="118.0" width="131.9" height="22" rx="3" />
|
||||
<text class="edge-label" x="610.0" y="133.0">여러 highlights를 가짐</text>
|
||||
<polyline class="edge kind-relationship style-solid emphasis-normal" points="220.0,228.0 300.0,228.0 300.0,169.0 380.0,169.0" data-evidence="36-36" />
|
||||
<rect class="edge-label-bg" x="261.4" y="184.5" width="125.2" height="22" rx="3" />
|
||||
<text class="edge-label" x="324.0" y="199.5">여러 feed_item이 딸림</text>
|
||||
<polyline class="edge kind-relationship style-solid emphasis-normal" points="220.0,92.0 300.0,92.0 300.0,151.0 380.0,151.0" data-evidence="35-35" />
|
||||
<rect class="edge-label-bg" x="261.4" y="107.5" width="125.2" height="22" rx="3" />
|
||||
<text class="edge-label" x="324.0" y="122.5">여러 feed_item을 가짐</text>
|
||||
<g id="node-users">
|
||||
<rect class="node-shape kind-entity emphasis-normal role-source" data-evidence="35-35" x="70.0" y="60.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="145.0" y="90.0">users</text>
|
||||
</g>
|
||||
<g id="node-pages">
|
||||
<rect class="node-shape kind-entity emphasis-normal role-source" data-evidence="36-36" x="70.0" y="196.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="145.0" y="226.0">pages</text>
|
||||
</g>
|
||||
<g id="node-feed-items">
|
||||
<rect class="node-shape kind-entity emphasis-normal role-store" data-evidence="35-35" x="380.0" y="128.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="455.0" y="158.0">feed_items</text>
|
||||
</g>
|
||||
<g id="node-highlights">
|
||||
<rect class="node-shape kind-entity emphasis-normal role-sink" data-evidence="37-37" x="690.0" y="128.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="765.0" y="158.0">highlights</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
|
||||
<diagram id="eager-lazy-query-sequence" name="EAGER 2차 조회는 반환 전에, LAZY highlights 조회는 매핑 접근 뒤에 실행된다">
|
||||
<mxGraphModel dx="680" dy="542" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="n_load-feed-mapping" value="loadFeed DTO 매핑" tooltip="FeedItem을 순회하며 응답 DTO를 조립하고 highlights 게터에 접근하는 호출자. | Evidence: L322-L322, L449-L451" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="45.0" y="35.0" width="150.0" height="64.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_hibernate" value="Hibernate" tooltip="파생 쿼리의 루트 조회와 EAGER 2차 SELECT, LAZY 컬렉션 초기화를 수행하는 JPA provider. | Evidence: L320-L322" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="255.0" y="35.0" width="150.0" height="64.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_postgresql" value="PostgreSQL" tooltip="Hibernate가 루트 및 연관 SELECT를 실행하는 데이터베이스. | Evidence: L417-L422, L447-L447" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=cylinder3;boundedLbl=1;backgroundOutline=1;fillColor=#eef6fb;" vertex="1" parent="1">
|
||||
<mxGeometry x="465.0" y="35.0" width="150.0" height="64.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="e_m1-find-all" value="findAllBy(...)" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_load-feed-mapping" target="n_hibernate">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="225.0" y="128.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_m2-root-select" value="SELECT feed_items" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_hibernate" target="n_postgresql">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="435.0" y="190.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_m3-eager-secondary-selects" value="SELECT user / page · EAGER 2차" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_hibernate" target="n_postgresql">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="435.0" y="252.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_m4-return-eager-loaded-items" value="EAGER 연관이 채워진 FeedItem 반환" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_hibernate" target="n_load-feed-mapping">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="225.0" y="314.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_m5-access-highlights" value="매핑 중 getHighlights() 접근" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_load-feed-mapping" target="n_hibernate">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="225.0" y="376.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_m6-lazy-highlights-select" value="SELECT highlights WHERE feed_item_id = ?" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_hibernate" target="n_postgresql">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="435.0" y="438.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="542" viewBox="0 0 680 542" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">EAGER 2차 조회는 반환 전에, LAZY highlights 조회는 매핑 접근 뒤에 실행된다</title>
|
||||
<desc id="diagram-description">세 참가자를 왼쪽부터 loadFeed DTO 매핑, Hibernate, PostgreSQL 순으로 읽는다. loadFeed가 findAllBy 파생 쿼리를 호출하면 Hibernate가 PostgreSQL에서 feed_items를 먼저 조회한다. 이어 fetch join되지 않은 EAGER user와 page를 별도의 2차 SELECT로 채우고, 반환 시점까지 로딩된 FeedItem을 loadFeed에 돌려준다. 이후 DTO 매핑이 getHighlights()에 접근하면 Hibernate가 해당 아이템의 highlights 컬렉션 SELECT를 실행한다.</desc>
|
||||
<metadata>{"techviz":{"spec_version":"1.1","id":"eager-lazy-query-sequence","profile":"sequence"},"source_context":{"document":"/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md","document_sha256":"1bb38964ca2a849690f5355646c1aa988111b4cd4e1055c6cb05cf7e5fc35cde","anchor":{"kind":"marker","value":"eager-lazy-query-sequence","line":324}},"evidence_policy":"Each factual element cites source lines or is marked assumption.","diagram_only":true}</metadata>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.canvas { fill: #ffffff; }
|
||||
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||||
.group-label-bg { fill: #ffffff; }
|
||||
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
|
||||
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
|
||||
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
|
||||
.edge.style-dotted { stroke-dasharray: 2 5; }
|
||||
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.edge.emphasis-muted { stroke: #9ca3af; }
|
||||
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
|
||||
.edge-label-bg { fill: #ffffff; }
|
||||
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
|
||||
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
|
||||
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
|
||||
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
|
||||
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
|
||||
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
|
||||
.node-shape.assumption { stroke-dasharray: 4 4; }
|
||||
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
|
||||
.controller-led { fill: #4b5563; }
|
||||
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
|
||||
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
|
||||
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
|
||||
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
|
||||
.node-detail { font-size: 11px; fill: #374151; }
|
||||
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
|
||||
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
|
||||
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
|
||||
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
|
||||
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
|
||||
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
|
||||
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
|
||||
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
|
||||
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
|
||||
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="680" height="542" />
|
||||
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="322-322,449-451" x="45.0" y="35.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="120.0" y="65.0">loadFeed DTO 매핑</text>
|
||||
<line class="lifeline" x1="120.0" y1="99.0" x2="120.0" y2="512.0" />
|
||||
<rect class="node-shape kind-participant emphasis-primary role-participant" data-evidence="320-322" x="255.0" y="35.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="330.0" y="65.0">Hibernate</text>
|
||||
<line class="lifeline" x1="330.0" y1="99.0" x2="330.0" y2="512.0" />
|
||||
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="417-422,447-447" x="465.0" y="45.7" width="150.0" height="42.7" /><ellipse class="node-shape kind-participant emphasis-normal role-participant" cx="540.0" cy="45.7" rx="75.0" ry="10.7" /><path class="storage-bottom" d="M 465.0 88.3 A 75.0 10.7 0 0 0 615.0 88.3" />
|
||||
<text class="node-label" x="540.0" y="65.0">PostgreSQL</text>
|
||||
<line class="lifeline" x1="540.0" y1="99.0" x2="540.0" y2="512.0" />
|
||||
<polyline class="edge kind-request style-solid emphasis-normal" points="120.0,140.0 330.0,140.0" data-evidence="320-320" />
|
||||
<rect class="edge-label-bg" x="159.1" y="114.0" width="131.9" height="22" rx="3" />
|
||||
<text class="edge-label" x="225.0" y="129.0">1. findAllBy(...)</text>
|
||||
<polyline class="edge kind-data style-solid emphasis-primary" points="330.0,202.0 540.0,202.0" data-evidence="320-320" />
|
||||
<rect class="edge-label-bg" x="359.0" y="176.0" width="152.0" height="22" rx="3" />
|
||||
<text class="edge-label" x="435.0" y="191.0">2. SELECT feed_items</text>
|
||||
<polyline class="edge kind-data style-solid emphasis-normal" points="330.0,264.0 540.0,264.0" data-evidence="318-321" />
|
||||
<rect class="edge-label-bg" x="318.8" y="238.0" width="232.4" height="22" rx="3" />
|
||||
<text class="edge-label" x="435.0" y="253.0">3. SELECT user / page · EAGER 2차</text>
|
||||
<polyline class="edge kind-response style-dashed emphasis-normal semantic-dashed" points="330.0,326.0 120.0,326.0" data-evidence="318-320" />
|
||||
<rect class="edge-label-bg" x="122.2" y="300.0" width="205.6" height="22" rx="3" />
|
||||
<text class="edge-label" x="225.0" y="315.0">4. EAGER 연관이 채워진 FeedItem 반환</text>
|
||||
<polyline class="edge kind-request style-solid emphasis-normal" points="120.0,388.0 330.0,388.0" data-evidence="322-322,449-451" />
|
||||
<rect class="edge-label-bg" x="128.9" y="362.0" width="192.2" height="22" rx="3" />
|
||||
<text class="edge-label" x="225.0" y="377.0">5. 매핑 중 getHighlights() 접근</text>
|
||||
<polyline class="edge kind-data style-solid emphasis-primary" points="330.0,450.0 540.0,450.0" data-evidence="322-322,429-430" />
|
||||
<rect class="edge-label-bg" x="281.9" y="424.0" width="306.1" height="22" rx="3" />
|
||||
<text class="edge-label" x="435.0" y="439.0">6. SELECT highlights WHERE feed_item_id = ?</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
|
||||
<diagram id="nplus1-query-fanout" name="반환 부모 수 N이 컬렉션 초기화와 자식 SELECT 횟수를 결정한다">
|
||||
<mxGraphModel dx="964" dy="300" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="964" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="n_feed-request" value="loadFeed(0, N) → FeedItem N개<br/>page size = 반환 부모 수 N" tooltip="한 요청에서 반환한 부모 수 N이 N+1 증가 계수가 되는 피드 조회. | Evidence: L341-L341, L398-L398" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="70.0" y="60.0" width="181.0" height="91.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_collection-initializations" value="Highlight 컬렉션 초기화 N회<br/>collectionFetches = N" tooltip="각 FeedItem의 지연 컬렉션 접근이 부모마다 한 번의 초기화를 만든 결과. | Evidence: L337-L337, L378-L384" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="411.0" y="70.0" width="181.0" height="71.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_highlight-selects" value="Highlight SELECT N회<br/>부모별 자식 행 전부 조회" tooltip="배치와 서브셀렉트가 없는 기준선에서 컬렉션 초기화마다 실행되는 자식 SELECT. | Evidence: L337-L337, L384-L385" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="752.0" y="70.0" width="167.0" height="71.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="e_initialization-runs-select" value="초기화마다 SELECT 1회" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_collection-initializations" target="n_highlight-selects">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="672.0" y="77.5" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_parents-trigger-initialization" value="아이템마다 컬렉션 접근" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_feed-request" target="n_collection-initializations">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="331.0" y="77.5" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="964" height="300" viewBox="0 0 964 300" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">반환 부모 수 N이 컬렉션 초기화와 자식 SELECT 횟수를 결정한다</title>
|
||||
<desc id="diagram-description">왼쪽의 loadFeed 요청은 한 페이지에서 N개의 FeedItem을 반환한다. 매핑이 각 부모의 Highlight 컬렉션에 접근하면 컬렉션 초기화가 부모마다 한 번씩 일어나 총 N회가 된다. 현재 기준선에서는 배치나 서브셀렉트가 없어 초기화 한 번마다 Highlight SELECT도 한 번 실행되므로 추가 조회가 N회 발생한다. 각 SELECT는 해당 부모의 Highlight 자식 행을 전부 읽는다.</desc>
|
||||
<metadata>{"techviz":{"spec_version":"1.1","id":"nplus1-query-fanout","profile":"component-flow"},"source_context":{"document":"/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md","document_sha256":"1bb38964ca2a849690f5355646c1aa988111b4cd4e1055c6cb05cf7e5fc35cde","anchor":{"kind":"marker","value":"nplus1-query-fanout","line":380}},"evidence_policy":"Each factual element cites source lines or is marked assumption.","diagram_only":true}</metadata>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.canvas { fill: #ffffff; }
|
||||
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||||
.group-label-bg { fill: #ffffff; }
|
||||
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
|
||||
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
|
||||
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
|
||||
.edge.style-dotted { stroke-dasharray: 2 5; }
|
||||
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.edge.emphasis-muted { stroke: #9ca3af; }
|
||||
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
|
||||
.edge-label-bg { fill: #ffffff; }
|
||||
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
|
||||
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
|
||||
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
|
||||
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
|
||||
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
|
||||
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
|
||||
.node-shape.assumption { stroke-dasharray: 4 4; }
|
||||
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
|
||||
.controller-led { fill: #4b5563; }
|
||||
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
|
||||
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
|
||||
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
|
||||
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
|
||||
.node-detail { font-size: 11px; fill: #374151; }
|
||||
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
|
||||
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
|
||||
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
|
||||
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
|
||||
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
|
||||
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
|
||||
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
|
||||
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
|
||||
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
|
||||
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="964" height="300" />
|
||||
<polyline class="edge kind-request style-solid emphasis-normal" points="592.0,105.5 672.0,105.5 672.0,105.5 752.0,105.5" data-evidence="337-337,384-384" />
|
||||
<rect class="edge-label-bg" x="612.8" y="63.5" width="118.5" height="22" rx="3" />
|
||||
<text class="edge-label" x="672.0" y="78.5">초기화마다 SELECT 1회</text>
|
||||
<polyline class="edge kind-request style-solid emphasis-primary" points="251.0,105.5 331.0,105.5 331.0,105.5 411.0,105.5" data-evidence="384-384" />
|
||||
<rect class="edge-label-bg" x="281.8" y="63.5" width="98.4" height="22" rx="3" />
|
||||
<text class="edge-label" x="331.0" y="78.5">아이템마다 컬렉션 접근</text>
|
||||
<g id="node-feed-request">
|
||||
<rect class="node-shape kind-request emphasis-normal role-source" data-evidence="341-341,398-398" x="70.0" y="60.0" width="181.0" height="91.0" rx="7" />
|
||||
<text class="node-label" x="160.5" y="87.0">loadFeed(0, N) →</text>
|
||||
<text class="node-label" x="160.5" y="105.0">FeedItem N개</text>
|
||||
<line class="node-detail-divider" x1="84.0" y1="126.0" x2="237.0" y2="126.0" />
|
||||
<text class="node-detail" x="86.0" y="143.0">page size = 반환 부모 수 N</text>
|
||||
</g>
|
||||
<g id="node-collection-initializations">
|
||||
<rect class="node-shape kind-operation emphasis-primary role-service" data-evidence="337-337,378-384" x="411.0" y="70.0" width="181.0" height="71.0" rx="7" />
|
||||
<text class="node-label" x="501.5" y="97.0">Highlight 컬렉션 초기화 N회</text>
|
||||
<line class="node-detail-divider" x1="425.0" y1="118.0" x2="578.0" y2="118.0" />
|
||||
<text class="node-detail" x="427.0" y="135.0">collectionFetches = N</text>
|
||||
</g>
|
||||
<g id="node-highlight-selects">
|
||||
<rect class="node-shape kind-query emphasis-normal role-sink" data-evidence="337-337,384-385" x="752.0" y="70.0" width="167.0" height="71.0" rx="7" />
|
||||
<text class="node-label" x="835.5" y="97.0">Highlight SELECT N회</text>
|
||||
<line class="node-detail-divider" x1="766.0" y1="118.0" x2="905.0" y2="118.0" />
|
||||
<text class="node-detail" x="768.0" y="135.0">부모별 자식 행 전부 조회</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
|
||||
<diagram id="query-port-boundary" name="조회 전략은 FeedQueryPort 뒤의 퍼시스턴스 어댑터에 격리된다">
|
||||
<mxGraphModel dx="1090" dy="360" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1090" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="n_get-feed-use-case" value="GetFeedUseCase" tooltip="조회 사용자, 페이지 크기, FeedSummary 계약만 아는 애플리케이션 계층. | Evidence: L295-L295" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=hexagon;perimeter=hexagonPerimeter2;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="420.0" y="185.0" width="230.0" height="130.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_feed-controller" value="FeedController<br/>GET /feed" tooltip="조회 입력과 FeedSummary 반환 형태만 아는 웹 계층. | Evidence: L295-L295" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="45.0" y="85.0" width="210.0" height="94.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_feed-query-adapter" value="FeedQueryAdapter<br/>PostgreSQL 조회<br/>Fetch Join · Batch Fetch<br/>DTO Projection · 윈도우 함수" tooltip="FeedQueryPort를 구현하며 구체 조회 전략을 책임지는 퍼시스턴스 어댑터. | Evidence: L295-L295, L299-L299" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="825.0" y="85.0" width="220.0" height="126.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_feed-query-port" value="FeedQueryPort" tooltip="상위 계층과 구체 조회 전략을 분리하는 조회 포트. | Evidence: L295-L295, L299-L299" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;rounded=1;arcSize=50;fillColor=#f6f1fb;" vertex="1" parent="1">
|
||||
<mxGeometry x="670.0" y="155.0" width="125.0" height="58.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="e_adapter-implements-port" value="implements" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_feed-query-adapter" target="n_feed-query-port">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="834.8" y="231.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_controller-to-use-case" value="GET /feed 조회 위임" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_feed-controller" target="n_get-feed-use-case">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="361.5" y="191.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_use-case-to-port" value="조회 의존" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_get-feed-use-case" target="n_feed-query-port">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="704.0" y="132.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1090" height="360" viewBox="0 0 1090 360" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">조회 전략은 FeedQueryPort 뒤의 퍼시스턴스 어댑터에 격리된다</title>
|
||||
<desc id="diagram-description">왼쪽의 FeedController가 GET /feed 요청을 받아 중앙의 GetFeedUseCase에 조회를 위임한다. 유스케이스는 오른쪽의 FeedQueryPort에 조회를 의존한다. FeedQueryAdapter는 FeedQueryPort를 구현하는 아웃바운드 어댑터이며 PostgreSQL 조회를 수행한다. Fetch Join, Batch Fetch, DTO Projection, 윈도우 함수 같은 구체 전략은 이 어댑터의 책임이므로 상위 계층은 전략 교체의 영향을 받지 않는다.</desc>
|
||||
<metadata>{"techviz":{"spec_version":"1.1","id":"query-port-boundary","profile":"ports-adapters"},"source_context":{"document":"/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md","document_sha256":"1bb38964ca2a849690f5355646c1aa988111b4cd4e1055c6cb05cf7e5fc35cde","anchor":{"kind":"marker","value":"query-port-boundary","line":297}},"evidence_policy":"Each factual element cites source lines or is marked assumption.","diagram_only":true}</metadata>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.canvas { fill: #ffffff; }
|
||||
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||||
.group-label-bg { fill: #ffffff; }
|
||||
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
|
||||
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
|
||||
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
|
||||
.edge.style-dotted { stroke-dasharray: 2 5; }
|
||||
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.edge.emphasis-muted { stroke: #9ca3af; }
|
||||
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
|
||||
.edge-label-bg { fill: #ffffff; }
|
||||
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
|
||||
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
|
||||
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
|
||||
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
|
||||
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
|
||||
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
|
||||
.node-shape.assumption { stroke-dasharray: 4 4; }
|
||||
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
|
||||
.controller-led { fill: #4b5563; }
|
||||
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
|
||||
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
|
||||
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
|
||||
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
|
||||
.node-detail { font-size: 11px; fill: #374151; }
|
||||
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
|
||||
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
|
||||
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
|
||||
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
|
||||
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
|
||||
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
|
||||
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
|
||||
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
|
||||
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
|
||||
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="1090" height="360" />
|
||||
<polyline class="edge kind-dependency style-solid emphasis-normal" points="935.0,211.0 935.0,259.0 732.5,259.0 732.5,213.0" data-evidence="295-295" />
|
||||
<rect class="edge-label-bg" x="792.2" y="217.0" width="85.0" height="22" rx="3" />
|
||||
<text class="edge-label" x="834.8" y="232.0">implements</text>
|
||||
<polyline class="edge kind-request style-solid emphasis-primary" points="255.0,132.0 337.5,132.0 337.5,250.0 420.0,250.0" data-evidence="295-295" />
|
||||
<rect class="edge-label-bg" x="302.2" y="177.0" width="118.5" height="22" rx="3" />
|
||||
<text class="edge-label" x="361.5" y="192.0">GET /feed 조회 위임</text>
|
||||
<polyline class="edge kind-dependency style-solid emphasis-normal" points="650.0,250.0 680.0,250.0 680.0,119.0 640.0,119.0 640.0,184.0 670.0,184.0" data-evidence="295-295" />
|
||||
<rect class="edge-label-bg" x="678.2" y="118.0" width="51.5" height="22" rx="3" />
|
||||
<text class="edge-label" x="704.0" y="133.0">조회 의존</text>
|
||||
<g id="node-get-feed-use-case">
|
||||
<polygon class="node-shape kind-application emphasis-primary role-core" data-evidence="295-295" points="454.0,185.0 616.0,185.0 650.0,250.0 616.0,315.0 454.0,315.0 420.0,250.0" />
|
||||
<text class="node-role" x="535.0" y="204.0">«core»</text>
|
||||
<text class="node-label" x="535.0" y="248.0">GetFeedUseCase</text>
|
||||
</g>
|
||||
<g id="node-feed-controller">
|
||||
<rect class="node-shape kind-adapter emphasis-normal role-inbound-adapter" data-evidence="295-295" x="45.0" y="85.0" width="210.0" height="94.0" rx="7" />
|
||||
<text class="node-role" x="150.0" y="104.0">«inbound-adapter»</text>
|
||||
<text class="node-label" x="150.0" y="131.0">FeedController</text>
|
||||
<line class="node-detail-divider" x1="59.0" y1="152.0" x2="241.0" y2="152.0" />
|
||||
<text class="node-detail" x="61.0" y="169.0">GET /feed</text>
|
||||
</g>
|
||||
<g id="node-feed-query-adapter">
|
||||
<rect class="node-shape kind-adapter emphasis-normal role-outbound-adapter" data-evidence="295-295,299-299" x="825.0" y="85.0" width="220.0" height="126.0" rx="7" />
|
||||
<text class="node-role" x="935.0" y="104.0">«outbound-adapter»</text>
|
||||
<text class="node-label" x="935.0" y="131.0">FeedQueryAdapter</text>
|
||||
<line class="node-detail-divider" x1="839.0" y1="152.0" x2="1031.0" y2="152.0" />
|
||||
<text class="node-detail" x="841.0" y="169.0">PostgreSQL 조회</text>
|
||||
<text class="node-detail" x="841.0" y="185.0">Fetch Join · Batch Fetch</text>
|
||||
<text class="node-detail" x="841.0" y="201.0">DTO Projection · 윈도우 함수</text>
|
||||
</g>
|
||||
<g id="node-feed-query-port">
|
||||
<rect class="node-shape kind-interface emphasis-normal role-port" data-evidence="295-295,299-299" x="670.0" y="155.0" width="125.0" height="58.0" rx="22" />
|
||||
<text class="node-role" x="732.5" y="174.0">«port»</text>
|
||||
<text class="node-label" x="732.5" y="201.0">FeedQueryPort</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
|
||||
<diagram id="skew-profile" name="Zipf-like 분포만 무거운 머리와 긴 꼬리를 함께 재현한다">
|
||||
<mxGraphModel dx="854" dy="300" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="854" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="n_uniform" value="균일분포<br/>분포 형태: 모두 3개<br/>극단적 소수: 없음<br/>스트레스 조건: 재현 못함<br/>선택 결과: 제외" tooltip="모든 아이템에 하이라이트 3개를 주는 분포로, 매우 많은 하이라이트 조건을 재현하지 못한다. | Evidence: L177-L177, L195-L195" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#9ca3af;fillColor=#f9fafb;" vertex="1" parent="1">
|
||||
<mxGeometry x="45.0" y="45.0" width="220.0" height="122.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_normal" value="정규분포<br/>분포 형태: 평균 근처 집중<br/>극단적 소수: 없음<br/>스트레스 조건: 재현 못함<br/>선택 결과: 제외" tooltip="평균 근처에 몰려 극단적으로 많은 소수와 무거운 머리를 만들지 못하는 분포다. | Evidence: L177-L177, L196-L196" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#9ca3af;fillColor=#f9fafb;" vertex="1" parent="1">
|
||||
<mxGeometry x="317.0" y="45.0" width="220.0" height="122.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_zipf-like" value="Zipf-like 합성 분포<br/>분포 형태: 무거운 머리 + 긴 꼬리<br/>극단적 소수: 있음 · 1위 500개<br/>스트레스 조건: 재현<br/>선택 결과: s=1.15 합성 분포" tooltip="순위 기반 지수로 편중 강도를 조절하며 무거운 머리와 긴 꼬리를 재현하는 합성 스트레스 분포다. | Evidence: L177-L177, L181-L181, L184-L184, L188-L190, L197-L197, L203-L203" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="589.0" y="45.0" width="220.0" height="122.0" as="geometry"/>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="854" height="300" viewBox="0 0 854 300" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">Zipf-like 분포만 무거운 머리와 긴 꼬리를 함께 재현한다</title>
|
||||
<desc id="diagram-description">왼쪽부터 균일분포, 정규분포, Zipf-like 합성 분포를 같은 네 기준으로 비교한다. 균일분포는 모든 아이템이 3개이고, 정규분포는 평균 근처에 몰려 둘 다 극단적으로 많은 소수를 만들지 못하므로 제외된다. Zipf-like 분포는 소수의 인기 아이템이 압도적인 무거운 머리와 나머지의 긴 꼬리를 만들며, 지수 s=1.15와 상한 500·하한 1을 사용해 매우 많은 하이라이트 조건과 Top-N 필요성을 재현하는 합성 스트레스 분포로 선택된다.</desc>
|
||||
<metadata>{"techviz":{"spec_version":"1.1","id":"skew-profile","profile":"comparison"},"source_context":{"document":"/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md","document_sha256":"1bb38964ca2a849690f5355646c1aa988111b4cd4e1055c6cb05cf7e5fc35cde","anchor":{"kind":"marker","value":"skew-profile","line":192}},"evidence_policy":"Each factual element cites source lines or is marked assumption.","diagram_only":true}</metadata>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.canvas { fill: #ffffff; }
|
||||
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||||
.group-label-bg { fill: #ffffff; }
|
||||
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
|
||||
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
|
||||
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
|
||||
.edge.style-dotted { stroke-dasharray: 2 5; }
|
||||
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.edge.emphasis-muted { stroke: #9ca3af; }
|
||||
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
|
||||
.edge-label-bg { fill: #ffffff; }
|
||||
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
|
||||
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
|
||||
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
|
||||
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
|
||||
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
|
||||
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
|
||||
.node-shape.assumption { stroke-dasharray: 4 4; }
|
||||
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
|
||||
.controller-led { fill: #4b5563; }
|
||||
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
|
||||
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
|
||||
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
|
||||
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
|
||||
.node-detail { font-size: 11px; fill: #374151; }
|
||||
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
|
||||
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
|
||||
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
|
||||
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
|
||||
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
|
||||
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
|
||||
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
|
||||
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
|
||||
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
|
||||
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="854" height="300" />
|
||||
<g id="node-uniform">
|
||||
<rect class="node-shape kind-distribution emphasis-muted role-option" data-evidence="177-177,195-195" x="45.0" y="45.0" width="220.0" height="122.0" rx="7" />
|
||||
<text class="node-label" x="155.0" y="72.0">균일분포</text>
|
||||
<line class="node-detail-divider" x1="59.0" y1="93.0" x2="251.0" y2="93.0" />
|
||||
<text class="node-detail" x="61.0" y="110.0">분포 형태: 모두 3개</text>
|
||||
<text class="node-detail" x="61.0" y="126.0">극단적 소수: 없음</text>
|
||||
<text class="node-detail" x="61.0" y="142.0">스트레스 조건: 재현 못함</text>
|
||||
<text class="node-detail" x="61.0" y="158.0">선택 결과: 제외</text>
|
||||
</g>
|
||||
<g id="node-normal">
|
||||
<rect class="node-shape kind-distribution emphasis-muted role-option" data-evidence="177-177,196-196" x="317.0" y="45.0" width="220.0" height="122.0" rx="7" />
|
||||
<text class="node-label" x="427.0" y="72.0">정규분포</text>
|
||||
<line class="node-detail-divider" x1="331.0" y1="93.0" x2="523.0" y2="93.0" />
|
||||
<text class="node-detail" x="333.0" y="110.0">분포 형태: 평균 근처 집중</text>
|
||||
<text class="node-detail" x="333.0" y="126.0">극단적 소수: 없음</text>
|
||||
<text class="node-detail" x="333.0" y="142.0">스트레스 조건: 재현 못함</text>
|
||||
<text class="node-detail" x="333.0" y="158.0">선택 결과: 제외</text>
|
||||
</g>
|
||||
<g id="node-zipf-like">
|
||||
<rect class="node-shape kind-distribution emphasis-primary role-option" data-evidence="177-177,181-181,184-184,188-190,197-197,203-203" x="589.0" y="45.0" width="220.0" height="122.0" rx="7" />
|
||||
<text class="node-label" x="699.0" y="72.0">Zipf-like 합성 분포</text>
|
||||
<line class="node-detail-divider" x1="603.0" y1="93.0" x2="795.0" y2="93.0" />
|
||||
<text class="node-detail" x="605.0" y="110.0">분포 형태: 무거운 머리 + 긴 꼬리</text>
|
||||
<text class="node-detail" x="605.0" y="126.0">극단적 소수: 있음 · 1위 500개</text>
|
||||
<text class="node-detail" x="605.0" y="142.0">스트레스 조건: 재현</text>
|
||||
<text class="node-detail" x="605.0" y="158.0">선택 결과: s=1.15 합성 분포</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
|
||||
<diagram id="strategy-journey" name="두 형제 문제는 Fetch Join에서 합류한 뒤 단계별 해법으로 최종 구조가 된다">
|
||||
<mxGraphModel dx="1236" dy="421" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1236" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="n_requirements-model-baseline" value="요구사항·모델·기준선<br/>1 과제 요구사항<br/>2 도메인·데이터 모델<br/>3 최초 피드 조회" tooltip="전체 여정은 과제 요구사항에서 도메인·데이터 모델을 거쳐 최초 피드 조회 기준선으로 시작한다. | Evidence: L25-L25" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="70.0" y="165.5" width="150.0" height="105.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_collection-n-plus-one" value="컬렉션 N+1 (N1)<br/>유형: 컬렉션 N+1<br/>발생: 기준선과 동시에<br/>관계: N2와 형제 문제<br/>진행: Fetch Join으로 합류" tooltip="기준선에서 N2와 동시에 나타나는 컬렉션 조회 문제다. | Evidence: L25-L25" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="383.5" y="60.0" width="167.0" height="122.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_hidden-to-one-queries" value="User·Page 숨은 쿼리 (N2)<br/>유형: User·Page 숨은 쿼리<br/>발생: 기준선과 동시에<br/>관계: N1과 형제 문제<br/>진행: Fetch Join으로 합류" tooltip="기준선에서 N1과 동시에 나타나는 User·Page 연관의 숨은 쿼리 문제다. | Evidence: L25-L25" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="380.0" y="254.0" width="174.0" height="122.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_fetch-join-failure" value="Fetch Join → 실패<br/>1 N1·N2 합류<br/>2 다중 컬렉션·페이징 실패" tooltip="N1과 N2가 Fetch Join 시도에서 합류한 뒤 다중 컬렉션·페이징 실패로 이어진다. | Evidence: L25-L25" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
|
||||
<mxGeometry x="714.0" y="174.0" width="150.0" height="88.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_improvement-chain" value="후속 개선 → 최종 구조<br/>1 Batch Fetch<br/>2 DTO Projection<br/>3 아이템별 Top-3<br/>4 Keyset Pagination<br/>5 가시성 조건 인덱싱<br/>6 최종 피드 조회 구조" tooltip="실패 뒤 다섯 조회 전략 단계가 명시된 순서로 발전해 최종 피드 조회 구조를 만든다. | Evidence: L25-L25" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="1024.0" y="140.0" width="167.0" height="156.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="e_baseline-reveals-n1" value="기준선에서 갈라짐" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_requirements-model-baseline" target="n_collection-n-plus-one">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="325.8" y="165.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_baseline-reveals-n2" value="기준선에서 갈라짐" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_requirements-model-baseline" target="n_hidden-to-one-queries">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="324.0" y="271.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_failure-to-improvements" value="실패 뒤 단계별 개선" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_fetch-join-failure" target="n_improvement-chain">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="944.0" y="190.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_n1-joins-fetch-join" value="Fetch Join으로 합류" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_collection-n-plus-one" target="n_fetch-join-failure">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="656.2" y="165.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_n2-joins-fetch-join" value="Fetch Join으로 합류" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_hidden-to-one-queries" target="n_fetch-join-failure">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="658.0" y="271.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@@ -0,0 +1,112 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1236" height="421" viewBox="0 0 1236 421" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">두 형제 문제는 Fetch Join에서 합류한 뒤 단계별 해법으로 최종 구조가 된다</title>
|
||||
<desc id="diagram-description">왼쪽에서 과제 요구사항, 도메인·데이터 모델, 최초 피드 조회 기준선 순으로 시작한다. 기준선에서 컬렉션 N+1(N1)과 User·Page 연관의 숨은 쿼리(N2)가 서로 앞뒤가 아닌 형제 문제로 동시에 갈라지고, 두 경로는 Fetch Join 시도에서 합류한다. 이 시도는 다중 컬렉션·페이징 실패로 이어진다. 마지막 노드는 Batch Fetch, DTO Projection, 아이템별 Top-3, Keyset Pagination, 가시성 조건 인덱싱을 거쳐 최종 피드 조회 구조에 도달하는 순서를 담는다.</desc>
|
||||
<metadata>{"techviz":{"spec_version":"1.1","id":"strategy-journey","profile":"component-flow"},"source_context":{"document":"/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md","document_sha256":"1bb38964ca2a849690f5355646c1aa988111b4cd4e1055c6cb05cf7e5fc35cde","anchor":{"kind":"marker","value":"strategy-journey","line":27}},"evidence_policy":"Each factual element cites source lines or is marked assumption.","diagram_only":true}</metadata>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.canvas { fill: #ffffff; }
|
||||
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||||
.group-label-bg { fill: #ffffff; }
|
||||
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
|
||||
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
|
||||
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
|
||||
.edge.style-dotted { stroke-dasharray: 2 5; }
|
||||
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.edge.emphasis-muted { stroke: #9ca3af; }
|
||||
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
|
||||
.edge-label-bg { fill: #ffffff; }
|
||||
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
|
||||
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
|
||||
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
|
||||
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
|
||||
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
|
||||
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
|
||||
.node-shape.assumption { stroke-dasharray: 4 4; }
|
||||
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
|
||||
.controller-led { fill: #4b5563; }
|
||||
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
|
||||
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
|
||||
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
|
||||
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
|
||||
.node-detail { font-size: 11px; fill: #374151; }
|
||||
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
|
||||
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
|
||||
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
|
||||
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
|
||||
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
|
||||
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
|
||||
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
|
||||
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
|
||||
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
|
||||
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="1236" height="421" />
|
||||
<polyline class="edge kind-problem style-solid emphasis-normal" points="220.0,209.0 301.8,209.0 301.8,121.0 383.5,121.0" data-evidence="25-25" />
|
||||
<rect class="edge-label-bg" x="286.6" y="151.0" width="78.3" height="22" rx="3" />
|
||||
<text class="edge-label" x="325.8" y="166.0">기준선에서 갈라짐</text>
|
||||
<polyline class="edge kind-problem style-solid emphasis-normal" points="220.0,227.0 300.0,227.0 300.0,315.0 380.0,315.0" data-evidence="25-25" />
|
||||
<rect class="edge-label-bg" x="284.9" y="257.0" width="78.3" height="22" rx="3" />
|
||||
<text class="edge-label" x="324.0" y="272.0">기준선에서 갈라짐</text>
|
||||
<polyline class="edge kind-improvement style-solid emphasis-normal" points="864.0,218.0 944.0,218.0 944.0,218.0 1024.0,218.0" data-evidence="25-25" />
|
||||
<rect class="edge-label-bg" x="898.1" y="176.0" width="91.7" height="22" rx="3" />
|
||||
<text class="edge-label" x="944.0" y="191.0">실패 뒤 단계별 개선</text>
|
||||
<polyline class="edge kind-flow style-solid emphasis-normal" points="550.5,121.0 632.2,121.0 632.2,209.0 714.0,209.0" data-evidence="25-25" />
|
||||
<rect class="edge-label-bg" x="597.0" y="151.0" width="118.5" height="22" rx="3" />
|
||||
<text class="edge-label" x="656.2" y="166.0">Fetch Join으로 합류</text>
|
||||
<polyline class="edge kind-flow style-solid emphasis-normal" points="554.0,315.0 634.0,315.0 634.0,227.0 714.0,227.0" data-evidence="25-25" />
|
||||
<rect class="edge-label-bg" x="598.8" y="257.0" width="118.5" height="22" rx="3" />
|
||||
<text class="edge-label" x="658.0" y="272.0">Fetch Join으로 합류</text>
|
||||
<g id="node-requirements-model-baseline">
|
||||
<rect class="node-shape kind-journey-stage emphasis-normal role-source" data-evidence="25-25" x="70.0" y="165.5" width="150.0" height="105.0" rx="7" />
|
||||
<text class="node-label" x="145.0" y="192.5">요구사항·모델·기준선</text>
|
||||
<line class="node-detail-divider" x1="84.0" y1="213.5" x2="206.0" y2="213.5" />
|
||||
<text class="node-detail" x="86.0" y="230.5">1 과제 요구사항</text>
|
||||
<text class="node-detail" x="86.0" y="246.5">2 도메인·데이터 모델</text>
|
||||
<text class="node-detail" x="86.0" y="262.5">3 최초 피드 조회</text>
|
||||
</g>
|
||||
<g id="node-collection-n-plus-one">
|
||||
<rect class="node-shape kind-problem emphasis-normal role-service" data-evidence="25-25" x="383.5" y="60.0" width="167.0" height="122.0" rx="7" />
|
||||
<text class="node-label" x="467.0" y="87.0">컬렉션 N+1 (N1)</text>
|
||||
<line class="node-detail-divider" x1="397.5" y1="108.0" x2="536.5" y2="108.0" />
|
||||
<text class="node-detail" x="399.5" y="125.0">유형: 컬렉션 N+1</text>
|
||||
<text class="node-detail" x="399.5" y="141.0">발생: 기준선과 동시에</text>
|
||||
<text class="node-detail" x="399.5" y="157.0">관계: N2와 형제 문제</text>
|
||||
<text class="node-detail" x="399.5" y="173.0">진행: Fetch Join으로 합류</text>
|
||||
</g>
|
||||
<g id="node-hidden-to-one-queries">
|
||||
<rect class="node-shape kind-problem emphasis-normal role-service" data-evidence="25-25" x="380.0" y="254.0" width="174.0" height="122.0" rx="7" />
|
||||
<text class="node-label" x="467.0" y="281.0">User·Page 숨은 쿼리 (N2)</text>
|
||||
<line class="node-detail-divider" x1="394.0" y1="302.0" x2="540.0" y2="302.0" />
|
||||
<text class="node-detail" x="396.0" y="319.0">유형: User·Page 숨은 쿼리</text>
|
||||
<text class="node-detail" x="396.0" y="335.0">발생: 기준선과 동시에</text>
|
||||
<text class="node-detail" x="396.0" y="351.0">관계: N1과 형제 문제</text>
|
||||
<text class="node-detail" x="396.0" y="367.0">진행: Fetch Join으로 합류</text>
|
||||
</g>
|
||||
<g id="node-fetch-join-failure">
|
||||
<rect class="node-shape kind-query-strategy emphasis-warning role-service" data-evidence="25-25" x="714.0" y="174.0" width="150.0" height="88.0" rx="7" />
|
||||
<text class="node-label" x="789.0" y="201.0">Fetch Join → 실패</text>
|
||||
<line class="node-detail-divider" x1="728.0" y1="222.0" x2="850.0" y2="222.0" />
|
||||
<text class="node-detail" x="730.0" y="239.0">1 N1·N2 합류</text>
|
||||
<text class="node-detail" x="730.0" y="255.0">2 다중 컬렉션·페이징 실패</text>
|
||||
</g>
|
||||
<g id="node-improvement-chain">
|
||||
<rect class="node-shape kind-query-strategy emphasis-primary role-sink" data-evidence="25-25" x="1024.0" y="140.0" width="167.0" height="156.0" rx="7" />
|
||||
<text class="node-label" x="1107.5" y="168.0">후속 개선 → 최종 구조</text>
|
||||
<line class="node-detail-divider" x1="1038.0" y1="189.0" x2="1177.0" y2="189.0" />
|
||||
<text class="node-detail" x="1040.0" y="206.0">1 Batch Fetch</text>
|
||||
<text class="node-detail" x="1040.0" y="222.0">2 DTO Projection</text>
|
||||
<text class="node-detail" x="1040.0" y="238.0">3 아이템별 Top-3</text>
|
||||
<text class="node-detail" x="1040.0" y="254.0">4 Keyset Pagination</text>
|
||||
<text class="node-detail" x="1040.0" y="270.0">5 가시성 조건 인덱싱</text>
|
||||
<text class="node-detail" x="1040.0" y="286.0">6 최종 피드 조회 구조</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
|
||||
<diagram id="target-schema" name="목표 스키마에 추가되는 mention 관계">
|
||||
<mxGraphModel dx="895" dy="305" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="895" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="n_users" value="users" tooltip="entity | Evidence: L35-L35" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="70.0" y="60.0" width="150.0" height="64.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_pages" value="pages" tooltip="entity | Evidence: L36-L36" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="70.0" y="196.0" width="150.0" height="64.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_feed-items" value="feed_items" tooltip="entity | Evidence: L35-L35" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="380.0" y="128.0" width="150.0" height="64.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_feed-item-mentions" value="feed_item_mentions" tooltip="entity | Evidence: L43-L43" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="690.0" y="60.0" width="160.0" height="64.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="n_highlights" value="highlights" tooltip="entity | Evidence: L37-L37" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
|
||||
<mxGeometry x="695.0" y="196.0" width="150.0" height="64.0" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="e_feed-items-have-highlights" value="여러 highlights를 가짐" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_feed-items" target="n_highlights">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="636.5" y="198.5" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_feed-items-to-mentions" value="피드 아이템을 연결" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_feed-items" target="n_feed-item-mentions">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="634.0" y="126.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_pages-have-feed-items" value="여러 feed_item이 딸림" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_pages" target="n_feed-items">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="324.0" y="198.5" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_users-have-feed-items" value="여러 feed_item을 가짐" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_users" target="n_feed-items">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="324.0" y="126.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e_users-to-feed-item-mentions" value="mentioned 사용자로 연결" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_users" target="n_feed-item-mentions">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="455.0" y="55.0" as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="895" height="305" viewBox="0 0 895 305" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">목표 스키마에 추가되는 mention 관계</title>
|
||||
<desc id="diagram-description">왼쪽의 users와 pages가 중앙의 feed_items에 연결된다. 오른쪽에는 highlights와 feed_item_mentions가 놓인다. feed_items는 두 엔티티에 각각 연결되고, 기존 users도 mentioned 사용자 역할로 feed_item_mentions에 연결된다.</desc>
|
||||
<metadata>{"techviz":{"spec_version":"1.1","id":"target-schema","profile":"component-flow"},"source_context":{"document":"/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md","document_sha256":"1bb38964ca2a849690f5355646c1aa988111b4cd4e1055c6cb05cf7e5fc35cde","anchor":{"kind":"marker","value":"target-schema","line":45}},"evidence_policy":"Each factual element cites source lines or is marked assumption.","diagram_only":true}</metadata>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.canvas { fill: #ffffff; }
|
||||
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||||
.group-label-bg { fill: #ffffff; }
|
||||
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
|
||||
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
|
||||
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
|
||||
.edge.style-dotted { stroke-dasharray: 2 5; }
|
||||
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.edge.emphasis-muted { stroke: #9ca3af; }
|
||||
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
|
||||
.edge-label-bg { fill: #ffffff; }
|
||||
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
|
||||
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
|
||||
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
|
||||
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
|
||||
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
|
||||
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
|
||||
.node-shape.assumption { stroke-dasharray: 4 4; }
|
||||
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
|
||||
.controller-led { fill: #4b5563; }
|
||||
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
|
||||
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
|
||||
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
|
||||
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
|
||||
.node-detail { font-size: 11px; fill: #374151; }
|
||||
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
|
||||
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
|
||||
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
|
||||
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
|
||||
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
|
||||
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
|
||||
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
|
||||
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
|
||||
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
|
||||
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="895" height="305" />
|
||||
<polyline class="edge kind-relationship style-solid emphasis-normal" points="530.0,169.0 612.5,169.0 612.5,228.0 695.0,228.0" data-evidence="37-37" />
|
||||
<rect class="edge-label-bg" x="570.5" y="184.5" width="131.9" height="22" rx="3" />
|
||||
<text class="edge-label" x="636.5" y="199.5">여러 highlights를 가짐</text>
|
||||
<polyline class="edge kind-relationship style-solid emphasis-normal" points="530.0,151.0 610.0,151.0 610.0,101.0 690.0,101.0" data-evidence="43-43" />
|
||||
<rect class="edge-label-bg" x="591.5" y="112.0" width="85.0" height="22" rx="3" />
|
||||
<text class="edge-label" x="634.0" y="127.0">피드 아이템을 연결</text>
|
||||
<polyline class="edge kind-relationship style-solid emphasis-normal" points="220.0,228.0 300.0,228.0 300.0,169.0 380.0,169.0" data-evidence="36-36" />
|
||||
<rect class="edge-label-bg" x="261.4" y="184.5" width="125.2" height="22" rx="3" />
|
||||
<text class="edge-label" x="324.0" y="199.5">여러 feed_item이 딸림</text>
|
||||
<polyline class="edge kind-relationship style-solid emphasis-normal" points="220.0,101.0 300.0,101.0 300.0,151.0 380.0,151.0" data-evidence="35-35" />
|
||||
<rect class="edge-label-bg" x="261.4" y="112.0" width="125.2" height="22" rx="3" />
|
||||
<text class="edge-label" x="324.0" y="127.0">여러 feed_item을 가짐</text>
|
||||
<polyline class="edge kind-relationship style-solid emphasis-normal" points="220.0,83.0 455.0,83.0 455.0,83.0 690.0,83.0" data-evidence="43-43" />
|
||||
<rect class="edge-label-bg" x="389.1" y="41.0" width="131.9" height="22" rx="3" />
|
||||
<text class="edge-label" x="455.0" y="56.0">mentioned 사용자로 연결</text>
|
||||
<g id="node-users">
|
||||
<rect class="node-shape kind-entity emphasis-normal role-source" data-evidence="35-35" x="70.0" y="60.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="145.0" y="90.0">users</text>
|
||||
</g>
|
||||
<g id="node-pages">
|
||||
<rect class="node-shape kind-entity emphasis-normal role-source" data-evidence="36-36" x="70.0" y="196.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="145.0" y="226.0">pages</text>
|
||||
</g>
|
||||
<g id="node-feed-items">
|
||||
<rect class="node-shape kind-entity emphasis-normal role-store" data-evidence="35-35" x="380.0" y="128.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="455.0" y="158.0">feed_items</text>
|
||||
</g>
|
||||
<g id="node-feed-item-mentions">
|
||||
<rect class="node-shape kind-entity emphasis-normal role-sink" data-evidence="43-43" x="690.0" y="60.0" width="160.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="770.0" y="90.0">feed_item_mentions</text>
|
||||
</g>
|
||||
<g id="node-highlights">
|
||||
<rect class="node-shape kind-entity emphasis-normal role-sink" data-evidence="37-37" x="695.0" y="196.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="770.0" y="226.0">highlights</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="700" height="230" viewBox="0 0 700 230" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">배치 페치는 부모를 먼저 페이징하고 자식을 IN 으로 묶는다</title>
|
||||
<desc id="diagram-description">왼쪽에서 엔티티만 페이징해 DB Limit이 정상 발행되고 부모 페이지가 정해진다. 가운데에서 그 페이지의 부모 키를 모아 자식을 IN 조건 한 번으로 가져온다. 오른쪽 실행계획은 준조인이라 부모와 자식을 곱하지 않고 자식 행만 반환한다. 왕복 수는 부모 수를 배치 크기로 나눈 올림값이 된다.</desc>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
<pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
|
||||
<line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="3" />
|
||||
</pattern>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.canvas { fill: #ffffff; }
|
||||
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||||
.group-label-bg { fill: #ffffff; }
|
||||
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
|
||||
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
|
||||
.edge.dashed { stroke-dasharray: 7 5; }
|
||||
.edge.primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.edge.muted { stroke: #9ca3af; }
|
||||
.node { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
|
||||
.node.primary { stroke: #2563eb; stroke-width: 2.4; }
|
||||
.node.muted { stroke: #9ca3af; fill: #f9fafb; }
|
||||
.node.marked { stroke: #4b5563; stroke-width: 2.4; stroke-dasharray: 5 4; }
|
||||
.label { font-size: 14px; font-weight: 650; text-anchor: middle; }
|
||||
.fill-kept { fill: #2563eb; stroke: none; }
|
||||
.fill-drop { fill: url(#hatch); stroke: #9ca3af; stroke-width: 1; }
|
||||
.fill-none { fill: #ffffff; stroke: #d1d5db; stroke-width: 1; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="700" height="230" />
|
||||
<rect class="node primary" x="40" y="70" width="160" height="80" rx="7" />
|
||||
<text class="label" x="120.0" y="115.0">부모 페이징</text>
|
||||
<rect class="node primary" x="265" y="60" width="170" height="100" rx="7" />
|
||||
<text class="label" x="350.0" y="115.0">부모 키 IN</text>
|
||||
<rect class="node " x="500" y="70" width="160" height="80" rx="7" />
|
||||
<text class="label" x="580.0" y="115.0">준조인</text>
|
||||
<polyline class="edge primary" points="200,110 265,110" />
|
||||
<polyline class="edge primary" points="435,110 500,110" />
|
||||
<rect class="group-box" x="20" y="178" width="660" height="40" rx="9" />
|
||||
<rect class="group-label-bg" x="34" y="169" width="97.6" height="18" />
|
||||
<text class="group-label" x="40" y="182">자식은 따로 가져온다</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="700" height="250" viewBox="0 0 700 250" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">컬렉션 fetch join은 부모 행을 자식 수만큼 늘린다</title>
|
||||
<desc id="diagram-description">왼쪽 부모 테이블에서 출발한 조인이 가운데를 지나면서 부모 한 행을 자식 수만큼 반복한 행 묶음으로 만든다. 그 묶음이 오른쪽 전송 단계로 그대로 나간다. 아래쪽에서 Hibernate 6 이상이 루트 엔티티를 중복 제거해 결과 리스트를 부모 수로 되돌리지만, 늘어난 행은 SQL과 전송 단계에 남아 있다. 그래서 리스트 크기로는 이 증가가 보이지 않고 조인 카디널리티나 실행계획의 실제 행수로 확인해야 한다.</desc>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
<pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
|
||||
<line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="3" />
|
||||
</pattern>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.canvas { fill: #ffffff; }
|
||||
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||||
.group-label-bg { fill: #ffffff; }
|
||||
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
|
||||
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
|
||||
.edge.dashed { stroke-dasharray: 7 5; }
|
||||
.edge.primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.edge.muted { stroke: #9ca3af; }
|
||||
.node { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
|
||||
.node.primary { stroke: #2563eb; stroke-width: 2.4; }
|
||||
.node.muted { stroke: #9ca3af; fill: #f9fafb; }
|
||||
.node.marked { stroke: #4b5563; stroke-width: 2.4; stroke-dasharray: 5 4; }
|
||||
.label { font-size: 14px; font-weight: 650; text-anchor: middle; }
|
||||
.fill-kept { fill: #2563eb; stroke: none; }
|
||||
.fill-drop { fill: url(#hatch); stroke: #9ca3af; stroke-width: 1; }
|
||||
.fill-none { fill: #ffffff; stroke: #d1d5db; stroke-width: 1; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="700" height="250" />
|
||||
<rect class="node " x="40" y="70" width="150" height="80" rx="7" />
|
||||
<text class="label" x="115.0" y="115.0">부모 테이블</text>
|
||||
<rect class="node primary" x="255" y="60" width="170" height="100" rx="7" />
|
||||
<text class="label" x="340.0" y="115.0">조인</text>
|
||||
<rect class="node " x="490" y="70" width="170" height="80" rx="7" />
|
||||
<text class="label" x="575.0" y="115.0">전송 행</text>
|
||||
<rect class="node muted" x="490" y="175" width="170" height="60" rx="7" />
|
||||
<text class="label" x="575.0" y="210.0">결과 리스트</text>
|
||||
<polyline class="edge " points="190,110 255,110" />
|
||||
<polyline class="edge primary" points="425,110 490,110" />
|
||||
<polyline class="edge dashed muted" points="575,150 575,175" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="542" viewBox="0 0 680 542" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">EAGER 2차 조회는 반환 전에, LAZY highlights 조회는 매핑 접근 뒤에 실행된다</title>
|
||||
<desc id="diagram-description">세 참가자를 왼쪽부터 loadFeed DTO 매핑, Hibernate, PostgreSQL 순으로 읽는다. loadFeed가 findAllBy 파생 쿼리를 호출하면 Hibernate가 PostgreSQL에서 feed_items를 먼저 조회한다. 이어 fetch join되지 않은 EAGER user와 page를 별도의 2차 SELECT로 채우고, 반환 시점까지 로딩된 FeedItem을 loadFeed에 돌려준다. 이후 DTO 매핑이 getHighlights()에 접근하면 Hibernate가 해당 아이템의 highlights 컬렉션 SELECT를 실행한다.</desc>
|
||||
<metadata>{"techviz":{"spec_version":"1.1","id":"eager-lazy-query-sequence","profile":"sequence"},"source_context":{"document":"/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md","document_sha256":"1bb38964ca2a849690f5355646c1aa988111b4cd4e1055c6cb05cf7e5fc35cde","anchor":{"kind":"marker","value":"eager-lazy-query-sequence","line":324}},"evidence_policy":"Each factual element cites source lines or is marked assumption.","diagram_only":true}</metadata>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.canvas { fill: #ffffff; }
|
||||
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||||
.group-label-bg { fill: #ffffff; }
|
||||
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
|
||||
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
|
||||
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
|
||||
.edge.style-dotted { stroke-dasharray: 2 5; }
|
||||
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.edge.emphasis-muted { stroke: #9ca3af; }
|
||||
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
|
||||
.edge-label-bg { fill: #ffffff; }
|
||||
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
|
||||
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
|
||||
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
|
||||
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
|
||||
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
|
||||
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
|
||||
.node-shape.assumption { stroke-dasharray: 4 4; }
|
||||
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
|
||||
.controller-led { fill: #4b5563; }
|
||||
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
|
||||
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
|
||||
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
|
||||
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
|
||||
.node-detail { font-size: 11px; fill: #374151; }
|
||||
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
|
||||
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
|
||||
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
|
||||
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
|
||||
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
|
||||
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
|
||||
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
|
||||
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
|
||||
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
|
||||
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="680" height="542" />
|
||||
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="322-322,449-451" x="45.0" y="35.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="120.0" y="65.0">loadFeed DTO 매핑</text>
|
||||
<line class="lifeline" x1="120.0" y1="99.0" x2="120.0" y2="512.0" />
|
||||
<rect class="node-shape kind-participant emphasis-primary role-participant" data-evidence="320-322" x="255.0" y="35.0" width="150.0" height="64.0" rx="7" />
|
||||
<text class="node-label" x="330.0" y="65.0">Hibernate</text>
|
||||
<line class="lifeline" x1="330.0" y1="99.0" x2="330.0" y2="512.0" />
|
||||
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="417-422,447-447" x="465.0" y="45.7" width="150.0" height="42.7" /><ellipse class="node-shape kind-participant emphasis-normal role-participant" cx="540.0" cy="45.7" rx="75.0" ry="10.7" /><path class="storage-bottom" d="M 465.0 88.3 A 75.0 10.7 0 0 0 615.0 88.3" />
|
||||
<text class="node-label" x="540.0" y="65.0">PostgreSQL</text>
|
||||
<line class="lifeline" x1="540.0" y1="99.0" x2="540.0" y2="512.0" />
|
||||
<polyline class="edge kind-request style-solid emphasis-normal" points="120.0,140.0 330.0,140.0" data-evidence="320-320" />
|
||||
<rect class="edge-label-bg" x="159.1" y="114.0" width="131.9" height="22" rx="3" />
|
||||
<text class="edge-label" x="225.0" y="129.0">1. findAllBy(...)</text>
|
||||
<polyline class="edge kind-data style-solid emphasis-primary" points="330.0,202.0 540.0,202.0" data-evidence="320-320" />
|
||||
<rect class="edge-label-bg" x="359.0" y="176.0" width="152.0" height="22" rx="3" />
|
||||
<text class="edge-label" x="435.0" y="191.0">2. SELECT feed_items</text>
|
||||
<polyline class="edge kind-data style-solid emphasis-normal" points="330.0,264.0 540.0,264.0" data-evidence="318-321" />
|
||||
<rect class="edge-label-bg" x="318.8" y="238.0" width="232.4" height="22" rx="3" />
|
||||
<text class="edge-label" x="435.0" y="253.0">3. SELECT user / page · EAGER 2차</text>
|
||||
<polyline class="edge kind-response style-dashed emphasis-normal semantic-dashed" points="330.0,326.0 120.0,326.0" data-evidence="318-320" />
|
||||
<rect class="edge-label-bg" x="122.2" y="300.0" width="205.6" height="22" rx="3" />
|
||||
<text class="edge-label" x="225.0" y="315.0">4. EAGER 연관이 채워진 FeedItem 반환</text>
|
||||
<polyline class="edge kind-request style-solid emphasis-normal" points="120.0,388.0 330.0,388.0" data-evidence="322-322,449-451" />
|
||||
<rect class="edge-label-bg" x="128.9" y="362.0" width="192.2" height="22" rx="3" />
|
||||
<text class="edge-label" x="225.0" y="377.0">5. 매핑 중 getHighlights() 접근</text>
|
||||
<polyline class="edge kind-data style-solid emphasis-primary" points="330.0,450.0 540.0,450.0" data-evidence="322-322,429-430" />
|
||||
<rect class="edge-label-bg" x="281.9" y="424.0" width="306.1" height="22" rx="3" />
|
||||
<text class="edge-label" x="435.0" y="439.0">6. SELECT highlights WHERE feed_item_id = ?</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="700" height="290" viewBox="0 0 700 290" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">컬렉션 fetch join에 페이징을 걸면 자르는 곳이 DB에서 메모리로 옮겨진다</title>
|
||||
<desc id="diagram-description">위쪽은 컬렉션 fetch join에 페이징을 건 경로다. 발행 SQL에 Limit 노드가 없어 조인 결과 전체가 애플리케이션으로 넘어오고, Hibernate가 메모리에서 부모 페이지만 남기고 나머지를 버린다. 아래쪽은 엔티티만 페이징한 경로다. Limit 노드가 붙어 DB가 페이지만 돌려주므로 애플리케이션은 버릴 것을 받지 않는다. 두 경로의 반환 건수는 같지만 위쪽은 부모 전체를 하이드레이트한다.</desc>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
<pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
|
||||
<line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="3" />
|
||||
</pattern>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.canvas { fill: #ffffff; }
|
||||
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||||
.group-label-bg { fill: #ffffff; }
|
||||
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
|
||||
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
|
||||
.edge.dashed { stroke-dasharray: 7 5; }
|
||||
.edge.primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.edge.muted { stroke: #9ca3af; }
|
||||
.node { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
|
||||
.node.primary { stroke: #2563eb; stroke-width: 2.4; }
|
||||
.node.muted { stroke: #9ca3af; fill: #f9fafb; }
|
||||
.node.marked { stroke: #4b5563; stroke-width: 2.4; stroke-dasharray: 5 4; }
|
||||
.label { font-size: 14px; font-weight: 650; text-anchor: middle; }
|
||||
.fill-kept { fill: #2563eb; stroke: none; }
|
||||
.fill-drop { fill: url(#hatch); stroke: #9ca3af; stroke-width: 1; }
|
||||
.fill-none { fill: #ffffff; stroke: #d1d5db; stroke-width: 1; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="700" height="290" />
|
||||
<rect class="group-box" x="30" y="50" width="640" height="96" rx="9" />
|
||||
<rect class="group-label-bg" x="44" y="41" width="166.0" height="18" />
|
||||
<text class="group-label" x="50" y="54">컬렉션 fetch join + 페이징</text>
|
||||
<rect class="node " x="55" y="74" width="165" height="56" rx="7" />
|
||||
<text class="label" x="137.5" y="107.0">조인 결과 전량</text>
|
||||
<rect class="node primary" x="275" y="74" width="165" height="56" rx="7" />
|
||||
<text class="label" x="357.5" y="107.0">메모리에서 자름</text>
|
||||
<rect class="node " x="495" y="74" width="150" height="56" rx="7" />
|
||||
<text class="label" x="570.0" y="107.0">페이지</text>
|
||||
<polyline class="edge " points="220,102 275,102" />
|
||||
<polyline class="edge " points="440,102 495,102" />
|
||||
<rect class="group-box" x="30" y="186" width="640" height="96" rx="9" />
|
||||
<rect class="group-label-bg" x="44" y="177" width="74.8" height="18" />
|
||||
<text class="group-label" x="50" y="190">엔티티만 페이징</text>
|
||||
<rect class="node primary" x="55" y="210" width="165" height="56" rx="7" />
|
||||
<text class="label" x="137.5" y="243.0">정렬 + Limit</text>
|
||||
<rect class="node primary" x="275" y="210" width="165" height="56" rx="7" />
|
||||
<text class="label" x="357.5" y="243.0">DB가 자름</text>
|
||||
<rect class="node " x="495" y="210" width="150" height="56" rx="7" />
|
||||
<text class="label" x="570.0" y="243.0">페이지</text>
|
||||
<polyline class="edge primary" points="220,238 275,238" />
|
||||
<polyline class="edge primary" points="440,238 495,238" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="280" viewBox="0 0 720 280" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">OFFSET은 건너뛸 행까지 읽고 버리지만 keyset은 커서 이후만 읽는다</title>
|
||||
<desc id="diagram-description">위쪽 OFFSET 막대에서 빗금 구간은 정렬 순서상 앞에 있어 만들어졌다가 버려지는 범위이고, 오른쪽 진한 구간이 실제로 반환되는 페이지다. 아래쪽 keyset 막대에서 빈 구간은 아예 읽지 않는 범위이고, 커서 표시 뒤부터 페이지만 읽는다. 페이지가 깊어질수록 위쪽 빗금 구간만 길어지고 아래쪽은 그대로다. 이 이점은 커서와 같은 순서의 정렬키 인덱스가 있을 때만 성립한다.</desc>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
<pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
|
||||
<line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="3" />
|
||||
</pattern>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.canvas { fill: #ffffff; }
|
||||
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||||
.group-label-bg { fill: #ffffff; }
|
||||
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
|
||||
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
|
||||
.edge.dashed { stroke-dasharray: 7 5; }
|
||||
.edge.primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.edge.muted { stroke: #9ca3af; }
|
||||
.node { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
|
||||
.node.primary { stroke: #2563eb; stroke-width: 2.4; }
|
||||
.node.muted { stroke: #9ca3af; fill: #f9fafb; }
|
||||
.node.marked { stroke: #4b5563; stroke-width: 2.4; stroke-dasharray: 5 4; }
|
||||
.label { font-size: 14px; font-weight: 650; text-anchor: middle; }
|
||||
.fill-kept { fill: #2563eb; stroke: none; }
|
||||
.fill-drop { fill: url(#hatch); stroke: #9ca3af; stroke-width: 1; }
|
||||
.fill-none { fill: #ffffff; stroke: #d1d5db; stroke-width: 1; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="720" height="280" />
|
||||
<rect class="group-box" x="30" y="46" width="660" height="84" rx="9" />
|
||||
<rect class="group-label-bg" x="44" y="37" width="59.599999999999994" height="18" />
|
||||
<text class="group-label" x="50" y="50">OFFSET</text>
|
||||
<rect class="fill-drop" x="55" y="76" width="500" height="34" rx="3" />
|
||||
<rect class="fill-kept" x="555" y="76" width="110" height="34" rx="3" />
|
||||
<text class="group-label" x="305" y="98" text-anchor="middle">버려지는 범위</text>
|
||||
<rect class="group-box" x="30" y="170" width="660" height="84" rx="9" />
|
||||
<rect class="group-label-bg" x="44" y="161" width="59.599999999999994" height="18" />
|
||||
<text class="group-label" x="50" y="174">keyset</text>
|
||||
<rect class="fill-none" x="55" y="200" width="500" height="34" rx="3" />
|
||||
<rect class="fill-kept" x="555" y="200" width="110" height="34" rx="3" />
|
||||
<text class="group-label" x="305" y="222" text-anchor="middle">읽지 않는 범위</text>
|
||||
<line x1="555" y1="188" x2="555" y2="246" stroke="#2563eb" stroke-width="2.4" />
|
||||
<text class="group-label" x="555" y="182" text-anchor="middle" fill="#2563eb">커서</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="660" height="230" viewBox="0 0 660 230" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">프로젝션은 엔티티 적재를 없애지만 자식 행수는 그대로 남는다</title>
|
||||
<desc id="diagram-description">왼쪽 배치 방식은 페이지를 조회하면서 엔티티를 대량으로 하이드레이트한다. 가운데 프로젝션은 필요한 스칼라 값만 캐리어로 받아 엔티티를 만들지 않고 발행 쿼리도 상수로 고정한다. 다만 오른쪽처럼 자식 조회는 페이지 부모의 자식을 전부 가져오므로 행수는 줄지 않는다. 화면이 필요로 하는 것은 부모당 상위 몇 개뿐이다.</desc>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
<pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
|
||||
<line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="3" />
|
||||
</pattern>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.canvas { fill: #ffffff; }
|
||||
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||||
.group-label-bg { fill: #ffffff; }
|
||||
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
|
||||
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
|
||||
.edge.dashed { stroke-dasharray: 7 5; }
|
||||
.edge.primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.edge.muted { stroke: #9ca3af; }
|
||||
.node { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
|
||||
.node.primary { stroke: #2563eb; stroke-width: 2.4; }
|
||||
.node.muted { stroke: #9ca3af; fill: #f9fafb; }
|
||||
.node.marked { stroke: #4b5563; stroke-width: 2.4; stroke-dasharray: 5 4; }
|
||||
.label { font-size: 14px; font-weight: 650; text-anchor: middle; }
|
||||
.fill-kept { fill: #2563eb; stroke: none; }
|
||||
.fill-drop { fill: url(#hatch); stroke: #9ca3af; stroke-width: 1; }
|
||||
.fill-none { fill: #ffffff; stroke: #d1d5db; stroke-width: 1; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="660" height="230" />
|
||||
<rect class="node " x="30" y="70" width="170" height="80" rx="7" />
|
||||
<text class="label" x="115.0" y="115.0">엔티티 적재</text>
|
||||
<rect class="node primary" x="245" y="60" width="170" height="100" rx="7" />
|
||||
<text class="label" x="330.0" y="115.0">스칼라 프로젝션</text>
|
||||
<rect class="node marked" x="460" y="70" width="170" height="80" rx="7" />
|
||||
<text class="label" x="545.0" y="115.0">자식 행 전량</text>
|
||||
<polyline class="edge primary" points="200,110 245,110" />
|
||||
<polyline class="edge muted" points="415,110 460,110" />
|
||||
<rect class="group-box" x="20" y="178" width="620" height="40" rx="9" />
|
||||
<rect class="group-label-bg" x="34" y="169" width="135.6" height="18" />
|
||||
<text class="group-label" x="40" y="182">적재는 사라지고 행수는 남는다</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="300" viewBox="0 0 720 300" role="img" aria-labelledby="diagram-title diagram-description">
|
||||
<title id="diagram-title">그룹당 상위 몇 개를 만드는 세 방식은 읽는 범위가 다르다</title>
|
||||
<desc id="diagram-description">세 방식 모두 부모마다 상위 몇 개씩 같은 결과를 만든다. 윈도우 함수는 파티션 전체를 읽어 순번을 매긴 뒤 상위만 남긴다. LATERAL은 부모마다 정렬 인덱스에서 필요한 개수만 읽고 멈춘다. 애플리케이션 그룹핑은 자식 전량을 애플리케이션으로 보낸 뒤 코드에서 자른다. 읽는 범위가 다르므로 읽은 블록 수도 갈린다. 그룹이 크고 필요한 개수가 작을수록 LATERAL이 읽지 않는 범위가 넓어진다.</desc>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" />
|
||||
</marker>
|
||||
<pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
|
||||
<line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="3" />
|
||||
</pattern>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.canvas { fill: #ffffff; }
|
||||
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
|
||||
.group-label-bg { fill: #ffffff; }
|
||||
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
|
||||
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
|
||||
.edge.dashed { stroke-dasharray: 7 5; }
|
||||
.edge.primary { stroke: #2563eb; stroke-width: 2.2; }
|
||||
.edge.muted { stroke: #9ca3af; }
|
||||
.node { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
|
||||
.node.primary { stroke: #2563eb; stroke-width: 2.4; }
|
||||
.node.muted { stroke: #9ca3af; fill: #f9fafb; }
|
||||
.node.marked { stroke: #4b5563; stroke-width: 2.4; stroke-dasharray: 5 4; }
|
||||
.label { font-size: 14px; font-weight: 650; text-anchor: middle; }
|
||||
.fill-kept { fill: #2563eb; stroke: none; }
|
||||
.fill-drop { fill: url(#hatch); stroke: #9ca3af; stroke-width: 1; }
|
||||
.fill-none { fill: #ffffff; stroke: #d1d5db; stroke-width: 1; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="canvas" width="720" height="300" />
|
||||
<rect class="node " x="40" y="56" width="190" height="62" rx="7" />
|
||||
<text class="label" x="135.0" y="92.0">윈도우 함수</text>
|
||||
<rect class="node " x="330" y="56" width="350" height="62" rx="7" />
|
||||
<text class="label" x="505.0" y="92.0">파티션 전체</text>
|
||||
<polyline class="edge " points="230,87 330,87" />
|
||||
<rect class="node primary" x="40" y="146" width="190" height="62" rx="7" />
|
||||
<text class="label" x="135.0" y="182.0">LATERAL</text>
|
||||
<rect class="node primary" x="330" y="146" width="350" height="62" rx="7" />
|
||||
<text class="label" x="505.0" y="182.0">부모별 인덱스 seek</text>
|
||||
<polyline class="edge primary" points="230,177 330,177" />
|
||||
<rect class="node " x="40" y="236" width="190" height="62" rx="7" />
|
||||
<text class="label" x="135.0" y="272.0">애플리케이션 그룹핑</text>
|
||||
<rect class="node muted" x="330" y="236" width="350" height="62" rx="7" />
|
||||
<text class="label" x="505.0" y="272.0">자식 전량</text>
|
||||
<polyline class="edge " points="230,267 330,267" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,7 @@
|
||||
metric,precompute,single_or,note
|
||||
page1_unified_parents,20,20,keyset page — both parent paths return the same 20
|
||||
page1_unified_rows,60,60,LATERAL top-3 per parent (<=60)
|
||||
page1_buffers_shared_hit,63,181,env-dependent (relative only — warm cache)
|
||||
deep_keyset_parent_scanned,19,200,precompute index-range(19) vs BitmapOr + hashed mention SubPlan(200)
|
||||
deep_keyset_buffers_shared_hit,60,88,env-dependent (relative only — warm cache)
|
||||
viewer_visible_set,1500,1500,feed_visible count for user008 (= single-OR page-1 candidate set)
|
||||
|
@@ -0,0 +1,4 @@
|
||||
N,collection_init,prepared_total,toone
|
||||
10,10,25,13
|
||||
100,100,222,120
|
||||
1000,1000,2022,1020
|
||||
|
@@ -0,0 +1,8 @@
|
||||
rank,highlights
|
||||
1,500
|
||||
2,225
|
||||
3,141
|
||||
5,79
|
||||
10,35
|
||||
50,6
|
||||
100,3
|
||||
|
@@ -0,0 +1,4 @@
|
||||
K,window_rows,window_buffers,window_ms,lateral_rows,lateral_buffers,lateral_ms
|
||||
3,60,162,1.388,60,114,0.271
|
||||
50,695,216,1.540,695,155,0.908
|
||||
500,1509,269,2.905,1509,171,1.259
|
||||
|
@@ -0,0 +1,3 @@
|
||||
variant,top_node,child_access,buffers_shared_hit,exec_ms
|
||||
with_index,Nested Loop,Index Scan using ix_highlights_feed_items_created (Limit 3),168,0.336
|
||||
without_index,Nested Loop,Seq Scan on highlights (Rows Removed by Filter 2842/loop),4446,5.472
|
||||
|
@@ -0,0 +1,4 @@
|
||||
strategy,top_node,returned_rows,buffers_shared_hit,exec_ms
|
||||
window,WindowAgg (Subquery Scan on t),60,430,1.552
|
||||
lateral,Nested Loop (Index Scan + Limit 3),60,204,0.323
|
||||
twostep,Sort (Hash Semi Join),1509,430,1.686
|
||||
|
@@ -0,0 +1,5 @@
|
||||
strategy,returned_rows,parents_covered,max_per_parent
|
||||
window,60,20,3
|
||||
lateral,60,20,3
|
||||
twostep_full,1509,20,unbounded
|
||||
naive_wrong_limit3,3,1,3
|
||||
|
@@ -0,0 +1,4 @@
|
||||
variant,top_node,returned_rows,scanned_rows,buffers_shared_hit,exec_ms
|
||||
offset,Limit<-Sort<-Seq Scan,20,2000,141,0.996
|
||||
keyset_with_index,Limit<-Index Only Scan,20,20,1,0.076
|
||||
keyset_without_index,Limit<-Sort<-Seq Scan (filter),20,20,141,0.373
|
||||
|
@@ -0,0 +1,4 @@
|
||||
offset,page,offset_scanned,offset_buffers,keyset_scanned,keyset_buffers
|
||||
0,1,20,1,20,3
|
||||
980,50,1000,18,20,3
|
||||
1980,100,2000,106,20,2
|
||||
|
@@ -0,0 +1,4 @@
|
||||
approach,top_node,sort,mentions_handling,candidate_rows,buffers_shared_hit,exec_ms
|
||||
single_or,Bitmap Heap Scan + top-N Sort,re-sort,hashed SubPlan,1500,122,0.808
|
||||
union_decompose,Merge Append (per-branch index),per-branch merge,Hash Join,,200,0.780
|
||||
precompute,Index Only Scan on feed_visible,none,pre-materialized,20,1,0.034
|
||||
|
@@ -0,0 +1,4 @@
|
||||
N,page_fetch,user_fetch,entity_fetch,collection_init,prepared_total
|
||||
10,10,3,13,10,25
|
||||
100,100,20,120,100,222
|
||||
1000,1000,20,1020,1000,2022
|
||||
|
@@ -0,0 +1,4 @@
|
||||
N,transferred_rows_join_card,list_size_hibernate6_dedup,distinct_items,seeded_highlights,blowup_x,prepared_total
|
||||
10,1285,10,10,1285,128.5,14
|
||||
100,1961,100,100,1961,19.6,121
|
||||
1000,2917,1000,1000,2917,2.9,1021
|
||||
|
@@ -0,0 +1,4 @@
|
||||
N,p50_ms,p99_ms,thread_alloc_kb
|
||||
10,6.184,6.566,1582
|
||||
100,13.890,16.062,3061
|
||||
1000,79.452,83.526,10230
|
||||
|
@@ -0,0 +1,4 @@
|
||||
N,returned_page,feed_item_loaded,over_fetch_x,seeded_highlights
|
||||
10,10,10,1.0,1285
|
||||
100,20,100,5.0,1961
|
||||
1000,20,1000,50.0,2917
|
||||
|
@@ -0,0 +1,4 @@
|
||||
N,l1_prepared_before,l5_prepared_after,l1_collfetch_before,l5_collfetch_after,feed_item_loaded_page,collapse_x
|
||||
10,25,5,10,1,10,5.0
|
||||
100,222,5,100,1,20,44.4
|
||||
1000,2022,23,1000,10,20,87.9
|
||||
|
@@ -0,0 +1,2 @@
|
||||
scope,page_size,entities_loaded
|
||||
page20_seed1000,20,1569
|
||||
|
@@ -0,0 +1,3 @@
|
||||
plan,explain_width_estimate
|
||||
l6_parent_projection,2088
|
||||
l5_entity_paging,1194
|
||||
|
@@ -0,0 +1,5 @@
|
||||
metric,before_l5_batch,after_l6_projection
|
||||
entities_loaded_page20_seed1000,1569,0
|
||||
prepared_n1000,23,2
|
||||
collection_fetch_n1000,10,0
|
||||
child_rows_page20_seed1000,1509,1509
|
||||
|
@@ -0,0 +1,29 @@
|
||||
-- Crown Task 4 — deep-page keyset on the precompute parent path (index-range seek, ~19 rows)
|
||||
-- FeedCrownIT.crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr (seed 2000, user008, deepest page)
|
||||
-- 읽기 포인트 (★ 실측 정정): 깊은 커서에선 사전계산 경로도 작은 Sort 가 붙는다 — Bitmap Index Scan on
|
||||
-- ix_feed_visible 이 남은 19행만 인덱스 range(Index Cond 에 ROW(...) < ROW(cursor))로 훑고, Bitmap 은
|
||||
-- 정렬 출력을 안 하므로 19행을 quicksort(26kB). 핵심: 훑는 행수 19, 부모 buffers 3 — 페이지 근방만 만진다
|
||||
-- (OFFSET 의 scan-then-discard 도, 단일 OR 의 전체 가시성 재해소도 아니다).
|
||||
|
||||
Nested Loop (cost=22.44..299.62 rows=60 width=686) (actual time=0.023..0.059 rows=19 loops=1)
|
||||
Buffers: shared hit=60
|
||||
-> Limit (cost=22.17..22.22 rows=20 width=24) (actual time=0.013..0.015 rows=19 loops=1)
|
||||
Buffers: shared hit=3
|
||||
-> Sort (cost=22.17..22.22 rows=21 width=24) (actual time=0.013..0.014 rows=19 loops=1)
|
||||
Sort Key: feed_visible.first_highlighted_at DESC, feed_visible.feed_item_id DESC
|
||||
Sort Method: quicksort Memory: 26kB
|
||||
Buffers: shared hit=3
|
||||
-> Bitmap Heap Scan on feed_visible (cost=4.49..21.71 rows=21 width=24) (actual time=0.005..0.006 rows=19 loops=1)
|
||||
Recheck Cond: ((viewer_id = 'e6a2f6da-c975-41ef-9b48-a4a6c1a72cc0'::uuid) AND (ROW(first_highlighted_at, feed_item_id) < ROW('2026-04-17 21:00:00+00'::timestamp with time zone, '759efcc1-efd0-4a24-94d6-7e83001fabb1'::uuid)))
|
||||
Heap Blocks: exact=1
|
||||
Buffers: shared hit=3
|
||||
-> Bitmap Index Scan on ix_feed_visible (cost=0.00..4.49 rows=21 width=0) (actual time=0.003..0.003 rows=19 loops=1)
|
||||
Index Cond: ((viewer_id = 'e6a2f6da-c975-41ef-9b48-a4a6c1a72cc0'::uuid) AND (ROW(first_highlighted_at, feed_item_id) < ROW('2026-04-17 21:00:00+00'::timestamp with time zone, '759efcc1-efd0-4a24-94d6-7e83001fabb1'::uuid)))
|
||||
Buffers: shared hit=2
|
||||
-> Limit (cost=0.28..13.83 rows=3 width=670) (actual time=0.002..0.002 rows=1 loops=19)
|
||||
Buffers: shared hit=57
|
||||
-> Index Scan using ix_highlights_feed_items_created on highlights h (cost=0.28..36.42 rows=8 width=670) (actual time=0.002..0.002 rows=1 loops=19)
|
||||
Index Cond: (feed_item_id = feed_visible.feed_item_id)
|
||||
Buffers: shared hit=57
|
||||
Planning Time: 0.075 ms
|
||||
Execution Time: 0.118 ms
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Crown Task 4 — deep-page keyset on the single-OR parent path (re-resolves visibility every page)
|
||||
-- FeedCrownIT.crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr (seed 2000, user008, deepest page)
|
||||
-- 읽기 포인트: 단일 OR 부모선택은 사전계산 읽기 모델(ix_feed_visible)을 못 쓴다(구조적). 매 페이지 가시성
|
||||
-- 3분기를 BitmapOr 로 다시 풀고(public/mentioned = ix_feed_items_visibility_sort, private = ix_feed_items_private),
|
||||
-- 멘션 EXISTS 는 hashed SubPlan 2 로 user008 의 멘션 200행을 materialize 한다 → 훑는 행수 200(사전계산 19 대비).
|
||||
-- 커서는 세 분기 Index Cond 에 ROW(...) < ROW(cursor) 로 들어가 seek 은 하나, 페이지마다 전체 가시성을 재계산한다.
|
||||
|
||||
Nested Loop (cost=94.10..163.18 rows=15 width=686) (actual time=0.128..0.161 rows=19 loops=1)
|
||||
Buffers: shared hit=88
|
||||
-> Limit (cost=93.82..93.83 rows=5 width=24) (actual time=0.118..0.120 rows=19 loops=1)
|
||||
Buffers: shared hit=31
|
||||
-> Sort (cost=93.82..93.83 rows=5 width=24) (actual time=0.117..0.119 rows=19 loops=1)
|
||||
Sort Key: fi.first_highlighted_at DESC, fi.id DESC
|
||||
Sort Method: quicksort Memory: 26kB
|
||||
Buffers: shared hit=31
|
||||
-> Bitmap Heap Scan on feed_items fi (cost=12.90..93.76 rows=5 width=24) (actual time=0.027..0.112 rows=19 loops=1)
|
||||
Recheck Cond: ((((visibility)::text = 'PUBLIC'::text) AND (ROW(first_highlighted_at, id) < ROW('2026-04-17 21:00:00+00'::timestamp with time zone, '759efcc1-efd0-4a24-94d6-7e83001fabb1'::uuid))) OR (((visibility)::text = 'MENTIONED'::text) AND (ROW(first_highlighted_at, id) < ROW('2026-04-17 21:00:00+00'::timestamp with time zone, '759efcc1-efd0-4a24-94d6-7e83001fabb1'::uuid))) OR ((user_id = 'e6a2f6da-c975-41ef-9b48-a4a6c1a72cc0'::uuid) AND (ROW(first_highlighted_at, id) < ROW('2026-04-17 21:00:00+00'::timestamp with time zone, '759efcc1-efd0-4a24-94d6-7e83001fabb1'::uuid)) AND ((visibility)::text = 'PRIVATE'::text)))
|
||||
Filter: (((visibility)::text = 'PUBLIC'::text) OR (((visibility)::text = 'MENTIONED'::text) AND (hashed SubPlan 2)) OR (((visibility)::text = 'PRIVATE'::text) AND (user_id = 'e6a2f6da-c975-41ef-9b48-a4a6c1a72cc0'::uuid)))
|
||||
Rows Removed by Filter: 4
|
||||
Heap Blocks: exact=6
|
||||
Buffers: shared hit=31
|
||||
-> BitmapOr (cost=12.90..12.90 rows=7 width=0) (actual time=0.019..0.019 rows=0 loops=1)
|
||||
Buffers: shared hit=6
|
||||
-> Bitmap Index Scan on ix_feed_items_visibility_sort (cost=0.00..4.31 rows=3 width=0) (actual time=0.013..0.014 rows=48 loops=1)
|
||||
Index Cond: (((visibility)::text = 'PUBLIC'::text) AND (ROW(first_highlighted_at, id) < ROW('2026-04-17 21:00:00+00'::timestamp with time zone, '759efcc1-efd0-4a24-94d6-7e83001fabb1'::uuid)))
|
||||
Buffers: shared hit=2
|
||||
-> Bitmap Index Scan on ix_feed_items_visibility_sort (cost=0.00..4.31 rows=3 width=0) (actual time=0.003..0.003 rows=18 loops=1)
|
||||
Index Cond: (((visibility)::text = 'MENTIONED'::text) AND (ROW(first_highlighted_at, id) < ROW('2026-04-17 21:00:00+00'::timestamp with time zone, '759efcc1-efd0-4a24-94d6-7e83001fabb1'::uuid)))
|
||||
Buffers: shared hit=2
|
||||
-> Bitmap Index Scan on ix_feed_items_private (cost=0.00..4.28 rows=1 width=0) (actual time=0.001..0.001 rows=1 loops=1)
|
||||
Index Cond: ((user_id = 'e6a2f6da-c975-41ef-9b48-a4a6c1a72cc0'::uuid) AND (ROW(first_highlighted_at, id) < ROW('2026-04-17 21:00:00+00'::timestamp with time zone, '759efcc1-efd0-4a24-94d6-7e83001fabb1'::uuid)))
|
||||
Buffers: shared hit=2
|
||||
SubPlan 2
|
||||
-> Bitmap Heap Scan on feed_item_mentions m (cost=4.33..24.04 rows=7 width=16) (actual time=0.014..0.038 rows=200 loops=1)
|
||||
Recheck Cond: (mentioned_user_id = 'e6a2f6da-c975-41ef-9b48-a4a6c1a72cc0'::uuid)
|
||||
Heap Blocks: exact=16
|
||||
Buffers: shared hit=19
|
||||
-> Bitmap Index Scan on ix_mentions_user (cost=0.00..4.33 rows=7 width=0) (actual time=0.011..0.011 rows=200 loops=1)
|
||||
Index Cond: (mentioned_user_id = 'e6a2f6da-c975-41ef-9b48-a4a6c1a72cc0'::uuid)
|
||||
Buffers: shared hit=3
|
||||
-> Limit (cost=0.28..13.83 rows=3 width=670) (actual time=0.002..0.002 rows=1 loops=19)
|
||||
Buffers: shared hit=57
|
||||
-> Index Scan using ix_highlights_feed_items_created on highlights h (cost=0.28..36.42 rows=8 width=670) (actual time=0.002..0.002 rows=1 loops=19)
|
||||
Index Cond: (feed_item_id = fi.id)
|
||||
Buffers: shared hit=57
|
||||
Planning Time: 0.153 ms
|
||||
Execution Time: 0.250 ms
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Crown Task 4 — unified feed query (precompute parent path): visibility + keyset + Top-N in ONE plan
|
||||
-- FeedCrownIT.crownUnifiedPlanStacksVisibilityKeysetAndTopN (seed 2000, viewer user008, page 1)
|
||||
-- 읽기 포인트: 세 기법이 한 플랜에 재정렬(Sort) 없이 겹쳐 있다 —
|
||||
-- (1) 가시성+keyset = feed_visible 커버링 인덱스의 Index Only Scan (viewer_id 조건, Heap Fetches 20),
|
||||
-- (2) Top-N = 부모 20건당 ix_highlights_feed_items_created 로 top-3 index seek (Nested Loop = LATERAL),
|
||||
-- (3) Sort 노드 없음 — 두 순서(부모 keyset·자식 created_at)가 모두 인덱스에서 나온다.
|
||||
|
||||
Nested Loop (cost=0.56..271.10 rows=60 width=686) (actual time=0.036..0.092 rows=60 loops=1)
|
||||
Buffers: shared hit=65 read=2
|
||||
-> Limit (cost=0.28..1.84 rows=20 width=24) (actual time=0.025..0.028 rows=20 loops=1)
|
||||
Buffers: shared hit=1 read=2
|
||||
-> Index Only Scan using ix_feed_visible on feed_visible (cost=0.28..117.22 rows=1500 width=24) (actual time=0.025..0.027 rows=20 loops=1)
|
||||
Index Cond: (viewer_id = '9ed28556-7ab3-4f5a-b327-7e0dd9536bf8'::uuid)
|
||||
Heap Fetches: 20
|
||||
Buffers: shared hit=1 read=2
|
||||
-> Limit (cost=0.28..13.42 rows=3 width=670) (actual time=0.003..0.003 rows=3 loops=20)
|
||||
Buffers: shared hit=64
|
||||
-> Index Scan using ix_highlights_feed_items_created on highlights h (cost=0.28..48.47 rows=11 width=670) (actual time=0.002..0.003 rows=3 loops=20)
|
||||
Index Cond: (feed_item_id = feed_visible.feed_item_id)
|
||||
Buffers: shared hit=64
|
||||
Planning:
|
||||
Buffers: shared hit=11 read=1
|
||||
Planning Time: 0.122 ms
|
||||
Execution Time: 0.120 ms
|
||||
@@ -0,0 +1,20 @@
|
||||
Plan A — 반복되는 하이라이트 자식 쿼리의 실행계획
|
||||
출처: FeedPersistenceIT.l1ExplainRepeatedHighlightChildQuery 콘솔 출력
|
||||
조건: 대량 시드 직후, ANALYZE 미실행. warm buffer cache(shared read=0).
|
||||
쿼리: SELECT * FROM highlights WHERE feed_item_id = ? (ORDER BY / LIMIT 없음)
|
||||
|
||||
Index Scan using ix_highlights_feed_items_created on highlights
|
||||
(cost=0.27..8.29 rows=1 width=710) (actual time=0.026..0.123 rows=500 loops=1)
|
||||
Index Cond: (feed_item_id = '2b5b931f-...'::uuid)
|
||||
Buffers: shared hit=14
|
||||
Planning Time: 0.086 ms
|
||||
Execution Time: 0.173 ms
|
||||
|
||||
주의(문서 §6.4 caveat와 동일):
|
||||
- 플래너 추정 rows=1 vs 실제 rows=500 → 500배 오추정. 시드 후 ANALYZE 미실행으로 통계가
|
||||
feed_item_id별 편중을 반영하지 못한 것으로 보임. EXPLAIN 전 `ANALYZE highlights` 필요.
|
||||
- Buffers: shared hit=14, read=0 → warm buffer cache 결과. cold 디스크 I/O 실행시간으로 읽지 말 것.
|
||||
- Execution Time 0.173 ms는 PostgreSQL executor 내부 시간. ORM 엔티티 생성·JDBC 결과 전달·
|
||||
DTO 매핑·직렬화·HTTP를 포함하지 않으므로 애플리케이션 지연(§6.2)과 같은 지표가 아니다.
|
||||
|
||||
Plan B (`ANALYZE highlights` 실행 후 재측정) — 예정(pending). 아직 미실행이므로 값 없음.
|
||||
@@ -0,0 +1,29 @@
|
||||
-- L14 index toggle — the SAME LATERAL query with ix_highlights_feed_items_created DROPPED
|
||||
-- FeedTopNIT.l14LateralDependsOnCompositeIndex (seed 1000, page 20, K=3; index dropped then restored)
|
||||
-- 읽기 포인트: 인덱스가 없으면 부모마다 highlights 를 Seq Scan 하고(Rows Removed by Filter: 2842/loop)
|
||||
-- top-N heapsort 로 3개를 고른다 → child 쪽 buffers shared hit=4340, 전체 4446 (인덱스판 168 의 ~26배),
|
||||
-- Execution 5.472 ms (인덱스판 0.336 ms 의 ~16배). 결론: LATERAL 이 빠른 건 LATERAL 이 아니라
|
||||
-- (feed_item_id, created_at DESC) 인덱스 seek 덕. 인덱스가 없으면 LATERAL 도 무너진다.
|
||||
|
||||
Nested Loop (cost=376.28..5070.14 rows=60 width=686) (actual time=0.599..5.451 rows=60 loops=1)
|
||||
Buffers: shared hit=4446
|
||||
-> Limit (cost=129.28..129.33 rows=20 width=24) (actual time=0.218..0.221 rows=20 loops=1)
|
||||
Buffers: shared hit=106
|
||||
-> Sort (cost=129.28..130.87 rows=636 width=24) (actual time=0.218..0.219 rows=20 loops=1)
|
||||
Sort Key: fi.first_highlighted_at DESC, fi.id
|
||||
Sort Method: top-N heapsort Memory: 26kB
|
||||
Buffers: shared hit=106
|
||||
-> Seq Scan on feed_items fi (cost=0.00..112.36 rows=636 width=24) (actual time=0.086..0.150 rows=1000 loops=1)
|
||||
Buffers: shared hit=106
|
||||
-> Limit (cost=246.99..247.00 rows=3 width=670) (actual time=0.261..0.261 rows=3 loops=20)
|
||||
Buffers: shared hit=4340
|
||||
-> Sort (cost=246.99..247.02 rows=12 width=670) (actual time=0.259..0.259 rows=3 loops=20)
|
||||
Sort Key: h.created_at DESC
|
||||
Sort Method: top-N heapsort Memory: 25kB
|
||||
Buffers: shared hit=4340
|
||||
-> Seq Scan on highlights h (cost=0.00..246.84 rows=12 width=670) (actual time=0.171..0.249 rows=75 loops=20)
|
||||
Filter: (feed_item_id = fi.id)
|
||||
Rows Removed by Filter: 2842
|
||||
Buffers: shared hit=4340
|
||||
Planning Time: 0.079 ms
|
||||
Execution Time: 5.472 ms
|
||||
@@ -0,0 +1,26 @@
|
||||
-- L14 (b) LATERAL top-3 per parent — the winning strategy (index seek)
|
||||
-- FeedTopNIT.l14ExplainThreeWayPlanCompareIsTheCrownJewel (seed 1000, page 20, K=3)
|
||||
-- SELECT p.id, top3.color, top3.text, top3.created_at
|
||||
-- FROM (SELECT fi.id FROM feed_items fi ORDER BY fi.first_highlighted_at DESC, fi.id ASC LIMIT 20) p
|
||||
-- CROSS JOIN LATERAL (SELECT h.color, h.text, h.created_at FROM highlights h
|
||||
-- WHERE h.feed_item_id = p.id ORDER BY h.created_at DESC LIMIT 3) top3
|
||||
-- 읽기 포인트: 부모마다 ix_highlights_feed_items_created 를 Index Scan 하고 Limit 3 에서 멈춘다
|
||||
-- (loops=20, 각 rows=3). buffers shared hit=204 로 세 해법 중 최소.
|
||||
|
||||
Nested Loop (cost=172.25..432.10 rows=60 width=686) (actual time=0.257..0.310 rows=60 loops=1)
|
||||
Buffers: shared hit=204
|
||||
-> Limit (cost=171.97..172.02 rows=20 width=24) (actual time=0.239..0.240 rows=20 loops=1)
|
||||
Buffers: shared hit=141
|
||||
-> Sort (cost=171.97..174.09 rows=846 width=24) (actual time=0.238..0.239 rows=20 loops=1)
|
||||
Sort Key: fi.first_highlighted_at DESC, fi.id
|
||||
Sort Method: top-N heapsort Memory: 26kB
|
||||
Buffers: shared hit=141
|
||||
-> Seq Scan on feed_items fi (cost=0.00..149.46 rows=846 width=24) (actual time=0.111..0.175 rows=1000 loops=1)
|
||||
Buffers: shared hit=141
|
||||
-> Limit (cost=0.28..12.96 rows=3 width=670) (actual time=0.003..0.003 rows=3 loops=20)
|
||||
Buffers: shared hit=63
|
||||
-> Index Scan using ix_highlights_feed_items_created on highlights h (cost=0.28..80.61 rows=19 width=670) (actual time=0.003..0.003 rows=3 loops=20)
|
||||
Index Cond: (feed_item_id = fi.id)
|
||||
Buffers: shared hit=63
|
||||
Planning Time: 0.068 ms
|
||||
Execution Time: 0.323 ms
|
||||
@@ -0,0 +1,26 @@
|
||||
-- L14 (c) two-step IN + app-side cut — correct result but transfers ALL page-parent highlights
|
||||
-- FeedTopNIT.l14ExplainThreeWayPlanCompareIsTheCrownJewel (seed 1000, page 20)
|
||||
-- SELECT h.feed_item_id, h.color, h.text, h.created_at FROM highlights h
|
||||
-- WHERE h.feed_item_id IN (<page-20 ids>) ORDER BY h.feed_item_id, h.created_at DESC
|
||||
-- 읽기 포인트: window 와 동일한 Hash Semi Join(rows=1509) — 다만 위에 WindowAgg 가 없어 1509행을 전량
|
||||
-- 애플리케이션으로 전송한다(앱에서 부모별 top-3 컷). buffers shared hit=430 (window 과 동일 = 같은 스캔).
|
||||
-- = L6 프로젝션이 남긴 잔여(childRows=1509)의 정체. 전송 낭비: 60행이면 되는데 1509행.
|
||||
|
||||
Sort (cost=531.52..532.49 rows=388 width=686) (actual time=1.586..1.625 rows=1509 loops=1)
|
||||
Sort Key: h.feed_item_id, h.created_at DESC
|
||||
Sort Method: quicksort Memory: 155kB
|
||||
Buffers: shared hit=430
|
||||
-> Hash Semi Join (cost=172.47..514.84 rows=388 width=686) (actual time=0.493..0.833 rows=1509 loops=1)
|
||||
Hash Cond: (h.feed_item_id = "ANY_subquery".id)
|
||||
Buffers: shared hit=430
|
||||
-> Seq Scan on highlights h (cost=0.00..327.85 rows=3885 width=686) (actual time=0.239..0.363 rows=2917 loops=1)
|
||||
Buffers: shared hit=289
|
||||
-> Hash (cost=172.22..172.22 rows=20 width=16) (actual time=0.251..0.252 rows=20 loops=1)
|
||||
Buffers: shared hit=141
|
||||
-> Subquery Scan on "ANY_subquery" (cost=171.97..172.22 rows=20 width=16) (actual time=0.239..0.241 rows=20 loops=1)
|
||||
-> Limit (cost=171.97..172.02 rows=20 width=24) (actual time=0.238..0.240 rows=20 loops=1)
|
||||
-> Sort (cost=171.97..174.09 rows=846 width=24) (actual time=0.238..0.238 rows=20 loops=1)
|
||||
Sort Key: fi.first_highlighted_at DESC, fi.id
|
||||
-> Seq Scan on feed_items fi (cost=0.00..149.46 rows=846 width=24) (actual time=0.112..0.175 rows=1000 loops=1)
|
||||
Planning Time: 0.060 ms
|
||||
Execution Time: 1.686 ms
|
||||
@@ -0,0 +1,33 @@
|
||||
-- L14 (a) window row_number() <= 3 — cuts in the DB but scans the whole partition
|
||||
-- FeedTopNIT.l14ExplainThreeWayPlanCompareIsTheCrownJewel (seed 1000, page 20, K=3)
|
||||
-- SELECT t.feed_item_id, t.color, t.text, t.created_at FROM (
|
||||
-- SELECT h.feed_item_id, h.color, h.text, h.created_at,
|
||||
-- row_number() OVER (PARTITION BY h.feed_item_id ORDER BY h.created_at DESC) AS rn
|
||||
-- FROM highlights h WHERE h.feed_item_id IN (<page-20 ids>)) t WHERE t.rn <= 3
|
||||
-- 읽기 포인트: Hash Semi Join 이 페이지 부모들의 하이라이트 전량(rows=1509)을 읽고 Sort 한 뒤 WindowAgg 가
|
||||
-- 순번을 매긴다. PG 15+ 는 rn<=3 을 WindowAgg 의 Run Condition 으로 밀어넣지만, 파티션 정렬은
|
||||
-- 이미 1509행 전량을 훑는다. 반환은 60행이지만 buffers shared hit=430 (two-step 과 같다 = 같은 스캔).
|
||||
|
||||
Subquery Scan on t (cost=531.52..543.16 rows=388 width=686) (actual time=1.376..1.486 rows=60 loops=1)
|
||||
Buffers: shared hit=430
|
||||
-> WindowAgg (cost=531.52..539.28 rows=388 width=694) (actual time=1.375..1.482 rows=60 loops=1)
|
||||
Run Condition: (row_number() OVER (?) <= 3)
|
||||
Buffers: shared hit=430
|
||||
-> Sort (cost=531.52..532.49 rows=388 width=686) (actual time=1.369..1.406 rows=1509 loops=1)
|
||||
Sort Key: h.feed_item_id, h.created_at DESC
|
||||
Sort Method: quicksort Memory: 155kB
|
||||
Buffers: shared hit=430
|
||||
-> Hash Semi Join (cost=172.47..514.84 rows=388 width=686) (actual time=0.590..0.941 rows=1509 loops=1)
|
||||
Hash Cond: (h.feed_item_id = "ANY_subquery".id)
|
||||
Buffers: shared hit=430
|
||||
-> Seq Scan on highlights h (cost=0.00..327.85 rows=3885 width=686) (actual time=0.289..0.421 rows=2917 loops=1)
|
||||
Buffers: shared hit=289
|
||||
-> Hash (cost=172.22..172.22 rows=20 width=16) (actual time=0.287..0.288 rows=20 loops=1)
|
||||
Buffers: shared hit=141
|
||||
-> Subquery Scan on "ANY_subquery" (cost=171.97..172.22 rows=20 width=16) (actual time=0.277..0.280 rows=20 loops=1)
|
||||
-> Limit (cost=171.97..172.02 rows=20 width=24) (actual time=0.277..0.279 rows=20 loops=1)
|
||||
-> Sort (cost=171.97..174.09 rows=846 width=24) (actual time=0.276..0.277 rows=20 loops=1)
|
||||
Sort Key: fi.first_highlighted_at DESC, fi.id
|
||||
-> Seq Scan on feed_items fi (cost=0.00..149.46 rows=846 width=24) (actual time=0.146..0.212 rows=1000 loops=1)
|
||||
Planning Time: 0.123 ms
|
||||
Execution Time: 1.552 ms
|
||||
@@ -0,0 +1,16 @@
|
||||
-- L15 keyset WITH sort-key index (range seek) — same deep page, offset 1980 equivalent cursor
|
||||
-- FeedKeysetIT.l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex (seed 2000)
|
||||
-- SELECT fi.id, fi.first_highlighted_at FROM feed_items fi
|
||||
-- WHERE (fi.first_highlighted_at, fi.id) < (TIMESTAMPTZ '...', '...'::uuid) -- cursor = 이전 페이지 마지막 행
|
||||
-- ORDER BY fi.first_highlighted_at DESC, fi.id DESC LIMIT 20
|
||||
-- 읽기 포인트: Index Only Scan(커버링) 으로 커서 이후 20행만 seek — actual rows=20, Heap Fetches=20, buffers 1(+2 read).
|
||||
-- 순서가 인덱스로 보장돼 Sort 노드가 없다. 페이지 깊이와 무관하게 상수(vs OFFSET 의 2000).
|
||||
|
||||
Limit (cost=0.28..18.14 rows=20 width=24) (actual time=0.054..0.060 rows=20 loops=1)
|
||||
Buffers: shared hit=1 read=2
|
||||
-> Index Only Scan using ix_feed_items_keyset on feed_items fi (cost=0.28..595.95 rows=667 width=24) (actual time=0.054..0.057 rows=20 loops=1)
|
||||
Index Cond: (ROW(first_highlighted_at, id) < ROW('2026-04-17 13:00:00+00'::timestamp with time zone, '17ab2b68-0981-43cc-a673-5757f7214899'::uuid))
|
||||
Heap Fetches: 20
|
||||
Buffers: shared hit=1 read=2
|
||||
Planning Time: 0.052 ms
|
||||
Execution Time: 0.076 ms
|
||||
@@ -0,0 +1,18 @@
|
||||
-- L15 keyset WITHOUT the sort-key index — same query, ix_feed_items_keyset absent
|
||||
-- FeedKeysetIT.l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex (seed 2000)
|
||||
-- 읽기 포인트: 결과 행(20)은 필터로 같지만, 정렬키 인덱스가 없어 Seq Scan 으로 2000 heap 행을 훑고
|
||||
-- (Rows Removed by Filter: 1980) Sort 한다 → buffers shared hit=141 (Index Only Scan 판의 ~140배).
|
||||
-- OFFSET(141)과 같은 buffers = 둘 다 전량 heap 접근. 정렬키 인덱스가 keyset 의 전제라는 증거.
|
||||
|
||||
Limit (cost=204.50..204.55 rows=20 width=24) (actual time=0.338..0.341 rows=20 loops=1)
|
||||
Buffers: shared hit=141
|
||||
-> Sort (cost=204.50..206.72 rows=887 width=24) (actual time=0.337..0.339 rows=20 loops=1)
|
||||
Sort Key: first_highlighted_at DESC, id DESC
|
||||
Sort Method: quicksort Memory: 26kB
|
||||
Buffers: shared hit=141
|
||||
-> Seq Scan on feed_items fi (cost=0.00..180.90 rows=887 width=24) (actual time=0.314..0.317 rows=20 loops=1)
|
||||
Filter: (ROW(first_highlighted_at, id) < ROW('2026-04-17 13:00:00+00'::timestamp with time zone, '17ab2b68-0981-43cc-a673-5757f7214899'::uuid))
|
||||
Rows Removed by Filter: 1980
|
||||
Buffers: shared hit=141
|
||||
Planning Time: 0.074 ms
|
||||
Execution Time: 0.373 ms
|
||||
@@ -0,0 +1,19 @@
|
||||
-- L15 OFFSET deep page (scan-then-discard) — page 100 of 100, offset 1980
|
||||
-- FeedKeysetIT.l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex (seed 2000, keyset index present)
|
||||
-- SELECT fi.id, fi.first_highlighted_at FROM feed_items fi
|
||||
-- ORDER BY fi.first_highlighted_at DESC, fi.id DESC OFFSET 1980 LIMIT 20
|
||||
-- 읽기 포인트: 정렬키 인덱스가 있어도 깊은 페이지에선 Seq Scan(2000)+Sort(2000) 로 전량을 훑고 20만 남긴다.
|
||||
-- Limit 하위 actual rows=2000 = 결과 20행을 위해 훑은 행(over-scan = offset+20). buffers shared hit=141.
|
||||
|
||||
Limit (cost=275.61..275.66 rows=20 width=24) (actual time=0.945..0.949 rows=20 loops=1)
|
||||
Buffers: shared hit=141
|
||||
-> Sort (cost=270.66..275.66 rows=2000 width=24) (actual time=0.759..0.865 rows=2000 loops=1)
|
||||
Sort Key: first_highlighted_at DESC, id DESC
|
||||
Sort Method: quicksort Memory: 189kB
|
||||
Buffers: shared hit=141
|
||||
-> Seq Scan on feed_items fi (cost=0.00..161.00 rows=2000 width=24) (actual time=0.124..0.369 rows=2000 loops=1)
|
||||
Buffers: shared hit=141
|
||||
Planning:
|
||||
Buffers: shared hit=5 read=1
|
||||
Planning Time: 0.137 ms
|
||||
Execution Time: 0.996 ms
|
||||
@@ -0,0 +1,33 @@
|
||||
-- L15 probe (→ L16): keyset + visibility OR/EXISTS — the sort-key index is lost
|
||||
-- FeedKeysetIT.l15ProbeVisibilityOrBreaksKeysetIndex (seed 2000, ix_feed_items_keyset present)
|
||||
-- SELECT fi.id, fi.first_highlighted_at FROM feed_items fi
|
||||
-- WHERE (fi.visibility='PUBLIC'
|
||||
-- OR (fi.visibility='MENTIONED' AND EXISTS(SELECT 1 FROM feed_item_mentions m WHERE m.feed_item_id=fi.id AND m.mentioned_user_id=:me))
|
||||
-- OR (fi.visibility='PRIVATE' AND fi.user_id=:me))
|
||||
-- AND (fi.first_highlighted_at, fi.id) < (:cursor)
|
||||
-- ORDER BY fi.first_highlighted_at DESC, fi.id DESC LIMIT 20
|
||||
-- 읽기 포인트: ix_feed_items_keyset(정렬키) 를 못 탄다. 대신 BitmapOr(visibility 3분기 각각 ix_feed_items_visibility_sort)
|
||||
-- + BitmapAnd(private = visibility ∩ user_id) + SubPlan(mentions EXISTS). bitmap 은 순서를 안 주므로
|
||||
-- Sort 노드가 재등장 = keyset 의 "순서 seek, Sort 없음" 이점 소멸 → L16(UNION 분해로 각 분기를 정렬 보장 인덱스로).
|
||||
|
||||
Limit (cost=100.32..100.34 rows=5 width=24) (actual time=0.189..0.193 rows=13 loops=1)
|
||||
Buffers: shared hit=26
|
||||
-> Sort (cost=100.32..100.34 rows=5 width=24) (actual time=0.188..0.190 rows=13 loops=1)
|
||||
Sort Key: fi.first_highlighted_at DESC, fi.id DESC
|
||||
-> Bitmap Heap Scan on feed_items fi (actual time=0.114..0.178 rows=13 loops=1)
|
||||
Recheck Cond: (((visibility='PUBLIC') AND (ROW(first_highlighted_at, id) < ROW(cursor)))
|
||||
OR ((visibility='MENTIONED') AND (ROW(first_highlighted_at, id) < ROW(cursor)))
|
||||
OR ((visibility='PRIVATE') AND (ROW(first_highlighted_at, id) < ROW(cursor)) AND (user_id = :me)))
|
||||
Filter: ((visibility='PUBLIC') OR ((visibility='MENTIONED') AND (SubPlan 1)) OR ((visibility='PRIVATE') AND (user_id = :me)))
|
||||
Rows Removed by Filter: 3
|
||||
-> BitmapOr (actual time=0.093..0.094 rows=0 loops=1)
|
||||
-> Bitmap Index Scan on ix_feed_items_visibility_sort (Index Cond: visibility='PUBLIC' AND ROW(...) < ROW(cursor))
|
||||
-> Bitmap Index Scan on ix_feed_items_visibility_sort (Index Cond: visibility='MENTIONED' AND ROW(...) < ROW(cursor))
|
||||
-> BitmapAnd
|
||||
-> Bitmap Index Scan on ix_feed_items_visibility_sort (Index Cond: visibility='PRIVATE' AND ROW(...) < ROW(cursor))
|
||||
-> Bitmap Index Scan on uq_feed_items_user_page (Index Cond: user_id = :me)
|
||||
SubPlan 1
|
||||
-> Index Only Scan using uq_feed_item_mentions on feed_item_mentions m (loops=4)
|
||||
Index Cond: ((feed_item_id = fi.id) AND (mentioned_user_id = :me))
|
||||
Planning Time: 0.319 ms
|
||||
Execution Time: 0.325 ms
|
||||
@@ -0,0 +1,18 @@
|
||||
-- L16 (c) precompute (CQRS read model) — per-viewer feed_visible table, single covering index scan
|
||||
-- FeedVisibilityIT.l16ExplainThreeWayPlanCompare / l16PrecomputeIsSingleIndexScanNoOrNoSort (seed 2000)
|
||||
-- CREATE TABLE feed_visible AS SELECT :me AS viewer_id, fi.id AS feed_item_id, fi.first_highlighted_at
|
||||
-- FROM feed_items fi WHERE <visible to me>;
|
||||
-- CREATE INDEX ix_feed_visible ON feed_visible (viewer_id, first_highlighted_at DESC, feed_item_id DESC);
|
||||
-- SELECT feed_item_id AS id, first_highlighted_at FROM feed_visible
|
||||
-- WHERE viewer_id=:me ORDER BY first_highlighted_at DESC, feed_item_id DESC LIMIT 20
|
||||
-- 읽기 포인트: 단일 Index Only Scan(커버링) — OR 도 조인도 Sort 도 없다. buffers shared hit=1(+2 read),
|
||||
-- 훑는 행 20. 단일 OR(122)·UNION(200) 대비 order-of-magnitude 적음 = CQRS 읽기 모델의 정체.
|
||||
|
||||
Limit (cost=0.28..1.84 rows=20 width=24) (actual time=0.021..0.025 rows=20 loops=1)
|
||||
Buffers: shared hit=1 read=2
|
||||
-> Index Only Scan using ix_feed_visible on feed_visible (cost=0.28..117.22 rows=1500 width=24) (actual time=0.021..0.023 rows=20 loops=1)
|
||||
Index Cond: (viewer_id = :me)
|
||||
Heap Fetches: 20
|
||||
Buffers: shared hit=1 read=2
|
||||
Planning Time: 0.102 ms
|
||||
Execution Time: 0.034 ms
|
||||
@@ -0,0 +1,30 @@
|
||||
-- L16 (a) single OR — the naive visibility filter: BitmapOr + top-N Sort + hashed SubPlan
|
||||
-- FeedVisibilityIT.l16ExplainThreeWayPlanCompare (seed 2000, viewer user008)
|
||||
-- SELECT fi.id, fi.first_highlighted_at FROM feed_items fi
|
||||
-- WHERE (fi.visibility='PUBLIC'
|
||||
-- OR (fi.visibility='MENTIONED' AND EXISTS(SELECT 1 FROM feed_item_mentions m WHERE m.feed_item_id=fi.id AND m.mentioned_user_id=:me))
|
||||
-- OR (fi.visibility='PRIVATE' AND fi.user_id=:me))
|
||||
-- ORDER BY fi.first_highlighted_at DESC, fi.id DESC LIMIT 20
|
||||
-- 읽기 포인트: seq scan 이 아니라 BitmapOr(3분기 인덱스)로 후보 1500 을 heap scan → top-N Sort(순서 손실)
|
||||
-- + 멘션 EXISTS 는 hashed SubPlan(후보마다 반복 아님). buffers shared hit=122.
|
||||
|
||||
Limit (cost=224.45..224.49 rows=15 width=24) (actual time=0.713..0.716 rows=20 loops=1)
|
||||
Buffers: shared hit=122
|
||||
-> Sort (cost=224.45..224.49 rows=15 width=24) (actual time=0.712..0.713 rows=20 loops=1)
|
||||
Sort Key: fi.first_highlighted_at DESC, fi.id DESC
|
||||
Sort Method: top-N heapsort Memory: 26kB
|
||||
Buffers: shared hit=122
|
||||
-> Bitmap Heap Scan on feed_items fi (actual time=0.256..0.590 rows=1500 loops=1)
|
||||
Recheck Cond: ((visibility='PUBLIC') OR (visibility='MENTIONED') OR ((user_id=:me) AND (visibility='PRIVATE')))
|
||||
Filter: ((visibility='PUBLIC') OR ((visibility='MENTIONED') AND (hashed SubPlan 2)) OR ((visibility='PRIVATE') AND (user_id=:me)))
|
||||
Rows Removed by Filter: 200
|
||||
Buffers: shared hit=122
|
||||
-> BitmapOr (actual time=0.185..0.185 rows=0 loops=1)
|
||||
-> Bitmap Index Scan on ix_feed_items_visibility_sort (Index Cond: visibility='PUBLIC') rows=2400
|
||||
-> Bitmap Index Scan on ix_feed_items_visibility_sort (Index Cond: visibility='MENTIONED') rows=800
|
||||
-> Bitmap Index Scan on ix_feed_items_private (Index Cond: user_id=:me) rows=100
|
||||
SubPlan 2
|
||||
-> Bitmap Heap Scan on feed_item_mentions m (Recheck Cond: mentioned_user_id=:me) rows=200
|
||||
-> Bitmap Index Scan on ix_mentions_user (Index Cond: mentioned_user_id=:me) rows=200
|
||||
Planning Time: 0.144 ms
|
||||
Execution Time: 0.808 ms
|
||||
@@ -0,0 +1,20 @@
|
||||
-- L16 union branches — each visibility branch rides its own optimal plan (what single-OR can't)
|
||||
-- FeedVisibilityIT.l16LowSelectivityBranchesRideTheirIndex (seed 2000, viewer user008)
|
||||
-- 읽기 포인트: 저선택도 분기는 자기 인덱스를 탄다 — mentioned=ix_mentions_user 조인, private=ix_feed_items_private
|
||||
-- partial 의 Index Only Scan. public(60% 고선택도)은 Bitmap Heap Scan+top-N Sort 가 최적.
|
||||
-- 단일 OR 은 3분기를 하나의 bitmap 으로 묶어 분기별 최적 플랜을 못 가진다.
|
||||
|
||||
== mentioned branch (JOIN feed_item_mentions on ix_mentions_user) ==
|
||||
Limit -> Sort (top-N) -> Hash Join (fi.id = m.feed_item_id)
|
||||
-> Bitmap Heap Scan on feed_items fi (visibility='MENTIONED')
|
||||
-> Hash -> Bitmap Heap Scan on feed_item_mentions m
|
||||
-> Bitmap Index Scan on ix_mentions_user (Index Cond: mentioned_user_id = :me) rows=200
|
||||
|
||||
== private branch (partial index ix_feed_items_private WHERE visibility='PRIVATE') ==
|
||||
Limit -> Incremental Sort (Presorted Key: first_highlighted_at)
|
||||
-> Index Only Scan using ix_feed_items_private on feed_items fi (Index Cond: user_id = :me) Heap Fetches: 21
|
||||
|
||||
== public branch (60% selectivity -> seq/bitmap + top-N is optimal, not an index range) ==
|
||||
Limit -> Sort (top-N heapsort)
|
||||
-> Bitmap Heap Scan on feed_items fi (Recheck Cond: visibility='PUBLIC')
|
||||
-> Bitmap Index Scan on ix_feed_items_visibility_sort (Index Cond: visibility='PUBLIC')
|
||||
@@ -0,0 +1,27 @@
|
||||
-- L16 (b) UNION decompose — 3 visibility branches, each index-ordered, Merge Append + Hash Join
|
||||
-- FeedVisibilityIT.l16ExplainThreeWayPlanCompare (seed 2000, viewer user008)
|
||||
-- (public branch) UNION ALL (mentioned branch: JOIN feed_item_mentions) UNION ALL (private branch: partial idx)
|
||||
-- ORDER BY first_highlighted_at DESC, id DESC LIMIT 20
|
||||
-- 읽기 포인트: Merge Append 가 미리 정렬된 분기 스트림을 병합(전체 재정렬 없음). 멘션 EXISTS 가 Hash Join(집합 기반)
|
||||
-- 으로, private 는 Index Only Scan(partial)+Incremental Sort 로. 구조는 우수하나 buffers 200(분기별 스캔).
|
||||
|
||||
Limit (cost=97.16..97.40 rows=13 width=24) (actual time=0.655..0.661 rows=20 loops=1)
|
||||
Buffers: shared hit=200
|
||||
-> Merge Append (cost=97.16..97.40 rows=13 width=24) (actual time=0.654..0.659 rows=20 loops=1)
|
||||
Sort Key: fi.first_highlighted_at DESC, fi.id DESC
|
||||
Buffers: shared hit=200
|
||||
-> Limit (rows=17) -- public branch
|
||||
-> Sort (top-N heapsort)
|
||||
-> Bitmap Heap Scan on feed_items fi (Recheck Cond: visibility='PUBLIC')
|
||||
-> Bitmap Index Scan on ix_feed_items_visibility_sort
|
||||
-> Limit (rows=3) -- mentioned branch: EXISTS -> Hash Join
|
||||
-> Sort (top-N heapsort)
|
||||
-> Hash Join (Hash Cond: fi_1.id = m.feed_item_id)
|
||||
-> Bitmap Heap Scan on feed_items fi_1 (visibility='MENTIONED')
|
||||
-> Hash -> Bitmap Heap Scan on feed_item_mentions m
|
||||
-> Bitmap Index Scan on ix_mentions_user (mentioned_user_id=:me)
|
||||
-> Limit (rows=2) -- private branch: partial index, index-only
|
||||
-> Incremental Sort (Presorted Key: fi_2.first_highlighted_at)
|
||||
-> Index Only Scan using ix_feed_items_private on feed_items fi_2 (user_id=:me) Heap Fetches: 21
|
||||
Planning Time: 0.398 ms
|
||||
Execution Time: 0.780 ms
|
||||
@@ -0,0 +1,29 @@
|
||||
컬렉션 하나만 fetch join한 조인의 실행계획 (Fetch Join 시도 — 카테시안 행 곱)
|
||||
출처: FeedPersistenceIT.l3ExplainCollectionJoinRowMultiplication 콘솔 출력
|
||||
조건: seed(100) 직후. warm buffer cache(shared read=0).
|
||||
쿼리: EXPLAIN (ANALYZE, BUFFERS)
|
||||
SELECT fi.id, h.id FROM feed_items fi JOIN highlights h ON h.feed_item_id = fi.id
|
||||
(fetch join `select f from FeedItemJpaEntity f join fetch f.highlights`가 발행하는 조인과 같은 shape)
|
||||
|
||||
Hash Join (cost=77.18..512.34 rows=4202 width=32) (actual time=0.589..0.894 rows=1961 loops=1)
|
||||
Hash Cond: (h.feed_item_id = fi.id)
|
||||
Buffers: shared hit=450
|
||||
-> Seq Scan on highlights h (cost=0.00..424.02 rows=4202 width=32) (actual time=0.471..0.570 rows=1961 loops=1)
|
||||
Buffers: shared hit=382
|
||||
-> Hash (cost=72.08..72.08 rows=408 width=16) (actual time=0.112..0.112 rows=100 loops=1)
|
||||
Buckets: 1024 Batches: 1 Memory Usage: 13kB
|
||||
Buffers: shared hit=68
|
||||
-> Seq Scan on feed_items fi (cost=0.00..72.08 rows=408 width=16) (actual time=0.084..0.092 rows=100 loops=1)
|
||||
Buffers: shared hit=68
|
||||
Planning Time: 0.099 ms
|
||||
Execution Time: 0.959 ms
|
||||
|
||||
관찰(문서 §9):
|
||||
- §6.4는 반복되는 자식 단건 쿼리를, §7.4는 반복되는 부모 단건 쿼리를 봤다. 여기서는 조인 한 방을 본다 —
|
||||
Hash Join 노드의 actual rows=1961이 카테시안의 실체다. 부모 feed_items는 100행(Hash 노드)인데,
|
||||
조인 결과는 1,961행(= Σ highlights)으로 부푼다. 쿼리는 하나인데 그 하나가 실어 나르는 행이 곱이다.
|
||||
- 이 1,961이 N2 랩(§7)의 아이템 수 100이 아니라 자식 총량(1,961)과 같다는 게 핵심 — 전송 비용이
|
||||
'왕복 수'에서 '전송 행수'로 옮겨갔다.
|
||||
- Buffers: shared read=0 → warm buffer cache. cold 디스크 I/O 실행시간으로 읽지 말 것.
|
||||
- Execution Time 0.959 ms는 executor 내부 시간(§6.4 caveat와 동일). 애플리케이션 지연이 아니다.
|
||||
- rows=4202(추정) vs rows=1961(실제)의 오차는 대량 시드 직후 ANALYZE 미실행 탓(§6.4 Plan A와 같은 통계 이슈).
|
||||
@@ -0,0 +1,35 @@
|
||||
컬렉션 fetch join + 페이징이 발행하는 조인의 실행계획 (a) — LIMIT 노드 없음
|
||||
출처: FeedPersistenceIT.l4ExplainCollectionJoinHasNoLimitButEntityPagingDoes 콘솔 출력 (a)
|
||||
조건: seed(100) 직후. warm buffer cache(shared read=0).
|
||||
쿼리: EXPLAIN (ANALYZE, BUFFERS)
|
||||
SELECT fi.*, h.* FROM feed_items fi JOIN highlights h ON h.feed_item_id = fi.id
|
||||
ORDER BY fi.first_highlighted_at DESC, fi.id ASC
|
||||
(fetch join `select f from FeedItemJpaEntity f join fetch f.highlights order by ...`가
|
||||
페이징(setMaxResults) 시 발행하는 조인과 같은 shape — 단, SQL에 LIMIT이 붙지 않는다)
|
||||
|
||||
Sort (cost=293.30..297.76 rows=1782 width=1904) (actual time=1.219..1.266 rows=1961 loops=1)
|
||||
Sort Key: fi.first_highlighted_at DESC, fi.id
|
||||
Sort Method: quicksort Memory: 445kB
|
||||
Buffers: shared hit=173
|
||||
-> Hash Join (cost=12.48..197.08 rows=1782 width=1904) (actual time=0.279..0.636 rows=1961 loops=1)
|
||||
Hash Cond: (h.feed_item_id = fi.id)
|
||||
Buffers: shared hit=173
|
||||
-> Seq Scan on highlights h (cost=0.00..179.82 rows=1782 width=710) (actual time=0.231..0.330 rows=1961 loops=1)
|
||||
Buffers: shared hit=162
|
||||
-> Hash (cost=11.66..11.66 rows=66 width=1194) (actual time=0.035..0.036 rows=100 loops=1)
|
||||
Buckets: 1024 Batches: 1 Memory Usage: 22kB
|
||||
Buffers: shared hit=11
|
||||
-> Seq Scan on feed_items fi (cost=0.00..11.66 rows=66 width=1194) (actual time=0.018..0.023 rows=100 loops=1)
|
||||
Buffers: shared hit=11
|
||||
Planning Time: 0.135 ms
|
||||
Execution Time: 1.369 ms
|
||||
|
||||
관찰(문서 §10):
|
||||
- 계획 어디에도 Limit 노드가 없다 = DB가 페이징을 하지 않았다. 조인 결과 전체(actual rows=1961 = Σ highlights)를
|
||||
quicksort로 445kB 정렬한 뒤 그대로 반환한다. 페이지 크기(20)로 자르는 일은 SQL 밖 — Hibernate가 메모리에서 한다.
|
||||
- 부모 feed_items는 100행(Hash 노드)인데 Hash Join 노드 actual rows=1961(= Σ highlights, §9.3)로 부푼다 —
|
||||
컬렉션 fetch join의 카테시안이 그대로다. 그 곱해진 행에 DB LIMIT을 걸면 "20개 부모"가 아니라 "20개 조인 행"을
|
||||
잘라 어떤 부모는 하이라이트가 잘린 반쪽으로 로드될 위험 → 그래서 Hibernate가 LIMIT을 빼고 인메모리 페이징한다.
|
||||
- Buffers: shared read=0 → warm buffer cache. cold 디스크 I/O 실행시간으로 읽지 말 것.
|
||||
- Execution Time 1.369 ms는 executor 내부 시간(§6.4 caveat와 동일). 애플리케이션 지연이 아니다.
|
||||
- 대조군은 l4-entity-paging-limit.txt (엔티티만 페이징 → Limit 노드 존재).
|
||||
@@ -0,0 +1,25 @@
|
||||
엔티티만 페이징한 SQL의 실행계획 (b) — Limit 노드 존재 (대조군)
|
||||
출처: FeedPersistenceIT.l4ExplainCollectionJoinHasNoLimitButEntityPagingDoes 콘솔 출력 (b)
|
||||
조건: seed(100) 직후. warm buffer cache(shared read=0).
|
||||
쿼리: EXPLAIN (ANALYZE, BUFFERS)
|
||||
SELECT fi.* FROM feed_items fi ORDER BY fi.first_highlighted_at DESC, fi.id ASC LIMIT 20
|
||||
(fetch join 없이 엔티티만 페이징한 SQL — DB가 정상적으로 페이징하는 모습)
|
||||
|
||||
Limit (cost=13.42..13.47 rows=20 width=1194) (actual time=0.038..0.040 rows=20 loops=1)
|
||||
Buffers: shared hit=11
|
||||
-> Sort (cost=13.42..13.58 rows=66 width=1194) (actual time=0.038..0.038 rows=20 loops=1)
|
||||
Sort Key: first_highlighted_at DESC, id
|
||||
Sort Method: top-N heapsort Memory: 28kB
|
||||
Buffers: shared hit=11
|
||||
-> Seq Scan on feed_items fi (cost=0.00..11.66 rows=66 width=1194) (actual time=0.015..0.019 rows=100 loops=1)
|
||||
Buffers: shared hit=11
|
||||
Planning Time: 0.029 ms
|
||||
Execution Time: 0.050 ms
|
||||
|
||||
관찰(문서 §10):
|
||||
- 계획 최상단에 Limit 노드가 있고 그 아래 Sort가 top-N heapsort(28kB)로 상위 20행만 취한다 = DB가 페이징을 했다.
|
||||
(a) l4-collection-join-no-limit.txt는 Limit 노드가 없어 전체 1961행을 quicksort(445kB)로 정렬했다 — 대조가 요점.
|
||||
- 28kB(top-N heapsort, 20행) vs 445kB(quicksort, 1961행): "DB 페이징 vs 인메모리 페이징"의 메모리 비용 차이가
|
||||
계획 레벨로 드러난다. (a)에 Limit이 없다는 것 자체가 "DB가 페이징을 안 했다 → Hibernate가 메모리에서 했다"의 증거.
|
||||
- 컬럼명·리터럴 하드코딩이라 인젝션 무관. PG 계획 문구는 버전·통계에 따라 흔들릴 수 있어 강가드 대신 눈 대조로 둔다.
|
||||
- Buffers: shared read=0 → warm buffer cache. Execution Time 0.050 ms는 executor 내부 시간(§6.4 caveat와 동일).
|
||||
@@ -0,0 +1,38 @@
|
||||
배치 IN 조회의 실행계획 (b) — 행을 곱하지 않는다 (카테시안 소멸)
|
||||
출처: FeedBatchFetchIT.l5ExplainEntityPagingHasLimitAndBatchInHasNoRowMultiplication 콘솔 출력 (b)
|
||||
조건: seed(100) 직후, default_batch_fetch_size=100 세션. warm buffer cache.
|
||||
쿼리: EXPLAIN (ANALYZE, BUFFERS)
|
||||
SELECT h.* FROM highlights h
|
||||
WHERE h.feed_item_id IN (SELECT fi.id FROM feed_items fi
|
||||
ORDER BY fi.first_highlighted_at DESC, fi.id ASC LIMIT 20)
|
||||
(배치 페치가 페이지 부모 20개의 highlights 를 IN 한 방으로 채우는 것과 같은 shape)
|
||||
|
||||
Hash Semi Join (cost=72.46..317.64 rows=234 width=710) (actual time=0.295..0.541 rows=1509 loops=1)
|
||||
Hash Cond: (h.feed_item_id = "ANY_subquery".id)
|
||||
Buffers: shared hit=272
|
||||
-> Seq Scan on highlights h (cost=0.00..236.43 rows=2343 width=710) (actual time=0.207..0.286 rows=1961 loops=1)
|
||||
Buffers: shared hit=213
|
||||
-> Hash (cost=72.21..72.21 rows=20 width=16) (actual time=0.084..0.085 rows=20 loops=1)
|
||||
Buckets: 1024 Batches: 1 Memory Usage: 9kB
|
||||
Buffers: shared hit=59
|
||||
-> Subquery Scan on "ANY_subquery" (cost=71.96..72.21 rows=20 width=16) (actual time=0.077..0.080 rows=20 loops=1)
|
||||
Buffers: shared hit=59
|
||||
-> Limit (cost=71.96..72.01 rows=20 width=24) (actual time=0.077..0.078 rows=20 loops=1)
|
||||
Buffers: shared hit=59
|
||||
-> Sort (cost=71.96..72.84 rows=354 width=24) (actual time=0.076..0.077 rows=20 loops=1)
|
||||
Sort Key: fi.first_highlighted_at DESC, fi.id
|
||||
Sort Method: top-N heapsort Memory: 26kB
|
||||
Buffers: shared hit=59
|
||||
-> Seq Scan on feed_items fi (cost=0.00..62.54 rows=354 width=24) (actual time=0.053..0.059 rows=100 loops=1)
|
||||
Buffers: shared hit=59
|
||||
Planning:
|
||||
Buffers: shared hit=28
|
||||
Planning Time: 0.206 ms
|
||||
Execution Time: 0.592 ms
|
||||
|
||||
관찰(문서 §11):
|
||||
- Semi Join 이 반환하는 행 = 1509(페이지 20개 부모의 highlights). 부모 M행 × 자식 = M×K 로 곱하지 않는다.
|
||||
L3 카테시안(조인이 feed_items ⋈ highlights 를 1961행으로 곱함)과 정반대 — 자식 K행만 반환(합, 곱 아님).
|
||||
- 배치 페치가 하는 일이 이 shape다: 페이지 부모 키를 모아 WHERE feed_item_id IN (…) 로 한 방에 채운다.
|
||||
Hibernate 는 이를 default_batch_fetch_size 만큼 쪼개 ceil(pageItems/batch) 번 발행한다.
|
||||
- Buffers: read≈0 → warm buffer cache. Execution Time 0.592 ms 는 executor 내부 시간(§6.4 caveat와 동일).
|
||||
@@ -0,0 +1,26 @@
|
||||
엔티티만 페이징한 SQL의 실행계획 (a) — Limit 노드 존재 (배치 페치 해법의 페이징)
|
||||
출처: FeedBatchFetchIT.l5ExplainEntityPagingHasLimitAndBatchInHasNoRowMultiplication 콘솔 출력 (a)
|
||||
조건: seed(100) 직후, default_batch_fetch_size=100 세션. warm buffer cache.
|
||||
쿼리: EXPLAIN (ANALYZE, BUFFERS)
|
||||
SELECT fi.* FROM feed_items fi ORDER BY fi.first_highlighted_at DESC, fi.id ASC LIMIT 20
|
||||
(배치 해법은 fetch join을 버리고 엔티티만 페이징한다 → DB가 정상 페이징)
|
||||
|
||||
Limit (cost=71.96..72.01 rows=20 width=1194) (actual time=0.108..0.109 rows=20 loops=1)
|
||||
Buffers: shared hit=65
|
||||
-> Sort (cost=71.96..72.84 rows=354 width=1194) (actual time=0.107..0.108 rows=20 loops=1)
|
||||
Sort Key: first_highlighted_at DESC, id
|
||||
Sort Method: top-N heapsort Memory: 28kB
|
||||
Buffers: shared hit=65
|
||||
-> Seq Scan on feed_items fi (cost=0.00..62.54 rows=354 width=1194) (actual time=0.075..0.080 rows=100 loops=1)
|
||||
Buffers: shared hit=59
|
||||
Planning:
|
||||
Buffers: shared hit=14 read=1
|
||||
Planning Time: 0.085 ms
|
||||
Execution Time: 0.118 ms
|
||||
|
||||
관찰(문서 §11):
|
||||
- 계획 최상단에 Limit 노드가 있다 = DB가 페이징을 했다. top-N heapsort 28kB로 상위 20행만 취한다.
|
||||
- L4 (a)(컬렉션 fetch join)는 Limit 노드가 없어 전체 1961행을 quicksort(445kB)로 정렬했다 — 정반대.
|
||||
fetch join을 버리니(엔티티만 페이징) 페이징이 DB로 내려간다(HHH000104 인메모리 페이징 소멸).
|
||||
- 대조군: l5-batch-in-semijoin.txt (페이지 부모들의 highlights 를 IN 한 방으로 — 행을 곱하지 않는다).
|
||||
- Buffers: read≈0 → warm buffer cache. Execution Time 0.118 ms 는 executor 내부 시간(§6.4 caveat와 동일).
|
||||
@@ -0,0 +1,27 @@
|
||||
-- L6 (b) child scalar IN projection — SELECT new carrier(h.feedItem.id, h.color, h.text, h.createdAt)
|
||||
-- FeedProjectionIT.l6ExplainProjectionHasLimitAndSemiJoinNotNarrowerWidth (seed 100, page size 20)
|
||||
-- SELECT h.feed_item_id, h.color, h.text, h.created_at FROM highlights h
|
||||
-- WHERE h.feed_item_id IN (SELECT fi.id FROM feed_items fi ORDER BY fi.first_highlighted_at DESC, fi.id ASC LIMIT 20)
|
||||
-- 읽기 포인트: Hash Semi Join 이라 자식 행(1509)만 반환 — 부모 M × 자식 K 로 곱하지 않는다(카테시안 없음).
|
||||
-- L5 배치 IN (b)와 같은 shape/행수(1509). 프로젝션은 필요 컬럼만(color/text/created_at) 읽는다.
|
||||
|
||||
Hash Semi Join (cost=50.51..235.83 rows=177 width=686) (actual time=0.218..0.452 rows=1509 loops=1)
|
||||
Hash Cond: (h.feed_item_id = "ANY_subquery".id)
|
||||
Buffers: shared hit=202
|
||||
-> Seq Scan on highlights h (cost=0.00..178.71 rows=1771 width=686) (actual time=0.148..0.226 rows=1961 loops=1)
|
||||
Buffers: shared hit=161
|
||||
-> Hash (cost=50.26..50.26 rows=20 width=16) (actual time=0.067..0.067 rows=20 loops=1)
|
||||
Buckets: 1024 Batches: 1 Memory Usage: 9kB
|
||||
Buffers: shared hit=41
|
||||
-> Subquery Scan on "ANY_subquery" (cost=50.01..50.26 rows=20 width=16) (actual time=0.060..0.062 rows=20 loops=1)
|
||||
Buffers: shared hit=41
|
||||
-> Limit (cost=50.01..50.06 rows=20 width=24) (actual time=0.059..0.060 rows=20 loops=1)
|
||||
Buffers: shared hit=41
|
||||
-> Sort (cost=50.01..50.62 rows=246 width=24) (actual time=0.059..0.059 rows=20 loops=1)
|
||||
Sort Key: fi.first_highlighted_at DESC, fi.id
|
||||
Sort Method: top-N heapsort Memory: 26kB
|
||||
Buffers: shared hit=41
|
||||
-> Seq Scan on feed_items fi (cost=0.00..43.46 rows=246 width=24) (actual time=0.035..0.041 rows=100 loops=1)
|
||||
Buffers: shared hit=41
|
||||
Planning Time: 0.064 ms
|
||||
Execution Time: 0.497 ms
|
||||
@@ -0,0 +1,34 @@
|
||||
-- L6 (a) parent scalar projection — SELECT new carrier(f.id, u.name, u.username, p.url, p.title, f.firstHighlightedAt)
|
||||
-- FeedProjectionIT.l6ExplainProjectionHasLimitAndSemiJoinNotNarrowerWidth (seed 100, page size 20)
|
||||
-- SELECT fi.id, u.name, u.username, p.url, p.title, fi.first_highlighted_at
|
||||
-- FROM feed_items fi JOIN users u ON u.id = fi.user_id JOIN pages p ON p.id = fi.page_id
|
||||
-- ORDER BY fi.first_highlighted_at DESC, fi.id ASC LIMIT 20
|
||||
-- 읽기 포인트: Limit 노드 존재(페이징 정상)이나 width=2088 로 엔티티 SELECT fi.*(L5 (a) width 1194)보다 넓다
|
||||
-- — users/pages 조인 + PG varchar 추정치 탓. 프로젝션 이득은 EXPLAIN 아니라 ORM 층(entityLoadCount 0).
|
||||
|
||||
Limit (cost=102.56..102.61 rows=20 width=2088) (actual time=0.187..0.190 rows=20 loops=1)
|
||||
Buffers: shared hit=81
|
||||
-> Sort (cost=102.56..103.17 rows=246 width=2088) (actual time=0.187..0.188 rows=20 loops=1)
|
||||
Sort Key: fi.first_highlighted_at DESC, fi.id
|
||||
Sort Method: top-N heapsort Memory: 27kB
|
||||
Buffers: shared hit=81
|
||||
-> Hash Join (cost=51.24..96.01 rows=246 width=2088) (actual time=0.137..0.161 rows=100 loops=1)
|
||||
Hash Cond: (fi.page_id = p.id)
|
||||
Buffers: shared hit=81
|
||||
-> Hash Join (cost=10.68..54.79 rows=246 width=1072) (actual time=0.072..0.087 rows=100 loops=1)
|
||||
Hash Cond: (fi.user_id = u.id)
|
||||
Buffers: shared hit=43
|
||||
-> Seq Scan on feed_items fi (cost=0.00..43.46 rows=246 width=56) (actual time=0.053..0.057 rows=100 loops=1)
|
||||
Buffers: shared hit=41
|
||||
-> Hash (cost=10.30..10.30 rows=30 width=1048) (actual time=0.010..0.010 rows=20 loops=1)
|
||||
Buckets: 1024 Batches: 1 Memory Usage: 10kB
|
||||
Buffers: shared hit=2
|
||||
-> Seq Scan on users u (cost=0.00..10.30 rows=30 width=1048) (actual time=0.003..0.004 rows=20 loops=1)
|
||||
Buffers: shared hit=2
|
||||
-> Hash (cost=39.14..39.14 rows=114 width=1048) (actual time=0.059..0.059 rows=100 loops=1)
|
||||
Buckets: 1024 Batches: 1 Memory Usage: 17kB
|
||||
Buffers: shared hit=38
|
||||
-> Seq Scan on pages p (cost=0.00..39.14 rows=114 width=1048) (actual time=0.040..0.045 rows=100 loops=1)
|
||||
Buffers: shared hit=38
|
||||
Planning Time: 0.134 ms
|
||||
Execution Time: 0.228 ms
|
||||
@@ -0,0 +1,20 @@
|
||||
반복되는 Page ToOne 부모 쿼리의 실행계획 (N2 — 선형 범인)
|
||||
출처: FeedPersistenceIT.l2ExplainRepeatedPageToOneQuery 콘솔 출력
|
||||
조건: seed(100) 직후. warm buffer cache(shared read=0). id는 시드된 실제 pages.id 1건.
|
||||
쿼리: SELECT * FROM pages WHERE id = ? (@ManyToOne EAGER가 행마다 반복하는 2차 SELECT)
|
||||
|
||||
Index Scan using pk_pages on pages
|
||||
(cost=0.14..8.15 rows=1 width=2104) (actual time=0.009..0.009 rows=1 loops=1)
|
||||
Index Cond: (id = 'a069f5ac-fa46-41f8-bcde-174153789467'::uuid)
|
||||
Buffers: shared hit=2
|
||||
Planning Time: 0.031 ms
|
||||
Execution Time: 0.021 ms
|
||||
|
||||
주의(문서 §7.3 / §6.4 caveat와 동일):
|
||||
- PK 조회라 pk_pages Index Scan으로 1건 0.021 ms. "쿼리가 느려서"가 아니다 — page는 아이템당
|
||||
고유(dedup 없음)라 이 빠른 계획이 정확히 N번 반복되는 게 문제다(왕복 N회).
|
||||
- users 계획(toone-users-plan.txt)과 실행계획이 사실상 동일하다. 비용을 가르는 것은 계획이 아니라
|
||||
반복 횟수(page=N vs user=distinct≤20)다 — 카디널리티가 곡선을 가른다.
|
||||
- Buffers: shared hit=2, read=0 → warm buffer cache. cold 디스크 I/O 실행시간으로 읽지 말 것.
|
||||
- Execution Time 0.021 ms는 executor 내부 시간. 애플리케이션 지연(§6.2)과 같은 지표가 아니다.
|
||||
- 인덱스로 안 풀린다(계획이 이미 PK Index Scan). 왕복 횟수 자체를 줄이는 fetch 전략이 필요(§9).
|
||||
@@ -0,0 +1,19 @@
|
||||
반복되는 User ToOne 부모 쿼리의 실행계획 (N2 — 평탄, 1차 캐시 dedup)
|
||||
출처: FeedPersistenceIT.l2ExplainRepeatedPageToOneQuery 콘솔 출력
|
||||
조건: seed(100) 직후. warm buffer cache(shared read=0). id는 시드된 실제 users.id 1건.
|
||||
쿼리: SELECT * FROM users WHERE id = ? (@ManyToOne EAGER가 반복하는 2차 SELECT)
|
||||
|
||||
Index Scan using pk_users on users
|
||||
(cost=0.14..8.15 rows=1 width=2104) (actual time=0.013..0.014 rows=1 loops=1)
|
||||
Index Cond: (id = '0a2a85ed-f8f3-47f0-b957-c477f4b077ab'::uuid)
|
||||
Buffers: shared hit=2
|
||||
Planning Time: 0.027 ms
|
||||
Execution Time: 0.022 ms
|
||||
|
||||
주의(문서 §7.3):
|
||||
- 단건 실행계획은 pages(toone-pages-plan.txt)와 사실상 동일하다: 둘 다 pk Index Scan, ~0.02 ms.
|
||||
- 그러나 반복 횟수가 다르다. user는 소수 풀(≤20)을 재사용하고 한 번 로드된 대상은 영속성
|
||||
컨텍스트(1차 캐시)에 남아 재조회되지 않으므로, 서로 다른 대상(distinct target) 수만큼만
|
||||
나간다 → N과 무관하게 ≤20에서 평탄. page는 아이템당 고유라 N번.
|
||||
- 결론: 같은 @ManyToOne(EAGER)·같은 실행계획인데 곡선이 갈리는 원인은 계획이 아니라
|
||||
데이터 분포(카디널리티)다. EXPLAIN만 보면 둘이 똑같아 보이는 것이 '숨은' N+1의 얼굴이다.
|
||||