ts-pattern을 활용한 강력한 디자인 시스템 컴포넌트 만들기

허민(허브)·2023년 5월 12일
19

개요

디자인 시스템은 현대의 웹 개발에서 필수적인 요소로 자리잡았습니다. 하지만 디자인 시스템의 구축과 유지 관리는 복잡하고 어려운 과정일 수 있습니다. 특히, 컴포넌트의 다양한 사이즈와 스타일에 대한 일관성을 유지하기 위해서는 많은 조건 분기와 타입 체크가 필요합니다. 이런 문제를 해결하기 위해 타입스크립트와 함께 사용할 수 있는 강력한 도구가 있습니다. 그것이 바로 ts-pattern입니다.

ts-pattern 이란?

ts-pattern은 타입스크립트에서 패턴 매칭을 구현할 수 있게 해주는 라이브러리입니다.

이 라이브러리를 사용하면 선언적이고 가독성 좋은 코드로 다양한 패턴을 처리 할 수 있습니다. 특히, 디자인 시스템 컴포넌트의 구현에서 ts-pattern은 타입 안정성선언적 구현을 동시에 제공하여 개발자가 더욱 효율적으로 컴포넌트를 구축할 수 있게 해줍니다.

이번 글에서는 ts-pattern을 사용하여 디자인 시스템 컴포넌트를 만드는 방법을 알아보겠습니다. 먼저 ts-pattern에 대한 간단한 소개와 이 라이브러리가 필요한 배경에 대해 알아보겠습니다. 그리고 실제 예제를 통해 어떻게 ts-pattern을 활용하여 강력하고 일관성 있는 디자인 시스템 컴포넌트를 구축할 수 있는지 살펴보겠습니다.

ts-pattern 간단 예제

import { match } from 'ts-pattern';

type Weather = 'sunny' | 'cloudy' | 'rainy' | 'snowy';

const getWeatherDescription = (weather: Weather): string => {
  return match<Weather, string>(weather)
    .with('sunny', () => 'It is a sunny day!')
    .with('cloudy', () => 'It is a cloudy day.')
    .with('rainy', () => 'It is raining outside.')
    .with('snowy', () => 'It is snowing!')
    .exhaustive();
};

const currentWeather = 'sunny';

const weatherDescription = getWeatherDescription(currentWeather);

console.log(weatherDescription);

위의 예시 코드는 getWeatherDescription 함수를 통해 주어진 weather 값에 따라 해당하는 날씨 설명을 반환합니다. match 함수를 사용하여 weather 값과 매칭되는 내용을 선택합니다. 이를 통해 타입 안정성을 유지하면서 각 경우에 대한 설명을 제공할 수 있습니다.

위의 코드를 실행하면 currentWeather 값이 'sunny'이므로 'It is a sunny day!'라는 메시지가 출력됩니다. 만약 currentWeather 값을 'rainy'로 변경하면 'It is raining outside.'라는 메시지가 출력될 것입니다.

이처럼 ts-pattern을 활용하면 조건에 따라 다른 로직을 간결하게 구현할 수 있고, 패턴 매칭을 통해 예상치 못한 경우에 대한 처리까지 강력하게 다룰 수 있습니다.

조금 더 심화한 ts-pattern 예제

import { match, type } from 'ts-pattern';

interface Shape {
  kind: 'circle' | 'rectangle' | 'triangle';
  radius?: number;
  width?: number;
  height?: number;
}

const calculateArea = (shape: Shape): number => {
  return match<Shape, number>(shape)
    .with({ kind: 'circle', radius: type.number }, ({ radius }) => Math.PI * Math.pow(radius, 2))
    .with({ kind: 'rectangle', width: type.number, height: type.number }, ({ width, height }) => width * height)
    .with({ kind: 'triangle', width: type.number, height: type.number }, ({ width, height }) => (width * height) / 2)
    .otherwise(() => 0)
    .exhaustive();
};

const circle: Shape = { kind: 'circle', radius: 5 };
const rectangle: Shape = { kind: 'rectangle', width: 4, height: 6 };
const triangle: Shape = { kind: 'triangle', width: 3, height: 8 };
const invalidShape: Shape = { kind: 'square', side: 5 };

console.log(calculateArea(circle)); // 78.53981633974483
console.log(calculateArea(rectangle)); // 24
console.log(calculateArea(triangle)); // 12
console.log(calculateArea(invalidShape)); // 0

위의 예시 코드는 calculateArea 함수를 통해 다양한 도형의 넓이를 계산합니다. match 함수와 type 함수를 함께 사용하여 입력된 shape 객체의 타입을 체크하고, 해당하는 도형의 넓이를 계산합니다.

