안녕하세요! 프론트엔드 개발자 양성 과정 강사입니다. 이번에는 CSS Grid 레이아웃의 진정한 꽃이자, 최신 스펙 중 가장 강력한 기능으로 꼽히는 서브그리드(Subgrid)에 대해 파헤쳐 볼 시간입니다.
요청하신 MDN 공식 문서 내용을 하나도 빠짐없이, 여러분이 이해하기 쉬운 구어체로 친절하게 번역해 드릴게요. 실무에서 어떻게 쓰이는지, 그리고 모던 프론트엔드 환경에서 왜 이 기능이 그토록 중요한지 제 경험을 듬뿍 담아 설명해 드리겠습니다. 자, 시작해 볼까요?
기준선(Baseline) 2023 - 새롭게 사용 가능해진 기능
2023년 9월부터 이 기능은 최신 기기 및 주요 브라우저의 최신 버전들 전반에서 작동하기 시작했습니다. 단, 구형 기기나 구버전 브라우저에서는 제대로 작동하지 않을 수 있습니다.
💡 강사의 팁: 서브그리드는 오랫동안 파이어폭스에서만 지원되다가 2023년 하반기에 크롬과 사파리에도 모두 정식 탑재되면서 비로소 실무에서 안전하게 쓸 수 있게 된 따끈따끈한 기능입니다. 포트폴리오를 만드실 때 이 기능을 활용하시면 "최신 CSS 트렌드를 잘 팔로우업하는 개발자"라는 좋은 인상을 줄 수 있답니다!
CSS 그리드 레이아웃(CSS grid layout) 모듈은 grid-template-columns와 grid-template-rows 속성을 위해 subgrid라는 값을 포함하고 있습니다. 이 가이드에서는 서브그리드가 정확히 어떤 역할을 하는지 자세히 알아보고, 이 기능이 해결해 주는 다양한 사용 사례와 디자인 패턴들을 살펴보겠습니다.
어떤 그리드 컨테이너에 display: grid를 추가하면, 오직 그 컨테이너의 직계 자식(direct children)들만이 '그리드 아이템'이 됩니다. 그리고 이 직계 자식들만 우리가 만든 그리드 위에 마음대로 배치할 수 있죠. 반면, 그 그리드 아이템들의 내부 자식 요소들은 그리드의 영향을 받지 않고 일반적인 문서 흐름(normal flow)에 따라 표시됩니다.
물론 그리드 아이템 자체를 또 다른 그리드 컨테이너로 만들어서(즉, display: grid를 한 번 더 줘서) 그리드를 "중첩(nest)"할 수는 있습니다. 하지만 이렇게 만든 중첩 그리드들은 부모 그리드와는 완전히 독립적이며 서로 아무런 연관이 없습니다. 즉, 부모 그리드의 트랙(행/열) 크기를 전혀 물려받지 않는다는 뜻이죠. 이로 인해 중첩된 그리드 안의 아이템들을 바깥쪽의 메인 그리드 선에 딱 맞춰 정렬하는 것은 과거에는 굉장히 까다롭고 어려운 일이었습니다.
하지만 이제 grid-template-columns나 grid-template-rows, 혹은 둘 다에 subgrid라는 값을 설정하면 이야기가 달라집니다. 새로운 트랙 크기를 일일이 다시 정의하는 대신, 중첩된 그리드가 부모 그리드에 정의된 트랙을 그대로 가져와서 사용하게 되거든요!
예를 들어, 여러분이 grid-template-columns: subgrid를 사용했고, 그 중첩된 그리드 요소가 부모 그리드의 열 3칸(column tracks)을 차지하고 있다고 가정해 봅시다. 그러면 그 중첩 그리드 내부에는 부모 그리드와 완전히 똑같은 크기를 가진 3개의 열 트랙이 생기게 됩니다.
이때 부모의 간격(gap)도 그대로 상속받지만, 필요하다면 서브그리드 쪽에 새로운 gap 값을 주어 덮어쓸 수도 있습니다. 또한 그리드 라인 이름(Line names)도 부모로부터 서브그리드로 그대로 전달되며, 서브그리드에서 독자적인 라인 이름을 추가로 선언할 수도 있습니다.
💡 강사의 팁: > 현업에서 리액트(React)를 이용해 컴포넌트 단위로 개발하다 보면 이 문제가 자주 발생합니다. 화면 전체의 그리드를 잡아놨는데, 특정 영역을 독립된 컴포넌트로 분리하는 순간 래퍼(wrapper) 태그가 생겨나면서 부모 그리드와의 연결이 끊겨버리죠. 스토리북(Storybook) 등을 활용해 컴포넌트 주도 개발(CDD)을 하신다면, 이
subgrid야말로 컴포넌트의 독립성을 유지하면서도 전체 페이지의 일관된 그리드 라인에 요소를 정렬할 수 있게 해주는 마법 같은 도구입니다!
아래 예제에서 전체 그리드 레이아웃은 9개의 1fr 열(column) 트랙과, 최소 100px 이상의 높이를 가지는 4개의 행(row)을 가지고 있습니다.
여기서 .item 요소는 2번 열 라인부터 7번 열 라인 사이, 그리고 2번 행 라인부터 4번 행 라인 사이에 배치되었습니다. 그리고 이 .item 자체에 display: grid를 지정해 그리드 컨테이너로 만든 다음, 열 트랙을 grid-template-columns: subgrid로 지정하여 서브그리드로 정의했습니다. 행 트랙은 일반적인 방식으로 크기를 지정했고요. 이 .item 요소가 부모의 5칸짜리 열 트랙을 차지하고 있기 때문에, 결과적으로 서브그리드 역시 5칸의 열 트랙을 가지게 됩니다.
이 .item이 서브그리드이기 때문에, 그 안에 있는 .subitem은 비록 바깥쪽 최상위 .grid의 직계 자식이 아님에도 불구하고 바깥쪽 그리드 라인에 맞춰 완벽하게 배치될 수 있습니다. 행(row)의 경우는 서브그리드로 지정하지 않았으므로, 일반적인 중첩 그리드처럼 독자적으로 작동합니다. 부모의 그리드 영역은 이 중첩된 그리드가 들어갈 수 있도록 충분한 크기로 알아서 늘어나게 됩니다.
<div class="grid">
<div class="item">
<div class="subitem"></div>
</div>
</div>
* {
box-sizing: border-box;
}
.grid {
border: 2px solid #f76707;
border-radius: 5px;
background-color: #fff4e6;
}
.item {
border: 2px solid #ffa94d;
border-radius: 5px;
background-color: #ffd8a8;
color: #d9480f;
}
.subitem {
background-color: rgb(40 240 83);
}
.grid {
display: grid;
grid-template-columns: repeat(9, 1fr);
grid-template-rows: repeat(4, minmax(100px, auto));
}
.item {
display: grid;
grid-column: 2 / 7;
grid-row: 2 / 4;
grid-template-columns: subgrid; /* 부모의 열을 그대로 물려받습니다! */
grid-template-rows: repeat(3, 80px); /* 행은 독자적으로 씁니다 */
}
.subitem {
grid-column: 3 / 6;
grid-row: 1 / 3;
}
여기서 꼭 기억하셔야 할 점은, 서브그리드 내부에서는 라인 번호 매기기가 처음부터 다시 시작된다는 것입니다. 즉, 서브그리드 내부에서의 1번 열 라인은 부모 그리드의 1번 라인이 아니라, 서브그리드가 시작되는 바로 그 지점의 첫 번째 라인이 됩니다. 서브그리드가 적용된 요소는 부모 그리드의 라인 '번호' 자체를 물려받지는 않습니다.
이것은 매우 유용한 특징인데요, 왜냐하면 어떤 컴포넌트가 메인 그리드의 어느 위치에 놓이더라도 그 컴포넌트 내부의 요소들을 배치할 때는 항상 "1번 라인부터 시작한다"고 확신하고 안전하게 레이아웃을 짤 수 있기 때문입니다. (컴포넌트 재사용성이 훌륭해지겠죠?)
이 예제는 위와 동일한 HTML을 사용하지만, 이번에는 subgrid 값을 열이 아닌 grid-template-rows에 적용했습니다. 열 트랙은 명시적으로 따로 크기를 정의했고요. 이 경우 열(column) 트랙은 일반적인 중첩 그리드처럼 독자적으로 작동하지만, 행(row) 트랙은 .item 요소가 차지하고 있는 두 개의 부모 행 트랙에 완전히 동기화되어 연결됩니다.
<div class="grid">
<div class="item">
<div class="subitem"></div>
</div>
</div>
* {
box-sizing: border-box;
}
.grid {
border: 2px solid #f76707;
border-radius: 5px;
background-color: #fff4e6;
}
.item {
border: 2px solid #ffa94d;
border-radius: 5px;
background-color: #ffd8a8;
color: #d9480f;
}
.subitem {
background-color: rgb(40 240 83);
}
.grid {
display: grid;
grid-template-columns: repeat(9, 1fr);
grid-template-rows: repeat(4, minmax(100px, auto));
}
.item {
display: grid;
grid-column: 2 / 7;
grid-row: 2 / 4;
grid-template-columns: repeat(3, 1fr); /* 이번엔 열을 독자적으로! */
grid-template-rows: subgrid; /* 부모의 행을 그대로 물려받습니다! */
}
.subitem {
grid-column: 2 / 4;
grid-row: 1 / 3;
}
이번 예제에서는 행과 열 모두를 서브그리드로 정의해 보겠습니다. 이렇게 하면 중첩된 서브그리드가 가로 세로 두 방향 모두에서 부모 그리드의 트랙과 완벽하게 일치하게 됩니다.
<div class="grid">
<div class="item">
<div class="subitem"></div>
</div>
</div>
* {
box-sizing: border-box;
}
.grid {
border: 2px solid #f76707;
border-radius: 5px;
background-color: #fff4e6;
}
.item {
border: 2px solid #ffa94d;
border-radius: 5px;
background-color: #ffd8a8;
color: #d9480f;
}
.subitem {
background-color: rgb(40 240 83);
}
.grid {
display: grid;
grid-template-columns: repeat(9, 1fr);
grid-template-rows: repeat(4, minmax(100px, auto));
}
.item {
display: grid;
grid-column: 2 / 7;
grid-row: 2 / 4;
grid-template-columns: subgrid; /* 가로도 묶고 */
grid-template-rows: subgrid; /* 세로도 묶습니다 */
}
.subitem {
grid-column: 3 / 6;
grid-row: 1 / 3;
}
여러분이 아이템들을 자동으로 배치(autoplace)해야 하는데 정확히 몇 개의 아이템이 들어올지 모르는 상황이라면, 서브그리드를 생성할 때 각별히 주의해야 합니다. 왜냐하면 서브그리드에서는 그 아이템들을 담아내기 위한 새로운 행(암시적 트랙)이 자동으로 생성되지 않기 때문입니다.
다음 예제를 잘 살펴볼까요? 위 예제와 동일한 부모-자식 그리드를 사용하고 있습니다. 그런데 서브그리드 안에 12개의 아이템이 있고, 이 아이템들이 10개의 빈 그리드 셀 안으로 스스로를 자동 배치하려고 시도합니다. 행과 열 모두 서브그리드 상태로 묶여서 칸이 고정되어 있기 때문에, 넘쳐나는 마지막 2개의 아이템은 들어갈 자리가 없습니다. 결국 이 두 아이템은 그리드의 맨 마지막 트랙 칸에 겹쳐서 꾸역꾸역 들어가게 됩니다. 이것이 공식 명세서에 정의된 정상적인 동작 방식입니다.
<div class="grid">
<div class="item">
<div class="subitem">1</div>
<div class="subitem">2</div>
<div class="subitem">3</div>
<div class="subitem">4</div>
<div class="subitem">5</div>
<div class="subitem">6</div>
<div class="subitem">7</div>
<div class="subitem">8</div>
<div class="subitem">9</div>
<div class="subitem">10</div>
<div class="subitem">11</div>
<div class="subitem">12</div>
</div>
</div>
* {
box-sizing: border-box;
}
body {
font: 1.2em sans-serif;
}
.grid {
border: 2px solid #f76707;
border-radius: 5px;
background-color: #fff4e6;
}
.item {
border: 2px solid #ffa94d;
border-radius: 5px;
color: #d9480f;
}
.subitem {
background-color: #d9480f;
color: white;
border-radius: 5px;
}
.grid {
display: grid;
grid-template-columns: repeat(9, 1fr);
grid-template-rows: repeat(4, minmax(100px, auto));
}
.item {
display: grid;
grid-column: 2 / 7;
grid-row: 2 / 4;
grid-template-columns: subgrid;
grid-template-rows: subgrid; /* 칸이 고정되어 더 늘어나지 않아요! */
}
만약 여기서 grid-template-rows의 값을 서브그리드에서 빼버린다면, 평소처럼 필요한 만큼의 행을 자동으로 만들어내는 암시적 트랙(implicit tracks) 생성이 활성화됩니다. 대신 이렇게 만들어진 새 트랙들은 부모 그리드의 선과는 일치하지 않게 되겠죠.
<div class="grid">
<div class="item">
<div class="subitem">1</div>
<div class="subitem">2</div>
<div class="subitem">3</div>
<div class="subitem">12</div>
</div>
</div>
* {
box-sizing: border-box;
}
body {
font: 1.2em sans-serif;
}
.grid {
border: 2px solid #f76707;
border-radius: 5px;
background-color: #fff4e6;
}
.item {
border: 2px solid #ffa94d;
border-radius: 5px;
color: #d9480f;
}
.subitem {
background-color: #d9480f;
color: white;
border-radius: 5px;
}
.grid {
display: grid;
grid-template-columns: repeat(9, 1fr);
grid-template-rows: repeat(4, minmax(100px, auto));
}
.item {
display: grid;
grid-column: 2 / 7;
grid-row: 2 / 4;
grid-template-columns: subgrid;
grid-auto-rows: minmax(100px, auto); /* 서브그리드를 풀고, 넘치면 새 행을 만들게 함! */
}
💡 강사의 팁: 게시판의 카드 리스트나 갤러리처럼 콘텐츠 개수가 동적으로 변하는 영역을 개발하실 때는 섣불리
grid-template-rows: subgrid를 주시면 안 됩니다. 아이템이 한 칸에 겹쳐버리는 대참사가 일어납니다! 데이터 개수를 모를 때는 세로 방향 서브그리드는 조심해서 다뤄주세요.
부모 요소에 지정된 gap, column-gap, row-gap 값들은 모두 서브그리드로 그대로 전달되어, 부모의 트랙 간격과 똑같은 여백을 서브그리드 안에도 만들어냅니다. 하지만 서브그리드 컨테이너에 직접 gap-* 속성을 다시 선언하면 이 기본 동작을 덮어쓸(override) 수 있습니다.
이 예제에서 부모 그리드는 행과 열 모두에 20px의 갭(gap)을 가지고 있지만, 서브그리드에서는 row-gap을 0으로 명시적으로 설정하여 부모의 갭 설정을 무시하고 있습니다.
<div class="grid">
<div class="item">
<div class="subitem"></div>
<div class="subitem2"></div>
</div>
</div>
* {
box-sizing: border-box;
}
.grid {
border: 2px solid #f76707;
border-radius: 5px;
background-color: #fff4e6;
}
.item {
border: 2px solid #ffa94d;
border-radius: 5px;
background-color: #ffd8a8;
color: #d9480f;
}
.subitem {
background-color: rgb(40 240 83);
}
.grid {
display: grid;
grid-template-columns: repeat(9, 1fr);
grid-template-rows: repeat(4, minmax(100px, auto));
gap: 20px; /* 부모는 20px 갭 */
}
.item {
display: grid;
grid-column: 2 / 7;
grid-row: 2 / 4;
grid-template-columns: subgrid;
grid-template-rows: subgrid;
row-gap: 0; /* 자식(서브그리드)에서 세로 갭을 0으로 덮어씀! */
}
.subitem {
grid-column: 3 / 6;
grid-row: 1 / 3;
}
.subitem2 {
background-color: rgb(0 0 0 / 0.5);
grid-column: 2;
grid-row: 1;
}
브라우저의 개발자 도구 그리드 인스펙터로 이것을 검사해보면, 서브그리드의 라인이 부모가 만든 갭의 한가운데(center)에 위치해 있는 것을 알 수 있습니다. 간격을 0으로 설정하는 것은 마치 요소에 음수 마진(negative margin)을 적용하는 것과 비슷한 효과를 내어, 원래라면 갭으로 비워져 있어야 할 여백 공간을 아이템이 다시 되찾아 쓰게 만들어 줍니다.

