본문 바로가기

프로젝트 개발일지/toss-slash 라이브러리

[toss-slash 라이브러리] Funnel 컴포넌트와 테스트 코드

사실, TIFY 프로젝트에서 프론트 리드님이 toss-slash 라이브러리 참고하면서 우리 프로젝트에 맞게 퍼널 컴포넌트 커스텀하신 걸 보고 나도 toss-slash 라이브러리에 대해서 깊게 파보고 싶다는 생각이 들었다.

그래서 toss-slash 라이브러리에 들어가서 코드를 슬쩍 봤는데 너무 고퀄의 코드여서 놀랐다.

https://github.com/toss/slash

 

진짜 놀랐던 점은 타입 코드였다.

타입을 너무 잘 써서 할 말을 잃었다.

그래서 나도 여러 코드들을 따라 구현해보면서 타입을 잘 쓰는 방법을 익히고 싶어서 시작하게 되었다.

 

그러다가, 각 컴포넌트/유틸 함수들뿐만 아니라 테스트 코드도 있다는 것을 알게 되었는데, 사실 나는 테스트 코드를 짜보고 싶다고만 생각했지, 제대로 배워보지는 못했다.

그래서 이참에 테스트 코드도 직접 작성해보면서 익혀보는 것도 정말 좋은 기회가 될 것 같아 jest로 가볍게 테스트하는 정도로 코드를 짜보고 있다.

 

처음으로 어떤 걸 구현해볼까 고민하다가 나도 퍼널 컴포넌트에 대해서 공부해봐야겠다는 생각이 들었다.

아래부터는 퍼널 컴포넌트 코드에 대한 자세한 설명이다.

 

퍼널

아래 영상은 토스의 고수 프론트엔드 개발자의 퍼널 컴포넌트 설명 영상이다.

https://www.youtube.com/watch?v=NwLWX2RNVcw

 

퍼널이 뭔지 궁금할 것 같아 간단히 설명해 보자면, 온보딩 프로세스 같이 여러 페이지들을 순서대로 보여줘야 하는 상황에서 퍼널을 사용하게 된다.

예를 들어, 회원 정보 입력 -> 마케팅 수신 동의 -> 가입 축하 페이지 등 이런 경우 말이다.

사실 이 프로세스를 단순히 내비게이션과 전역 상태를 통해서 구현할 수는 있기는 하다.

하지만, 이렇게 관리하게 되면 설계를 한 눈에 파악할 수가 없다.

여기서 퍼널 컴포넌트를 사용하게 되면 구조를 한 눈에 파악할 수 있고, 응집도도 높일 수 있다.

 

간단하게 퍼널 컴포넌트가 어떻게 사용하는지 보여주자면 다음과 같다.

스텝 컴포넌트는 위에서 언급한 회원 정보 입력, 마케팅 수신 동의, 가입 축하 페이지 이렇게 하나의 페이지로 생각하면 된다.

이 스텝 컴포넌트들을 퍼널 컴포넌트로 감싸서 사용하면 된다.

퍼널 컴포넌트에는 전체 스텝명의 배열, 현재 스텝명을 전달해 주면 우리가 원하는 대로 페이지를 관리할 수 있게 된다.

const steps: NonEmptyArray<string> = ["가입방식", "주민번호", "집주소", "가입성공"];
const [step, setStep] = useState<"가입방식"|"주민번호"|"집주소"|"가입성공">("가입방식")

<Funnel steps={steps} step={"가입방식"}>
  <Step name={"가입방식"}>가입방식</Step> 
  <Step name={"주민번호"}>주민번호</Step> 
  <Step name={"집주소"}>집주소</Step> 
  <Step name={"가입성공"}>가입성공</Step> 
</Funnel>

 

내부 구현에 대해서는 이제 알아보도록 하자.

 

퍼널 컴포넌트, 스텝 컴포넌트

이제 퍼널 컴포넌트와 스텝 컴포넌트를 구현해볼 것인데, 구현에 앞서 아주 간단한 타입 코드 하나만 알아보자.

