블로그 개발기 11 (관리 도구 편)
이미지·통계·필터를 관리 화면에 붙이며 판정 기준을 한곳으로 모은 기록
지난 글에서는 글과 댓글에 좋아요를 붙였다. 독자가 보는 화면에 기능을 하나 더한 셈이다. 그런데 그 기능을 확인하려고 관리 화면을 오가다 보니, 정작 관리하는 쪽의 도구가 부족하다는 게 눈에 들어왔다.
포스트 목록은 검색창 하나로 제목·주소·태그를 한꺼번에 찾았다. 특정 태그가 붙은 글만 보고 싶어도 이름이 비슷한 글이 함께 걸렸다. 댓글 관리의 글 필터는 글이 늘수록 끝없이 길어지는 드롭다운이었다. 이미지는 더 불분명했다. Storage에 무엇이 올라가 있고, 어떤 글이 쓰고 있고, 다음 자동 정리에서 무엇이 지워지는지 볼 수 있는 화면이 없었다.
자동 정리는 개발기 5에서 한 번 크게 틀린 적이 있다. 쓰고 있는 썸네일을 고아로 판정했던 일이다. 그 뒤로 정리 스크립트는 고쳤지만, 확인하는 방법은 여전히 터미널에서 스크립트를 돌려보는 것뿐이었다.
"지워질 파일 목록을 보려면 매번 dry-run을 돌려야 하나?"
그래서 이번에는 관리 화면을 손보기로 했다. 필터를 정리하고, 이미지 관리 탭과 통계 탭을 새로 두었다. 9월 28일부터 사흘 동안 이어진 작업이었고, 끝나고 보니 대부분의 시간은 화면보다 판정 기준을 한곳에 모으는 일에 들어갔다.
목록에서 원하는 것만 고르기
검색과 필터를 나눴다
포스트 관리의 검색창은 편했지만 정확하지 않았다. react를 검색하면 react-native 태그가 붙은 글과 제목에 react가 들어간 글이 함께 나왔다. "이 태그가 붙은 글"을 고르는 것과 "이 단어가 들어간 글"을 찾는 것은 다른 일이었다.
툴바에 태그와 시리즈 드롭다운을 따로 두었다. 목록은 많이 쓴 순서로 정렬하고 이름 옆에 개수를 붙였다. 값은 정확히 같은 것만 걸리게 했다. 선택한 값은 URL의 tag, series에 남겨서 댓글 화면에 다녀와도 유지되도록 했다.
댓글 관리도 같은 방향으로 바꿨다. 글·작성자 필터를 검색할 수 있는 드롭다운으로 바꾸고, 제목순 대신 최근 댓글이 달린 순서로 보여줬다. 정렬, 본문 검색, 작성일 범위 필터도 더했다.
걸린 조건은 칩으로 늘어놓고 하나씩 풀 수 있게 했다. 이 칩은 처음에 댓글 화면에만 있었는데, 포스트 화면에도 필요해지면서 공용 컴포넌트로 옮겼다. 나중에는 포스트·댓글·이미지 세 목록이 같은 필터 바를 쓰게 됐다.
잘못된 날짜를 몰래 고치지 않았다
작성일 범위에서 끝 날짜가 시작 날짜보다 앞서는 경우가 있었다. 두 값을 바꿔서 조회해주는 방법도 있었지만 그렇게 하지 않았다. 사용자가 고른 것과 다른 조건으로 조회하면, 결과가 맞아 보여도 왜 그렇게 나왔는지 설명하기 어렵다.
그래서 역순이면 필터를 적용하지 않고 그 칸에 안내를 띄웠다. 날짜 칸에는 max를 걸어 연도가 다섯 자리로 늘어나지 않게 했다. 날짜 경계는 KST 기준으로 맞췄다. 뒤에 만든 통계 탭도 같은 원칙을 따른다. 시작일 ≤ 종료일 ≤ 오늘을 폼과 API에서 함께 검사하고, 역순 날짜를 자동으로 교환하지 않는다.
필터를 늘리면서 조회도 한 번 정리했다. 댓글 관리 화면은 직렬로 9번 쿼리를 보내고 있었다. 두 카운트를 한 번에 묻고, 드롭다운 목록을 조인 한 번씩으로 만들고, 걸러진 글·작성자는 그 목록에서 찾도록 해서 5번으로 줄였다. 로컬에서 10회 잰 중앙값은 248ms에서 181ms가 됐다.
스켈레톤이 다른 화면까지 덮고 있었다
필터를 붙이고 나서 로딩 화면이 어긋났다. 포스트 관리 스켈레톤에는 시리즈 드롭다운 자리가 없어서, 로딩 중에는 상태 버튼이 346px까지 늘었다가 목록이 뜨면 229px로 줄었다. 시리즈 목록은 로딩 중에 알 수 없지만, 드롭다운 자리만큼은 미리 그려두기로 했다.
더 이상한 문제도 있었다. 댓글 관리를 주소로 바로 열면 포스트 스켈레톤이 먼저 보이고, 그다음 댓글 스켈레톤, 그다음 목록이 나왔다. 탭을 눌러 이동할 때는 드러나지 않았다.
원인은 Next의 loading.tsx 범위였다. loading은 같은 폴더의 page뿐 아니라 그 아래 경로 전체를 Suspense로 감싼다. (management)/loading.tsx가 /admin/comments까지 덮고 있었다. 포스트 page와 loading을 (management)/(posts) 그룹으로 한 단계 내려서 범위를 좁혔다. URL은 그대로이고, 공통 껍데기는 바깥 layout에 남는다.
admin/(management)/
layout.tsx 공통 헤더·탭 (그대로)
(posts)/
page.tsx 포스트 목록
loading.tsx 포스트 스켈레톤 — 여기서만
comments/
page.tsx
loading.tsx
옮긴 뒤에는 tsc가 TS2307로 실패했다. .next에 남아 있던 옛 경로의 생성 타입 때문이었다. .next/types를 지우고 다시 빌드하니 통과했다.
이미지가 어디서 쓰이는지
같은 질문에 두 곳이 다르게 답하고 있었다
이미지 관리 화면을 만들기 전에 먼저 확인한 것이 있다. "이 이미지는 쓰이고 있는가"를 판정하는 코드가 어디에 있는지였다. 찾아보니 두 군데였다.
에디터의 이미지 고르기 모달은 본문 문자열에 경로가 포함되는지(includes)로 판정했다. 자동 정리는 본문에서 URL을 파싱해 경로를 뽑았다. 당시 이미지 78개에서는 두 결과가 같았다. 그래서 문제가 드러나지 않았다. 하지만 두 가지 경우에 결과가 갈렸다.
| 경우 | 고르기 모달 | 자동 정리 |
|---|---|---|
| 인코딩된 파일명 (공백·한글·괄호) | 미사용 | 사용 중 |
변환 주소(/render/image/)·서명 주소(/object/sign/) | 사용 중 | 미사용 |
두 번째 줄이 문제였다. 글에서 변환 주소로 이미지를 쓰고 있으면, 화면에서는 사용 중으로 보이는데 매일 도는 정리 작업은 그 파일을 지운다. 지금까지 그런 글이 없었을 뿐이다.
"화면이 '사용 중'이라고 해놓고 크론이 지우면, 그 화면은 거짓말을 하는 거다."
판정을 한 함수로 모았다
경로 추출은 referencedImagePaths 하나로 두고, 공개 주소뿐 아니라 변환·서명 주소도 쓰임으로 세게 했다. 이 결과로 삭제를 결정하므로, 애매하면 "쓰는 중" 쪽으로 넓게 잡았다.
const prefix = new RegExp(
`/storage/v1/(?:object|render/image)/(?:public|sign|authenticated)/${escaped}/`
);
for (const remainder of text.split(prefix).slice(1)) {
// 마크다운 링크의 닫는 괄호는 경로에서 제외한다
const url = new URL("/" + remainder.split(/[)\s"'<>]/)[0], "https://storage.invalid");
paths.push(decodeURIComponent(url.pathname.slice(1)));
}
그 위에 collectImageUsage를 두고 모든 글(초안 포함)의 본문과 썸네일을 함께 훑게 했다. 썸네일 주소는 본문에 나타나지 않는다. 개발기 5에서 틀렸던 게 바로 이 부분이었다. 자동 정리, 이미지 고르기, 이번에 만든 이미지 관리 화면이 모두 이 함수 하나를 본다. 유예 시간도 GRACE_HOURS 상수 하나로 모았다.
화면의 상태와 크론의 동작을 맞췄다
이미지 관리 탭(/admin/images)에서는 이미지마다 세 가지 상태를 보여준다.
| 상태 | 의미 |
|---|---|
| 사용 중 | 어떤 글이 본문이나 썸네일로 쓴다 |
| 유예 중 | 아무도 안 쓰지만 올린 지 24시간이 안 됐다. 저장 전 초안일 수 있다 |
| 삭제 예정 | 다음 자동 정리에서 지워진다 |
이 상태는 화면에서 계산하지 않고 서버가 정한다. 자동 정리와 같은 판정, 같은 유예 시간, 같은 경계 조건을 쓴다. 여기서 "삭제 예정"인 것과 다음 정리에서 실제로 지워지는 것이 다르면 이 화면을 만든 의미가 없다. 다음 정리 시각도 vercel.json의 크론 설정(UTC 03시, KST 12시)과 맞춰 계산했다.
상세 패널에서는 그 이미지를 쓰는 글과 편집 화면 링크를 보여준다. 지우기는 미사용 이미지에만 허용했다.
삭제 API는 화면을 믿지 않는다
삭제 버튼을 미사용 이미지에만 보여준다고 해서 안전해지는 것은 아니다. 화면을 연 뒤에 다른 창에서 그 이미지를 글에 넣었을 수도 있다. 그래서 삭제 요청이 오면 서버가 쓰임을 처음부터 다시 모은다.
const missing = paths.filter((path) => !objects.has(path));
if (missing.length > 0) throw new ImageDeleteRefused(404, `이미 없는 이미지가 있습니다: ...`);
const inUse = paths.filter((path) => usage.has(path));
if (inUse.length > 0) throw new ImageDeleteRefused(409, `글에서 쓰고 있어 지울 수 없습니다: ...`);
하나라도 쓰는 중이거나 버킷에 없으면 전부 거절한다. 일부만 지우고 나머지를 실패로 돌려주는 방식은 택하지 않았다. 무엇이 지워졌는지 다시 확인해야 하는 상황을 만들고 싶지 않았다. 쓰는 이미지는 지우지 않으므로 캐시 무효화도 필요 없다.
목록이 커질 것을 먼저 가정했다
보이는 숫자와 지워지는 수가 같아야 했다
글별로 이미지를 보고 싶어서 글 필터를 더했다. 그 글이 쓰는 이미지에 더해, 그 글 폴더(posts/<slug>/, thumbnails/<slug>/)에 있지만 아무도 안 쓰는 이미지도 함께 보이게 했다. 어떤 이미지가 어느 글을 쓰다가 버려졌는지 글 단위로 볼 수 있다.
그러자 "삭제 예정 지금 삭제" 버튼의 범위가 문제가 됐다. 글 필터를 걸어둔 상태에서 버튼을 누르면, 버킷 전체의 삭제 예정 파일을 지워야 할까, 지금 걸린 범위만 지워야 할까. 나는 후자로 정했다. 화면에 보이는 숫자와 실제로 지워지는 수가 같아야 누르기 전에 결과를 예상할 수 있다. 상태 버튼의 숫자도 같은 범위를 따르게 했다.
전체를 내려보내던 목록을 커서로 바꿨다
처음 만든 이미지 관리 화면은 버킷 전체를 한 번에 내려보냈다. 78개일 때는 문제가 없다. 하지만 글이 쌓이면 이미지는 글보다 빠르게 늘어난다. 필터와 정렬도 브라우저에서 하고 있었기 때문에, 목록이 커지면 화면을 여는 것부터 느려질 구조였다.
조회를 SQL로 옮겼다. storage.objects와 쓰임 정보를 조인해 상태를 계산하고, 필터를 적용한 뒤 24개씩 커서로 끊어서 보낸다. 전체 개수와 용량, 상태별 개수는 같은 쿼리에서 따로 집계한다. 목록은 나눠 받아도 요약 숫자는 전체 기준이어야 하기 때문이다.
| 전 | 후 | |
|---|---|---|
| 전송 | 버킷 전체 | 24개씩 커서 |
| 필터·정렬 | 브라우저 | SQL |
| 요약 숫자 | 받은 목록에서 계산 | 전체 범위를 서버에서 집계 |
| 일괄 삭제 대상 | 받은 목록 | 아직 안 받은 페이지까지 서버에서 다시 선정 |
커서에는 마지막 행의 위치와 함께 조회 조건과 기준 시각을 넣었다. 필터를 바꾼 뒤 옛 커서로 다음 페이지를 요청하면 거절한다. 기준 시각을 고정하지 않으면, 페이지를 넘기는 사이 유예 시간이 끝난 이미지의 상태가 바뀌어 목록이 흔들릴 수 있다.
800개로 경계를 확인했다
실제 이미지는 100개도 안 되니 페이지 경계 문제는 지금 데이터로 재현되지 않는다. 그래서 실제 PostgreSQL에서 가상 행 800개를 만들어 같은 쿼리를 돌리는 검사를 따로 두었다. DB나 Storage에는 아무것도 쓰지 않는다.
같은 시각에 올린 파일, 마이크로초만 다른 파일을 일부러 섞었다. 커서가 밀리초로 잘리면 경계에서 행이 빠지거나 겹치기 때문이다. 최신순·오래된순·용량순 세 가지 모두에서 800개가 빠짐없이 한 번씩 나오는지, 정렬 기준이 같을 때 순서가 흔들리지 않는지 확인했다. 검색어의 %, _가 와일드카드가 아니라 글자로 취급되는지, 아직 받지 않은 페이지의 파일도 일괄 삭제 대상에 들어가는지도 같이 봤다.
좁은 화면과 떴다 사라지는 창
누른 결과가 화면 밖에 있었다
이미지 관리를 좁은 화면에서 열어보니 이미지를 눌러도 아무 반응이 없는 것처럼 보였다. 실제로는 상세 패널이 그리드 맨 아래로 밀려나 있었다. 반응은 했지만 보이는 곳에서 하지 않았다.
800px 이하에서는 이미지를 고르는 순간 아래에서 올라오는 시트로 상세를 띄웠다. 처음에는 네이티브 dialog로 만들었는데, 그 위에 뜨는 삭제 확인창이 시트 밑에 깔렸다. dialog는 최상위 레이어에 올라가서 z-index로는 순서를 바꿀 수 없다. 시트를 body에 붙인 막으로 다시 그렸다.
툴바도 나눴다. 한 줄에 몰아넣었더니 폭마다 줄바꿈 위치가 달라졌다. 쓰임 필터·정렬·초기화·일괄 삭제 줄과 검색·글 필터 줄로 나누고, 375px 폰에서도 넘치지 않게 칸 폭을 다시 정했다.
여섯 개의 창이 여섯 가지로 움직였다
시트를 만들고 나니 다른 창들이 눈에 걸렸다. 확인창, 검색, 사이드 메뉴, 이미지 고르기, 이미지 뷰어, 이미지 관리 시트. 어떤 것은 아래에서 올라오고 어떤 것은 뚝 나타났다. 닫힐 때는 거의 다 뚝 사라졌다. 이미지 고르기 모달은 뒤 페이지 스크롤 잠금도 없었다.
움직임은 styles/motion에 열림 220ms, 닫힘 180ms와 곡선 하나로 모았다. 동작은 useOverlay 훅 하나에 두었다. 닫힘 애니메이션이 끝난 뒤 실제로 제거하고, 움직임 줄이기 설정이면 바로 닫고, 창 위에 창이 뜨면 잠금 개수를 세서 마지막 창이 닫힐 때만 스크롤을 푼다.
let locks = 0; // 지금 뒤 페이지를 잠그고 있는 창의 수
useEffect(() => {
if (!active) return;
if (locks++ === 0) lockHtmlAndBody();
return () => {
if (--locks > 0) return;
restoreHtmlAndBody();
};
}, [active]);
body만 잠그면 모바일 Safari에서는 터치 스크롤이 html로 새어 나간다. 그래서 둘 다 잠갔다.
닫힘을 기다리다 멈춘 창
닫힘 애니메이션을 기다리는 구조에는 함정이 있었다. animationend가 끝내 오지 않으면 창이 반쯤 사라진 채로 화면을 막는다. 가려진 탭에서는 프레임이 멈추기 때문에 실제로 일어나는 일이다. 닫힘 시간보다 넉넉히 기다린 뒤 강제로 닫는 안전망을 두었다.
검증하면서 고친 것이 세 가지 더 있었다. 이미지 뷰어는 dialog의 close 이벤트가 오지 않으면 부모가 여전히 열린 줄 알고 스크롤 잠금을 남겨 두었다. 그 상태에서는 다시 열리지도 않았다. 시트는 닫힘 선택자를 막 스타일 안에 중첩해 두어서 내려가는 애니메이션이 걸리지 않았다. 드롭다운에서 Escape를 누르면 그 드롭다운을 담은 모달까지 함께 닫혔다.
셋 다 기능이 동작하지 않는 문제는 아니었다. 그래서 더 늦게 발견됐다. 창 하나를 여닫는 것만 봐서는 드러나지 않고, 창 위에 창을 띄우고 순서대로 닫아봐야 보였다.
통계를 글 관리에서 떼어냈다
방문·조회 통계는 원래 포스트 관리 화면 위쪽에 붙어 있었다. 글을 관리하러 들어갈 때마다 그래프를 먼저 지나쳐야 했다. 통계를 별도 탭(/admin/stats)으로 옮기고, 포스트 목록은 글 관리에만 집중하게 했다.
새로 수집하는 정보나 테이블은 없다. 기존 daily_stats와 post_daily_views만 쓴다. 대신 기간과 그래프 단위를 분리했다. 최근 7일·30일·90일이나 직접 고른 날짜를 기간으로 두고, 같은 기간을 일별·주별·월별로 바꿔 볼 수 있다.
비교 기준은 명확히 정했다. 요약 카드의 증감은 직전 같은 일수와 비교한다. 오늘이 포함된 기간은 집계 중으로 표시한다. 기록을 시작하기 전 날짜는 0으로 채우지 않는다. 0으로 채우면 기록이 없던 기간이 "방문이 없던 기간"으로 읽히고, 그 뒤의 증가율이 부풀려진다.
| 항목 | 기준 |
|---|---|
| 주별 | KST 월~일. 양 끝 주는 선택한 날짜만 합산하고 부분 집계로 표시 |
| 기간 방문 | 일별 방문 수의 합계. 고유 방문자가 아니다 |
| 기록 시작 전 | 0이 아니라 빈 값 |
| 기록 시작 후 빈 날 | 0 |
| 태그 통계 | 현재 태그 기준. 여러 태그가 붙은 글은 중복 포함 |
일·주·월 전환은 이미 받은 일별 기록을 브라우저에서 다시 묶는다. 서버 요청이 없다. 기간을 바꾸는 동안에는 이전 결과를 유지하고 로딩 막대만 보여줬다. 매번 스켈레톤으로 바꾸면 비교하려던 숫자가 사라지기 때문이다.
그리고 남은 손질
사이드 메뉴가 가끔 늦게 채워졌다
원인은 두 가지였다. 목록 요청이 메뉴를 연 다음에야 시작됐다. 그리고 40px로 그리는 썸네일을 width 400, quality 100으로 요청해서 828px 이미지를 받고 있었다. 처음 보는 크기면 이미지 최적화기가 원본부터 다시 만들기 때문에 늦었다.
메뉴 버튼에 포인터가 닿거나 포커스가 가면 목록을 미리 받게 했다. 썸네일 요청은 64px(2x 128px)로 줄였다. 같은 흐름에서 큰 PNG 썸네일 14개(합계 22MB)도 1200px JPEG로 바꿨다. 합계는 0.75MB가 됐다. WebP는 쓰지 않았다. OG 이미지를 그리는 satori가 WebP를 읽지 못한다.
소개 페이지로 가는 링크가 없었다
Search Console의 미색인 페이지를 조사하다가, /about으로 향하는 링크가 사이트 전체 서버 HTML에 하나도 없다는 걸 알았다. 링크가 사이드 메뉴에만 있었는데, 그 메뉴는 열어야 마운트되는 클라이언트 컴포넌트라 크롤러에 보이지 않았다. 푸터에 소개 링크를 더했다.
다만 이것이 미색인 문제를 해결했다고 보지는 않는다. 그 21개는 모두 "발견됨 - 현재 색인되지 않음" 상태였고, 인바운드 링크 수와 색인 여부 사이에 관계가 보이지 않았다. 고친 것과 원래 조사하던 문제는 따로 적어 두었다.
흰 배경이 눈부셨다
라이트 테마의 순백 배경을 #fcfcfc로 낮췄다. 카드는 흰색 그대로라 살짝 떠 보인다. 배경이 낮아지자 링크색 #0070f3의 대비가 4.44가 되어 AA 기준(4.5)에 못 미쳤다. 한 단계 진한 #006ceb(4.71)로 바꿨다. 배경색 하나를 바꿔도 그 위의 글자 대비는 다시 재야 했다.
결과
관리 화면이 포스트·댓글·이미지·통계 네 탭으로 나뉘었다. 세 목록은 같은 필터 바와 칩, 같은 모바일 시트를 쓴다.
| 이전 | 지금 |
|---|---|
| 태그로 고르면 비슷한 이름이 함께 걸림 | 태그·시리즈를 정확히 일치로 고르고 URL에 유지 |
| 지워질 이미지는 스크립트 dry-run으로 확인 | 이미지 관리 탭에서 상태·삭제 예정 시각 확인 |
| 쓰임 판정이 두 곳에서 따로 | collectImageUsage 하나를 모든 화면과 크론이 공유 |
| 이미지 목록을 전부 전송 | SQL 필터와 24개 커서 조회 |
| 창마다 다른 움직임과 스크롤 잠금 | 여섯 창이 같은 속도·곡선·잠금 규칙 |
| 통계가 포스트 목록 위에 | 별도 탭, 기간과 단위 분리, 직전 동일 일수 비교 |
검증은 변경마다 yarn lint와 tsc를 돌렸다. 이미지 목록은 가상 800개로 정렬·페이지 경계·필터 범위를 확인했고, 통계는 날짜 경계와 집계·비교 계산을 DB 없이 검사하는 스크립트를 두었다. 마크다운 렌더 검사는 Storage로 옮긴 이미지에 옛 /images/ 경로를 요구하던 부분을 고쳐 12개 모두 통과시켰다.
확인하지 못한 것도 있다. 편집 화면의 삭제 버튼과 관리 목록 표의 배경색은 관리자 로그인이 필요해 브라우저에서 직접 보지 못했다. 댓글 필터의 한글 조합 처리도 실제 IME로는 확인하지 못했다.
느낀점
- 같은 질문에 답하는 코드는 하나여야 했다. 쓰임 판정이 두 곳에 있었을 때 지금 데이터에서는 결과가 같았다. 결과가 같다는 것이 규칙이 같다는 뜻은 아니었다.
- 화면이 보여주는 것과 서버가 하는 일을 맞추는 게 먼저였다. "삭제 예정"이라는 표시, 일괄 삭제 버튼의 숫자, 상태 버튼의 개수가 모두 실제 동작과 같은 범위를 가리키게 하는 데 시간이 가장 많이 들었다.
- 지금 데이터로 재현되지 않는 문제는 따로 만들어서 확인했다. 이미지가 100개도 안 되는 블로그에서 페이지 경계 버그는 보이지 않는다. 800개를 만들고 나서야 커서 정밀도를 확인할 수 있었다.
- 잘못된 입력은 고쳐주기보다 알려주는 편이 나았다. 역순 날짜를 몰래 바꾸거나, 기록 없는 날을 0으로 채우면 화면은 매끄러워진다. 대신 그 숫자를 설명할 수 없게 된다.
처음에는 필터 몇 개와 이미지 목록 화면 하나를 생각했다. 끝나고 보니 새로 만든 화면보다 기존 코드에서 같은 것을 다르게 판단하던 자리를 찾아 합친 일이 더 많았다.
관리 화면은 나 혼자 쓰는 곳이다. 그래서 여기서 잘못 보여주면, 그걸 믿고 지우는 사람도 나다. 이번 작업에서 가장 오래 붙잡고 있던 것도 결국 그 점이었다. 여기까지 읽어주신 분들께 감사드리며 이번 글을 마친다.
댓글