Pinterest 스타일 레이아웃, React에서 어떻게 구현할까?

LinkDropper·2025년 11월 20일

Link Dropper

목록 보기
15/17
post-thumbnail

링크 드라퍼(Link Dropper)의 탐색 페이지를 개발하면서, 다양한 높이의 콘텐츠를 자연스럽게 정렬하는 Masonry 레이아웃무한 스크롤 기능을 구현하게 되었습니다. 단순히 보기 좋은 UI를 넘어서 사용자 경험을 해치지 않고 성능까지 고려한 구조를 만드는 것이 핵심 과제였습니다.

이 글에서는 CSS만으로 구현했던 초기 시도부터 Flexbox 기반의 명시적 레이아웃으로의 전환, 그리고 무한 스크롤과 스켈레톤 로딩까지, 실제 구현 과정과 트러블슈팅 경험을 모두 공유합니다.


프로젝트 요구사항 정리

탐색 기능은 아래와 같은 요구를 충족해야 했습니다:

  1. Masonry 레이아웃 (다양한 높이의 링크 카드 배치)
  2. 무한 스크롤 (스크롤 시 자동으로 다음 페이지 로드)
  3. 반응형 지원 (모바일: 1열, 태블릿: 2열, 데스크톱: 3~4열)
  4. 로딩 시 기존 콘텐츠 위치 유지
  5. 스켈레톤 UI와 다크모드 지원

기술 스택 선택

TanStack Query (React Query)

무한 스크롤과 서버 상태 관리를 위해 useInfiniteQuery 훅을 사용했습니다.

선택 이유:

  • 직관적인 페이지네이션 (getNextPageParam)
  • 중복 요청 방지
  • 캐싱 및 백그라운드 데이터 업데이트

Intersection Observer API

scroll 이벤트 대신 Intersection Observer를 선택해 뷰포트 진입을 감지했습니다.

선택 이유:

  • 메인 스레드 부담이 적음
  • 트리거 시점(threshold) 세밀 조정 가능

시도 1: CSS Column 기반 Masonry 레이아웃

간단한 코드로 구현 가능한 column-count 속성을 먼저 사용했습니다.

.ExploreLinkList {
  column-count: 4;
  column-gap: 1.5rem;
}

.card {
  break-inside: avoid;
  margin-bottom: 1rem;
}

문제 발생:
무한 스크롤로 새 아이템이 로드되면 기존 카드들이 다른 컬럼으로 재배치되는 현상이 발생했습니다. 이는 사용자 경험을 해치는 큰 단점이었습니다.


시도 2: Flexbox 기반 명시적 Masonry 레이아웃

이 문제를 해결하기 위해 Flexbox를 사용한 명시적 컬럼 분배 방식으로 전환했습니다.

컬럼 분배 로직 (Round-robin 방식)

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);
}, []);

Flexbox 레이아웃

.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;
}

무한 스크롤 구현

Intersection Observer 훅

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);

추가 로딩: 간단한 가로 스켈레톤 4개

별도의 레이아웃 변화 없이 사용자에게 로딩 상태를 전달합니다.


성능 최적화

useMemo로 재연산 최소화

  • 평탄화된 링크 배열
  • 컬럼 분배 결과
const allLinks = useMemo(() => data?.pages.flatMap(p => p.links) ?? [], [data]);

이벤트 위임

카드 클릭 이벤트를 부모 요소에서 처리하여 리스너를 단 하나로 유지.

React Query 캐싱 전략

staleTime: 5 * 60 * 1000

탐색 페이지 재방문 시 캐시 데이터를 그대로 사용해 UX 개선.


개선 아이디어

1. 스마트한 컬럼 배치

각 컬럼의 총 높이를 추적해 가장 짧은 컬럼에 배치.

const shortestColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));

2. 가상 스크롤 도입

렌더링 성능 개선을 위해 react-window 또는 react-virtual 사용 고려.

3. 낙관적 업데이트

서버 응답 없이 먼저 UI 반영 후, 실패 시 롤백.


마무리하며

이번 구현을 통해 얻은 가장 큰 인사이트는 다음과 같습니다:

  • CSS Column은 동적 콘텐츠에 불리하다
  • 명시적인 JavaScript 제어가 예측 가능한 레이아웃을 만든다
  • 라이브러리 선택이 복잡도를 줄여준다
  • 퍼포먼스 최적화는 필수

적용 가능한 사례

  • Pinterest/Unsplash 스타일 이미지 갤러리
  • 무한 스크롤 기반 블로그/뉴스 피드
  • 상품 목록, 게시판 등

🧪 링크 드라퍼, 정식 출시!

링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 수정하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있습니다.

• 🔗 빠르고 간편한 링크 저장
• 🧠 저장한 링크를 폴더별로 정리
• 🌐 폴더를 친구에게 공유 가능
• ⚡ 크롬 익스텐션 원클릭 저장

👉 링크 드라퍼 사용하러 가기
👉 크롬 웹스토어에서 설치하기


💬 카카오톡 채널 추가하고 소식 받기

서비스 업데이트
기능 꿀팁
카카오톡 채널을 통해 빠르게 받아보세요!
👉 카카오톡 채널 추가하기

profile
“기록하는 습관을 도구로 만들다 — 두 개발자의 링크 드라퍼 구축기”

0개의 댓글