던플 개발기 (3): 상태를 숨기지 않는 프론트엔드 — RSC, 디자인 시스템, freshness

Dunplay

지난 두 편(수집 파이프라인, 점검·장애 대응)에서 백엔드가 데이터를 모으고, 멈출 때는 멈췄다고 정직하게 말하도록 만들었습니다. 이번 편은 그 정직함을 화면까지 끌고 오는 이야기입니다.

던플마켓의 프론트엔드에는 화면을 화려하게 만들려는 욕심보다 먼저 풀어야 할 문제가 있었습니다. 데이터에 한계가 있고(점검·지연·관측 한계), 그 한계를 숨기면 사용자는 "고장 났다"고 느낍니다. 그래서 프론트엔드의 1차 목표는 예쁜 차트가 아니라 상태를 숨기지 않는 정보 도구를 만드는 것이었습니다.

한눈에 보면

  • 첫 화면(차트·시세·순위)은 RSC로 서버에서 그리고, 검색·정렬·무한 스크롤만 Client Component로 둡니다.
  • 서버 상태는 TanStack Query, 찜·모달 같은 작은 client 상태는 Zustand로 역할을 나눕니다. 서버 응답을 Zustand에 복제하지 않습니다.
  • 백엔드의 freshness·providerStatus를 화면에 그대로 드러냅니다. 점검 중에도 빈 화면이 아니라 "마지막 갱신 시각 + 점검 안내"를 보여 줍니다.
  • 스타일은 shadcn/ui open-code + Tailwind로만 합니다. CSS module·CSS-in-JS는 쓰지 않습니다.
  • 색은 의미를 담은 **semantic token(OKLCH)**으로 관리하고, 가격은 한국 금융 관례대로 상승 red·하락 blue를 행동 색과 분리합니다.
  • 폰트는 Pretendard KS X 1001 subset을 self-host해 2MB variable을 weight당 약 267KB로 줄였습니다.

무엇을 서버에서, 무엇을 클라이언트에서

던플마켓의 화면은 대부분 "이미 수집해 둔 데이터를 빠르게 보여 주기"입니다. 검색처럼 사용자가 손으로 조작하는 부분만 상호작용이 필요합니다. 그래서 Next.js App Router의 Server Component를 기본값으로 두고, 상호작용이 필요한 잎사귀만 Client Component로 떼어 냈습니다.

flowchart TD
  PAGE["page.tsx (서버)"] --> SHELL["AppShell + ProviderStatusBanner (서버)"]
  PAGE --> CHART["PriceChart / RankingList (서버, BFF 조회)"]
  PAGE --> SEARCH["GlobalItemSearch ('use client')"]
  PAGE --> FAV["FavoriteList ('use client', localStorage)"]
  SEARCH --> RQ["TanStack Query"]
  FAV --> Z["Zustand"]
  • 첫 HTML에 필요한 차트·시세·순위는 서버에서 BFF REST로 조회해 그대로 렌더합니다. 사용자는 빈 화면을 거치지 않습니다.
  • 검색 자동완성, 정렬, 필터, 무한 스크롤만 Client Component입니다.
  • 'use client' 경계는 가능한 한 잎사귀로 미룹니다. 페이지 최상단에 붙여 트리 전체를 클라이언트로 끌고 가지 않습니다.

이 판단의 근거는 제 블로그의 async 서버 컴포넌트서버/클라이언트 합성 패턴 글에 정리한 그대로입니다. 던플마켓은 그 원칙을 실제 제품에 적용한 사례인 셈입니다.

상태 관리는 역할로 나눈다

App Router에서 가장 헷갈리는 부분이 "그럼 상태 관리 도구는 어디에 쓰나"입니다. 던플마켓은 도구를 기능이 아니라 역할로 나눴습니다.

데이터 도구
첫 화면 서버 데이터 RSC + BFF 조회 메인 차트, 인기 순위
브라우저에서 다시 부르는 서버 상태 TanStack Query 검색 결과, 무한 스크롤
작은 client 상태 Zustand 찜 목록, 모달, 검색 UI

