[사이드 프로젝트] 공동 컴포넌트 개발

이언덕·2025년 10월 26일
post-thumbnail

1️⃣ 파일 구조 정리

공동 컴포넌트를 만들기 전에 먼저 src 구조를 다듬었다.
정리 전 → 정리 후로 비교해 보면 변화가 명확하다.

🗂 정리 전

파일 구성이 통일되지 않았고, 폴더별 역할이 모호했다.
예를 들어 components/ui와 shared/ui가 섞여 있고,
전역 상수와 SEO 설정도 각기 다른 위치에 흩어져 있었다.



🧾 정리 후

공통 컴포넌트는 shared/, 전역 설정은 lib/,
메타데이터는 seo/로 분리했다.
결과적으로 폴더 역할이 명확해지고 import 경로도 단순해졌다.





📁 폴더 역할 정리

app/

Next.js App Router의 루트 폴더.
라우팅, 전역 레이아웃, 에러 경계 등을 관리한다.
서버/클라이언트의 진입점이며 모든 페이지 구조의 기준이 된다.

components/

페이지나 도메인에 종속된 조립형 UI를 모은다.
예: 랜딩 섹션 카드, 인증 폼, 기능 선택 카드 등
즉, 페이지에서 바로 사용하는 중간 단위 컴포넌트들이다.

hooks/

재사용 가능한 React 훅을 보관한다.
UI와 도메인에 관계없이 쓸 수 있는 범용 로직을 넣는다.
예: 브레이크포인트 감지, 스크롤 락, 포커스 제어 등.

lib/

앱 전반에서 쓰이는 유틸 함수와 전역 상수를 모은다.
환경변수(SITE_URL), 라우트 상수, 포맷터 등 공용 로직이 위치한다.
앱의 “도구함” 역할을 한다.

seo/

Next.js Metadata 설정 전용.
전역 메타데이터(baseMetadata)와 각 페이지의
SEO 오버라이드 유틸(makePageMetadata)을 관리한다.
SEO 관련 로직은 이곳에 집중시켰다.

shared/ ⚠️

버튼, 인풋, 아이콘처럼 여러 페이지에서 공통으로 사용하는 UI를 모은다.
디자인 시스템의 “원자/분자” 단위에 해당하며, shadcn에서 생성되는 컴포넌트의 기본 위치다.

⚠️ 주의:
shadcn alias를 @/shared로 바꾸어야 한다.
components.json 파일을 아래처럼 수정한다.

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "new-york",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "",
    "css": "src/styles/globals.css",
    "baseColor": "zinc",
    "cssVariables": true,
    "prefix": ""
  },
  "iconLibrary": "lucide",
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/shared",   // ✅ 변경된 부분
    "lib": "@/lib",
    "hooks": "@/hooks"
  },
  "registries": {}
}

.

stores/

Zustand 전역 상태 관리 폴더.
서버 상태는 React Query가, 클라이언트 상태(UI/플래너 등)는 이곳에서 관리한다.
예: 사용자 정보, 모달 상태, 테마 설정 등.

styles/

전역 스타일시트 보관소.
Tailwind 초기화(globals.css)와 폰트, 리셋 등
앱 전체에 영향을 주는 스타일을 관리한다.

types/

공용 타입 선언 폴더.
외부에서 생성된 타입(Supabase 등)이나 앱 전역 유틸 타입을 모아
다른 모듈에서 참조할 수 있도록 한다.




2️⃣ 페이지 파일 추가 — 라우트 구조 잡기

폴더를 정리했으니 이제는 페이지 파일을 추가할 차례다.
Next.js App Router에서는 app/ 폴더의 구조가 곧 URL 구조이기 때문에,
초기 설계가 전체 페이지 관리의 기반이 된다.

이번 단계의 목표는 단순하다.
“전체 페이지의 뼈대만 먼저 만든다.”
아직 내용은 비워두고, URL 체계와 라우트 그룹 구조를 세팅한다.

📂 라우트 그룹(Route Group) 이해하기

1️⃣ Route Group이란?

Next.js에서 (폴더명) 형태는 Route Group을 뜻한다.
이 폴더는 URL 경로에는 표시되지 않지만, 내부적으로 공통 레이아웃·규칙을 공유할 수 있는 단위다.

쉽게 말해, “보이는 주소는 그대로 두고, 안쪽 폴더만 역할별로 정리할 수 있는 상자”다.

2️⃣ 왜 써야 할까?

이유설명
✅ URL을 깔끔하게 유지내부 폴더 구조가 복잡해도, 외부 경로는 /login, /planner처럼 단순하게 유지된다.
🧩 공통 UI를 한 번만 작성(auth)/layout.tsx 하나로 로그인·회원가입·비밀번호찾기 레이아웃을 재사용할 수 있다.
🧱 유지보수가 쉽다“마케팅/인증/온보딩/실사용” 등 주제별로 정리하면 협업 시 흐름이 한눈에 들어온다.
🔐 보안 경계 설정이 명확(planner) 영역은 로그인 후에만 접근하도록 그룹 단위로 제한할 수 있다.

3️⃣ 언제 쓰면 좋을까?

  • 공통 레이아웃이 필요한 구간
    • 예: (auth) 그룹의 로그인/회원가입/비밀번호 찾기 → 같은 인증 카드 UI 공유
  • 단계별 흐름(온보딩/설정)
    • 예: (setup) 그룹의 모듈 선택 → 디자인 선택 → 배치 완료
  • 보호가 필요한 내부 페이지
    • 예: (planner) 그룹의 사용자 플래너 → 로그인 인증이 필요

📁 이번 프로젝트의 라우트 구조

아래 구조는 현재 PlanMate 프로젝트의 전체 페이지를 기능 단위로 나눈 것이다.

src/
└─ app/
   ├─ (marketing)/
   │   ├─ layout.tsx
   │   └─ page.tsx                         # → "/"
   │
   ├─ (auth)/
   │   ├─ layout.tsx
   │   ├─ login/
   │   │   └─ page.tsx                     # → "/login"
   │   ├─ signup/
   │   │   ├─ page.tsx                     # → "/signup"              (선택 카드)
   │   │   └─ basic/
   │   │       └─ page.tsx                 # → "/signup/basic"         (이름) → (이메일) → (비밀번호) → (약관 동의)
   │   │
   │   └─ forgot-password/
   │       └─ page.tsx                     # → "/forgot-password"     (이메일 입력) → (이메일 인증) → (비번 재설정)
   │
   ├─ (setup)/
   │   ├─ layout.tsx
   │   └─ setup/
   │       ├─ page.tsx                     # → "/setup"               (모듈 선택)
   │       ├─ [module]/
   │       │     └─ page.tsx               # → "/setup/:module"       (모듈별 디자인 선택)
   │       └─ layout/
   │            └─ page.tsx                # → "/setup/layout"        (배치 보드)
   │
   ├─ (planner)/
   │   ├─ layout.tsx
   │   └─ page.tsx                         # → "/planner"
   │   # (필요 시 모듈별 화면은 이후에 추가)
   │   # ├─ daily/page.tsx                # → "/planner/daily"
   │   # ├─ weekly/page.tsx               # → "/planner/weekly"
   │   # ├─ monthly/page.tsx              # → "/planner/monthly"
   │   # ├─ todos/page.tsx                # → "/planner/todos"
   │   # ├─ habits/page.tsx               # → "/planner/habits"
   │   # └─ notes/page.tsx                # → "/planner/notes"
   │
   ├─ layout.tsx                           # 루트 레이아웃(전역 CSS/Provider/메타)
   ├─ global-error.tsx
   ├─ not-found.tsx
   ├─ loading.tsx
   └─ QueryProvider.tsx


