무한스크롤 피드 — 페이지 단위 가상 스크롤로 노드 상한 만들기
/posts 전체 피드에 DOM 노드·메모리 상한을 만드는 트러블슈팅 문서다. 도입 전 상태를 확정하고, 설계 원칙과 해결 방안, 구현 기록과 측정 방법을 정리한다. 뒤로가기 스크롤 복원 프로토콜은 별도 문서(TB3.md)에서 다룬다.
1. 현 상태 (도입 전)
1.1 무한스크롤로 포스트가 계속 붙는다
// routes/posts/+page.svelte
const PAGE_SIZE = 10;
const feed = gqlInfiniteQuery<PostType[], number | null>(() => ({
queryKey: ['posts', 'feed', zone],
getNextPageParam: (posts) => {
/* createdAt 커서 */
},
initialData: { pages: [data.posts], pageParams: [null] }
}));
onMount(() => observeInfiniteScroll(listContainer, fetchMore));
- 커서 기반, 10장 단위로
feed.pages에 배열이 하나씩 추가된다. observeInfiniteScroll이 리스트 끝에 1px sentinel을 붙이고, 바닥 40% 전에 미리fetchNextPage()를 부른다.- 첫 페이지는 SSR(
data.posts)로 오고initialData로 들어간다. QueryClient는routes/+layout.svelte:26에 있어 페이지 이동 후 돌아와도 로드했던 페이지 배열이 캐시에 그대로 살아있다.
1.2 포스트 본문은 접힘 / 펼침 두 상태를 가진다
<!-- ReviewCard.svelte -->
let expanded = $state(false);
let clamped = $state(false);
$effect(() => {
body;
clamped = expandable && contentEl ? contentEl.scrollHeight > contentEl.clientHeight : false;
});
...
class="... {expandable && !expanded ? 'line-clamp-[10]' : ''}"
expanded는ReviewCard내부의 로컬$state다. 컴포넌트가 언마운트되면 사라진다.clamped판정은 마운트마다scrollHeight를 읽는 강제 동기 레이아웃이다.- 즉 같은 포스트라도 카드 높이가 두 가지다. 10줄 클램프 상태와 전체 펼침 상태.
1.3 화면에 들어온 적 없는 카드는 고정 높이 자리만 잡는다
<div class="lazy-section [contain-intrinsic-size:auto_700px]">
.lazy-section {
content-visibility: auto;
contain-intrinsic-size: auto 600px;
overflow-clip-margin: 40px;
}
- 화면 밖 카드는 레이아웃·페인트·이미지 디코드를 스킵하고 700px 플레이스홀더로만 존재한다.
- 뷰포트 근처에 오면 실제로 렌더되며 높이가 실제 값으로 바뀐다.
auto키워드 덕분에 한 번 렌더된 뒤에는 실측 높이가 "last remembered size"로 노드에 기억된다. 다시 화면 밖으로 나가도 700px로 되돌아가지 않는다.- 이 기억은 DOM 노드에 붙어 있어서, 노드가 사라지면 같이 사라진다.
1.4 노드는 늘기만 하고 줄지 않는다
- 화면 밖으로 나간 카드를 언마운트하는 로직이 없다.
reviews는feed.pages를 전부 평탄화한다. - DOM 노드, 이벤트 리스너,
Carousel의ResizeObserver, 디코드된 이미지가 스크롤한 만큼 선형으로 누적되고 상한이 없다. content-visibility: auto가 막아주는 것은 렌더링 비용이지 존재 비용이 아니다. 노드는 남고, 이미 디코드된 이미지도 캐시에 남는다.
2. 목표
| 목표 | 성공 기준 |
|---|---|
| 가상 스크롤로 노드 수를 일정하게 유지한다 | 스크롤을 아무리 내려도 DOM 노드·리스너·이미지 캐시가 상한에 걸린다 |
부수 조건:
- SEO를 깎지 않는다. 첫 페이지(SSR HTML에 들어가는 10장)는 항상 마운트 상태로 둔다. 2페이지부터는 어차피 SSR HTML에 없으므로 언마운트해도 손실이 0이다.
- 하이드레이션 mismatch가 없어야 한다. 첫 렌더는 SSR과 동일하게 두고 윈도우는
onMount이후에 켠다. - 기능 변경은 없다. 접힘/펼침, 좋아요, 캐러셀 모두 그대로 동작해야 한다.
- 뒤로가기 스크롤 복원이 지금보다 나빠지지 않는다. 복원 프로토콜 자체는 TB3.md의 주제다.
3. 설계 원칙 — 추정 높이를 만들지 않는다
가상화는 "화면 밖 것을 없애고, 없앤 자리를 무언가로 높이를 채우는 것"이다. 문제는 그 채우는 높이다.
- 채운 높이가 실제와 다르면 마운트 ↔ spacer 스왑 때마다 문서 전체 높이가 출렁인다. 뷰포트 위쪽에서 출렁이면 보던 콘텐츠가 밀리고(scroll anchoring이 개입하는데 브라우저마다 동작이 다르다), 아래쪽에서 출렁이면 스크롤바가 튄다. 뒤로가기 복원의 좌표계도 함께 무너진다 (TB3.md 1장).
- 지금의
content-visibility가 정확히 이 실패의 약한 버전이다. 노드를 없애지는 않지만 높이를 700px 추정치로 대체했고, 추정과 실제의 차이가 그대로 증상이 됐다.
라이브러리 도입 검토
직접 만들기 전에 기존 가상 스크롤 라이브러리를 먼저 검토했다. 이 피드의 요구사항은 네 가지다.
- window 스크롤. 피드는 문서 스크롤이다. 사이드바와 한 문서 안에서 흐르므로, 자체
overflow컨테이너를 강제하는 라이브러리는 레이아웃 자체가 성립하지 않는다. - SSR 첫 페이지. 첫 10장은 일반 플로우 HTML로 SSR에 들어가야 한다(2장 부수 조건). 리스트 전체를 absolute 포지셔닝 컨테이너로 바꾸면 하이드레이션 시 첫 화면이 재배치된다.
- 높이를 미리 알 수 없다. 사진 개수·aspect-ratio, 본문
line-clamp판정, 더보기 펼침으로 렌더 후에도 높이가 계속 바뀐다. - 추정 높이 금지. 이 장의 원칙. 추정치는 스왑마다 좌표계를 흔든다.
후보 1 — @tanstack/svelte-virtual
- 특징: headless 가상화의 사실상 표준.
estimateSize(추정) +measureElement(렌더 후 실측)로 가변 높이를 지원하고,createWindowVirtualizer로 window 스크롤도 된다. Svelte 어댑터가 활발히 유지보수된다. - 탈락 사유: 요구 1·2는 통과하지만 아키텍처가 추정치 기반이라 3·4에서 탈락한다. 안 잰 아이템의 오프셋은
estimateSize로 계산되고, 실측이 도착할 때마다 전체 오프셋을 재계산해scrollTop을 보정한다.
const virtualizer = createVirtualizer({
count: reviews.length,
estimateSize: () => 700, // ← contain-intrinsic-size: 700px 과 같은 역할
measureElement // ← auto (last remembered size) 와 같은 역할
});
estimateSize는 지금 걷어내려는 contain-intrinsic-size: 700px과 같은 자리의 같은 추정치다. 카드 높이가 로드 후에도 계속 변하니(요구 3) 측정 → 재배치 → 보정 루프가 상시 돌고, measurementsCache가 컴포넌트 수명이라 뒤로가기마다 리셋된다(TB3.md 1장에서 이것이 복원 드리프트를 그대로 재현함을 다룬다). 아이템이 210개면 관찰·실측 대상도 210개다.
후보 2 — @sveltejs/svelte-virtual-list
- 특징: Rich Harris가 만든 Svelte 진영의 레퍼런스 구현. 렌더된 행들의 평균 높이로 안 본 영역을 추정한다.
- 탈락 사유: 자체
overflow-y컨테이너가 필수라 window 스크롤이 안 된다 — 요구 1에서 즉시 탈락. 저장소가 아카이브돼 Svelte 3 시절에 멈춰 있고 Svelte 5/runes 미대응이다. 평균 높이 추정은 추정치 중에서도 가장 거친 형태라, 본문 길이 편차가 큰 이 카드에서는 스크롤바가 상시 출렁인다.
후보 3 — svelte-tiny-virtual-list
- 특징:
react-tiny-virtual-list포트로 유지보수가 활발하고 가볍다.itemSize를 숫자·배열·함수로 받고,svelte-infinite-loading과의 연동 예제도 공식 제공된다. - 탈락 사유: 높이를 호출자가 미리 알려줘야 하고, 렌더 후 실측이라는 개념 자체가 없다. 우리 카드 높이는 렌더 전에 계산이 불가능하니(요구 3) 즉시 탈락. 자체 스크롤 컨테이너 기반이라 요구 1도 통과하지 못한다.
검토 결론
세 후보의 탈락 사유는 결국 하나로 수렴한다: 높이를 모르는 아이템을 추정으로 채우고, 실측이 도착하면 보정하는 구조다. 고정 높이 리스트(채팅, 로그, 표)에서는 옳은 설계지만, 이 피드는 높이가 계속 변하고 좌표계가 흔들리면 안 된다.
결론: "가상 스크롤을 도입한다"가 해법이 아니다. "추정 높이를 만들지 않는 방식으로 가상화한다"가 해법이다.
4. 해결 방안 — 페이지 단위 + 실측 높이 윈도우
4.1 단위는 아이템이 아니라 feed.pages의 10장 묶음
[page 0] ← SSR, 항상 마운트
[page 1] ← spacer (실측 높이 1,183px)
[page 2] ← spacer
[page 3] ← mounted ┐
[page 4] ← mounted ├ 윈도우 (뷰포트 주변 N페이지)
[page 5] ← mounted ┘
[page 6] ← spacer
아이템 단위 대신 페이지 단위를 고르는 이유:
IntersectionObserver가 카드 210개가 아니라 페이지 21개에만 붙는다. 높이 측정도 21번.- 기존
feed.pages구조를 그대로 쓴다.reviews평탄화 로직만 손대면 된다. - 언마운트되면
Carousel의ResizeObserver/MutationObserver, 좋아요 핸들러가 함께 정리된다. - 언마운트되면
<img>가 사라져 이미지 디코드 캐시 참조도 끊긴다. 노드보다 이쪽이 총량이 크다. - 카드 높이가 가변적이라도 페이지 높이는 마운트 상태에서 한 번 재면 확정된다.
4.2 핵심 — 추정 높이를 아예 만들지 않는다
3장의 원칙을 성립시키는 조건이 무한스크롤에는 이미 갖춰져 있다. 무한스크롤은 아래로만 확장되므로:
- 아직 로드되지 않은 페이지는 문서에 존재하지 않는다. 높이를 추정할 필요 자체가 없다.
- 이미 로드된 페이지는 전부 한 번은 마운트된 적이 있다. 새 페이지는 바닥(=뷰포트 안)에 붙으므로 즉시 렌더되고 그때 실측된다.
- 따라서 언마운트할 때 직전에 잰 실측 높이를 그대로 spacer에 넣으면 문서 전체 높이가 1px도 변하지 않는다.
function unmountPage(index: number) {
heights[index] = pageEl.getBoundingClientRect().height;
mounted.delete(index);
}
추정치가 없으니 스왑 전후로 좌표계가 흔들리지 않는다. 3장의 실패 조건이 구조적으로 제거된다.
4.3 content-visibility는 피드 카드에서 제거한다
윈도우가 들어오면 .lazy-section은 불필요할 뿐 아니라 해롭다.
- 윈도우 밖은 이미 언마운트라 렌더 비용이 0이다. 스킵할 것이 없다.
- 윈도우 안에 남겨두면, 마운트된 페이지의 높이가 내부 카드의 skip/unskip에 따라 흔들려 4.2에서 잰 실측 높이가 무의미해진다. 특히 화면 밖에서 페이지를 마운트하면 내부 카드가 전부 미렌더 상태라 700px씩 잡혀 페이지 높이가 실제의 60% 수준으로 잡힌다.
routes/posts/+page.svelte의 lazy-section [contain-intrinsic-size:auto_700px]를 걷어낸다. layout.css의 .lazy-section 자체는 홈 화면 섹션들이 쓰고 있으므로 남긴다.
4.4 카드 높이를 결정하는 상태를 카드 밖으로 뺀다
언마운트/재마운트를 하려면 필수다. expanded가 ReviewCard 로컬 $state인 한, 페이지가 언마운트됐다 돌아오면 펼쳤던 카드가 접히고 실측해둔 높이가 즉시 틀려진다.
ReviewCard의 expanded를 $bindable로 바꾸고, 피드가 SvelteSet에 함수 바인딩으로 연결한다.
// ReviewCard.svelte
- let expanded = $state(false);
+ let { post, expanded = $bindable(false), ... } = $props();
<!-- routes/posts/+page.svelte -->
let expandedPostIds = new SvelteSet<string>();
<ReviewCard
post={review}
bind:expanded={
() => expandedPostIds.has(review.eid),
(value) => value ? expandedPostIds.add(review.eid) : expandedPostIds.delete(review.eid)
}
/>
expanded = true 대입부(bodyClick 핸들러, 더보기 버튼)는 그대로 둔다. $bindable은 대입이 부모로 전파된다.
ReviewCard를 쓰는 나머지 4곳은 한 글자도 바뀌지 않는다. bind:expanded를 쓰지 않으면 기본값 false로 각자 로컬 상태를 갖는다 — 제어/비제어를 한 프롭이 겸하는 패턴이다.
| 사용처 | 변경 |
|---|---|
routes/posts/+page.svelte:105 | 대상 |
map/SelectedPlace/ReviewTab.svelte:277 | 그대로 |
modals/common/ShowPost.svelte:91 (expandable={false}) | 그대로 |
(user)/_components/RecordTab.svelte:161 | 그대로 |
place/[eid]/_components/MainSection/ReviewList.svelte:46 | 그대로 |
이렇게 하면 윈도우 재마운트 시 높이가 재현된다. clamped는 측정으로 매번 다시 구하지만 폭·폰트가 같으면 결정적이라 문제없다.
같은 이유로 Carousel의 가로 스크롤 위치도 재마운트 시 초기화된다. 세로 높이에는 영향이 없어 우선순위는 낮지만 UX상 확인은 필요하다.
4.5 윈도우 크기
6장의 실측에서 카드당 102 노드 / 1.56MB가 나왔다. 여기서 역산한다.
| 목표 | 노드 예산 | 총 카드 수 | SSR 고정 | 윈도우 | 예상 노드 | 예상 이미지 캐시 |
|---|---|---|---|---|---|---|
| 모바일 | 5,000 | 40장 | 1페이지 | 3페이지 | ~4,200 | ~155MB |
| PC | 10,000 | 90장 | 1페이지 | 8페이지 | ~9,300 | ~233MB |
총 카드 수 = SSR 10장 + 윈도우 페이지 × 10장이다. 첫 페이지는 SEO 때문에 항상 마운트하므로 윈도우 예산에서 먼저 뺀다.
- 모바일 윈도우를 4페이지로 늘리면 50장 = 5,226 노드로 예산을 살짝 넘는다. 3페이지가 상한이다.
- 이미지 캐시 예상값에는 윈도우로 줄일 수 없는 상수 ~93MB(피드 밖 사이드바·배너 이미지 + 브라우저 기저)가 포함돼 있다. 6.6 참고.
핵심은 절대값이 아니라 이 값이 상한이 된다는 것이다. 도입 전에는 180장에서 18,481 노드 / 369MB이고 계속 선형으로 늘어난다.
5. 구현 순서
각 단계는 그 자체로 동작해야 하고, 다음 단계 없이도 되돌릴 수 있어야 한다.
| # | 작업 | 이유 |
|---|---|---|
| 0 | 카드당 102 노드 / 1.56MB 확보. 윈도우 크기와 개선폭 기준선이 여기서 나왔다 | |
| 1 | expanded를 피드 레벨로 승격 (4.4) | 언마운트의 선행 조건. 단독으로도 동작 변화 없음 |
| 2 | 여기서 목표 달성. lazy-section 제거도 이 단계 | |
| 3 | 재측정 (6장) — 7.4에 기록 예정 | 언마운트가 실제로 메모리를 놓아주는지 확인 |
2단계가 노드는 줄였는데 이미지 캐시는 안 줄었을 가능성이 있고, 그건 재측정으로만 확인된다.
6. 측정 방법 — 노드가 얼마나 쌓이고 메모리를 얼마나 쓰는가
6.1 먼저, 노드 수는 메모리가 아니다
알아야 할 것이 세 개인데 서로 다른 도구로만 보인다.
| 알고 싶은 것 | 어디에 잡히나 | 도구 |
|---|---|---|
| DOM 노드 개수 | — | Performance monitor / 콘솔 |
| DOM 노드의 실제 비용 (Node + ComputedStyle + LayoutObject) | C++ 쪽. JS heap에 안 잡힘 | Chrome 작업 관리자 |
| 디코드된 이미지 비트맵 | C++ 쪽. JS heap에 안 잡힘 | Chrome 작업 관리자 |
| JS 객체 | JS heap | Performance monitor |
Performance monitor의 JS heap size만 보면 가장 큰 두 항목을 통째로 놓친다. 무한스크롤 피드에서 실제로 문제가 되는 건 대개 이미지 디코드 캐시다.
6.2 Performance monitor — 시간에 따른 기울기
Cmd + Option + I → DevTools
Cmd + Shift + P → 커맨드 메뉴
"Performance monitor" 입력
JS heap size / DOM Nodes / JS event listeners만 켜고 스크롤하면 증가 기울기가 보인다. 줄어드는 구간이 있는지가 관전 포인트다.
주의 — DOM Nodes는 GC 전까지 줄지 않는다. 언마운트 효과를 확인할 때는 Memory 패널의 🗑(Collect garbage)을 누른 뒤 읽는다. 그리고 이 수치는 문서 전체(헤더·사이드바·모달 포함)이고 텍스트 노드도 센다.
6.3 Chrome 작업 관리자 — 진짜 총량
macOS의 활성 상태 보기가 아니다. 거기서는 Google Chrome Helper (Renderer) 프로세스만 보이고 어느 탭인지 구분되지 않는다.
Chrome 메뉴 막대 → 창(Window) → 작업 관리자(Task Manager)
Shift+Esc는 Windows 전용이라 맥에서는 안 먹는다. 열린 뒤 컬럼 헤더를 우클릭해 메모리 사용 공간 · 이미지 캐시 · JavaScript 메모리를 추가한다.
읽을 때 주의:
이미지 캐시가메모리 사용 공간보다 클 수 있다. 오류가 아니다. 이미지 캐시는 상당 부분이 discardable memory — 압박을 받으면 버리는 영역이라 footprint에 전부 잡히지 않는다. "지금 X를 쓰는 중이고, 버릴 수 있는 이미지 Y를 붙들고 있다" 로 읽는다.- 메모리 여유가 큰 맥에서 잰 값을 저사양 기기의 안전 마진으로 읽으면 안 된다. 맥에서는 Chrome이 캐시를 버릴 이유가 없어 최대치가 나온다.
6.4 콘솔 하네스 — 피드 서브트리만 좁혀서
전역 카운트(document.querySelectorAll('*'))는 헤더·사이드바·모달이 상수 오프셋으로 섞여 카드당 값이 왜곡된다. 피드 안쪽만 센다.
function feedStats(label = '') {
const first = document.querySelector('section .lazy-section') ?? document.querySelector('section article');
const feed = first?.closest('section');
if (!feed) return console.warn('feed not found');
const cards = feed.querySelectorAll('article').length;
const walker = document.createTreeWalker(feed, NodeFilter.SHOW_ALL);
let nodes = 0;
while (walker.nextNode()) nodes++;
const imgs = [...feed.querySelectorAll('img')].filter((i) => i.naturalWidth > 0);
const uniq = new Map();
imgs.forEach((i) => uniq.set(i.currentSrc, i.naturalWidth * i.naturalHeight * 4));
const decodedMB = [...uniq.values()].reduce((a, b) => a + b, 0) / 1e6;
const row = {
label,
cards,
nodes,
nodesPerCard: +(nodes / cards).toFixed(1),
imgEl: imgs.length,
imgUnique: uniq.size,
decodedMB: +decodedMB.toFixed(0),
jsHeapMB: +((performance.memory?.usedJSHeapSize ?? NaN) / 1e6).toFixed(1)
};
console.table([row]);
return row;
}
이 하네스가 세지 않는 것을 명시해둔다.
- 피드 밖(사이드바
PopularPosts, 배너, 헤더)의 노드와 이미지. - 이미 DOM에서 사라졌지만 브라우저 캐시에 남아 있는 이미지. → 작업 관리자 값과 차이가 나는 주 원인이다.
naturalWidth × naturalHeight × 4는 디코드 비트맵의 추정치다. 브라우저가 여러 해상도로 디코드해 들고 있거나 이미 버렸을 수 있다. 절대값보다 카드 수에 대한 기울기를 보는 용도다.performance.memory는 크롬 전용이고 해상도가 거칠며, 위 표대로 DOM/이미지 비용이 빠져 있다.
디코드 비용은 표시 크기가 아니라 원본 크기로 결정된다. CSS로 줄여 그려도 원본이 크면 그만큼 물고 있다. 개별 이미지를 확인할 때:
[...document.querySelectorAll('article img')]
.slice(0, 8)
.forEach((img) =>
console.log(
`${img.naturalWidth}×${img.naturalHeight}`,
`표시 ${Math.round(img.getBoundingClientRect().width)}px`,
((img.naturalWidth * img.naturalHeight * 4) / 1e6).toFixed(2) + 'MB',
img.currentSrc
)
);
naturalWidth가 표시 폭의 2배(DPR 2)를 크게 넘으면 그 배수만큼이 순수 낭비다. 0×0은 아직 로드 안 된 정상 상태다.
6.5 측정 절차
기울기를 봐야 하므로 한 시점이 아니라 여러 시점을 찍는다.
- 새 탭에서
/posts를 연다. 다른 탭·확장은 최소화한다. - 작업 관리자를 띄우고 컬럼을 추가해둔다.
feedStats('초기')실행 + 작업 관리자 값 기록.- 스크롤로 5페이지(50장)씩 늘리며
feedStats('50장'),feedStats('100장')… 를 반복. 매번 작업 관리자 값도 같이 기록. - Memory 패널에서 🗑 GC 후 한 번 더 읽어 회수되는 몫과 남는 몫을 분리한다.
이 표의 선형 기울기가 곧 "상한이 없다"의 증거이고, 4.5의 윈도우 크기를 정하는 근거이자, 재측정에서 개선폭을 재는 기준선이다.
6.6 실측 결과 (기준선)
MacBook / Chrome 시크릿 탭 / 피드 뷰포트 폭 700px / DPR 2.
| 시점 | 카드 | 노드 | 카드당 노드 | img 엘리먼트 | img 고유 | 디코드(하네스 추정) | 이미지 캐시 | 메모리 사용 공간 | JS 메모리 |
|---|---|---|---|---|---|---|---|---|---|
| 초기 | 10 | 1,106 | 110.6 | 28 | 27 | 18MB | 97.3MB | 181MB | 14.2MB |
| 50장 | 50 | 5,285 | 105.7 | 156 | 132 | 92MB | 181MB | 191MB | 17.5MB |
| 90장 | 90 | 9,300 | 103.3 | 255 | 210 | 147MB | 239MB | 192MB | 19.3MB |
| 180장 | 180 | 18,481 | 102.7 | 499 | 385 | 267MB | 369MB | 236MB | 25.4MB |
최소제곱 회귀 결과:
| 항목 | 카드당 기울기 | 절편(상수) |
|---|---|---|
| DOM 노드 | 102.0개 | 126 |
| 이미지 캐시 (작업 관리자) | 1.56MB | 92.5MB |
| 디코드 추정 (하네스) | 1.44MB | 12.3MB |
| JS 메모리 | 0.07MB | 13.6MB |
| 고유 이미지 | 2.11장 | — |
읽는 방법
1. 완전한 선형이다. 감쇠하는 구간이 없다. 카드당 노드가 110.6 → 102.7로 수렴하는 건 상수 오프셋(126)이 희석되기 때문이지 카드당 비용이 줄어서가 아니다. 회귀 기울기는 102로 일정하다. 상한이 없다는 1.4의 서술이 수치로 확인됐다.
2. 노드 예산은 이미 초과했다.
| 예산 | 도달 카드 수 | 도달 페이지 |
|---|---|---|
| 모바일 3,000 | 28장 | 3페이지 |
| 모바일 5,000 | 48장 | 5페이지 |
| PC 10,000 | 97장 | 10페이지 |
5페이지만 스크롤하면 모바일 권장 상한에 닿는다. 180장 시점은 PC 예산의 1.9배, 모바일 예산의 3.7배다.
3. 문제의 95%는 JS heap 바깥에 있다. 180장까지 늘어난 총량 중 이미지 캐시가 +272MB, JS 메모리는 +11MB다. JS heap size만 봤다면 문제의 4%만 보고 "메모리 문제 없음"이라고 결론냈을 것이다. 6.1에서 예고한 함정이 그대로 나타났다.
4. 하네스 추정치는 기울기가 신뢰할 만하다. 하네스 1.44MB/카드 vs 작업 관리자 1.56MB/카드 — 92% 일치다. 절대값은 12.3MB vs 92.5MB로 크게 다르지만 그건 절편(피드 밖 이미지)의 차이일 뿐이다. 앞으로 반복 측정은 콘솔 하네스만으로 해도 되고, 작업 관리자는 절대 총량 확인용으로만 쓰면 된다.
5. 메모리 사용 공간(181→236MB, +55MB)이 이미지 캐시(97→369MB, +272MB)보다 훨씬 덜 늘었다. 6.3에서 적은 discardable memory가 실제로 확인된 것이다. "지금 236MB를 쓰는 중이고, 버릴 수 있는 이미지 369MB를 붙들고 있다." 맥이라 버릴 압박이 없어 최대치가 나온 값이고, 저사양 기기의 안전 마진으로 읽으면 안 된다.
6. 이미지 URL 중복이 23%다. 엘리먼트 499개에 고유 385개. 브라우저는 URL 단위로 디코드 캐시를 잡으므로 중복 114개는 메모리를 더 쓰지 않는다. 고유 이미지 개당 0.69MB.
7. 윈도우로 줄일 수 없는 상수 ~81MB가 있다. 이미지 캐시 절편 92.5MB에서 하네스 절편 12.3MB를 빼면 피드 밖에서 81MB가 나온다. 사이드바 PopularPosts, 배너, RecommendedUsers 아바타와 브라우저 기저다. 카드 0장일 때도 존재하므로 언마운트로는 손댈 수 없다. 별건으로 확인할 값어치가 있다 — 사이드바 이미지가 그 정도를 쓸 이유가 없다.
6.7 언마운트가 실제로 회수되는지 확인
<img>를 DOM에서 지워도 브라우저가 디코드 캐시를 즉시 버린다는 보장은 없다. 2단계 구현 후 같은 절차로 다시 재서 이미지 캐시 컬럼이 실제로 상한에 걸리는지 확인해야 한다. Memory 패널의 heap snapshot 비교로 detached DOM 노드가 남아 있지 않은지도 함께 본다.
7. 구현 기록 (2026-08-12)
7.1 1단계 — expanded 승격 (완료)
4.4 설계대로 적용됐다. ReviewCard.svelte의 let expanded = $state(false)를 expanded = $bindable(false) prop으로 승격하고, 피드가 SvelteSet + 함수 바인딩으로 연결한다.
<!-- routes/posts/+page.svelte -->
const expandedPostIds = new SvelteSet<string>();
<ReviewCard
post={review}
bind:expanded={() => expandedPostIds.has(review.eid), (value) => setPostExpanded(review.eid, value)}
/>
바인딩하지 않는 나머지 4곳(4.4의 표)은 비제어 fallback으로 동작해 코드·동작 모두 무변경으로 확인됐다.
7.2 2단계 — 페이지 윈도우 (완료)
routes/posts/+page.svelte 단일 파일에서 구현. 설계 대비 구체화된 지점:
- 렌더 단위 —
reviews평탄화를reviewPages로 교체.feed.pages의 페이지 구조를 유지한 채 eid 중복만 제거한다. - 윈도우 판정 —
IntersectionObserver(rootMargin100% 0px)가 마운트 래퍼/spacer 양쪽을 관찰해visiblePageIndexes를 유지한다. 마운트 범위 =[min visible − r, max visible + r], r은EXTRA_PAGE_COUNT = 1고정. 4.5의 모바일 예산(3페이지)과 같고 PC 예산(8페이지)보다 타이트한데, 실측(7.4)에서 이 값으로도 노드 상한이 예산을 크게 밑돌아 그대로 둔다. PC에서 위로 스크롤 시 재마운트가 잦다고 느껴지면 그때 r만 올리면 된다. - 실측 — 마운트 래퍼에 붙는
ResizeObserver가pageHeights[i]를 상시 갱신한다. 이미지 로드·펼침으로 높이가 변해도 언마운트 직전의 최신 실측값이 spacer에 들어간다. - 핵심 규칙 (4.2의 코드화) — 실측 높이가 없는 페이지는 언마운트하지 않는다 (
isPageMounted의 null 가드). 추정 높이가 구조적으로 존재할 수 없고, 새로 fetch된 페이지는 자동으로 마운트 상태로 시작한다. 부수 효과: 뒤로가기 직후엔 캐시된 전 페이지가 일단 전량 마운트됐다가 실측 완료 후 윈도우로 접힌다 → 이 단계 단독으로는 복원 동작이 기존과 동일하게 유지된다(2장의 부수 조건 충족). 이 전량 마운트 순간까지 없애는 것은 복원 프로토콜(TB3.md)의 범위다. flow-root— 페이지 래퍼(class="visible-page flow-root")에 BFC를 만든다. 구분선 마진(데스크톱Divider24px, 모바일my-[30px])이 래퍼 밖으로 collapse되면getBoundingClientRect()실측에서 빠져 spacer 치환마다 페이지당 24~30px씩 어긋난다.visible-page는 스타일 없는 마커 클래스이고 BFC는 Tailwindflow-root가 만든다 — 이름이 아니라 기능이므로 지우면 안 된다. 시각적 간격은 동일하다.- zone 전환 —
{#key zone}으로 리스트를 재생성해 이전 지역의 높이·가시 상태가 섞이지 않게 한다. 전환 중엔 스켈레톤이 덮고 있어 재생성이 보이지 않는다. - 컴포넌트화 — 윈도우 메커니즘 전체를
src/lib/components/interface/common/VirtualPageList.svelte로 추출했다.pages(2차원 배열)와 페이지 렌더 snippet만 받고, 마운트/spacer 분기·실측·옵저버·flow-root·"실측 없으면 언마운트 금지" 규칙을 내부에 봉인한다. radius(EXTRA_PAGE_COUNT = 1)와 rootMargin은 내장, 첫 페이지 상시 마운트는keepFirstPageMountedprop(기본 true)이다. 피드 외 사용처는 이 컴포넌트만 가져다 쓰면 된다.
<VirtualPageList pages={reviewPages}>
{#snippet page(pagePosts, pageIndex)}
<!-- 페이지 한 묶음의 렌더링만 호출자가 정의 -->
{/snippet}
</VirtualPageList>
7.3 측정 교훈 — dev 모드는 메모리 검증에 못 쓴다
dev 서버에서 스크롤/페이지 왕복을 반복하면 GC 후에도 detached DOM이 수만 개 남아 누수처럼 보였다. heap snapshot 정황상 Svelte dev 런타임의 디버그 메타데이터, Vite HMR 레지스트리, 콘솔 로그 retention(entity store가 dev에서 patch마다 객체를 로그), query devtools가 참조를 유지하는 것으로 보이나 retainer 개별 특정까지는 하지 않았다. production build(pnpm build && pnpm preview)에서는 노드 수가 윈도우 상한에서 일정하게 유지됨을 확인했다. 이후 모든 메모리 측정은 preview에서 한다. dev에서 신뢰 가능한 것은 attached 노드만 세는 6.4 하네스뿐이다.
7.4 전/후 실측 비교 (2026-08-12)
측정 조건: production preview(pnpm build && pnpm preview) / Chrome 시크릿 탭 / 절차는 6.5, 하네스는 6.4. 전 = c739e57~1 시점의 +page.svelte(윈도우 없음, git restore --source로 해당 파일만 교체), 후 = 현재 구현(EXTRA_PAGE_COUNT = 1). 두 상태 모두 같은 지점(1 / 6 / 11 / 20페이지 로드 = 10 / 60 / 110 / 200장)에서 기록했다.
전 — 상한 없음, 전부 선형:
| 로드 | 마운트 카드 | 피드 노드 | img 고유 | 디코드(추정) | 이미지 캐시 | 메모리 사용 공간 | JS 메모리 |
|---|---|---|---|---|---|---|---|
| 1페이지 | 10 | 1,042 | 19 | 13MB | 135MB | 276MB | 19.0MB |
| 6페이지 | 60 | 6,353 | 149 | 101MB | 228MB | 305MB | 21.3MB |
| 11페이지 | 110 | 11,513 | 277 | 194MB | 331MB | 319MB | 23.4MB |
| 20페이지 | 200 | 21,248 | 524 | 357MB | 498MB | 366MB | 30.3MB |
회귀 기울기: 노드 106.2개/카드, 이미지 캐시 1.92MB/카드, 디코드 추정 1.81MB/카드, JS 메모리 0.06MB/카드. 6.6 기준선(102개 / 1.56MB)과 같은 패턴이 preview 빌드에서 재확인됐다.
후 — 상한 고정:
| 로드 | 마운트 카드 | 피드 노드 | img 고유 | 디코드(추정) | 이미지 캐시 | 메모리 사용 공간 | JS 메모리 |
|---|---|---|---|---|---|---|---|
| 1페이지 | 10 | 1,028 | 18 | 13MB | 126MB | 179MB | 9.0MB |
| 6페이지 | 40 | 4,213 | 97 | 67MB | 181MB | 212MB | 9.7MB |
| 11페이지 | 30 | 3,088 | 74 | 52MB | 168MB | 233MB | 9.7MB |
| 20페이지 | 40 | 4,400 | 110 | 76MB | 189MB | 260MB | 10.5MB |
20페이지(200장) 시점 비교:
| 지표 | 전 | 후 | 변화 |
|---|---|---|---|
| 마운트 카드 | 200 | 40 | 상한 (EXTRA_PAGE_COUNT = 1 윈도우) |
| 피드 노드 | 21,248 | 4,400 | −79% |
| 고유 이미지 | 524 | 110 | −79% |
| 디코드 추정(하네스) | 357MB | 76MB | −79% |
| 이미지 캐시 | 498MB | 189MB | −62% |
| 메모리 사용 공간 | 366MB | 260MB | −29% |
| JS 메모리 | 30.3MB | 10.5MB | −65% |
| GC 후 monitor 노드 | 44,093 | 15,826 | −64% |
읽는 방법
1. 상한이 실제로 걸렸다. 후는 로드량이 6 → 11 → 20페이지로 늘어도 노드가 4.2k → 3.1k → 4.4k로 윈도우 위치에 따라 출렁일 뿐 증가 추세가 없다. 마운트 카드 3050장 = page 0 + 윈도우(가시 12페이지 ± 1), 설계값 그대로다. 2장의 목표 달성.
2. 6.7의 질문에 답이 나왔다 — 디코드 캐시는 실제로 회수된다. <img>가 DOM에서 사라지면 브라우저가 즉시 버린다는 보장이 없다는 게 6.7의 우려였는데, 이미지 캐시가 로드량과 무관하게 126~189MB에서 횡보하고 11페이지 시점에는 오히려 감소(181 → 168MB)했다. 언마운트가 참조를 끊으면 브라우저가 실제로 버린다.
3. 남는 선형분은 데이터(JSON)뿐이다. 후의 JS 메모리가 9.0 → 10.5MB로 완만히 늘어나는 것은 TanStack 캐시에 쌓이는 포스트 데이터로, 카드 DOM·이미지와는 자릿수가 다르다.
4. 측정 시 보정 두 가지. ① Performance monitor의 GC 후 노드 수는 문서 전체 + 미회수 detached + 같은 렌더러 프로세스의 탭까지 포함해 피드 실측(하네스)보다 크게 나온다 — 절대값은 참고치로만. ② spacer 개수를 div[aria-hidden="true"]로 세면 무한스크롤 sentinel이 1개 섞인다 (−1 보정 필요). 마운트 래퍼는 div.visible-page로 센다.
8. 열린 항목
- 피드 밖 이미지 상수 ~81MB — 6.6-7. 카드 0장일 때도 존재해 윈도우로 못 줄인다. 사이드바
PopularPosts·배너·RecommendedUsers아바타가 이 정도를 쓸 이유가 없다. 6.4의 원본 크기 스니펫을 사이드바에 돌려 확인한다. Carousel가로 스크롤 위치 — 재마운트 시 초기화된다. 저장할 가치가 있는지.clamped강제 동기 레이아웃 —fetchNextPage()로 10장이 한 번에 마운트되면scrollHeight읽기가 10번 연달아 발생한다. 메모리와는 별개의 축(스크롤 재킹)이고 아직 프로파일로 확인하지 않았다.- 모바일 실측 — 위 절차를 저사양 안드로이드에서도 돌려야 한다. 렌더러가 죽는 실제 상한이 진짜 기준이다.
- 이미지 원본 크기 — 6.4로 확인 후, 표시 크기 대비 과대한 variant가 있으면 별건으로 다룬다. 윈도우로 상한을 잡은 뒤가 순서상 맞다.