타입스크립트와 함께 컴포넌트를 단계 별로 추상화해보자

    타입스크립트와 함께 컴포넌트를 단계 별로 추상화해보자


    최근 필자가 활동하고 있는 루비콘 팀에서는 멘토링 프로젝트를 함께 진행했던 멘티 분들과 함께 lubycon-ui-kit이라는 작은 프로젝트를 시작했다. 뭐 시작한지 얼마 안 되어서 아직 아무 것도 없지만, 한글을 기반으로 제작해 국내의 디자이너나 개발자가 사용하기 용이한 UI 라이브러리를 만들자는 의의를 가지고 조금씩 작업 중이다.

    이 프로젝트는 최근 많은 프로젝트들이 그러하듯 React와 TypeScript를 적당한 비율로 섞어주고, 여기에 Storybook을 한 움큼 뿌린 뒤 Rollup으로 마무리해서 만들어졌다.

    일단 컴포넌트가 디자인되고나면 개발자들과 디자이너들이 모여서 리뷰를 하고 이후 리뷰 통과가 된다면 바로 개발을 진행해서 개발 서버에 스토리북을 배포하고 최종 리뷰를 하는 짧은 이터레이션으로 진행이 되는데, 최근 UI Kit에 들어가는 컴포넌트의 인터페이스를 타이핑하면서 삽질했던 일을 조금 남겨볼까 한다.

    이제는 정말 추상화 뿐이야…

    필자는 개인적으로 이런 오픈 소스 라이브러리 프로젝트를 만들 때 가장 어려운 부분이 바로 설계라고 생각한다. 이 설계에는 모듈이나 컴포넌트의 인터페이스라던가 사용자에게 어떤 모듈을 노출시키고 어떤 모듈은 감출 것이냐하는 캡슐화 같은 것들이 모두 포함된다.

    게다가 이런 프로젝트는 프로젝트를 처음 시작할 때부터 혼자 만드는 것이 아니라 여러 명이 협업을 해야하기 때문에 설계에 대한 가치관을 팀 내에서 맞추기도 해야한다.

    솔직히 필자는 이 프로젝트를 들어가면서 설계에 대한 고민을 깊게 하지는 않았는데, 왜냐하면 필자는 회사에서 이미 TDS(Toss Design System)를 사용하여 서비스 개발을 하고 있기 때문에 그냥 막연하게 “이거랑 비슷한 느낌으로 만들면 되는 거 아닌가?”라고 생각했기 때문이다.

    tds 진짜 이 디자인 시스템 하나 덕분에 개발 속도가 엄청나게 빨라진다

    그래서 디자이너 분들과 함께 싱크하는 첫 미팅에서 TDS를 예로 들면서 신나게 설명하고 있었는데, 이런 대답이 돌아왔다.

    개발자님…? 디자인 시스템이랑 UI Kit은 조금 다른 개념이에요.

    이게 무슨 소리인고 하니, 사실 디자인 시스템이라는 것은 조직의 브랜드와 디자인에 대한 철학이 녹아있는 개념이라고 한다. 그러니까 필자가 회사에서 사용하고 있는 TDS라는 녀석은 토스라는 조직이 표방하는 디자인에 대한 여러 철학들이 녹아있는 녀석이기 때문에 다른 조직에서 이걸 그대로 사용하기에는 조금 어려울 수도 있다는 의미이다.

    반면에 UI Kit이라는 개념은 최대한 이러한 철학들과 격리되어 진짜로 UI 컴포넌트들만을 제공하는 것이라고 한다.

    즉, Bootstrap같은 녀석들은 어떤 디자인에 대한 철학을 가지고 있는 것이 아니라 그저 레고처럼 조립해서 사용할 수 있는 UI 컴포넌트들의 모음이라서 UI Kit인 것이고, 구글의 Material Design이나 에어비앤비의 Design Language System, 토스의 TDS처럼 어떤 조직에서 디자인에 대한 철학들을 만들고 그 철학을 UI Kit에 녹여내면 디자인 시스템이 되는 것이다. (UI Kit은 디자인 시스템을 포괄하는 상위 개념이라고 한다)

    사실 필자는 회사에서 매일 사용하는 TDS의 인터페이스에 굉장히 익숙해져있는 상태라서 “그냥 이거랑 비슷한 느낌으로 만들면 되지 않을까?”라고 막연하게 생각만 하고 있었는데, 이 얘기를 듣고나서 앞서 이야기한 디자인 시스템들을 다시 살펴보니 해당 디자인 시스템을 만든 조직의 디자인 철학이 상당히 많이 묻어 있다는 것을 알 수 있었다.

    결국 UI Kit은 사용자가 다양한 조직의 디자인 가이드나 코딩 컨벤션에 맞게 커스터마이징까지도 가능해야 할 정도로 높은 자유도를 보장할 수 있어야하기 때문에 디자인 시스템을 만들 때보다 추상화에 대한 고민을 더 깊게 해야한다는 것이다.

    사용자의 자유도를 보장하는 컴포넌트 만들기

    이 이야기를 듣고 나서 프론트엔드 개발자들이 제일 먼저 논의했던 내용은 “사용자에게 어디까지 자유도를 보장해줄 것인가?”였다.

    예를 들어 Button 같은 클리커블한 컴포넌트를 만든다고 생각해보자. HTML을 사용하여 이 엘리먼트가 클릭이 가능한 녀석이라는 것을 표현하기 위한 방법은 하나가 아니다.

    <button>난 버튼</button>
    
    <input type="button">나도 버튼</input>
    
    <a>버튼은 아닌데 버튼처럼 써도 돼</a>
    
    <div role="button">사실 나도 버튼이기는 해</div>

    물론 마지막의 role="button"처럼 버튼을 표현하는 경우에는 개발자가 별도로 엘리먼트에 tabindex를 사용하여 포커스가 가능하게 만들어주고, 포커싱된 상태에서 엔터나 스페이스를 눌렀을 때 버튼이 활성화 될 수 있도록 click이나 keydown 이벤트 핸들러도 정의해주어야 시맨틱한 버튼이라고 인정받을 수 있기 때문에 조금 이상하다고 생각이 될 수는 있지만, 중요한 건 저게 에러가 아니라는 것이다.

    어떤 조직의 철학에 종속되어있는 디자인 시스템이라면 그냥 ”Button 컴포넌트는 반드시 button으로만 렌더될 수 있다”는 규칙을 만들어 버린다고 해도 팀 내에 그 설계 철학에 대한 내용이 공유되어 있기만 하면 상관없을 수 있지만, UI Kit은 사용자가 기존에 HTML을 사용하던 방법을 최대한 훼손하지 않으면서 UI에 관련된 부분만을 도와주어야 하기 때문에 이런 고민이 필수적이다.

    결국 이런 고민을 거듭하던 끝에 최대한 HTML의 원래 사용법을 살려주자는 방향으로 의사 결정이 되었고, 어쩌다보니 필자가 이 프로젝트의 첫 번째 컴포넌트를 설계하게 되었다.

    Text 컴포넌트를 만들어 보자

    필자가 개발을 맡게 된 녀석은 바로 Text 컴포넌트이다. UI Kit을 정의할 때 디자이너 분들이 시스템의 뼈대가 되는 타이포그래피와 컬러 팔레트를 먼저 정의해야 한다고 해서 자연스럽게 타이포그래피를 표현할 수 있는 이 컴포넌트를 가장 먼저 개발하게 된 것이다.

    물론 타이포그래피와 관련된 스타일 데이터 자체는 SCSS나 CSS Variable로도 제공이 되고 JS 모듈로도 제공이 되지만, 타이포그래프를 사용할 때마다 일일히 모듈을 임포트하거나 클래스를 작성하게 하는 것은 상당히 귀찮으므로 그냥 컴포넌트를 하나 제공해주는 것이 깔끔하다.

    // 대략 이런 느낌으로 말이다
    <Text typography="h1">나는 머릿말 1이야</Text>
    <Text typography="content">나는 콘텐츠야</Text>

    그리고 HTML 상에서 이런 문자열을 렌더하려고 할 때 span, p, div 등 다양한 엘리먼트를 사용할 수 있기 때문에 Text 컴포넌트 또한 동일하게 여러 엘리먼트로 렌더될 수 있도록 추상적인 인터페이스를 가져야한다.

    즉, Text 컴포넌트는 타이포그래피라는 관심사만을 가져야 하고 이 관심사를 제외한 나머지 모든 기능은 HTML의 pspan 등을 사용할 때와 완전 동일하게 만들어줘야 한다는 것이다.

    조금씩 추상화하면서 설계하기

    앞서 이야기한 것과 같이 사용자의 자유도를 보장하기 위해 리액트 내에서 HTML 엘리먼트를 사용할 때의 동작을 훼손해서는 안 되므로, 이 컴포넌트의 프로퍼티는 기본적으로 렌더 대상이 되는 엘리먼트의 속성들을 그대로 재현해야한다.

    또한 사용자는 이 컴포넌트를 사용할 때 span이든 pdiv든 원하는 엘리먼트를 렌더할 수 있어야한다. 물론 사용자에게 span이나 p만을 사용하여 렌더하는 것을 강제할 수도 있겠지만 그런 룰을 강제하는 것 자체가 타이포그래피라는 관심사를 벗어나는 일이기 때문이다.

    그리고 필자는 타입스크립트를 사용하여 개발을 하고 있고, 이 라이브러리는 *.d.ts 파일을 사용하여 타입을 제공해야 하기 때문에 사용자가 렌더하기를 원하는 엘리먼트에 맞춰서 컴포넌트의 프로퍼티 타입도 유연하게 변경되어 IDE의 자동완성기능과 컴파일러의 정적 타입 분석을 제대로 이용할 수 있어야 한다.

    이 포스팅에서는 필자가 삽질했던 추상화 과정을 총 HTML 엘리먼트 흉내내기, 커스텀 프로퍼티 추가하기, 원하는 엘리먼트로 자유롭게 렌더할 수 있게 만들기의 3가지 단계로 나누어서 이야기해볼까 한다.

    HTML 엘리먼트 흉내내기

    가장 먼저 해볼 수 있는 설계는 바로 HTML 엘리먼트를 그대로 흉내내는 것이다. 사실 리액트는 원하는 엘리먼트의 속성 타입들을 편하게 사용할 수 있도록 HTMLAttributes라는 타입을 제공하고 있기 때문에 그냥 이렇게만 해도 HTML 엘리먼트를 똑같이 따라할 수는 있다.

    type Props = HTMLAttributes<'span'>;
    const Text = (props: Props) => { /* ... */ }
    html attrs 사실 이렇게만 해도 자동완성까지 이쁘게 해준다

    하지만 이 타입은 사용할 수가 없다. 왜냐하면 HTMLAttributes 타입은 말 그대로 리액트에서 제공하는 HTML에 대한 기본적인 속성들만 가지고 있는 녀석이라 refkey 등을 포함하고 있지 않기 때문이다.

    물론 리액트에서 HTML 요소를 타이핑할 때 사용하는 DetailedHTMLProps라는 타입을 사용하면 프로퍼티에 ref를 포함시킬 수는 있지만, 그렇다고 해서 실제로 이 컴포넌트가 ref를 통과시키지는 않는다. 이건 말 그대로 타입만 선언되는 것이다.

    이 내용은 리액트의 공식 문서의 Forwarding Refs 섹션에도 잘 나와있다.

    refs will not get passed through. That’s because ref is not a prop. Like key, it’s handled differently by React.

    Forwarding Refs - reactjs.org

    결국 진짜 리액트에서 사용되는 HTML 요소들을 똑같이 재현하려면 상위 컴포넌트에서 ref를 받아서 하위 컴포넌트로 넘겨줄 수 있는 기능까지 모두 구현해야 한다는 뜻이고, 이때 사용하는 것이 바로 forwardRef 함수이다.

    const Text = forwardRef(function Text(
      props: ComponentPropsWithoutRef<'span'>,
      ref: Ref<HTMLSpanElement>
    ) {
      return <span ref={ref}>{props.children}</span>
    });

    forwardRef 함수는 자신의 인자로 들어온 함수에게 propsref를 전달해주는데, 이때 전달된 레퍼런스를 의미하는 ref는 별도의 인자로 넘어오기 때문에 자연스럽게 propsref를 제외한 프로퍼티들 이라고 유추할 수 있다.

    다행히 리액트는 컴포넌트의 프로퍼티 중에서 ref만 제외한 나머지 프로퍼티들 쉽게 타이핑할 수 있도록 ComponentPropsWithoutRef라는 타입을 제공하고 있기 때문에 굳이 개발자가 Omit 유틸리티 타입을 사용하여 ref 키를 직접 제거하지 않아도 된다.

    커스텀 프로퍼티 추가하기

    자, 여기까지 오면 Text 컴포넌트는 span 엘리먼트와 동일한 기능을 가진 컴포넌트가 된다. 이렇게 forwardRef 함수를 사용하여 상위 컴포넌트가 받은 모든 것들을 하위 컴포넌트에게 그대로 넘겨주는 패턴은 고차 컴포넌트(Higher-order Component)를 만들 때도 사용되는 일반적인 기법이다. (사실 HOC 때문에 더 많이 쓴다)

    하지만 Text 컴포넌트는 span 엘리먼트의 속성 뿐 아니라 UI Kit에서 자체적으로 제공하려고 하는 타이포그래피와 관련된 커스텀 프로퍼티도 받을 수 있어야 하는데, 이대로라면 그냥 span 그 자체가 되어버린다. 그래서 방금 선언했던 span 엘리먼트의 속성 타입에 커스텀 프로퍼티에 대한 타입을 추가해주어야한다.

    어차피 타입스크립트는 &(intersection) 타입을 제공하고 있으니 이걸 사용하면 간단하게 구현할 수 있을 것 같다.

    type TextProps = {
      typography?: string;
    } & ComponentPropsWithoutRef<'span'>;
    
    const Text = forwardRef(function Text(
      { typography, ...props}: Props,
      ref: Ref<HTMLSpanElement>
    ) {
      //...
    })

    인터섹션 타입은 서로 다른 두 개의 타입을 합치는 타입이기 때문에 이렇게 하면 필자가 선언한 typography라는 프로퍼티와 span 엘리먼트의 속성들을 묶어서 TextProps라는 타입으로 선언할 수 있다.

    하지만 이 방법에도 문제는 존재하는데, 바로 ComponentPropsWithoutRef<'span'> 타입에서 제공되는 프로퍼티와 커스텀 프로퍼티가 동일한 키를 가지고 있는 경우에 대해서 대응이 불가능하다는 점이다.

    이게 정확히 어떤 상황을 이야기하는 것인지 한번 예시를 통해 알아보도록 하자. 만약 필자가 커스텀 프로퍼티에 customId라는 프로퍼티를 추가하게 되면 이 프로퍼티는 필자가 선언한 타입 대로 잘 추론이 된다.

    type TextProps = {
      customId?: number;
    } & ComponentPropsWithoutRef<'span'>;
    o 추론이 아주 잘 되는 만족스러운 모습

    그러나 id와 같이 ComponentPropsWithoutRef<'span'> 타입 내부에 이미 선언되어 있는 녀석을 커스텀 프로퍼티에 추가하면 인터섹션 타입으로 엮은 두 개의 타입이 충돌하면서 필자가 선언했던 id 프로퍼티가 쌩뚱맞게 undefined로 추론되는 것을 볼 수 있다.

    type TextProps = {
      id?: number;
    } & ComponentPropsWithoutRef<'span'>;
    x 혼란스러워 하는 컴파일러.jpg

    이렇게 컴파일러가 혼란스러워하는 이유는 타입스크립트에서 인터섹션 타입을 통해 타입을 합친다는 것이 상속과 오버라이딩 개념이 아니기 때문이다. 컴파일러 입장에서는 저런 식으로 타입을 선언하면 커스텀 프로퍼티의 idComponentPropsWithoutRef<'span'> 타입 내부의 id 중 어떤 것이 맞는 것인지 알 수가 없게 되어 버린다.

    type A = { id?: number };
    type B = { id?: string };
    
    // 둘 다 id를 가지고 있는데...?
    // 뭐가 맞는거임...?
    type Result = A & B;

    그리고 이 이슈는 타입이 아니라 인터페이스로 선언하고 extends를 사용하여 상속해도 동일하게 발생한다. 동일한 프로퍼티를 가진 녀석을 상속해서 오버라이딩하려고 하면 Interface '*' incorrectly extends... 어쩌고 하는 컴파일 에러가 발생한다.

    사실 우리가 자바스크립트의 Object.assign 같은 메소드를 사용하여 객체를 병합할 때는 첫 번째 인자의 객체가 가진 프로퍼티와 두 번째 객체가 가진 프로퍼티가 중복된다면 암묵적으로 첫 번째 객체의 프로퍼티를 두 번째 객체의 프로퍼티로 오버라이딩해버린다.

    Object.assign({ foo: 1 }, { foo: 2 });
    // 두 번째 객체의 프로퍼티로 암묵적 오버라이딩된다
    { foo: 2 }

    하지만 타입스크립트는 정적 타이핑 언어이고 명확한 선언을 지향하므로 이런 암묵적인 오버라이딩을 절대 허용하지 않는 것이다.

    그래서 이런 식으로 타입을 병합하려면 먼저 Omit 유틸리티 타입을 사용해서 오버라이딩하려고 하는 프로퍼티를 제거한 후 병합해야지 문제가 없다. 하지만 매번 Omit을 사용해서 타입을 병합하는 건 상당히 귀찮은 일이므로 아예 병합용 유틸 타입을 하나 선언해서 사용하는 것이 손목 건강과 정신 건강에도 좋다.

    type Combine<T, K> = T & Omit<K, keyof T>;

    Combine 타입은 제네릭으로 2개의 타입을 받은 후 K 타입에서 T 타입이 가진 프로퍼티와 중복되는 녀석들을 몽땅 제거한 후 인터섹션 타입으로 병합한다. 즉, T 타입에 선언된 키가 K 타입에도 존재한다면 오버라이딩하는 것이다.

    이제 Combine 타입을 사용해서 필자가 사용하고자 하는 커스텀 프로퍼티와 span 엘리먼트의 속성들을 병합하여 최종적인 Text 컴포넌트의 프로퍼티를 선언하면 된다.

    만약 개발자가 직접 선언한 커스텀 프로퍼티에 span 엘리먼트의 속성과 중복되는 값이 있더라도 Combine 타입이 자동으로 오버라이딩을 해줄 것이므로, 개발자는 별 다른 고민없이 평소처럼 프로퍼티를 선언할 수 있게 된다.

    type TextProps = Combine<{ id?: number; }, ComponentPropsWithoutRef<'span'>>;

    원하는 엘리먼트로 렌더할 수 있게 만들기

    이제 Text 컴포넌트는 자유롭게 커스텀 프로퍼티와 렌더 대상인 HTML 엘리먼트의 프로퍼티를 모두 포함하는 유연한 컴포넌트가 되었지만 아직 한 가지 추상화가 더 남아있다.

    현재까지 만든 Text 컴포넌트는 무조건 span 엘리먼트로만 렌더가 가능하다. 그러나 앞서 여러 번 이야기했듯이 HTML을 사용하면서 컨텐츠를 표현할 때 반드시 span만 사용하라는 법은 없기 때문에 이제 렌더 대상인 엘리먼트까지도 자유롭게 바꿀 수 있는 녀석으로 한 단계 더 추상화해야한다.

    <Text as="p" /> // p로 렌더되며, p 엘리먼트의 속성이 타이핑됨
    <Text as="a" href="..." target="..." /> // a로 렌더되며, a 엘리먼트의 속성이 타이핑됨

    일반적으로는 as라는 프로퍼티를 사용하여 렌더하고 싶은 엘리먼트의 태그 명을 넘기게 되면 해당 엘리먼트로 렌더해주는 기능을 제공하는데, 필자도 그냥 이걸 똑같이 구현하기로 했다. (사실 그냥 부트스트랩 배꼈다)

    앞선 추상화들에서는 렌더 대상이 되는 엘리먼트가 반드시 span이라는 보장이 있었기 때문에 그저 타입을 잘 합쳐주기만 하면 문제를 해결할 수 있었지만, 이제는 렌더 대상이 뭐가 될지 모르기 때문에 제네릭 타입으로 유연하게 받아와야 한다.

    다시 한번 아까 선언했던 Text 컴포넌트의 프로퍼티를 가져와서 살펴보도록 하자.

    type TextProps = Combine<{ id?: number; }, ComponentPropsWithoutRef<'span'>>;

    필자는 이 타입이 span 뿐 아니라 다양한 엘리먼트를 받을 수 있도록 만들고 싶기 때문에 ComponentPropsWithoutRef<'span'> 타입이 제네릭으로 받고 있는 span이라는 녀석을 ComponentPropsWithoutRef<T> 처럼 변수화해야한다.

    즉, as라는 프로퍼티로 HTML 엘리먼트의 이름을 받고 나면 이 이름과 매칭되는 엘리먼트 속성 타입을 찾아 아까 만들었던 Combine 타입으로 Text 컴포넌트의 프로퍼티에 합쳐주면 되는 것이다.

    제네릭과 타입 추론 부셔보기

    이제 어떻게 하면 필자가 생각했던 설계대로 타이핑을 할 수 있을 지 한번 생각해보도록 하자. 우선 가장 바깥 쪽에 위치해서 사용자로부터 직접 제네릭 타입 T를 받는 Text 컴포넌트의 프로퍼티부터 직접 타이핑해보면서 생각해보는 것이 좋을 것 같다.

    // Text 컴포넌트의 커스텀 프로퍼티 선언
    type TextBaseProps<T> = {
      typography?: string;
      as?: T;
    }
    
    // Props<T>는 ComponentPropsWithoutRef<T>에 이 값을 그대로 넘겨준다.
    // 그리고 커스텀 프로퍼티 내부의 as에도 T 타입을 바인딩해준다.
    type TextProps<T> =
      Combine<TextBaseProps<T>, ComponentPropsWithoutRef<T>>;
    .
    .
    .
    generic error 틀렸다 주인놈아...!

    행복회로를 돌려보면 아무 문제 없을 것 같았던 필자의 타이핑에 왜 이런 문제가 발생한 것일까? 어차피 전부 T라는 타입 변수를 사용하고 있고 그걸 그대로 넘겨준 것 뿐인데 말이다.

    왜냐하면 ComponentPropsWithoutRef타입이 제네릭으로 받을 수 있는 타입이 ElementType으로 정해져있기 때문이다. 필자가 작성한 타입은 타입 변수 T에 대한 어떠한 제약도 없기 때문에 Props<T>에서 받은 타입을 바로 ComponentPropsWithoutRef에게 넘기려고 하면 컴파일러가 에러를 뱉어내는 슬픈 상황이 발생하는 것이다.

    그래서 타입 변수 TComponentPropsWithoutRef 타입에게 안전하게 넘겨주고 싶다면, Text 함수가 제네릭으로 타입 변수를 받아오는 시점부터 전부 ElementType 타입만 사용할 수 있도록 제한을 만들어주어야 할 필요가 있다.

    제네릭 타입을 사용할 때 타입 변수로 들어올 수 있는 타입을 제한하려면 그냥 ”TElementType을 상속한 녀석이어야해!”라고 명시적으로 컴파일러에게 알려주면 된다.

    type TextBaseProps<T extends ElementType> = {
      typography?: string;
      as?: T;
    }
    
    type TextProps<T extends ElementType> =
      Combine<TextBaseProps<T>, ComponentPropsWithoutRef<T>>;
    function Text<T extends ElementType>(props: TextProps<T>) {}

    이렇게 작성한 타이핑을 보면 Text 컴포넌트가 제네릭 타입을 받고 있기 때문에 <Text<'span'> />처럼 이상한 문법으로 컴포넌트를 호출해야한다고 생각할 수도 있지만 사실은 그렇지 않다.

    왜냐하면 이 제네릭 타입 T는 추론이 가능한 녀석이기 때문이다. 위의 예시를 보면 Text 컴포넌트가 받고 있는 타입 변수 TTextProps<T>를 거쳐 TextBaseProps<T>로 넘어간 후 그대로 as에 바인딩되고 있다.

    결국 이 타입 변수들은 사실 모두 같은 녀석이라는 것이 보장된다는 것이다.


    1. Text<T>의 타입 변수 T
    2. TextProps<T>의 타입 변수 T
    3. TextBaseProps<T>의 타입 변수 T
    4. as 프로퍼티에 바인딩 된 타입 변수 T
    5. ComponentPropsWithoutRef<T>의 타입 변수 T

    그 말인 즉슨 이 중 한 군데만이라도 타입 변수 T가 어떤 타입인지 명확하게 알 수 있다면 나머지도 자연스럽게 추론이 가능하다는 이야기이다.

    이렇게 타입스크립트의 타입 추론 기능을 이용하면 Text 컴포넌트를 사용할 때 타입 변수 T의 기본 값으로 span을 할당하거나, 혹은 as 프로퍼티의 값으로 span과 같은 값이 들어온다면 나머지 T들도 자동으로 동일한 타입으로 채워지도록 만들 수 있다.

    function Text<T extends ElementType = 'span'>(props: Props<T>) {}
    
    <Text /> // -> T는 알아서 span으로 추론된다
    <Text as="p" /> // -> T는 p로 추론된다

    이제 대략적인 타입 추론의 흐름을 파악했으니 타이핑을 제대로 해보도록 하자.

    as 프로퍼티 타이핑을 추상화하기

    사실 as 프로퍼티처럼 원하는 엘리먼트로 렌더할 수 있는 기능을 가지는 컴포넌트는 Text만 있는 것이 아니다. 아직은 개발이 끝나지 않았지만, 페이지의 레이아웃을 담당하는 Grid 컴포넌트처럼 추상화된 관심사를 가진 컴포넌트들은 모두 이런 프로퍼티가 필요할 것이다.

    하지만 지금 필자가 TextBaseProps 타입에 as라는 프로퍼티를 직접 넣은 것처럼, 이런 컴포넌트의 프로퍼티를 선언할 때마다 개발자가 이 작업을 매번 수행하고 제네릭 타입의 추론 관계를 생각해야한다면 꽤나 불편할 것이다.

    그래서 다른 컴포넌트를 만들 때도 쉽게 as라는 프로퍼티를 추가할 수 있도록 이 부분의 타이핑을 최대한 추상화해놓을 필요가 있다. 이런 느낌으로 말이다.

    // 텍스트 컴포넌트의 프로퍼티
    type TextBaseProps = {
      typography?: string;
    }
    
    // T 타입을 추론할 수 있는 as 프로퍼티를 자동으로 포함하고
    // T 타입으로 HTML 엘리먼트 속성까지 타이핑 해주는 OverridableProps!
    type TextProps<T extends ElementType> = OverridableProps<T, TextBaseProps>;

    OverridableProps 타입은 특정 컴포넌트가 as 프로퍼티를 사용하여 HTML 엘리먼트 이름을 받고, 내부적으로 해당 엘리먼트의 속성 타입을 찾아 바인딩해주는 부분만 추상화한 것이다.

    이렇게 필요한 부분을 추상화해두면 필자가 아닌 다른 개발자는 ComponentPropsWithoutRef을 사용해야한다던가 Combine 타입을 사용할 때 타입 변수 TElementType으로 제한해야한다던가 하는 귀찮은 부분을 생각하지 않고도 as 프로퍼티를 쉽고 빠르게 추가할 수 있을 것이다. (…라고 행복회로를 돌려봅니다)

    이제 OverridableProps를 만들기 위해서 아까 만들었던 녀석들을 조금씩 고쳐보도록 하자.

    아까 필자가 만들었던 Combine<T, K> 유틸 타입은 제네릭으로 받을 수 있는 타입에 제한이 없지만, 우리가 합쳐야 하는 HTML 엘리먼트의 속성들을 의미하는 ComponentPropsWithoutRef<T>는 제네릭으로 받을 수 있는 타입이 ElementType으로 제한되어 있다.

    그래서 제일 먼저 이 타입들을 쉽게 일치시킬 수 있는 작업을 먼저 해주어야 한다.

    type CombineElementProps<T extends ElementType, K = unknown> = Combine<
      K,
      ComponentPropsWithoutRef<T>
    >;
    // ComponentPropsWithoutRef 타입의 존재를 감추자
    type TextProps<T extends ElementType = 'span'> =
      CombineElementProps<T, TextBaseProps>;

    CombineElementProps는 합치려고 하는 타입 두 가지 중 하나를 HTML 엘리먼트를 의미하도록 제한하고, 이 타입을 그대로 ComponentPropsWithoutRef에게 넘겨주기 때문에 타입이 깨지는 에러가 발생하지 않는다.

    그리고 K 타입 변수의 기본 타입을 unknown으로 정의함으로써 이 유틸 타입을 사용할 때는 반드시 K에 해당하는 타입인 컴포넌트의 프로퍼티를 입력하도록 강제하였다.

    같은 이유로 CombineElementProps로 제네릭 타입을 넘겨야하는 TextProps 역시 자신이 받는 타입을 ElementType으로 제한해야한다. 이후 이 CombineElementProps를 사용하여 as 프로퍼티를 추가해주는 OverridableProps 타입을 정의하면 된다.

    type OverridableProps<T extends ElementType, K = unknown> = {
      as?: T;
    } & CombineElementProps<T, K>;
    
    // 제네릭 타입 추론이 가능한 as 프로퍼티까지 추가된다. 
    type TextProps<T extends ElementType> = OverridableProps<T, TextBaseProps>;

    이제 OverridableProps 타입을 사용하여 상속하기를 원하는 ElementType과 자신이 선언한 컴포넌트의 프로퍼티만 넘기면 편하게 as 프로퍼티를 가진 타입을 만들어낼 수 있다.

    전체 코드를 한 번에 보자

    아무래도 코드와 설명이 오가는 포스팅에서는 전체 코드의 맥락을 파악하기가 쉽지 않으니, 맥락을 파악하기 쉽도록 전체 코드를 복붙한 내용을 올린다.

    export type Combine<T, K> = T & Omit<K, keyof T>;
    
    export type CombineElementProps<T extends ElementType, K = unknown> = Combine<
      K,
      ComponentPropsWithoutRef<T>
    >;
    
    type OverridableProps<T extends ElementType, K = unknown> = {
      as?: T;
    } & CombineElementProps<T, K>;
    type TextBaseProps = {
      typography?: string;
    }
    
    type TextProps<T extends ElementType> = OverridableProps<T, TextBaseProps>;
    function Text<T extends ElementType = 'span'>(
      { typography = 'content', as, ...props }: TextProps<T>,
      ref: Ref<any>
    ) {
      const target = as ?? 'span';
      const Component = target;
      
      return (
        <Component
          ref={ref}
          // 대충 타이포그래피 클래스 렌더하는 로직
          {...props}
        />
      );
    };
    
    export default forwardRef(Text) as typeof Text;

    마치며

    사실 처음 Text 컴포넌트를 개발하는 사람으로 필자가 당첨되었을때는 조금 우습게 보고 있었다. 뭐 컴포넌트 겨우 하나기도 하고 그렇게 대단한 기능이 있는 것도 아니니까 금방 할 거라고 생각했는데, 생각보다 타이핑이 빡세더라.

    물론 필자는 회사에서든 개인 프로젝트든 주로 타입스크립트를 사용하고 있기는 하지만, 이렇게 겹겹이 추론되어야하는 제네릭 타이핑을 직접 작성할 일은 몇 번 없었기 때문에 그냥 그때마다 구글신께 여쭤봐서 어떻게든 해결하고 잊어버리는 상황의 연속이었던 것 같다.

    이번에 삽질하면서 처음 알게 된 사실이지만, @types/react의 내용은 그렇게 읽기에 빡센 편이 아니기 때문에 심심할 때 한번 쭉 살펴보는 것도 좋은 것 같다.

    필자가 이번에 많이 사용했던 ComponentPropsWithoutRef 타입은 내부적으로 4~5단계의 타입 참조 체인을 가지고 있는데, 여기서 참조하고 있는 타입들만 쭉 흝어봐도 평소에 유용하게 사용할 수 있는 녀석들을 많이 만날 수 있어서 좋았다.

    ComponentPropsWithoutRef<T> > PropsWithoutRef<T> > ComponentProps<T> > JSX.IntrinsicElements[T] > …(몇 개 더 있음)

    막상 파일을 까보면 3천 줄 정도라서 전부 읽기에는 좀 부담스럽다고 느낄 수 있지만, 필자처럼 타입 참조 체인을 타면서 읽다보면 생각보다 핵심적인 타입의 개수는 그렇게 많지 않다. 게다가 HTML의 엘리먼트를 키로 가지고 있는 IntrinsicElements 같은 녀석들이 차지하는 라인 비중이 높기도 하다.

    typesafe actions 솔직히 typesafe-actions에 비하면 리액트의 타입 가독성은 상당히 혜자라고 볼 수 있다
    여긴 진짜 지옥 그 자체...

    이상으로 타입스크립트와 함께 컴포넌트를 단계 별로 추상화해보자 포스팅을 마친다.

    Evan Moon

    🐢 거북이처럼 살자

    개발을 잘하기 위해서가 아닌 개발을 즐기기 위해 노력하는 개발자입니다. 사소한 생각 정리부터 튜토리얼, 삽질기 정도를 주로 끄적이고 있습니다.