괄호(( )) 폴더명은 URL에 포함되지 않는다.
즉 (auth)/login의 실제 주소는 /login이다.

🧭 라우트 그룹별 역할

(marketing)

  • 서비스 소개, 기능 설명, 랜딩 페이지 등을 모은다.
  • 로그인 없이 접근 가능한 공개 구간이다.
  • 예: / (랜딩), /about, /contact 등

(auth)

  • 로그인, 회원가입, 비밀번호 찾기 등 인증 관련 페이지를 모은다.
  • /login, /signup, /reset-password로 연결된다.
  • (auth)/layout.tsx를 두어 로고, 중앙 카드 등 공통 디자인을 적용한다.

(setup)

  • 첫 가입 후 사용자 맞춤 설정(온보딩) 단계.
    “모듈 선택 → 디자인 선택 → 배치 완료” 순서로 진행된다.
  • CTA 버튼, Back 버튼이 고정된 레이아웃을 공유한다.

(planner)

  • 로그인 후 사용하는 실제 서비스 핵심 구간이다.
    /planner 대시보드 및 /daily, /weekly, /todos 등 주요 기능 화면을 포함한다.
  • 로그인한 사용자만 접근 가능한 보호 라우트 영역이다.



🧩 페이지 템플릿

0) page.tsx 대표 예시 (Auth/Login)

// src/app/(auth)/login/page.tsx
import { makePageMetadata } from "@/seo/metadata";

export const metadata = makePageMetadata({
  title: "로그인",
  description: "PlanMate 로그인 페이지",
  canonical: "/login",
});

export default function LoginPage() {
  return <div>로그인 페이지</div>;
}

다른 page.tsx도 동일 패턴으로
파일 경로만 다르고, makePageMetadata() 안의 값과 <div>페이지명</div> 정도만 바꾸면 된다.

🧭 페이지별 Metadata 작성 가이드

Next.js App Router에서는 각 페이지 단위로 metadata를 지정하면
SEO, 공유 미리보기, 검색 노출 제어 등을 세밀하게 관리할 수 있다.
PlanMate에서는 이 과정을 단순화하기 위해 makePageMetadata() 유틸을 사용한다.

1️⃣ 기본 정보 (모든 페이지 공통)

각 페이지에서 makePageMetadata()를 불러오면,
전역 기본값(title, description, OG 이미지, 언어, 검증 코드 등)이 자동으로 포함된다.
필요한 값만 덮어쓰면 된다.

import { makePageMetadata } from "@/seo/metadata";

export const metadata = makePageMetadata({
  title: "회원가입",
  description: "PlanMate 회원가입 페이지",
  canonical: "/signup",
});

✅ metadataBase, openGraph, twitter, verification 등은
이미 /seo/metadata.ts의 baseMetadata에서 전역 관리 중이므로
페이지마다 다시 작성할 필요가 없다.

2️⃣ 인덱싱 제어 (비공개 페이지용)

  • 로그인, 비밀번호 찾기, 설정 등은 검색 제외 권장
  • makePageMetadata()는 필요한 옵션만 받아들이므로, robots는 직접 추가한다
export const metadata = {
  ...makePageMetadata({
    title: "로그인",
    canonical: "/login",
  }),
  robots: { index: false, follow: false },
};

follow: true로만 두면 링크는 크롤러가 추적하지만 인덱싱은 하지 않는다.

3️⃣ 배포 환경별 제어 (옵션)

  • 프리뷰/스테이징 환경에서는 자동으로 noindex를 걸고 싶을 때
const isProd = process.env.NEXT_PUBLIC_VERCEL_ENV === "production";

export const metadata = {
  ...makePageMetadata({
    title: "로그인",
    canonical: "/login",
  }),
  robots: isProd ? { index: true, follow: true } : { index: false, follow: false },
};

4️⃣ 동적 라우트 페이지

파라미터에 따라 타이틀·캐노니컬이 달라지는 페이지는
정적 metadata 대신 generateMetadata()를 이용해 makePageMetadata()를 호출한다.

// app/(planner)/notes/[id]/page.tsx
import { makePageMetadata } from "@/seo/metadata";

export async function generateMetadata({ params }: { params: { id: string } }) {
  return makePageMetadata({
    title: `메모 상세 #${params.id}`,
    canonical: `/planner/notes/${params.id}`,
  });
}

export default function NotePage() {
  return <div>메모 상세</div>;
}

5️⃣ 공개 페이지 (Open Graph / Twitter)

마케팅·랜딩 페이지처럼 공유 미리보기가 필요한 경우,
makePageMetadata()의 ogImage만 바꾸면 된다.

export const metadata = makePageMetadata({
  title: "PlanMate — 맞춤형 데일리 플래너",
  description: "나만의 플래너를 만들 수 있는 서비스",
  canonical: "/",
  ogImage: "/og/landing.png",
});

openGraph나 twitter 필드는 전역 baseMetadata에 이미 정의되어 있으므로
ogImage만 넘겨도 자동으로 병합된다.

6️⃣ 언어·다국어 (전역에서 자동 적용)

언어별 URL(ko, en)은 이미 baseMetadata.alternates.languages로 등록되어 있다.
개별 페이지에서는 canonical만 넣으면 된다.

7️⃣ 작성 시 주의사항

  • 전역 CSS(global.css)는 layout.tsx에서만 import
    개별 page.tsx에서는 중복 import 금지.
  • 에러/로딩/404 페이지는 그룹 또는 루트 단위로 한 번만 둔다.
    (error.tsx, loading.tsx, not-found.tsx, global-error.tsx 등)
  • shadcn/ui 컴포넌트는 실제 UI 구현 시점에 추가.
    지금은 <div>페이지명</div>까지만 작성해도 충분하다.



1) 루트 레이아웃

// src/app/layout.tsx
import { baseMetadata } from "@/seo/metadata";
import type { Metadata } from "next";
import "../styles/globals.css";
import { Providers } from "./QueryProviders";

export const metadata: Metadata = baseMetadata;

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

2) 그룹 레이아웃들

// src/app/(marketing)/layout.tsx
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
  return <>{children}</>;
}
// src/app/(auth)/layout.tsx
export default function AuthLayout({ children }: { children: React.ReactNode }) {
  return <>{children}</>;
}
// src/app/(setup)/layout.tsx
export default function SetupLayout({ children }: { children: React.ReactNode }) {
  return <>{children}</>;
}
// src/app/(planner)/layout.tsx
export default function PlannerLayout({ children }: { children: React.ReactNode }) {
  return <>{children}</>;
}

✅ 정리

  • seo/metadata.ts를 유지하고, 모든 페이지는 makePageMetadata()를 통해 메타데이터를 간결하게 선언한다.
  • baseMetadata는 루트 레이아웃에서 한 번만 export.
  • 각 페이지는 필요한 항목(title, description, canonical, ogImage)만 넘기면 된다.
  • 덕분에 전역 SEO, 다국어, OG 이미지, 검증 태그를 한곳에서 통일 관리할 수 있다.


🔨 metadata 리팩토링 — layout.tsx로 이동한 이유와 구조

목표
각 페이지에 흩어져 있던 metadata를 그룹 단위(layout.tsx)로 옮겨
“SEO 에러 없이, 관리하기 쉬운 구조”로 정리한다.

1️⃣ 왜 옮겨야 했나

Next.js 13(App Router)부터는 클라이언트 컴포넌트("use client") 안에서 metadata를 export할 수 없다.
즉, 다음 코드는 빌드 에러가 난다.

"use client";

