최근 안드로이드 공식 블로그를 확인하다가, Compose Foundation에 CSS Grid를 닮은 새로운 Grid API가 추가됐다는 소식을 알게 되었다. 지금까지 Compose에서 격자 형태 레이아웃을 짜려면 LazyVerticalGrid나 직접 Layout을 구현하는 방법밖에 없었는데, 트랙 기반으로 2차원 배치를 선언할 수 있다는 점이 흥미로워서 직접 하나씩 만들어보며 정리해보고자 한다.
Grid API란
CSS Grid에서 착안한 2차원 레이아웃이다. 열(column)과 행(row) 트랙을 먼저 선언하고, 자식을 그 격자에 배치하는 방식이다. 다만 LazyVerticalGrid와 이름이 비슷해서 헷갈리기 쉬운데, Grid는 스크롤 컨테이너가 아니라 일반 레이아웃이다. 자식이 화면 밖으로 나가도 전부 컴포즈된다는 점을 먼저 기억해둘 필요가 있다.
@OptIn(ExperimentalGridApi::class)
Grid(
config = {
column(72.dp) // 고정
column(1.fr) // 남은 공간 분배
row(GridTrackSize.Auto)
gap(8.dp)
}
) {
Box(Modifier.gridItem(row = 0, column = 0))
}
2026년 4월 기준 Compose 1.11.0-alpha04에서 도입되어 foundation-layout 1.11.1에서 정식으로 쓸 수 있다. 아직 실험 API라 모든 곳에 @OptIn(ExperimentalGridApi::class)가 필요하다.
트랙 크기 6종 + minmax
필자가 처음 헷갈렸던 부분이 바로 이 크기 해석 순서였다. Grid는 ① 고정 크기(Fixed) → ② 콘텐츠 기반(Auto/MinContent/MaxContent)·비율(Percentage) 순으로 먼저 자리를 떼어주고, ③ 그러고도 남은 공간만 Flex(fr) 트랙이 나눠 갖는다.
Grid(
config = {
column(GridTrackSize.Fixed(64.dp)) // 항상 64dp
column(GridTrackSize.Percentage(0.25f)) // 컨테이너 폭의 25%
column(GridTrackSize.Flex(1.fr)) // 남은 공간 분배
column(GridTrackSize.Auto) // 콘텐츠 크기에 맞춤
row(GridTrackSize.Auto)
gap(6.dp)
}
) {
// ...
}
컨테이너 폭을 줄여가면서 확인해보면, 고정 트랙과 비율 트랙은 자기 몫을 그대로 지키고 fr 트랙부터 먼저 줄어드는 걸 볼 수 있다. Row의 weight와 비슷한 개념이지만 "트랙" 단위로 붙는다는 게 차이다.
MinContent와 MaxContent도 재밌다. 같은 문장을 넣어도 MinContent는 줄바꿈을 최대한 해서 가장 긴 단어 폭까지 줄이고, MaxContent는 줄바꿈 없이 한 줄로 펼친 폭을 그대로 요구한다. 최소 크기를 보장하면서 남으면 확장하고 싶으면 minmax(40.dp,
1.fr)처럼 조합하면 된다.
fr 비율과 gap의 관계
gap도 크기 계산에 관여한다는 걸 처음엔 놓쳤었다. gap은 남은 공간을 계산하기 전에 먼저 차감되기 때문에, 간격을 키우면 그만큼 fr 트랙이 줄어든다.
Grid(
config = {
column(firstWeight.fr)
column(secondWeight.fr)
column(1.fr)
row(GridTrackSize.Auto)
columnGap(columnGapDp.dp)
rowGap(rowGapDp.dp)
}
) { /* ... */ }
columnGap·rowGap을 슬라이더로 조절해보면, fr 트랙끼리의 비율(1fr:2fr:3fr)은 유지되면서도 전체 셀 크기는 gap이 커질수록 함께 줄어드는 걸 확인할 수 있다.
배치: 자동 배치와 명시 배치
Modifier를 따로 주지 않으면 배치 커서가 순서대로 셀을 채운다. 특정 위치를 지정하고 싶으면 Modifier.gridItem으로 좌표와 span을 직접 준다.
// 헤더: 0행을 3칸 모두 차지
Header(Modifier.gridItem(row = 0, column = 0, columnSpan = 3))
// 사이드바: IntRange 오버로드도 있다
Side(Modifier.gridItem(rows = 1..2, columns = 0..0))
// 본문: 1행의 1~2열
Body(Modifier.gridItem(row = 1, column = 1, columnSpan = 2))
여기서 함정을 하나 발견했는데, 명시 배치는 좌표 충돌을 검증해주지 않는다는 점이다. 두 아이템이 같은 좌표를 요구하면 예외를 던지는 게 아니라, 그냥 오류 없이 그대로 포개져서 나중에 배치된 쪽이 위에 그려진다. 직접 좌표를 관리하는 순간부터 겹침 검증은 온전히 개발자 몫이 되는 셈이다. 예제에 토글을 하나 넣어서 일부러 좌표를 겹치게 해봤는데, 정말 아무 경고 없이 조용히 포개져서 살짝 당황했다.
좌표를 지정하지 않은 아이템의 진행 방향은 GridFlow로 정한다. GridFlow.Row는 한 행을 다 채우고 다음 행으로, GridFlow.Column은 한 열을 다 채우고 다음 열로 넘어간다.
Grid는 lazy가 아니다 — 직접 실측해보기
가장 중요하게 짚고 싶은 부분이다. Grid와 LazyVerticalGrid는 이름이 비슷해서 같은 성격일 거라 오해하기 쉬운데, 실제로 살아있는 자식 컴포지션 수를 세보면 완전히 다르게 동작한다.
// DisposableEffect로 컴포지션 진입/폐기를 카운트
@Composable
private fun CountedCell(index: Int, onAliveChange: (Int) -> Unit) {
DisposableEffect(Unit) {
onAliveChange(1)
onDispose { onAliveChange(-1) }
} // ...
}
같은 60개 셀을 Grid(verticalScroll)와 LazyVerticalGrid에 각각 넣고 스크롤해보면, Grid 쪽은 스크롤 여부와 무관하게 60개가 항상 살아있는 반면 LazyVerticalGrid는 화면에 보이는 만큼만 유지하다가 스크롤에 따라 증감한다. 판별 기준은 단순하다. 셀 개수가 데이터 양에 비례해서 늘어난다면 Grid가 아니라 Lazy 계열을 써야 한다. Grid는 헤더-사이드바-본문처럼 개수가 고정된 대시보드형 레이아웃에 맞는 API다.
정리
- 트랙을 먼저 선언(
column/row)하고 자식을 배치한다. - 크기 해석 순서: 고정·콘텐츠·비율 트랙이 먼저, 남은 공간을
fr이 분배한다. gap은 남은 공간 계산 전에 먼저 차감된다.- 배치는
gridItem(row, column, span)또는IntRange오버로드로. - 좌표 충돌은 오류가 아니라 포개짐 — 검증은 개발자 몫이다.
- non-lazy이므로 모든 자식이 컴포즈된다 — 대량 데이터에는 부적합하다.
참고로 이름 붙인 영역(area() / gridItem(area)) 같은 CSS Grid의 grid-template-areas에 해당하는 기능은 Compose 1.12.0-beta01에 새로 추가된 것이라, 이번 글에서 다룬 1.11.1 기준에는 아직 없다. 1.12가 정식 승격되면 그 부분도 따로 다뤄볼 생각이다.
이번에는 Compose 1.11의 새로운 Grid API에 대하여 작성해 보았다. 아직 실험 API 단계라 실무에 바로 쓰기엔 이르지만, CSS Grid에 익숙한 입장에서는 개념이 크게 낯설지 않아서 정식 릴리즈가 기대된다. 다음에는 같은 1.11에 함께 추가된 MediaQuery API도 이어서 정리해볼 예정이다.
해당 게시글에 사용한 예제는 다음 Github에 올려두었다.
https://github.com/HeeGyeong/ComposeSample
GitHub - HeeGyeong/ComposeSample: This project provides various examples needed to actually use Jetpack Compose.
This project provides various examples needed to actually use Jetpack Compose. - HeeGyeong/ComposeSample
github.com