티스토리 뷰

동아리 스터디 중 DropdownList 컴포넌트를 가용성 있게 만들기로 했다.

컴포넌트의 가용성?


Typescript를 공부하면서 범용성 좋은 컴포넌트를 만들어보진 못한 것 같아, 새로운 Dropdown 컴포넌트를 구성하기 시작했다.

type SingleItem = string | number; //string이거나 number인 경우

interface ObjectItem {
  id: string | number;
  name: string | number;
  label: string | number;
  value: SingleItem;
}

각 사용 범위에 맞게 item과 value를 정해주고, Dropdown의 Props로 number, string, object 객체든 뭐든 받아낼 준비를 해야 했다.

하지만, Typescript라는 특성으로 인해서, 각 아이템의 명확한 타입을 지정해주지 않으면, 완성도 높은 컴포넌트를 작성할 수 없다는 것을 깨닫는 것은 별로 오래 걸리지 않았다.

 

export type DropdownItem = SingleItem | ObjectItem;

 

export type을 통해 다른 위치에서도 Dropdown에 넣을 수 있는 Item의 타입을 선언했다.

interface Props {
  items: SingleItem[] | ObjectItem[];
  selected: SingleItem | ObjectItem;
}

 

 

위와 같은 형식으로 코드를 진행함에 있어서 사실 큰 문제는 없었다.

 

대충 뚜뚜따따 해서 만든 디자인에 대강 내용을 때려넣고 useState를 남용하면서 만들면 누구나 쉽게 Dropdown리스트는 만들 수 있다.

const Dropdown = ({ items, selected }: Props) => {
  const [isOpen, setIsOpen] = useState('closed');
  const [label, setLabel] = useState('드롭다운 테스트');
  const [icon, setIcon] = useState('▼');

  const onClickDrop = useCallback(() => {
    if (isOpen === 'closed') {
      setIsOpen('open');
      setIcon('▲');
    } else {
      setIsOpen('closed');
      setIcon('▼');
    }
  }, []);

  const onClose = useCallback(() => {
    setIsOpen('closed');
    setIcon('▼');
  }, []);

  const ref = useOutsideClick(onClose);

  const onChangeLabel = (text: string | number) => {
    let temp = RefineItemType(text);
    setLabel(temp);
    console.log(text);
  };

  return (
    <div className={styles.container}>
      <ul className={styles.itemContainer} ref={ref}>
        <button type="button" className={styles.label} onClick={onClickDrop}>
          <text>{label}</text>
          <text>{icon}</text>
        </button>
        {items.map((item, index) => (
          <li key={`${item}_${index}`} className={cs(styles.itemWrapper, styles[isOpen])} onClick={() => onChangeLabel(items[index])}>
            {items[index]}
          </li>
        ))}
      </ul>

      <div>{label}</div>
    </div>
  );
};

export default memo(Dropdown);

사실 만드는 과정이 어려운 건 아니지만, 중요한 것은 ‘범용성’

범용성 있는 컴포넌트 형성을 위해 타입과 관련된 구글링을 진행하다가 제대로 사용할 줄은 몰랐던 ‘Generic’에 대해서 보게 되었다.

 


제네릭이 뭐야?

TypeScript Docs 에서도 잘 정의되고, 일관된 API, 재사용성이 높은 컴포넌트를 강조하고 있다.

소프트웨어 엔지니어링에서 매우 중요한 부분이다!

C#와 Java와 같은 기본 언어에서 재사용성을 높이는 도구는 ‘Generics’라고 설명하고 있다.

예제를 살펴보자

function identity(arg: number): number {
  return arg;
}

 

 

들어오는 인자를 그대로 반환하는 함수가 있다고 할 때, 우리는 인자에 타입을 지정하여, 특정 타입임을 정의해야 한다.

(혹은 변수 : any 와 같은 형식으로 기술할 수 있겠다)

행여나 우리가 any라는 타입으로 기술하였다고 한들 문제가 되진 않는다. 다만, 어떤 타입을 넘겨도 any 타입이 반환된다는 정보만 있어, 문제가 생기는 것이다.

 

function identity<Type>(arg: Type): Type {
  return arg;
}

 

대신 무엇이 반환되는지 확인할 수 있도록 Type이라는 변수를 추가해보자.

 

