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