재사용 가능한 버튼 컴포넌트 만들기 - React

Ben·2022년 10월 18일
6

Today I Learned

목록 보기
52/57

개인 프로젝트를 하면서 접근했던 과정들과 생각들을 담은 글입니다. 틀린 내용이 있을 수 있습니다.

개요

  • 프론트엔드에서 재사용 가능한 컴포넌트를 만드는 과정은 중요하면서도 어렵다.
  • 버튼의 재사용성을 높이기 위해서는 공통적인 스타일을 지키면서도, 다양한 크기 및 다양한 색상을 표현할 수 있어야 한다.
  • 개인 프로젝트에서 재사용할 수 있는 버튼 컴포넌트를 만드는 연습을 해 보았다.

사용 스택 및 예상 구조

사용 스택

  • TypeScript, React, styled-components, storybook

TypeScript

  • interface와 type을 정의하고 이를 컴포넌트에 적용하여, 입력 받을 타입을 강제할 수 있다.
  • 불가능한 타입을 입력할 경우, 컴파일 단계에서 에러를 내어주므로 실제 실행 전에 에러를 잡아내, 생산성을 극대화 할 수 있다.

React

  • React, Vue, Angular 등 어떠한 것들을 사용해도 동일하다.
  • 그러나 가장 익숙한 tool이고, 다른 라이브러리 또는 프레임워크에 비해 다뤄본 경험이 많기 때문에 React를 사용

styled-components

  • styled-components는 대표적인 css-in-js 라이브러리이다.
  • css-in-js를 사용하면 컴포넌트에 대한 클래스명을 고민하지 않아도 원하는 스타일을 쉽게 바인딩할 수 있다.
  • 비슷한 css-in-js 라이브러리로 emotion이 있다. emotion과 styled-components는 다운로드 수 및 사용 방법이 매우 비슷하다.
  • emotion은 SSR 환경에서도 다른 설정이 필요 없다는 것이 장점이지만, pragma 설정을 위해 babel환경에서 추가적인 플러그인 설치가 필요하다. 현재 프로젝트는 create-react-app으로 만들었기 때문에 babel 설정을 하기 위해서는 yarn eject를 통해 설정 파일을 밖으로 꺼내거나, craco 패키지를 설치하여 babel만 따로 설정해주는 작업이 필요하다.
  • 따라서 emotion을 사용하는 대신 create-react-app 환경에서 별 다른 설정이 필요 없는 styled-components를 사용하기로 하였다.

storybook

  • 컴포넌트 단위로 관리할 수 있는 라이브러리
  • App.tsx에 컴포넌트를 작성하고 띄워서 확인하지 않고, 각각의 독립된 컴포넌트를 렌더링하여 여러가지 조건에서 렌더링 테스트를 진행할 수 있다
  • 여러 디자인 시스템을 테스트하고 관리하는데 있어서, 컴포넌트 단위로 관리할 수 있는 storybook 라이브러리를 선택하였다.

설치하기

React TypeScript 설치하기

# 현재 경로에 프로젝트 설치
npx create-react-app --template typescript . 

storybook 설치하기

npx -p @storybook/cli sb init

styled-components 설치하기

yarn add styled-components

기준

재사용성이 높은 버튼 컴포넌트는 어떠한 기준을 만족해야 할까?

확장성

가장 기본적인 구조만 구성하여 다양한 곳에서 재사용할 수 있어야 하므로, 이를 바탕으로 다른 컴포넌트를 쉽게 조합할 수 있도록 prop을 통해 컴포넌트를 조합할 수 있어야 한다.

또한, 다양한 버튼 관련 기본 프롭들을 배치할 수 있도록 prop interface를 정의해야 한다.

// example
// Component.tsx

interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'default' | 'success' | 'warning' | 'error'
}

