프론트엔드/SeriVoca

[React] 나만의 Button 컴포넌트 만들기

icems0428 2025. 10. 6. 01:32

시리보카는 나만의 영단어장 웹 애플리케이션으로, 그동안 개발하면서 느꼈던 갈증들을 해소하는 사이드 프로젝트입니다.

왜 그런거 있잖아요.. 클린하고 확장성 있는 코드를 쓰기 위해 더 집착(?)하고 싶은데 시간상 일정상 넘어가야 했던 디테일한 요소들!

그동안 개발하며 "이렇게 하면 좀 더 좋을텐데" 라고 느꼈던 점들을 개선하여 차곡차곡 기록하려고 합니다!

 

참고: 이 글은 완성된 버튼을 만드는 방법이 아니라, 공통 컴포넌트를 설계할 때 어떤 기준으로 구조를 세워나가는지에 대한 나의 기록입니다.

 

오늘 할 것은 최강의 버튼 컴포넌트 만들기!

초점은 "클린 코드"과 "확장성"입니다.


기술스택 : React, TypeScript, TailwindCSS

1. 스타일 분리

디자인 팀에서 넘겨주신 버튼 컴포넌트를 보면 보통 "모드"가 나뉘어 있죠? 저같은 경우는 "primary", "secondary", "disabled" 이렇게 나뉘어진 형태로 전달 받은 적이 있습니다.

"primary" - 앱의 시그니처 컬러가 들어간 기본 버튼

"secondary" - 기본 컬러의 깔끔한 버튼

"disabled" - 클릭할 수 없는 상태의 버튼

이렇게 다양한 경우, 아니 모드가 적어도 두 개 이상이 된다면 각 모드에 해당하는 스타일을 따로 정리해두는 것이 리뷰어 입장에서도 편합니다.

type ButtonProps = {
  ...
  variant: 'primary' | 'secondary' | 'disabled';
};

export const Button = ({ ..., variant }: ButtonProps) => {
  const variantStyle = variant ? ButtonStyle[variant] : '';
  return (
    <button className={variantStyle} onClick={...} disabled={variant === 'disabled'}>
      {content}
    </button>
  );
};

const ButtonStyle = {
  primary: 'bg-blue-500 text-gray-700 cursor-pointer',
  secondary: 'bg-white text-gray-700 cursor-pointer',
  disabled: 'bg-gray-200 text-gray-400 cursor-not-allowed',
};

 

이런 식으로 말이죠! 각각의 모드에 따른 스타일이 한 눈에 들어옵니다.

 

2. 커스텀 스타일링

하지만 버튼이 저 세 가지 모드로만 쓰인다는 보장은 없습니다. 예를 들면, 어떤 버튼에서는 백그라운드 컬러가 빨간색인 버튼이 있을 수 있겠죠. 하지만 그 외에 다른 속성들은 기존의 버튼 컴포넌트와 같다면, 배경색 하나만을 위해 컴포넌트를 또 만드는 것은 비효율적이고, 꽤나 귀찮은 일일 것입니다. 그렇기 때문에 공통 컴포넌트는 어느정도 유연하게 만드는 게 중요합니다.

 

저는 개발을 시작한지 얼마 안 되었을 때, 어느정도 유연하게 만들기 위해서 별의별 props를 만든 기억이 있습니다. 예를 들면 bgColor, textColor 같은 걸 말이죠.. 그런데 이렇게 일반적이지 않은 props는 다른 사람이 사용할 때 헷갈릴 수 있으며, 또다른 커스텀 속성이 추가될 때마다 props를 수정해야 합니다.

 

그래서 저는 현재 className을 props로 받아와 기존의 스타일을 변경할 수 있도록 하는 방법을 선호합니다. (스타일이 덮어씌워지면 안되는 컴포넌트는 예외입니다.)

모드, 즉 variant는 optional props로 받아옵니다. 그리고 커스텀을 위해 className도 optional props로 받아오는 방법입니다.

tailwind에서는 단순히

className = {`${variant} ${className}`}

 

