블로그 개발기 16 (관리 다듬기 편)
관리 화면의 머리줄·통계·헤더를 같은 기준으로 다시 맞췄다
지난 글에서는 관리 화면을 휴대폰에서 쓸 수 있게 스크롤과 필터를 정리했다. 그 뒤로 관리 화면을 매일 열어 쓰다 보니, 이번에는 기능이 아니라 화면끼리 어긋난 부분이 눈에 걸렸다.
블로그 관리와 AI 관리는 같은 관리 화면인데 제목 크기와 탭 모양, 버튼 위치가 달랐다. 목록은 화면 밖에서 바뀐 내용을 반영하지 못해서 매번 브라우저를 새로고침했다. 통계 탭은 스크롤을 내리면 기간을 바꾸러 다시 맨 위로 올라가야 했고, 320px 폭에서는 페이지 전체가 조금 축소된 채로 보였다.
"같은 관리 화면인데, 왜 화면마다 버튼 높이가 다르지?"
하나씩은 작은 문제였다. 다만 고칠 때마다 기준을 따로 정하면 다음 화면에서 또 어긋날 것 같았다. 그래서 이번에는 관리 화면 전체를 같은 기준으로 맞추는 데 하루를 썼다. 커밋은 10개였다.
두 관리 화면이 머리줄 하나를 나눠 쓰게 했다
목록은 스스로 새로 고쳐지지 않았다
관리 목록이 알아서 갱신된다고 생각했던 적이 있다. 내가 화면 안에서 바꾼 것은 곧바로 반영되니 그렇게 보였다. 하지만 다른 사람이 단 댓글이나 다른 탭에서 올린 이미지는 목록이 알 방법이 없다. 결국 F5를 누르는 습관이 생겼고, 그 습관이 문제를 말해주고 있었다.
새로고침 버튼을 공용으로 만들었다. 다만 목록마다 데이터를 받는 방식이 달라서 버튼이 할 일도 달라야 했다.
| 목록 | 데이터를 받는 곳 | 새로고침이 하는 일 |
|---|---|---|
| 포스트·댓글 | 서버 컴포넌트 | router.refresh() |
| 이미지·통계 | 화면이 직접 받은 쿼리 | 그 쿼리의 다시 받기 |
| AI 관리 | 화면이 직접 받은 쿼리 | query.refetch() |
router.refresh()만 걸면 이미지와 통계 탭에서는 버튼을 눌러도 아무것도 바뀌지 않는다. 그래서 공통 머리줄에는 버튼만 두고, 각 탭이 자기 다시 받기 함수를 등록하게 했다. 등록한 탭이 없으면 서버 새로고침이 기본으로 동작한다.
export function useAdminRefresh(run: () => void, busy: boolean) {
const register = useContext(Registry);
useEffect(() => { register({ run, busy }); }, [register, run, busy]);
useEffect(() => () => register(null), [register]);
}
버튼은 탭이 아니라 제목 글자에 맞췄다
블로그 관리는 제목 아래에 탭 줄이 있었고, AI 관리는 제목과 탭이 한 줄이었다. AI 관리의 한 줄 배치가 공간을 덜 썼기 때문에 그쪽으로 맞추고, 제목 크기는 블로그 관리를 따랐다. 제목·탭·버튼을 그리는 AdminHeading과 탭 AdminTabs를 만들어 두 화면이 같은 컴포넌트를 쓰게 했다. 블로그 관리 탭은 주소가 바뀌니 링크로, AI 관리 탭은 한 화면 안에서 내용만 바뀌니 role="tab" 버튼으로 그리되 선택 표시는 같은 속성 하나로 맞췄다.
가장 오래 걸린 것은 버튼의 세로 위치였다. 처음에는 줄 아래쪽에 맞췄는데, 탭이 밑줄에 붙어야 해서 그 기준을 따르면 버튼이 바닥에 붙어 보였다. 8px 띄우자 이번에는 제목보다 떠 보였다. 결국 눈으로 맞추는 대신 숫자를 쟀다. 버튼의 세로 가운데를 제목 글자의 세로 가운데에 맞추기로 했다. 밑줄에서 제목 글자 가운데까지가 23.5px이었고, 버튼 아래 여백을 3.5px로 줄이자 버튼 가운데도 23.5px이 됐다.
같은 줄의 "새 글 쓰기" 버튼은 글자 크기와 여백만큼만 높이를 가져서 새로고침 버튼(38px)보다 낮았다. 머리줄 오른쪽에 놓이는 버튼은 높이를 하나로 정했다. 링크로 감싼 버튼은 링크가 줄 높이를 더하지 않도록 링크를 flex 상자로 두었다.
수집 상태 표는 주가 늘어도 버티게 했다
주 이름을 나열하는 설명은 데이터와 같이 자랐다
AI 관리의 수집 상태 표는 주마다 한 줄이다. 어떤 주는 일부만 관측됐고, 이번 주는 아직 진행 중이며, 아예 관측되지 않은 주도 있다. 처음에는 이 상태를 주 칸에 "부분 관측 · 진행 중"처럼 글로 적었다. 그러자 첫 열이 넓어지거나 가운데 정렬한 날짜가 행마다 밀렸다.
글을 표 위로 옮겨 "부분 관측: 9/1, 9/8…"처럼 주를 나열하는 방법도 해봤다. 지금은 몇 줄이면 끝나지만 1년이 지나면 그 설명이 표보다 길어진다. 데이터가 늘어도 화면이 그대로 동작해야 한다는 기준을 이때 분명히 세웠다.
"주가 50개가 되면 이 설명은 몇 줄이 되지?"
그래서 상태는 날짜 옆의 작은 아이콘으로만 표시하고, 표 위에는 아이콘 세 개의 뜻만 늘 같은 자리에 두었다. 몇 주가 해당하는지는 세지 않는다. 아이콘은 날짜 오른쪽에 절대 위치로 붙여서 아이콘이 있는 행과 없는 행의 날짜가 같은 가운데에 놓이게 했다.
아이콘은 같은 형식으로 묶었다
아이콘 모양은 여러 번 바꿨다. 노란 삼각형은 경고처럼 보였고, 테두리만 있는 아이콘은 라이트 테마에서 너무 흐렸다. 꽉 찬 점은 무엇을 뜻하는지 읽히지 않았다. 최종적으로는 세 아이콘을 같은 형식으로 묶었다. 색을 채운 원 위에 바탕색 기호를 파낸다.
| 상태 | 원 색 | 파낸 기호 |
|---|---|---|
| 부분 관측 | 앰버 | 왼쪽 반원 |
| 진행 중 | 파랑 | 시곗바늘 |
| 미관측 | 회색 | 빼기 |
앰버와 파랑은 기존 토큰에 없어서 --warnicon과 --progresscolor를 라이트와 다크에 한 벌씩 정의했다. 넓은 화면에서는 표가 26rem 안에서 스크롤하고 머리글이 위에 붙는다. 열 폭은 주 칸을 내용만큼(1%), 숫자 일곱 칸을 14%씩 잡아 좌우 여백이 같게 했다.
통계 화면을 처음부터 다시 읽었다
모든 행에 붙은 배지는 정보가 아니었다
글별 성과 표에는 행마다 "발행" 배지가 있었다. 거의 모든 글이 발행 상태라서 배지는 자리만 차지했다. 초안으로 돌린 글만 표시하는 방법도 고민했지만, 배지를 완전히 빼는 쪽을 골랐다. 배지를 그리던 status 값은 다른 곳에서 쓰지 않아서 조회 SQL과 타입에서도 뺐다.
배지가 빠지자 제목 칸이 크게 비었다. 이미 받아 오던 데이터 안에서 채울 것을 찾았다. 제목 아래에는 태그를 두고, 조회수 옆에는 기간 전체 조회 대비 비중을 붙였다. 비중 막대도 함께 넣었다가 뺐다. 숫자 하나로 충분했고, 모바일 카드에서는 막대가 줄만 늘렸다.
모바일 카드는 2행 2열로 정리했다. 제목 옆에 조회수, 태그 옆에 조회 일수가 오고, 오른쪽 두 값은 라벨 폭을 맞춰 같은 세로선에서 시작한다. 제목 칸이 제목과 태그를 한 상자에 담고 있어서 그대로는 두 행으로 나눌 수 없었다. 그 칸을 display: contents로 풀어 제목과 태그가 각자 그리드 칸을 차지하게 했다.
표는 10개인데 문장은 5개를 말했다
조회된 글이 늘면 표도 계속 길어지는지 확인하려고 코드를 다시 봤다. 표는 정렬 기준의 상위 10개만 보여주는데, 그 사실은 화면 읽기 도구용 설명에만 있었다. 표 위 문장은 "조회 상위 5개 글이 전체의 X%"라고 말하고 있었다. 표와 문장이 서로 다른 개수를 기준으로 삼고 있었던 셈이다.
개수를 TOP_POSTS = 10 하나로 묶었다. 표, 문장, 도움말, 표 설명이 모두 이 값을 쓴다. 검증 스크립트도 글이 11개일 때 표에 10개가 나오고 비중도 그 10개로 계산되는지 확인하도록 바꿨다.
필터는 새로 나타나지 않고 제자리에서 붙어야 했다
통계 탭에서 기간을 바꾸려면 맨 위로 올라가야 했다. 기간 필터를 헤더 아래에 붙이는 일은 생각보다 여러 번 고쳤다.
| 시도 | 문제 |
|---|---|
| 패널이 지나가면 한 줄 요약바를 따로 띄움 | 늦게 나타나고, 공중에 뜬 카드처럼 보임 |
| 요약바를 헤더 바로 밑에 틈 없이 붙임 | 켜지는 순간 패널 아래 제자리에 있다가 따라 올라옴 |
| 기간 줄 자체를 sticky로 | 디자인을 바꾼 줄이라 기존 카드와 어울리지 않음 |
| 기존 카드의 필터 줄만 sticky로 | 채택 |
따로 나타나는 막대는 아무리 다듬어도 갑자기 생긴 것으로 보였다. 원래 카드에 있던 필터 줄이 스크롤을 따라 올라가 헤더 밑에 붙는 것이 가장 자연스러웠다.
여기에는 CSS 제약이 두 가지 있었다. sticky 요소는 부모 상자 안에서만 붙어 있는다. 필터 줄이 카드 안에 있으면 카드가 끝나는 순간 같이 밀려 올라간다. 그리고 그리드 아이템은 부모가 아니라 자기 그리드 칸 안에서만 붙는다. 그래서 바깥 상자를 flex로 바꾸고, 카드를 위(제목) · 가운데(필터) · 아래(비교 설명) 세 조각으로 나눴다. 카드 section은 display: contents로 풀어 세 조각이 바깥 flex에 직접 올라가게 했다. 세 조각은 테두리를 이어 붙여 한 장의 카드처럼 보인다.
.dashboard.analyticsRoot { display:flex; flex-direction:column; gap:0; }
.periodCard { display:contents; }
.periodControls { position:sticky; top:64px; z-index:10; }
.periodControls[data-stuck]::before {
content:""; position:absolute; z-index:-1; inset:0 -100vmax;
background:var(--background); border-bottom:var(--border-width) solid var(--bordercolor);
}
붙어 있는 동안에는 카드 테두리를 감추고, 헤더와 같은 바탕을 화면 좌우 끝까지 칠한다. 그래야 헤더와 필터 사이로 뒤 화면이 비치지 않는다. 넘치는 폭은 html의 overflow-x: clip이 자른다. 붙었는지는 관찰 경계를 붙는 자리보다 1px 위로 잡은 IntersectionObserver로 판단한다. 붙어 있는 동안에만 필터 줄의 윗변이 잘려 보이기 때문이다.
버튼 하나를 없애자 자리가 생겼다
날짜를 고른 뒤 "기간 적용"을 한 번 더 눌러야 하는 것도 걸렸다. 7·30·90일 버튼은 누르면 바로 바뀌는데 날짜만 두 단계였다. 날짜가 바뀌면 바로 조회하게 했다.
다만 키보드로 날짜를 치면 칸마다 값이 바뀐다. 일을 "1", "15" 순으로 치면 중간의 1일로도 요청이 나간다. 그래서 올바른 날짜가 400ms 동안 그대로일 때만 조회하고, Enter는 기다리지 않고 바로 조회하게 했다. 시작일이 종료일보다 늦으면 조회하지 않고 오류 문구만 보여준다.
const error = analyticsDateError(from, to, today);
setDateError(error);
if (error || (from === range.from && to === range.to)) return;
applyTimer.current = setTimeout(() => navigate({ ...range, preset: "custom", from, to }), delay);
버튼이 빠지자 모바일 필터에 한 줄이 비었다. 모바일에서는 그 자리에 7·30·90일 버튼을 삼등분해 넣었다. 처음 넣었을 때 날짜 칸과 버튼이 카드 폭의 일부(238px)에서 멈췄는데, PC용 justify-content: space-between이 그리드 열을 내용 폭으로 줄이고 있었다.
좁은 화면에서 페이지가 축소돼 보였다
넘친 곳은 통계가 아니라 헤더와 푸터였다
320px 폭에서 관리 화면이 꽉 차지 않고 오른쪽이 비어 보였다. 통계 화면을 의심했지만, 데이터가 실제로 불러와진 상태에서 화면 밖으로 넘치는 요소를 전부 세어보니 통계 쪽은 없었다. 넘친 것은 두 곳이었다.
| 위치 | 원인 | 넘친 폭 (320px 기준) |
|---|---|---|
| 헤더 오른쪽 | 검색 · 아바타 · 관리 · AI 관리 · 로그아웃 | 52px |
| 푸터 아이콘 줄 | min-width: 25rem 고정 | 432px보다 좁으면 넘침 |
페이지 일부가 화면보다 넓으면 모바일 브라우저는 그 폭에 맞춰 페이지 전체를 축소해서 보여준다. 오른쪽이 비어 보인 이유가 이것이었다.
처음에는 좁을 때 이름을 숨기자고 생각했는데, 확인해 보니 이름은 이미 모바일에서 숨겨져 있었다. 그래서 세 버튼을 아바타를 누르면 펼쳐지는 계정 메뉴로 옮겼다. 폭에 따라 헤더 모양이 바뀌지 않도록 모든 폭에서 같은 메뉴를 쓴다. 검색과 계정 버튼은 왼쪽 메뉴 버튼과 같은 48px 높이와 최소 폭, 같은 호버를 쓴다. 계정 버튼만 둥근 알약 모양 호버를 쓰고 있었던 것도 이때 같이 맞췄다. 푸터 아이콘 줄은 화면 폭 안에서 함께 줄어들게 했다.
프로필을 미리 그리는 캐시는 되돌렸다
헤더의 다른 버튼은 서버가 그린 HTML에 이미 들어 있어서 바로 보인다. 계정 영역만 화면 스크립트가 시작된 뒤 /api/me를 한 번 더 받아야 해서 잠깐 스켈레톤으로 남는다. 이를 줄이려고 마지막으로 받은 프로필을 브라우저 저장소에 두고, 다음 방문에서 응답을 기다리지 않고 먼저 그리게 해봤다.
곧 문제가 보였다. 같은 브라우저에서 다른 계정으로 로그인하면 이전 계정의 프로필이 잠깐 보였다가 바뀔 수 있다. 세션 쿠키의 사용자 id와 만료 시각을 확인해 맞을 때만 쓰도록 보완했지만, 화면에 보이는 사람이 잠깐이라도 틀릴 수 있다는 점은 남았다.
"빨리 보이는 것과 맞게 보이는 것 중에 뭐가 먼저지?"
잠깐 스켈레톤이 보이더라도 틀린 프로필이 보이지 않는 쪽을 택했다. 캐시는 모두 되돌렸다. 대신 이 과정에서 처리방침의 빈틈을 하나 찾았다. 처리방침은 "아래가 저장하는 전부"라면서 쿠키 세 개만 적고 있었다. 실제로는 브라우저 저장소에 테마, 최근 본 글, 관리 목록 조건을 두고 있었다. 쿠키 절을 "쿠키와 브라우저 저장소"로 넓혀 세 항목과 남는 기간을 적었다.
모바일 기준은 원래 640px 하나였다
마지막으로 화면 전환 기준이 정말 통일돼 있는지 반응형 규칙을 전부 훑었다. 프로젝트 규칙은 모바일로 바뀌는 지점을 640px 하나로 정해 두고 있었다. 그런데 AI 관리만 760px을 쓰고 있었다. 641~760px에서 AI 관리는 표가 카드로 바뀌고 여백도 모바일 값인데, 머리줄과 필터는 PC 모양인 반쯤 모바일 상태였다.
기준을 640px로 맞추자 그 폭에서 PC 표가 넘치는 곳이 두 군데 드러났다. 수집 상태 표는 641px에서 상자보다 34px 넓었다. 좁을 때 머리글이 두 줄로 접히게 했다. 작업 기록 표는 "부분 완료" 배지가 상태 칸 밖으로 6px 밀려 있었다. 좁은 PC 폭에서 그 칸을 11%에서 15%로 넓혔다. 통계의 정렬 버튼 끝 "순"을 375px보다 좁을 때 숨기는 규칙은 화면 모드를 바꾸지 않고 글자만 줄이는 예외로 남겼다.
결과
| 항목 | 전 | 후 |
|---|---|---|
| 관리 머리줄 | 화면마다 제목·탭·버튼이 다름 | AdminHeading 하나, 버튼 가운데를 제목 글자에 맞춤 |
| 목록 갱신 | 브라우저 새로고침 | 탭마다 맞는 방식의 새로고침 버튼 |
| 수집 상태 표 | 상태를 글로 적어 날짜가 밀림 | 날짜 옆 아이콘, 범례는 뜻만 |
| 글별 성과 | 발행 배지, 표 10개·문장 5개 | 태그와 비중, 개수는 TOP_POSTS 하나 |
| 통계 기간 | 맨 위로 올라가 적용 버튼까지 | 필터 줄이 헤더 밑에 붙고 날짜를 고르면 바로 조회 |
| 320px 헤더 | 52px 넘쳐 페이지 축소 | 계정 메뉴, 넘침 없음 |
| 모바일 기준 | 640px와 760px 혼재 | 640px 하나 |
커밋마다 그 시점의 파일만 따로 꺼내 tsc와 바뀐 파일의 eslint를 돌렸다. 통계 계산을 바꾼 커밋은 verify:analytics도 통과했다. 화면은 브라우저 자동화 탭에서 폭을 320·375·425·641·700·760·900·1100px로 바꿔 가며 요소 위치와 넘침을 쟀다. 측정값은 데이터가 실제로 불러와진 화면에서 얻은 것이고, 일부는 실제 스타일시트로 같은 구조를 그린 예시 화면에서 얻었다.
느낀점
- 눈으로 맞추면 다음 화면에서 다시 틀어진다. 버튼 위치를 "조금 위로"로 고치는 동안에는 계속 어긋났다. 제목 글자 가운데라는 기준을 숫자로 정하고 나서야 끝났다.
- 설명이 데이터와 함께 자라면 그 설명은 실패한 것이다. 주 이름을 나열하는 범례는 오늘은 보기 좋았지만 1년 뒤를 견디지 못한다. 상태의 뜻만 고정하고 개수는 표에 맡겼다.
- 따로 나타나는 UI는 아무리 다듬어도 갑자기 생긴다. 원래 있던 필터가 제자리에서 붙는 것이 가장 자연스러웠고, 그러려면 CSS의 붙는 범위를 먼저 이해해야 했다.
- 빠르게 보이는 것보다 맞게 보이는 것이 먼저다. 프로필 캐시는 대부분의 경우 이득이었지만, 틀린 사람이 보일 수 있는 경우가 하나라도 있으면 쓰지 않는 편이 맞았다.
- 기준이 하나라고 적혀 있어도 실제로 하나인지는 세어봐야 안다. 640px 규칙은 문서에 있었지만 AI 관리는 다른 숫자를 쓰고 있었다.
이번 작업은 새 기능보다 이미 있는 화면을 같은 기준으로 맞추는 일이 대부분이었다. 하나씩은 작은 차이였지만, 모아 놓고 보니 관리 화면을 쓸 때마다 조금씩 걸리던 것들이었다.
읽어주셔서 감사합니다.
댓글