export const metadata = { title: "로그인" }; // ❌ 금지

metadata는 서버 전용 속성이기 때문이다.
따라서 클라이언트 훅(useState, onClick)을 사용하는 페이지에서는
metadata를 해당 라우트 그룹의 layout.tsx로 옮겨야 한다.

2️⃣ 리팩토링 구조

리팩토링 후에는 각 라우트 그룹((marketing)/(auth)/(setup)/(planner)) 에만
layout.tsx를 두고, 그 안에서 SEO 정보를 정의한다.

그룹역할경로
(marketing)랜딩 페이지/
(auth)로그인/회원가입/비밀번호 재설정/login, /signup, ...
(setup)온보딩 설정/setup, /setup/:module
(planner)실제 플래너 화면/planner

→ 이 4곳만 별도 layout.tsx를 두면 된다.

3️⃣ 그룹별 layout.tsx — makePageMetadata()로 선언

이제 각 그룹은 필요할 때만 SEO를 정의한다.
하위 page.tsx 파일에는 "use client"를 자유롭게 써도 된다.

예: 랜딩 페이지 (app/(marketing)/layout.tsx)

import { makePageMetadata } from "@/seo/metadata";

export const metadata = makePageMetadata({
  title: "PlanMate — 맞춤형 데일리 플래너",
  description: "원하는 모듈을 조합해 나만의 플래너를 만드는 PlanMate 랜딩 페이지",
  canonical: "/",
});

export default function MarketingLayout({ children }: { children: React.ReactNode }) {
  return <>{children}</>;
}

4️⃣ 그럼 하위 페이지들은?

결론적으로 각 그룹 내부의 모든 page.tsx에는 metadata를 넣을 필요가 없다.

케이스어떻게 해야 하는가
page.tsx가 "use client"인 경우✅ metadata 제거 (layout.tsx에서 상속됨)
하위 페이지마다 다른 타이틀·canonical이 필요⚙️ generateMetadata() 함수 사용
동적 라우트(/setup/[module] 등)⚙️ generateMetadata()로 동적 값 생성
특별한 SEO 조정이 필요 없음✅ 아무 것도 하지 않아도 됨 (부모 layout 상속됨)

✅ 최종 정리

  • metadata는 그룹 단위(layout.tsx)에서만 export한다.
    → 하위 page.tsx에 중복된 metadata export가 있다면 전부 삭제
  • layout이 없는 페이지라면, 상위 그룹 layout의 metadata가 자동 상속된다.
  • 각 페이지별 title/canonical이 달라야 할 때만 generateMetadata() 사용
  • 클라이언트 페이지(use client)에는 절대 metadata export 금지



🧩 공동 컴포넌트 (랜딩페이지)

목적: 랜딩페이지에서 재사용할 수 있는 “공용 버튼” 컴포넌트를 만든다.
첫 번째로 Hero 섹션에 사용되는 버튼(Desktop, Mac, iOS)을 기준으로 구조를 잡고,
이후 Feature, SpecialCard 같은 컴포넌트도 같은 방식으로 확장할 수 있게 설계한다.

1) HeroButton

이렇게 생겼다 / 이렇게 동작한다

🎨 기본 상태

  • 세 버튼 모두 흰 배경 + 얇은 회색 테두리(#d4d4d4)
  • 좌측에 아이콘(Monitor, Apple, Smartphone)과 라벨 텍스트(Desktop, Mac, iOS)
  • 텍스트 색상은 #111827(color-gray-900), 폰트 두께는 medium(t-18-m)
  • 전체적으로 입체감 없이 평면적, shadow는 없음



⚫️ 호버 상태

  • 호버된 버튼만 검정 배경(#000) + 흰 텍스트(#fff) + 검정 테두리(#000) 로 반전
  • 나머지 버튼(Mac, iOS)은 그대로 유지
  • 아이콘 색도 자동 반전되어 강조 효과 발생
  • 모양은 동일하게 캡슐형(rounded-full), 그림자 없이 깔끔한 대비만 사용





Hero 버튼 구현 — 공용 Button으로 시작하기

버튼 하나라도 유지보수하기 좋은 구조로 만들기 위해
기능·스타일·데이터를 서로 분리했다.

  • /lib/variants → 버튼의 모양과 스타일(cva) 정의
  • /types → 버튼이 받을 수 있는 속성(Props)을 한눈에 관리
  • /shared → 실제 화면에서 렌더링되는 컴포넌트 (이 컴포넌트를 화면페이지에서 import해서 사용하는 거임)

이렇게 나누면 나중에 스타일을 바꾸거나 새로운 버튼을 추가할 때
서로 영향을 주지 않고 확장할 수 있다.

또, react-lucide 아이콘은 버튼 내부가 아니라 화면 쪽에서 children으로 합성한다.
이렇게 하면 버튼은 디자인과 동작에만 집중하고,
텍스트·아이콘 조합은 필요할 때 자유롭게 바꿀 수 있다.

📁 폴더 구조

lib/
  variants/
    button.hero.ts         # Hero 전용 cva (스타일만)
shared/
  button.tsx               # 공용 Button 컴포넌트 (현재 Hero 스타일 적용)
types/
  button.ts                # 공용 Button 프롭 타입

.

(1) lib/variants/button.hero.ts — Hero 전용 cva

// lib/variants/button.hero.ts
import { cva } from "class-variance-authority";

export const heroButtonVariants = cva(
  [
    // Layout
    "inline-flex items-center justify-center",
    "h-[5.4rem] px-[3rem]",

    // Typography (from globals.css)
    "t-18-m",

    // Transitions & a11y
    "transition-[color,background-color,border-color,transform] duration-200",
    "active:translate-y-[1px]",

    // Base (white/gray)
    "bg-[var(--color-white)] text-[var(--color-gray-900)] border border-[var(--color-gray-300)]",

    // Hover invert (black/white/black)
    "hover:bg-[var(--color-black)] hover:text-[var(--color-white)] hover:border-[var(--color-black)]]",
  ].join(" "),
  {
    variants: {
      intent: { primary: "" },
      glow: { true: "shadow-[0_6px_16px_0_rgba(0,0,0,0.08)]", false: "" },
      radius: { true: "rounded-full", false: "" },
    },
    defaultVariants: {
      intent: "primary",
      glow: false,
      radius: true,
    },
  },
);

설명

  • 요구사항에 맞게 단일 사이즈: 높이 5.4rem, 좌우 패딩 3rem.
  • 텍스트는 글로벌 유틸 t-18-m 사용(18px, weight 500).
  • 기본/호버 컬러는 globals.css 토큰에 맞춰 흰↔검정 반전.
  • glow/pill 토글 옵션으로 유지(기본: glow=false, pill=true).
  • 아이콘은 텍스트 색을 상속하므로, 호버 시 자동으로 흰색으로 반전된다.

⚠️ 색깔 토큰 사용

항상 색깔 토큰을 사용할 때는 var를 집어넣어 사용한다
예) text-[var(--color-gray-900)]

(2) types/button.ts — 공용 버튼 Prop 타입

// types/button.ts
import type { ButtonHTMLAttributes } from "react";

/** 모든 버튼 공통 HTML 속성 + 공용 옵션의 바탕 */
export interface BaseButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  asChild?: boolean; // <button> 대신 <a>/<Link> 등으로 대체
  className?: string; // 필요 시 tailwind 덧입히기
}

/** 화면에서 실제로 쓰는 공용 옵션(현재는 Hero 기준) */
export type ButtonIntent = "primary";

