블로그 개발기 9 (관리 화면 편)

관리 화면을 하루 종일 다시 만든 기록
calendar icon

2026년 9월 17일

조회 3

tag icon

어제 저녁에 관리 화면에 두 가지를 붙였다. 글별·태그별 조회 추이 차트와 댓글 관리다. 한 30분 정도 이리저리 눌러보고 "이 정도면 됐지" 하고 닫았다.

그런데 오늘 아침에 다시 열어보니 전혀 아니었다. 차트 카드가 두 장 나란히 서서 똑같은 기간 탭을 두 번 보여주고 있었고, 버튼에 마우스를 올렸을 때의 색이 이미 선택된 버튼의 색과 같아서 뭐가 눌린 건지 알 수가 없었다. 결정타는 검색창이었다. 한글을 치면 조합 중에 글자가 깨졌다.

"어제의 나는 기능만 붙이고 도망갔구나…"

솔직히 만든 직후에는 이런 게 안 보인다. 만든 사람 눈이라 그렇다. 그래서 오늘은 새로운 걸 붙이지 않고 어제 것을 다시 만드는 날로 정했다. 코드 커밋은 14개, 08:58부터 17:22까지 하루가 통째로 들어갔다.

자 그럼, 뭘 어떻게 고쳤는지 하나씩 뜯어보자.


차트 두 벌을 하나로

어제의 차트는 VisitorChart.tsx 366줄과 TagViewsChart.tsx 247줄, 이렇게 두 개의 컴포넌트였다. 날짜를 묶는 함수는 공유하고 있었지만 카드, 기간 탭, 툴팁, 범례는 각자 그리고 있었다. 그러니 화면에 카드가 두 장 서고 "Daily · Weekly · Monthly" 탭이 두 번 나올 수밖에.

거기다 날짜 칸을 데이터가 정하고 있었다. points.slice(-30)이라 기록이 30일보다 짧으면 있는 만큼만 그렸고, 기록이 없는 날은 아예 칸이 없어서 선이 그 날을 건너뛰고 이어졌다. 어제는 몰랐는데 오늘 보니 이게 참 거슬렸다.

그래서 둘 다 지우고 AdminAnalytics.tsx 90줄과 순수 함수만 모아둔 admin-chart.ts 52줄로 다시 썼다. 카드는 한 장이고, 위에 "방문 / 태그별 조회" 탭으로 어떤 지표를 볼지 고른다. 613줄이 142줄이 됐다. 새로 만든 게 아니라 같은 걸 두 번 하던 걸 한 번만 하게 한 것뿐인데 이렇게 줄었다.

날짜 칸은 달력이 정한다

핵심은 bucketPoints 함수 하나다. 어떤 지표든 날짜별 값을 넣으면 미리 정해진 달력 칸에 담아서 돌려준다.

집계칸 수기준
일별30오늘부터 거꾸로 30일
주별12이번 주 월요일부터 거꾸로 12주
월별12고른 연도의 1~12월

칸이 데이터가 아니라 달력에서 나오니까 방문과 태그 조회를 겹쳐 그려도 x축이 어긋나지 않는다. 지표를 몇 개 더 붙여도 함수는 이거 하나로 끝난다.

기록이 없는 날은 0이 아니다

여기서 하나 결정한 게 있다. 집계가 시작되기 전 날짜와 아직 오지 않은 날짜는 0이 아니라 null로 둔다.

value: sums.get(key) ?? null,

왜냐고? 0으로 채우면 선이 바닥에 깔려서 그날 아무도 안 온 것처럼 보이기 때문이다. 실제로는 그날을 세지 않았을 뿐인데 말이다. 반대로 어제처럼 칸을 아예 빼버리면 x축 간격이 날마다 달라진다. null로 두면 칸은 남고 선만 끊긴다. 끊긴 선은 "모른다"는 뜻을 정확하게 전달한다. 툴팁도 0명이 아니라 기록 없음으로 나오게 했다.

그리고 이 함수들은 React를 전혀 모르는 순수 함수라서 스크립트로 검사할 수 있다. verify-admin-ux.mts를 만들어서 연도 경계, 미래 날짜, 빠진 날과 0인 날을 돌려본다. 화면을 열지 않아도 날짜 계산이 맞는지 알 수 있으니 마음이 편하다.