아래 코드는 이름 그대로 빈 배열인지를 판단하는 타입이다.

export type NonEmptyArray<T> = readonly [T, ...T[]];

 

이때까지는 자꾸 빈 배열 관련해서 ....map is not function과 같은 에러가 많이 났었는데, 이 타입 코드를 사용하면 잘 해결되지 않을까 싶다.

 

이제 진짜로 스텝 컴포넌트부터 살펴보자.

export type 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}</>;
};

스텝명, 해당 스텝 컴포넌트가 처음에 렌더링 될 때 실행되었으면 좋겠는 함수, 그리고 자식 요소 이렇게 세 가지를 props로 전달받는다.

 

다른 점은 딱히 특이한 점이 없었는데, 이 문법이 신기했다.

onEnter?.()

 

이전까지 나는 아래처럼 쓰고 있었는데 간결하고 되게 좋은 거 같다.

onEnter && onEnter()

 

이제 퍼널 컴포넌트에 대해서 알아보자.

export type 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);

  if (!targetStep) {
    throw new Error(`${step} 스텝 컴포넌트를 찾지 못했습니다.`);
  }

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

 

사실 퍼널 컴포넌트 보면서 진짜 많이 배웠다.

어떻게 이런 코드를 짜지?라는 생각을 정말 많이 했달까...

타입적으로 너무 잘 짠 거 같다는 생각이 잔뜩...

 

퍼널 컴포넌트는 전체 스텝을 가지고 있는 steps 배열, 현재 스텝을 나타내는 step, 그리고 Step 컴포넌트 (배열) 이렇게 받아서 컴포넌트를 구성한다.

 

퍼널 내부 구현 중 validChildren 부분이 가장 인상적이었는데, 먼저 리액트 엘리먼트 자식들을 배열로 변환하고, 유효한 리액트 엘리먼트만 필터링하고, steps에 스텝명이 정의된 것만 또 필터링해서 validChildren을 구성한다.

이때 중요한 것은 filter 부분이다.

const validChildren = Children.toArray(children)
    .filter(isValidElement)
    .filter((i) =>
      steps.includes((i.props as Partial<StepProps<Steps>>).name ?? "")
    ) as Array<ReactElement<StepProps<Steps>>>;

 

첫 번째 filter 부분부터 보자면 isValidElement를 활용해서 필터링을 한다.

이때 타입 가드를 활용해서 알맞은 타입을 가진 요소만 골라내도록 했다.

 

아래 코드는 isValidElement의 함수 시그니처이다.

function isValidElement<P>(object: {} | null | undefined): object is ReactElement<P>;

그래서 이 함수를 활용해서 리액트 엘리먼트 타입만 골라내도록 했다.

 

이펙티브 타입스크립트 책에서도 배열에 undefined가 섞여있는 경우 filter 함수 내부에 타입 가드를 이용해서 undefined 타입만 걸러내도록 해서 문제를 해결하는 예시를 보여줬었다.

이 예시를 보니 너무 잘 이해가 됐다.

 

이번엔 두 번째 필터 함수를 보자.

.filter((i) =>
  steps.includes((i.props as Partial<StepProps<Steps>>).name ?? "")
) as Array<ReactElement<StepProps<Steps>>>;

요소를 하나씩 반복하면서 steps에 존재하는 스텝명을 가진 요소인지 확인한다.

 

이때 타입 단언을 왜 썼나 궁금해서 지워봤더니 아래 에러들이 발생했다.

두 타입 모두 unknown으로 인식해서 타입 단언을 통해 해결해 준 것을 알 수 있다.

첫 번째 i.props가 unknown인 이유는 아마 i가 Step 컴포넌트임이 확실하지 않아서 name 프로퍼티가 없을 수도 있기 때문인 것 같고, 두 번째 filter 함수의 반환값이 unknown인 이유도 반환값이 다른 리액트 엘리먼트 배열일 수도 있기 때문에 그런 것 같다.

그래서 타입을 확실하게 지정해 주기 위해서 타입 단언을 사용한 것을 알 수 있었다.

 