/** 공용 Button 프롭 — 화면에서 쓰는 건 이 세 가지(+asChild/className) */
export interface ButtonProps extends BaseButtonProps {
  intent?: ButtonIntent; // 색/톤: outline(요구안) | primary(선택)
  glow?: boolean; // 글로우 그림자 on/off (기본 false)
  pill?: boolean; // 캡슐형 on/off (기본 true)
}

설명

  • 이 파일은 버튼이 받을 수 있는 모든 Prop의 기준점이다.
  • 화면에서 사용하는 Prop은 다음 세 가지다
    1. intent: 색상(채움/외곽선)
    2. glow: 그림자 on/off
    3. pill: 모서리 둥글기 on/off

(3) shared/button.tsx — 공용 Button (현재 Hero 스타일 적용)

// shared/button.tsx
"use client";
import { cn } from "@/lib/utils";
import { heroButtonVariants } from "@/lib/variants/button.hero";
import type { ButtonProps } from "@/types/button";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    { asChild, className, intent = "primary", glow = false, pill = true, children, ...rest },
    ref,
  ) => {
    const Comp = asChild ? Slot : "button";
    return (
      <Comp
        ref={ref}
        type={asChild ? undefined : "button"}
        className={cn(heroButtonVariants({ intent, glow, pill }), className)}
        {...rest}

        {children}
      </Comp>
    );
  },
);
Button.displayName = "Button";

설명

  • 이제 이 컴포넌트가 공용 버튼의 중심이 된다.
  • 화면에서 쓰는 Prop은 intent, glow, pill 세 가지다.
  • 아이콘은 버튼 내부가 아니라 화면 쪽(children) 에서 합성한다.
  • asChild를 쓰면 <Link>나 <a> 태그로 감싸서 쓸 수 있다.
  • 클래스 조합은 cva가 알아서 처리하므로, 컴포넌트는 옵션만 받아 렌더링한다.

(4) 사용 예시 — 아이콘은 화면에서 합성 (react-lucide)

// app/(marketing)/page.tsx
import { makePageMetadata } from "@/seo/metadata";
import { Button } from "@/shared/button";
import { Laptop, Monitor, Smartphone } from "lucide-react";

export const metadata = makePageMetadata({
  title: "PlanMate — 맞춤형 데일리 플래너",
  description: "원하는 모듈을 조합해 나만의 플래너를 만드는 PlanMate 랜딩 페이지",
  canonical: "/",
});

export default function Home() {
  return (
    <div className="flex justify-center">
      <div className="flex gap-3">
        <Button intent="primary" pill>
          <span className="inline-flex items-center gap-2">
            <Monitor className="h-7 w-7" />
            <span>Desktop</span>
          </span>
        </Button>

        <Button intent="primary" pill>
          <span className="inline-flex items-center gap-2">
            <Laptop className="h-7 w-7" />
            <span>Mac</span>
          </span>
        </Button>

        <Button intent="primary" pill>
          <span className="inline-flex items-center gap-2">
            <Smartphone className="h-7 w-7" />
            <span>iOS</span>
          </span>
        </Button>
      </div>
    </div>
  );
}

설명

  • 아이콘과 텍스트 간격은 gap-2로 조절, 구조는 자유롭게 변경 가능.
  • 이렇게 하면 버튼은 “모양과 동작만 담당”하고,
    콘텐츠(텍스트·아이콘)는 사용 화면에서 직접 구성한다.


2) 공용 버튼 확장 — FeatureButton 설계 개요

HeroButton을 기반으로 두 번째 버튼 프리셋(FeatureButton) 을 확장한다.
단순히 스타일을 복제하는 것이 아니라, 앞으로 등장할 모든 버튼(AuthButton, CTAButton 등)을
하나의 공용 시스템(Button System) 안에서 통일하기 위한 설계다.

핵심은 프리셋(preset) 개념이다.
공용 Button 컴포넌트가 preset="hero" | "feature" 값을 받아
내부에서 각각의 스타일 정의(cva)를 자동으로 선택하도록 했다.

이렇게 하면 새로운 버튼을 추가할 때마다 컴포넌트를 새로 만들 필요 없이
lib/variants/button.<preset>.ts 파일 하나만 추가하면 된다.
타입(types/button.ts)과 공용 컴포넌트(shared/button.tsx)는 그대로 유지된다.

FeatureButton은 “기능 소개 섹션”에 맞게

  • 정보 위계가 낮고
  • 가볍게 반응하는 호버(살짝 회색 배경)
    을 목표로 한다.

    이 구조 덕분에 이후 버튼을 추가하더라도 스타일 간섭 없이 독립적으로 관리할 수 있고,
    전체 디자인 시스템의 일관성을 유지할 수 있다.

3) FeatureButton

이렇게 생겼다 / 이렇게 동작한다

🎨 기본 상태

  • 흰 배경, 텍스트 #111827(gray-900), t-16-m
  • 아이콘·텍스트 좌측 정렬형도 자연스럽도록 내부 레이아웃 여유 확보



⚫️ 호버 상태

  • 배경만 살짝 회색 배경#f5f5f5(color-gray-100)
  • 텍스트/아이콘 색상 변화 없음



FeatureButton 구조 — Hero와 동일하게 ‘cva / types / shared’로 분리

lib/
  variants/
    button.hero.ts            # Hero 전용 cva
    button.feature.ts         # Feature 전용 cva
    button.presets.ts         # preset → cva 매핑 스위치
shared/
  button.tsx                  # 공용 Button (preset으로 cva 선택)
types/
  button.ts                   # 공용 프롭 타입

.

(1) lib/variants/button.feature.ts — Feature 전용 cva

// lib/variants/button.feature.ts
import { cva } from "class-variance-authority";

export const featureButtonVariants = cva(
  [
    // Layout
    "inline-flex items-center justify-start w-full gap-3",
    "h-[4.4rem] px-[1.8rem]",

    // Typography
    "t-16-m",

    // Base (no border / transparent / token-based color)
    "bg-transparent",
    "text-[var(--color-gray-900)]",
    "shadow-none",

    // Hover & Interaction (soft bg only)
    "transition-[background-color,transform] duration-150",
    "hover:bg-[var(--color-gray-100)]",
  ].join(" "),
  {
    variants: {
      // Radius (globals.css 토큰과 1:1 매핑)
      radius: {
        sm: "rounded-[var(--radius-sm)]",
        md: "rounded-[var(--radius-md)]",
        lg: "rounded-[var(--radius-lg)]",
        xl: "rounded-[var(--radius-xl)]",
        "2xl": "rounded-[var(--radius-2xl)]",
      },

      // Size 프리셋에서 sm/md/xl/2xl은 일단 빈 슬롯으로 남겨 확장 여지 확보
      size: {
        sm: "",
        md: "",
        lg: "h-[4.8rem] px-[2.2rem] t-18-m",
        xl: "",
        "2xl": "",
      },
    },

    // Defaults
    defaultVariants: {
      radius: "2xl",
      size: "md",
    },
  },
);

설명

  • 색상은 전부 테마 토큰 직참조: text-[var(--color-gray-900)], hover:bg-[var(--color-gray-100)].
  • 보더 X / 그림자 X / 기본 투명 → 호버 때 배경만 은은하게.
  • radius와 size는 확장성으로 여러 옵션을 넣어주었다

(1-1) lib/variants/button.presets.ts — preset 스위치

// lib/variants/button.presets.ts
import type { ButtonPreset } from "@/types/button";
import type { VariantProps } from "class-variance-authority";
import { featureButtonVariants } from "./button.feature";
import { heroButtonVariants } from "./button.hero";

// 각 cva가 허용하는 옵션 타입
export type HeroVariantProps = VariantProps<typeof heroButtonVariants>;
export type FeatureVariantProps = VariantProps<typeof featureButtonVariants>;

