클린 아키텍쳐 이슈 트래커 (2) 타입스크립트

2024. 3. 17. 16:38카테고리 없음

 

실제 코드를 예제로 사용하려다, 정리하고자 했던 개념들에 오히려 혼돈만 주는 것 같아 예제를 각색했습니다.

 

Type vs Interface

Typescript 문서나 Type과 Interface를 비교한 글을 봐도 개인 기호나 팀 컨벤션을 따를 뿐 명확한 기준점은 찾지 못했다. 하지만  Effective Typescript에서 내 기준으론 가장 정답에 가까운 기준을 보게 됐는데, 바로 declaration merging 이다.

 

interface IState {
  name: string;
  capital: string;
}

interface IState {
  population: number;
}

const wyoming: IState = {
  name: 'Wyoming',
  capital: 'Cheyenne',
  population: 500_000,
}

 

위와 같이 인터페이스는 declaration merging이 가능하다.

 

type Reptile = {
    genus: string
}

// You cannot add new variables in the same way
type Reptile = {
    breed?: string
}

 

하지만 type은 declaration merging이 불가능하다.

 

이 점을 기준으로 삼았을 때 어떤 API에 대한 타입을 선언해야 하면 OCP를 준수하기 위해 Interface를 사용하는게 좋고,  프로젝트 내부에서 사용되는 타입을 선언해야 한다면 type을 사용하는게 좋다. 개인적으론 이게 가장 명확한 기준이라고 생각한다. 

 

그렇다면 라이브러리가 아닌 프로젝트를 만들땐 모두 type을 사용해야 할까?

DIP를 지키기 위해 저수준 모듈이 아닌 고수준 모듈에 의존하도록 해야하는데, 고수준 모듈은 추상 클래스 또는 (계약을 위한) Interface를 사용한다.

 

물론 (Typescript의) type을 (계약을 위한) Interface로 사용 하는 것도 가능은 하다.  

type ClockType = {
  currentTime: Date;
};

class Clock implements ClockType {
  currentTime: Date = new Date();
  constructor(h: number, m: number) {}
}

 

하지만 (계약을 위한) Interface를 나타내기 위해서는 type보다 (Typescript의) interface를 사용하는게 의미론적으로, 코드 문맥상 더 적절하다고 생각했다.

또한 클래스 A에서 (계약을 위한) Interface B에 의존할 때, A입장에서 B는 하나의 라이브러리 또는 외부 시스템이라고 볼 수 있다고 생각했다. 따라서 이러한 경우엔 type 대신 (Typescript의) interface를 사용했다.

 

그렇다면 리액트 컴포넌트 Props 타입은?

그럼 리액트에서 props의 타입을 표현하는데 있어 type이 적절할까 interface가 적절할까? 물론 대부분의 글에서 이 역시 취향 차이라고 말하고 있다. 내 생각엔 컴포넌트의 props에 대한 타입도 (계약을 위한) Interface로 생각할 수 있다.

 

리액트에서 컴포넌트는 (거의 항상) composition 된다. 특정 컴포넌트를 사용하는 측에선 컴포넌트의 내부 구현에 관심이 없다. 특정 컴포넌트의 props에만 의존한다. 이런 점에서 컴포넌트의 props의 타입은 (계약을 위한)Interface라고 할 수 있지 않을까? 따라서 의미론적인 관점에서 컴포넌트의 props에 대한 타입도 (Typescript의) interface로 정의했다. 

 

그럼 처음으로 돌아가 interface가 팀 컨벤션일때, declaration merging을 방지할 순 없을까? 다행히 @typescript-eslint/no-redeclare 규칙으로 이를 방지할 수 있다. 

 

ComponentPropsWithoutRef

 

일반적인 html 태그를 래핑한 컴포넌트를 만들때 다음과 같은 이슈가 있었다.

interface ButtonProps {
  children: ReactNode;
}

const Button = ({children}:ButtonProps) => {
  return <button className="bg-red-700">{children}</button>
}

 

만약 위 상황에서 Button을 사용하는 컴포넌트에서 Button 컴포넌트에 onClick prop을 전달해주고 싶으면 어떻게 해야할까? 

 

1) Button 파일을 열어 ButtonProps interface에 onClick을 추가하고

2) Button 내 jsx에 <button className="bg-red-700" onClick={onClick}/> 이런식으로 prop을 추가해주어야 한다.

3) 최종적으로 Button 컴포넌트를 사용하는 컴포넌트 파일로 돌아와 onClick prop을 추가한다.

 

