피드를 오래 내리면 폰이 느려지고 탭이 죽는다
무한스크롤에 메모리 상한 만들기
- 01 · 문제
- 피드를 오래 내리면 점점 버벅이고, 폰에서는 이미지가 늦게 뜨거나 탭이 통째로 새로고침된다
- 02 · 분석
- 렌더러 프로세스의 메모리 구성(DOM·디코드 이미지·JS 힙)선형 증가와 상한의 부재content-visibility가 막는 것과 못 막는 것가상화와 추정 높이의 레이아웃 시프트
- 03 · 04 · 해결 방안과 결론
- 라이브러리 세 개를 검토한 뒤, 이 피드에만 있는 조건(로드된 것은 전부 한 번 그려졌다)을 이용해 추정 없는 페이지 단위 가상 스크롤을 직접 만들었다
- 결과
- 200장 기준 DOM 노드 21,248 → 4,400(−79%)이미지 캐시 498MB → 189MB(−62%)JS 힙 30.3MB → 10.5MB(−65%)로드량에 비례하던 사용량을 상한 고정으로
사내 프로젝트라 이름은 A(사용자용 웹), B(어드민)로 표기한다. 이 글은 A에서 있었던 일이다.
문제
사용자용 웹 A의 피드는 무한스크롤이다. 사진이 들어간 포스트 카드가 10장씩 계속 붙는다. 처음엔 빠르다. 그런데 100장, 200장을 내려가면 이런 일이 생긴다.
- 스크롤이 점점 무거워진다. 특히 빠르게 넘길 때 프레임이 끊긴다.
- 폰에서 사진이 늦게 뜬다. 조금 전에 봤던 사진도 다시 올라가면 잠깐 비어 있다.
- 다른 앱을 갔다 돌아오면 탭이 통째로 새로고침돼 있다. 보던 위치도, 열어 둔 것도 사라진다.
사용자에게는 "많이 내려가면 느려진다"로 요약된다. 피드는 이 서비스에서 가장 오래 머무는 화면이고, 오래 머물수록 나빠지는 구조는 곧 가장 열심히 쓰는 사용자에게 가장 나쁜 경험을 주는 구조다.
마지막 증상이 특히 중요했다. 탭이 새로고침되는 건 페이지가 느려서가 아니라 운영체제가 메모리가 부족하다고 판단해 브라우저의 탭을 폐기했기 때문이다. 저사양 기기일수록 빨리 온다. 이건 "최적화하면 좋은" 문제가 아니라 일부 기기에서 피드를 쓸 수 없게 만드는 문제였다.
분석
무엇이 어디에 쌓이는가 — 브라우저 메모리의 세 영역
"카드가 쌓이니 무거워진다"는 감은 있었지만, 무엇이 얼마나 쌓이는지는 몰랐다. 먼저 카드 하나가 브라우저 안에서 어디에 무엇으로 존재하는지 정리했다.
| 무엇 | 어디에 있나 | 어떤 도구로 보이나 |
|---|---|---|
| DOM 노드 개수 | — | Performance monitor / 콘솔 |
| DOM 노드의 실제 비용 (Node + ComputedStyle + LayoutObject) | C++ 힙. JS 힙에 안 잡힘 | Chrome 작업 관리자 |
| 디코드된 이미지 비트맵 | C++ 쪽. JS 힙에 안 잡힘 | Chrome 작업 관리자 |
| JS 객체 (컴포넌트 인스턴스, 반응형 상태, 클로저) | JS 힙 | Performance monitor / 힙 스냅샷 |
핵심은 문제의 대부분이 JS 힙 바깥에 있다는 점이다. JS 힙만 보면 "별로 안 늘었는데?"가 된다. 사진 한 장은 디코드되면 가로 × 세로 × 4바이트의 픽셀 버퍼가 된다. DPR 2인 폰에서 뷰포트 폭에 맞춘 사진 하나가 수 MB다. 이건 렌더러 프로세스의 C++ 영역에 있고, JS 힙 스냅샷에는 안 나온다.
기준선을 잰다 — 선형이고 상한이 없다
여러 시점에서 찍어야 기울기가 보인다. 프로덕션 빌드, 시크릿 탭, 피드 폭 700px, DPR 2에서 스크롤로 카드를 늘리며 기록했다.
| 카드 | DOM 노드 | 이미지 캐시 | 메모리 사용 공간 | JS 힙 |
|---|---|---|---|---|
| 10 | 1,106 | 97MB | 181MB | 14.2MB |
| 50 | 5,285 | 181MB | 191MB | 17.5MB |
| 90 | 9,300 | 239MB | 192MB | 19.3MB |
| 180 | 18,481 | 369MB | 236MB | 25.4MB |
최소제곱 회귀로 기울기를 구했다.
| 항목 | 카드당 |
|---|---|
| DOM 노드 | 102개 |
| 이미지 캐시 | 1.56MB |
| JS 힙 | 0.07MB |
세 가지가 확인됐다.
- 완전한 선형이다. 감쇠하는 구간이 없다. 즉 상한이 없다.
- JS 힙은 문제가 아니다. 180장까지 이미지 캐시가 +272MB 늘 때 JS 힙은 +11MB다.
이미지 캐시와메모리 사용 공간은 다른 것을 잰다. 이미지 캐시는 "이 페이지의 이미지를 전부 디코드하면 이만큼"이라는 장부 값이고, 메모리 사용 공간은 실제 상주량이다. 브라우저가 디코드 픽셀을 예산 안에서 버리고 있어서 실제 증가는 +55MB에 그쳤다. 그 예산이 작은 저사양 기기에서는 버리는 게 안 따라가고, 그 끝이 탭 폐기다.
왜 상한이 없는가 — content-visibility가 막는 것과 못 막는 것
카드에는 이미 content-visibility: auto가 걸려 있었다. 화면 밖 카드는 레이아웃·페인트·이미지 디코드를 건너뛰고, 대신 contain-intrinsic-size: auto 700px로 700px 자리만 잡는다.
이게 막아 주는 것은 렌더링 비용이다. 화면 밖 카드를 그리는 데 CPU를 쓰지 않는다. 하지만 존재 비용은 못 막는다. DOM 노드는 트리에 남아 있고, 이벤트 리스너도 붙어 있고, 한 번 디코드된 이미지는 캐시에 남는다. 화면 밖으로 나간 카드를 언마운트하는 로직이 없었으니, 스크롤한 만큼 쌓이고 줄어들 일이 없었다.
그리고 700px이라는 추정치가 또 다른 문제를 만들고 있었다. 실제 카드 높이는 사진 비율, 본문 줄 수, 더보기 펼침으로 400~1,200px까지 다양하다. 화면 밖 카드가 700px 자리를 잡고 있다가 뷰포트 근처에 와서 실제 높이로 바뀌면 문서 전체 높이가 출렁인다. 뷰포트 위쪽에서 출렁이면 보던 내용이 밀리고, 아래쪽에서 출렁이면 스크롤바가 튄다. 뒤로가기 복원이 어긋나는 문제도 여기서 나왔다(다음 글).
가상화란 무엇이고, 무엇이 어려운가
상한을 만들려면 화면 밖 카드를 없애야 한다. 없애면 그 자리의 높이를 무언가로 채워야 한다. 이게 가상 스크롤이다. 어려운 건 "채우는 높이"다.
- 채운 높이가 실제와 다르면, 실제 카드와 빈 껍데기를 바꿔 끼울 때마다 문서 높이가 변한다. 위에서 말한 출렁임이 스왑마다 일어난다.
- 그래서 일반적인 가상 스크롤 라이브러리는 추정 높이 → 렌더 후 실측 → 오프셋 재계산 → scrollTop 보정의 루프를 돈다. 추정이 틀릴수록 보정이 잦다.
이 추정은 결함이 아니다. 목록 중간으로 점프하거나 임의 인덱스부터 그리는 일반 리스트에서는 한 번도 그려진 적 없는 아이템의 높이를 알 방법이 없다. 추정은 그 상황을 위한 필수 장치다.
그런데 이 피드에는 그 상황이 없다. 무한스크롤은 아래로만 자란다. 새 페이지는 항상 뷰포트 바닥에 붙어서 바로 렌더된다. 즉 로드된 페이지는 전부 한 번은 화면에 그려진 적이 있고, 그때 실측할 수 있다. "안 본 아이템"이 존재하지 않는다. 라이브러리는 이 조건을 전제로 삼을 수 없어서 추정을 들고 다니고, 우리는 이 조건을 전제로 삼을 수 있어서 추정을 버릴 수 있다.
해결 방안
| 선택지 | 방법 | 장점 | 단점 | 이 피드에 |
|---|---|---|---|---|
| A. 페이지 수 상한 | 20페이지 넘으면 "더 보기" 버튼으로 전환하거나 앞쪽을 버린다 | 구현 단순 | 무한스크롤이라는 UX를 포기. 앞쪽을 버리면 위로 못 돌아간다 | 부적합 |
| B. 이미지만 언로드 | 화면 밖 카드의 src를 비운다 | 이미지 캐시 기울기(1.56MB/장)만 줄인다 | 노드 102개/장은 그대로. 다시 올라오면 재요청·재디코드 | 부분 해결 |
| C. 라이브러리 가상 스크롤 | @tanstack/svelte-virtual 등 | 검증된 구현, 유지보수 | 추정 높이 기반. window 스크롤·SSR·가변 높이 요구와 충돌 (아래 상세) | 부적합 |
| D. 직접 구현 | 페이지 단위 윈도우 + 실측 높이 spacer | 추정 없음. 이 피드의 조건을 그대로 이용 | 코드를 소유하는 비용. 카드 로컬 상태를 밖으로 빼야 함 | 채택 |
| E. 서버 측 이미지 리사이즈 | 기기 폭에 맞춘 사이즈 제공 | 카드당 이미지 비용 자체를 줄인다 | 백엔드 또는 이미지 서버 작업. 상한을 만들지는 못한다 | 병행 제안 (별도 작업) |
C를 접은 이유 — 후보 세 개를 요구사항에 대본다
요구사항은 넷이다. ① 문서(window) 스크롤이어야 한다(사이드바와 한 문서에서 흐른다). ② 첫 페이지 10장은 SEO를 위해 SSR HTML에 그대로 들어가야 한다. ③ 높이를 미리 알 수 없고 렌더 후에도 바뀐다. ④ 추정 높이를 만들지 않는다.
| 후보 | 특징 | 탈락 사유 |
|---|---|---|
@tanstack/svelte-virtual | estimateSize + measureElement. window 스크롤 지원 | ①②는 통과. 하지만 아키텍처가 추정치 기반이라 ③④에서 탈락. estimateSize: () => 700은 지금 걷어내려는 contain-intrinsic-size: 700px과 같은 자리의 같은 추정치. 측정 캐시가 컴포넌트 수명이라 재렌더마다 리셋 |
@sveltejs/svelte-virtual-list | Svelte 제작자의 레퍼런스 구현, 200줄 | 자체 overflow 뷰포트 필수 → ①에서 탈락. 안 잰 행을 "지금까지 평균" 하나로 채워 스크롤바가 출렁인다. 2019년 이후 미갱신, Svelte 3 문법 |
svelte-tiny-virtual-list | Svelte 5 전용, itemSize를 함수로 | 높이는 호출자가 알려주는 값이고 라이브러리가 DOM을 재지 않는다 → ③에서 탈락 |
세 후보의 탈락 사유는 둘로 갈린다. 자체 스크롤 컨테이너를 강제하거나(레이아웃 제약), 안 본 아이템을 추정으로 채우거나(높이 처리). 어느 쪽이든 이 피드의 조건과 맞지 않았다.
결론: 가상 스크롤은 필요하다. 다만 이 피드는 "로드된 것은 모두 한 번 그려졌다"는 조건 덕에 추정 없이 가상화할 수 있고, 그 조건을 이용하는 라이브러리가 없으므로 직접 만든다.
결론
D를 채택했다. 설계의 핵심은 셋이다.
단위는 카드가 아니라 페이지(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번이다. API가 페이지 단위로 주는 구조를 그대로 쓴다.
추정 높이를 만들지 않는다
언마운트 직전에 getBoundingClientRect()로 잰 실측 높이를 그대로 spacer에 넣는다. 문서 전체 높이가 1px도 변하지 않으니 스왑 전후로 좌표계가 흔들리지 않는다.
function unmountPage(index: number) {
heights[index] = pageEl.getBoundingClientRect().height; // 언마운트 직전의 실측
mounted.delete(index);
}
content-visibility: auto는 피드 카드에서 걷어냈다. 윈도우 밖은 언마운트라 스킵할 렌더 비용이 없고, 윈도우 안에 남겨 두면 카드의 skip/unskip에 따라 페이지 높이가 흔들려 실측이 무의미해지기 때문이다. 페이지 래퍼에는 flow-root를 줘서 마진이 래퍼 밖으로 collapse되어 실측에서 빠지는 일을 막았다. 이게 없으면 spacer 치환마다 페이지당 24~30px씩 어긋난다.
높이를 정하는 상태를 카드 밖으로 뺀다
카드의 "더보기 펼침" 상태가 카드 컴포넌트 안의 로컬 $state였다. 언마운트되면 사라진다. 그러면 페이지가 돌아왔을 때 펼쳤던 카드가 접히고, 실측해 둔 높이가 즉시 틀려진다. expanded를 $bindable prop으로 올리고, 피드가 SvelteSet에 함수 바인딩으로 연결해 재마운트 시 같은 높이가 재현되게 했다.
<ReviewCard
post={review}
bind:expanded={() => expandedPostIds.has(review.eid), (v) => setPostExpanded(review.eid, v)}
/>
윈도우 메커니즘 전체는 VirtualPageList.svelte로 추출했다. 호출부는 pages와 페이지 하나를 그리는 snippet만 넘기고, 관찰·실측·spacer 치환은 컴포넌트 안에만 둔다.
전/후 실측 — 200장 시점
같은 조건(프로덕션 프리뷰, 시크릿 탭)에서 같은 지점(1 / 6 / 11 / 20페이지 로드)을 다시 쟀다.
| 로드 | 전: 마운트 카드 | 전: 노드 | 전: 이미지 캐시 | 후: 마운트 카드 | 후: 노드 | 후: 이미지 캐시 |
|---|---|---|---|---|---|---|
| 1페이지 | 10 | 1,042 | 135MB | 10 | 1,028 | 126MB |
| 6페이지 | 60 | 6,353 | 228MB | 40 | 4,213 | 181MB |
| 11페이지 | 110 | 11,513 | 331MB | 30 | 3,088 | 168MB |
| 20페이지 | 200 | 21,248 | 498MB | 40 | 4,400 | 189MB |
| 지표 (200장) | 전 | 후 | 변화 |
|---|---|---|---|
| 마운트 카드 | 200 | 40 | 스크롤과 무관하게 일정 |
| 피드 DOM 노드 | 21,248 | 4,400 | −79% |
| 이미지 캐시 | 498MB | 189MB | −62% |
| 메모리 사용 공간 | 366MB | 260MB | −29% |
| JS 힙 | 30.3MB | 10.5MB | −65% |
두 가지가 확인됐다. 상한이 실제로 걸렸다. 후는 6 → 11 → 20페이지로 늘어도 노드가 4.2k → 3.1k → 4.4k로 윈도우 위치에 따라 출렁일 뿐 증가 추세가 없다. 마운트 카드 3050장은 page 0 + 윈도우(가시 12페이지 ± 1)라는 설계값 그대로다. 디코드 캐시는 실제로 회수된다. <img>를 DOM에서 지운다고 브라우저가 즉시 버린다는 보장은 없어서 확인이 필요했는데, 이미지 캐시가 로드량과 무관하게 126~189MB에서 횡보하고 11페이지 시점엔 오히려 줄었다(181 → 168MB). 언마운트가 참조를 끊으면 브라우저가 실제로 버린다.
감수한 부작용
- 카드의 로컬 상태는 이제 전부 의심 대상이다. 펼침만 올렸지만, 앞으로 카드에 동영상 재생 위치·댓글 입력 중 텍스트 같은 로컬 상태가 생기면 같은 문제가 생긴다. "카드 안의
$state는 언마운트되면 사라진다"를 컴포넌트 주석과 리뷰 규칙에 적었다. - 뒤로가기 복원이 새 문제를 얻었다. 페이지 높이와 펼침 상태가 컴포넌트와 함께 죽으니, 뒤로가기로 돌아왔을 때 실측이 없어 전 페이지가 한꺼번에 마운트되는 스파이크가 생겼다. 이건 다음 글에서 다뤘다.
- 윈도우 밖 페이지로 스크롤하면 잠깐 빈 spacer가 보인다. rootMargin을 뷰포트 높이만큼 잡아 미리 마운트하지만, 아주 빠르게 넘기면 한 프레임 정도 비어 있다. 저사양 기기에서 탭이 죽는 것과 비교하면 감수할 만한 수준이라고 판단했다.
운영에서 보는 것
- 기울기 회귀 테스트. Performance monitor로
DOM Nodes를 켜고 20페이지를 스크롤한 뒤 GC를 누른다. 노드가 증가 추세면 어딘가에서 상한이 깨진 것이다. 피드 컴포넌트를 건드리는 PR은 이 절차를 한 번 돌리고 숫자를 PR에 적는다. 도구는 DevTools뿐이다. - 카드당 노드 수. 기준선 102개다. 카드 UI를 바꿀 때 이 숫자를 다시 잰다. 윈도우가 40장이면 노드 4,000개인데, 카드가 200개 노드가 되면 8,000개다. 상한이 있어도 상한 자체가 커질 수 있다.
- E(이미지 리사이즈)는 별도로 추적한다. 상한이 189MB인 건 카드당 1.5MB짜리 원본 이미지를 받기 때문이다. 기기 폭에 맞춘 이미지를 받으면 이 상한 자체가 내려간다. 백엔드 팀에 수치와 함께 제안했다.
남긴 것
일반 해법이 못 쓰는 전제를 우리 문제는 갖고 있을 수 있다. 라이브러리가 추정 높이를 들고 다니는 건 결함이 아니라 일반 리스트를 위한 필수 장치다. 이 피드는 아래로만 자라서 로드된 것이 전부 한 번은 그려졌다는 조건이 있었고, 그 조건 하나가 추정을 통째로 없애 줬다. 라이브러리를 고르기 전에 우리 문제에만 있는 제약과 조건부터 적어 보는 게 순서였다.
바꾸기 전에 재고, 바꾼 뒤에 다시 잰다. 감으로는 "카드가 쌓이니 무거워진다" 정도였는데, 기준선을 여러 시점에서 찍자 카드당 노드 102개, 이미지 1.56MB라는 기울기와 "상한이 없다"는 사실이 숫자로 확정됐다. 그리고 같은 절차로 다시 재서 상한이 실제로 걸렸다는 것까지 확인했다. 원인을 숫자로 잡고 개선을 숫자로 닫은 과정이 이 작업에서 가장 뿌듯한 부분이었다.