Files
document-haness/.playwright-mcp/page-2026-09-04T03-00-11-541Z.yml

555 lines
50 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
- generic [ref=f3e3]:
- link "본문으로 건너뛰기" [ref=f3e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f3e5]:
- generic [ref=f3e6]:
- link "TechLog Studio" [ref=f3e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f3e8]: Studio
- navigation "Studio 주 탐색" [ref=f3e10]:
- link "작업본" [ref=f3e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f3e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f3e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f3e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f3e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f3e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f3e17]
- main [ref=f3e18]:
- generic [ref=f3e19]:
- generic [ref=f3e20]:
- region [ref=f3e21]:
- generic [ref=f3e22]:
- paragraph [ref=f3e23]: CASE · VERSION 5
- heading "문서 편집" [level=1] [ref=f3e24]
- paragraph [ref=f3e25]: DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1
- region [ref=f3e26]:
- generic [ref=f3e27]:
- paragraph [ref=f3e28]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f3e29]
- generic [ref=f3e30]:
- generic [ref=f3e31]:
- generic [ref=f3e32]: 제목
- textbox "제목" [ref=f3e33]: DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1
- generic [ref=f3e34]:
- generic [ref=f3e35]: slug
- textbox "slug" [ref=f3e36]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- text: collection-nplus1-dto-mapping
- generic [ref=f3e37]:
- generic [ref=f3e38]: 요약
- textbox "요약" [ref=f3e39]: 피드 아이템을 엔티티로 조회한 뒤 Stream으로 DTO에 옮기는 코드를 그대로 두고 측정했다. 매핑이 getHighlights()에 접근할 때마다 비워 뒀던 하이라이트 목록이 하나씩 채워졌고, 채워진 목록 수가 반환 아이템 수 N과 정확히 같았다. N=1,000에서 이 요청 하나가 준비한 SQL 문장은 2,022건이었다.
- generic [aria-hidden] [ref=f3e40]: 목록 카드에는 약 90자까지 보입니다 · 181 / 2000
- generic [ref=f3e41]:
- generic [ref=f3e42]: Topic
- combobox "Topic" [ref=f3e43]:
- option "선택하지 않음"
- option "JPA 피드 조회 성능" [selected]
- option "OAuth/OIDC 인증 경계"
- generic [ref=f3e44]:
- generic [ref=f3e45]: Project
- combobox "Project" [ref=f3e46]:
- option "미지정"
- option "Backend Clean Architecture"
- option "KeyCloak Patterns"
- option "Liner N + 1문제" [selected]
- status [ref=f3e47]
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f3e48]:
- generic [ref=f3e50] [cursor=pointer]:
- checkbox "파생 쿼리 그대로" [ref=f3e51]
- generic [ref=f3e52]: 파생 쿼리 그대로
- generic [ref=f3e53] [cursor=pointer]:
- checkbox "컬렉션 fetch join" [ref=f3e54]
- generic [ref=f3e55]: 컬렉션 fetch join
- generic [ref=f3e56] [cursor=pointer]:
- checkbox "fetch join + 페이징" [ref=f3e57]
- generic [ref=f3e58]: fetch join + 페이징
- group "관계" [ref=f3e59]:
- generic [ref=f3e61]:
- generic [ref=f3e62]:
- generic [ref=f3e63]: 관계 1 대상
- combobox "관계 1 대상" [ref=f3e64]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [disabled]
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준"
- option "Fetch Type과 Fetch Strategy 구분" [disabled]
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준" [selected]
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f3e65]:
- generic [ref=f3e66]: 관계 1 이유
- textbox "관계 1 이유" [ref=f3e67]: 이 측정에서 사용한 지표와 회계 항등식이다.
- generic [aria-hidden] [ref=f3e68]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f3e69]:
- button "위로" [disabled] [ref=f3e70]
- button "아래로" [ref=f3e71]
- button "삭제" [ref=f3e72]
- generic [ref=f3e73]:
- generic [ref=f3e74]:
- generic [ref=f3e75]: 관계 2 대상
- combobox "관계 2 대상" [ref=f3e76]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [disabled]
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준"
- option "Fetch Type과 Fetch Strategy 구분" [selected]
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준" [disabled]
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f3e77]:
- generic [ref=f3e78]: 관계 2 이유
- textbox "관계 2 이유" [ref=f3e79]: 컬렉션이 지연 로딩이라 접근 시점에 조회가 나갔다.
- generic [aria-hidden] [ref=f3e80]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f3e81]:
- button "위로" [ref=f3e82]
- button "아래로" [ref=f3e83]
- button "삭제" [ref=f3e84]
- generic [ref=f3e85]:
- generic [ref=f3e86]:
- generic [ref=f3e87]: 관계 3 대상
- combobox "관계 3 대상" [ref=f3e88]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [selected]
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준"
- option "Fetch Type과 Fetch Strategy 구분" [disabled]
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준" [disabled]
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f3e89]:
- generic [ref=f3e90]: 관계 3 이유
- textbox "관계 3 이유" [ref=f3e91]: 같은 기준선에서 함께 드러난 ToOne 쪽 문제다.
- generic [aria-hidden] [ref=f3e92]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f3e93]:
- button "위로" [ref=f3e94]
- button "아래로" [disabled] [ref=f3e95]
- button "삭제" [ref=f3e96]
- button "관계 추가" [ref=f3e97]
- region [ref=f3e98]:
- generic [ref=f3e99]:
- paragraph [ref=f3e100]: CASE
- heading "문제와 검증" [level=2] [ref=f3e101]
- generic [ref=f3e102]:
- generic [ref=f3e103]:
- generic [ref=f3e104]: 문제
- textbox "문제" [ref=f3e105]: 피드 API는 한 페이지에 하이라이트가 많아져도 조회량이 따라 늘지 않아야 했다. 최초 구현은 findAllBy로 FeedItem을 페이징 조회한 뒤, Stream으로 순회하며 FeedSummary로 필드를 옮겼다. 이 코드에는 하이라이트를 도는 명시적인 for가 없고 getHighlights().stream()만 있다. 그래서 조회가 몇 번 나가는지 코드만 보고는 알기 어려웠다.
- generic [ref=f3e106]:
- generic [ref=f3e107]: 결론
- textbox "결론" [ref=f3e108]: 지연 로딩으로 비워 두는 Highlight 목록이 아이템마다 하나씩 채워졌다. 채워진 목록 수는 N=10에서 10, N=100에서 100, N=1,000에서 1,000으로 N과 정확히 같았다. 같은 실행에서 준비된 SQL 문장(PreparedStatement)은 25건, 222건, 2,022건이었다. 코드에 명시적인 반복문은 없지만, Stream이 아이템을 하나씩 도는 동안 컬렉션 접근도 그만큼 일어났다. 지연 로딩 컬렉션은 접근하는 순간 조회하므로 아이템이 N개면 접근과 조회도 N번 발생한다. 조회량이 하이라이트 수를 따라 늘지 않아야 한다는 요구는 두 가지 방식으로 깨졌다. 하나는 부모 아이템 수에 비례해 늘어나는 DB 왕복(N+1)이고, 다른 하나는 한 번의 왕복에서 그 아이템의 하이라이트를 전부 읽어 오는 과조회다. 하이라이트가 가장 많은 아이템은 500행이었다. 왕복 수는 테이블 전체 행 수를 따라 늘지 않았다. 한 요청에서 DTO로 조립하는 부모 엔티티 수를 따라 늘었다.
- generic [ref=f3e109]:
- generic [ref=f3e110]: 검증 환경
- textbox "검증 환경" [ref=f3e111]: "Java 21 Spring Boot 4.0.0 Hibernate ORM 7.1.8.Final PostgreSQL : postgres:16-alpine (Testcontainers, 클래스당 1개 공유) 테스트 구성 @DataJpaTest + @AutoConfigureTestDatabase(replace = NONE) spring.flyway.enabled : true spring.jpa.hibernate.ddl-auto : validate hibernate.generate_statistics : true 측정 도구 쿼리 수 : Hibernate Statistics 지연 : System.nanoTime 실행계획 : EXPLAIN (ANALYZE, BUFFERS) DB 캐시 : warm (shared read=0)"
- generic [ref=f3e112]:
- generic [ref=f3e113]: 재현 조건
- textbox "재현 조건" [ref=f3e114]: "1. FeedSeedFixture.seed(N)으로 N ∈ {10, 100, 1000} 데이터를 만든다. user는 max(3, min(20, N/5+1))명, page는 N개, highlight는 순위 기반 편중 분포로 생성된다. 2. stats.clear() 직후 loadFeed(0, N)을 1회 실행하고 getCollectionFetchCount()와 getPrepareStatementCount()를 읽는다. 3. 지연은 따로 잰다. latencyMicros(n, 7, 2)로 7회 반복하고 앞 2회는 워밍업으로 버린다. 매 반복 전에 em.clear()를 호출한다. 4. 반복되는 하이라이트 자식 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 확인한다."
- generic [ref=f3e115]:
- generic [ref=f3e116]: 마지막 검증일
- textbox "마지막 검증일" [ref=f3e117]
- generic [ref=f3e118]:
- generic [ref=f3e119]: 본문 Markdown
- group "Markdown 삽입" [ref=f3e120]:
- button "코드" [ref=f3e121] [cursor=pointer]
- button "표" [ref=f3e122] [cursor=pointer]
- button "목록" [ref=f3e123] [cursor=pointer]
- textbox "본문 Markdown" [ref=f3e124]: "## 측정한 loadFeed 구현 ```java label=\"FeedQueryAdapter.loadFeed — 엔티티 조회 후 메모리에서 DTO 매핑\" @Override public List<FeedSummary> loadFeed(int page, int size) { return feedItem.findAllBy(PageRequest.of(Math.max(0, page), size <= 0 ? 20 : size)).stream() .map(fi -> new FeedSummary( fi.getId().toString(), fi.getUser().getName(), fi.getUser().getUsername(), // ToOne (즉시 로딩) fi.getPage().getUrl(), fi.getPage().getTitle(), // ToOne (즉시 로딩) fi.getFirstHighlightedAt(), fi.getHighlights().stream() // 컬렉션 (지연 로딩) .map(h -> new FeedSummary.HighlightSummary(h.getColor(), h.getText(), h.getCreatedAt())) .toList())) .toList(); } ``` ## N에 따라 늘어난 컬렉션 초기화와 총 PreparedStatement :::evidence key=\"nplus1-query-fanout-644febe6\" alt=\"왼쪽의 loadFeed 요청이 한 페이지에서 FeedItem N개를 반환하고, 매핑이 각 부모의 Highlight 컬렉션에 접근하면 컬렉션 초기화가 부모마다 한 번씩 일어나 총 N회가 되며, 배치가 없어 초기화 한 번마다 Highlight SELECT도 한 번 실행되어 추가 조회가 N회 발생하는 것을 보여 주는 흐름도.\" caption=\" \" zoom=\"true\" ::: 총 PreparedStatement는 Hibernate가 SQL 한 건을 실행하려고 JDBC에서 얻은 문장 객체 수다. 이 요청이 SQL 문장을 몇 건 준비했는지를 뜻한다. | N | 초기화 Highlight 컬렉션 | 총 PreparedStatement | 지연 중앙값(5회) | 지연 최댓값(5회) | 시드 하이라이트 | |---:|---:|---:|---:|---:|---:| | 10 | 10 | 25 | 32.8 ms | 36.1 ms | 1,285 | | 100 | 100 | 222 | 85.9 ms | 108.3 ms | 1,961 | | 1,000 | 1,000 | 2,022 | 193.7 ms | 238.4 ms | 2,917 | 초기화 컬렉션 수는 N이 10, 100, 1,000일 때 각각 10, 100, 1,000으로 N과 같았다. 같은 실행에서 시드된 하이라이트는 1,285, 1,961, 2,917개로 N에 정비례하지 않는데도, 조회 수는 하이라이트 총량이 아니라 N을 따라갔다. 총 PreparedStatement를 SQL 형태별로 가르면 다음과 같다. ```text label=\"총 PreparedStatement의 구성\" 총 PreparedStatement = content 1 + count 1 ← Spring Data Page 반환의 전체 건수 count + distinct User targets ← ToOne, 1차 캐시로 중복 제거되어 distinct 수만큼 + N Page ← ToOne, 아이템마다 달라 N번 + N Highlight 컬렉션 ← 지연 로딩 컬렉션 초기화 ``` 검산: `1 + 1 + 3 + 10 + 10 = 25` · `1 + 1 + 20 + 100 + 100 = 222` · `1 + 1 + 20 + 1000 + 1000 = 2022` count 쿼리는 findAllBy(Pageable)가 `Page<FeedItem>`을 반환해서 나온다. offset이 0이고 pageSize가 반환 건수보다 크면 Spring Data가 count를 건너뛴다. 이 측정은 pageSize와 반환 건수가 같아 count가 실제로 실행된다. ## 반복되는 하이라이트 조회 하나의 실행계획 반복되는 하이라이트 조회 하나를 실행계획으로 확인했다. ```text label=\"Plan A — 대량 시드 직후, ANALYZE 실행 전\" 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 ``` 개별 조회는 `ix_highlights_feed_items_created` 인덱스로 처리되어 0.173 ms에 끝났다. 이 조회가 아이템마다 한 번씩 N번 반복되어, N=1,000에서 피드 한 번 로딩이 194 ms가 됐다. 실행 시간이 0.173 ms라고 해서 이 쿼리가 필요한 만큼만 읽는 것은 아니다. 이 쿼리는 `SELECT * FROM highlights WHERE feed_item_id = ?`라 해당 아이템의 하이라이트를 한 번에 최대 500행까지 읽는다. 응답에 필요한 것은 최신 3개인데 쿼리에 결과량을 제한하는 조건이 없다. 추정 rows=1과 실제 rows=500은 500배 차이가 난다. 대량 시드 직후 ANALYZE를 실행하지 않아 통계가 편중을 담지 못했다는 가설을 세웠고, 아직 검증하지 않았다. ## 초기화 컬렉션 수와 PreparedStatement 수가 뜻하는 것 | 지표 | 뜻 | 주의 | |---|---|---| | `getCollectionFetchCount()` | 초기화된 컬렉션 수 | 실행된 SELECT SQL 수가 아니다 | | `getPrepareStatementCount()` | 획득한 PreparedStatement 수 | SQL 실행 수와 항상 같지는 않다 | 이 구현에는 batch나 subselect 설정이 없어 컬렉션 하나를 초기화할 때 SQL 하나가 나간다. 이 조건에서만 초기화 컬렉션 수 N과 자식 SELECT 수 N이 같다. Batch Fetch를 적용하면 이 등식이 깨진다. ## page size를 고정해도 남는 요청당 왕복 page size를 20으로 고정하면 한 요청의 왕복도 20으로 고정된다. 그 20회가 트래픽에 곱해진다. ```text label=\"왕복이 처리량에 곱해지는 형태\" 추가 Highlight SELECT/초 ≈ page size × RPS 예) page size 20 × 1,000 RPS ≈ 초당 20,000 자식 SELECT ``` ## 측정 범위 이 수치는 단일 스레드 퍼시스턴스 통합 테스트에서 잰 값이다. SQL 형태와 데이터 규모에 따라 조회 횟수가 어떻게 늘어나는지를 봤다. 지연은 이미 열린 테스트 트랜잭션 안에서 어댑터 호출만 잰 값이고, 단일 스레드에 warm cache인 로컬 비교값이라 HTTP 종단 지연도 운영 p99도 아니다. 표본이 5개뿐이라 p50·p99가 아니라 중앙값과 최댓값으로 적었다. 고트래픽 처리량·connection pool 안정성·동시성은 이 측정의 범위 밖이다."
- group [ref=f3e125]:
- paragraph [ref=f3e126]: EVIDENCE
- heading "본문에 Asset 삽입" [level=3] [ref=f3e127]
- paragraph [ref=f3e128]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
- generic [ref=f3e129]:
- generic [ref=f3e130]:
- generic [ref=f3e131]: 업로드 종류
- combobox "업로드 종류" [ref=f3e132]:
- option "이미지"
- option "다이어그램" [selected]
- option "첨부파일"
- button "Asset 업로드" [ref=f3e133]
- generic [ref=f3e134]:
- search [ref=f3e135]:
- generic [ref=f3e136]: Asset 검색
- generic [ref=f3e137]:
- searchbox "Asset 검색" [ref=f3e138]
- button "검색" [ref=f3e139]
- generic [ref=f3e140]:
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f3e141]
- generic [ref=f3e142]: 삽입할 때 크게 보기 허용
- status [ref=f3e143]: 삽입할 수 있는 Asset 8개
- list [ref=f3e144]:
- listitem [ref=f3e145]:
- button "ap4-edge-trust-1cff2399" [ref=f3e146]
- button "삭제" [ref=f3e147]
- listitem [ref=f3e148]:
- button "ap3-csrf-split-501dd1f7" [ref=f3e149]
- button "삭제" [ref=f3e150]
- listitem [ref=f3e151]:
- button "ap3-bff-custody-82fa18bd" [ref=f3e152]
- button "삭제" [ref=f3e153]
- listitem [ref=f3e154]:
- button "ap2-split-custody-779cb791" [ref=f3e155]
- button "삭제" [ref=f3e156]
- listitem [ref=f3e157]:
- button "ap1-custody-v3-6e0376d2" [ref=f3e158]
- button "삭제" [ref=f3e159]
- listitem [ref=f3e160]:
- button "ap1-custody-v2-e110bd98" [ref=f3e161]
- button "삭제" [ref=f3e162]
- listitem [ref=f3e163]:
- button "ap1-credential-custody-f5e0c027" [ref=f3e164]
- button "삭제" [ref=f3e165]
- listitem [ref=f3e166]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f3e167]
- button "삭제" [ref=f3e168]
- region [ref=f3e169]:
- generic [ref=f3e170]:
- paragraph [ref=f3e171]: LIVE
- heading "즉시 미리보기" [level=2] [ref=f3e172]
- generic [ref=f3e175]:
- generic [ref=f3e176]:
- navigation "문서 경로" [ref=f3e177]:
- link "검증 기록" [ref=f3e178] [cursor=pointer]:
- /url: /explore/cases
- generic [aria-hidden] [ref=f3e179]: /
- generic [ref=f3e180]: JPA 피드 조회 성능
- generic [aria-hidden] [ref=f3e181]: /
- link "Liner N + 1문제" [ref=f3e182] [cursor=pointer]:
- /url: /projects/liner-n-plus-1
- heading "DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1" [level=1] [ref=f3e183]
- paragraph [ref=f3e184]: 피드 아이템을 엔티티로 조회한 뒤 Stream으로 DTO에 옮기는 코드를 그대로 두고 측정했다. 매핑이 getHighlights()에 접근할 때마다 비워 뒀던 하이라이트 목록이 하나씩 채워졌고, 채워진 목록 수가 반환 아이템 수 N과 정확히 같았다. N=1,000에서 이 요청 하나가 준비한 SQL 문장은 2,022건이었다.
- region "문제와 결론" [ref=f3e189]:
- generic [ref=f3e190]:
- paragraph [ref=f3e191]: 문제
- paragraph [ref=f3e192]: 피드 API는 한 페이지에 하이라이트가 많아져도 조회량이 따라 늘지 않아야 했다. 최초 구현은 findAllBy로 FeedItem을 페이징 조회한 뒤, Stream으로 순회하며 FeedSummary로 필드를 옮겼다.
- paragraph [ref=f3e193]: 이 코드에는 하이라이트를 도는 명시적인 for가 없고 getHighlights().stream()만 있다. 그래서 조회가 몇 번 나가는지 코드만 보고는 알기 어려웠다.
- generic [ref=f3e194]:
- paragraph [ref=f3e195]: 결론
- paragraph [ref=f3e196]: 지연 로딩으로 비워 두는 Highlight 목록이 아이템마다 하나씩 채워졌다. 채워진 목록 수는 N=10에서 10, N=100에서 100, N=1,000에서 1,000으로 N과 정확히 같았다. 같은 실행에서 준비된 SQL 문장(PreparedStatement)은 25건, 222건, 2,022건이었다.
- paragraph [ref=f3e197]: 코드에 명시적인 반복문은 없지만, Stream이 아이템을 하나씩 도는 동안 컬렉션 접근도 그만큼 일어났다. 지연 로딩 컬렉션은 접근하는 순간 조회하므로 아이템이 N개면 접근과 조회도 N번 발생한다.
- paragraph [ref=f3e198]: 조회량이 하이라이트 수를 따라 늘지 않아야 한다는 요구는 두 가지 방식으로 깨졌다. 하나는 부모 아이템 수에 비례해 늘어나는 DB 왕복(N+1)이고, 다른 하나는 한 번의 왕복에서 그 아이템의 하이라이트를 전부 읽어 오는 과조회다. 하이라이트가 가장 많은 아이템은 500행이었다.
- paragraph [ref=f3e199]: 왕복 수는 테이블 전체 행 수를 따라 늘지 않았다. 한 요청에서 DTO로 조립하는 부모 엔티티 수를 따라 늘었다.
- generic [ref=f3e200]:
- generic [ref=f3e201]:
- term [ref=f3e202]: 검증 환경
- definition [ref=f3e203]:
- paragraph [ref=f3e204]: "Java 21Spring Boot 4.0.0Hibernate ORM 7.1.8.FinalPostgreSQL : postgres:16-alpine (Testcontainers, 클래스당 1개 공유)"
- paragraph [ref=f3e205]: "테스트 구성@DataJpaTest + @AutoConfigureTestDatabase(replace = NONE)spring.flyway.enabled : truespring.jpa.hibernate.ddl-auto : validatehibernate.generate_statistics : true"
- paragraph [ref=f3e206]: "측정 도구쿼리 수 : Hibernate Statistics지연 : System.nanoTime실행계획 : EXPLAIN (ANALYZE, BUFFERS)"
- paragraph [ref=f3e207]: "DB 캐시 : warm (shared read=0)"
- generic [ref=f3e208]:
- term [ref=f3e209]: 검증 데이터
- definition [ref=f3e210]:
- paragraph [ref=f3e211]: "1. FeedSeedFixture.seed(N)으로 N ∈ {10, 100, 1000} 데이터를 만든다. user는 max(3, min(20, N/5+1))명, page는 N개, highlight는 순위 기반 편중 분포로 생성된다."
- paragraph [ref=f3e212]: 2. stats.clear() 직후 loadFeed(0, N)을 1회 실행하고 getCollectionFetchCount()와 getPrepareStatementCount()를 읽는다.
- paragraph [ref=f3e213]: 3. 지연은 따로 잰다. latencyMicros(n, 7, 2)로 7회 반복하고 앞 2회는 워밍업으로 버린다. 매 반복 전에 em.clear()를 호출한다.
- paragraph [ref=f3e214]: 4. 반복되는 하이라이트 자식 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 확인한다.
- generic [ref=f3e215]:
- term [ref=f3e216]: 기록
- definition [ref=f3e217]: 게시 게시 전 · 마지막 검증
- group [ref=f3e219]:
- generic "목차 · 측정한 loadFeed 구현" [ref=f3e510] [cursor=pointer]
- article [ref=f3e222]:
- region [ref=f3e511]:
- heading [level=2] [ref=f3e512]:
- link "측정한 loadFeed 구현 바로가기" [ref=f3e513] [cursor=pointer]:
- /url: "#측정한-loadfeed-구현"
- text: 측정한 loadFeed 구현
- generic [aria-hidden] [ref=f3e514]: "#"
- figure "JAVA ·FeedQueryAdapter.loadFeed — 엔티티 조회 후 메모리에서 DTO 매핑 코드 복사" [ref=f3e515]:
- generic [ref=f3e516]:
- generic [ref=f3e517]: JAVA
- generic [ref=f3e518]: ·FeedQueryAdapter.loadFeed — 엔티티 조회 후 메모리에서 DTO 매핑
- button "코드 복사" [ref=f3e519] [cursor=pointer]: 복사
- region "FeedQueryAdapter.loadFeed — 엔티티 조회 후 메모리에서 DTO 매핑 코드" [ref=f3e520]:
- code [ref=f3e521]: "@Override public List<FeedSummary> loadFeed(int page, int size) { return feedItem.findAllBy(PageRequest.of(Math.max(0, page), size <= 0 ? 20 : size)).stream() .map(fi -> new FeedSummary( fi.getId().toString(), fi.getUser().getName(), fi.getUser().getUsername(), // ToOne (즉시 로딩) fi.getPage().getUrl(), fi.getPage().getTitle(), // ToOne (즉시 로딩) fi.getFirstHighlightedAt(), fi.getHighlights().stream() // 컬렉션 (지연 로딩) .map(h -> new FeedSummary.HighlightSummary(h.getColor(), h.getText(), h.getCreatedAt())) .toList())) .toList(); }"
- region [ref=f3e397]:
- heading [level=2] [ref=f3e398]:
- link "N에 따라 늘어난 컬렉션 초기화와 총 PreparedStatement 바로가기" [ref=f3e399] [cursor=pointer]:
- /url: "#n에-따라-늘어난-컬렉션-초기화와-총-preparedstatement"
- text: N에 따라 늘어난 컬렉션 초기화와 총 PreparedStatement
- generic [aria-hidden] [ref=f3e400]: "#"
- figure [ref=f3e401]:
- button "nplus1-query-fanout-644febe6 이미지 크게 보기" [ref=f3e402]:
- img "왼쪽의 loadFeed 요청이 한 페이지에서 FeedItem N개를 반환하고, 매핑이 각 부모의 Highlight 컬렉션에 접근하면 컬렉션 초기화가 부모마다 한 번씩 일어나 총 N회가 되며, 배치가 없어 초기화 한 번마다 Highlight SELECT도 한 번 실행되어 추가 조회가 N회 발생하는 것을 보여 주는 흐름도." [ref=f3e403]
- generic [ref=f3e404]: 크게 보기
- generic [ref=f3e405]: 왼쪽의 loadFeed 요청이 한 페이지에서 FeedItem N개를 반환하고, 매핑이 각 부모의 Highlight 컬렉션에 접근하면 컬렉션 초기화가 부모마다 한 번씩 일어나 총 N회가 되며, 배치가 없어 초기화 한 번마다 Highlight SELECT도 한 번 실행되어 추가 조회가 N회 발생하는 것을 보여 주는 흐름도.
- paragraph [ref=f3e523]: 총 PreparedStatement는 Hibernate가 SQL 한 건을 실행하려고 JDBC에서 얻은 문장 객체 수다. 이 요청이 SQL 문장을 몇 건 준비했는지를 뜻한다.
- region "표" [ref=f3e524]:
- table [ref=f3e525]:
- caption [ref=f3e526]
- rowgroup [ref=f3e527]:
- row [ref=f3e528]:
- columnheader "N" [ref=f3e529]
- columnheader "초기화 Highlight 컬렉션" [ref=f3e530]
- columnheader "총 PreparedStatement" [ref=f3e531]
- columnheader "지연 중앙값(5회)" [ref=f3e532]
- columnheader "지연 최댓값(5회)" [ref=f3e533]
- columnheader "시드 하이라이트" [ref=f3e534]
- rowgroup [ref=f3e535]:
- row [ref=f3e536]:
- cell "10" [ref=f3e537]
- cell "10" [ref=f3e538]
- cell "25" [ref=f3e539]
- cell "32.8 ms" [ref=f3e540]
- cell "36.1 ms" [ref=f3e541]
- cell "1,285" [ref=f3e542]
- row [ref=f3e543]:
- cell "100" [ref=f3e544]
- cell "100" [ref=f3e545]
- cell "222" [ref=f3e546]
- cell "85.9 ms" [ref=f3e547]
- cell "108.3 ms" [ref=f3e548]
- cell "1,961" [ref=f3e549]
- row [ref=f3e550]:
- cell "1,000" [ref=f3e551]
- cell "1,000" [ref=f3e552]
- cell "2,022" [ref=f3e553]
- cell "193.7 ms" [ref=f3e554]
- cell "238.4 ms" [ref=f3e555]
- cell "2,917" [ref=f3e556]
- paragraph [ref=f3e440]: 초기화 컬렉션 수는 N이 10, 100, 1,000일 때 각각 10, 100, 1,000으로 N과 같았다. 같은 실행에서 시드된 하이라이트는 1,285, 1,961, 2,917개로 N에 정비례하지 않는데도, 조회 수는 하이라이트 총량이 아니라 N을 따라갔다.
- paragraph [ref=f3e557]: 총 PreparedStatement를 SQL 형태별로 가르면 다음과 같다.
- figure "TEXT ·총 PreparedStatement의 구성 코드 복사" [ref=f3e558]:
- generic [ref=f3e559]:
- generic [ref=f3e560]: TEXT
- generic [ref=f3e561]: ·총 PreparedStatement의 구성
- button "코드 복사" [ref=f3e562] [cursor=pointer]: 복사
- region "총 PreparedStatement의 구성 코드" [ref=f3e563]:
- code [ref=f3e564]: 총 PreparedStatement = content 1 + count 1 ← Spring Data Page 반환의 전체 건수 count + distinct User targets ← ToOne, 1차 캐시로 중복 제거되어 distinct 수만큼 + N Page ← ToOne, 아이템마다 달라 N번 + N Highlight 컬렉션 ← 지연 로딩 컬렉션 초기화
- paragraph [ref=f3e453]:
- text: "검산:"
- code [ref=f3e454]: 1 + 1 + 3 + 10 + 10 = 25
- text: ·
- code [ref=f3e566]: 1 + 1 + 20 + 100 + 100 = 222
- text: ·
- code [ref=f3e567]: 1 + 1 + 20 + 1000 + 1000 = 2022
- paragraph [ref=f3e568]:
- text: count 쿼리는 findAllBy(Pageable)가
- code [ref=f3e569]: Page<FeedItem>
- text: 을 반환해서 나온다. offset이 0이고 pageSize가 반환 건수보다 크면 Spring Data가 count를 건너뛴다. 이 측정은 pageSize와 반환 건수가 같아 count가 실제로 실행된다.
- region [ref=f3e455]:
- heading [level=2] [ref=f3e456]:
- link "반복되는 하이라이트 조회 하나의 실행계획 바로가기" [ref=f3e457] [cursor=pointer]:
- /url: "#반복되는-하이라이트-조회-하나의-실행계획"
- text: 반복되는 하이라이트 조회 하나의 실행계획
- generic [aria-hidden] [ref=f3e458]: "#"
- paragraph [ref=f3e459]: 반복되는 하이라이트 조회 하나를 실행계획으로 확인했다.
- figure "TEXT ·Plan A — 대량 시드 직후, ANALYZE 실행 전 코드 복사" [ref=f3e460]:
- generic [ref=f3e461]:
- generic [ref=f3e462]: TEXT
- generic [ref=f3e463]: ·Plan A — 대량 시드 직후, ANALYZE 실행 전
- button "코드 복사" [ref=f3e464] [cursor=pointer]: 복사
- region "Plan A — 대량 시드 직후, ANALYZE 실행 전 코드" [ref=f3e465]:
- code [ref=f3e466]: "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"
- paragraph [ref=f3e468]:
- text: 개별 조회는
- code [ref=f3e469]: ix_highlights_feed_items_created
- text: 인덱스로 처리되어 0.173 ms에 끝났다. 이 조회가 아이템마다 한 번씩 N번 반복되어, N=1,000에서 피드 한 번 로딩이 194 ms가 됐다.
- paragraph [ref=f3e470]:
- text: 실행 시간이 0.173 ms라고 해서 이 쿼리가 필요한 만큼만 읽는 것은 아니다. 이 쿼리는
- code [ref=f3e471]: SELECT * FROM highlights WHERE feed_item_id = ?
- text: 라 해당 아이템의 하이라이트를 한 번에 최대 500행까지 읽는다. 응답에 필요한 것은 최신 3개인데 쿼리에 결과량을 제한하는 조건이 없다.
- paragraph [ref=f3e472]: 추정 rows=1과 실제 rows=500은 500배 차이가 난다. 대량 시드 직후 ANALYZE를 실행하지 않아 통계가 편중을 담지 못했다는 가설을 세웠고, 아직 검증하지 않았다.
- region [ref=f3e473]:
- heading [level=2] [ref=f3e474]:
- link "초기화 컬렉션 수와 PreparedStatement 수가 뜻하는 것 바로가기" [ref=f3e475] [cursor=pointer]:
- /url: "#초기화-컬렉션-수와-preparedstatement-수가-뜻하는-것"
- text: 초기화 컬렉션 수와 PreparedStatement 수가 뜻하는 것
- generic [aria-hidden] [ref=f3e476]: "#"
- region "표" [ref=f3e477]:
- table [ref=f3e478]:
- caption [ref=f3e479]
- rowgroup [ref=f3e480]:
- row [ref=f3e481]:
- columnheader "지표" [ref=f3e482]
- columnheader "뜻" [ref=f3e483]
- columnheader "주의" [ref=f3e484]
- rowgroup [ref=f3e485]:
- row [ref=f3e486]:
- cell [ref=f3e487]:
- code [ref=f3e488]: getCollectionFetchCount()
- cell "초기화된 컬렉션 수" [ref=f3e489]
- cell "실행된 SELECT SQL 수가 아니다" [ref=f3e490]
- row [ref=f3e491]:
- cell [ref=f3e492]:
- code [ref=f3e493]: getPrepareStatementCount()
- cell "획득한 PreparedStatement 수" [ref=f3e494]
- cell "SQL 실행 수와 항상 같지는 않다" [ref=f3e495]
- paragraph [ref=f3e496]: 이 구현에는 batch나 subselect 설정이 없어 컬렉션 하나를 초기화할 때 SQL 하나가 나간다. 이 조건에서만 초기화 컬렉션 수 N과 자식 SELECT 수 N이 같다. Batch Fetch를 적용하면 이 등식이 깨진다.
- region [ref=f3e497]:
- heading [level=2] [ref=f3e498]:
- link "page size를 고정해도 남는 요청당 왕복 바로가기" [ref=f3e499] [cursor=pointer]:
- /url: "#page-size를-고정해도-남는-요청당-왕복"
- text: page size를 고정해도 남는 요청당 왕복
- generic [aria-hidden] [ref=f3e500]: "#"
- paragraph [ref=f3e501]: page size를 20으로 고정하면 한 요청의 왕복도 20으로 고정된다. 그 20회가 트래픽에 곱해진다.
- figure "TEXT ·왕복이 처리량에 곱해지는 형태 코드 복사" [ref=f3e502]:
- generic [ref=f3e503]:
- generic [ref=f3e504]: TEXT
- generic [ref=f3e505]: ·왕복이 처리량에 곱해지는 형태
- button "코드 복사" [ref=f3e506] [cursor=pointer]: 복사
- region "왕복이 처리량에 곱해지는 형태 코드" [ref=f3e507]:
- code [ref=f3e508]: 추가 Highlight SELECT/초 ≈ page size × RPS 예) page size 20 × 1,000 RPS ≈ 초당 20,000 자식 SELECT
- region [ref=f3e342]:
- heading [level=2] [ref=f3e343]:
- link "측정 범위 바로가기" [ref=f3e344] [cursor=pointer]:
- /url: "#측정-범위"
- text: 측정 범위
- generic [aria-hidden] [ref=f3e345]: "#"
- paragraph [ref=f3e346]: 이 수치는 단일 스레드 퍼시스턴스 통합 테스트에서 잰 값이다. SQL 형태와 데이터 규모에 따라 조회 횟수가 어떻게 늘어나는지를 봤다. 지연은 이미 열린 테스트 트랜잭션 안에서 어댑터 호출만 잰 값이고, 단일 스레드에 warm cache인 로컬 비교값이라 HTTP 종단 지연도 운영 p99도 아니다.
- paragraph [ref=f3e347]: 표본이 5개뿐이라 p50·p99가 아니라 중앙값과 최댓값으로 적었다. 고트래픽 처리량·connection pool 안정성·동시성은 이 측정의 범위 밖이다.
- region [ref=f3e348]:
- paragraph [ref=f3e349]: Next
- heading "다음에 읽을 것" [level=2] [ref=f3e350]
- list [ref=f3e351]:
- listitem [ref=f3e352]:
- link "검증 기록 Fetch 타입이 아닌 조회 방식으로 인한 N+1" [ref=f3e353] [cursor=pointer]:
- /url: /cases/eager-toone-nplus1-without-access
- generic [ref=f3e354]: 검증 기록
- generic [ref=f3e355]:
- strong [ref=f3e356]: Fetch 타입이 아닌 조회 방식으로 인한 N+1
- paragraph [aria-hidden] [ref=f3e357]: 같은 기준선에서 함께 드러난 ToOne 쪽 문제다.
- generic [aria-hidden] [ref=f3e358]:
- complementary [ref=f3e359]:
- heading "작업 상태" [level=2] [ref=f3e360]
- status "편집 상태" [ref=f3e361]: 저장 충돌
- generic [ref=f3e362]:
- generic [ref=f3e363]:
- term [ref=f3e364]: 저장 버전
- definition [ref=f3e365]: "5"
- generic [ref=f3e366]:
- term [ref=f3e367]: 종류
- definition [ref=f3e368]: 검증 기록
- alert [ref=f3e570]: 서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.
- generic [ref=f3e370]:
- button "저장" [disabled] [ref=f3e371]
- button "게시" [disabled] [ref=f3e372]
- paragraph [ref=f3e373]: 저장된 version이 더 최신입니다