탭과 검색 상태

아침에 포스트 관리와 댓글 관리 위에 같은 탭 두 개를 달았다. 그런데 탭을 누르면 그 탭까지 사라졌다가 다시 그려지는 게 아닌가. 두 페이지가 각자 껍데기를 그리고 있었기 때문이다.

이건 Next.js의 라우트 그룹으로 풀었다. admin/(management)/layout.tsx에 껍데기를 두고 두 페이지를 그 밑으로 옮기니 탭은 그대로 있고 안쪽만 바뀐다. 괄호 폴더 하나 만든 게 전부라 허무할 정도였다.

검색 상태를 URL에 뒀다

포스트 목록의 검색어, 상태 필터, 정렬을 ?q=react&status=DRAFT&sort=views처럼 URL에 싣는다. 이렇게 하면 새로고침해도 남고, 댓글 관리로 갔다가 돌아와도 보던 목록 그대로다.

돌아오는 링크가 returnTo 파라미터인데, 이런 건 열린 리다이렉트가 되기 쉽다. 관리자만 쓰는 화면이라 위험은 작지만 막는 비용도 작으니 막았다. /admin에 쿼리만 붙은 것 외에는 전부 /admin으로 바꾼다.

for (const unsafe of [
    "https://evil.example/admin", "//evil.example",
    "/admin/posts/1", "/admin/../login",
    "/admin?returnTo=https://evil.example",
]) assert.equal(safeAdminReturn(unsafe), "/admin");

한글이 조합 중에 깨졌다

아침에 제일 거슬렸던 그 문제다. 검색창이 입력값을 URL 상태로 관리하는 제어 컴포넌트였는데, 영어에서는 전혀 티가 안 나던 게 한글에서만 깨졌다. 한글은 한 글자를 완성하는 동안 값이 여러 번 바뀌기 때문이다. 그때마다 URL로 갔다가 돌아온 값이 입력창을 덮어썼던 것이다.

<input ref={input} defaultValue={query}
    onCompositionStart={() => { composing.current = true; }}
    onCompositionEnd={(e) => { composing.current = false; update({ q: e.currentTarget.value }); }}
    onChange={(e) => { if (!composing.current) update({ q: e.currentTarget.value }); }} />

입력창을 비제어로 돌리고 조합이 끝났을 때만 URL에 쓰도록 했다. 검색 대화상자에도 같은 문제가 있어서 조합 중에는 방향키를 무시하게 했다. 한글 사용자가 만드는 블로그인데 한글 입력을 영어 기준으로 테스트하고 있었다니, 좀 부끄럽다.


호버와 선택은 다른 상태다

09:31부터 10:01까지 커밋 다섯 개가 전부 호버, 테두리, 선택 색이다. 테두리를 2px로 올렸다가 3분 만에 1px로 되돌린 것도 있다. 30분 동안 색만 만졌다는 얘기인데, 그 끝에 남은 결정은 결국 하나였다.

그전까지 호버는 링크 강조색인 파랑을 쓰고 있었다. 테두리도 파랑, 글자도 파랑, 배경은 파랑 8%. 그런데 선택된 상태도 같은 파랑이었다. 그러니 필터 버튼 세 개 중에 어느 게 켜져 있는지 확인하려면 마우스를 치워야 했다.

호버선택됨
전파랑 테두리, 파랑 글자파랑 테두리, 파랑 글자, 굵게
후회색 면, 전경색 테두리전경색 면, 배경색 글자

생각해보면 호버는 "누를 수 있다"는 뜻이고 선택은 "이미 눌렀다"는 뜻이다. 뜻이 다르면 색도 달라야 한다. 강조색은 링크에만 남기고 호버와 선택은 중립 토큰으로 통일했다.

"색을 고르는 게 아니라 상태를 먼저 정하는 거였다."

초점 링도 같은 규칙으로

같은 이유로 초점 링도 한 벌로 맞췄다. 그러다가 헤더 햄버거 버튼의 링만 혼자 주황색인 걸 발견했다. 원인은 다크 테마용 auto-dark 클래스였다. 이게 아이콘이 아니라 버튼에 붙어 있어서 filter: invert()가 초점 링까지 뒤집었고, 파랑의 보색인 주황이 나온 것이다. 클래스를 <Image>로 내리니 끝났다.

