
링크 드라퍼(Link Dropper)의 탐색 페이지를 개발하면서, 다양한 높이의 콘텐츠를 자연스럽게 정렬하는 Masonry 레이아웃과 무한 스크롤 기능을 구현하게 되었습니다. 단순히 보기 좋은 UI를 넘어서 사용자 경험을 해치지 않고 성능까지 고려한 구조를 만드는 것이 핵심 과제였습니다.
이 글에서는 CSS만으로 구현했던 초기 시도부터 Flexbox 기반의 명시적 레이아웃으로의 전환, 그리고 무한 스크롤과 스켈레톤 로딩까지, 실제 구현 과정과 트러블슈팅 경험을 모두 공유합니다.
탐색 기능은 아래와 같은 요구를 충족해야 했습니다:
무한 스크롤과 서버 상태 관리를 위해 useInfiniteQuery 훅을 사용했습니다.
선택 이유:
getNextPageParam)scroll 이벤트 대신 Intersection Observer를 선택해 뷰포트 진입을 감지했습니다.
선택 이유:
간단한 코드로 구현 가능한 column-count 속성을 먼저 사용했습니다.
.ExploreLinkList {
column-count: 4;
column-gap: 1.5rem;
}
.card {
break-inside: avoid;
margin-bottom: 1rem;
}
문제 발생:
무한 스크롤로 새 아이템이 로드되면 기존 카드들이 다른 컬럼으로 재배치되는 현상이 발생했습니다. 이는 사용자 경험을 해치는 큰 단점이었습니다.
이 문제를 해결하기 위해 Flexbox를 사용한 명시적 컬럼 분배 방식으로 전환했습니다.
export const useMasonryLayout = <T extends { id: number }>(
items: T[],
columnCount: number
): T[][] =>
useMemo(() => {
const columns: T[][] = Array.from({ length: columnCount }, () => []);
items.forEach((item, index) => {
const columnIndex = index % columnCount;
columns[columnIndex].push(item);
});
return columns;
}, [items, columnCount]);
useEffect(() => {
const updateColumnCount = () => {
if (window.innerWidth <= 480) setColumnCount(1);
else if (window.innerWidth <= 768) setColumnCount(2);
else if (window.innerWidth <= 1024) setColumnCount(3);
else setColumnCount(4);
};
updateColumnCount();
window.addEventListener("resize", updateColumnCount);
return () => window.removeEventListener("resize", updateColumnCount);
}, []);
.ExploreLinkList {
display: flex;
gap: 1.5rem;
padding: 1.5rem;
max-width: 73rem;
margin: 0 auto;
align-items: flex-start;
}
.column {
display: flex;
flex-direction: column;
flex: 1;
gap: 1rem;
min-width: 0;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) fetchNextPage();
},
{ threshold: 0.1 }
);
hasNextPage, isFetchingNextPage 조건에 따라 옵저버 등록 여부를 조절합니다.
컬럼 수에 맞게 스켈레톤 아이템 분배.
const loadingColumns = Array.from({ length: columnCount }, (_, i) => i);
const itemsPerColumn = Math.ceil(12 / columnCount);
별도의 레이아웃 변화 없이 사용자에게 로딩 상태를 전달합니다.
const allLinks = useMemo(() => data?.pages.flatMap(p => p.links) ?? [], [data]);
카드 클릭 이벤트를 부모 요소에서 처리하여 리스너를 단 하나로 유지.
staleTime: 5 * 60 * 1000
탐색 페이지 재방문 시 캐시 데이터를 그대로 사용해 UX 개선.
각 컬럼의 총 높이를 추적해 가장 짧은 컬럼에 배치.
const shortestColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));
렌더링 성능 개선을 위해 react-window 또는 react-virtual 사용 고려.
서버 응답 없이 먼저 UI 반영 후, 실패 시 롤백.
이번 구현을 통해 얻은 가장 큰 인사이트는 다음과 같습니다:
링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 수정하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있습니다.
• 🔗 빠르고 간편한 링크 저장
• 🧠 저장한 링크를 폴더별로 정리
• 🌐 폴더를 친구에게 공유 가능
• ⚡ 크롬 익스텐션 원클릭 저장
👉 링크 드라퍼 사용하러 가기
👉 크롬 웹스토어에서 설치하기
서비스 업데이트
기능 꿀팁
카카오톡 채널을 통해 빠르게 받아보세요!
👉 카카오톡 채널 추가하기