// 두 옵션 타입의 교차(공통 superset). 필요한 키만 뽑아 쓸 거라 안전함.
type PresetSupersetOpts = Partial<HeroVariantProps & FeatureVariantProps>;

/**
 * preset에 맞춰 내부에서 알맞은 cva를 선택해 className을 만든다.
 * - 호출부는 기존처럼 하나의 객체를 넘기면 됨 (여분 키는 내부에서 무시)
 */
export function getButtonClasses(preset: ButtonPreset, opts: PresetSupersetOpts = {}): string {
  if (preset === "hero") {
    const { intent, glow, pill } = opts as Partial<HeroVariantProps>;
    return heroButtonVariants({ intent, glow, pill });
  }
  // preset === "feature"
  const { radius, size } = opts as Partial<FeatureVariantProps>;
  return featureButtonVariants({ radius, size });
}

이 파일이 왜 생겼는가?

  • 문제: 버튼을 상황(히어로/기능 소개 등)마다 다르게 꾸미고 싶은데, 파일마다 if/else를 여기저기 흩뿌리면 유지보수가 어렵다.
  • 해결: “버튼 스타일 고르는 스위치”를 한 파일에 모은다.
    preset="hero"면 히어로용 cva, preset="feature"면 기능 소개용 cva를 여기에서만 선택한다.
  • 결과: 실제 버튼 컴포넌트(shared/button.tsx)는 얇고 단순해지고,
    새 스타일군이 추가되어도 이 스위치에 한 줄만 보태면 끝이라 팀 전체가 이해/확장하기 쉽다.

동작 흐름(한 줄 요약)

버튼이 렌더될 때 → preset 값 확인 → 해당하는 cva를 골라 클래스 문자열 생성 → 버튼에 적용

왜 “여기 한 곳”에서만 고르나?

  • 스타일 분기 로직이 하나로 모이면
    1. 어디를 고쳐야 할지 항상 한 눈에 보이고,
    2. 실수로 두 군데를 따로 고칠 일이 없다(일관성),
    3. 온보딩이 쉽다(“스타일 추가는 여기서!” 라고 딱 알려주면 끝).

이렇게 확장한다(예: 새 프리셋 chip 추가) (중요하니 꼭 봐야함)

  1. lib/variants/button.chip.ts 파일을 만들어 chip 전용 cva를 만든다.
   // lib/variants/button.chip.ts
   import { cva } from "class-variance-authority";
   export const chipButtonVariants = cva(
     ["inline-flex items-center h-8 px-3 rounded-full text-[var(--color-gray-900)] bg-[var(--color-gray-100)]"].join(" "),
     { variants: { size: { sm: "", md: "h-9 px-4" } }, defaultVariants: { size: "sm" } }
   );
  1. 타입에 프리셋 이름을 추가한다. (types/button.ts)
   export type ButtonPreset = "hero" | "feature" | "chip";
  1. 이 파일(/lib/variants/button.presets.ts)에 분기를 추가한다.
   import { chipButtonVariants } from "./button.chip";

   export type ChipVariantProps = VariantProps<typeof chipButtonVariants>;

   type PresetSupersetOpts = Partial<HeroVariantProps & FeatureVariantProps & ChipVariantProps>;

   export function getButtonClasses(preset: ButtonPreset, opts: PresetSupersetOpts = {}): string {
     if (preset === "hero") {
       const { intent, glow, pill } = (opts || {}) as HeroVariantProps;
       return heroButtonVariants({ intent, glow, pill });
     }
     if (preset === "chip") {
       const { size } = (opts || {}) as ChipVariantProps;
       return chipButtonVariants({ size });
     }
     const { radius, size, pill, glow } = (opts || {}) as FeatureVariantProps;
     return featureButtonVariants({ radius, size, pill, glow });
   }
  1. 화면에서는 똑같이 preset="chip"만 지정하면 끝.
   <Button preset="chip", size="md">Inbox</Button>

.

(2) types/button.ts — 프롭 확장(preset, size, radius)

// types/button.ts
import type { ButtonHTMLAttributes } from "react";

// 어떤 디자인 규칙군을 적용할지 결정
export type ButtonPreset = "hero" | "feature";

// hero 전용 의도(필요 시 확장 가능)
export type ButtonIntent = "primary";

export interface BaseButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  asChild?: boolean;   // <a>나 <Link>로 대체 가능
  className?: string;  // 추가 Tailwind 클래스 덧입히기
}

/** 공용 Button 프롭 */
export interface ButtonProps extends BaseButtonProps {
  // 어떤 cva를 쓸지 결정 (hero | feature)
  preset?: ButtonPreset;

  // hero 전용 옵션
  intent?: ButtonIntent;
  glow?: boolean;
  pill?: boolean;

  /** feature 전용 옵션 */
  size?: "sm" | "md" | "lg" | "xl" | "2xl";
  radius?: "sm" | "md" | "lg" | "xl" | "2xl";
}

1️⃣ preset — 디자인 룩을 한 줄로 고르는 스위치

  • preset="hero" → 히어로(랜딩 CTA) 스타일
    → 테두리 있고, 흑백 반전되는 강한 버튼
  • preset="feature" → 기능 소개 섹션용 스타일
    → 보더 없이, 연한 회색 호버로 부드럽게 반응
  • 앞으로 새로운 스타일이 필요하면 "chip", "auth" 같은 프리셋을 여기 enum에 추가만 하면 됨.


    이 한 줄 덕분에 “같은 Button 컴포넌트”를 쓰면서도
    룩&필만 바꿔가며 재활용할 수 있다.

2️⃣ hero 전용 옵션

옵션설명
intent색상 의도(현재는 "primary"만 사용 중)
glow그림자 효과 On/Off
pill버튼 모양(캡슐형 On/Off)

이건 랜딩 CTA 버튼 같은 HeroButton에서만 의미가 있다.

3️⃣ feature 전용 옵션

옵션설명
size타일형 버튼의 높이/패딩 (sm, md, lg, xl, 2xl)
radius둥근 정도 (sm, md, lg, xl, 2xl)

.

(3) shared/button.tsx — 공용 Button (Hero 전용 → preset 기반으로 확장)

// shared/button.tsx
"use client";
import { cn } from "@/lib/utils";
import { getButtonClasses } from "@/lib/variants/button.presets"; // ⬅️ hero/feature 등 프리셋 스위치
import type { ButtonProps } from "@/types/button";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      asChild,
      className,
      // 프리셋 기본값: 기존과의 호환을 위해 "hero"
      preset = "hero",

      // hero 전용 기본값
      intent = "primary",
      glow = false,
      pill = true,

      // feature 전용 기본값
      size = "md",
      radius = "2xl",

      children,
      ...rest
    },
    ref,
  ) => {
    const Comp = asChild ? Slot : "button";

    // ⬇️ preset에 따라 알맞은 cva를 내부에서 선택해 클래스 생성
    const classes = getButtonClasses(preset, {
      intent, glow, pill,   // hero 옵션
      size, radius,         // feature 옵션
    });

    return (
      <Comp
        ref={ref}
        type={asChild ? undefined : "button"}
        className={cn(classes, className)}
        {...rest}

        {children}
      </Comp>
    );
  },
);
Button.displayName = "Button";

설명 — “Hero 전용”에서 “preset 기반”으로 확장한 과정
처음에는 Hero 버튼만 있었기 때문에
heroButtonVariants를 직접 불러와 클래스를 합성하는 구조로 만들었다.

// (이전) hero만 지원
className={cn(heroButtonVariants({ intent, glow, pill }), className)}

