← 글 목록

notes

CSS,Style - *(Masonry,Waterfall )Layout

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

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

가장 흔한 이름

  • Masonry layout (메이슨리 레이아웃) 벽돌 쌓기처럼 빈틈 없이 위에서 아래로 채우는 방식이라는 뜻입니다. Pinterest에서 많이 쓰여 Pinterest layout이라고도 합니다.

다른 표현

  • Waterfall layout (워터폴 / 폭포수 레이아웃) — 아시아권 UI·기획에서 자주 쓰는 말
  • Staggered grid (스태aggered grid) — 높이가 어긋난 그리드
  • Uneven grid / asymmetric grid — 덜 기술적인 표현

CSS/React 쪽

  • CSS grid-template-rows: masonry (브라우저 지원은 아직 제한적)
  • JS 라이브러리: Masonry.js, react-masonry-css 등


1. 전체 구조

3개 파일이 역할을 나눕니다.

flowchart TB
  Form["combine.review.form.tsx<br/>API · 인피니티 스크롤"]
  Masonry["combine.review.masonry.tsx<br/>2열 레이아웃 (CSS columns)"]
  Card["combine.review.card.tsx<br/>카드 UI"]
  Utils["combine.utils.ts<br/>더미 사진 생성"]

  Form -->|"items, onItemClick"| Masonry
  Masonry -->|"item, onClick"| Card
  Card -->|"getCombineReviewDummyImage()"| Utils

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

CSS → 실제 동작

SVG: Grid vs Columns 비교

기존 grid grid-cols-2 — 행(row) 기준 정렬

<svg viewBox="0 0 420 280" xmlns="http://www.w3.org/2000/svg" width="100%" style="max-width:420px">
<rect x="0" y="0" width="420" height="280" fill="#f7f7f8" rx="8"/>
<text x="210" y="22" text-anchor="middle" font-size="13" font-weight="bold" fill="#171719">grid grid-cols-2 (기존)</text>
<!-- Left column grid -->
<rect x="20" y="40" width="180" height="100" fill="#ffd6c4" stroke="#ff4802" stroke-width="1.5" rx="6"/>
<text x="110" y="95" text-anchor="middle" font-size="11" fill="#171719">카드 1 (짧음)</text>
<rect x="20" y="150" width="180" height="100" fill="#e8e8ea" stroke="#70737c" stroke-width="1" rx="6"/>
<text x="110" y="205" text-anchor="middle" font-size="11" fill="#171719">카드 3</text>
<!-- Right column grid -->
<rect x="220" y="40" width="180" height="160" fill="#ffd6c4" stroke="#ff4802" stroke-width="1.5" rx="6"/>
<text x="310" y="125" text-anchor="middle" font-size="11" fill="#171719">카드 2 (김)</text>
<rect x="220" y="210" width="180" height="50" fill="#fff3cd" stroke="#eab305" stroke-width="1.5" stroke-dasharray="4" rx="6"/>
<text x="310" y="240" text-anchor="middle" font-size="10" fill="#eab305">빈 공간</text>
<!-- Grid lines -->
<line x1="210" y1="35" x2="210" y2="270" stroke="#70737c" stroke-width="1" stroke-dasharray="3"/>
<line x1="15" y1="145" x2="405" y2="145" stroke="#70737c" stroke-width="1" stroke-dasharray="3"/>
<text x="210" y="272" text-anchor="middle" font-size="10" fill="#70737c">행(row) 높이 = 가장 긴 카드 기준 → 아래 빈틈 발생</text>
</svg>

현재 columns-2 — 열(column) 기준 채우기

