프로젝트에서 공통으로 사용하는 버튼 컴포넌트 개발을 맡게 되었습니다.
아래 보이는 버튼들을 모두 하나의 버튼 컴포넌트로 관리해야 한다니... 일단 도전!
// 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을 꼼꼼히 작성했습니다. 이를 통해 팀원들과의 협업 능력을 향상시키는 계기가 되었을 뿐 아니라 제 코드의 구조와 의도를 다시 한 번 검토하며 스스로 학습할 수 있는 좋은 기회가 되었습니다.
'프로젝트 > Next+TypeScript' 카테고리의 다른 글
[Taskify] 무한스크롤 - 해결 (4) | 2024.12.19 |
---|---|
[Taskify] 대시보드 상세 - 무한 스크롤 (4) | 2024.12.19 |
[Taskify] 대시보드 상세 페이지 (4) | 2024.12.17 |
[Taskify] Chip(공통 컴포넌트) 추가 (4) | 2024.12.16 |
[프로젝트] Taskify (태스키파이) 소개 (5) | 2024.12.12 |