이렇게 덮어씌우려 하면, 같은 속성을 다른 값으로 지정할 때 정상적으로 덮어씌워지지 않습니다. (되는 것도, 안 되는 것도 있음.)

그래서 사용한 방식은 tailwind-merge의 twMerge 라이브러리입니다. 이 라이브러리는 공통되는 속성이 있으면, 뒤에서 정의한 스타일을 적용하여 충돌을 해결해줍니다.

 

variant와 className을 모두 넘기지 않을 경우를 대비하여, 공통 스타일인 baseStyle도 정의해줍니다. twMerge를 통해 스타일링 충돌을 간편히 해결할 수 있으니 공통 스타일도 분리하는 게 이득이겠죠?

 

정리하면 다음과 같습니다.

import { twMerge } from 'tailwind-merge';

type ButtonProps = {
  ...,
  variant?: 'primary' | 'secondary' | 'disabled';
  className?: string;
};

export const Button = ({ content, variant, onClick, className }: ButtonProps) => {
  const baseStyle = 'px-8 py-2 rounded-xl text-sm';
  const variantStyle = variant ? ButtonStyle[variant] : '';
  const classNameStyle = className ? className : '';
  
  const mergedStyle = twMerge(baseStyle, variantStyle, classNameStyle);
  
  return (
    <button className={mergedStyle} onClick={...} disabled={variant === 'disabled'}>
      {content}
    </button>
  );
};

const ButtonStyle = {
  primary: 'bg-blue-500 text-gray-700 cursor-pointer',
  secondary: 'bg-white text-gray-700 cursor-pointer',
  disabled: 'bg-gray-200 text-gray-400 cursor-not-allowed',
};

 

 

3. Children Props 설정

children이 공통 컴포넌트의 props로 사용되는 것을 많이 보셨을 겁니다. children은 컴포넌트의 열린 태그와 닫힌 태그 사이에 들어가는 내용을 자유롭게 전달할 수 있게 하는 특별한 props입니다. 사실 이 속성은 어떤 컴포넌트인지에 따라 전달하는게 나을 수도, 투머치일 수도 있습니다. 예를 들면 Alert 모달 내에 체크박스가 들어가야 한다면, 이 컴포넌트는 children을 props로 전달해야 하겠죠.

 

하지만, Button 컴포넌트는 들어갈 수 있는 요소가 한정되어 있다고 생각합니다. 버튼 내용에는 보통 string이 들어가기 때문에, 예외 경우가 많지 않다면 content: string props만 받아도 충분할 것입니다. 최종 Button 컴포넌트는 다음과 같습니다.

import { twMerge } from 'tailwind-merge';

type ButtonProps = {
  content: string;
  variant?: 'primary' | 'secondary' | 'disabled';
  onClick?: () => void;
  className?: string;
};

export const Button = ({ content, variant, onClick, className }: ButtonProps) => {
  const baseStyle = 'px-8 py-2 rounded-xl text-sm';
  const variantStyle = variant ? ButtonStyle[variant] : '';
  const classNameStyle = className ? className : '';
  
  const mergedStyle = twMerge(baseStyle, variantStyle, classNameStyle);
  
  return (
    <button className={mergedStyle} onClick={onClick} disabled={variant === 'disabled'}>
      {content}
    </button>
  );
};

const ButtonStyle = {
  primary: 'bg-blue-500 text-gray-700 cursor-pointer',
  secondary: 'bg-white text-gray-700 cursor-pointer',
  disabled: 'bg-gray-200 text-gray-400 cursor-not-allowed',
};

 


다시 한 번 남기지만, 이 글은 완성된 버튼 컴포넌트를 만드는 게 아닌, 더 깔끔하고 유연한 코드를 짜기 위한 토대를 작성한 글입니다.

결국 어떤 기능을 만드는지보다도, 어떤 구조로 설계할지 고민하는 것이 중요한 것 같습니다. 이를 통해 다른 컴포넌트들을 만들 때엔 좀 더 쉽고 빠르게 좋은 코드를 만들 수 있을 것이라 생각합니다.