하지만 onClick과 같이 이미 일반적인 button 태그에 지원되는 prop이 필요할 때마다 이렇게 추가해야 하는건 너무 불편하다. 또 형상 관리에서 (나는 개인 프로젝트라 상관 없었지만..) conflict 발생을 굉장히 많이 발생시킬것이다.  이런 문제를 해결하기 위해 ComponentPropsWithoutRef를 사용했다.

 

interface ButtonProps extends ComponentPropsWithoutRef<"button"> {}

const Button({...rest}: ButtonProps) => {  
  return <button {...rest} />;
}

 

이런식으로 사용하면 일일히 native button 태그에 지원되는 prop을 매번 추가해줄 필요가 없다.

이와 관련하여 유사한 타입으로 JSX.IntrinsicElements["button"]과 ButtonHTMLAttributes<HTMLButtonElement>이 있지만 ComponentPropsWithoutRef가 전자를 훌륭하게 래핑한 타입이고, 후자는 너무 그 이름이 장황하여 ComponentPropsWithoutRef를 쓰는게 가장 현명한 선택인듯 하다.

 

 

ReactNode vs JSX.Element vs ReactElement

결론부터 말하자면 composition을 위해 children의 타입을 선언하기 위해 ReactNode를 사용했다. 이유는 좀 더 범용적으로 사용되길 원해서였다.

 

ReactElement나 JSX.element는 문자열이나 숫자와 같이 React가 렌더링할 수 있는 모든 것을 표현하는 데 사용할 수는 없다.

반면 ReactNode는 문자열, 숫자, null, undefined 등 렌더링 가능한 모든 타입을 가지고 있기 때문에 특히 공용 컴포넌트에서 children의 타입을 정의할 때는 ReactNode를 사용했다.

 

물론 타입은 가능한 좁게 사용하는게 좋기 때문에, 좀 더 구체적인 타입이 필요할 땐 ReactElement를 사용하면 된다.

 


 

먼저 ReactElement와 JSX.Element에 대해 살펴보자. 이 둘은 기능적으로 동일하다. JSX가 바벨을 통해 트랜스파일링 되면 React.createElement() 함수로 바뀌게 되고, 이 함수의 반환형이라고 할 수 있다.

 

    interface ReactElement<
        P = any,
        T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>,
    > {
        type: T;
        props: P;
        key: string | null;
    }
declare global {
  namespace JSX {
    interface Element extends React.ReactElement<any, any> {}
  }
}

 

하지만 children을 ReactElement 또는 JSX.Element를 사용하게 되면 다음과 같은 문제가 발생한다. 

 

const Component = ({
  children,
}: {
  children: JSX.Element;
}) => {
  return <div>{children}</div>;
};




<Component>hello world</Component> // 에러. 왜냐하면 JSX.Element는 오직 JSX만을 children으로 받기 때문이다.

 

하지만 ReactNode는 리액트에서 렌더링 가능한 모든 타입을 담고 있다.

 

declare namespace React {
  type ReactNode =
    | ReactElement
    | string
    | number
    | ReactFragment
    | ReactPortal
    | boolean
    | null
    | undefined;
}

 

공용 컴포넌트는 마치 라이브러리처럼 코드를 작성해야 한다. 사용하는 측에서 JSX를 children으로 줄지, string을 children으로 줄지 모른다. 따라서 children의 타입을 정의할때 ReactNode를 사용했다.

 

 

keyof, typeof 타입 연산자

 

다음과 같은 경우를 살펴보자.

interface ButtonProps extends ComponentPropsWithoutRef<'button'> {
  variant: "contained" | "outline" | "ghosts"
  size: "S" | "M" | "L"
}

const base =
  'flex gap-1 justify-center items-center flex-shrink-0 hover:opacity-80 active:opacity-[64] disabled:opacity-[32]';

const variants = {
  contained: 'text-accent-text bg-accent-background',
  outline: 'text-accent-text-weak border border-accent-border-weak',
  ghosts: 'text-neutral-text',
};

const sizes = {
  S: 'w-[120px] h-10 text-S font-bold rounded-regular ',
  M: 'w-40 h-10 text-M font-bold rounded-medium ',
  L: 'w-60 h-14 text-L font-bold rounded-large ',
};

function Button({
  variant,
  size,
  children,
  ...rest
}: ButtonProps) {
  return (
    <button
      {...rest}
      className={
        [base, variants[variant], sizes[size]].join(' ') +
      }
    >
      {children}
    </button>
  );
}

 

만약 이런 경우에 button의 size에 XS, XL가 추가되야 하면 어떤 수정사항이 일어날까?

