가상화 기반 Masonry 레이아웃 직접 구현
마지막 수정일 · 2026. 04. 28.
TL;DR
사진과 영상이 섞여 column 단위로 떨어지지 않는 자유 배치 + 무한 스크롤 홈 화면이 요구사항이었는데, 기존 Masonry / windowing 라이브러리가 전부 요구사항을 못 맞춰서 둘 다 직접 구현함.
- 배치 — 각 column의 누적 높이를 추적해 가장 낮은 곳에 넣는 FFD(First Fit Decreasing) 방식. 빈 공간을 남기지 않는 게 목적
- 관측 — 배치 결과가 좋은지를 감으로 보지 않으려고, column 높이 편차를 해상도로 정규화해 로그로 쌓는 hook을 같이 만듦
- 가상화 —
useMeasureHeight(ResizeObserver로 실측) +useWindowing(누적 높이로 start/end index 계산) 두 hook으로 windowing을 직접 얹음 - 라이브러리를 안 쓴 이유는 성능이 아니라 요구사항 대비 소모 리소스. 커스텀 비용이 직접 구현 비용을 넘어가는 지점이었음
디자인/QA 쪽 이야기는 Framer Code Component 기반 디자인 QA 프로세스로 이어짐.
문제상황
홈 화면은 셀럽 콘텐츠에서 뽑아낸 코디 이미지가 쌓이는 피드였다. 여기에 두 가지 제약이 있었다.
- 다양한 비율의 사진 + 9:16 비율의 비디오가 섞인다. 아이템 높이가 제각각이다
- 무한 스크롤이다. 전체 아이템 목록을 미리 알 수 없다
높이가 제각각인 아이템을 grid로 깔면 짧은 column 아래로 빈 공간이 생긴다. 모바일에서 이 빈 공간은 그냥 '못생김'이 아니라 스크롤 한 화면당 보여줄 수 있는 콘텐츠 수가 줄어드는 문제였다. 그래서 벽돌 쌓듯 어긋나게 배치하는 Masonry 레이아웃이 필요했다.
라이브러리를 먼저 봤는데 둘 다 걸렸다.
| 후보 | 막힌 지점 |
|---|---|
react-virtualized Masonry |
위치 계산을 prop으로 받아야 한다. 아이템이 페이징으로 비동기 로딩되는 구조와 맞지 않음 |
react-window 계열 |
높이 가상화 스트레스 테스트에서 아이템 위치가 깜빡이는 문제 발생 |
둘 다 "커스텀하면 되긴 하는" 수준이었지만, 커스텀 지점이 라이브러리의 핵심 가정(위치를 외부에서 결정 / 높이가 안정적)이라 손대는 순간 라이브러리를 쓰는 의미가 없어졌다.
배치 — FFD(Bin-Packing)
Masonry 배치는 결국 bin packing 문제다. column이 bin이고 아이템 높이가 물건이다. 온라인(아이템이 순차적으로 들어옴) 상황이라 최적해는 못 구하고, FFD 계열의 그리디로 갔다.
핵심은 "가장 낮은 column에 넣는다" 가 아니라 "넣었을 때 목표 높이에서 가장 덜 벗어나는 column에 넣는다" 로 잡은 것이다. 단순히 최소 높이 column만 고르면 큰 아이템이 들어올 때 편차가 한 번에 벌어진다.
useEffect(() => {
// ...생략
sortedImages.forEach((img) => {
const height = 1 / img.aspectRatio;
const bestCol = heights.reduce(
(best, _, i) =>
Math.abs(heights[i] + height - targetHeight) <
Math.abs(heights[best] + height - targetHeight)
? i
: best,
0
);
newColumns[bestCol].push(img);
heights[bestCol] += height;
});
높이를 1 / aspectRatio로 다루는 게 포인트다. column 폭을 1로 정규화한 상대 높이라서, 실제 픽셀 폭이 얼마든(기기 해상도가 뭐든) 같은 배치 결과가 나온다. 이미지가 로드되기 전에도 aspectRatio만 알면 계산되므로, 비동기 로딩과도 충돌하지 않는다. react-virtualized에서 막혔던 지점이 여기서 풀렸다.
렌더는 계산된 column 배열을 그대로 그린다.
return (
<MasonryWrapper gap={gap}>
{columns.map((col, i) => (
<Column key={i} gap={gap}>
{col.map((img) => (
<FeedImage
key={img.id}
src={img.url}
aspectRatio={img.aspectRatio}
/>
))}
</Column>
))}
</MasonryWrapper>
);
배치 품질을 로그로 관측하기
여기서 하나 더 붙였다. 배치가 잘 됐는지를 눈으로 보고 판단하지 않으려고 로깅 hook을 만들었다.
export function useMasonryGapLog({ colHeights, screenWidth }: GapLogProps) {
// ...생략
const max = Math.max(...colHeights);
const min = Math.min(...colHeights);
const diff = max - min;
const ratio = (diff / screenWidth) * 100;
logEvent({
category: "masonry",
action: "column_gap_logged",
label: "normalized_gap_ratio",
value: Math.round(ratio),
metadata: {
columnCount: colHeights.length,
diffInPx: diff,
screenWidth,
timestamp: Date.now(),
},
});
column 높이 편차(max - min)를 그대로 쌓으면 기기별로 비교가 안 된다. 큰 화면에서는 편차 100px가 아무것도 아니지만 작은 화면에서는 눈에 띈다. 그래서 화면 폭으로 나눠 정규화한 비율을 지표로 삼았다.
이렇게 해두니 "지금 배치 알고리즘이 실사용 데이터에서 얼마나 어긋나는가"를 개발자 감이 아니라 분포로 볼 수 있었다. 레이아웃 알고리즘을 바꿀 때 A/B 비교의 기준도 이 값이 됐다.
무한 스크롤이 붙자 생긴 문제
배치가 끝나고 무한 스크롤을 붙이자 성능 문제가 나왔다. 세 방향이었다.
- 초기 렌더링 지연 — 대량 DOM 생성 + 이미지 로딩 + 레이아웃 계산 부하가 한 번에 걸림
- 과도한 메모리 사용 — 화면 밖 요소까지 전부 렌더링해서 리소스를 낭비
- 모바일 스크롤 버벅임 — 긴 DOM 트리로 렌더링 병목과 프레임 드랍
세 번째가 제일 급했다. 앞의 둘은 숫자로만 나쁘지만, 스크롤 버벅임은 사용자가 바로 체감한다. 홈 화면 = 무한 스크롤인 서비스에서 스크롤이 끊기면 서비스 전체가 느린 걸로 인식된다.
windowing 라이브러리를 안 쓴 이유
여기서도 라이브러리를 먼저 검토했다. react-virtualized, react-window, @tanstack/react-virtual 셋 다 잘 관리되고 있었고 다운로드 수도 충분했다.
문제는 성능이 아니라 요구사항 대비 소모 리소스였다. 위 오른쪽 그래프가 그때 정리한 판단 기준이다.
- 요구사항이 단순한 구간에서는 라이브러리가 압도적으로 싸다
- 그런데 요구사항 복잡도가 올라가면 커스텀 비용이 지수적으로 붙는다. 라이브러리의 가정을 벗어나는 순간부터 라이브러리와 싸우게 되기 때문이다
- 직접 구현은 초기 비용이 높지만 복잡도에 대해 완만하게 증가한다
우리 요구사항(column 단위로 안 떨어지는 자유 배치 + 비동기 페이징 + 높이 실측 보정)은 이미 교차점 오른쪽에 있었다. 그래서 직접 구현으로 갔다. "라이브러리를 쓰지 말자"가 아니라 "우리 요구사항은 교차점 어디에 있는가"를 먼저 그린 것이 이 판단의 전부다.
두 개의 hook으로 만든 windowing
windowing을 두 개의 hook으로 쪼갰다. 높이를 재는 책임과 무엇을 그릴지 정하는 책임을 분리한 것이다.
useMeasureHeight — ResizeObserver로 frame별 갱신
export function useMeasureHeight({ onChange }: UseMeasureHeightOptions) {
const ref = useRef<HTMLElement | null>(null);
const observerRef = useRef<ResizeObserver | null>(null);
const previousHeightRef = useRef(0);
useEffect(() => {
if (!ref.current) return;
observerRef.current = new ResizeObserver((entries) => {
for (const entry of entries) {
const height = entry.contentRect.height;
if (height !== previousHeightRef.current) {
previousHeightRef.current = height;
onChange(height);
}
}
});
observerRef.current.observe(ref.current);
return () => {
observerRef.current?.disconnect();
};
}, [onChange]);
return ref;
}
previousHeightRef로 이전 높이와 같으면 아예 onChange를 안 쏘는 것이 중요했다. ResizeObserver는 레이아웃이 안정된 뒤에도 콜백이 반복해서 들어올 수 있는데, 그때마다 상위 상태를 갱신하면 측정 → 리렌더 → 측정 루프가 돈다. react-window 테스트에서 봤던 "아이템 위치가 깜빡이는" 증상이 딱 이 계열이었다.
useWindowing — 누적 높이로 start / end index 계산
export function useWindowing({
scrollTop,
containerHeight,
itemHeights,
overscan = 600, // 600px(휴대폰 높이 기준)
}: UseWindowingOptions) {
// ...생략
const { startIndex, endIndex, offsetTop } = useMemo(() => {
// find start index
for (let i = 0; i < itemHeights.length; i++) {
if (total + itemHeights[i] > scrollTop - overscan) {
start = i;
offset = total;
break;
}
total += itemHeights[i];
}
// find end index
total = offset;
for (let i = start; i < itemHeights.length; i++) {
total += itemHeights[i];
if (total > scrollTop + containerHeight + overscan) {
end = i;
break;
}
}
return { startIndex: start, endIndex: end, offsetTop: offset };
}, [scrollTop, containerHeight, itemHeights, overscan]);
useMemo의존성이[scrollTop, containerHeight, itemHeights, overscan]뿐이라, 스크롤이 멈춰 있으면 계산이 아예 안 돈다. 여기에 아이템 컴포넌트를 memo로 감싸면 스크롤 중 실제 리렌더 대상은 새로 들어오고 나가는 아이템뿐이 된다overscan = 600은 임의의 숫자가 아니라 휴대폰 화면 높이 한 장분이다. 화면 한 장 앞까지 미리 그려두면 빠르게 튕기듯 스크롤해도 빈 화면이 안 보인다offsetTop을 같이 반환해서 상단 스페이서 높이로 쓴다. 이게 있어야 잘라낸 앞부분만큼 스크롤 위치가 유지된다
결과
- 무한 스크롤 깊이가 늘어나도 배치가 흔들리지 않는 홈 화면을 확보했다. 스크롤을 아무리 내려도 렌더링되는 DOM 수는 화면 + overscan 분량으로 일정하다
- 무한 스크롤 최적화 전체(windowing + dynamic import + 리소스 지연 로딩 + 스켈레톤 UI) 기준으로 앱 진입 초기 로딩 1,500ms → 700ms, 평균 프레임 28fps → 53fps
- 배치 품질이
normalized_gap_ratio지표로 남아서, 이후 알고리즘 변경을 감이 아니라 분포 비교로 판단할 수 있게 됐다
여기서 얻은 판단 기준은 라이브러리 선택 일반으로도 남았다. "이 라이브러리가 좋은가"가 아니라 "우리 요구사항이 이 라이브러리의 가정 안에 있는가" 를 먼저 본다. 가정을 벗어나면 커스텀 비용이 직접 구현 비용을 금방 넘어선다.
남은 것 / 한계
- 아이템 높이 추정의 정확도
aspectRatio기반이라 이미지/비디오는 잘 맞지만, 텍스트가 붙는 카드 타입이 생기면 실측 전까지 높이를 모른다. 이 경우 실측 보정이 일어날 때마다 전체 높이가 변해 스크롤바가 튄다. 나중에 EMR 쪽에서 같은 문제를 만났고, 거기서는 캔버스 글리프 측정으로 풀었다 → 가상화 리스트의 동적 높이 추정 및 스크롤 성능 개선 - column 수 변경 시 전체 재계산
화면 회전이나 리사이즈로 columnCount가 바뀌면 전체 배치를 다시 계산한다. 아이템이 수천 개 쌓인 뒤에는 이 계산이 한 프레임을 넘길 수 있다. 청크로 쪼개거나 이전 배치를 재사용하는 방법을 안 넣어둔 상태다 itemHeights배열 참조
useMemo의존성에 배열이 그대로 들어가 있어서, 상위에서 매번 새 배열을 만들면 memo가 무력화된다. 실사용에서는 문제가 없었지만 구조적으로는 위험한 지점이다