<svg viewBox="0 0 420 280" xmlns="http://www.w3.org/2000/svg" width="100%" style="max-width:420px">
<rect x="0" y="0" width="420" height="280" fill="#f7f7f8" rx="8"/>
<text x="210" y="22" text-anchor="middle" font-size="13" font-weight="bold" fill="#171719">columns-2 (현재 Masonry)</text>
<!-- Left column -->
<rect x="20" y="40" width="180" height="100" fill="#ffd6c4" stroke="#ff4802" stroke-width="1.5" rx="6"/>
<text x="110" y="95" text-anchor="middle" font-size="11" fill="#171719">카드 1</text>
<rect x="20" y="152" width="180" height="80" fill="#ffd6c4" stroke="#ff4802" stroke-width="1.5" rx="6"/>
<text x="110" y="197" text-anchor="middle" font-size="11" fill="#171719">카드 3</text>
<!-- Right column -->
<rect x="220" y="40" width="180" height="160" fill="#ffd6c4" stroke="#ff4802" stroke-width="1.5" rx="6"/>
<text x="310" y="125" text-anchor="middle" font-size="11" fill="#171719">카드 2 (김)</text>
<rect x="220" y="212" width="180" height="50" fill="#ffd6c4" stroke="#ff4802" stroke-width="1.5" rx="6"/>
<text x="310" y="242" text-anchor="middle" font-size="11" fill="#171719">카드 4</text>
<!-- Column divider -->
<line x1="210" y1="35" x2="210" y2="270" stroke="#70737c" stroke-width="1" stroke-dasharray="3"/>
<!-- Flow arrows -->
<text x="110" y="38" text-anchor="middle" font-size="9" fill="#ff4802">↓ 1열 먼저 채움</text>
<text x="310" y="38" text-anchor="middle" font-size="9" fill="#ff4802">↓ 2열 채움</text>
<text x="210" y="272" text-anchor="middle" font-size="10" fill="#70737c">빈틈 없이 위→아래로 쌓임 (Pinterest형)</text>
</svg>

3. 카드 배치 순서 (중요)

CSS columns는 DOM 순서대로 1열을 먼저 위→아래로 채운 뒤, 2열로 넘어갑니다.

<svg viewBox="0 0 420 200" xmlns="http://www.w3.org/2000/svg" width="100%" style="max-width:420px">
<rect x="0" y="0" width="420" height="200" fill="#f7f7f8" rx="8"/>
<text x="210" y="20" text-anchor="middle" font-size="12" font-weight="bold" fill="#171719">DOM 순서 → 화면 배치</text>
<!-- DOM list -->
<rect x="10" y="35" width="80" height="150" fill="#fff" stroke="#70737c" rx="4"/>
<text x="50" y="52" text-anchor="middle" font-size="9" fill="#70737c">DOM</text>
<rect x="18" y="60" width="64" height="22" fill="#ffd6c4" stroke="#ff4802" rx="3"/><text x="50" y="75" text-anchor="middle" font-size="9">①</text>
<rect x="18" y="88" width="64" height="22" fill="#ffd6c4" stroke="#ff4802" rx="3"/><text x="50" y="103" text-anchor="middle" font-size="9">②</text>
<rect x="18" y="116" width="64" height="22" fill="#ffd6c4" stroke="#ff4802" rx="3"/><text x="50" y="131" text-anchor="middle" font-size="9">③</text>
<rect x="18" y="144" width="64" height="22" fill="#ffd6c4" stroke="#ff4802" rx="3"/><text x="50" y="159" text-anchor="middle" font-size="9">④</text>
<!-- Arrow -->
<text x="115" y="110" font-size="20" fill="#ff4802">→</text>
<!-- Screen -->
<rect x="140" y="35" width="130" height="150" fill="#fff" stroke="#70737c" rx="4"/>
<text x="205" y="52" text-anchor="middle" font-size="9" fill="#70737c">1열</text>
<rect x="148" y="60" width="114" height="30" fill="#ffd6c4" stroke="#ff4802" rx="3"/><text x="205" y="80" text-anchor="middle" font-size="10">①</text>
<rect x="148" y="96" width="114" height="40" fill="#ffd6c4" stroke="#ff4802" rx="3"/><text x="205" y="121" text-anchor="middle" font-size="10">③</text>
<rect x="280" y="35" width="130" height="150" fill="#fff" stroke="#70737c" rx="4"/>
<text x="345" y="52" text-anchor="middle" font-size="9" fill="#70737c">2열</text>
<rect x="288" y="60" width="114" height="50" fill="#ffd6c4" stroke="#ff4802" rx="3"/><text x="345" y="90" text-anchor="middle" font-size="10">②</text>
<rect x="288" y="116" width="114" height="30" fill="#ffd6c4" stroke="#ff4802" rx="3"/><text x="345" y="136" text-anchor="middle" font-size="10">④</text>
<text x="210" y="195" text-anchor="middle" font-size="10" fill="#70737c">①③ = 왼쪽 / ②④ = 오른쪽 (행 단위 X, 열 단위 O)</text>
</svg>

