Claude Code로 SaaS 대시보드를 하루 만에 재구축 — Tailwind v4 + CSS Grid 테이블형 레이아웃

HeatMapX Engineering Team22 min read
  • engineering
  • tailwind
  • css-grid
  • dark-mode
  • claude-code

TL;DR

  • 문제: 신규 유저 15명 중 11명(73%)이 회원가입 후 URL 등록 없이 이탈
  • 가설: 우리 카테고리의 모든 히트맵 툴은 라이트 UI가 기본 — 다크 대시보드가 혼란과 초기 이탈을 유발하고 있을 가능성
  • 실행: 하루 만에 전체 대시보드 재설계 — 라이트 / 다크 / 시스템 3단계 토글 + GitHub 스타일 테이블형 목록
  • 배운 것: Tailwind v4의 @custom-variant, CSS Grid auto 컬럼의 함정, 긴 텍스트에 대한 3단계 방어, 그리고 10~16번의 반복이 예외가 아닌 표준인 이유
  • 결과: 이탈률 영향은 측정 중; 2주치 데이터 확보 후 후속 포스트 예정

배경: 대시보드가 너무 어두워서 유저를 잃고 있었던 걸까?

HeatMapX(heatmapx.com)는 Claude Code CLI에서 호출할 수 있는 히트맵 및 CRO 도구입니다. 첫 번째 프로모션 푸시를 시작한 뒤, 한 가지 패턴이 눈에 띄었습니다. 신규 유저 15명 중 11명(73%)이 회원가입은 완료했지만 URL을 단 하나도 등록하지 않고 떠났습니다.

하나의 가설: 우리 카테고리의 주요 히트맵 툴들은 모두 라이트 UI를 기본값으로 제공합니다. HeatMapX만 어두운 관리자 인터페이스를 쓰는 이단아였습니다. 흰 테마 앱들 사이에서 처음 다크 대시보드를 마주한 유저들이 혼란스러워 이탈하고 있는 게 아닐까요.

그래서 Claude Code와 함께 단 하나의 세션에서 전면 재설계를 단행했습니다. 이 글은 그 가설 검증의 첫 번째 파트 — 이탈 진단과 재설계 실행을 다룹니다. 실제 수치를 담은 후속편은 배포 후 2주치 데이터가 쌓이면 공개합니다.

최종 디자인 설정

