프로젝트/Next+TypeScript

[Taskify] 버튼(공통 컴포넌트) 추가

dev-hpk 2024. 12. 13. 15:11

프로젝트에서 공통으로 사용하는 버튼 컴포넌트 개발을 맡게 되었습니다.

아래 보이는 버튼들을 모두 하나의 버튼 컴포넌트로 관리해야 한다니... 일단 도전!

버튼 UI

// Button.tsx
import styles from "./Button.module.css";
import { PropsWithChildren, ReactNode } from "react";
import clsx from "clsx";

interface ButtonProps {
  children: ReactNode;
  classes?: string | string[];
  disabled?: boolean;
  handler: () => void;
}

function Button({
  children,
  classes,
  disabled,
  handler,
}: PropsWithChildren<ButtonProps>) {
  const btnClass = classes
    ? Array.isArray(classes)
      ? classes.map((className) => styles[className] || className)
      : [styles[classes] || classes]
    : [];

  return (
    <button
      className={clsx(styles.button, ...btnClass)}
      onClick={handler}
      disabled={disabled}
    >
      {children}
    </button>
  );
}

export default Button;

 

 

Button을 사용하는 페이지에서 props로 스타일, disabled, handler를 전달하도록 만들어 봤습니다.

  • classes: 클래스 이름 or 클래스 이름 배열로 styles 객체와 매핑해 Module Css에 선언한 스타일을 적용하기 위한 prop
  • disalbed: 버튼의 disabled 상태를 버튼을 사용하는 페이지에서 관리할 수 있게 하기 위한 prop
  • handler: 버튼을 클릭했을 때 동작을 처리할 클릭 이벤트 핸들러

작업을 마치고 Button 컴포넌트를 사용하다가 문제를 발견했습니다.

버튼의 스타일을 커스텀하기 위해서 클래스를 직접 추가해줘야 한다는 것입니다..😱

 

예시 코드)

<Button classes={['colored', 'border', 'font-3xl-bold', 'round', ....]} />

 

멘토님께 질문도 해보고 디자인 라이브러리들을 참고해 본 결과 Button을 감싸는 상위 컴포넌트를 추가하기로 했습니다.

추가로 class를 직접 넣는 방법 대신 정해진 디자인을 사용하는 방법을 선택했습니다.

 

수정한 코드

import { ButtonHTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import clsx from 'clsx';
import generateClassNames from '@/utils/generateClassNames';
import styles from './Button.module.css';

const types = {
  normal: {
    classes: ['normal', 'border'],
  },
  normal_colored: {
    classes: ['normal', 'colored'],
  },
  delete: {
    classes: ['delete', 'border'],
  },
  cancel: {
    classes: ['cancle', 'border'],
  },
  edit: {
    classes: ['edit', 'border'],
  },
  modal: {
    classes: ['modal', 'border'],
  },
  modal_colored: {
    classes: ['modal', 'colored'],
  },
  modal_single: {
    classes: ['modal', 'single', 'colored'],
  },
  auth: {
    classes: ['auth', 'colored'],
  },
  column: {
    classes: ['column', 'border'],
  },
  todo: {
    classes: ['todo', 'border'],
  },
  dashboard_add: {
    classes: ['dashboard', 'add', 'border'],
  },
  dashboard_delete: {
    classes: ['dashboard', 'delete', 'border'],
  },
  dashboard_card: {
    classes: ['dashboard', 'card', 'border'],
  },
};

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  children: ReactNode;
  classes: string[];
}

function Button({
  children,
  classes,
  ...props
}: PropsWithChildren<ButtonProps>) {
  const classNames = generateClassNames(classes, styles);
  return (
    <button
      type="button"
      className={clsx(styles.button, classNames)}
      {...props}
    >
      {children}
    </button>
  );
}

type BadgeColor = 'green' | 'purple' | 'orange' | 'blue' | 'pink';

interface CDSButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  btnType: keyof typeof types;
  badge?: BadgeColor;
  owner?: boolean;
}

function CDSButton({
  children,
  btnType,
  badge,
  owner,
  ...props
}: CDSButtonProps) {
  return (
    <Button classes={types[btnType].classes} {...props}>
      <span className={styles.button_content}>
        {btnType === 'dashboard_card' && badge && (
          <span className={clsx(styles.badge, styles[badge])} />
        )}
        {children}
        {['column', 'todo', 'dashboard_add'].includes(btnType) && (
          <img className={styles.icon_plus} src="ic/ic_chip.svg" alt="icon" />
        )}
        {btnType === 'dashboard_card' && owner && (
          <img
            className={styles.icon_crown}
            src="ic/ic_crown.svg"
            alt="ic_crown"
          />
        )}
      </span>
    </Button>
  );
}

