← 글 목록

notes

CSS,Style - *(Masonry,Waterfall )Layout

CSS Masonry·Waterfall Layout의 배치 방식과 구현 선택지 메모.

  • 카드 높이가 제각각인데 빈틈 없이 2열로 쌓는 배치를 Masonry 라고 부른다.
  • JS 라이브러리 없이 CSS columns 만으로 만들었고, 인피니티 스크롤도 DOM 추가만으로 된다.
  • 대신 순서가 열 우선이라, 좌우 번갈아 순서가 필요하면 JS 로 배열을 나눠야 한다.

이런 배치를 뭐라고 부르나

카드 높이가 제각각인데 2열로 쌓이고, 좌우 열이 가로로 맞지 않는 배치는 보통 이렇게 부른다.

  • Masonry layout (메이슨리 레이아웃): 벽돌 쌓기처럼 빈틈 없이 위에서 아래로 채운다는 뜻이다. Pinterest 에서 많이 써서 Pinterest layout 이라고도 한다.
  • Waterfall layout (폭포수 레이아웃): 아시아권 UI, 기획에서 자주 쓰는 말이다.
  • Staggered grid: 높이가 어긋난 그리드. Android 쪽에서 많이 쓰는 이름이다.
  • Uneven grid, asymmetric grid: 덜 기술적인 표현이다.

구현 방법은 CSS columns, JS 라이브러리(Masonry.js, react-masonry-css), 그리고 아직 준비 중인 네이티브 CSS 정도가 있다.


1. 전체 구조

파일 네 개가 역할을 나눈다.


2. 핵심은 CSS Multi-column Layout

Masonry.js 대신 브라우저에 들어 있는 CSS 다단(column) 레이아웃을 썼다.

const masonryClassName = cn("columns-2 gap-atomic-unit-spacing-8");
const itemClassName = cn("mb-atomic-unit-spacing-8 w-full break-inside-avoid");

기존에 쓰던 grid grid-cols-2 는 행 기준으로 정렬된다. 행 높이가 그 행에서 제일 긴 카드에 맞춰지니까 짧은 카드 아래에 빈틈이 생긴다.

columns-2 는 열 기준으로 채운다. 카드가 위에서 아래로 바로 이어 붙어서 빈틈이 없다.


3. 카드 배치 순서

여기가 제일 중요하다. CSS columns 는 DOM 순서대로 1열을 먼저 위에서 아래로 채우고 나서 2열로 넘어간다.

Figma 시안처럼 1·2, 3·4 순서(좌우 번갈아)가 필요하면 CSS columns 로는 안 된다. JS 로 배열을 두 열로 나눠서 만들어야 한다. 지금은 Pinterest 처럼 열 우선으로 채우는 방식이다.


4. break-inside-avoid 역할

이 속성이 없으면 긴 카드가 열 경계에서 잘려서, 위쪽은 1열 끝에 아래쪽은 2열 처음에 보일 수 있다.


5. 더미 사진 생성 로직

API 에 사진 필드가 없어서 reviewSeq 로 항상 같은 더미를 만든다. 높이 5가지 중 하나를 골라서 카드마다 높이가 달라지게 했다.

getCombineReviewDummyImage
const REVIEW_DUMMY_IMAGE_VARIANTS = [
  { width: 400, height: 520 },
  { width: 400, height: 400 },
  { width: 400, height: 560 },
  { width: 400, height: 480 },
  { width: 400, height: 440 },
] as const;

export function getCombineReviewDummyImage(reviewSeq: number) {
  const variant =
    REVIEW_DUMMY_IMAGE_VARIANTS[
      Math.abs(reviewSeq) % REVIEW_DUMMY_IMAGE_VARIANTS.length
    ];

  return {
    url: `https://picsum.photos/seed/combine-review-${reviewSeq}/${variant.width}/${variant.height}`,
    aspectRatio: `${variant.width} / ${variant.height}`,
    photoCount: (Math.abs(reviewSeq) % 5) + 1,
  };
}

카드에서는 aspectRatio 를 inline style 로 넣는다.

<div className={imageWrapClassName} style={{ aspectRatio: dummyImage.aspectRatio }}>

고정 aspect-[3/4] 대신 카드마다 비율을 다르게 주니까, columns 레이아웃에서 높이 차이가 커져 워터폴처럼 보인다.


6. 카드 내부 구조


7. 인피니티 스크롤과의 관계

스크롤 컨테이너(#scroll-root) 안의 sentinel 이 보이면 훅이 fetchNextPage() 를 부르고, 리스트가 늘어나면 Masonry 가 새 카드를 그린다.

  • Masonry 쪽에 따로 JS 레이아웃 코드가 없다.
  • 새 카드가 DOM 에 붙으면 브라우저가 columns 규칙으로 알아서 배치한다.
  • Masonry.js 처럼 reloadItems(), layout() 을 부를 필요가 없다.

하나 주의할 건, columns 는 전체 높이를 다시 나눠서 배치하기 때문에 카드가 추가될 때 기존 카드가 다른 열로 옮겨 갈 수 있다는 점이다. 무한 스크롤에서 이게 거슬리면 JS 로 두 열을 나눠 담는 방식이 더 안정적이다.


8. Masonry.js vs CSS columns


9. 네이티브 CSS Masonry 현황

처음에는 grid-template-rows: masonry 로 제안됐는데, 이름과 문법을 두고 논의가 길어지다가 display: grid-lanes 로 정리됐다.

2026년 10월 기준으로 Safari 26.4 부터 기본 지원되고, Chrome, Edge, Firefox 는 아직 플래그를 켜야 된다. 지금 쓰려면 @supports 로 감싸고 columns 를 fallback 으로 두는 게 맞다.

grid-lanes + columns fallback
.masonry {
  columns: 2;
  column-gap: 8px;
}
.masonry > * {
  break-inside: avoid;
  margin-bottom: 8px;
}

@supports (display: grid-lanes) {
  .masonry {
    display: grid-lanes;
    grid-template-columns: repeat(2, 1fr);
    gap: 8px;
  }
  .masonry > * {
    margin-bottom: 0;
  }
}

grid-lanes 는 카드를 가장 짧은 열에 넣어서 순서가 좌우로 흐른다. columns 의 열 우선 문제도 같이 해결된다.


10. 나중에 API 사진을 붙일 때

combine.utils.ts 만 고치면 된다.

getCombineReviewImage 예시
// API 필드가 추가된 뒤
export function getCombineReviewImage(item: ISearchReviewReviewListItems) {
  if (item.reviewPicUrl) {
    return {
      url: item.reviewPicUrl,
      aspectRatio: "3 / 4",
      photoCount: item.photoCount ?? 1,
    };
  }
  return getCombineReviewDummyImage(item.reviewSeq);
}

combine.review.masonry.tsx 와 columns 레이아웃은 그대로 두면 된다. 단 실제 사진은 비율을 미리 모르니까, API 가 가로세로 크기를 같이 주면 로딩 중 레이아웃이 흔들리지 않는다.