요소 라이트 다크
페이지 배경 bg-neutral-100 (#f5f5f5) dark:bg-neutral-950 (#0a0a0a)
카드 배경 bg-white + border-slate-200 dark:bg-slate-900 + dark:border-slate-800
기본 텍스트 text-slate-900 dark:text-slate-100
키 컬러 (CTA) bg-orange-600 (#ea580c) 양쪽 모드 공유
테두리 반경 rounded-md (6px) · 그림자 없음 · GitHub 스타일
테마 토글 ☀ / 💻 / 🌙 헤더의 3단계 토글

Tailwind v4 다크 모드 — 기본 설정에서 수동 토글까지

@media prefers-color-scheme으로 시작하기

Tailwind v4의 기본 동작은 dark: 변형을 @media (prefers-color-scheme: dark)에 직접 연결합니다. 별도 설정 없이, 그냥 OS 다크 모드 설정을 따라갑니다.

{/* layout.tsx */}
<div className="bg-neutral-100 text-slate-900 dark:bg-neutral-950 dark:text-slate-100">
  ...
</div>

이것으로 OS 추적 케이스(밤에 자동 다크 전환 등)는 추가 작업 없이 처리됩니다. 하지만 유저가 OS 설정을 오버라이드하고 싶을 때도 있습니다 — "시스템"이 오작동하거나 단순히 개인 취향이거나. 그래서 3단계 토글로 확장했습니다.

클래스 기반 dark 변형으로 전환

Tailwind v4에서는 @custom-variant를 사용해 dark: 변형 동작을 재정의할 수 있습니다. <html>.dark가 있을 때만 dark:가 발동되도록 전환했습니다.

/* globals.css */
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

:where() 래퍼는 특이성(specificity)을 0으로 유지합니다. 없으면 다크 변형 스타일이 스타일시트의 다른 특이성 규칙과 충돌할 수 있습니다.

SSR 플래시 방지를 위한 동기화 스크립트

React 하이드레이션 이후 useEffect 안에서 테마를 적용하면, 첫 렌더링 시 잠깐 흰 화면이 번쩍이는 FOUC(Flash of Unstyled Content)가 발생합니다. 해결책은 body가 렌더링되기 전 <head>에 삽입해 동기적으로 실행되는 인라인 스크립트입니다.

{/* app/layout.tsx */}
<head>
  <script dangerouslySetInnerHTML={{ __html: `
    (function(){try{
      var t = localStorage.getItem('theme') || 'system';
      var d = t === 'dark' || (t === 'system' &&
        window.matchMedia('(prefers-color-scheme: dark)').matches);
      if (d) document.documentElement.classList.add('dark');
    }catch(e){}})();
  `}} />
</head>

이 스크립트는 React가 마운트되기 전에 동기적으로 실행되므로, body가 렌더링을 시작할 때 이미 <html>.dark가 붙어 있습니다. <html>suppressHydrationWarning을 추가해 하이드레이션 불일치 경고를 억제하세요.

💡 Lesson 1: Tailwind v4의 dark:는 설정 없이도 작동합니다. OS 추적만으로 충분하다면 아무것도 건드릴 필요 없습니다. 수동 사용자 토글이 필요할 때만 @custom-variant + 동기화 스크립트를 추가하면 됩니다 — 깔끔한 2단계 접근법입니다.

CSS Grid 테이블 정렬 — auto 컬럼이 물어뜯는 지점

단일 컬럼 목록은 역부족이었다

등록 후 사이트 카드가 "뭔가 이상하다"는 피드백을 받은 뒤, 가장 먼저 떠오른 방법을 시도했습니다. 2열 카드 그리드를 1열 목록으로 교체한 것입니다. 각 SiteCard는 내부적으로 flexbox를 써서 이름 · URL · 상태 · 이벤트를 정렬하고, 오른쪽에 꺾쇠 표시를 뒀습니다.

문제는 항목이 여러 개 생기자마자 드러났습니다.

짧은 이름 · example.com · ● Active · 5,577
매우 긴 사이트 이름이라서... · normal.com · ● Active · 5,577
ACME · acme.io · ● Active · 5,577

· Active, · 5,577, › 꺾쇠가 모두 행마다 다른 x 좌표에 위치합니다. 눈이 "상태 컬럼은 여기"라는 기준을 잡을 수가 없습니다. 목록처럼 스캔되지 않고 텍스트가 쌓인 것처럼 읽힙니다.

이건 목록이 아니었습니다. 텍스트를 세로로 쌓아놓은 것이었습니다.

CSS Grid 테이블 레이아웃으로 전환

flexbox 내부를 CSS Grid 고정 컬럼 정의로 교체했습니다. 모든 카드가 동일한 grid template를 공유하기 때문에, 컬럼 왼쪽 가장자리가 페이지 전체에서 완벽하게 정렬됩니다.

// SiteCard.tsx
<Link className="grid grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,1.5fr)_minmax(0,3fr)_110px_72px_20px] items-center gap-x-4 ...">
  <h3 className="truncate">{name}</h3>
  <span className="truncate">{displayUrl}</span>
  <span className="truncate font-mono">{apiKey}</span>
  <span className="truncate font-mono">{trackerSnippet}</span>
  <span>● Active</span>
  <span>{count}</span>
  <span>›</span>
</Link>

헤더 행도 완전히 동일한 grid template을 사용합니다.

// page.tsx 헤더 행
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,1.5fr)_minmax(0,3fr)_110px_72px_20px] gap-x-4 ...">
  <span>SITE</span>
  <span>URL</span>
  <span>API KEY</span>
  <span>TRACKER TAG</span>
  <span>STATUS</span>
  <span>TODAY</span>
  <span></span>
</div>

함정: 별개 그리드의 auto 컬럼은 독립적으로 크기가 계산된다