CDSButton.defaultProps = {
  badge: null,
  owner: false,
};

export default CDSButton;

 

코드 설명을 먼저 하려고 했는데 너무 길어서... 파일별로 분리한 후 설명하겠습니다!

 

코드 분리

/type/button.ts

import { ButtonHTMLAttributes, ReactNode } from 'react';

// BadgeColor: 배지 색상 타입.
// 허용되는 색상: 'green', 'purple', 'orange', 'blue', 'pink'
export type BadgeColor = 'green' | 'purple' | 'orange' | 'blue' | 'pink';

// 버튼의 타입과 관련된 클래스 리스트.
export const types = {
  normal: {
    classes: ['normal', 'border'],
  },
  normal_colored: {
    classes: ['normal', 'colored'],
  },
  delete: {
    classes: ['delete', 'border'],
  },
  cancel: {
    classes: ['cancle', 'border'],
  },
  edit: {
    classes: ['edit', 'border'],
  },
  modal: {
    classes: ['modal', 'border'],
  },
  modal_colored: {
    classes: ['modal', 'colored'],
  },
  modal_single: {
    classes: ['modal', 'single', 'colored'],
  },
  auth: {
    classes: ['auth', 'colored'],
  },
  column: {
    classes: ['column', 'border'],
  },
  todo: {
    classes: ['todo', 'border'],
  },
  dashboard_add: {
    classes: ['dashboard', 'add', 'border'],
  },
  dashboard_delete: {
    classes: ['dashboard', 'delete', 'border'],
  },
  dashboard_card: {
    classes: ['dashboard', 'card', 'border'],
  },
};

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  children: ReactNode;
  classes: string[];
}

export interface CDSButtonProps
  extends ButtonHTMLAttributes<HTMLButtonElement> {
  btnType: keyof typeof types;
  badge?: BadgeColor;
  owner?: boolean;
}
  • ButtonHTMLAttributes: Button Html 요소가 가질 수 있는 모든 속성 타입을 제공합니다. 기존에 disabled나, handler 같은 prop을 사용하지 않아도 되기 때문에 상속받아서 사용했습니다.
    React.ComponentProps <'button'>을 사용하면 ButtonHTMLAttributes와 동일한 동작을 더 간결하게 할 수 있다고 해서 추후 더 공부해 보기로 하겠습니다. 
  • types: Button 컴포넌트를 사용할 때 클래스를 직접 넣어주지 않아도 type만 설정하면 사용할 수 있도록 타입을 선언했습니다. 버튼 타입이 추가되거나 스타일이 바뀌어도 유지보수 측면에서도 유리하겠죠?

/utils/generateClassNames.ts

/**
 * 각 클래스 이름에 해당하는 스타일을 매핑하는 유틸 함수
 *
 * @param {string | string[]} classes - 클래스 이름 문자열 또는 문자열 배열
 * @param {Record<string, string>} styles - 클래스 이름에 대한 스타일을 매핑한 객체
 * @returns {string[]} 스타일을 매핑한 클래스 이름 배열
 *
 * - `classes`가 문자열일 경우 해당 클래스에 해당하는 스타일을 반환하고,
 * - `classes`가 배열일 경우 배열 내 각 클래스에 대해 스타일을 적용한 결과를 반환합니다.
 * - module.css에 해당 클래스가 없으면 원래의 클래스 이름을 그대로 반환합니다.
 */
const generateClassNames = (
  classes: string | string[],
  styles: Record<string, string>,
): string[] => {
  if (!classes) return [];
  return Array.isArray(classes)
    ? classes.map((className) => styles[className] || className)
    : [styles[classes] || classes];
};

export default generateClassNames;

 

CDSButton.tsx

import clsx from 'clsx';
import { CDSButtonProps, types } from '@/type/button';
import styles from './Button.module.css';
import Button from './Button';

/**
 * CDSButton: 다양한 버튼 유형을 처리하는 공통 버튼 컴포넌트.
 *
 * @param {CDSButtonProps} props - 커스텀 버튼 속성
 * @returns {JSX.Element} - 버튼 컴포넌트
 */
