slash useFunnel 라이브러리 뜯어보기 (1) - Funnel 컴포넌트 분석

김유진·2023년 11월 14일
7

slash

목록 보기
1/3
post-thumbnail

최근 TIFY 개발을 하면서, 14개의 취향 설문 조사를 담당하여 개발해야 할 일이 있었다.
14개 종류의 취향 설문 조사 안에서도 6~7개의 질문이 있고, 그 질문은 객관식/주관식/다중선택 방식으로 종류가 나뉘어 어떻게 개발해야 하나 머리가 정말 아팠다.

머리를 끙끙 싸매고 있었는데, slash23 컨퍼런스
를 들으면서
아, useFunnel, 프로젝트에 적용해봐야겠다! 라는 생각이 들었고,
React에서 사용할 수 있도록 커스텀화해서 사용해보고자 하였다. 먼저 이 패턴을 적용하기 위해서는 어떤 동작 원리를 가지고 있는지 분석해 보아야 하는 법.... 꼼꼼히 살펴보자.

Funnel 컴포넌트 분석

전체 코드 보기 !

/** @tossdocs-ignore */
import { assert } from '@toss/assert';
import { Children, isValidElement, ReactElement, ReactNode, useEffect } from 'react';
import { NonEmptyArray } from './models';

export interface FunnelProps<Steps extends NonEmptyArray<string>> {
  steps: Steps;
  step: Steps[number];
  children: Array<ReactElement<StepProps<Steps>>> | ReactElement<StepProps<Steps>>;
}

export const Funnel = <Steps extends NonEmptyArray<string>>({ steps, step, children }: FunnelProps<Steps>) => {
  const validChildren = Children.toArray(children)
    .filter(isValidElement)
    .filter(i => steps.includes((i.props as Partial<StepProps<Steps>>).name ?? '')) as Array<
    ReactElement<StepProps<Steps>>
  >;

  const targetStep = validChildren.find(child => child.props.name === step);

  assert(targetStep != null, `${step} 스텝 컴포넌트를 찾지 못했습니다.`);

  return <>{targetStep}</>;
};

export interface StepProps<Steps extends NonEmptyArray<string>> {
  name: Steps[number];
  onEnter?: () => void;
  children: ReactNode;
}

export const Step = <Steps extends NonEmptyArray<string>>({ onEnter, children }: StepProps<Steps>) => {
  useEffect(() => {
    onEnter?.();
  }, [onEnter]);

  return <>{children}</>;
};

일단 Funnel의 Props가 어떻게 구성되어 있는지 확인해보자.

export interface FunnelProps<Steps extends NonEmptyArray<string>> {
  steps: Steps;
  step: Steps[number];
  children: Array<ReactElement<StepProps<Steps>>> | ReactElement<StepProps<Steps>>;
}

Steps라는 제네릭 타입이 NonEmptyArray<string>> 타입을 상속받고 있다. 그럼 NonEmptyArray<string> 은 어떤 타입인지 알아보자.

/** @tossdocs-ignore */
export type NonEmptyArray<T> = readonly [T, ...T[]];

제네릭 타입으로 받은 것에 대한 배열 타입이다. readonly를 지정하여 수정이 불가능하다. 지금까지 어떤 배열에 대한 타입을 작성할 때, 문자열 배열을 작성하고 as const로만 지정해서 해당 배열의 요소들만 가진 const 타입으로 작성하였는데, 이렇게 타입을 작성할수도 있겠구나!
그럼 steps의 타입에 대해 확인했으니, children 으로는 어떤 친구들이 와야 하는지 알아보자.

children: Array<ReactElement<StepProps<Steps>>> | ReactElement<StepProps<Steps>>;

오... 복잡한데? 일단 ReactElement 의 내부가 어떻게 구현되어 있는지 뜯어봐야 할 듯 하다.

interface ReactElement<P = any, T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>> {
  type: T;
  props: P;
  key: Key | null;
}

ReactElement의 pros를 지정해주는 제네릭 타입이군. 그럼 Funnel이라는 컴포넌트의 자식이 가져야 하는 props는, StepProps<Steps> 타입을 가져야 한다는 것이다.

export interface StepProps<Steps extends NonEmptyArray<string>> {
  name: Steps[number];
  onEnter?: () => void;
  children: ReactNode;
}