호버 링은 테두리 자리에 outline을 그리는 방식이다. 테두리 바깥에 두면 두 선이 겹쳐서 두 색 2px가 되니까, outline-offset을 테두리 폭만큼 안으로 넣고 호버 중에는 테두리를 투명하게 한다. 그리고 outline-style은 none에서 solid로 전환이 안 되기 때문에 늘 투명한 링을 깔아두고 색만 바꾼다. 이걸 segmented.ts 한 파일로 뽑아서 상태 필터와 기간 탭이 같이 쓰도록 했다.


댓글 관리를 다시 만들었다

어제의 댓글 관리는 전체 댓글을 최신순으로 보여주고, 글별로 거르고, 지우는 게 전부였다. 실제로 써보니 세 가지가 아쉬웠다.

첫째, 답글이 무엇에 대한 답인지 모른다. 최신순 한 줄 목록이라 트리를 그리지 않는데, 그러면 "네 맞아요" 같은 답글이 혼자 덩그러니 떠 있다. 그래서 부모 댓글의 작성자와 본문을 인용 한 줄로 위에 깔았다.

둘째, 지운 댓글이 눈에 안 띈다. 흐리게만 처리했더니 목록을 훑을 때 그냥 지나쳐 버렸다. 행 왼쪽에 빨간 띠를 두르고 본문에 취소선을 그었다. 누가 언제 지웠는지도 배지에 같이 적는다.

셋째, 긴 댓글 하나가 화면을 다 먹는다. 댓글은 2000자까지 쓸 수 있어서 다섯 줄에서 접기로 했다. 여기서 "더 보기" 버튼을 글자 수 기준으로 달면 넓은 화면에서는 안 잘린 댓글에도 붙어버린다. 그래서 실제로 잘렸는지를 그려본 뒤에 잰다.

useEffect(() => {
    const el = bodyRef.current;
    if (el) setOverflows(el.scrollHeight > el.clientHeight + 1);
}, [comment.body]);

필터는 상태(전체, 게시 중, 삭제됨), 글, 작성자 셋으로 늘렸다. 드롭다운에는 댓글이 하나라도 달린 글과 사람만 넣었는데, 빈 선택지는 고를 이유가 없기 때문이다. 목록은 25개씩 끊는다.


그리고 경고 한 줄

여기까지 하고 개발 서버를 다시 띄웠는데 터미널에 처음 보는 줄이 찍혀 있었다.

DeprecationWarning: Calling client.query() when the client is already
executing a query is deprecated and will be removed in pg@9.0.

경고라서 화면은 멀쩡하다. 그냥 넘어가도 됐다. 그런데 "pg@9에서 제거된다"는 말이 걸렸다. 드라이버가 한 판 올라가면 이게 오류가 된다는 뜻이다. 그때 가서 "어제까지 되던 게 왜 안 되지"로 시작하는 것보다는 지금 찾는 게 훨씬 싸다.

경고는 트랜잭션 안에서만 난다

pg 소스를 열어보니 Client 하나에 쿼리가 이미 줄 서 있는데 또 query()를 부를 때 나는 경고였다. 여기서 중요한 건 이게 Pool이 아니라 Client라는 점이다. 풀은 쿼리마다 커넥션을 빌려 쓰니 한 커넥션에 쿼리가 겹칠 일이 없다. 겹치려면 커넥션 하나를 붙들고 있어야 하고, Prisma에서 그런 곳은 $transaction뿐이다.

--trace-deprecation을 켜고 스택을 따라가니 방금 고친 댓글 관리의 목록 조회였다. 트랜잭션 안에서 Promise.all로 쿼리 넷을 동시에 던지고 있었다. 트랜잭션 밖에서 하던 습관을 그대로 들고 들어온 건데, 커넥션이 하나라 어차피 줄 서서 실행되니 병렬화도 아니었다. 얻는 건 없고 경고만 얻은 셈이다. 순서대로 await하게 바꿨다.

그리고 다시 띄웠는데… 경고가 그대로 났다.

두 번째 원인은 Prisma였다

스택을 다시 보니 위쪽이 전부 Prisma 런타임의 interpretNode였다. 내 코드가 아니라 Prisma가 스스로 트랜잭션 커넥션에 쿼리를 겹쳐 던지고 있다는 뜻이다. 어느 쿼리인지 알아야 하니 트랜잭션에 쿼리 하나만 넣고 select 조합을 바꿔가며 돌려봤다.