Figma처럼 1·2, 3·4 순서(좌우 번갈아)가 필요하면 CSS columns 대신 2열 배열 분배 JS가 필요합니다. 지금은 Pinterest형 열 우선 채우기입니다.


4. break-inside-avoid 역할

이 속성이 없으면 긴 카드가 열 경계에서 잘려 보일 수 있습니다.

<svg viewBox="0 0 420 160" xmlns="http://www.w3.org/2000/svg" width="100%" style="max-width:420px">
<text x="105" y="18" text-anchor="middle" font-size="11" font-weight="bold" fill="#da1e28">break-inside: auto (나쁨)</text>
<rect x="20" y="28" width="170" height="120" fill="#fff" stroke="#70737c" rx="4"/>
<rect x="28" y="36" width="154" height="60" fill="#ffd6c4" stroke="#ff4802" rx="4"/>
<text x="105" y="70" text-anchor="middle" font-size="10">카드 상단</text>
<line x1="20" y1="100" x2="190" y2="100" stroke="#da1e28" stroke-width="2" stroke-dasharray="5"/>
<text x="105" y="98" text-anchor="middle" font-size="8" fill="#da1e28">열 경계에서 잘림!</text>
<rect x="28" y="104" width="154" height="36" fill="#ffd6c4" stroke="#ff4802" rx="4" opacity="0.5"/>
<text x="105" y="126" text-anchor="middle" font-size="10">카드 하단 → 2열로</text>

<text x="315" y="18" text-anchor="middle" font-size="11" font-weight="bold" fill="#1a8038">break-inside: avoid (현재)</text>
<rect x="230" y="28" width="170" height="120" fill="#fff" stroke="#70737c" rx="4"/>
<rect x="238" y="36" width="154" height="100" fill="#ffd6c4" stroke="#ff4802" rx="4"/>
<text x="315" y="80" text-anchor="middle" font-size="10">카드 전체가</text>
<text x="315" y="95" text-anchor="middle" font-size="10">한 열에 유지</text>
<text x="315" y="115" text-anchor="middle" font-size="9" fill="#1a8038">✓ 잘리지 않음</text>
</svg>


5. 더미 사진 생성 로직

API에 사진 필드가 없어 reviewSeq로 안정적인 더미를 만듭니다.

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

SVG: reviewSeq → 더미 이미지