첫 번째 시도는 grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto_auto_auto]를 사용했습니다. 생각은 이랬습니다. "auto는 콘텐츠에 맞게 조정되고, 헤더와 SiteCard가 같은 template 문자열을 공유하니 정렬될 것이다."

정렬되지 않았습니다. 이유는 다음과 같습니다.

  • 헤더의 auto 컬럼: "STATUS" 텍스트 너비에 맞춰 크기 계산
  • SiteCard의 auto 컬럼: "● Active" 텍스트 너비에 맞춰 크기 계산
  • 둘은 별개의 grid 컨테이너 — 트랙 크기는 각자의 콘텐츠 기준으로 독립 계산됨

두 가지 해결책:

  1. 고정 너비: auto110px, 72px 같은 명시적 픽셀 값으로 교체 (우리가 실제 배포한 방법)
  2. CSS Subgrid: 헤더와 모든 SiteCard를 단일 부모 그리드 안에 넣고, 각 행이 subgrid로 부모 트랙을 상속 (더 복잡함)

💡 Lesson 2: 별개 grid 컨테이너에 걸쳐 테이블 정렬이 필요할 때 auto 컬럼은 사용하지 마세요. 각 컨테이너는 자신의 콘텐츠 기준으로 트랙 너비를 독립 계산합니다. 고정 픽셀 너비를 사용하거나 subgrid로 트랙을 공유하세요.

긴 텍스트 방어 — 3단계

초기 DB 스키마는 이랬습니다.

CREATE TABLE sites (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name text NOT NULL,  -- 길이 제한 없음
  url text NOT NULL,   -- 길이 제한 없음
  ...
);

Postgres의 text는 사실상 무제한입니다. 10,000자짜리 사이트 이름도 완전히 유효한 SQL이고 — 테이블 레이아웃을 즉시 박살냅니다. 고정 컬럼 그리드로 전환하면서 텍스트 길이 제약은 선택이 아닌 필수가 됐습니다.

우리가 채택한 3단계 방어:

  1. 프론트엔드 HTML5 제한: 입력 필드에 maxLength={60} / maxLength={512}
  2. 서버 사이드 검증: Server Action에서 name.length > 60을 확인하고 오류 반환
  3. 표시 시 잘라내기: 모든 grid 셀에 truncate 클래스 적용, minmax(0, ...)과 조합해 overflow 활성화

3단계는 사람들이 가장 자주 놓칩니다. truncateoverflow: hidden + text-overflow: ellipsis + white-space: nowrap으로 확장되지만, flex나 grid 컨텍스트에서는 셀에 min-width: 0도 필요합니다 — 없으면 셀이 콘텐츠에 맞게 늘어나 overflow가 발동하지 않습니다. minmax(0, ...)이 정확히 그 역할을 합니다.

16번의 디자인 반복

이번 세션에서 SiteCard 하나만 16번의 디자인 반복을 거쳤습니다. 주요 턴:

# 변경 내용 이유
1 CopyBlock을 밝은 회색 pill로 교체 검은 배경 + 초록 텍스트가 GitHub 라이트 모드에서 어색함
3 A/B/C/D 4가지 옵션 비교 클릭 어포던스를 더 강하게 만들 필요가 있었음
4 1열 목록으로 전환 "너무 장식적"이라 기각 — 기본으로 복귀
7 "View Heatmap →" CTA 제거 "깔끔해 보이지 않는다"
9 CSS Grid 테이블 레이아웃으로 전환 구분자 기반 문자열 레이아웃이 "목록 같지 않다"
10 auto → 고정 픽셀 너비 헤더와 카드 컬럼이 정렬되지 않음
11 API KEY 컬럼 복원 "어, API 키는 어디 갔지?"
12 TRACKER TAG 컬럼 추가 "둘 다 나란히 놓아달라"
14 maxLength + 서버 검증 추가 긴 텍스트 견고성을 위한 제대로 된 수정
16 헤더 옆에 "+" 버튼 추가 (컴팩트 모드) 스크롤 없이 빠르게 추가

처음 시도에 완성되길 기대하지 마세요. 디자인 반복은 "개발 서버에서 확인 → 즉각적인 피드백 → 다음 시도"를 10번 이상 반복하는 것을 의미합니다. 처음부터 그 루프를 계획에 포함하세요. Claude Code 같은 인터랙티브 AI 개발 환경은 이 패턴이 진정으로 빛을 발하는 곳입니다.