트랜잭션 안의 findMany select경고
author + post없음
author + parent { author }없음
author + post + parent발생

같은 층위에 to-one relation이 셋이면 Prisma가 relation 조회를 Promise.all로 묶어서 트랜잭션 커넥션에 던진다. 내가 방금 고친 것과 똑같은 실수를 라이브러리가 하고 있었던 것이다. 찾아보니 이미 올라와 있는 이슈였다. prisma/prisma#29407

상류가 고쳐줄 때까지 기다릴 수도 있었지만 pg@9가 먼저 오면 오류가 되니까 내 쪽에서 피하기로 했다. 셋 중 post는 같은 트랜잭션에서 드롭다운용으로 이미 조회하고 있었다. 거기에 id와 status를 더 실어 오고, 행에서는 postId로 Map에서 찾게 했다. relation이 둘로 줄었고 경고는 0건이 됐다.

같은 자리에 있던 경합 다섯 개

트랜잭션과 커넥션 주변을 한참 들여다보다 보니, 예전에 코드 감사에서 적어두고 미뤄둔 항목들이 전부 이 근처에 있었다. 하나같이 "동시에 두 번 일어나면"으로 시작하는 것들이라 이 김에 같이 정리했다.

문제전후
커넥션 상한URL의 connection_limit=1. pg 어댑터는 이 값을 읽지 않아서 실제 상한은 pg 기본값인 10PrismaPg({ max: 1 })
같은 댓글 동시 삭제update가 무조건 실행돼 카운트가 2 빠짐updateMany({ deletedAt: null })로 바뀐 행이 있을 때만 차감
업로드 중 발행![업로드 중 …]() 자리표시자가 그대로 저장됨업로드가 끝날 때까지 저장을 막음
저장 버튼 연타요청이 두 번 나가고, 새 글이면 글이 둘 생김첫 요청이 끝날 때까지 무시
이미지 정리a.png?download=1을 다른 파일로 봐서 a.png가 삭제 대상URL로 파싱해 pathname만 비교

커넥션 상한은 좀 아찔했다. README에 "필수"라고 적어두고 보름 넘게 믿고 있던 값인데, 인스턴스마다 최대 열 개까지 열 수 있었던 셈이다. 무료 티어 커넥션 한도를 생각하면 운이 좋았다. 문서도 같은 커밋에서 고쳤다. 틀린 문서를 두면 다음에 또 믿게 되니까.

이 다섯 개는 브라우저에서 손으로 재현할 수가 없다. "두 요청이 거의 동시에"를 손으로 만들 수는 없으니 말이다. 그래서 회귀 검사를 스크립트로 남겼다. 모듈을 vm에서 실행하되 require를 가로채서 DB와 React 자리에 가짜를 꽂는 방식이다. yarn verify:audit-fixes로 돌리면 외부 접속 없이 몇 초면 끝난다.


에디터 스크롤을 한 번 멈추게 했다

마지막은 에디터다. 본문과 미리보기 칸이 화면 높이라서 두 칸이 딱 맞게 보이는 스크롤 자리가 하나뿐인데, 덜 내리면 아래가 잘리고 더 내리면 위가 잘려서 매번 손으로 맞춰야 했다. 내가 원하는 동작은 간단했다. 위에서 휙 굴리면 그 자리에서 한 번 멈추고, 다시 굴려야 푸터로 내려가는 것.

간단한 줄 알았는데 세 번 만에 됐다.

시도방식왜 안 됐나
1CSS scroll-snap-type: y proximity끌어당기는 거리를 정할 수 없다. 맨 위에서 한 번만 굴려도 칸까지 튀어 와서 제목, 설명, 태그 입력란을 지나칠 수가 없었다
2scrollend에서 80px 안이면 그 자리로 맞추기휙 굴리면 근처에서 멈추지 않는다. 멈추게 하는 게 아니라 나중에 조정하는 것이었다
3wheel을 가로채서 멈추기됐다. 단, 보강이 하나 필요했다

세 번째 방식은 아래로 굴리는 휠에서 칸 상단이 저장 줄 아래 자리를 지나치려는 순간 거기서 세우고 preventDefault한다. 같은 손짓으로 이어지는 휠(300ms 안)은 삼키고, 손을 뗐다가 다시 굴리면 통과시킨다.

