
공동 컴포넌트를 만들기 전에 먼저
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 등)이나 앱 전역 유틸 타입을 모아
다른 모듈에서 참조할 수 있도록 한다.
폴더를 정리했으니 이제는 페이지 파일을 추가할 차례다.
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,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나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같은 컴포넌트도 같은 방식으로 확장할 수 있게 설계한다.
이렇게 생겼다 / 이렇게 동작한다
🎨 기본 상태
- 세 버튼 모두 흰 배경 + 얇은 회색 테두리(#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은 다음 세 가지다
intent: 색상(채움/외곽선)glow: 그림자 on/offpill: 모서리 둥글기 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로 조절, 구조는 자유롭게 변경 가능.- 이렇게 하면 버튼은 “모양과 동작만 담당”하고,
콘텐츠(텍스트·아이콘)는 사용 화면에서 직접 구성한다.
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은 “기능 소개 섹션”에 맞게
- 정보 위계가 낮고
- 가볍게 반응하는 호버(살짝 회색 배경)
을 목표로 한다.
이 구조 덕분에 이후 버튼을 추가하더라도 스타일 간섭 없이 독립적으로 관리할 수 있고,
전체 디자인 시스템의 일관성을 유지할 수 있다.
이렇게 생겼다 / 이렇게 동작한다
🎨 기본 상태
- 흰 배경, 텍스트
#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를 골라 클래스 문자열 생성 → 버튼에 적용
왜 “여기 한 곳”에서만 고르나?
- 스타일 분기 로직이 하나로 모이면
- 어디를 고쳐야 할지 항상 한 눈에 보이고,
- 실수로 두 군데를 따로 고칠 일이 없다(일관성),
- 온보딩이 쉽다(“스타일 추가는 여기서!” 라고 딱 알려주면 끝).
이렇게 확장한다(예: 새 프리셋
chip추가) (중요하니 꼭 봐야함)
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" } } );
- 타입에 프리셋 이름을 추가한다. (
types/button.ts)export type ButtonPreset = "hero" | "feature" | "chip";
- 이 파일(
/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 }); }
- 화면에서는 똑같이
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.6remradius --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이 어디서 정의되고, 어떻게 흘러가고, 최종 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 컴포넌트가 콘텐츠만 채워 넣었다.
목표
기존에는 한 타입(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 없음이 구조 덕분에 버튼 컴포넌트가 훨씬 짧고 명확해졌다.
각 프리셋이 “자기 일만 하는 구조”가 되어,
확장성·안정성·정적 타입 안전성이 모두 강화됐다.