mimizae 님의 블로그
button 공통 컴포넌트 구현 본문
37기 DIVE SOPT에서 진행한 프로젝트 CareNA 프로젝트에서 맡은 부분을 구현한 과정을 담은 글입니다! 🙌🏻

💡 내가 구현을 맡은 UI

💡 폴더 구조 선정 이유
shared
│ │ │
│ │ └── ui
│ │ ├── buttons
│ │ │ ├── add-button.tsx
│ │ │ ├── button.tsx
│ │ │ └── ocr-button.tsx
│ │ ├── check-box
│ │ │ └── check-box-button.tsx
│ │ └── radio-button
│ │ └── radio-button.tsx
FSD 기준에 맞춰 공통으로 사용되는 UI 컴포넌트들을 관리하기 위해 shared/ui 하위에 구성했다.
버튼, 체크박스, 라디오 버튼 모두 특정 도메인에 종속되지 않고 여러 화면에서 재사용되는 컴포넌트이기 때문에, 공통 레이어인 shared가 가장 적절하다고 판단했다.
buttons 폴더 내부에서 button.tsx와 add-button.tsx, ocr-button.tsx를 분리한 이유는 UI의 성격 차이 때문이다.
button.tsx는 서비스 전반에서 사용되는 기본 직사각형 버튼이고, 사이즈나 컬러 등 다양한 variant가 있어서 이를 내부에서 분기 처리하도록 했다.
반면 add-button.tsx와 ocr-button.tsx는 기본 버튼의 변형이라기보다는, UI 자체가 독립적이고 역할이 명확한 버튼이었기 때문에 공통 버튼과는 분리해 각각의 컴포넌트로 관리했다!
또한 체크박스와 라디오 버튼은 버튼처럼 보일 수는 있지만, 버튼이 아니라 input이다. 그래서 버튼 컴포넌트들과는 분리해 check-box, radio-button처럼 독립적인 폴더 구조를 구성했다.
여기에서!! 이 컴포넌트들을 input을 담당한 팀원이 가져가지 않은 이유는, 우리 서비스에서의 input 컴포넌트가 단순 선택이 아니라 텍스트 입력과 값 검증, 에러 처리 등 복잡한 입력 로직을 포함하고 있었기 때문이다.
체크박스와 라디오 버튼은 input 요소이긴 하지만, 값 입력보다는 선택의 의미가 더 강한 UI라고 판단했고, 그 특성상 내가 맡은 버튼 섹션에서 함께 관리하는 것이 더 적절하다고 생각했다!!
💡 button.tsx
🔻 button.tsx 코드 전문 🔻
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/shared/libs/cn";
const buttonVariants = cva(
`flex w-full items-center justify-center
bg-primary-400 text-white active:bg-primary-600 transition-default
disabled:bg-gray-300 disabled:text-gray-600 disabled:cursor-not-allowed`,
{
variants: {
size: {
sm: "label05-r-14 h-[3.2rem] min-w-[6rem] rounded-[4px] px-[1rem]",
lg: "label04-r-16 h-[5.2rem] min-w-[8.4rem] rounded-[8px] px-[2rem]",
},
},
defaultVariants: {
size: "lg",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, size, type = "button", ...props }, ref) => {
return (
<button
ref={ref}
type={type}
className={cn(buttonVariants({ size }), className)}
{...props}
/>
);
},
);
Button.displayName = "Button";
UI를 일관되게 관리하기 위해 class-variance-authority(cva)를 사용해 구현했다.
버튼의 사이즈나 상태에 따라 클래스가 달라지는 구조이기 때문에, 조건 분기 로직을 JSX 안에서 처리하기보다는 스타일 변형을 선언적으로 관리하고자 했다!!
cva는 variant 기반으로 className을 조합해주는 유틸리티다.
buttonVariants에서는 버튼의 공통 스타일을 기본 클래스로 정의하고, size라는 variant를 통해 버튼 크기에 따른 스타일을 분기했다.
sm, lg 사이즈에 따라 높이, 패딩, 폰트 스타일, border-radius가 달라지도록 설정했고, defaultVariants를 통해 별도 지정이 없을 경우 기본값으로 lg 사이즈가 적용되도록 했다.
이렇게 함으로써 버튼 사이즈가 추가되더라도 JSX를 수정하지 않고 variant만 확장하면 되도록 구조를 만들었다!!
const buttonVariants = cva( baseClass, {
variants: {size: { ... } },
defaultVariants: {size: "lg" },
});
ButtonProps 타입은 React.ButtonHTMLAttributes<HTMLButtonElement>와 VariantProps<typeof buttonVariants>를 상속받아 정의했다. (이건 저번 합동 세미나 때 배운 것이다 ㅎ.ㅎ!!!)
ButtonHTMLAttributes를 사용한 이유는, 이 컴포넌트가 실제로는 <button> 태그를 래핑한 컴포넌트이기 때문에 onClick, disabled, type 같은 기본 버튼 속성들을 그대로 지원하기 위함이다!!!!
이 타입을 상속함으로써, 별도의 타입 정의 없이도 네이티브 버튼의 모든 속성을 안전하게 사용할 수 있다. 🤩
또한 VariantProps<typeof buttonVariants>를 함께 사용해, size 같은 cva에서 정의한 variant가 자동으로 props 타입에 포함되도록 했다!!!
이 덕분에 size="sm"처럼 사용할 때 타입 안정성을 유지할 수 있고, 존재하지 않는 variant 값을 전달하는 실수를 방지할 수 있다.
버튼 컴포넌트는 React.forwardRef를 사용해 구현했다. 이는 외부에서 버튼 DOM에 직접 접근해야 하는 경우(포커스 제어, 스크롤 이동 등)를 대비한 것으로, 공통 컴포넌트로서 확장성을 고려한 선택이다.
마지막으로 cn 유틸리티를 사용해 buttonVariants에서 생성된 클래스와 외부에서 전달된 className을 병합했다.
이를 통해 기본 스타일을 유지하면서도, 필요한 경우 추가 스타일을 유연하게 확장할 수 있도록 했다!
💡 ocr-button.tsx와 add-button.tsx
🔻 ocr-button.tsx와 add-button.tsx 코드 전문 보기 🔻
add-button.tsx
import type * as React from "react";
import { Plus } from "@/shared/assets/svg";
import { cn } from "@/shared/libs/cn";
interface AddButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
export const AddButton = ({ className, ...props }: AddButtonProps) => {
return (
<button
type="button"
{...props}
className={cn(
"flex w-fit items-center justify-center gap-[0.4rem] rounded-[4px] bg-white px-[0.8rem] py-[0.4rem] transition-default active:bg-gray-200",
className,
)}
>
<span className="label06-r-12 text-gray-900">검진결과추가</span>
<Plus aria-hidden="true" />
</button>
);
};
ocr-button.tsx
import type * as React from "react";
import { Scan } from "@/shared/assets/svg";
import { cn } from "@/shared/libs/cn";
type OcrButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
export const OcrButton = ({ className, ...props }: OcrButtonProps) => {
return (
<button
type="button"
{...props}
className={cn(
"mx-auto flex w-[23.6rem] items-center justify-center gap-[1.2rem] whitespace-nowrap rounded-[8px] border border-primary-600 bg-primary-50 px-[1.6rem] py-[1.2rem] transition-default active:bg-primary-100",
className,
)}
>
<Scan aria-hidden="true" />
<span className="label03-m-12 text-primary-600">
검진 결과지 스캔하고 자동 입력받기
</span>
</button>
);
};
위의 구조 선정의 이유에서도 언급했지만,
AddButton과 OcrButton는 공통 버튼 컴포넌트(Button)와 역할이 겹쳐 보일 수 있으나!! 스타일과 사용 목적이 명확하게 고정된 버튼이라는 점에서 분리된 컴포넌트로 구현했다.
두 컴포넌트 모두 특정 화면과 흐름에서만 사용되며, 디자인상 아이콘과 텍스트 배치, 색상, 크기가 고정되어 있어 size나 variant로 일반화하기보다는 의미 중심의 버튼 컴포넌트로 관리하는 것이 더 적절하다고 판단했다.
구현 자체는 기본적인 버튼 컴포넌트와 크게 다르지 않다.
실제로 <button> 태그를 그대로 사용하고, React.ButtonHTMLAttributes<HTMLButtonElement>를 상속해 onClick, disabled 등 네이티브 버튼 속성을 그대로 전달받을 수 있도록 했다!!
스타일은 Tailwind 클래스 문자열로 고정되어 있으며, cn 유틸리티를 사용해 내부 기본 클래스와 외부에서 전달받은 className을 병합했다.
💡 check-box.tsx
🔻 check-box.tsx 코드 전문 🔻
import type * as React from "react";
import { CheckW } from "@/shared/assets/svg";
import { cn } from "@/shared/libs/cn";
interface CheckBoxProps
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"type" | "onChange"
>
{
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
export const CheckBox = ({ checked, onChange, disabled }: CheckBoxProps) => {
return (
<label
className={cn(
"relative inline-flex items-center",
disabled ? "cursor-not-allowed" : "cursor-pointer",
)}
>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="peer sr-only"
/>
<span
className={cn(
"flex h-[2rem] w-[2rem] items-center justify-center rounded-[4px] border transition-default [&>svg]:opacity-0 peer-checked:[&>svg]:opacity-100",
disabled ? "border-gray-500 bg-gray-200" : "border-gray-900 peer-checked:bg-primary-500",
)}
>
<CheckW />
</span>
</label>
);
};
기본 HTML checkbox의 스타일 제약을 피하기 위해, 실제 input은 숨기고(label + peer)를 활용해 커스텀 UI를 구현한 방식으로 접근했다!!! 접근성과 상태 관리는 유지하면서, 디자인은 완전히 제어할 수 있도록 구성했다.
구조적으로는 <label> 안에 input[type="checkbox"]와 시각적인 역할을 하는 span을 함께 배치했다.
이렇게 구성하면 label을 클릭했을 때 input이 함께 토글되기 때문에, 별도의 클릭 핸들링 없이도 자연스러운 사용자 인터랙션을 구현할 수 있다.
실제 checkbox input은 sr-only 클래스를 사용해 화면에서는 숨기되, 포커스나 checked 상태 같은 기본 동작은 그대로 유지하도록 했다.
Tailwind의 peer 클래스를 사용한 것이 핵심이다!!! 이번에 peer에 대해서 새로 알게 되었는데…
이 컴포넌트에서는 실제 checkbox 역할을 하는 input에 peer 클래스를 적용하고, 그 다음 형제 요소인 span에서 peer-checked를 사용해 체크 상태에 따른 UI 변화를 처리했다!!!!
기존에는 체크 여부에 따라 React state로 조건 분기해 className을 제어하는 방식이 익숙했지만…
// 이런 느낌... 으으 복잡~~
const [checked, setChecked] = useState(false);
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
<span
className={cn(
"w-8 h-8 border rounded",
checked && "bg-primary-500"
)}
>
<CheckIcon className={checked ? "opacity-100" : "opacity-0"} />
</span>
특히 checkbox나 radio처럼 상태가 단순한 컴포넌트에서는, 이런 방식이 오히려 코드 가독성과 유지보수성 면에서 더 효과적이라고 느꼈다!!
또한 label과 함께 사용함으로써 클릭 영역을 자연스럽게 확장할 수 있었고, input을 sr-only로 숨기면서도 접근성과 기본 동작은 그대로 유지할 수 있었다.
여기서 [&>svg]:opacity-0는 Tailwind의 arbitrary selector 문법으로, 해당 span의 직계 자식인 svg 요소에만 스타일을 적용한다는 의미다.
즉, 기본 상태에서는 체크 아이콘(CheckW)이 렌더링되어 있지만 보이지 않도록 opacity를 0으로 설정한 것이다!!!
그리고 peer-checked:[&>svg]:opacity-100을 통해 체크 상태일 때만 아이콘이 나타나도록 했다.
이 방식을 사용함으로써 조건부 렌더링 없이도, CSS만으로 아이콘의 노출 여부를 제어할 수 있었다 ㅎ.ㅎ
💡 radio-button.tsx
🔻radio-button.tsx 코드 전문 🔻
import { cn } from "@/shared/libs/cn";
interface RadioButtonProps {
value: string;
text?: string;
checked: boolean;
onChange: (value: string) => void;
name: string;
disabled?: boolean;
}
export const RadioButton = ({
value,
text,
checked,
onChange,
name,
disabled,
}: RadioButtonProps) => {
return (
<label
className={cn(
"flex w-fit cursor-pointer items-center",
text && "gap-[1.2rem] py-[1.2rem] pl-[1.2rem]",
disabled && "cursor-not-allowed",
)}
>
<input
type="radio"
name={name}
checked={checked}
disabled={disabled}
value={value}
onChange={() => onChange(value)}
className="peer sr-only"
/>
<span className="flex h-[2rem] w-[2rem] flex-shrink-0 items-center justify-center rounded-full border border-gray-700 transition-default peer-checked:border-primary-500 peer-disabled:cursor-not-allowed peer-disabled:border-gray-500 peer-disabled:bg-gray-200 peer-checked:peer-disabled:border-gray-500 peer-checked:peer-disabled:bg-gray-100 [&>span]:opacity-0 peer-checked:[&>span]:opacity-100 peer-disabled:[&>span]:bg-gray-500">
<span className="h-[1rem] w-[1rem] rounded-full bg-primary-500" />
</span>
{text && <span className="label04-r-16 text-black">{text}</span>}
</label>
);
};
체크박스와 거의 비슷한데!! 고려해야 할 경우의 수가 하나 늘었다.

바로 check가 되었지만 disabled인 상태이다.
건강 검진 기록이 이미 등록이 되어있는 상태에서 추가하려고 할 때, 기본 정보 등은 사전에 등록된 건강 검진 기록에 따라 미리 선택된 채로 페이지에 진입하게 된다. 이런 경우에!! 이미 check가 되었지만 disabled인 상태를 사용한다.
여기에서 핵심은 checked + disabled 상태를 어떻게 표현했는지다!!!
일반적인 disabled 상태와 구분하기 위해, 단순히 회색 처리만 하는 것이 아니라 peer-checked:peer-disabled:* 형태의 조합 셀렉터를 사용해 선택은 되어 있지만 비활성화된 상태임을 시각적으로 드러내도록 했다.
peer-checked:border-primary-500
peer-disabled:border-gray-500
peer-checked:peer-disabled:border-gray-500
peer-checked:peer-disabled:bg-gray-100
또한 내부에 있는 원형 indicator 역시 [&>span] arbitrary selector를 사용해 제어했다!!!!
처음에는 data-disabled 같은 커스텀 속성을 사용해 상태를 구분하는 방식도 고려했지만, 최종적으로는 Tailwind에서 제공하는 peer-disabled 셀렉터를 최대한 활용하는 쪽으로 수정했다.
그 이유는 상태 판단 기준을 HTML input의 실제 상태에 맞추는 것이 더 직관적이고, checkbox 컴포넌트와도 동일한 패턴을 유지할 수 있었기 때문이다.
💡 최종 구현 예시

💡ETC
'FE' 카테고리의 다른 글
| 프론트엔드의 보안 (0) | 2026.03.01 |
|---|---|
| drop-down 공통 컴포넌트 구현 (0) | 2026.02.18 |
| 마이페이지 min-height 문제 (0) | 2026.02.10 |
| ticker 구현기 (0) | 2026.02.09 |
| RouteHandle은 뭐고, 왜 라우트 단에서 헤더를 관리할 수 있을까? (0) | 2026.02.03 |