왜 필요했나
이직 포트폴리오용으로 프론트엔드 스킬을 보여줄 무언가가 필요했다. 마침 이 프로젝트엔 shadcn/ui가 설치까지만 되어 있고 사실상 안 쓰이고 있었다. components/ui/엔 button.tsx, badge.tsx 두 개뿐이었고, 그나마 실제로 화면에 쓰인 건 Badge 하나였다.
관리자 전용 통계 페이지를 만들면서 이 shadcn을 제대로 붙여보기로 했다. 조건은 세 가지였다. 관리자 로그인 이후에만 들어갈 수 있는 진입점일 것, shadcn 컴포넌트로 그래프·정량 데이터를 보여줄 것, 새 스키마 없이 이미 DB에 있는 방문자 수·게시물 조회수 데이터를 활용할 것.
설계: 재사용할 것과 새로 만들 것
결정할 지점이 몇 개 있었다. 인증 방식, 컴포넌트를 어디서 가져올지, 데이터 소스.
인증은 새 미들웨어나 requireAdmin() 같은 헬퍼 함수를 만드는 대신, posts/new/page.tsx에 이미 있던 패턴(getSession 체크 후 redirect)을 그대로 재사용하기로 했다. 이 블로그는 회원가입이 막혀 있는 1인 블로그라 로그인 성공 자체가 곧 관리자 권한이고, admin 라우트가 하나뿐인 지금 시점에 추상화를 미리 만들 이유가 없었다.
컴포넌트 쪽은 계획과 달랐다. shadcn CLI로 card, table, tabs, select, skeleton, chart를 한 번에 받으려고 했는데, 그 전에 프로젝트를 훑어보다 더 근본적인 문제를 발견했다. Button, Badge가 참조하는 bg-primary, border-border, ring-ring 같은 shadcn 시맨틱 토큰이 globals.css에 아예 정의돼 있지 않았다. 이 프로젝트는 GitHub Primer 톤 커스텀 토큰(--bg-default, --fg-muted 등)만 쓰고 있었고, shadcn이 기본으로 기대하는 토큰 세트는 처음부터 마이그레이션된 적이 없었다. 지금까지 Button이 실제로 쓰인 곳이 하나도 없었던 것도 우연이 아니라, 쓰는 순간 스타일이 깨졌을 거란 뜻이었다.
그래서 새 컴포넌트를 추가하기 전에 기존 Primer 팔레트에 shadcn 토큰을 매핑하는 작업부터 했다.
--color-primary: var(--accent-fg);
--color-card: var(--bg-default);
--color-border: var(--border-default);
--color-muted: var(--bg-subtle);
--color-ring: var(--accent-fg);라이트/다크 모드 각각에 차트 전용 팔레트(--chart-1~--chart-5)도 함께 추가했다.
데이터 소스는 새 스키마 없이 기존 테이블만 쓰기로 했다. site_visits(날짜별 방문자 수)로 추이를, posts.viewCount로 인기글 Top10과 평균 조회수를 뽑을 수 있었다.
구현: 세 단계
1. 데이터 계층. src/lib/admin-stats.ts에 방문자 추이(getVisitTrend), 오늘/누적 방문자(getVisitSummary), 게시물 총계·평균 조회수(getPostSummary), 조회수 Top10(getTopPosts) 네 개 쿼리를 뽑아냈다.
2. UI 컴포넌트. shadcn CLI(npx shadcn add)로 자동 설치하려던 계획이 막혀서(자세한 건 트러블슈팅 참고), card, table, tabs, select, skeleton, chart를 기존 button.tsx/badge.tsx와 같은 스타일(data-slot 속성, 통합 radix-ui 패키지)로 손으로 재구성했다.
3. 페이지 조립. /admin/stats에 요약 카드 4개(오늘/누적 방문자, 총 게시물, 평균 조회수), 방문자 추이 영역 차트, 인기글 Top10 테이블을 배치했다. 기간 선택(7/30/90일)은 Select + URL searchParams로 처리하고, 기간이 바뀌면 Suspense+Skeleton으로 로딩 UI가 뜨게 했다.
<Suspense key={days} fallback={<StatsSkeleton />}>
<StatsContent days={days} />
</Suspense>로그인 상태일 때만 헤더에 "통계" 링크가 뜨도록, 기존 로그아웃 버튼과 같은 조건 분기에 추가했다.
트러블슈팅: 두 군데서 막혔다
첫 번째는 컴포넌트 설치 자체였다. npx shadcn add card table tabs select skeleton chart를 돌렸더니, 이 작업 환경(마운트된 디렉터리)에서 pnpm/npm이 설치 전에 하는 하드링크 프로브(store 경로 확인용 임시 파일을 만들었다 지우는 과정)가 EPERM: operation not permitted, unlink로 계속 실패했다. pnpm exec도, pnpm install도, npm으로 바꿔봐도 마찬가지였다. 결국 CLI 자동 설치를 포기하고 컴포넌트 파일을 직접 작성하는 쪽으로 방향을 틀었다.
두 번째는 타입이었다. chart.tsx(shadcn의 recharts 래퍼)를 기억에 의존해서 작성했는데, 로컬에서 pnpm typecheck를 돌려보니 recharts 3.8.0 기준으로 타입 에러가 여러 개 났다. ChartTooltipContent에서 payload/label에 접근하는 부분, ChartLegendContent의 payload 타입 부분이 전부 안 맞았다. recharts가 v2에서 v3로 오면서 Tooltip/Legend의 prop 타입 구조 자체가 바뀐 게 원인이었다. 직접 다시 짜맞추는 대신 shadcn 공식 레지스트리(ui.shadcn.com/r/styles/new-york-v4/chart.json)에서 recharts 3.8.0에 맞는 소스를 가져와 통째로 교체하니 에러가 사라졌다. 손으로 재구성한 컴포넌트라도, 버전이 명시된 의존성이 걸린 부분은 기억이 아니라 실제 설치 버전 기준으로 검증해야 한다는 걸 다시 확인했다.
검증 과정에서 이번 작업과 무관한 기존 버그도 하나 걸렸다. src/lib/tech-digest/create-digest-post.ts가 DigestPost 타입에 없는 title을 참조하고 있었다. summarize.ts의 프롬프트 주석에 있던 규칙("subtitle은 제목의 '- ' 뒤에 붙는 부제")을 근거로 이번 주 개발자 뉴스 - ${post.subtitle} 형태로 제목을 조합하도록 고쳤다.
아래의 스크린샷은 완료된 관리자용 통계화면의 스크린샷이다.

남은 것
role 기반 권한 체계는 없다. 지금은 로그인 성공 자체가 관리자 권한이라 충분하지만, 나중에 계정이 여러 개가 되면 user 테이블에 role 컬럼을 추가하고 이 페이지의 인증 체크도 바꿔야 한다.
tabs, select 컴포넌트는 이번에 설치는 해뒀지만 select만 기간 필터에 쓰고 tabs는 아직 안 썼다. 카테고리별·태그별 분포처럼 뷰를 전환할 화면이 생기면 그때 쓰기로 하고 지금은 남겨뒀다.