function CDSButton({
  children,
  btnType,
  badge,
  owner,
  ...props
}: CDSButtonProps) {
  /**
   * renderPlusIcon: 특정 버튼 유형에서 + 아이콘 렌더링
   * - 대상: 'column', 'todo', 'dashboard_add' 타입
   * @returns {JSX.Element} - + 아이콘
   */
  const renderPlusIcon = () =>
    ['column', 'todo', 'dashboard_add'].includes(btnType) && (
      <img className={styles.icon_plus} src="ic/ic_chip.svg" alt="icon" />
    );

  /**
   * renderOwnerIcon: 대시보드 카드 유형에서 소유인 경우 왕관 아이콘 렌더링
   * - 조건: btnType === 'dashboard_card' && owner === true
   * @returns {JSX.Element} - 왕관 아이콘
   */
  const renderOwnerIcon = () =>
    btnType === 'dashboard_card' &&
    owner && (
      <img className={styles.icon_crown} src="ic/ic_crown.svg" alt="ic_crown" />
    );

  /**
   * renderBadge: 대시보드 카드 유형에서 색상 배지 렌더링
   * - 조건: btnType === 'dashboard_card' && badge !== null
   * @returns {JSX.Element} - 배지 span 태그
   */
  const renderBadge = () =>
    btnType === 'dashboard_card' &&
    badge && <span className={clsx(styles.badge, styles[badge])} />;

  return (
    <Button classes={types[btnType].classes} {...props}>
      <span className={styles.button_content}>
        {renderBadge()}
        {children}
        {renderPlusIcon()}
        {renderOwnerIcon()}
      </span>
    </Button>
  );
}

CDSButton.defaultProps = {
  badge: null,
  owner: false,
};

export default CDSButton;

 

CDSButton 컴포넌트는 처음에 아래 코드처럼 조건부 렌더링으로 처리했었습니다.

작업을 마치고 보니 가독성 측면에서 너무 떨어지고, 조건이 추가되면 유지보수하기 어려울 것 같아서 개별 함수로 분리했습니다.

svg 아이콘은 아직 SVG 컴포넌트 방식과 SVGR 중 결정 전이라 임의로 img 태그 사용했습니다...!

...
return (
    <Button classes={types[btnType].classes} {...props}>
      <span className={styles.button_content}>
        {btnType === 'dashboard_card' && badge && (
          <span className={clsx(styles.badge, styles[badge])} />
        )}
        {children}
        {['column', 'todo', 'dashboard_add'].includes(btnType) && (
          <img className={styles.icon_plus} src="ic/ic_chip.svg" alt="icon" />
        )}
        {btnType === 'dashboard_card' && owner && (
          <img
            className={styles.icon_crown}
            src="ic/ic_crown.svg"
            alt="ic_crown"
          />
        )}
      </span>
    </Button>
  );
  ...

 

Button.tsx

import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import generateClassNames from '@/utils/generateClassNames';
import { ButtonProps } from '@/type/button';
import styles from './Button.module.css';

/**
 * Button: 공통 버튼 컴포넌트
 * @param {ReactNode} children - 버튼 내부의 콘텐츠
 * @returns {JSX.Element} - 버튼 컴포넌트
 */
function Button({
  children,
  classes,
  ...props
}: PropsWithChildren<ButtonProps>) {
  const classNames = generateClassNames(classes, styles);
  return (
    <button
      type="button"
      className={clsx(styles.button, classNames)}
      {...props}
    >
      {children}
    </button>
  );
}

export default Button;

 

완성하고 보니, 컴포넌트를 만드는 시간보다 리팩토링에 쓴 시간이 더 많은 것 같네요...😂

리팩토링도 끝났겠다 이제 결과물을 공개하겠습니다..! 다른 페이지에서 바로 가져다 쓸 수 있게 테스트 페이지에 작업했습니다.

 

 

 

느낀 점

  • 초기 설계의 중요성 : 초기 잘못된 코드 구조 때문에 리팩토링을 반복하면서 초기 설계가 얼마나 중요한지 알게 되었습니다.
  • 코드 품질 향상에 대한 새로운 관점 : 리팩토링 과정에서 코드 품질 향상에 집중하다 보니 가독성과 유지보수성을 고려하게 되었고, 이 과정에서 단순히 동작하는 코드와 유지보수 가능한 코드의 차이를 깨닫게 되었습니다.
  • 협업의 중요성 : 멘토님과 팀원들과의 코드 리뷰를 통해 개선 방향을 명확히 하고 더 나은 코드를 작성할 수 있었습니다.
  • 코드 설명을 문서화하자 : 팀원들이 제가 만든 컴포넌트를 쉽게 이해하게 하기 위해 jsdoc을 꼼꼼히 작성했습니다. 이를 통해 팀원들과의 협업 능력을 향상시키는 계기가 되었을 뿐 아니라 제 코드의 구조와 의도를 다시 한 번 검토하며 스스로 학습할 수 있는 좋은 기회가 되었습니다.
 

 

 

 

[프로젝트] Taskify (태스키파이) 소개

드디어 중급 프로젝트 시작이다!! 지난 초급 프로젝트는 팀원들 모두 첫 프로젝트를 진행하는 상황이라 소통과 일정 관리, 그리고 업무 분담 면에서 많은 어려움이 있었습니다. 작업이 겹치거

dev-hpk.tistory.com