관리자 화면의 목록 표는 열이 많다. 프로젝트 목록 하나에도 구분·이름·업체·기간·진행률·담당·코드·등록일이 붙는다. 데스크톱에서는 아무 문제가 없다. 표는 원래 여러 값을 한눈에 늘어놓으라고 있는 물건이니까.
그런데 폰에서 열어보면 무너진다. 폭 393pt 안에 열 여덟 개를 넣을 방법은 없다.
가로 스크롤은 표를 포기하는 것이다
가장 손쉬운 답은 표를 그대로 두고 overflow-x:auto로 감싸는 것이다. 구현에 1분이 걸린다. 그리고 대개 아무도 그 표를 다시 보지 않는다.
한 화면에 두세 열만 보이기 때문이다. 표의 가치는 여러 열을 한눈에 비교하는 것인데, 가로 스크롤은 정확히 그 가치를 없앤다. 프로젝트 이름을 보려고 왼쪽으로 밀고, 진행률을 보려고 오른쪽으로 밀고, 다시 이름을 확인하려고 왼쪽으로 민다. 사람이 스크롤 위치를 기억하며 좌우로 왕복해야 한다.
카드형은 세로로 폭발한다
두 번째로 흔한 답은 표를 카드처럼 푸는 것이다. tr과 td를 display:block으로 바꾸고, 각 칸에 열 제목을 라벨로 붙인다.
td::before {
content: attr(data-label);
}
널리 쓰이는 방법이고, 라벨이 붙으니 무슨 값인지는 확실히 알 수 있다. 대신 두 가지를 지불한다.
첫째, 셀마다 data-label을 박아야 한다. 열 여덟 개짜리 표에 행이 스무 개면 속성 160개다. 열 제목을 바꾸면 그 160개를 같이 고쳐야 한다.
둘째, 그리고 이쪽이 더 치명적인데, 한 행이 열 개수만큼 세로로 늘어난다. 여덟 열이면 한 행이 여덟 줄이다. 스무 행짜리 목록이 160줄이 된다. 가로 스크롤을 없앤 대가로 세로 스크롤이 폭발한다. 목록을 훑어보는 일 자체가 불가능해진다.
라벨이 필요한 칸은 생각보다 적다
여기서 한 발 물러서서 보면, 카드형이 지불하는 비용의 상당 부분은 필요하지도 않은 라벨 때문이다.
프로젝트 이름 칸에 든 값은 라벨 없이도 프로젝트 이름인 줄 안다. 업체명도, ~03/31 같은 날짜도, 초록색 배지도 마찬가지다. 생김새와 위치가 이미 의미를 말하고 있다. 라벨이 정말 필요한 건 맥락 없이 숫자만 남는 칸 — 진행률, 이슈 건수, 방치일수 같은 몇 개뿐이다.
그렇다면 표를 세로로 풀어헤칠 게 아니라, 한 행을 문단 한 덩어리로 보고 칸들을 낱말처럼 이어 붙이면 어떨까. 문단에서는 읽는 순서가 곧 의미다. 열 제목은 감춘다.
첫 시도: inline-block으로 흘리기
이 아이디어의 가장 단순한 구현은 tr을 display:block으로 두고 td를 inline 또는 inline-block으로 만드는 것이다. 실제로 한동안 이렇게 썼다. 칸들이 줄글처럼 이어지고 폭이 모자라면 알아서 다음 줄로 넘어간다. 방향은 맞았다.
그런데 실제 화면에 적용해 나가다 보면 손은 계속 가는데 원하는 대로 안 되는 지점들이 나온다. 세 가지였다.
1. 칸 순서를 바꿀 수 없다
데스크톱 표의 열 순서와 폰에서 읽기 좋은 순서는 다르다. 표는 "코드 · 구분 · 이름 · 업체 · 기간 · 진행률 · 담당" 순인데, 폰에서는 구분 배지가 먼저 오고 이름이 크게 나오는 편이 자연스럽다.
그런데 인라인 흐름은 DOM 순서를 그대로 따른다. CSS로 순서를 건드릴 방법이 없다. 마크업의 td 순서를 바꾸면 데스크톱 표의 열 순서가 같이 바뀐다. 결국 화면마다 타협하거나, 같은 값을 두 번 출력하고 하나씩 숨기게 된다.
2. 특정 칸만 오른쪽 끝으로 밀 수 없다
"이름은 왼쪽, 진행률은 줄 오른쪽 끝"처럼 붙이고 싶은데 방법이 마땅치 않다.
text-align:right는 부모에 거는 속성이라 그 줄 전체가 오른쪽으로 간다.float:right는 요소를 흐름에서 빼버려서 줄 높이 계산이 어긋난다.margin-left:auto는 인라인 레벨 요소에서 0으로 계산된다. 아무 일도 일어나지 않는다.
3. 열 폭을 맞출 수 없다
inline-block에 width를 주는 것 자체는 된다. 문제는 그 칸들을 여러 행에 걸쳐 세로로 정렬할 방법이 없다는 것이다. 오른쪽으로 못 미니 숫자 칸이 앞 칸 길이에 따라 행마다 다른 위치에서 시작한다.
여기에 인라인 특유의 성가심이 얹힌다. 인라인 요소 사이의 마크업 개행과 들여쓰기가 공백 문자로 렌더링된다. 칸 사이 간격이 Blade 템플릿을 어떻게 줄바꿈했느냐에 따라 달라진다. font-size:0 같은 편법을 쓰게 되고, 간격을 margin으로 주면 줄 끝에도 남아 오른쪽 여백이 어긋난다.
해법: tr을 flex로
세 가지가 한 줄로 풀린다. tr을 flex 컨테이너로 만드는 것이다.
@include media-breakpoint-down(md) {
.table.table-flow {
display: block;
thead { display: none; }
tbody { display: block; }
tr {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: .25rem;
row-gap: 2px;
padding: .5rem;
border-bottom: 1px solid var(--bs-border-color);
}
td {
border: 0;
order: 90;
}
}
}
중요한 건 보이는 결과가 inline-block 시절과 같다는 점이다. flex 아이템이 되면 td의 display 값은 무시되고 블록화되지만, 내용 폭만큼만 차지하므로 결국 inline-block을 늘어놓은 것과 같은 그림이 나온다. 잃는 것 없이 능력만 얻는다.
그리고 막혔던 세 가지가 각각 한 줄이 된다.
.flow-3 { order: 3; } /* 순서 */
.flow-end { margin-left: auto; } /* 오른쪽 끝으로 */
.flow-block { flex: 0 0 100%; } /* 한 줄 독차지 */
.flow-hide { display: none; } /* 모바일에서 감춤 */
margin-left:auto는 flex에서 남은 자리를 통째로 먹는다. 그래서 그 칸이 오른쪽 끝까지 밀려나고, 뒤따르는 칸들도 같이 밀려가 오른쪽에 모인다. 부트스트랩의 ms-auto와 같은 원리다.
간격도 column-gap이 맡는다. margin과 달리 줄 끝에는 남지 않고, 마크업 개행의 영향도 받지 않는다. 인라인 시절의 공백 문자 성가심이 통째로 사라진다.
order 기본값을 90으로 두는 이유
작은 함정이 하나 있다. flex의 order 기본값은 0이다. 그래서 td를 그냥 두면 번호를 안 매긴 칸이 .flow-3보다 앞에 온다. 순서를 지정한 칸만 앞으로 나오게 하려면 기본값을 뒤로 밀어둬야 한다.
td { order: 90; } /* 안 매긴 칸은 뒤로 */
@for $i from 1 through 12 {
.flow-#{$i} { order: $i; }
}
실제 마크업은 이렇게 된다. 데스크톱 표의 열 순서는 그대로 두고, 폰에서 보일 순서만 클래스로 지정한다.
<table class="table table-flow">
<tr>
<td class="flow-hide">PRJ-1043</td> <!-- 폰에선 감춤 -->
<td class="flow-1"><span class="badge">유지보수</span></td>
<td class="flow-2 flow-block">D시청 홈페이지 개편</td> <!-- 한 줄 독차지 -->
<td class="flow-3">A업체</td>
<td class="flow-4 flow-end">72%</td> <!-- 오른쪽 끝 -->
</tr>
</table>
td 순서는 데스크톱 표 그대로 두고, 폰에서 보일 순서와 위치만 클래스로 지정한다.숫자만 남는 칸에는 라벨을 붙인다
열 제목을 감췄으니, 앞서 말한 "맥락 없는 숫자 칸"은 여전히 문제다. 72가 진행률인지 이슈 건수인지 알 수 없다.
여기서 카드형이 하던 대로 셀마다 data-label을 박으면 같은 비용을 다시 치른다. 대신 열 제목이 이미 있는 곳 — th — 에 한 번만 선언하게 했다.
<th class="flow-label">열린이슈</th>
<th class="flow-label" data-flow-label="방치">방치일수</th> <!-- 문구가 다를 때 -->
스크립트가 thead를 한 번 읽어서 커스텀 속성을 <table>에 심고, CSS가 그걸 읽어 그린다.
// JS — 열 개수만큼만 값을 심는다
table.style.setProperty('--flow-label-' + col, JSON.stringify(text));
// CSS
@for $i from 1 through 12 {
td:nth-child(#{$i}):not([colspan])::before {
content: var(--flow-label-#{$i});
margin-right: 3px;
color: var(--bs-secondary-color);
font-size: var(--bs-font-size-xs);
}
}
라벨을 셀이 아니라 표에 심는 것이 요령이다. 세 가지가 따라온다.
- DOM 노드가 하나도 안 늘어난다. 행이 몇 개든 심는 값은 열 개수만큼이다.
- 검색·페이징으로
tbody를 갈아끼워도 살아남는다. 셀에 박아두는 방식은 다시 그릴 때마다 재실행해야 하고, 한 번 놓치면 라벨이 사라진다. - 데스크톱에서는 CSS가 아예 안 그린다. 숨길 노드 자체가 없다.
여기서 var()에 폴백을 일부러 주지 않은 게 포인트다. 커스텀 속성이 없으면 var()가 무효가 되고 content는 초기값 normal로 떨어져 의사요소 자체가 생기지 않는다. 폴백으로 ""를 주면 빈 상자가 생겨서 margin-right만 남는다. 라벨 없는 칸마다 3px씩 어긋난다.
오른쪽에 모은 숫자를 세로로 맞추기
.flow-end로 오른쪽에 모아 붙인 숫자 칸은 값 길이가 행마다 달라서 열이 어긋나 보인다. 오른쪽 끝은 맞는데 그 왼쪽이 들쭉날쭉하다.
그 열만 폭을 고정하면 맞는다. 라벨과 같은 경로로 th에서 선언한다.
<th class="flow-label" data-flow-width="3.6rem">사용</th>
tr td:nth-child(#{$i}) {
width: var(--flow-width-#{$i}, auto) !important;
min-width: var(--flow-width-#{$i}, 0);
display: var(--flow-display-#{$i}, block);
justify-content: space-between;
align-items: baseline;
}
세 가지 디테일이 들어 있다.
min-width를 같이 주는 게 핵심이다. width만 주면 flex가 자리 모자랄 때 고정 폭을 깎아버려서 정렬이 도로 깨진다. flex 아이템의 기본 min-width는 auto지만, 축소 자체는 flex-shrink로 일어나기 때문에 하한을 명시해야 한다.
칸째로 오른쪽 정렬하지 않는다. text-align:right를 걸면 값은 오른쪽에 맞지만 라벨이 값 길이만큼 밀린다. 오른쪽 끝만 맞고 왼쪽 라벨 줄이 들쭉날쭉해진다. 대신 그 칸을 flex로 만들어 space-between으로 라벨은 왼쪽, 값은 오른쪽에 각각 붙인다.
display를 커스텀 속성으로 받는 이유가 여기서 나온다. 폭을 지정한 열만 flex로 만들어야 하는데, CSS로는 "특정 커스텀 속성이 설정된 요소"를 선택할 수 없다. 그래서 스크립트가 폭을 심을 때 --flow-display-N: flex도 같이 심는다. 안 심은 칸은 block(flex 아이템의 기본값)이라 그대로다.
프레임워크와 부딪힌 두 곳
!important 끼리는 명시도로 갈린다
칸 여백은 tr이 일괄로 주므로 td는 0이어야 한다. 그런데 쓰고 있던 테마가 tr td:first-child / :last-child에 !important로 좌우 여백을 박아두고 있었다.
!important가 붙으면 무조건 이긴다고 생각하기 쉬운데, !important끼리 부딪히면 다시 명시도로 갈린다. 저쪽이 (0,2,2)라 .table.table-flow td(0,2,1)로는 진다.
/* tr 을 끼우고 :first-child / :last-child 까지 그대로 받아 적어 (0,3,2) 로 이긴다 */
tr td,
tr td:first-child,
tr td:last-child {
padding: 0 !important;
}
행 배경이 조각조각 끊긴다
부트스트랩은 .table-active 같은 상태 배경을 칸마다 inset box-shadow로 칠한다. 격자에서는 칸이 열 폭을 꽉 채우니 그게 곧 행 배경이 된다.
그런데 문단에서는 칸이 내용 폭만큼만 차지한다. 그래서 배경이 낱말마다 조각조각 끊겨 형광펜으로 띄엄띄엄 그은 것처럼 보인다. 문단에서 행은 한 덩어리이므로, tr이 칠하고 칸은 칠하지 않게 바꾼다.
tr { background-color: var(--bs-table-bg-state, transparent); }
tr td { box-shadow: none; }
남은 한계
- 임의 위치에서 줄바꿈할 수 없다.
.flow-block으로 한 줄을 독차지시키는 것만 된다. flex에는 "여기서부터 새 줄, 단 뒤엣것들은 서로 붙여서"를 나타내는 방법이 없다 — 빈 칸을 하나 끼워 넣어야 하는데td를 늘릴 수는 없다. - 머리글이 두 줄인 표는 지원하지 않는다. 스크립트가
thead tr중 첫 줄만 읽는다.colspan이 섞이면 열 위치가 어긋나기 때문이다. - 열 12개까지만 다룬다.
@for루프의 상한이고, 늘리면 CSS도 그만큼 커진다.
언제 쓰고, 언제 쓰지 않나
목록형 표에 맞는다. 각 행이 하나의 개체(프로젝트, 사이트, 이슈)이고, 사용자가 하는 일이 "쭉 훑어보고 하나를 고르는" 것인 경우다. 이런 표에서 열은 개체의 속성일 뿐이라 문단으로 흘려도 의미가 상하지 않는다.
수치 비교표에는 맞지 않는다. 월별 매출처럼 열 사이의 비교가 목적인 표는 격자 구조 자체가 정보다. 이런 표는 차라리 가로 스크롤을 두거나, 폰에서는 차트로 바꿔주는 편이 낫다.
정리하면 이렇다. 모바일 대응에서 표를 만나면 선택지가 "가로로 밀기"와 "세로로 풀기" 둘뿐인 것처럼 보인다. 하지만 목록형 표라면 세 번째 길이 있다. 표의 격자는 데스크톱에서 여러 값을 나란히 보여주기 위한 장치이고, 화면이 좁아지면 그 격자 대신 내용을 문단으로 흘려보내는 편이 읽기 편할 때가 많다.
물론 격자 자체가 정보인 표도 있다. 어느 쪽인지는 그 표가 화면에서 무슨 일을 하는지 보고 정하면 된다. 적어도 선택지가 둘뿐은 아니다.