그런데 이것도 처음엔 천천히 굴릴 때만 걸리고 휙 굴리면 그냥 지나갔다. 왜냐고? 브라우저의 부드러운 스크롤이 휠 이벤트보다 늦게 도착하기 때문이다. 휙 굴리면 scrollY가 아직 위쪽에 있는 채로 다음 휠이 들어온다. 그래서 scrollY에 이번 굴림을 더한 값만 보면 "아직 안 닿았네" 하고 통과시키고, 그 사이 쌓여 있던 애니메이션이 자리를 넘어가 버린다.

// 같은 손짓 안에서는 굴린 만큼을 누적한 "도착할 자리"로 판단한다
expected = now - lastWheel < GESTURE_GAP_MS ? Math.max(current, expected) : current;
expected += delta;
if (expected < wanted) return;
event.preventDefault();
window.scrollTo({ top: wanted, behavior: "instant" });

여기에 멈추기 전에 이미 출발한 애니메이션이 자리를 넘어가면 scroll 이벤트에서 되돌리는 보강을 더했다. 같은 레이아웃의 정적 페이지에서 실제 휠로 확인해 보니, 16ms 간격으로 15번 굴리면 그 자리에서 멈추고, 손을 뗀 뒤 다시 굴리면 끝까지 내려가고, 천천히 굴리면 그 자리를 거쳐서 내려간다.

이건 useScrollStop 훅으로 뽑아 뒀다. 화면 높이짜리 구역이 있는 페이지면 어디든 붙일 수 있다. 휠만 다루고 키보드와 스크롤 막대는 막지 않는데, 멈추게 하고 싶은 건 결국 휙 굴리는 손짓이기 때문이다.


결과

어제 저녁오늘
차트 코드두 벌 613줄한 벌 142줄
기록 없는 날칸이 없어 건너뜀칸은 있고 선만 끊김
탭 전환껍데기까지 다시 그림안쪽만 바뀜
검색어, 필터, 정렬페이지를 떠나면 사라짐URL에 남음
한글 검색조합 중에 깨짐조합이 끝나야 반영
호버 / 선택둘 다 파랑회색 면 / 전경색 면
답글혼자 떠 있음부모 인용 한 줄
pg 경고댓글 관리를 열 때마다0건
에디터 스크롤맞는 자리를 손으로 찾음휙 굴리면 그 자리에서 멈춤

느낀점

  • 같은 걸 두 번 하고 있으면 하나로 줄이는 게 먼저다. 차트 두 벌을 각자 고치려 했으면 오늘도 두 벌이었을 것이다. 날짜 칸을 달력이 정하게 하니 지표가 몇 개든 함수는 하나다.
  • 색보다 상태를 먼저 정한다. 호버와 선택이 같은 색이면 사용자는 구분할 방법이 없다. 상태마다 뜻을 정하고 나서야 색이 정해졌다.
  • 한글은 영어와 다른 입력 경로를 탄다. 제어 컴포넌트가 영어에서 멀쩡하다고 한글에서도 멀쩡한 게 아니다. 한글로 한 번 쳐보기 전까지는 재현조차 안 됐다.
  • 경고는 오류의 예고편이다. "제거된다"는 말이 붙어 있으면 지금 보는 게 제일 싸다. 따라가 보니 내 실수 하나와 라이브러리 버그 하나가 겹쳐 있었고, 그 옆에서 커넥션 상한이 걸려 있지 않았다는 것까지 알게 됐다.
  • 브라우저의 스크롤은 휠보다 늦다. scrollY는 이미 지난 자리다. 어디로 가는 중인지는 직접 세야 한다.

기능을 붙이는 날보다 이런 날이 더 피곤하다. 남는 게 눈에 잘 안 보여서 그렇다. 그래도 내일 관리 화면을 열었을 때 거슬리는 게 하나도 없다면 그게 오늘의 결과일 것이다. 여기까지 읽어주신 분들께 감사 인사 올리고 여기서 마치겠다.

좋아요 —
hamburger icon

무링의 개발 블로그

FDE를 향하는 개발자


댓글

GitHub 계정으로 로그인하면 댓글을 남길 수 있습니다.

로그인

연관된 글