CSS 그리드를 사용할 때, 여러분은 그리드 라인에 이름을 붙여두고 라인 번호 대신 그 이름을 기준으로 아이템을 배치할 수 있습니다. 부모 그리드에서 만들어둔 이 라인 이름들은 서브그리드 안으로 고스란히 전달되기 때문에, 서브그리드 안에서도 그 이름들을 이용해 아이템을 배치할 수 있어요. 아래 예제에서는 부모에서 지어준 이름인 col-start와 col-end를 사용해 자식(subitem)을 배치하고 있습니다.
<div class="grid">
<div class="item">
<div class="subitem"></div>
</div>
</div>
* {
box-sizing: border-box;
}
.grid {
border: 2px solid #f76707;
border-radius: 5px;
background-color: #fff4e6;
}
.item {
border: 2px solid #ffa94d;
border-radius: 5px;
background-color: #ffd8a8;
color: #d9480f;
}
.subitem {
background-color: rgb(40 240 83);
}
.grid {
display: grid;
/* 부모에서 col-start와 col-end라는 이름을 부여했습니다 */
grid-template-columns: 1fr 1fr 1fr [col-start] 1fr 1fr 1fr [col-end] 1fr 1fr 1fr;
grid-template-rows: repeat(4, minmax(100px, auto));
gap: 20px;
}
.item {
display: grid;
grid-column: 2 / 7;
grid-row: 2 / 4;
grid-template-columns: subgrid;
grid-template-rows: subgrid;
}
.subitem {
/* 부모의 라인 이름을 자식 내부에서 그대로 사용합니다! */
grid-column: col-start / col-end;
grid-row: 1 / 3;
}
여기에 더해, 여러분은 서브그리드 자체에 독자적인 라인 이름을 새롭게 지정할 수도 있습니다. subgrid라는 키워드 바로 뒤에 대괄호([])로 묶은 라인 이름 목록을 나열하면 되죠. 예를 들어, 서브그리드에 4개의 라인이 있다면 grid-template-columns: subgrid [line1] [line2] [line3] [line4] 와 같은 문법으로 모든 라인에 새 이름을 붙여줄 수 있습니다.
서브그리드에서 명시한 이 새로운 이름들은 부모가 물려준 이름 목록에 '추가'되는 형태입니다. 따라서 부모가 준 이름, 내가 새로 만든 이름, 혹은 둘 다를 섞어서 사용할 수 있어요. 아래 예제에서는 하나의 아이템은 부모가 준 라인 이름(col-start)을 사용해서 배치하고, 다른 하나의 아이템은 서브그리드에서 자체적으로 선언한 라인 이름(sub-b)을 사용하여 배치하는 모습을 보여줍니다.
<div class="grid">
<div class="item">
<div class="subitem"></div>
<div class="subitem2"></div>
</div>
</div>
* {
box-sizing: border-box;
}
.grid {
border: 2px solid #f76707;
border-radius: 5px;
background-color: #fff4e6;
}
.item {
border: 2px solid #ffa94d;
border-radius: 5px;
background-color: #ffd8a8;
color: #d9480f;
}
.subitem {
background-color: rgb(40 240 83);
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr [col-start] 1fr 1fr 1fr [col-end] 1fr 1fr 1fr;
grid-template-rows: repeat(4, minmax(100px, auto));
gap: 20px;
}
.item {
display: grid;
grid-column: 2 / 7;
grid-row: 2 / 4;
/* 서브그리드를 씀과 동시에, 자체적인 라인 이름들을 새롭게 덧붙입니다 */
grid-template-columns: subgrid [sub-a] [sub-b] [sub-c] [sub-d] [sub-e] [sub-f];
grid-template-rows: subgrid;
}
.subitem {
/* 부모가 지어준 이름을 사용하는 아이템 */
grid-column: col-start / col-end;
grid-row: 1 / 3;
}
.subitem2 {
background-color: rgb(0 0 0 / 0.5);
/* 자식(서브그리드)이 새롭게 지어준 이름을 사용하는 아이템 */
grid-column: sub-b / sub-d;
grid-row: 1;
}
서브그리드는 사실 일반적인 중첩 그리드(nested grid)와 굉장히 유사하게 작동합니다. 유일한 차이점이 있다면, 서브그리드의 트랙 크기는 자기가 스스로 결정하는 것이 아니라 '부모 그리드'에 의해 결정된다는 것뿐이죠. 하지만 다른 중첩 그리드들과 마찬가지로, 서브그리드 안에 들어있는 콘텐츠의 크기가 커지면 (트랙 크기를 자동으로 조절할 수 있도록 auto나 min-content 등으로 유연하게 설정해 둔 경우라면) 그 크기에 맞춰 전체 트랙 사이즈가 변할 수 있습니다. 즉 크기가 자동으로 설정된 행(row) 트랙의 경우, 부모 그리드의 메인 콘텐츠뿐만 아니라 서브그리드 안에 들어있는 콘텐츠까지 모두 포용할 수 있도록 함께 커지게 됩니다.
서브그리드 값은 일반적인 중첩 그리드와 거의 똑같은 방식으로 동작하기 때문에, 이 둘 사이를 오가며 스위칭하는 것이 매우 쉽습니다. 예를 들어, 코드를 짜다가 "아, 여기엔 행 방향으로 알아서 칸이 늘어나는 암시적 그리드가 필요하겠네"라는 생각이 들면, grid-template-rows에 걸어뒀던 subgrid 값을 쓱 지워버리고, 대신 암시적 트랙의 크기를 제어하기 위해 grid-auto-rows 값을 툭 넣어주기만 하면 끝입니다.
관련 명세 정보는 CSS Grid Layout Module Level 2: subgrids 문서를 참고해 주세요.
호환성 표는 원본 문서를 참고해 주세요. (2023년 말 기준으로 Chrome, Edge, Safari, Firefox 등 모든 주요 모던 브라우저에서 subgrid를 완벽히 지원하고 있습니다!)
MDN 문서 개선에 참여하기 (Help improve MDN)
지금까지 CSS Grid의 가장 강력한 무기 중 하나인 서브그리드에 대해 알아보았습니다. 컴포넌트 단위로 쪼개어 개발하는 요즘 프론트엔드 생태계에서, 전체 페이지의 그리드 규격을 잃지 않고 컴포넌트 내부를 제어할 수 있다는 건 엄청난 이점입니다. 실습해 보시다가 막히는 부분이 있다면 언제든 강사에게 질문 남겨주세요!