블로그 개발기 15 (관리 모바일 편)
휴대폰에서 관리 화면의 스크롤과 필터, 보던 상태를 다시 정리했다
지난 글까지는 AI 활용 기록을 모으고 발행하는 흐름을 정리했다. 그동안 블로그 관리 화면은 관리 도구 편에서 붙인 구성을 PC에서 주로 다듬어 왔다. 휴대폰으로도 열리기는 했지만, 실제로 쓰려고 하면 걸리는 부분이 하나씩 보였다.
목록은 페이지 안의 높이가 정해진 상자에서 따로 스크롤했다. 필터 바는 상태와 검색이 두 줄로 놓여 390px 폭에서 154px를 차지했고, 헤더까지 더하면 화면 위쪽 4분의 1을 덮었다. 필터를 고르고 시트를 닫으면 내려 둔 목록이 맨 위로 돌아가기도 했다.
"목록을 내리려고 했는데, 왜 페이지가 같이 움직이지?"
그래서 하루를 들여 관리 화면을 휴대폰 기준으로 다시 봤다. 스크롤의 주인을 하나로 정하고, 필터가 차지하는 높이를 줄이고, 보던 조건이 새로고침과 탭 이동 뒤에도 남게 했다. 그 사이에 강제 push 뒤 배포가 실패하는 일도 한 번 있었다.
스크롤은 하나만 남겼다
목록 상자와 페이지가 같은 손짓을 나눠 가졌다
PC에서는 목록이 제 상자 안에서 스크롤하는 편이 편하다. 필터와 개수 요약, 페이지 이동이 늘 제자리에 있고 목록만 움직이기 때문이다. 휴대폰에서도 같은 구조를 유지하고 있었다.
문제는 터치였다. 페이지도 스크롤하고 그 안의 목록 상자도 스크롤하니, 같은 손짓이 어느 쪽을 움직일지가 손가락을 댄 위치에 따라 갈렸다. 목록 끝에 닿으면 관성이 상자 경계에서 끊기고 다음 손짓부터 페이지가 움직였다. AI 관리 화면은 아예 화면 높이에 고정한 틀 안에서 표만 스크롤했다.
스크롤 상자가 겹치는 구조 자체를 좁은 화면에서 없애기로 했다. 640px 이하에서는 목록 상자의 높이 제한을 풀어 페이지 하나로 스크롤한다. 그 대신 필터 바를 헤더 바로 아래에 붙여, 목록이 그 밑으로 지나간 뒤 푸터까지 이어지게 했다.
| 구분 | PC | 640px 이하 |
|---|---|---|
| 스크롤하는 곳 | 목록 상자 안 | 페이지 전체 |
| 목록 상자 높이 | 화면에 맞춰 제한 | 제한 없음 |
| 필터 바 | 목록 위 제자리 | 헤더 아래(64px)에 붙음 |
| 푸터 | 목록 상자 밖 | 목록 끝에 이어서 보임 |
PC는 기존 방식을 그대로 뒀다. 넓은 화면에서 목록 안 스크롤이 불편하다는 근거는 없었고, 같은 규칙을 양쪽에 억지로 맞출 이유도 없었다.
붙어 있는 필터 바에는 바탕이 필요했다
필터 바를 position: sticky로 붙이는 것만으로는 부족했다. 목록 행이 그 밑으로 지나가니 바탕이 비어 있으면 행의 글자가 비쳐 보였다. 바탕을 칠하되 레이아웃 폭은 바꾸지 않고 좌우 화면 끝까지 늘려야 했다.
position: sticky;
top: 64px;
z-index: 10;
background: var(--background);
/* 바탕만 좌우 화면 끝까지 늘린다. 레이아웃 폭은 그대로다 */
border-image: linear-gradient(var(--background) 0 0) fill 0 / / 0 100vmax;
sticky가 동작하려면 바로 위 부모가 목록까지 감싸는 상자여야 했다. 필터만 감싼 상자 안에서는 움직일 자리가 없어 붙지 않는다. 이 조건은 나중에 구조를 바꾸다 놓치기 쉬워서 주석으로 남겼다.
코드로 굴리던 곳은 두 경우를 함께 다뤘다
목록을 코드로 움직이는 곳이 몇 군데 있었다. 이미지 관리의 무한 로드, 조건을 바꾼 뒤 목록 첫머리로 돌아가기, 작업 기록에서 특정 행을 찾아가는 동작이다. 모두 목록 상자의 scrollTop을 기준으로 작성돼 있었다.
이제는 같은 상자가 화면 폭에 따라 스스로 스크롤하기도 하고 펼쳐지기도 한다. 그래서 상자의 계산된 overflow-y로 어느 쪽인지 판단하는 작은 모듈을 두었다.
export const scrollsInside = (box: HTMLElement) =>
getComputedStyle(box).overflowY !== "visible";
export function scrollListToTop(box: HTMLElement | null) {
if (!box) return;
if (scrollsInside(box)) { box.scrollTo({ top: 0 }); return; }
// 페이지 스크롤이면 목록 첫머리가 필터 밑에 가려졌을 때만 올린다
const hidden = coveredTop() - box.getBoundingClientRect().top;
if (hidden > 0) window.scrollBy({ top: -hidden });
}
화면 폭을 JS에서 다시 재지 않은 이유는 기준을 하나로 두고 싶어서였다. 펼칠지 말지는 CSS의 미디어 쿼리가 정하고, 코드는 그 결과만 읽는다. 페이지 스크롤에서는 맨 위가 아니라 붙어 있는 필터 바의 아래 끝까지만 올리도록 했다.
가로 스크롤 표는 머리글을 붙일 수 없었다
AI 사용량 표는 열이 많아 가로 스크롤 상자 안에 있었다. 페이지 스크롤로 바꾸고 나니 표 머리글도 필터 바 아래에 붙어야 했는데, 가로 스크롤 상자 안의 요소는 페이지 기준으로 sticky가 걸리지 않았다.
열을 줄이는 쪽을 택했다. 모바일에서는 수치 열을 한 칸에 쌓고, 도구·모델·추론 수준은 프로젝트 아래 줄로 옮겼다. 프로젝트별 표에서 주 열이던 날짜는 날짜가 바뀌는 지점의 구분 줄로 바꿨다. 상자는 overflow: clip으로 두고 머리글을 필터 바 아래 끝에 붙였다.
390px에서 표 폭이 364px로 넘치지 않는 것을 확인했다. 정보를 빼지 않고 배치만 바꾼 것이라, 넓은 화면과 공개 AI 페이지의 표는 그대로다.
화면 위쪽을 필터가 덮고 있었다
한 줄 배치를 공용으로 옮겼다
필터 바가 위에 붙고 나니 그 높이가 더 크게 느껴졌다. 스크롤해도 늘 화면에 남아 있기 때문이다. 블로그 관리의 필터는 상태 버튼 줄, 검색 줄, 요약까지 쌓여 있었다.
AI 관리에는 이미 한 줄로 줄인 배치가 있었다. 상태·검색·필터·정렬을 한 줄에 두고, 검색은 아이콘을 누르면 그 줄을 덮어 펼치는 방식이다. 이 배치를 AI 관리 안에만 두지 않고 공용 FilterBar로 옮겨 블로그 관리도 같이 쓰게 했다. 검색어가 걸린 채 검색창이 접히면 아이콘 옆에 점을 찍어 알린다.
| 화면 (390px) | 이전 | 이후 |
|---|---|---|
| 포스트·댓글 | 154px | 96px |
| 이미지 | 164px | 116px |
| AI 관리 | 102px | 102px |
AI 관리 쪽에 따로 있던 같은 코드와 CSS는 지웠다. 기간 줄임말과 표 구성 전환처럼 그 화면에만 필요한 것만 남겼다.
이미지 상태 다섯 칸은 한 줄을 다 쓰게 뒀다
이미지 관리는 상태가 전체·썸네일·본문·미사용·삭제 예정의 다섯 칸이다. 한 줄에 다 넣으려면 옆으로 밀어야 했는데, 그러면 정리할 때 가장 먼저 보고 싶은 미사용·삭제 예정 개수가 가려졌다.
그래서 이미지 화면만은 상태가 첫 줄을 다 쓰고, 둘째 줄에 개수 요약과 아이콘을 두었다. 공용 배치를 쓰되 화면의 쓰임이 다르면 줄 수를 하나 더 쓰는 편을 택했다. 그래도 164px에서 116px로 줄었다.
시트는 끌어서 닫게 했다
닫기 버튼 대신 손잡이를 뒀다
모바일에서 필터와 이미지 정보는 아래에서 올라오는 시트로 연다. 닫기 버튼이 오른쪽 위에 있었는데, 휴대폰에서는 아래로 끌어내려 닫는 쪽이 손에 익은 동작이다.
위쪽에 손잡이 막대를 두고, 막대나 제목 줄을 96px 넘게 끌어내리거나 빠르게 튕기면 닫히게 했다. 덜 끌면 제자리로 돌아간다. 막대 자체도 <제목> 닫기 버튼이라 눌러도 닫히고, 키보드는 Enter와 Escape로 닫는다. PC의 필터 팝오버는 마우스로 끌 일이 없어서 X 버튼을 그대로 두었다.
포인터를 붙잡자 탭이 닫기로 가지 않았다
빠르게 끌면 첫 이동부터 손가락이 끌기 영역 밖으로 나간다. 그래서 누르는 순간 setPointerCapture로 포인터를 붙잡았다. 그런데 이렇게 하자 끌지 않고 막대만 눌렀을 때 click이 안쪽 막대 버튼에 가지 않아 닫히지 않았다.
붙잡는 시점을 움직인 뒤로 미루면 탭은 살아나지만, 이번에는 빠른 드래그의 첫 이동을 놓쳤다. 둘 중 하나를 포기하는 대신 끌기 처리에서 직접 판단하기로 했다.
const onDragEnd = () => {
// ...
// 포인터를 붙잡으면 click 이 막대 버튼에 가지 않는다.
// 끌지 않고 막대를 누른 것은 여기서 닫는다
if (!state.moved) {
if (state.onGrabber) requestClose();
return;
}
// 96px 넘게 끌었거나 0.5px/ms 보다 빠르면 닫는다
};
버튼의 onClick은 키보드로 누른 경우(detail === 0)만 받게 했다. 포인터로 누른 것은 끌기 처리가, 키보드로 누른 것은 버튼이 맡는 식으로 나눴다.
초점을 돌려주다 페이지가 맨 위로 튀었다
목록을 한참 내린 채 필터 시트를 닫으면 페이지가 맨 위로 튀었다. 시트를 닫을 때 연 버튼으로 초점을 돌려주는데, 그 버튼이 위에 붙은 필터 바 안에 있었다.
"버튼은 화면에 보이는데, 브라우저는 왜 그 버튼을 찾아 올라가지?"
시트가 열려 있는 동안은 뒤 페이지의 스크롤을 잠근다. 그 상태에서 sticky 요소에 focus()를 하자 브라우저가 그 요소를 보이게 하려고 원래 자리, 즉 페이지 맨 위 근처로 끌어올렸다. 초점 복귀에 preventScroll: true를 주어 고쳤다. 390px에서 1500px 내린 채 시트를 닫거나 적용해도 위치가 유지되는 것을 확인했다.
보던 화면은 주소에 남겼다
새로고침하면 첫 탭으로 돌아갔다
포스트 목록은 이전부터 필터를 주소에 두고 있었다. 하지만 AI 관리와 이미지 관리는 새로고침하면 첫 탭과 기본 조건으로 돌아갔다. 뒤로 가기와 탭 이동의 동작도 화면마다 달랐다.
화면마다 따로 고치지 않고 규칙을 하나로 정했다. 탭·상태·조건·정렬·검색어는 주소에 둔다. 기본값은 주소에서 빼고, 다중 선택은 같은 이름을 반복한다. 모르는 값은 기본값으로 읽어서, 손으로 고친 주소나 옛 주소가 화면을 깨뜨리지 않게 했다.
export const IMAGE_LIST_FIELDS = {
status: { fallback: "all", allowed: ["all", "thumbnail", "body", "unused", "scheduled"] },
sort: { fallback: "newest", allowed: ["newest", "oldest", "size"] },
q: { fallback: "" },
post: { fallback: "", multi: true },
from: { fallback: "", date: true },
// ...
} satisfies Record<string, ListField>;
화면은 이 정의만 넘기면 useListState로 주소를 읽고 쓴다. 이미지 관리는 서버도 같은 주소를 읽어 첫 목록을 만든다. 통계의 인기 글 정렬과 AI 관리의 기간·보기·조건도 같은 규칙에 넣었다.
조건을 바꿀 때는 기록을 쌓지 않았다
주소에 상태를 두면 뒤로 가기가 무엇을 되돌려야 하는지 정해야 한다. 화면마다 이 부분이 달랐다. 댓글은 조건을 바꿀 때마다 push했고, 통계는 pushState로 기록을 쌓았다.
조건 변경은 기록을 쌓지 않기로 했다. 필터를 세 번 바꾼 뒤 뒤로 가기를 세 번 눌러야 관리 화면을 떠날 수 있다면, 그건 되돌리기가 아니라 같은 화면에 갇힌 것에 가깝다.
| 동작 | 기록 | 뒤로 가기의 결과 |
|---|---|---|
| 다른 화면으로 이동 | 쌓음 | 이전 화면 |
| 탭 안의 조건·정렬·검색 변경 | 바꾸기만 함 | 관리 화면을 떠남 |
| 쪽 이동 | 바꾸기만 함 | 관리 화면을 떠남 |
편집기의 본문/미리보기 탭은 이 규칙에 넣지 않았다. 글을 열면 늘 본문에서 시작하는 것이 의도였기 때문이다.
탭을 오갈 때의 기억은 sessionStorage에 뒀다
주소만으로는 한 가지가 부족했다. 이미지 탭에서 조건을 걸어 두고 댓글 탭에 다녀오면, 이미지 탭 링크는 기본 주소를 가리키고 있었다. 이전에는 포스트 목록만 returnTo로 조건을 들고 다녔다.
관리 탭마다 마지막으로 보던 주소를 sessionStorage에 기억하게 했다. 브라우저 탭 하나 안에서만 쓰는 편의라 그 범위가 맞았다. 사생활 보호 모드처럼 저장소가 막혀 있으면 기본 주소로 열린다. 기억이 실패해도 화면이 깨지지 않는 쪽이 중요했다.
발행 뒤에는 관리 목록으로 돌아오게 했다
같은 흐름에서 발행 뒤의 이동도 바꿨다. 이전에는 발행하면 게시된 글로 넘어갔고, 초안 저장은 관리 목록으로 돌아갔다. 휴대폰에서 글을 고치고 발행한 뒤 다음 일을 하려면 다시 관리 화면을 찾아가야 했다.
이제는 둘 다 관리 목록으로 돌아오고, 발행 완료 토스트에 포스트 보기 바로가기를 둔다. 바로가기가 있는 토스트는 8초 동안 남고, 마우스를 올리거나 키보드 초점이 있는 동안에는 사라지지 않게 했다. 누르려는 순간에 사라지는 링크는 없는 것보다 불편하다.
강제 push 뒤 배포가 실패했다
비교할 커밋이 사라져 있었다
이 블로그는 backup/·output/·*.md만 바뀐 push는 배포하지 않는다. Vercel의 ignoreCommand가 직전 배포 커밋과 지금 커밋을 git diff로 비교해서, 차이가 없으면 빌드를 취소한다. 배포마다 함수 번들이 쌓이고 하루 배포 횟수에도 한도가 있어서 둔 장치다.
이날 강제 push로 이력을 바꾼 뒤 배포 하나가 실패했다. 직전에 성공한 배포의 커밋이 원격에서 사라졌고, git diff가 그 커밋을 찾지 못해 bad object로 exit 128을 냈다. ignoreCommand는 0이면 건너뛰고 1이면 빌드하는데, 그 밖의 값을 Vercel은 빌드 진행이 아니라 배포 오류로 처리했다.
모를 때는 빌드하는 쪽으로 정했다
비교할 기준이 없으면 바뀐 것이 없다고 말할 수 없다. 직전 배포 커밋이 저장소에 있을 때만 비교하고, 없거나 비어 있으면 빌드하도록 바꿨다.
"ignoreCommand": "if git cat-file -e \"$VERCEL_GIT_PREVIOUS_SHA^{commit}\" 2>/dev/null; then git diff --quiet \"$VERCEL_GIT_PREVIOUS_SHA\" \"$VERCEL_GIT_COMMIT_SHA\" -- . ':!backup' ':!output' ':!*.md'; else exit 1; fi"
배포를 아끼려는 장치가 판단할 수 없는 상황에서 배포를 막는 쪽으로 실패하면 본말이 바뀐다. 건너뛰기는 확실할 때만 하고, 모를 때는 한 번 더 빌드하는 편이 싸다. 셸에서 없는 SHA와 빈 SHA는 1, 백업만 바뀐 커밋은 0, 코드 변경은 1이 나오는 것을 확인했고, 이 수정 커밋의 Vercel 배포도 성공했다.
결과
| 항목 | 이전 | 이후 |
|---|---|---|
| 모바일 목록 스크롤 | 페이지와 목록 상자가 따로 | 페이지 하나, 필터 바가 위에 붙음 |
| 필터 바 높이 (390px) | 포스트·댓글 154px, 이미지 164px | 96px, 116px |
| 하단 시트 닫기 | 오른쪽 위 X 버튼 | 손잡이 탭·끌기·튕기기, Enter·Escape |
| 시트를 닫은 뒤 위치 | 페이지 맨 위로 튐 | 내려 둔 위치 유지 |
| 새로고침 뒤 조건 | 이미지·AI 관리는 기본값으로 | 주소에서 복원 |
| 탭 이동 뒤 조건 | 포스트만 유지 | 네 탭 모두 유지 |
| 발행 뒤 이동 | 게시된 글 | 관리 목록과 토스트 바로가기 |
| 직전 배포 커밋이 없을 때 | 배포 오류 | 빌드 진행 |
각 변경은 yarn lint와 yarn tsc --noEmit을 통과했고, 주소 상태는 기존 관리 화면 검사(verify-admin-analytics·filters·ux)도 함께 통과했다. 화면은 브라우저를 390px로 줄여 확인했다. 포스트·댓글·이미지와 AI 관리 세 탭의 필터 바가 64px에 붙고 목록 끝에서 푸터가 보였으며, 이미지 무한 로드가 24장에서 48장으로 이어졌다. 1300px에서는 기존 배치가 그대로였다.
확인하지 못한 것도 많다. 실제 휴대폰의 터치로는 아직 확인하지 않았다. 시트 끌기는 마우스로 흉내 낸 결과이고, 관성 스크롤이 실제로 덜 끊기는지도 손으로 확인한 것이 아니다. 발행 뒤 이동은 모의 저장 응답으로만 확인했고, 칩이나 검색으로 필터 바 높이가 바뀔 때 표 머리글의 위치, 한글 조합 중의 주소 갱신, 양 테마에서의 토스트 모양도 보지 않았다.
느낀점
- 스크롤 상자를 겹치지 않는 것이 먼저였다. 겹친 상자에서 관성과 경계를 다듬는 것보다, 좁은 화면에서 상자 하나를 펼치는 쪽이 문제를 통째로 없앴다.
- 위에 붙는 요소는 높이가 곧 비용이었다. 같은 154px라도 스크롤하면 사라지던 때와 늘 남아 있을 때의 무게가 달랐다.
- 뒤로 가기는 화면을 떠나는 동작으로 남겼다. 조건을 바꿀 때마다 기록을 쌓으면 되돌리기가 아니라 같은 화면에 머무르게 만들었다.
- 포인터와 초점은 생각보다 많은 것을 함께 움직였다. 포인터를 붙잡자 click이 사라졌고, 초점을 돌려주자 스크롤이 움직였다.
- 판단할 수 없을 때의 기본값을 정해야 했다. 배포를 아끼는 장치도 기준 커밋이 사라지는 경우까지 정해 두지 않으면 배포를 막았다.
이번 작업은 새 기능을 붙이기보다 이미 있는 화면을 다른 기기에서 다시 써 본 기록에 가깝다. PC에서는 문제없던 구조가 터치와 좁은 폭에서는 다른 판단을 요구했다. 그 과정에서 AI 관리에만 있던 배치를 공용으로 옮기며 같은 코드가 두 곳에 있던 것도 정리됐다.
아직 실제 휴대폰에서 오래 써 본 것은 아니다. 손으로 써 보며 걸리는 부분이 생기면 그때 이어서 적으려고 한다. 읽어줘서 감사하다.
댓글