AI 보조 개발에서 실제로 효과적이었던 패턴

이건 특정 도구에 한정된 이야기가 아닙니다 — 인터랙티브 AI와 함께 작업할 때 효과적으로 증명된 일반적인 패턴들입니다.

  • 새 API 문법 조회 비용 제로: Tailwind v4의 @custom-variant는 비교적 새로운 기능입니다. Claude Code가 즉시 동작하는 형태로 제시해줘서 문서를 뒤질 필요가 없었습니다.
  • 구현 → 확인 → 수정 루프가 수 초 만에 돌아감: CSS Grid auto 정렬 오류를 재현하고, 원인을 파악하고, 수정을 배포하는 데 총 약 5분이 걸렸습니다.
  • 기계적 반복 작업이 사라짐: 15개 로케일 파일에서 플레이스홀더 텍스트(in-golexample.com)를 한 번의 반복으로 교체.
  • HMR 친화성: 수정 → 저장 → 즉각 브라우저 새로고침 → 피드백 → 재수정. 루프가 빠르게 돌아가 몰입 상태를 유지합니다.
  • 한 세션에서 엔드투엔드 완결: 재설계 완료부터 프로덕션 푸시, Vercel 배포 확인까지 — 컨텍스트 전환 없음.

잘 작동하지 않은 패턴은 다음 섹션에 있습니다.

힘들었던 것 / 아직 남은 것

  • 모호한 피드백을 구조적 결정으로 번역하는 과정이 반복 횟수를 늘렸습니다. "뭔가 이상하다"가 "테이블 vs. 목록 vs. 카드" 같은 구조적 합의가 되기 전까지는 아무것도 진행되지 않았습니다. 처음에 구조적 합의를 먼저 했다면 3~4번의 반복을 줄일 수 있었을 것입니다.
  • 10번째 반복 즈음부터 요구사항이 계속 늘어났습니다 — "모두 인라인으로", "모두 잘리게", "모든 컬럼을" — 그리고 일부 제약은 진정으로 상충 관계에 있었습니다(정보 밀도 vs. 가독성). 최종 형태에 이르는 깔끔한 지름길은 없었습니다.
  • 모바일은 손도 안 댔습니다. 7열 그리드는 작은 화면에서 가로 스크롤을 유발합니다. 해결책은 sm: 미디어 쿼리로 컬럼을 쌓거나, 별도의 모바일 컴포넌트를 만드는 것입니다. 다음에 해결할 과제입니다.

결론

단 하나의 세션에서 HeatMapX 대시보드를 다크 카드 그리드에서 라이트/다크/시스템 토글 레이아웃과 GitHub 스타일 테이블형 목록으로 재구축했습니다. 핵심 기술적 시사점:

  1. Tailwind v4 다크 모드: @media 기반 다크 모드로 시작하세요 — 설정이 필요 없습니다. 유저가 수동 오버라이드를 필요로 할 때만 @custom-variant + 동기화 스크립트를 추가하세요.
  2. CSS Grid 테이블 정렬: auto 컬럼은 별개 grid 컨테이너 간에 정렬되지 않습니다. 고정 픽셀 너비 또는 CSS Subgrid를 사용하세요 — 신뢰할 수 있는 선택지는 이 둘뿐입니다.
  3. 긴 텍스트 견고성: 3단계 모두 필요합니다 — 프론트엔드 maxLength, 서버 사이드 길이 검사, 그리고 minmax(0, ...)과 조합한 truncate 표시.
  4. 디자인 반복 예산: 컴포넌트당 10~16번의 반복을 예상하고 처음부터 빠른 피드백 루프를 구축하세요.

이것이 실제로 이탈률을 개선하는지는 아직 열린 질문입니다. 2주치 실제 데이터와 함께 후속편을 공개할 예정입니다.

Claude Code에서 실행하는 히트맵, 무료로 시작.

한 줄의 트래커 태그를 붙이고 CLI에서 분석부터 개선 제안까지.