이 방식은 단일 버튼만 쓸 때는 단순했지만,
새로운 버튼 스타일이 추가될 때마다 Button 컴포넌트를 수정해야 했다.
결국 관리 포인트가 늘어나는 문제가 있었다.


이번에 preset 시스템을 도입하면서 구조를 완전히 분리했다.
버튼이 어떤 스타일을 쓸지는 preset 값이 결정하고,
그에 맞는 cva를 getButtonClasses 함수가 알아서 골라주게 했다.

즉,

getButtonClasses("hero")  → heroButtonVariants 사용  
getButtonClasses("feature") → featureButtonVariants 사용

이렇게 내부에서 자동으로 분기되도록 만들었다.

그 결과, Button 컴포넌트는 더 이상 특정 스타일에 묶이지 않고
“룩&필 스위치” 역할만 하는 가벼운 공용 컴포넌트로 변했다.

이전과의 차이를 요약하면 다음과 같았다.

구분이전현재
구조Hero 전용preset 기반 공용 구조
스타일 연결heroButtonVariants 직접 호출getButtonClasses로 자동 선택
확장성새 스타일 추가 시 컴포넌트 수정 필요프리셋만 추가하면 자동 연결
옵션intent/glow/pill 3개만 지원각 프리셋별 옵션 확장 (size, radius 등)

또한 커스텀 속성(intent, glow, pill)는 그대로 유지했기 때문에
하위 호환성도 완벽히 지켰다.
기존 HeroButton을 쓰던 코드는 그대로 동작했다.

아이콘 합성 방식(children)과
asChild로 <a>나 <Link>를 감싸는 구조도 그대로 유지했다.
따라서 컴포넌트의 사용법은 바뀌지 않았고,
내부 구조만 더 유연하게 진화한 셈이었다.

결과적으로
이제는 버튼 스타일을 바꾸고 싶을 때 preset="feature"만 지정하면 됐다.
새로운 버튼 디자인이 추가되어도 Button 파일은 더 이상 수정할 일이 없게 되었다.



(4) /shared/feature-group-button.tsx — 아이콘·텍스트 조합형 Feature 버튼 만들기

랜딩페이지의 기능 소개 섹션에서는
아이콘 + 제목 + 설명이 반복되는 타일형 UI가 많았다.
이걸 매번 <Button> children으로 조립하면 코드가 길고 불일치가 생겼다.

그래서 이 조합을 하나로 묶은 공용 조합형 컴포넌트
FeatureGroupButton을 만들었다.

📁 1) 타입 정의 — types/feature-button.ts

먼저, 버튼이 받아야 할 프롭 타입을 분리했다.
Button이 룩(스타일 프리셋)을 담당한다면,
FeatureGroupButton은 콘텐츠 구성(icon/title/description) 을 담당한다.

// types/feature-button.ts
import type * as React from "react";

/** FeatureGroupButton 전용 프롭 (콘텐츠/구성 전담) */
export type FeatureGroupButtonProps = Omit<
  React.ButtonHTMLAttributes<HTMLButtonElement>,
  "children"> 
& {
  icon?: React.ReactNode; // react-lucide 아이콘 그대로
  title: string; // 제목(필수)
  description?: string; // 보조 설명(선택)
  radius?: "sm" | "md" | "lg" | "xl" | "2xl"; // 바깥 곡률(테마 변수 매핑)
  iconRadius?: "sm" | "md" | "lg" | "xl" | "2xl"; // 아이콘 박스 곡률(테마 변수 매핑)
  className?: string;
};

이렇게 타입을 따로 두면,
“버튼 모양 관련 프롭”(preset, radius, size)은 Button 쪽에서,
“콘텐츠 관련 프롭”(icon, title, description)은 여기서 관리하게 되어
역할이 완전히 분리된다.

⚙️ 2) 구현 — /shared/feature-group-button.tsx

버튼 내부 구조를 표준화했다.
버튼 하나에 아이콘 박스 + 텍스트 스택을 세팅하고,
바깥은 공용 Button preset="feature"로 감쌌다.

"use client";

import { cn } from "@/lib/utils";
import { Button } from "@/shared/button";
import type { FeatureGroupButtonProps } from "@/types/feature-button";
import * as React from "react";

// 아이콘 박스 라운드 클래스 매핑(테마 변수 연결)
const iconRadiusClass: Record<NonNullable<FeatureGroupButtonProps["iconRadius"]>, string> = {
  sm: "rounded-[var(--radius-sm)]",
  md: "rounded-[var(--radius-md)]",
  lg: "rounded-[var(--radius-lg)]",
  xl: "rounded-[var(--radius-xl)]",
  "2xl": "rounded-[var(--radius-2xl)]",
};

export const FeatureGroupButton = React.forwardRef<HTMLButtonElement, FeatureGroupButtonProps>(
  ({ icon, title, description, radius = "2xl", iconRadius = "lg", className, ...rest }, ref) => {
    // 접근성: aria-label 없으면 title로 대체
    const ariaLabel = (rest["aria-label"] as string | undefined) ?? title;

    return (
      <Button
        ref={ref}
        preset="feature"
        radius={radius}
        className={cn("h-[6.4rem] w-[29.6rem] justify-start px-[2.0rem]", className)}
        aria-label={ariaLabel}
        {...rest}>

        {/* 전체 내용 wrapper */}
        <span className="inline-flex w-full items-center gap-6">
          {/* 아이콘 박스 */}
          {icon ? (
            <span
              className={cn(
                "inline-flex h-[4.5rem] w-[4.5rem] shrink-0 items-center justify-center",
                iconRadiusClass[iconRadius],
                "bg-[var(--color-white)]",
                "border border-[var(--color-gray-300)]",
                "shadow-[var(--shadow-soft)]",
              )}>

              <span className="text-[var(--color-gray-900)]">{icon}</span>
            </span>
          ) : null}

          {/* 텍스트 스택 */}
          <span className="flex min-w-0 flex-col text-left">
            <span className="t-16-b text-[var(--color-gray-900)] truncate">{title}</span>
            {description ? (
              <span className="t-14-m text-[var(--color-gray-600)] truncate">{description}</span>
            ) : null}
          </span>
        </span>
      </Button>
    );
  },
);

FeatureGroupButton.displayName = "FeatureGroupButton";

💡 구조 설명

구분역할
Button preset="feature"보더 없음, 투명 배경, 호버 시 gray-100로만 변함
icon box정사각형(4.5rem) · 테두리 gray-300 · 배경 white · 그림자 soft
text stack좌정렬, 제목/설명 두 줄, truncate로 넘침 방지
크기고정 높이 6.4rem, 폭 29.6rem
radius--radius-* 토큰 매핑으로 테마 일관 유지

이 구조를 만들고 나니, 아이콘과 텍스트 배치가 어디서 써도
항상 같은 높이·간격·모서리 값으로 통일됐다.

🧱 3) 페이지에서 사용 — Hero CTA + FeatureGroupButton

랜딩 상단에서는 CTA 버튼(Hero)과 기능 버튼(FeatureGroup)을 함께 썼다.

import { makePageMetadata } from "@/seo/metadata";
import { Button } from "@/shared/button";
import { FeatureGroupButton } from "@/shared/feature-group-button";
import { Calendar, Laptop, Monitor, Smartphone } from "lucide-react";

export const metadata = makePageMetadata({
  title: "PlanMate — 맞춤형 데일리 플래너",
  description: "원하는 모듈을 조합해 나만의 플래너를 만드는 PlanMate 랜딩 페이지",
  canonical: "/",
});