이 예시에서는 circle, rectangle, triangle 객체에 대해 각각 해당하는 도형의 넓이를 계산하고 출력합니다. 또한 invalidShape 객체는 어떤 도형에도 해당하지 않으므로 기본적으로 0을 반환합니다.

이처럼 ts-pattern을 활용하여 패턴 매칭과 함께 타입 체크를 수행하면, 코드의 안정성을 높일 수 있습니다. 패턴 매칭과 타입 체크를 조합하여 더욱 강력하고 안정적인 로직을 구현할 수 있습니다.

이제 함께 ts-pattern으로 강력한 디자인 시스템 컴포넌트를 만들어 보는 것을 시작해봅시다. 예시 코드에서는 버튼 컴포넌트를 구현합니다. 버튼의 크기와 스타일은 props로 전달되며, ts-pattern을 사용하여 각 속성에 맞는 스타일을 동적으로 적용합니다.

ts-pattern을 활용한 컴포넌트 구현

react 기반 라이브러리에서 하시는걸 추천드립니다. 먼저 필요한 라이브러리인 styled-componentsts-pattern 을 설치해줍니다. 어떤 스타일 라이브러리라도 좋으니 원하시는 라이브러리를 설치해서 적용해봐주셔도 좋습니다.

우리의 목표는 size와 variant를 지정하여 사용할 수 있는 button을 만들려고 합니다.

먼저 Button이라는 폴더 내 아래와 같이 파일을 만들어줍니다.

Button
├── index.tsx
├── styled.ts
└── types.ts

개발에 필요한 스타일요소를 미리 선언해보겠습니다. styled.ts 파일에서 아래와 같이 선언을 해줍니다. 실제 큰 서비스를 개발을 할때는 필요한 컬러나 스타일은 전역 theme으로 관리하시는것이 좋습니다. 예제인 점을 참고하여 따라와 주세요.

export const sizeStyles = {
  small: {
    padding: '5px 10px',
    fontSize: '12px',
  },
  normal: {
    padding: '10px 15px',
    fontSize: '14px',
  },
  large: {
    padding: '15px 20px',
    fontSize: '16px',
  },
};

export const variantStyles = {
  primary: {
    backgroundColor: '#f1f1f1',
    color: '#333',
  },
  secondary: {
    backgroundColor: '#e6e6e6',
    color: '#777',
  },
};

그리고 버튼 컴포넌트와 필요한 타입을 선언해보겠습니다.

// types.ts
export type Size = keyof typeof sizeStyles;
export type Variant = keyof typeof variantStyles;

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  size?: Size;
  variant?: Variant;
  children?: React.ReactNode;
}

각 버튼의 스타일에 필요한 요소들은 미리 선언한 스타일 상수로부터 타입을 추출하여 만듭니다. 그리고 해당 버튼은 button 태그에서 사용할 수 있는 props들도 적용할 수 있도록 ButtonHTMLAttributes<HTMLButtonElement> 를 상속해줍니다.

optional로 지정한 이유는 버튼을 size나 variant를 지정해주지 않아도 기본 값으로 사용할 수 있게 하기 위함입니다. 이 부분은 아래서 다시 한번 살펴보겠습니다.

//index.js

import { ButtonProps } from './types';

const Button = ({ size, variant, children, ...props }: ButtonProps) => {
  return (
    <button>
      {children}
    </button>
  );
};

export default Button;

버튼 컴포넌트를 만들어주고 필요한 props와 타입을 지정해 줍니다. FC를 사용하지 않고 children 타입을 직접 지정하는 이유는 이번 블로그의 주제와 연결되어 있지 않기 때문에 이 블로그를 읽어보시는걸 추천드립니다.

이제 필요한 스타일을 해보겠습니다.
먼저 button을 간단하게 스타일 해보도록 하겠습니다. 그리고 필요한 size와 variant를 props로 넘겨주도록 하겠습니다.

//styled.ts

export const StyledButton = styled.button<{ size?: Size; variant?: Variant }>`
  border: none;
  border-radius: 4px;
  cursor: pointer;
`;

ts-pattern을 적용한 스타일 분기

우리는 방금 선언해놓았던 sizeStylesvariantStyles 의 key에 따라 스타일을 버튼에 지정할 수 있어야합니다.

이부분에서 switch문을 통한 분기와 ts-pattern을 통한 분기를 비교해보며 각 장단점을 함께 알아보겠습니다.

먼저 size에 대한 분기처리를 해주고 알맞은 스타일을 반환하는 코드를 만들어보겠습니다.
match 함수는 매개변수로 전달된 값을 패턴 매칭하기 위해 사용됩니다.
with 메서드는 match 함수의 반환 객체에 연결되어, 특정 패턴에 대한 처리를 정의합니다. otherwise 메서드는 패턴 매칭에서 어떤 패턴에도 매칭되지 않을 경우 실행되는 함수를 정의하는 데 사용됩니다.