function Component({ variant = 'default', ...props }: Props) {
  return (
    <button {...props}>{children}</button>
}

export default Component

재사용성

재사용성을 높이기 위해서는 해당 컴포넌트가 도메인에 얽히지 않아야 하며, prop, attributes의 이름들이 일반적이어야 한다. 필요한 프롭들을 추가함으로써 편리하게 사용할 수 있지만, 나중에 재사용하려고 했을 때 일반적으로 사용되는 버튼 컴포넌트의 프롭들과 네이밍 컨벤션이 다르다면 혼란이 오게 된다.

// example
// Button.tsx

// bad
function Button({ onClickButton, title }: Props) {
  return (
    <button onClick={onClickButton}>{title}</button>
}

// bad
// no domain on atom component
// 도메인이 컴포넌트에 얽히면 재사용이 어렵다.
interface IAuthPageRoutingButtonProps {
  type: 'signup' | 'signin';
}

function AuthPageRoutingButton({ type }: IAuthPageRoutingButtonProps) {
  const { text, path, link } =
    type === 'signup'
      ? ...
      : ...

  return (
    <S.Container>
      {text}
      <Link href={path}>{link}</Link>
    </S.Container>
  );

// good
function Button({ onClick, children }: Props) {
  return (
    <button onClick={onClick}>{title}</button>
}

요구 사항

우선 재사용 가능한 버튼이 가져야 할 조건들에 대해서 생각해본다.

버튼의 shape는 항상 동일해야 한다.

다양한 환경을 커버하더라도, 전체적인 모양은 변하지 않아야 한다. 즉 어떤 크기를 갖든, 어떤 색을 갖던지 간에 border-radius, padding 값에 대한 속성들은 고정되어 있어야 한다.

variant prop을 통해 preset된 색상을 선택할 수 있어야 한다.

디자인 시스템을 정의 하는 이유는, 일관성 있는 디자인을 구현하기 위한 것으로 제한적으로 prop을 통해 정의된 스타일을 적용할 수 있도록 만들어야 한다.

미리 정의된 색을 variant 라는 prop을 이용하여 선택할 수 있도록 만들 것이다.

size prop을 통해 preset된 크기를 선택할 수 있어야 한다.

variant prop을 정의하는 것과 동일한 이유로, size 역시 일관성 있는 디자인을 구현하기 위한 것으로, 미리 정의된 스타일을 size라는 prop을 이용하여 선택할 수 있도록 만들 것이다.

버튼 hover, active diabled에 대한 각각의 스타일이 존재하여, 사용자가 버튼을 클릭했을 때 클릭하는 느낌이 들어야 하며, disabled 되었을 때는 disabled 되었다는 느낌이 들어야 한다.

따라서 variant에 맞도록 적절한 색상을 :hover, :active, :disabled 등 다양한 경우에 맞게 배치해야 한다.

ButtonBase 만들기

ButtonBase를 styled-components를 이용하여 만들어주었다.

styled-components로 스타일을 만들면 좋은 점은, styled-components로 정의한 스타일은, 따로 컴포넌트 형식으로 감싸주지 않아도 컴포넌트처럼 동작하며 prop을 가질 수 있다는 것이다.

하지만, 여기서는 Prop 자체를 확장성 있게 받을 수 있도록 interface를 상속하여 추가해 주었다.

// ButtonBase.tsx
// 실제 적용 코드는 여러 파일로 분리되어 있으나, 편의상 한 파일에 작성하였습니다.

type Variant = 'default' | 'primary' | 'success' | 'warning' | 'error';
type Size = 'sm' | 'md' | 'lg' | 'xl';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  /* Button 색상을 결정하는 prop입니다. */
  variant: Variant;

  /* Button Size를 결정하는 prop입니다. */
  size: Size;

  /* Button이 fullWidth를 차지할 것인지 결정하는 prop입니다.*/
  fullWidth: boolean;
}

export type Props = Partial<ButtonProps>;

const ButtonBase = styled.button<Props>`
  display: inline-flex;
  gap: 4px;
  justify-content: center;
  align-items: center;
  vertical-align: center;
  position: relative;
  min-width: 64px;
  border: none;
  border-radius: 6px;
  padding: 10px 12px;
  cursor: pointer;

  line-height: 1.2;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;

  ${/* button의 Content를 선택할 수 없도록 한다. */}
  user-select: none;  

  transition: background-color 0.1s ease;
`;

export default ButtonBase;

여기서, 버튼이 hover, active, disabled 될 때, variant에 맞는 각각의 색깔을 보여줘야 하기 때문에, 사전에 primary, success, warning, error에 관련된 속성들을 정의해 두었다.

// example
// color.ts

const COLOR = {
  GREY_100: '',
  GRAY_200: '',
  // ...

  SUCCESS_100: '',
  SUCCESS_200: '',
  // ...
}

export default COLOR;

그리고 각 variant에 맞는 색상들을, active, hover, disabled에 맞게끔 표시하기 위해, styled-components 의 css 함수를 이용하여 함수형으로 스타일을 정의해 주었다.

css 함수를 이용해서 스타일을 정의하면 prop에 따라 동적으로 스타일을 구현할 수 있을 뿐더러 가독성 및 해당 함수를 재사용할 수 있기 때문에 애용하는 편이다.

// ButtonBase.tsx

// getX00Color는 variant에 맞는 색을 불러오는 함수입니다.
// :hover, :active, :disabled에 맞는 variant 색상을 적용합니다.
// :disabled일 때는 cursor 및 pointer-events가 발생하지 않도록
// 처리합니다.
const buttonRoleStyle = css<Props>`
  ${({ variant = 'default' }) => css`
    background-color: ${get500Color(variant)};
    color: ${DEFAULT_COLORS.GREY_25};

    &:hover {
      background-color: ${get600Color(variant)};
    }

    &:active {
      background-color: ${get700Color(variant)};
    }

    &:disabled {
      background-color: ${get300Color(variant)};
      pointer-events: none;
      cursor: default;
    }
  `}
`;

const ButtonBase = styled.button<Props>`
  ${// ... }

  /* background-color style */
  ${buttonRoleStyle}
`;

마찬가지로, size prop을 통해 size를 동적으로 구성할 수 있는 css 함수와 fullWidth를 통해 전체 너비를 차지할 것인지에 prop을 통해 동적으로 구성할 수 있는 함수를 구현하였다.

// button size를 동적으로 구성하는 함수
const sizeStyle = css<Props>`
  ${({ size = 'md' }) => {
    if (size === 'sm') {
      return css`
        padding: 8px 10px;
        font-size: ${DEFAULT_FONT_SIZES.b2}px;
      `;
    }

    if (size === 'lg') {
      return css`
        padding: 14px 18px;
        font-size: ${DEFAULT_FONT_SIZES.b2}px;
      `;
    }

    if (size === 'xl') {
      return css`
        padding: 14px 18px;
        font-size: ${DEFAULT_FONT_SIZES.b1}px;
      `;
    }

    // default styles
    return css`
      padding: 10px 12px;
      font-size: ${DEFAULT_FONT_SIZES}px;
    `;
  }}
`;

// button block style을 동적으로 구성하는 함수
const blockStyle = css<Props>`
  ${({ fullWidth }) => {
    if (fullWidth) {
      return css`
        width: 100%;
      `;
    }
    return css``;
  }}
`;

const ButtonBase = styled.button<Props>`
  /** ... */

  /* size style */
  ${buttonRoleStyle}

  /* block style */
  ${blockStyle}
`;

Storybook 구성하기

버튼 컴포넌트가 어느정도 완성되었으니, 컴포넌트를 페이지에 삽입하지 않고 테스트할 수 있는 storybook 컴포넌트를 만들어보자. storybook 컴포넌트는 *.stories.tsx 형태의 파일 컨벤션을 갖는다.

// ButtonBase.stories.tsx
import Button from './ButtonBase';
import type { ComponentMeta, ComponentStory } from '@storybook/react';

// Component meta data
export default {
  title: 'Components/Button',
  component: Button,
  args: {
    variant: 'primary',
    disabled: false,
  },
} as ComponentMeta<typeof Button>;

// 표시할 컴포넌트
export const Default: ComponentStory<typeof Button> = (args) => (
  <Button {...args}>Button</Button>
);

storybook 컴포넌트는 기본만 작성해 주었다. 저렇게 작성만 하더라도, 똑똑한 storybook 컴포넌트가 알아서 옵션들을 잡아주기 때문에, 추가로 건드릴 것이 별로 없었다.

잘 동작함!

이렇게 잘 뜨는 것을 확인할 수 있고, 여러 옵션들을 클릭하면 그에 맞게 버튼이 렌더링되는 것을 확인할 수 있다.

여러가지 옵션들을 선택해서 확인하면, 모든 옵션에 대해 잘 동작함을 확인할 수 있다. 다양한 옵션들을 선택해서 확인해보면, 다음과 같은 컴포넌트들을 확인할 수 있다.

ButtonBase를 바탕으로, LoadingButton 만들기

ButtonBase는 그대로 갖다 쓸 수 있지만, 비동기 작업을 위하여 로딩중에는 로딩 스피너가 보여지는 컴포넌트를 만들어 볼 것이다. 우리는 이미 ButtonBase라는 재사용성이 높은 컴포넌트를 만들었기 때문에, 바로 확장만 해서 사용할 수 있다.

ButtonBase의 재사용성이 높은 이유는, 컴포넌트가 어떤 컴포넌트에도 의존적이지 않고, 상태를 갖지 않으며, 도메인에 얽혀있지 않기 때문이다.

LoadingButton 요구사항

  • loading이라는 prop을 통해 버튼의 상태와 스피너가 보이도록 처리해야 한다.

컴포넌트 구현하기

컴포넌트를 구현해보자. Button 컴포넌트를 만들기 전에, Spinner의 스타일을 정의해 두었다. 이 Spinner는 size를 프롭으로 받아서 컴포넌트의 크기를 결정할 수 있다.

interface Props extends ButtonBaseProps {
  loading?: boolean;
}

type LoadingProps = Pick<Props, 'loading'>;

const Button = ({ loading = false, children, size, ...props }: Props) => (
  <LoadingButton disabled={loading} size={size} {...props}>
    <Wrapper loading={loading}>
      <Spinner size={size === 'xl' ? 12 : 10} />
    </Wrapper>
    {children}
  </LoadingButton>
);

export default Button;

const buttonLoadingStyle = css<Props>`
  ${({ disabled }) => {
    if (disabled) {
      return css`
        color: ${DEFAULT_COLORS.TRANSPARENT};
      `;
    }
    return css``;
  }}
`;

const LoadingButton = styled(ButtonBase)`
  ${buttonLoadingStyle}
`;

const Wrapper = styled.span<LoadingProps>`
  display: flex;
  justify-content: center;
  align-items: center;
  position: absolute;
  visibility: ${({ loading }) => (loading ? 'visible' : 'hidden')};
`;

LoadingButton은 loading이란 프롭을 받고, loading 상태라면 button이 disabled 처리가 되며, 스피너가 보여진다. Loading 시 스타일이 기존 버튼과 약간 다르기 때문에, styled-components의 스타일을 이어받아 버튼의 스타일을 새롭게 정의하였다.

  • 이 때, 버튼의 글자가 사라지더라도 버튼의 레이아웃이 변하면 안되기 때문에, 글자를 언마운트 하는 것이 아닌, css style인 transparent color를 이용하여 시각적으로 보이지 않게 처리하였다.
  • 이미 ButtonBase에 user-select: none; 속성으로 버튼의 속성을 긁어서 볼 수 없도록 처리하였기 때문에, 글자를 transparent 처리하더라도 보여지지 않는다.

Storybook 구성하기

// LoadingButton.stories.tsx

import Button from './LoadingButton';
import type { ComponentMeta, ComponentStory } from '@storybook/react';

export default {
  title: 'Components/LoadingButton',
  component: Button,
  args: {
    loading: false,
    fullWidth: false,
    variant: 'default',
  },
  argTypes: {
    loading: {
      name: 'loading',
      type: { name: 'boolean', required: false },
      defaultValue: false,
      description: 'loading condition',
    },
    variant: {
      name: 'variant',
      type: { name: 'string', required: false },
      description: 'variant color',
      control: 'radio',
      options: ['default', 'primary', 'success', 'warning', 'error'],
    },
    size: {
      name: 'size',
      type: { name: 'string', required: false },
      description: 'size',
      control: 'radio',
      options: ['sm', 'md', 'lg', 'xl'],
    },
  },
} as ComponentMeta<typeof Button>;

export const Default: ComponentStory<typeof Button> = (args) => (
  <Button {...args}>Button</Button>
);

아까 ButtonBase와는 달리, argTypes를 통해 옵션들을 잡아주지 않으면 제대로 스토리북에서 옵션을 설정할 수 없는 문제가 있어서 해당 부분을 커스터마이징 해주었다.

loading 상태에 맞게끔 잘 동작함을 확인할 수 있다.

TL;DR

재사용성 높은 컴포넌트를 구현하기 위해서는,

  • 확장성이 있도록 설계해야한다.
  • attribute 명이 일반적이어야 한다.
  • 도메인에 얽혀있으면 안된다.

의문점

  • get300Color 같은 함수를 사용하는 것이 맞나? 하는 생각
    • 단순히 variant에 따라 색을 반환하는 것이고, 비슷한 형태(로직이랄 것도 없긴 하지만)가 반복됨
  • LoadingButton 컴포넌트를 조금만 더 일반적으로 구현하는 방법?

더 보완해야할 점

  • 좀 더 다양한 케이스에 대한 대응 → loading button의 position의 위치 및 가운데 정렬 뿐만 아니라 다른 케이스에 대한 대응 역시 필요하다고 생각
  • outlined button에 대한 스타일 정의
  • 버튼 컴포넌트가 여러 개가 놓여져 있을 때 테스트 해봐야 함

결론 및 느낀 점

메인 로직 먼저 구현하는 것이 맞긴 한데, 개인 프로젝트인 만큼 원하는 부분을 먼저 다뤄보고자 하였다.

탑다운으로 컴포넌트를 만드는 것이 아닌, 바텀업 방식으로 작은 컴포넌트들을 조합해서 만들어보는 방식을 사용해보고 싶었다.

이러한 재사용성이 높은 컴포넌트를 우선적으로 구현함으로써 앞으로 개발 생산성에 긍정적인 영향을 미칠 수 있을 거라 기대한다.

profile
New Blog -> https://portfolio-mrbartrns.vercel.app

2개의 댓글

comment-user-thumbnail
2023년 6월 20일

정말 유익한 포스팅 잘 읽었습니다! 아주 상세한 설명과 예제도 적당히 깔끔하게 정리되어 있어서 읽기 수월했습니다.

1개의 답글