⇒ identity 라는 함수에 Type이라는 타입 변수를 추가하고, 이를 유저가 준 인수의 타입을 캡처한다. (numberstring 처럼)

 

 

즉, 받는 인자와 반환하는 인자를 같은 타입을 사용할 수 있도록 통일한다.

제네릭을 사용하면, 타입을 불문하고 정보 손실 없이 동작할 수 있도록 정확한 기능을 제공한다.

function identity<Type>(arg: Type): Type {
  return arg;
}

//identity 라는 함수에 <Type>이라는 타입 변수 추가
function identity<Type>(arg: Type): Type {
  return arg;
}

//arg는 Type 변수에 명시된 타입을 받아서 Type을 반환한다.

 

 

 


제네릭 타입 (Generic Types)

우리는 결국 범용성 있는 코드를 작성하기 위해 제네릭을 사용한다는 것을 배웠다.

그렇다면, 함수에 Props가 필요한 상황이라고 가정해보자.

interface 혹은 type을 지정하여 사용하게 될 것이다.

interface GenericIdentityFn {
  <Type>(arg: Type): Type;
}

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: GenericIdentityFn = identity;

 

 

위 처럼 작성된 예제가 있다고 가정하자.

interface GenericIdentityFn<Type> {
  (arg: Type): Type;
}

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: GenericIdentityFn<number> = identity;

아주 조금 수정이 이뤄졌다.

 

 

그럼에도, GenericIdentityFn 함수를 사용할 때, 타입인수를 명확하게 작성해주는 것은 타입의 제네릭을 설명하는데 큰 도움을 준다.

 

 


제네릭 제약조건 (Generic Constraints)

function loggingIdentity<Type>(arg: Type): Type {
  console.log(arg.length); //Property 'length' does not exist on type 'Type'.
  return arg;
}

위처럼 작성하면, length에는 오류가 생긴다.

 

“Type에 들어오는 변수 중에서는 length가 없는 애들도 있을 수 있어!”



무작정 타입을 설정해주는 것도 물론 좋은 방지책이 될 수는 있지만, 꾸준히 말했던 범용성 적인 측면에서 보았을 때,

모두 충족시켜주는 코드를 작성하는 것도 큰 도움이 될 수 있다.

interface Lengthwise {
  length: number;
}

function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
  console.log(arg.length); // Now we know it has a .length property, so no more error
  return arg;
}

 

 

여기서 말하는 lengthNumber야! 그러니까 당황하지 마렴 ^,^



interface를 통해 작성해주고, Typeextends를 통해 제약사항을 명시하게 되면, 문제는 사라진다.

loggingIdentity(3);
//Argument of type 'number' is not assignable to parameter of type 'Lengthwise'.

 

그렇게 되면, 세부적인 오류까지 잡아낼 수 있다는 장점이 있고, 들어올 수 없는 인자들까지 확인할 수 있다.

 

 


제네릭 제약조건에서 타입 매개변수 사용 (Using Type Parameters in Generic Constraints)

function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
  return obj[key];
}

let x = { a: 1, b: 2, c: 3, d: 4 };

getProperty(x, "a");
getProperty(x, "m");
//Argument of type '"m"' is not assignable to parameter of type '"a" | "b" | "c" | "d"'.

 

다른 타입의 매개변수로 제한되어 있는 상태의 매개변수도 선언할 수 있다!

 

 

이름이 있는 객체에서 인자를 가져오고 싶은 경우, 존재하지 않는 값을 가져오지 않기 위해 새로운 제약조건인 key값으로 제약을 둘 수 있다.



결론

범용성 높은 컴포넌트를 작성하기 위해서는 어떤 타입이던 편리하게 받아들이고, 사용할 수 있는 Generic이 있다는 것을 배우게 되었다.

 

지금으로서는 ‘아니 이렇게 중요하고 좋은 걸 이제 알았다고?’ 라고 생각이 들지만, 내가 직접 사용할 수 있는 정도로 체득하기에는 어느정도의 시간이 필요할 것 같다.

 

 

기존에는 Props 하나하나에 타입을 지정해주었다면, 더 다양하게 사용할 수 있는 Type 변수의 추가로 더 활용성이 높은 컴포넌트와 코드 작성이 가능하지 않을까 싶다.

 

 

참고 자료

https://www.typescriptlang.org/ko/docs/handbook/2/generics.html

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
글 보관함