props를 optional로 정의해도 괜찮은 이유는 otherwise에서 버튼의 기본값을 반환해주고 있기 때문입니다.

// styled.ts
const sizePattern = (size?: Size) =>
  match(size)
    .with('small', (size) => sizeStyles[size])
    .with('normal', (size) => sizeStyles[size])
    .with('large', (size) => sizeStyles[size])
    .otherwise(() => sizeStyles.normal);

이와 유사하게 variantPattern 함수도 작성해보겠습니다

// styled.ts
const variantPattern = (variant?: Variant) =>
  match(variant)
    .with('primary', (variant) => variantStyles[variant])
    .with('secondary', (variant) => variantStyles[variant])
    .otherwise(() => variantStyles.primary);

실제 개발을 하실땐 size나 variant의 key를 상수로 분리하시면 실수를 방지할 수 있어 좋습니다.

그러면 이제 props를 받아 처리하는 함수를 만들었으니 StyledButton에 적용해보겠습니다.


//styled.ts
export const StyledButton = styled.button<{ size?: Size; variant?: Variant }>`
  border: none;
  border-radius: 4px;
  cursor: pointer;

  ${({ size }) => sizePattern(size)};
  ${({ variant }) => variantPattern(variant)};
`;

//index.tsx
const Button = ({ size, variant, children, ...props }: ButtonProps) => {
  return (
    <StyledButton size={size} variant={variant} {...props}>
      {children}
    </StyledButton>
  );
};

그럼 이제 간단하게 형태가 만들어졌습니다. 한번 잘 만들어졌는지 확인해볼까요? 이런 버튼이 만들어졌다면 성공입니다!

//someComponent.tsx
<Button size="large" variant="primary">
  Test Button
</Button>

흐음.. 하지만 클릭을 해보니 조금 심심하네요. 간단한 애니메이션과 카운트를 하는 기능을 저는 추가해주었습니다.

export const StyledButton = styled.button<{ size?: Size; variant?: Variant }>`
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color, transform 0.3s ease-in-out;

  ${({ size }) => sizePattern(size)};
  ${({ variant }) => variantPattern(variant)};

  &:hover {
    background-color: rgba(0, 0, 0, 0.2);
  }

  &:active {
    background-color: rgba(0, 0, 0, 0.2);
    transform: scale(0.9);
    transition: none; // active 이벤트가 발생한 순간에 변화가 적용
  }
`;
//someComponent.tsx
export default function someComponent() {
  const [count, setCount] = useState(0);

  return (
    <Container>
      <Button
        size="large"
        variant="primary"
        onClick={() => setCount((prev) => ++prev)}
      >
        countUp
      </Button>
      <Button
        size="small"
        variant="secondary"
        onClick={() => setCount((prev) => --prev)}
      >
        countDown
      </Button>

      <p>count number : {count} </p>
    </Container>
  );
}

저희가 원하였던 결과를 잘 만들 수 있었네요! 전체 코드는 이렇습니다. 잘 안되셨던 분은 참고하여 끝까지 완성해보세요.

//styled.ts
import styled, { css } from 'styled-components';
import { match } from 'ts-pattern';
import { Size, Variant } from './types';

export const sizeStyles = {
  small: {
    padding: '5px 10px',
    fontSize: '12px',
  },
  normal: {
    padding: '10px 15px',
    fontSize: '14px',
  },
  large: {
    padding: '15px 20px',
    fontSize: '16px',
  },
};

export const variantStyles = {
  primary: {
    backgroundColor: '#f1f1f1',
    color: '#333',
  },
  secondary: {
    backgroundColor: '#e6e6e6',
    color: '#777',
  },
};

const sizePattern = (size?: Size) =>
  match(size)
    .with('small', (size) => sizeStyles[size])
    .with('normal', (size) => sizeStyles[size])
    .with('large', (size) => sizeStyles[size])
    .otherwise(() => sizeStyles.normal);

const variantPattern = (variant?: Variant) =>
  match(variant)
    .with('primary', (variant) => variantStyles[variant])
    .with('secondary', (variant) => variantStyles[variant])
    .otherwise(() => variantStyles.primary);

export const StyledButton = styled.button<{ size?: Size; variant?: Variant }>`
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color, transform 0.3s ease-in-out;

  ${({ size }) => sizePattern(size)};
  ${({ variant }) => variantPattern(variant)};

  &:hover {
    background-color: rgba(0, 0, 0, 0.2);
  }

  &:active {
    background-color: rgba(0, 0, 0, 0.2);
    transform: scale(0.9);
    transition: none; // active 이벤트가 발생한 순간에 변화가 적용
  }
`;
//index.tsx
import { StyledButton } from './styled';
import { ButtonProps } from './types';