1) sizes에 XS와 XL 키를 추가하고 그에 해당하는 스타일을 value에 넣는다.

2) ButtonProps interface의 size에 XS와 XL을 추가한다.

 

사소한 부분이긴 하지만, 하나의 수정사항에 대해 2곳의 코드를 수정해야 하고 둘의 정합성을 항상 사람이 관리해야 한다. 이를 방지하기 위해서 keyof 타입 연산자와 typeof 타입 연산자를 사용했다.

 

interface ButtonProps extends ComponentPropsWithoutRef<'button'> {
  variant: keyof typeof variants
  size: keyof typeof sizes
}

const base =
  'flex gap-1 justify-center items-center flex-shrink-0 hover:opacity-80 active:opacity-[64] disabled:opacity-[32]';

const variants = {
  contained: 'text-accent-text bg-accent-background',
  outline: 'text-accent-text-weak border border-accent-border-weak',
  ghosts: 'text-neutral-text',
};

const sizes = {
  S: 'w-[120px] h-10 text-S font-bold rounded-regular ',
  M: 'w-40 h-10 text-M font-bold rounded-medium ',
  L: 'w-60 h-14 text-L font-bold rounded-large ',
};

function Button({
  variant,
  size,
  children,
  ...rest
}: ButtonProps) {
  return (
    <button
      {...rest}
      className={
        [base, variants[variant], sizes[size]].join(' ') +
      }
    >
      {children}
    </button>
  );
}

 

Branding (Branded) 타입

다음과 같은 상황을 생각해보자. 

 

class A {
  name: string;
  constructor (name:string) {
    this.name = name;
  }
}

class B {
  name: string;
  other: string;

  constructor (name: string ,other: string) {
    this.name = name;
    this.other = other;
  }
}

const changeNameOfA = (arg: A["name"]) => {  };


changeNameOfA(new A("시저").name);
changeNameOfA(new B("caesar", "단순문자").name);
changeNameOfA(new B("caesar", "단순문자").other);

 

changeNameOfA 함수에서는 A 인스턴스의 name만 받도록 만들고 싶은 상황이다. 하지만 A의 name은 primitive string 타입이므로, B의 name 혹은 B의 other 역시도 타입을 만족하게 된다.

 

이러한 primitive 타입의 단점을 보완할 수 있는 방법이 없을까 고민하다 찾아보니 Branding 타입이란게 있었다.

type Brand<K, T> = K & { __brand: T };

class A {
  name: Brand<string, "A">;
  constructor (name:string) {
    this.name = name as Brand<string, "A">;
  }
}

class B {
  name: Brand<string, "B">;
  other: string;

  constructor (name: string, other: string) {
    this.name = name as Brand<string, "B">;
    this.other = other as Brand<string, "B">;
  }
}



const changeNameOfA = (arg: A["name"]) => {  };


changeNameOfA(new A("시저").name);
changeNameOfA(new B("caesar", "other").name); // Argument of type 'Brand<string, "B">' is not assignable to parameter of type 'Brand<string, "A">'.

 

이런 식으로 Brand 타입을 정의하여 기본 primitive 타입 대신 브랜딩 타입으로 사용했다. 다만 타입 안정성은 늘었지만 매번 타입 강제 변환을 해줘야 하는 점이 불편했다. 

 

돌아보니,

쭉 적고 읽어보니 되게 간단하고 기초적인 내용들이라는 생각이 든다. 하지만 이제 살을 붙일 뼈대는 적어도 잡았다는 생각이 든다. 0번 글에서도 말했지만, 타입스크립트를 강제로 사용해야 했던 2번째 프로젝트에서는 타입스크립트가 오히려 코드를 작성하는 시간만 작성하고 이런저런 에러도 많이 터져서 장애물이라고 생각했었다. 하지만 이젠 오류를 방지하기 위한 툴로서 내 코드의 안전성을 도와줄 도구로 사용할 수 있게 됐다고 느낀다. 아직도 모르는 개념은 수없이 많다. 하지만 적어도 그때그때 찾아가면서 타입 시스템을 활용할 준비는 된 것 같다.

 

그리고 학자형(?) 스타일인 내가 거의 처음으로 야생형 방식으로 타입스크립트를 공부해봤는데, 항상 야생형이 되고 싶었던 나에게 어떤 식으로 학습해야할지 좀 감을 얻을 수 있었다. 프로젝트에 필요한 여러 옵션들을 찾고, 그 옵션들의 장단을 비교해서 적용하는 식으로 학습하면 좀 더 가치있는, 생산성 있는 학습 방법이 되지 않을까 싶다.