<svg viewBox="0 0 480 180" xmlns="http://www.w3.org/2000/svg" width="100%" style="max-width:480px">
<rect x="0" y="0" width="480" height="180" fill="#f7f7f8" rx="8"/>
<!-- Input -->
<rect x="20" y="60" width="90" height="50" fill="#fff" stroke="#ff4802" stroke-width="2" rx="6"/>
<text x="65" y="82" text-anchor="middle" font-size="10" fill="#70737c">reviewSeq</text>
<text x="65" y="100" text-anchor="middle" font-size="14" font-weight="bold" fill="#171719">42</text>
<!-- Modulo -->
<rect x="140" y="60" width="100" height="50" fill="#fff" stroke="#70737c" rx="6"/>
<text x="190" y="82" text-anchor="middle" font-size="9" fill="#70737c">% 5 variants</text>
<text x="190" y="100" text-anchor="middle" font-size="12" fill="#171719">index = 2</text>
<!-- Output -->
<rect x="270" y="40" width="190" height="110" fill="#fff" stroke="#1a8038" stroke-width="1.5" rx="6"/>
<text x="365" y="58" text-anchor="middle" font-size="10" font-weight="bold" fill="#1a8038">반환값</text>
<text x="280" y="78" font-size="9" fill="#171719">url: picsum/400/560</text>
<text x="280" y="96" font-size="9" fill="#171719">aspectRatio: 400/560</text>
<text x="280" y="114" font-size="9" fill="#171719">photoCount: 3</text>
<text x="280" y="136" font-size="8" fill="#70737c">seed 고정 → 같은 후기 = 같은 사진</text>
<!-- Arrows -->
<text x="125" y="90" font-size="16" fill="#ff4802">→</text>
<text x="255" y="90" font-size="16" fill="#ff4802">→</text>
<!-- Height variants -->
<text x="20" y="145" font-size="9" fill="#70737c">5가지 높이: 520 / 400 / 560 / 480 / 440 px → 카드마다 다른 높이 → Masonry 효과</text>
</svg>

카드에서는 aspectRatio를 inline style로 적용합니다.

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

고정 aspect-[3/4] 대신 카드마다 다른 비율 → columns 레이아웃에서 높이 차이가 커져 워터폴처럼 보입니다.


6. 카드 내부 구조

<svg viewBox="0 0 200 320" xmlns="http://www.w3.org/2000/svg" width="100%" style="max-width:200px">
<rect x="0" y="0" width="200" height="320" fill="#fff" stroke="#e1e2e4" stroke-width="1.5" rx="12"/>
<!-- Image area -->
<rect x="0" y="0" width="200" height="180" fill="#d4d4d8" rx="12"/>
<text x="100" y="100" text-anchor="middle" font-size="11" fill="#70737c">더미 이미지</text>
<!-- Star badge -->
<rect x="8" y="8" width="44" height="22" fill="#fff" rx="11" opacity="0.9"/>
<text x="30" y="23" text-anchor="middle" font-size="10" fill="#171719">★ 4.0</text>
<!-- Photo count -->
<rect x="160" y="152" width="32" height="20" fill="#000" opacity="0.6" rx="4"/>
<text x="176" y="166" text-anchor="middle" font-size="10" fill="#fff">3</text>
<!-- Text area -->
<rect x="12" y="192" width="176" height="36" fill="#f7f7f8" rx="4"/>
<text x="100" y="210" text-anchor="middle" font-size="9" fill="#70737c">후기 텍스트 (2줄 + 더보기)</text>
<text x="12" y="244" font-size="10" font-weight="bold" fill="#171719">양평 ○○ 캠핑장</text>
<text x="12" y="262" font-size="9" fill="#70737c">26.04.25(토)</text>
<!-- Labels -->
<text x="100" y="300" text-anchor="middle" font-size="8" fill="#ff4802">실제 API 데이터</text>
<text x="100" y="312" text-anchor="middle" font-size="8" fill="#70737c">더미: 이미지·사진수만</text>
</svg>

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

sequenceDiagram
  participant User as 사용자
  participant Scroll as #tq-scroll-root
  participant Hook as useCombineInfiniteScroll
  participant API as useSearchReviewInfiniteQuery
  participant Masonry as CombineReviewMasonry

  User->>Scroll: 아래로 스크롤
  Scroll->>Hook: sentinel 교차 감지
  Hook->>API: fetchNextPage()
  API-->>Masonry: reviewList 증가
  Note over Masonry: columns-2가<br/>새 카드를 자동 배치
  • Masonry 쪽에 별도 JS 레이아웃 코드 없음
  • 새 카드가 DOM에 append되면 브라우저가 columns 규칙으로 자동 배치
  • Masonry.js 대비 reloadItems() / layout() 호출 불필요

8. Masonry.js vs 직접 구현 비교


9. 나중에 API 사진 연동 시

combine.utils.ts만 수정하면 됩니다.

// 예시 (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 레이아웃은 그대로 둬도 됩니다.