오키, 그럼 Funnel 컴포넌트의 자식들은 string 타입의 수정 불가능한 배열에 대한 요소, 즉 step을 name으로 가지고 있어야 하고, onEnter 라는 함수를 선택적으로 가질 수 있는 것이다. 대략적으로 예시코드를 작성해보자면,

<Funnel>
  	<FunnelChildren name='1번퍼널' onEnter={handleEnter}>
      <GetUserName/>
    </FunnelChildren>
</Funnel>

이런 느낌!

FunnelProps 정리

그럼, FunnelProps 를 알기 쉽게 풀어서 정리해보자. NonEmptyArraystring 타입을 제네릭으로 받으니,

  • steps : 수정이 불가능한 문자열 타입의 배열
  • step : steps 배열의 요소 (문자열)
  • children : ReactElement의 배열 (단,name 이라는 props를 가지고 있어야 함)

Funnel 컴포넌트 구현부

export const Funnel = <Steps extends NonEmptyArray<string>>({ steps, step, children }: FunnelProps<Steps>) => {
  const validChildren = Children.toArray(children)
    .filter(isValidElement)
    .filter(i => steps.includes((i.props as Partial<StepProps<Steps>>).name ?? '')) as Array<
    ReactElement<StepProps<Steps>>
  >;
  ...
}

반가운 얼굴의 등장이다. 이번에 데브코스에서 과제를 할 때 isVaildElement React API를 이용하여 퍼널과 비슷하게 구현해봤었는데 이 경험 덕에 이 부분 분석을 빠르게 진행할 수 있었던 것 같다.

먼저, Funnel 컴포넌트가 가지고 있는 자식 요소들을 배열로 만든 다음에, React Element인지 확인하는 것이다.

React Element인지 확인하는게 뭔데요...

import { isValidElement, createElement } from 'react';

// ✅ React elements
console.log(isValidElement(<p />)); // true
console.log(isValidElement(createElement('p'))); // true

// ❌ Not React elements
console.log(isValidElement(25)); // false
console.log(isValidElement('Hello')); // false
console.log(isValidElement({ age: 42 })); // false

JSX 태그를 이용하여 만들어진 것과 createElement를 통해 만들어진 객체만 React element로 간주하는 모습니다.
위에서 FunnelProps의 자식 요소들이 ReactElement이어야 하므로, 타입에 대한 검증을 꼼꼼히 한번 더 진행하는 모습이다. 라이브러리 코드니까 children 타입에 대해 꼼꼼히 검증하는 것이 아닐까.

다음으로 한번 더 filter 함수를 이용하여 검증을 해 주는데, 자식 컴포넌트의 props를 꺼내 와 name을 확인하고, steps 배열에 존재하는지 확인한다. 이 때 props 타입 처리하는 데 있어서 왜 이렇게 코드를 작성했을까 신기해서 그 이유를 생각해보았따.

나라면 .. 🫠

(i.props as StepProps<Steps>).name ?? ''

이렇게 작성했을 것 같다.
이렇게 작성하면 분명히, name이 정의되지 않은 컴포넌트가 들어갔을 때

어 ?? i.props에 name이라는 속성이 존재하지 않아!!!!!!

하면서 에러를 name is undefined~~ 라고 내뱉지 않았을까 싶다.
하지만,

(i.props as Partial<StepProps<Steps>>).name ?? '')

이렇게 Partial을 이용함으로써 nameundefined로 반환될 수 있음을 정의해준 것 같다. 👏
자, 이제 valideChildren을 다시 StepProps라는 타입의 props를 가지는 children의 배열로 타입 검증을 완료했다.

남은 로직을 살펴보자.

export const Funnel = <Steps extends NonEmptyArray<string>>({ steps, step, children }: FunnelProps<Steps>) => {
  ...
  const targetStep = validChildren.find(child => child.props.name === step);
  assert(targetStep != null, `${step} 스텝 컴포넌트를 찾지 못했습니다.`);
  return <>{targetStep}</>;
}

현재 Funnel의 props로 들어오는 step과 일치하는 name props를 가진 자식 요소를 찾아서 반환해준다. 만약, null을 반환한다면 해당 컴포넌트를 찾지 못했다는 에러 문구를 반환한다.

이렇게 Funnel 컴포넌트가 어떻게 작동하는지 그 원리를 살펴보았다!
다음 글에는 useFunnel 커스텀 훅이 어떻게 동작하는지 그 동작 원리를 깊이 탐구해보자.

1개의 댓글

comment-user-thumbnail
2023년 11월 14일

글 재미있게 봤습니다.

답글 달기