메인페이지 메모리 이슈 디버깅에서 시작해, 가상화 리스트 적용과 노출 로깅 정합성 문제 해결까지. 롤백과 재적용을 거쳐 안정적으로 배포한 이야기예요.
0. 발단 — “스크롤 몇 번 했을 뿐인데”
메인페이지가 느려진다는 리포트가 들어왔어요. 특히 저사양 기기에서 피드를 4~6페이지 정도 스크롤하면 눈에 띄게 버벅였어요. Chrome DevTools Memory 패널을 열고 프로파일링을 시작했어요.
수치는 예상보다 심각했어요:
📊 1회 스크롤당 약 4MB의 메모리가 증가했고, 250MB를 넘기면 저사양 기기에서 크래시가 발생했어요.
MAIN_ARTICLE_FEED_PER_PAGE = 10이므로, 46페이지를 넘기면 4060개의 카드가 DOM에 마운트된 채 유지돼요. 각 카드는 가벼운 컴포넌트가 아니었어요 — Relay fragment 데이터, 여러 개의 IntersectionObserver, Ky 인스턴스 2개, 채팅/관심/노출/광고용 훅들, lazy-load 이미지까지 포함되어 있었어요.
웹 정적 분석 — 6가지 병목
| 순위 | 병목 | 설명 |
|---|---|---|
| 1 | 무한 리스트 증식 | 아이템이 DOM에서 절대 제거되지 않음 |
| 2 | 무거운 Fragment | 카드 1개당 Relay 데이터가 과도하게 큼 |
| 3 | 중복 광고 로거 | 광고 로깅 훅이 불필요하게 여러 번 마운트 |
| 4 | 과다한 IntersectionObserver | 카드마다 여러 개의 Observer 인스턴스 |
| 5 | loadNext 동시성 | 페이지네이션 요청이 중복 발생 |
| 6 | 카드당 무거운 훅 묶음 | 채팅/관심/노출/광고 훅이 전부 마운트 |
1순위인 “무한 리스트 증식” — 이것이 가상화 리스트 도입의 직접적 동기였어요.
1. 메모리 프로파일링 — 숫자로 확인하기
문제의 크기를 정량화하기 위해 Chrome DevTools Memory 패널에서 힙 스냅샷을 비교했어요.
동일한 스크롤 시나리오를 기준으로 VirtualList 적용 전후를 각각 Local 빌드와 Prod 빌드에서 측정했어요.
Local 빌드 — VirtualList 적용 전후 비교
| 항목 | 변화량 |
|---|---|
| memoizedState | -50.0% |
| create / deps / inst | -46.5% |
| Function | -20.3% |
| Array | -19.4% |
Prod 빌드 비교
| 항목 | 변화량 |
|---|---|
| Object | -58.6% |
| memoizedState | -39.1% |
🎯 총 메모리 약 70MB 감량, 스크롤 속도가 확연히 빨라졌어요
2. React Virtuoso 동작 원리 파악
가상화 라이브러리로 react-virtuoso를 선택했어요. 적용하기 전에 내부 동작을 철저히 파악했어요.
핵심 구조
┌─────────────────────────┐
│ Top Placeholder │ ← 스크롤 위쪽 빈 공간 (height 동적)
├─────────────────────────┤
│ Rendered Items │ ← 실제 DOM에 마운트된 아이템들
│ (viewport + overscan) │
├─────────────────────────┤
│ Bottom Placeholder │ ← 스크롤 아래쪽 빈 공간 (height 동적)
└─────────────────────────┘
- Top/Bottom Placeholder: 스크롤 위치에 따라 높이가 동적으로 변하는 빈
div예요. 전체 스크롤 영역의 크기를 유지하면서 실제 렌더링은 뷰포트 근처만 수행해요 - overscan (increaseViewportBy): 뷰포트 바깥에 미리 렌더링해두는 버퍼 영역이에요. 빠른 스크롤 시 빈 화면을 방지해요
- ResizeObserver: 각 아이템의 실제 높이를 측정하여 동적 높이를 처리해요
- 마운트/언마운트 사이클: 뷰포트를 벗어난 아이템은 언마운트돼요 (recycling 없음)
이 “마운트/언마운트” 특성이 바로 노출 로깅 문제의 근원이었어요.
3. 구조적 문제 — 가상화 × 노출 로깅 충돌
가상화를 적용하자마자 노출 로깅(Impression Log) 중복 문제가 터졌어요.
왜 충돌하는가
일반 리스트: 마운트 1번 → useEffect 1번 → 로그 1번 ✅
가상화 리스트: 스크롤 왕복 → 마운트 N번 → useEffect N번 → 로그 N번 ❌
기존에는 IntersectionObserver의 once: true 옵션으로 아이템당 1회만 로깅했어요. 하지만 가상화 환경에서는:
- 아이템이 뷰포트를 벗어나면 컴포넌트가 언마운트돼요
- Observer 인스턴스도 함께 사라져요
- 다시 스크롤해서 돌아오면 새 컴포넌트 + 새 Observer가 생성돼요
once가 리셋되어 같은 아이템을 다시 로깅해요
노출 로깅은 광고 과금과 추천 알고리즘 피드백에 사용되므로, 중복은 데이터 품질 저하로 직결되는 심각한 문제였어요.
4. 해결 — Context + Set 기반 노출 추적 시스템
컴포넌트 생명주기 바깥에 상태를 보존해야 한다는 것이 핵심 인사이트였어요.
4-1. VirtualList 래퍼 — Set 소유 + Context 전파
const VirtualList = ({ ref, ...virtuosoProps }) => {
const impressedItemKeysRef = useRef<Set<string>>(new Set());
useImperativeHandle(ref, () => ({
resetImpressionKeys: () => {
impressedItemKeysRef.current = new Set();
},
}));
return (
<ImpressionDedupeContext.Provider value={impressedItemKeysRef}>
<Virtuoso {...virtuosoProps} />
</ImpressionDedupeContext.Provider>
);
};
Set<string>을 VirtualList가 소유해요 — 개별 아이템 마운트/언마운트와 무관하게 유지돼요- Context로 하위 트리 전체에 전파해요 — 모든 아이템이 같은 Set을 참조해요
resetImpressionKeys()를 imperative handle로 노출해요 — 필터/지역 변경 시 초기화용이에요
4-2. ImpressionLog — 이벤트 키 기반 중복 판별
const ImpressionLog = ({ event, children, ...props }) => {
const impressedKeysRef = useImpressionDedupeContext();
return (
<InViewChecker
options= threshold: 0.5, once: !impressedKeysRef
onInView={() => {
if (impressedKeysRef) {
const eventKey = `${event.name}:${JSON.stringify(
params, Object.keys(params).sort()
)}`;
if (impressedKeysRef.current.has(eventKey)) return;
impressedKeysRef.current.add(eventKey);
}
Logger.track(event.name, event.params);
}}
>
{children}
</InViewChecker>
);
};
설계 결정 3가지:
- 이벤트 키 =
이벤트명:JSON.stringify(정렬된 파라미터)— 동일 아이템의 동일 이벤트만 정확히 중복 판별해요 - 파라미터 키 정렬 (
Object.keys(params).sort()) — 객체 프로퍼티 순서에 의한 오판을 방지해요 - Context 없으면
once: true폴백 — 비가상화 리스트에서 기존 동작을 그대로 유지해요 (하위 호환성)
4-3. 리마운트 방지 — Virtuoso 공식 패턴
// 모듈 레벨에서 메모이즈된 렌더러
const ExampleContentCard = React.memo(({ index, item, context }) => {
if (item.type === 'banner') return <ExampleItem />;
return <ExampleCard /* ... */ />;
});
const exampleContent = (index, item, context) => (
<ExampleContentCard index={index} item={item} context={context} />
);
increaseViewportBy: 1500은 대략 10개 아이템(150px × 10)에 해당하는 프리로드 버퍼로, 빠른 스크롤 시에도 빈 영역이 보이지 않도록 했어요.
5. 결과
✅ 정량적 성과
memoizedState -50%, Function -20.3%, Array -19.4% (Local)
Object -58.6%, memoizedState -39.1% (Prod)
메모리 약 70MB 감량, 스크롤 속도 확연히 개선
DOM 마운트 아이템 수: 전체 → 뷰포트 + 버퍼로 대폭 감소
🔒 정성적 성과
노출 로깅 정합성 보장: 아이템당 정확히 1회만 로깅 → 광고 과금/추천 알고리즘 데이터 품질 유지
하위 호환성 유지: Context 없는 환경에서 기존 동작 그대로
초기화 시점 정확: 필터/지역 변경, 새로고침 시 올바른 리셋
안정적 배포: 롤백 → 수정 → 재적용의 점진적 전략
6. 회고
라이브러리 선택부터 적용까지 쉽지 않은 결정이었어요
특히 메모리로 인해 크래시가 발생하는 케이스가 쉽게 재현이 되기 때문에 수정은 필연적이었지만
가상화 리스트는 추후 관리 유지보수의 비용을 높이기 때문에 최 최후의 수단이었어요
간단하게는 훅호출을 줄이고 GraphQL사용처를 줄이는등의 시도도 해보았지만 결론은 가상화 리스트였어요
리스트 적용 이후 빨라진 프레임과 안정적인 구조는 페이지 이탈률 뿐아니라 사용자 UX 개선에 큰 도움이 될것이라고 생각해요!