export default function Home() {
  return (
    <div className="flex flex-col justify-center items-center gap-8">
      {/* Hero CTA */}
      {/* ... 기존 Hero 버튼들 ... */}

      {/* Feature Group */}
      <FeatureGroupButton
        icon={<Calendar className="h-7 w-7" />}
        title="일간"
        description="오늘의 일정과 할 일을 한눈에"
      />
    </div>
  );
}
  • Hero 버튼은 CTA 역할, 흑백 반전으로 시선을 끌었다.
  • FeatureGroup 버튼은 기능 소개용, 보더 없는 밝은 톤으로 정보 위계를 낮췄다.
  • 두 종류의 버튼이 같은 Button 시스템 안에서 돌아가므로,
    스타일 충돌 없이 일관된 UX를 유지했다.

✅ 정리

  • 아이콘·텍스트가 함께 있는 버튼을 한 번만 구현해 어디서든 재사용할 수 있었다.
  • 버튼의 룩(스타일) 은 preset으로,
    내용(아이콘/텍스트) 은 FeatureGroupButton으로 분리되어 유지보수가 쉬워졌다.
  • 색상과 곡률은 전부 토큰 기반이라, 다크모드나 테마 변경에도 한 번에 대응됐다.

    이렇게 해서 랜딩의 Hero / Feature 영역 버튼 구조가 완성되었다.

Prop 흐름 — variants → types → preset → Button → UI

마무리로 "어떻게 공동 컴포넌트를 확장"시키는지 또 “Prop이 어디서 정의되고, 어떻게 흘러가고, 최종 UI에서 무엇을 바꿨는가”에만 집중했다.

핵심은 네 가지 단계로 정리했다
1. cva(variants) — 프리셋별로 “무엇을 바꿀 수 있는지” 선언.
2. types(ButtonProps) — 공용 버튼이 받을 수 있는 전체 옵션(API)을 열어두기.
3. preset 스위치(getButtonClasses) — 어떤 cva를 쓸지 한 곳에서 결정하도록 만들기.
4. 공용 Button & 페이지/조합 컴포넌트 — 실제 UI에서는 스타일을 “선택”만 하게 하기.

이 구조 덕분에, 새로운 버튼군을 추가할 때마다
[variants 정의] → [ButtonPreset 추가 & variants type 추가] → [preset 스위치 한 줄] → [UI에서 preset 사용]
이 네 단계만 밟으면 확장이 끝났다.
즉, Prop의 생애주기를 정확히 이해해두면 어떤 버튼을 만들든 리듬은 같았다.

Prop 흐름 구조 요약

[cva variants]
  ├─ heroButtonVariants(intent, glow, pill)
  └─ featureButtonVariants(radius, size)
        │
        ▼
[types/button.ts]
  ButtonPreset = "hero" | "feature"
  ButtonProps { preset, intent, glow, pill, radius, size, ... }
        │
        ▼
[button.presets.ts]
  getButtonClasses(preset, opts)
    ├─ if hero    → heroButtonVariants(intent, glow, pill)
    └─ if feature → featureButtonVariants(radius, size)
        │
        ▼
[shared/button.tsx]
  <Button preset=... {...프리셋별 옵션}>
     └─ classes = getButtonClasses(preset, 옵션)
        │
        ▼
[페이지 / 조합형]
  - Hero CTA: <Button preset="hero" ...>…</Button>
  - Feature 타일: <FeatureGroupButton …/> → 내부에서 <Button preset="feature" …>

1) cva — 프리셋별 “바꿀 수 있는 옵션” 선언

각 버튼 프리셋은 cva() 함수로 정의됐고, 여기서 “무엇을 바꿀 수 있는가”가 명시됐다.
예시는 다음과 같았다.

// lib/variants/button.hero.ts
import { cva } from "class-variance-authority";

export const heroButtonVariants = cva(
  [
    // 🧱 Layout & 기본 스타일
    "inline-flex items-center justify-center h-[5.4rem] px-[3rem]",
    "t-18-m bg-[var(--color-white)] text-[var(--color-gray-900)] border border-[var(--color-gray-300)]",

    // 🪄 Hover & Interaction
    "hover:bg-[var(--color-black)] hover:text-[var(--color-white)] hover:border-[var(--color-black)]",
    "transition-[color,background-color,border-color] duration-200 active:translate-y-[1px]",
  ],
  {
    // 🎛️ Hero 버튼이 받을 수 있는 옵션들
    variants: {
      intent: { primary: "" }, // 색상 의도
      glow: { true: "shadow-[0_6px_16px_rgba(0,0,0,0.08)]", false: "" }, // 그림자 on/off
      pill: { true: "rounded-full", false: "" }, // 모양: 캡슐형 여부
    },
    defaultVariants: {
      intent: "primary",
      glow: false,
      pill: true,
    },
  },
);
  • lib/variants/button.hero.ts에서 heroButtonVariants({ intent, glow, pill })만 받도록 선언했다.
  • lib/variants/button.feature.ts 코드는 생략했음!


    여기서 “무엇을 바꿀 수 있는가(variants)”가 정해진다.
    즉, hero는 intent/glow/pill만 사용할 수 있다.

    ⚠️ 항상 cva 함수에서는 색상·라운드·호버 효과 전부 디자인 토큰 기반(var(--color-*), var(--radius-*))으로 통일해야한다.



2) types — 공용 Button의 표면 API

// types/button.ts (요지)
export type ButtonPreset = "hero" | "feature";

export interface ButtonProps extends BaseButtonProps {
  preset?: ButtonPreset;

  // hero only
  intent?: "primary";
  glow?: boolean;
  pill?: boolean;

  // feature only
  size?: "sm" | "md" | "lg" | "xl" | "2xl";
  radius?: "sm" | "md" | "lg" | "xl" | "2xl";
}

여러 프리셋을 하나의 컴포넌트로 쓰기 위해 공용 타입을 넓게 열어두었고,
실제로는 프리셋별 cva가 자기 옵션만 소비하도록 했다.
더 엄격한 분리(프리셋별 never)도 가능했지만, 이번에는 사용성을 우선했다.



3) preset 스위치 — 어떤 cva를 쓸지 한 곳에서 결정한다

// lib/variants/button.presets.ts (슬림 & any 없음)
import type { VariantProps } from "class-variance-authority";
import { heroButtonVariants } from "./button.hero";
import { featureButtonVariants } from "./button.feature";
import type { ButtonPreset } from "@/types/button";

export type HeroOpts = VariantProps<typeof heroButtonVariants>;
export type FeatOpts = VariantProps<typeof featureButtonVariants>;
type Superset = Partial<HeroOpts & FeatOpts>;

export function getButtonClasses(preset: ButtonPreset, opts: Superset = {}): string {
  if (preset === "hero") {
    const { intent, glow, pill } = opts as Partial<HeroOpts>;
    return heroButtonVariants({ intent, glow, pill });
  }
  const { radius, size } = opts as Partial<FeatOpts>;
  return featureButtonVariants({ radius, size });
}

이 함수는 프리셋마다 cva를 자동으로 연결하는 역할을 했다.
any는 사용하지 않았고, Partial<HeroOpts & FeatOpts> 교차 타입으로 유연성을 확보했다.
새 프리셋을 추가할 때는 button.<새프리셋>.ts을 /lib/variants폴더 안에 만들고 이 파일에 분기 한 줄만 추가하면 됐다.



4) 공용 Button: 프리셋 + 옵션을 한 번에 받아 클래스 생성

