Type-Safe하게 다형성 지원하기
F-Lab : 상위 1% 개발자들의 멘토링
안녕하세요. F-Lab에서 프론트엔드 엔지니어로 근무하고 있는 Tino 입니다.
저희는 중복되는 노력을 최소화해 빠르게 가치를 전달하고, 제품의 일관성을 이유로 ‘FDS’라는 이름의 디자인 시스템을 개발해 사용하고 있어요.
‘FDS’는 ‘F-Lab Design System’의 줄임말이에요
FDS를 고도화시키는 과정에서 다형성에 대해 고민하고 개발자의 실수를 최소화하기 위해 Type-Safe 하게 개발하려 노력한 경험을 공유하고자 해요.
기존 FDS의 문제점
FDS의 버튼의 생김새를 띄면서 Link처럼 동작하는 요구사항이 있을 때 기존에는 아래와 같은 방법으로 코드를 풀어냈어야 했어요.
<code class="language-typescript language-tsx">import { Button } from '@flab/fds/components'; // 1번 방법 <Link href="/foo"> <Button>go foo</Button> </Link> // 2번 방법 const router = useRouter(); <Button onClick={() => router.push('/foo')}>go foo</Button>
Link로 감싸는 1번 방법의 경우 불필요한 Depth가 생기고, 의미론적(semantic)이지 않은 2번 방법은 검색 엔진 최적화에 불리한 방법이라 생각되었어요.
이를 해소하기 위해 저희는 Polymorphism, 한글로 다형성을 지원하는 컴포넌트들로 FDS를 구성하기로 했어요.
다형성이란?
컴퓨터 과학에서의 다형성은 위키백과에서 다음과 같이 설명되고 있어요.
프로그램 언어의 다형성(多形性, polymorphism; 폴리모피즘)은 그 프로그래밍 언어의 자료형 체계의 성질을 나타내는 것으로, 프로그램 언어의 각 요소들(상수, 변수, 식, 오브젝트, 함수, 메소드 등)이 다양한 자료형(type)에 속하는 것이 허가되는 성질을 가리킨다.
출처 : 위키피디아
객체지향에서의 다형성은 분류될 방법이 많지만, 본문의 주제와는 맞지 않다고 생각해 깊게 다루지 않아요.
본문에서 다루는 다형성은 MUI와 Mantine 컴포넌트들의 component API, Chakra의 as API를 뜻해요.
<code class="language-typescript language-tsx">import { Button } from '@mantine/core'; ... <Button component="a" href="/foo">go foo</Button>
생김새는 Button의 생김새를 띄지만 root에 렌더링되는 HTML 요소 혹은 컴포넌트를 다르게 하는 것을 의미해요.
이를 통해 위 FDS의 문제점을 아래와 같은 코드 조각으로 해결하길 바랐어요.
<code class="language-typescript language-tsx">import { Button } from '@flab/fds/components'; <Button as={Link} href="/foo">go foo</Button>
가장 간단한 다형성 지원
가장 간단히 다형성을 지원하는 방법은 React.ElementType
의 props를 추가하는 방법이에요.
간단하게 코드 조각으로 만들어보면 다음과 같아요.
<code class="language-typescript language-tsx">interface Props { // ... as?: React.ElementType; } const Button: FC<Props> = ({ as, children }) => { const Component = as || 'button'; return <Component>{children}</Component>; } // 사용하는 곳에서 <Button as="a">foo</Button>
이해를 위해 forwardRef, 인터페이스 확장 등은 걷어낸 모습이에요.
대문자로 시작하는 변수 Component
를 만든 이유는 동적으로 JSX의 타입을 정해주기 위함이에요.
위 코드 조각처럼 ElementType의 Prop만을 추가해서 다형성을 지원할 수 있었겠지만, 저희는 아래의 문제점을 이유로 조금 더 고도화하길 바랐어요.
타입 문제점
가장 크게 느낀 문제는 Type-Safe 하지 않은 지원이라는 것이에요.
<code class="language-typescript language-tsx">interface Props extends ButtonHTMLAttributes<HTMLButtonElement>{ as?: ElementType; // React. 생략 } // 사용하는 곳에서 <Button as="a" href="???">foo</Button>
기존 Button 컴포넌트는 HTML button 태그의 Attributes를 확장해 사용하고 있었는데, as를 통해 HTML 태그가 바뀌게 되었을 시 제대로 된 타입 추론이 되지 않은 문제가 있었어요.
저희는 as에 주입한 HTML 태그 혹은 컴포넌트의 Props가 추론되고, 자동 완성되어 개발자의 생산성을 높이고 휴먼 에러를 사전에 방지하고 싶었어요.
Essential grammar
아래의 해결 방법을 충분히 이해하기 위해서는 몇 가지 타입스크립트의 문법에 대한 이해가 필요해요.
- Generics
- Generic extends
- Generic parameter default
- Intersection type
- Omit utility type
나열된 문법을 이해하고 계시다면, 아래 ‘해결 방법’ 문단으로 넘어가셔도 좋아요.
Generics
<code class="language-plaintext">잘 정의되고 일관된 API뿐만 아닌 재사용 가능한 컴포넌트를 구축하는 것도 소프트웨어 엔지니어링에서의 주요한 부분입니다. 현재의 데이터와 미래의 데이터 모두를 다룰 수 있는 컴포넌트는 거대한 소프트웨어 시스템을 구성하는 데 있어 가장 유연한 능력을 제공할 것입니다. C#과 Java 같은 언어에서, 재사용 가능한 컴포넌트를 생성하는 도구상자의 주요 도구 중 하나는 제네릭입니다, 즉, 단일 타입이 아닌 다양한 타입에서 작동하는 컴포넌트를 작성할 수 있습니다. 사용자는 제네릭을 통해 여러 타입의 컴포넌트나 자신만의 타입을 사용할 수 있습니다.
출처 : TypeScript Documentation Generics
자세한 설명은 위 타입스크립트 문서에서 확인하실 수 있으며,
간단한 설명이라면 타입의 매개변수
정도로 이해하실 수 있어요.
<code class="language-typescript">function identity<Type>(arg: Type): Type { return arg; } let output = identity("myString"); // 출력 타입은 'string'입니다.
위 identity 함수처럼 함수를 사용하는 곳에서 넘겨준 인수의 타입을 캡처하고 사용할 수 있는 것을 일반적으로 제네릭 함수라고 표현합니다.
Generic extends
<code class="language-typescript">function identity<Type extends string>(arg: Type): Type { return arg; }
Generic의 extends는 인터페이스의 extends처럼 사전적인 ‘확장하다’의 의미를 갖지 않아요.
이는 타입을 제한하는 역할을 하며, 집합으로써의 타입을 생각하면 더욱 이해하기 편해요.
<code class="language-plaintext">function identity<Type extends string>(arg: Type): Type { return arg; } type Name = 'fitz' | 'tino' | 'eden' | 'jito'; const name: Name = 'tino'; identity(name); // string이 더욱 큰 집합이기에 문제가 없어요.
Generic parameter default
Optional 매개변수에 default value를 사용할 수 있듯, generic에도 default type을 선언할 수 있어요.
<code class="language-typescript">type Person<C> = { name: string; onClick: C; } const person: Person<VoidFunction> = { name: 'tino', onClick: () => console.log('hello'), }
위 예시처럼 일반적인 Generic type이 존재해 사용하는 곳에서 주입을 해주어야 한다면,
아래처럼 default type을 이용해 주입하지 않았을 때의 기본 타입을 명시해 줄 수 있어요.
<code class="language-typescript">type PersonWithDefault<C = VoidFunction> = { name: string; onClick: C; } const personWithDefault: PersonWithDefault = { name: 'tino', onClick: () => console.log('hello'), }
Intersection type
‘교집합’을 뜻하는 intersection type은 literal type에서 양쪽 모두 할당할 수 있는 타입만 남길 수 있어요.
<code class="language-typescript">type Designer = 'John | 'Tobi'; type Developer = 'John' | 'Tino'; type AllRounder = Designer & Developer; // 'John'
객체의 타입의 교집합은 모든 속성이 들어간 타입을 만들 수 있어요.
<code class="language-typescript">interface Person { name: string; age: number; } interface Develop { part: string; } type Developer = Person & Develop; const developer: Developer = { name: 'tino', age: 1, part: 'frontend' }
참고하면 좋은 링크 :
Omit utility type
Omit<Type, Keys>
첫 번째로 전달받은 Type에서 두 번째로 전달받은 keys를 제거한 타입을 생성하는 유틸리티 타입이에요.
<code class="language-typescript">interface Person { name: string; age: number; height: number; } type ShyPerson = Omit<Person, 'age' | 'height'>; const tino: ShyPerson = { name: 'tino', };
참고하면 좋은 링크 :
해결 방법
Props에 대한 타입을 만들기 전에, 필요한 것들은 무엇인지 정의하는 게 이해를 도울 수 있을 거 같아요.
가장 먼저 각 컴포넌트에서 제공되어야 하는 고유한 Props들이 있어요.
가령 예를 들자면 Button 컴포넌트의 size 같은 요소가 있을 거 같아요.
<code class="language-typescript language-tsx">type Size = 'small' | 'medium' | 'large' | 'x-large' | 'xx-large'; interface Props { size?: Size; }
두 번째로는 위에서 설명한 ElementType의 Props가 필요해요.
<code class="language-typescript language-tsx">interface Props { size?: Size; as?: ElementType; }
물론 이렇게 ElementType을 모든 컴포넌트에서 작성할 수 있겠지만, 반복되는 타입 정의를 아래와 같은 제네릭 타입을 만들어 해소했어요.
<code class="language-typescript language-tsx">type AsProp<C extends ElementType> = { as?: C; };
그리고 전달받은 ElementType의 모든 Props가 필요하고, 이는 다음과 같이 나타낼 수 있어요.
<code class="language-typescript language-tsx">type ElementTypeProps<C extends ElementType> = React.ComponentProps<C>; // 이하 React. 생략
이렇게
- 컴포넌트의 고유한 Props
- ElementType
- ElementType의 Props
세 가지를 교차 타입(Intersection Type)으로 나타내면 다음과 같아요.
<code class="language-typescript language-tsx">type AsProp<C extends ElementType> = { as?: C; }; type PolymorphicComponentProps<C extends ElementType, Props = object> = Props & AsProps<C> & ComponentProps<C>; // 각 컴포넌트의 고유한 Props는 동일한 이름으로 제네릭을 통해 주입받아요
하지만 ComponentProps<C>
안에 Props & AsProps<C>
에 존재하는 동일한 이름이면서, 다른 타입인 속성이 존재하면 never
타입으로 추론될 수 있어요.
이를 위해 ComponentProps에서 Props & AsProps<C>에 존재하는 속성을 제거하는 단계를 거쳐야 하는데, 이는 다음과 같이 작성할 수 있었어요.
<code class="language-typescript language-tsx">type AsProp<C extends ElementType> = { as?: C; }; type KeyWithAs<C extends ElementType, Props> = keyof (AsProp<C> & Props); type PolymorphicComponentProps<C extends ElementType, Props = object> = (Props & AsProps<C>) & Omit<ComponentProps<C>, KeyWithAs<C, Props>>
여기까지 작성한 PolymorphicComponentProps 타입을 이용해 컴포넌트의 타입을 다음과 같이 정의할 수 있어요.
<code class="language-typescript language-tsx">type Props<C extends ElementType> = PolymorphicComponentProps<C, { size?: Size; } > type ButtonType = <C extends ElementType = 'button'>(props: Props<C>) => ReactElement; const Button: ButtonType = ({ as, size, children, ...rest }) => { const Component = as || 'button'; return <Component size={size} {...rest}>{children}</Component>; }
ButtonType
이라는 제네릭 함수를 선언해 사용하는 인수에 따라 타입을 캡처해 이에 맞는 Props를 추론할 수 있게 됐어요.
참고 : https://www.typescriptlang.org/ko/docs/handbook/2/generics.html
forwardRef
사실 FDS의 컴포넌트들은 ref에 대한 포워딩도 제공하고 있어서 조금 더 수정이 필요했어요.
가장 먼저 수정할 것은 PolymorphicComponentProps
타입인데요.
기존에 사용하던 React.ComponentProps
에서 React.ComponentPropsWithoutRef
로 변경해주면 돼요.
<code class="language-typescript language-tsx">type PolymorphicComponentProps<C extends ElementType, Props = object> = (Props & AsProp<C>) & Omit<ComponentPropsWithoutRef<C>, KeyWithAs<C, Props>>;
이후 ref에 대한 타입도 캡처되는 ElementType에 따라 변경해 주어야 하는데요.
이는 React.ComponentPropsWithRef
타입을 이용해 간단히 꺼내어 쓸 수 있었어요.
<code class="language-typescript language-tsx">type PolymorphicRef<C extends ElementType> = ComponentPropsWithRef<C>['ref'];
컴포넌트를 선언하는 곳에서 타입에 대한 보일러 플레이트를 줄이기 위해 이 두 타입을 조합한 타입을 선언했어요.
<code class="language-typescript language-tsx">type PolymorphicComponentPropsWithRef<C extends ElementType, Props = object> = Props & { ref?: PolymorphicRef<C> };
필요한 준비는 모두 끝났어요.
이를 위 Button 컴포넌트에 적용하고 재사용되는 타입들을 분리한 최종 모습은 아래와 같아요.
<code class="language-typescript language-tsx">// polymorphic.d.ts import { type ComponentPropsWithoutRef, type ComponentPropsWithRef, type ElementType } from 'react'; type AsProp<C extends ElementType> = { as?: C; }; type KeyWithAs<C extends ElementType, Props> = keyof (AsProp<C> & Props); type PolymorphicRef<C extends ElementType> = ComponentPropsWithRef<C>['ref']; type PolymorphicComponentProps<C extends ElementType, Props = object> = (Props & AsProp<C>) & Omit<ComponentPropsWithoutRef<C>, KeyWithAs<C, Props>>; type PolymorphicComponentPropsWithRef<C extends ElementType, Props = object> = Props & { ref?: PolymorphicRef<C> };
<code class="language-typescript">// Button.tsx type Props<C extends ElementType> = PolymorphicComponentProps< C, { size?: Size; } >; type ButtonType = <C extends ElementType = 'button'>(props: PolymorphicComponentPropsWithRef<C, Props<C>>) => ReactElement | null; export const Button: ButtonType = forwardRef(function Button<C extends ElementType = 'button'>( { children, size = 'small', as, ...rest }: Props<C>, ref?: PolymorphicRef<C>, ) { const Component = as || 'button'; return ( <Component ref={ref} size={size} {...rest}> {children} </Component> ); });
이렇게 동적인 ref를 포워딩하고 주입한 ElementType에 맞게 Props가 추론되는, 즉 다형성을 지원하는 컴포넌트를 만들 수 있었어요.
With Styled
위에서는 다루지 않았지만, F-Lab에서는 스타일링 도구로 emotion/styled를 사용해요.
저희와 같은 도구를 사용하시는 분들은 해당 도구가 as prop을 지원하기 때문에 간단하게 아래와 같이 바꿔볼 수 있어요.
<code class="language-typescript language-tsx">... export const Button: ButtonType = forwardRef(function Button<C extends ElementType = 'button'>( { children, size = 'small', as, ...rest }: Props<C>, ref?: PolymorphicRef<C>, ) { return ( <StyledButton as={as} ref={ref} size={size} {...rest}> {children} </Component> ); }); const StyledButton = styled.button` color: blue; `;
마치며
디자인 시스템을 도입하는 회사들도 많아지고, 이를 통해 번들링, 모노레포, 패키지 배포 등 다양한 분야를 경험할 수 있는 점 때문에 많은 분들이 학습을 목표로 디자인 시스템을 만들고 계시는 것을 보았는데요.
이런 경험들에 다형성과 타입에 대한 고민들을 같이 녹인다면 더욱 좋은 경험이 될 수 있을 거라 믿어요.
부디 본문에서 다뤘던 경험이 여러분들의 경험에 도움이 되길 바라며 글을 마칩니다. 감사합니다.
글쓴이에 대해 알고 싶다면?
이 컨텐츠는 F-Lab의 고유 자산으로 상업적인 목적의 복사 및 배포를 금합니다.