규칙은 단순합니다. TanStack Query는 브라우저 캐시·refetch가 필요한 서버 상태에만 쓰고, Zustand는 순수한 client 상태에만 씁니다. 서버에서 받은 데이터를 Zustand에 복제하지 않습니다. 복제하는 순간 "어느 쪽이 진실인가"라는 동기화 문제가 생기기 때문입니다. App Router에서 React Query를 섞는 기준은 이 글에 따로 정리했습니다.

찜 목록은 로그인 전까지 localStorage에 최대 20개 저장합니다. 여기엔 SSR 함정이 하나 있습니다. 서버에는 localStorage가 없으므로, 마운트 전에 찜 목록을 읽으면 서버와 클라이언트 HTML이 어긋나 hydration mismatch가 납니다. 그래서 찜은 client mount 이후에 읽습니다. 나중에 로그인이 붙으면 localStorage 목록을 서버 계정 목록으로 병합할 수 있게, 처음부터 item ID 중심으로 저장해 뒀습니다.

상태를 숨기지 않는 UI

여기가 이 시리즈의 핵심이 화면으로 드러나는 지점입니다. 백엔드는 매물·차트 응답에 observedAt, freshness, providerStatus를 실어 보냅니다. 프론트엔드는 이걸 버리지 않고 그대로 보여 줍니다.

  • app shell에 ProviderStatusBanner를 둬서 점검·지연·복구 중임을 상단에 안내합니다.
  • 매물과 차트에는 마지막 갱신 시각을 함께 표시합니다.
  • 점검 중에도 저장된 마지막 데이터를 읽을 수 있게 합니다.
  • 최초 데이터가 없을 때는 빈 매물 목록이 아니라 "준비 중" 상태를 보여 줍니다.

freshness는 백엔드와 같은 다섯 단계를 그대로 씁니다.

FRESH        방금 수집됨
DELAYED      예상보다 늦음
STALE        오래됨
MAINTENANCE  점검 중, 저장된 데이터 제공
NO_DATA      아직 첫 수집 전

차이는 작지만 체감은 큽니다. 빈 화면은 "고장 났다"로 읽히고, "12분 전 갱신 · 점검 중"이 붙은 화면은 "잠깐 멈췄을 뿐"으로 읽힙니다. 점검·장애 대응 편에서 차트의 점검 구간을 0이 아니라 끊긴 구간으로 그린다고 했는데, 그 약속을 지키는 곳이 바로 이 화면입니다.

디자인 시스템: 왜 이렇게 깔았나

던플마켓은 숫자·차트·표를 오래 봐도 피로하지 않게 읽는 게 목적입니다. 토스증권의 정보 밀도와 탐색 구조를 참고하되 화면·문구를 복제하지 않고, 던전앤파이터를 연상시키는 navy·violet·gold를 pastel 위에 얹었습니다.

스타일은 Tailwind와 open-code로만

libs/shared/ui는 shadcn/ui를 기반으로 만들되, 설치형 패키지가 아니라 수정 가능한 open-code component로 가져와 던플 토큰에 맞게 고쳤습니다. 스타일링은 Tailwind utility와 cva/cn만 씁니다. CSS module·CSS-in-JS·컴포넌트 전용 stylesheet는 쓰지 않습니다. 시스템 레벨 CSS는 reset·token·폰트·motion utility 네 가지로 제한합니다.

이건 취향이 아니라 일관성 결정이었습니다. 스타일 표현 방법이 여러 개면 같은 버튼을 세 가지 방식으로 만들게 됩니다. CSS-in-JS와 Tailwind를 두고 고민한 과정은 CSS Modules vs CSS-in-JS vs Tailwind 글에 정리해 뒀는데, 던플마켓은 그중 "런타임 비용 없고 SSR과 잘 맞는" 쪽을 택한 셈입니다.

색은 의미로 관리한다

컴포넌트에서 원시 색상값을 직접 쓰지 않습니다. 색은 의미를 담은 semantic token으로만 쓰고, 값은 OKLCH로 관리하며, light·dark 대비를 자동 테스트로 검증합니다.

foundation palette
  → semantic surface / text
  → component token
  → feature token

행동 색(역할)은 다섯으로 고정했습니다.

