목록 화면 아래에는 대개 이런 줄이 붙어 있다. « ‹ 6 7 8 9 › ». 데스크톱에서는 이 줄이 아무 문제를 일으키지 않는다. 폭이 900px쯤 되니 번호 아홉 개에 이동 버튼 넷을 붙여도 여유롭다.
폰에서 같은 줄을 열면 사정이 달라진다. 그리고 대부분의 프로젝트가 같은 방식으로 이 문제를 처리한다 — 번호 개수를 줄인다. 데스크톱은 9개, 모바일은 4개.
이 타협을 오래 쓰다가 결국 버렸다. 번호를 줄이는 대신, 넘치면 가로로 스크롤되는 한 줄로 바꿨다. 왜 그게 나은지, 그리고 바꾸면서 실제로 걸린 것들을 정리한다.
폰에서는 버튼 크기와 페이지 범위가 같은 폭을 두고 싸운다
먼저 폭 계산부터. 흔한 안드로이드 폰의 뷰포트가 360px이고, 본문 컨테이너 좌우 여백을 12px씩 빼면 쓸 수 있는 폭은 약 336px이다.
여기에 놓을 것이 두 종류다.
- 이동 버튼 4개 — 처음 · 이전 · 다음 · 마지막
- 번호 버튼 n개
탭 타겟 권장 크기는 애플 HIG가 44×44pt, 머티리얼이 48×48dp다. 44px을 기준으로 잡으면 336px에 들어가는 칸은 일곱 개다. 이동 버튼이 넷을 먼저 가져가므로, 번호에 남는 자리는 세 칸이다.
세 칸짜리 페이징은 쓸모가 없다. 그래서 실제 코드는 대개 네 개를 쓰고, 네 개가 들어가는 이유는 버튼이 44px보다 작기 때문이다.
부트스트랩 기본 .btn은 패딩이 세로 6px, 가로 12px이다. 본문 글꼴 16px 기준으로 높이는 약 38px, 한 자리 숫자 버튼의 폭은 약 35px이 된다. 여덟 칸을 붙여도 300px 남짓이라 336px 안에 들어간다. 줄이 들어맞는 이유가 탭 타겟을 지키지 않아서인 것이다.
숫자 자릿수가 바뀌면 줄 전체가 움직인다
버튼 폭이 내용에 따라 결정되면 부작용이 하나 더 붙는다. 한 자리 숫자 버튼은 약 35px, 두 자리는 약 44px이다.
9페이지에서 다음을 누르면 번호가 7 8 9 10에서 8 9 10 11로 바뀌면서 줄 전체가 넓어진다. 가운데 정렬이면 좌우로 벌어지고, 오른쪽 끝의 다음 버튼이 손가락 아래에서 몇 픽셀 밀린다. 연속으로 페이지를 넘길 때 같은 자리를 계속 누를 수 없다.
min-width와 font-variant-numeric: tabular-nums로 막을 수 있는 문제지만, 폭을 고정하면 아까 계산으로 되돌아간다. 칸을 키우면 개수가 줄고, 개수를 지키면 칸이 작아진다. 336px 안에서는 이 둘이 서로를 잡아먹는다.
그래서 보통은 이렇게 갈라진다
부딪히는 두 요구를 화면 크기로 갈라내는 것이 관행적인 해법이다. 템플릿은 대개 이런 모양이 된다.
<?php $maxPageBtn = 9; ?>
<!-- 계산: startPage, endPage, prevPage, nextPage ... -->
<div class="btn-group d-none d-md-inline-block">
<!-- 데스크톱용 번호 9개 -->
</div>
<?php $maxPageBtn = 4; ?>
<!-- 같은 계산 한 벌 더: startPage, endPage, prevPage, nextPage ... -->
<div class="btn-group d-md-none">
<!-- 모바일용 번호 4개 -->
</div>
동작은 한다. 다만 세 가지가 딸려 온다.
첫째, 모바일에서 한 번에 4페이지씩만 움직인다. 이전·다음 버튼의 점프 폭은 보통 번호 개수와 같게 잡는다. 데스크톱에서 9씩 뛰던 것이 폰에서는 4씩 뛴다. 8페이지에서 30페이지로 가려면 여섯 번을 눌러야 한다. 폰에서 더 불편한 화면을 폰에서 더 많이 눌러야 하는 구조로 만든 것이다.
둘째, 같은 계산이 두 벌 존재한다. 시작·끝 페이지를 구하는 산술이 $maxPageBtn만 다른 채로 복사되어 있다. 페이징 규칙을 하나 고치면 두 곳을 고쳐야 하고, 한 곳만 고치면 데스크톱과 모바일이 서로 다르게 동작한다. 화면 폭에 따라 다르게 틀리는 버그는 재현하기가 특히 성가시다.
셋째, 두 벌이 전부 DOM에 들어간다. d-none과 d-md-none은 렌더링을 막는 것이 아니라 그려진 것을 숨기는 것이다. 폰으로 접속해도 데스크톱용 링크 열세 개가 문서에 그대로 실린다. 크롤러와 스크린 리더는 같은 페이지 링크를 두 벌 본다.
이 셋 중 무엇도 치명적이지 않다. 그런데 셋 다 같은 뿌리에서 나온다. $maxPageBtn이라는 값이다.
이건 페이징 정책처럼 생겼지만 사실 픽셀 결정을 개수로 적어둔 값이다. "이 화면 폭에 버튼이 몇 개 들어가는가"에 대한 답을 상수로 박아둔 것. 그러니 화면 폭이 두 종류면 값도 두 개가 되고, 값이 두 개면 계산도 두 벌이 되고, 계산이 두 벌이면 마크업도 두 벌이 된다.
질문을 바꾼다 — 몇 개가 들어가나 대신, 누가 폭을 정하나
여기서 한 발 물러서면 이상한 지점이 보인다. 왜 서버가 화면 폭을 알아야 하는가?
번호 창을 계산하는 코드는 전부 "뷰포트에 들어갈 만큼만 만들자"는 전제 위에 서 있다. 그런데 문서가 뷰포트보다 넓을 때 어떻게 할지는 브라우저가 이미 알고 있다. 넘치는 것은 스크롤하면 된다.
그러면 두 가지가 분리된다.
- 몇 개를 문서에 넣을 것인가 — 서버가 정한다. 화면 폭과 무관한, 문서 예산의 문제다.
- 몇 개가 지금 보이는가 — 사용자가 정한다. 손가락으로 민 만큼.
지금까지는 이 둘이 한 값에 묶여 있었다. 떼어내면 $maxPageBtn을 화면별로 가를 이유가 사라진다.
가로 스크롤은 표에서는 답이 아니었는데
여기서 스스로 반박이 하나 걸린다. 표를 폰에서 보여주는 문제를 다룰 때는 가로 스크롤을 가장 먼저 버렸다. 같은 손으로 페이징에는 가로 스크롤을 쓰는 것이 앞뒤가 맞는가.
맞는다고 본다. 두 콘텐츠의 가치가 놓인 자리가 다르기 때문이다.
표의 가치는 여러 열을 한눈에 비교하는 것이다. 가로 스크롤은 한 번에 두세 열만 보여주므로 정확히 그 가치를 없앤다. 이름을 보려고 왼쪽으로 밀고 금액을 보려고 오른쪽으로 밀면, 비교는 사람의 기억이 대신하게 된다.
페이징에는 비교할 것이 없다. 7페이지와 41페이지를 동시에 볼 이유가 없다. 필요한 것은 세 가지뿐이다.
- 내가 지금 어디인가
- 한 칸 옆으로
- 가끔, 아주 멀리
앞의 둘은 현재 페이지 주변만 보이면 된다. 세 번째는 스크롤로 풀 문제가 아니라 고정된 처음·마지막 버튼으로 풀 문제다. 그래서 스크롤 영역 밖에 두었다.
정리하면 이렇게 갈린다. 동시에 보는 것이 가치인 콘텐츠에 가로 스크롤은 독이고, 하나를 찾아 누르는 것이 전부인 콘텐츠에는 값이 거의 들지 않는다.
구조
마크업은 세 덩어리다. 고정 · 스크롤 · 고정.
<nav class="pager" aria-label="페이지 이동">
<a class="pager-edge" href="/list/1" aria-label="첫 페이지">«</a>
<div class="pager-track">
<a class="pager-num" href="/list/1">1</a>
<a class="pager-num" href="/list/2">2</a>
...
<a class="pager-num is-current" href="/list/7" aria-current="page">7</a>
...
<a class="pager-num" href="/list/50">50</a>
</div>
<a class="pager-edge" href="/list/50" aria-label="마지막 페이지">»</a>
</nav>
가장 큰 변화는 없어진 것에 있다. d-none도 d-md-none도 없고, 같은 블록의 두 번째 사본도 없고, 화면별 $maxPageBtn도 없다. 루프가 하나다.
데스크톱에서도 이 마크업을 그대로 쓴다. 폭이 900px이면 번호 50개는 어차피 다 안 들어가지만, 스무 개쯤 되는 목록에서는 줄이 넘치지 않아 overflow-x가 아예 발동하지 않고 평범한 페이징으로 보인다. 넘칠 때만 스크롤이 되는 것이 곧 반응형이라, 미디어 쿼리로 분기할 일이 없다.
현재 페이지도 링크로 둔다. <span>으로 바꾸면 마크업이 갈리고 뒤에 나올 가운데 정렬 코드에서 한 번 더 분기해야 하는데, 자기 페이지를 다시 눌러도 손해가 없으니 통일하는 편이 낫다. 대신 aria-current="page"를 붙인다. 스크린 리더가 현재 위치를 읽어주기도 하고, 스타일과 스크립트가 잡을 기준점도 된다.
CSS에서 실제로 걸린 것들
.pager {
display: flex;
align-items: center;
gap: 4px;
}
.pager-track {
position: relative; /* offsetLeft 기준점 */
display: flex;
gap: 4px;
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: none; /* Firefox */
}
.pager-track::-webkit-scrollbar { display: none; }
.pager-num,
.pager-edge {
flex: 0 0 auto; /* 줄어들지 않게 */
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
height: 44px;
padding: 0 8px;
font-variant-numeric: tabular-nums;
}
.pager-num.is-current { background: #131A21; color: #fff; }
flex-shrink를 막지 않으면 아무것도 넘치지 않는다
가장 먼저 걸린 곳이다. overflow-x: auto를 걸었는데 스크롤이 생기지 않았다.
플렉스 아이템의 flex-shrink 기본값은 1이다. 자리가 모자라면 아이템이 넘치는 대신 찌그러진다. 번호 50개를 336px에 밀어 넣으면 넘치는 것이 아니라 한 칸이 6px이 된다.
flex: 0 0 auto로 축소를 막아야 비로소 줄이 컨테이너를 넘고, 그때부터 overflow-x가 일을 한다. 플렉스 컨테이너에서 가로 스크롤이 안 생기면 십중팔구 이것이다.
스와이프가 뒤로가기로 새는 것
overscroll-behavior-x: contain이 없으면, 줄의 왼쪽 끝에서 한 번 더 오른쪽으로 밀었을 때 스크롤이 상위로 전파된다. iOS 사파리에서는 뒤로 가기 제스처가, 크롬 안드로이드에서는 당겨서 새로고침이나 히스토리 이동이 걸린다.
페이징 줄은 화면 폭의 대부분을 차지하는 가로 스크롤 영역이라 이 사고가 잘 난다. 목록을 넘기려다 이전 페이지로 튕겨 나가는 것은 꽤 나쁜 경험이다. 한 줄로 막아둔다.
스크롤바를 숨기면 스크롤되는 줄 모른다
스크롤바를 숨긴 것은 데스크톱에서 줄 아래에 회색 막대가 생기는 것을 막기 위해서다. 그런데 이러면 더 있다는 사실을 알릴 방법이 사라진다. iOS는 어차피 스크롤하기 전까지 스크롤바를 보여주지 않으므로, 숨기지 않아도 상황은 비슷하다.
가장 확실한 신호는 장식이 아니라 배치다. 현재 페이지를 가운데에 두면 양쪽 끝에 항상 반쯤 잘린 칸이 남는다. 잘린 칸은 "여기서 끝이 아니다"를 별도 설명 없이 전달한다.
가장자리 페이드를 더 얹을 수도 있다.
.pager-track {
-webkit-mask-image: linear-gradient(to right,
transparent 0, #000 16px, #000 calc(100% - 16px), transparent 100%);
mask-image: linear-gradient(to right,
transparent 0, #000 16px, #000 calc(100% - 16px), transparent 100%);
}
다만 이 마스크는 고정이라, 1페이지에 있어서 왼쪽에 더 볼 것이 없을 때도 왼쪽이 흐려진다. 스크롤 위치에 따라 마스크를 켜고 끄려면 스크립트가 붙는데, 그 정도 값어치는 아니라고 봤다. 반쯤 잘린 칸이 이미 일을 하고 있어서다.
scroll-snap은 넣지 않았다
scroll-snap-type: x mandatory를 먼저 시도했다가 뺐다. 손을 뗄 때마다 무조건 한 칸에 정렬되니 긴 줄을 훑을 때 걸리적거리고, 어차피 정확히 한 칸을 눌러야 하는 UI라 칸이 딱 맞춰져 있을 이유가 없다.
굳이 넣는다면 proximity다. 놓은 위치가 스냅 지점 근처일 때만 맞춰준다. 다만 없어도 아무 불편이 없었다.
현재 페이지를 가운데로 — scrollIntoView를 쓰지 않는 이유
페이지를 새로 열면 스크롤 위치는 항상 0이다. 20페이지를 보고 있는데 줄은 1부터 시작한 채로 그려진다. 현재 페이지를 가운데로 옮겨야 한다.
가장 먼저 떠오르는 것은 이쪽이다.
// 쓰지 않았다
cur.scrollIntoView({ inline: 'center', block: 'nearest' });
문제는 scrollIntoView가 스크롤 가능한 조상을 전부 거슬러 올라가며 움직인다는 점이다. 문서 자체도 그 대상에 포함된다.
페이징 줄은 목록 맨 아래에 있다. 페이지를 막 열었을 때 그 줄은 화면 밖에 있으므로, block: 'nearest'를 줘도 "가장 가까운 위치"가 곧 그 줄이 보이는 위치다. 결과적으로 로드 직후 화면이 목록 맨 아래로 훌쩍 내려간다. 사용자는 목록을 처음부터 볼 기회를 잃는다.
가로로만 움직이면 되는 일이니, 그 요소의 scrollLeft만 직접 건드린다.
document.querySelectorAll('.pager-track').forEach(function (track) {
var cur = track.querySelector('[aria-current="page"]');
if (!cur) return;
track.scrollLeft = cur.offsetLeft - (track.clientWidth - cur.offsetWidth) / 2;
});
세 가지를 짚어둔다.
하나, .pager-track에 position: relative가 반드시 있어야 한다. offsetLeft는 문서 기준이 아니라 offsetParent 기준이다. 트랙이 static이면 offsetParent는 더 위쪽의 배치된 조상이 되고, 그 조상이 왼쪽으로 얼마나 떨어져 있느냐가 계산에 섞여 들어간다. 화면 폭에 따라 엉뚱한 위치로 스크롤되는데, 원인을 찾기가 은근히 어렵다. CSS에 position: relative 한 줄이 스크립트의 전제인 것이다.
둘, 음수는 신경 쓰지 않아도 된다. 1페이지에서는 계산 결과가 음수가 되는데, scrollLeft는 유효 범위로 알아서 잘린다. 최대값도 마찬가지다. 별도 clamp가 필요 없다.
셋, scroll-behavior: smooth와 같이 쓰면 안 된다. 트랙이나 상위에 이 속성이 걸려 있으면 scrollLeft 대입도 애니메이션이 된다. 페이지를 열 때마다 페이징 줄이 스르륵 미끄러지는 것이 보인다. 처음 위치를 잡는 것은 애니메이션할 일이 아니다.
스크립트는 마크업 바로 뒤, 문서 끝에서 실행하면 충분하다. 페이지 이동이 전부 링크(전체 새로고침)라서, 이동할 때마다 새 현재 페이지 기준으로 다시 가운데가 잡힌다. 스크롤 위치를 보존하는 코드가 따로 필요하지 않다.
링크를 몇 개까지 넣을 것인가
솔직하게 쓰면, 창 계산이 완전히 사라지지는 않는다. 페이지가 5,000개인 목록에 링크 5,000개를 심을 수는 없다.
문제는 용량보다 포커스다. 링크 하나가 60바이트라면 5,000개는 300KB로 무겁긴 해도 못 볼 수준은 아니다. 그런데 그건 동시에 키보드로 탭을 5,000번 눌러야 다음 요소로 넘어가는 문서이고, 스크린 리더에게는 5,000개짜리 링크 목록이다. 페이징 하나가 페이지 전체의 탐색을 망친다.
그래서 상한을 둔다. 다만 기준이 달라졌다.
- 전 — "이 화면 폭에 몇 개가 들어가나". 기기마다 답이 다르고, 그래서 값이 여러 개가 된다.
- 후 — "문서에 링크를 몇 개까지 둘 것인가". 화면과 무관한 하나의 숫자다.
실제로는 이렇게 잡았다. 전체 페이지 수가 상한(예: 60) 이하면 전부 넣고, 넘으면 현재 페이지 앞뒤로 잘라낸다. 대부분의 목록은 상한 아래라 분기를 타지 않고, 분기 하나가 남더라도 그 값이 화면 폭과 얽히지 않는다. 계산 두 벌이 한 벌이 되고, 그 한 벌이 훨씬 헐거워진 것이다.
무한 스크롤로 가지 않은 이유
여기까지 오면 "폰에서는 그냥 무한 스크롤이나 더 보기 버튼을 쓰지" 하는 이야기가 나온다. 번호 자체를 없애는 방향이다. 그러지 않은 이유가 셋 있다.
URL에 위치가 남지 않는다. 페이지 번호가 주소에 있으면 그 화면을 북마크하고 공유하고 새로고침할 수 있다. 무한 스크롤에서 "그 목록의 그 지점"을 남에게 보내는 방법은 없다.
돌아왔을 때 위치를 잃는다. 목록 화면은 들어갔다 나오는 화면이다. 상세를 열고 뒤로 가기를 누르면 목록으로 돌아온다. 페이지 번호가 있으면 브라우저가 같은 URL을 다시 그리는 것으로 끝난다. 무한 스크롤은 스크롤 위치와 이미 불러온 항목 수를 직접 복원해야 하고, 이 복원은 자주 어긋난다.
전체 규모가 보이지 않는다. "50페이지"라는 표시는 그 자체로 정보다. 검색 조건이 너무 넓다는 신호이기도 하다. 무한 스크롤은 끝이 어디인지 알려주지 않은 채로 계속 불러온다.
번호를 없앨 이유가 있는 화면도 물론 있다. 피드처럼 끝까지 볼 생각이 없는 목록, 되돌아올 일이 없는 목록이 그렇다. 관리자 목록이나 게시판은 그 반대쪽에 있다.
남은 한계
먼 페이지로는 여전히 잘 못 간다. 47페이지로 가려면 손가락으로 한참 밀어야 한다. 가로 스크롤이 푼 것은 "칸이 작아지거나 개수가 줄거나" 하는 딜레마이지, 원거리 이동이 아니다. 원거리는 처음·마지막 버튼과 — 정말 필요하면 — 페이지 번호 직접 입력이 맡는다.
다만 이건 페이징으로 풀 문제가 아닐 가능성이 높다. 50페이지짜리 목록에서 47페이지를 번호로 찾아가는 사람은 거의 없다. 그 사람에게 필요한 것은 정렬과 검색이다. 페이징이 할 일은 그 사람을 47페이지로 데려다주는 것이 아니라, 47페이지가 존재한다는 사실을 알려서 검색을 쓰게 만드는 쪽에 가깝다.
마우스에서 가로 스크롤은 여전히 불편하다. 트랙패드는 두 손가락으로 밀면 되지만 휠 마우스는 Shift를 같이 눌러야 한다. 다행히 데스크톱은 폭이 넉넉해 웬만해선 넘치지 않는다. 넘칠 만큼 페이지가 많은 화면이라면 앞 문단의 이야기로 돌아간다.
가운데 정렬은 스크립트가 필요하다. 스크롤 위치를 CSS만으로 초기화하는 방법은 없다. 스크립트가 막히면 줄은 1페이지부터 보이는 상태로 남는다. 링크는 전부 살아 있으니 망가지지는 않지만, 현재 페이지를 보려면 직접 밀어야 한다. 감수할 만한 열화라고 봤다.
정리
바꾸고 나서 남은 것은 이 한 문장이다.
화면에 몇 개가 들어가는지를 서버가 계산하고 있었다면, 그 계산은 대개 지울 수 있다.
$maxPageBtn = 4 같은 상수는 페이징 정책처럼 생겼지만 실은 픽셀 계산의 결과물이다. 이런 값이 코드에 박히면 화면 종류만큼 값이 늘고, 값이 늘면 계산이 복사되고, 계산이 복사되면 마크업이 복사된다. 미디어 쿼리로 분기한 두 벌짜리 컴포넌트는 대개 이 경로로 만들어진다.
넘침을 허용하면 그 사슬이 첫 칸에서 끊어진다. 얼마나 보일지는 브라우저와 사용자가 정하고, 서버는 무엇을 문서에 넣을지만 정한다. 페이징에서는 그 대가가 거의 없었다 — 애초에 여러 페이지 번호를 한눈에 비교할 일이 없기 때문이다.
같은 판단을 표에 적용하면 반대 결론이 나온다. 그쪽은 한눈에 비교하는 것이 표의 전부라, 넘침을 허용하는 순간 잃는 것이 생긴다. 가로 스크롤을 쓸지 말지는 취향이 아니라, 그 콘텐츠의 가치가 동시성에 걸려 있는지로 갈린다.