// shared/button.tsx (요지)
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      preset = "hero",
      // hero only
      intent = "primary", glow = false, pill = true,
      // feature only
      size = "md", radius = "2xl",
      asChild, className, children, ...rest
    },
    ref,
  ) => {
    const classes = getButtonClasses(preset, { intent, glow, pill, size, radius });
    const Comp = rest.asChild ? Slot : "button";
    return (
      <Comp ref={ref} type={rest.asChild ? undefined : "button"} className={cn(classes, className)} {...rest}>
        {children}
      </Comp>
    );
  },
);

Button 컴포넌트는 실제 스타일을 생성하지 않았다.
단지 preset에 따라 알맞은 cva를 선택하고, 해당 클래스 문자열을 합쳐 렌더링했다.
즉, 룩(스타일) 은 preset이, 내용(텍스트/아이콘) 은 children이 담당하도록 설계했다.



5) 실제 사용 — 의도만 고르면 되게 했다

Hero CTA (강한 버튼) 예시를 이렇게 썼다.

<Button preset="hero" pill>
  <span className="inline-flex items-center gap-2">
    <Monitor className="h-7 w-7" />
    <span>Desktop</span>
  </span>
</Button>
  • preset="hero"가 들어갔고, 내부에서 heroButtonVariants(intent, glow, pill)을 사용했다.
  • 테두리가 있었고, 호버 시 검/흰 반전이 일어났다.


    Feature 타일(조합형) 예시를 이렇게 썼다.
// shared/feature-group-button.tsx 내부
<Button preset="feature" radius={radius} className="h-[6.4rem] w-[29.6rem] justify-start px-[2rem]">
  {/* 아이콘 캡슐 + 텍스트 스택 */}
</Button>
  • preset="feature"가 들어갔고, 내부에서 featureButtonVariants(radius, size)를 사용했다.
  • 보더가 없었고, 기본 투명 + 호버 시 gray-100로만 살짝 밝아지는 인터랙션으로 동작했다.
  • 조합형 컴포넌트는 콘텐츠(icon/title/description) 만 책임지고, 룩은 Button preset이 해결하도록 구성했다.



꼭 지켜야할 규칙

  • 토큰만 사용하기.
    색/라운드/그림자를 var(--color-*), var(--radius-*), var(--shadow-soft)로만 다뤄야한다.
    테마가 바뀌면 토큰만 바꿔 대응할 수 있게 만들면 된다.



정리 — Prop의 생애주기

앞으로 공동 컴포넌트 Button이든 Input이든 이 과정을 거쳐가면 된다.

1. variants에서 “무엇을 바꿀 수 있는지” prop variants를 선언.
2. types에서 공용 Prop 타입을 열어두기.
3. preset 스위치 (button.presets.ts)에서 어떤 스타일(cva)을 적용할지 결정.
4. Button이 룩을 선택했고,
5. UI 컴포넌트가 콘텐츠만 채워 넣었다.



🧩 Button 컴포넌트 리팩토링

목표
기존에는 한 타입(ButtonProps)에 모든 옵션을 몰아넣어 혼란스러웠다.
이번 리팩토링에서는 버튼을 hero, feature, auth 세 가지 프리셋으로 나누고,
각 프리셋별로 타입과 로직을 분리해 any 없이 안전하게, 자동완성은 깔끔하게 만드는 것이 목표였다.



1️⃣ types/button.ts — “옵션 한데 섞기” → “프리셋별 유니온(정확히 필요한 옵션만 허용)”

❓ 문제

이전에는 한 타입(ButtonProps)에 모든 옵션을 몰아넣어,
preset="feature"인데 intent(hero 전용 옵션)를 써도 에러가 나지 않았다.
결국 컴파일러가 버튼 종류를 구분하지 못하는 구조였다.

마치 “모든 문을 하나의 마스터키로 열던” 구조라,
엉뚱한 문도 열리던 셈이다.

⚙️ 이전 코드

// ❌ 모든 옵션이 한 타입에 섞여 있었다
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  asChild?: boolean;
  className?: string;

  preset?: "hero" | "feature" | "auth";

  // hero 전용
  intent?: "primary"; glow?: boolean; pill?: boolean;

  // feature 전용
  radius?: "sm" | "md" | "lg" | "xl" | "2xl";

  // auth 전용
  color?: "black" | "white";
}

hero, feature, auth용 옵션이 전부 뒤섞여 있어
타입 시스템이 “이건 어느 버튼의 옵션인지”를 알 수 없었다.

🔧 이후 리팩토링 코드

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { getButtonClasses } from "@/lib/variants/button.presets";
import type { ButtonProps } from "@/types/button";

// 지정된 키만 제외한 객체 복사 (any 없이 타입 안전)
function omitKeys<T extends object, K extends readonly (keyof T)[]>(
  obj: T,
  keys: K
): Omit<T, K[number]> {
  const out: Partial<T> = {};
  for (const k in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, k) && !keys.includes(k as K[number])) {
      out[k as keyof T] = obj[k];
    }
  }
  return out as Omit<T, K[number]>;
}

// 프리셋별 필요한 옵션만 계산
function computePlan(props: ButtonProps) {
  const asChild = props.asChild ?? false;
  const className = props.className;
  const children = (props as React.PropsWithChildren).children;

  if (props.preset === "hero") {
    const intent = props.intent ?? "primary";
    const glow = props.glow ?? false;
    const pill = props.pill ?? true;
    const classes = getButtonClasses("hero", { intent, glow, pill });
    const native = omitKeys(props, ["preset", "intent", "glow", "pill", "className", "asChild", "children"] as const);
    return { asChild, className, children, classes, native };
  }

  if (props.preset === "feature") {
    const radius = props.radius ?? "2xl";
    const classes = getButtonClasses("feature", { radius });
    const native = omitKeys(props, ["preset", "radius", "className", "asChild", "children"] as const);
    return { asChild, className, children, classes, native };
  }

  // auth
  const color = props.color ?? "black";
  const classes = getButtonClasses("auth", { color });
  const native = omitKeys(props, ["preset", "color", "className", "asChild", "children"] as const);
  return { asChild, className, children, classes, native };
}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
  const plan = computePlan(props);
  const Comp = plan.asChild ? Slot : "button";

  return (
    <Comp
      ref={ref}
      type={plan.asChild ? undefined : "button"} // 기본 submit 방지
      className={cn(plan.classes, plan.className)}
      {...plan.native}

      {plan.children}
    </Comp>
  );
});
Button.displayName = "Button";

💬 왜 이렇게 바꿨나

이제는 프리셋별 JSX를 복사하지 않는다.
computePlan 내부에서 “필요한 기본값만 계산” 하고,
렌더는 단 한 번만 실행된다.
즉, 한 줄짜리 return만으로 모든 버튼 프리셋을 처리할 수 있다.

💡 이제는 “필요한 계산만 다르고, 렌더는 하나”라는 원칙으로 정리된 셈이다.

✅ 결과

개선 포인트설명
JSX 중복 제거렌더링 블록이 한 번만 존재
ESLint 경고 제거preset, _c 등 미사용 변수 없음
DOM 안전성 유지omitKeys()로 커스텀 prop 누수 차단
가독성 향상“계산은 분기, 렌더는 하나” 구조 명확

🔍 확인 예시

// ✅ 올바른 사용
<Button preset="hero" intent="primary" />
<Button preset="feature" radius="lg" />
<Button preset="auth" color="black" />

// ❌ 잘못된 사용 → 컴파일 에러
<Button preset="auth" radius="xl" /> // auth에는 radius 없음

이 구조 덕분에 버튼 컴포넌트가 훨씬 짧고 명확해졌다.
각 프리셋이 “자기 일만 하는 구조”가 되어,
확장성·안정성·정적 타입 안전성이 모두 강화됐다.

0개의 댓글