role 의미 기본
primary 주요 행동·핵심 강조 navy / violet
secondary 보조 행동 pastel violet
tertiary 골드 가치·포인트 gold
warning 주의·지연 amber
danger 파괴적 행동·치명 오류 rose

여기서 한 가지를 분명히 분리했습니다. 가격 방향과 행동 색을 섞지 않는 것입니다. 한국 사용자에게 익숙한 금융 관례대로 가격 상승은 red, 하락은 blue로 두는데, 이 red를 danger 버튼과 같은 토큰으로 쓰면 "위험"과 "가격 상승"이 뒤엉킵니다. 그래서 price-up/price-down을 행동 role과 별도 토큰으로 뒀습니다. 그리고 색만으로 의미를 전달하지 않도록 부호·label·아이콘을 함께 씁니다.

던전앤파이터 아이템 등급(rarity)도 행동 색과 분리했습니다. mythic·primordial처럼 그라데이션이 필요한 등급은 gradient 토큰을 유지하되, 텍스트·아이콘·차트 범례처럼 그라데이션을 못 쓰는 곳에는 단색 fallback을 둡니다. 그라데이션 위 글자는 text-white/text-black을 직접 쓰지 않고, 그라데이션 양 끝 색 모두에 대해 AA 대비를 만족하는 전용 foreground 토큰을 씁니다.

폰트는 subset을 self-host한다

본문과 숫자는 Pretendard를 씁니다. 처음에는 단일 variable 폰트(약 2MB)를 모든 route가 적재했는데, 최종 점검에서 KS X 1001 한글 subset의 weight별 정적 woff2로 교체했습니다.

구분 크기
단일 variable woff2 약 2MB
KS X 1001 subset (weight당) 약 267KB

UI가 쓰는 네 weight(400/500/600/700)만 next/font/local로 self-host합니다. route당 합계는 단일 blob 대비 약 48% 줄었고, 외부 폰트 요청과 layout shift도 사라졌습니다. Pretendard는 SIL Open Font License로 배포되므로 license 파일도 함께 vendor합니다.

Storybook은 갤러리가 아니라 작업 공간

마지막으로, 컴포넌트를 앱 전체를 띄우지 않고 검증하려고 Storybook을 작업 공간으로 씁니다. 모든 story에서 light·dark 전환과 viewport(360~1440px)를 확인하고, @storybook/addon-a11y(axe)로 접근성을 점검합니다. 여기에 더해 Jest로 light/dark 토큰 parity, WCAG 대비 비율(contrast.spec.ts), 핵심 keyboard 동작을 자동 검증합니다.

솔직히 아직 못 한 것도 있습니다. axe 결과를 CI에서 실패시키는 test-storybook 게이트는, test-runner가 이 워크스페이스의 Jest 30과 아직 호환되지 않아 미뤄 둔 상태입니다. 억지로 의존성을 올리는 대신, 그때까지는 axe 패널과 수동·자동 keyboard 점검을 함께 합니다. 못 한 걸 했다고 적지 않는 것도, 이 시리즈 내내 지켜 온 원칙의 연장입니다.

정리하면

프론트엔드 편의 중심은 "정직함을 화면까지 잇는다"였습니다.

  • 첫 화면은 RSC로 서버에서 그리고, 상호작용이 필요한 잎사귀만 client로 둔다.
  • 서버 상태는 TanStack Query, 작은 client 상태는 Zustand로 역할을 나누고 데이터를 복제하지 않는다.
  • 백엔드의 freshness·providerStatus를 화면에 그대로 드러내, 점검 중에도 빈 화면 대신 마지막 갱신 시각을 보여 준다.
  • 스타일은 Tailwind와 open-code로만, 색은 의미를 담은 토큰으로, 가격 색은 행동 색과 분리한다.
  • 폰트는 subset self-host로 비용을 줄이고, Storybook으로 상태·테마·접근성을 검증한다.

(0)부터 여기까지, 던플마켓을 만들며 가장 자주 되뇐 문장은 하나였습니다. 모르는 것을 아는 척하지 않는다. 백엔드에서는 is_truncatedcollection_gaps로, 프론트엔드에서는 freshness와 "준비 중" 상태로, 같은 원칙이 끝까지 이어졌습니다.

같이 보면 좋은 글