한 축이냐, 두 축이냐
Flexbox(CSS Flexible Box Layout)는 요소를 하나의 주축을 따라 배분하고, 필요하면 줄바꿈으로 여러 줄을 만듭니다. 줄마다 따로 계산되므로 서로 다른 줄의 요소는 정렬이 맞지 않습니다. Grid(CSS Grid Layout)는 컨테이너에 행과 열을 정의하고 요소를 그 공유 구조에 배치하므로 가로와 세로가 모두 맞습니다.
기억하기 쉬운 기준은 flexbox는 콘텐츠에서 바깥으로, grid는 레이아웃에서 안쪽으로라는 것입니다. flexbox에서는 요소 자신의 크기가 결과를 정하고 컨테이너가 따라갑니다. grid에서는 컨테이너의 트랙을 먼저 선언하고 요소를 거기에 담습니다. 어느 쪽이 주도해야 하는지 물어보세요. 버튼 한 줄이 라벨 길이만큼만 차지해야 한다면 flexbox입니다. 콘텐츠가 뭐든 240px 사이드바와 가변 본문 열이 있어야 하는 페이지라면 grid입니다.
둘은 경쟁 관계가 아닙니다. grid 셀이 flex 컨테이너가 될 수 있고, flex 아이템이 grid 컨테이너가 될 수도 있습니다. 실무 규칙은 페이지 전체와 2차원 배치에는 grid, 한 줄이나 한 열로 늘어선 컴포넌트에는 flexbox입니다.
자주 쓰는 UI 패턴별 판단표
패턴 이름만 붙이면 선택은 대부분 분명해집니다. 아래 표는 가장 자주 나오는 레이아웃과 그렇게 고르는 이유입니다.
| 패턴 | 더 나은 선택 | 이유 |
|---|---|---|
| 내비게이션 바 | Flexbox | 링크 글자 길이로 크기가 정해지는 한 줄이며, margin-left: auto로 마지막 항목만 오른쪽으로 보냄 |
| 같은 폭 카드 목록 | Grid | 열을 컨테이너가 정하므로 마지막 줄이 짧아도 열 폭이 그대로 유지됨 |
| 홀리 그레일 페이지(헤더·사이드바·본문·보조·푸터) | Grid | 이름 붙인 영역으로 페이지 전체를 2차원으로 표현 |
| 폼 행(라벨 + 입력) | Grid | 라벨 열이 모든 행에서 맞음. flexbox는 행마다 따로 정렬됨 |
| 미디어 오브젝트(이미지 + 글) | Flexbox | 이미지는 고정 크기, 글이 나머지를 차지: 글에 flex: 1 |
| 가운데 정렬 히어로 | 둘 다 가능 | display: grid; place-items: center가 가장 짧음. flexbox는 속성 두 개 필요 |
| 벽돌형(masonry) 갤러리 | 엄밀히는 둘 다 아님 | 위에서 아래로 흐르는 배치는 CSS 다단(column-width)을 쓰고, grid는 행이 정렬됨 |
| 줄바꿈되는 툴바·태그 목록 | Flexbox | 항목이 자기 폭을 유지하며 흐르듯 줄바꿈됨 |
flex: 1과 flex: auto는 다르다
flex 단축 속성은 grow, shrink, basis를 한꺼번에 정합니다. flex: 1은 1 1 0%로, basis가 0이라 모든 아이템이 0에서 출발해 남는 공간을 grow 비율대로 나눠 가집니다. flex: auto는 1 1 auto로, 아이템이 콘텐츠 폭에서 출발하고 남은 공간만 나눕니다. 콘텐츠 크기가 서로 다르면 차이가 바로 드러납니다.
콘텐츠 폭이 100px, 200px, 300px인 아이템 세 개가 900px 컨테이너에 있다고 합시다. flex: 1이면 셋 다 정확히 300px이 됩니다. flex: auto이면 남는 300px을 셋이 똑같이 나눠 100px씩 더해지므로 200px, 300px, 400px이 됩니다. 폭이 같은 열을 원하면 flex: 1, 콘텐츠가 넓은 쪽이 비례해서 더 넓게 남기를 원하면 flex: auto입니다.
.equal > * { flex: 1; } /* 1 1 0% -> 300px 300px 300px */
.natural > * { flex: auto; } /* 1 1 auto -> 200px 300px 400px */
.fixed > * { flex: none; } /* 0 0 auto -> 콘텐츠 폭, 크기 변하지 않음 */flex-wrap에서 자주 걸리는 곳
줄바꿈 여부는 각 아이템의 가상 크기, 즉 min과 max로 제한한 basis로 정해집니다. flex: 1은 basis가 0%라서 항상 한 줄에 들어가 줄바꿈이 일어나지 않고, 그래서 flex-wrap: wrap을 추가했는데 아무 일도 없다고 당황하게 됩니다. 줄바꿈이 되게 하려면 실제 basis를 주세요. flex: 1 1 240px은 240px이 더 들어가지 않을 때 줄을 바꾸고, 각 줄에서 남은 공간만큼 아이템이 늘어납니다.
또 하나는 마지막 줄입니다. 각 flex 줄은 자기 줄의 남은 공간을 따로 나누므로 아이템이 하나뿐인 마지막 줄은 그 아이템이 줄 전체로 늘어나 위쪽 아이템보다 훨씬 넓어집니다. 모든 줄에서 열 폭이 같아야 한다면 grid로 바꿀 때라는 신호입니다.
.tags { display: flex; flex-wrap: wrap; gap: 8px; }
.cards { display: flex; flex-wrap: wrap; gap: 16px; }
.cards > * { flex: 1 1 240px; } /* 마지막 줄: 아이템 1개면 전체 폭으로 늘어남 */반응형 그리드: auto-fit과 auto-fill
웹에서 가장 쓸모 있는 grid 한 줄은 grid-template-columns: repeat(auto-fit, minmax(240px, 1fr))입니다. 브라우저는 최소 240px인 열을 들어가는 만큼 만들고, 남은 공간을 1fr로 나눕니다. 열의 수는 floor((너비 + gap) / (최소폭 + gap))입니다. gap이 16px이고 최소폭이 240px이면 1200px에서 4열, 1000px에서 3열, 500px에서 2열, 320px에서 1열입니다.
두 키워드가 만드는 트랙 수는 같습니다. 차이는 아이템이 트랙보다 적을 때만 나타납니다. auto-fill은 빈 트랙을 남겨 두므로 아이템은 열 폭을 유지하고 오른쪽에 빈 공간이 생깁니다. auto-fit은 빈 트랙을 폭 0으로 접어서 1fr의 공간이 실제 있는 아이템에게 돌아갑니다. 아이템 3개, 1200px 컨테이너, gap 16px이면 auto-fill은 288px 트랙 4개를 만들고 하나를 비워 두며, auto-fit은 약 389px 트랙 3개를 만듭니다.
아이템이 적어도 카드 크기를 일정하게 두려면 auto-fill, 적은 아이템이 한 줄을 채우게 하려면 auto-fit을 쓰세요. 한 가지 더, 컨테이너가 최소폭보다 좁으면 그냥 minmax(240px, 1fr)는 넘칩니다. minmax(min(240px, 100%), 1fr)로 쓰면 해결됩니다.
.cards {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
}grid-template-areas로 페이지 짜기
이름 붙인 영역을 쓰면 CSS가 레이아웃 모양 그대로 읽힙니다. 따옴표로 묶은 문자열 하나가 한 행, 단어 하나가 한 열이고, 같은 이름을 반복하면 셀이 사각형으로 합쳐집니다. 고정 240px 사이드바와 가변 minmax(0, 1fr) 본문 열이 고전적인 모양입니다. 마크업은 그대로 두고 미디어 쿼리나 컨테이너 쿼리 안에서 영역만 바꿀 수 있습니다.
.page {
display: grid;
gap: 16px;
min-height: 100vh;
grid-template-columns: 240px minmax(0, 1fr) 200px;
grid-template-rows: auto 1fr auto;
grid-template-areas:
"header header header"
"nav main aside"
"footer footer footer";
}
.page > header { grid-area: header; }
.page > nav { grid-area: nav; }
.page > main { grid-area: main; }
.page > aside { grid-area: aside; }
.page > footer { grid-area: footer; }
@media (max-width: 720px) {
.page {
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "header" "nav" "main" "aside" "footer";
}
}gap은 두 모델 모두에서 쓴다
gap(그리고 row-gap, column-gap)은 flexbox와 grid 모두에서 쓸 수 있습니다. 요소 사이에만 간격을 넣고 바깥쪽 가장자리에는 넣지 않으므로 음수 마진이나 :last-child 예외 처리가 필요 없습니다. 줄바꿈되는 flex 컨테이너에서는 같은 줄의 아이템 사이와 줄 사이 모두에 적용됩니다. 형제 요소 사이의 간격은 마진 대신 gap으로, 마진은 컴포넌트를 주변과 떨어뜨리는 데 쓰세요.
정렬 속성 한눈에 보기
속성 이름은 두 모델에서 같지만 작용하는 축이 다릅니다. flexbox에서는 flex-direction에 따라 축이 바뀝니다. grid에서는 justify 계열이 행(인라인) 축에, align 계열이 열(블록) 축에 작용합니다.
| 속성 | Flexbox | Grid |
|---|---|---|
| justify-content | 주축 방향으로 아이템 배분 | 컨테이너 안에서 열 전체의 묶음을 배분 |
| align-items | 교차축에서 아이템 정렬(줄마다) | 셀 안에서 각 아이템을 블록 축으로 정렬 |
| justify-items | 쓰지 않음 | 셀 안에서 각 아이템을 인라인 축으로 정렬 |
| place-items | 쓰지 않음 | 단축 속성: align-items, justify-items 순서 |
| align-content | 줄바꿈 시 줄들을 배분 | 행 전체의 묶음을 배분 |
| align-self / justify-self | align-self는 아이템 하나를 덮어씀, justify-self는 무시 | 둘 다 셀 안에서 아이템 하나를 덮어씀 |
min-width: 0 오버플로 함정
flex 아이템과 grid 아이템에는 자동 최소 크기가 있습니다. flex 아이템은 min-width를 지정하지 않으면 min-content 크기 아래로 줄어들지 않습니다. 긴 URL, 폭이 넓은 pre 블록, 이미지 하나가 아이템을 컨테이너 밖으로 밀어내고, flex: 1로도 막을 수 없습니다. 해결책은 아이템에 min-width: 0을 주는 것입니다(overflow: hidden도 자동 최소값을 풀어 줍니다).
grid도 트랙 크기에서 같은 함정이 있습니다. 1fr은 minmax(auto, 1fr)의 줄임말이고, auto 최솟값은 콘텐츠의 min-content 크기라서 폭 넓은 자식 하나가 열 전체를 늘립니다. minmax(0, 1fr)로 쓰면 트랙이 콘텐츠보다 작아질 수 있으니, 그 뒤에 overflow-x: auto나 overflow-wrap: anywhere로 콘텐츠의 동작을 정하세요.
.row > .text { flex: 1; min-width: 0; } /* flex 해결 */
.page { grid-template-columns: 240px minmax(0, 1fr); } /* grid 해결 */
pre { overflow-x: auto; } /* 늘리지 말고 스크롤 */subgrid와 컨테이너 쿼리
subgrid는 중첩된 grid가 부모의 트랙을 그대로 쓰게 해서, 서로 다른 카드 안의 라벨과 입력도 카드 사이에서 정렬되게 합니다. 자식에 grid-template-columns: subgrid를 쓰고 자식이 부모의 트랙에 걸치게 하면 됩니다. 컨테이너 쿼리는 뷰포트가 아니라 컨테이너의 크기로 컴포넌트를 스타일링합니다. 컨테이너에 container-type: inline-size를, 규칙에 @container (min-width: 400px)를 씁니다. 둘 다 현재 브라우저의 Baseline 기능 범위에 들어 있지만, 의존하기 전에 자신의 지원 대상 브라우저를 확인하세요.
이 조합은 뷰포트 미디어 쿼리의 상당수를 대체합니다. 카드 컴포넌트가 넓은 본문 열에 있든 좁은 사이드바에 있든, 주어진 공간에 따라 세로 배치에서 가로 배치로 바뀔 수 있습니다.
.card-list { container-type: inline-size; }
.card { display: grid; gap: 12px; }
@container (min-width: 480px) {
.card { grid-template-columns: 160px minmax(0, 1fr); }
}