const Button = ({ size, variant, children, ...props }: ButtonProps) => {
  return (
    <StyledButton size={size} variant={variant} {...props}>
      {children}
    </StyledButton>
  );
};

export default Button;
//types.ts
import { sizeStyles, variantStyles } from './styled';

export type Size = keyof typeof sizeStyles;
export type Variant = keyof typeof variantStyles;

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  size?: Size;
  variant?: Variant;
  children?: React.ReactNode;
}

ts-pattern vs switch 무엇이 더 좋을까?

이번엔 switch문과 한번 코드를 비교해보겠습니다.

// switch 문으로 작성한 코드 
const getSizeStyles = (size?: Size): typeof sizeStyles[keyof typeof sizeStyles] => {
  switch (size) {
    case 'small':
      return sizeStyles.small;
    case 'normal':
      return sizeStyles.normal;
    case 'large':
      return sizeStyles.large;
    default:
      return sizeStyles.normal;
  }
};

// ts-pattern 으로 작성한 코드 
const sizePattern = (size?: Size) =>
  match(size)
    .with('small', (size) => sizeStyles[size])
    .with('normal', (size) => sizeStyles[size])
    .with('large', (size) => sizeStyles[size])
    .otherwise(() => sizeStyles.normal);

지금까지 작성해왔던 ts-pattern 의 패턴매칭과 switch문의 분기 어떤게 더 좋으신가요?
ts-patternswitch 문은 모두 선택사항이며, 개발자의 선호나 프로젝트의 요구사항에 따라 다를 수 있습니다. 각각의 장단점을 살펴보겠습니다.

ts-pattern 의 장단점

ts-pattern의 장점

  • 더 간결하고 읽기 쉬운 코드를 작성할 수 있습니다.
  • 타입 안정성을 제공하여 패턴 매칭의 누락된 경우를 컴파일 타임에 감지할 수 있습니다.
  • 복잡한 패턴 매칭을 처리하기에 유용합니다.

ts-pattern의 단점

  • 추가적인 라이브러리로 의존성을 추가해야 합니다.
  • 프로젝트에 이미 많은 의존성이 있다면 번들 사이즈가 커질 수 있습니다.
  • switch 문에 비해 학습 곡선이 좀 더 높을 수 있습니다.

switch의 장단점

switch 문의 장점

  • JavaScript와 TypeScript에서 기본적으로 지원하는 문법이므로 추가적인 의존성이 필요하지 않습니다.
  • 익숙한 문법으로 작성할 수 있고, 다수의 개발자가 익히고 있는 기능입니다.

switch 문의 단점:

  • 보다 반복적이고 번거로운 코드 작성이 필요할 수 있습니다.
  • 타입 안정성이 ts-pattern에 비해 떨어질 수 있습니다.
  • 복잡한 패턴 매칭을 처리하기에는 제한적일 수 있습니다.

따라서, 어떤 방식이 더 나은지는 개인적인 선호와 프로젝트의 요구사항에 따라 다를 수 있습니다. 간단한 패턴 매칭을 처리해야 한다면 switch 문을 사용하는 것도 좋은 선택일 수 있습니다. 하지만 복잡한 패턴을 다루거나 타입 안정성을 강화해야 한다면 ts-pattern을 고려해 볼 수 있습니다.

이야기를 마치며

ts-pattern은 디자인 시스템 컴포넌트를 구축하기 위한 강력한 도구로 활용될 수 있습니다. 타입 안정성과 선언적 구현을 통해 개발자는 코드의 신뢰성을 높이고 생산성을 향상시킬 수 있습니다. 예시 코드와 복잡한 예시를 통해 ts-pattern의 활용법을 살펴보았으며, 실전에서의 적용 가능성을 확인하였습니다. 디자인 시스템 개발에 관심이 있는 개발자들에게 ts-pattern은 강력한 도구가 될 수 있습니다.

지금까지 ts-pattern을 활용하여 디자인 시스템 컴포넌트를 만드는 방법을 함께 살펴보았습니다. 이제 여러분도 타입 안정성과 선언적 구현의 힘으로 더욱 효율적이고 견고한 컴포넌트를 개발할 수 있게 되었습니다.

자신만의 멋진 컴포넌트를 만들어보세요! 글을 잘 읽었다면 댓글 달아주시면 감사하겠습니다.👏

profile
Adventure, Challenge, Consistency

4개의 댓글

comment-user-thumbnail
2023년 5월 16일

오 하나 배워갑니당~~
선언적으로 사용할수있어서 흐름이 잘 읽히는것같아요!

1개의 답글
comment-user-thumbnail
2023년 5월 23일

좋은 글 감사합니다 :)

1개의 답글