이렇게 정의한 validChildren을 활용해서 현재 스텝명과 동일한 children을 찾아주고 이를 렌더링 해주게 된다.

 

테스트 코드

아래 코드는 스텝 컴포넌트를 위한 테스트 코드이다.

아주 간단한 수준으로만 짰으니 참고만 하길...

describe("스텝 컴포넌트", () => {
  it("처음 렌더링될 때 onEnter 함수가 호출됩니다.", () => {
    const onEnterMock = jest.fn();

    render(
      <Step name={"Step1"} onEnter={onEnterMock}>
        Step 1
      </Step>
    );

    expect(onEnterMock).toHaveBeenCalled();
  });

  it("children을 렌더링합니다.", () => {
    render(<Step name="Step1">Step 1</Step>);

    expect(screen.getByText("Step 1")).toBeInTheDocument();
  });
});

 

두 경우를 테스트해 봤는데, 첫 번째 경우는 렌더링 시 onEnter 함수가 호출되는지, 두 번째 경우는 children을 잘 렌더링 하는지 테스트했다.

 

첫 번째 테스트 코드부터 설명하면 jest.fn()을 사용해서 onEnter 함수의 목을 만들었고, expect(onEnterMock).toHaveBeenCalled()를 사용해서 목 함수가 호출되었는지를 확인한다.

 

두 번째 테스트 코드는 Step1 스텝 컴포넌트를 렌더링시키고 Step 1이라는 텍스트를 가진 엘리먼트가 화면에 존재하는지 확인하는 코드이다.

 

이제 퍼널 컴포넌트 테스트이다.

const steps: NonEmptyArray<string> = ["Step1", "Step2", "Step3"];

describe("퍼널 컴포넌트", () => {
  it("정상적으로 동작합니다.", () => {
    render(
      <Funnel steps={steps} step={"Step2"}>
        {steps.map((step) => (
          <Step name={step}>{step}</Step>
        ))}
      </Funnel>
    );

    expect(screen.getByText("Step2")).toBeInTheDocument();
  });

  it("타겟 스텝 컴포넌트를 찾지 못하면 에러를 발생시킵니다.", () => {
    expect.assertions(1);

    const renderWithAssertion = (): RenderResult => {
      return render(
        <Funnel steps={steps} step={"Step4"}>
          {steps.map((step) => (
            <Step name={step}>{step}</Step>
          ))}
        </Funnel>
      );
    };

    expect(() => renderWithAssertion()).toThrow(
      "Step4 스텝 컴포넌트를 찾지 못했습니다."
    );
  });
});

 

첫 번째 테스트에서는 정상적으로 잘 동작하는 예시를 넣어놓고, 잘 동작하는지 확인하였고, 두 번째 테스트에서는 일부러 타겟 스텝 컴포넌트를 찾지 못하는 예시를 렌더링시키고 에러가 잘 발생하는지 확인하였다.

 

첫 번째 테스트 코드는 스텝 컴포넌트 테스트 코드와 유사하니 넘어가고, 두 번째 테스트 코드를 좀 더 알아보도록 하자.

expect.assertions(1) 이 코드는 해당 테스트에서 예상되는 assertion의 개수를 지정한 것이다.

renderWithAssertion 함수는 Step4를 찾지 못할 때 에러가 발생하는지 확인하기 위해서 사용했는데, 퍼널 컴포넌트와 스텝 컴포넌트를 렌더링한다.

expect(() => renderWithAssertion()).toThrow() 부분은 퍼널 컴포넌트에서 타겟 스텝 컴포넌트를 찾지 못했을 때 발생시키기로 한 throw 에러 부분이 잘 동작하는지 확인한 코드이다.

 

그래서 이렇게 테스트 코드를 간단하게 짜봤고, 동작시키니 다 통과한 것을 알 수 있었다.

 

이번에는 진짜 간단한 수준만 테스트해 봤는데 점차 테스트도 심화적으로 해보면서 익혀봐야겠다.