ghdtjgus
article thumbnail

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

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

https://github.com/toss/slash

 

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

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

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

 

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

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

 

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

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

 

1. 퍼널

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

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

 

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

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

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

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

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

 

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

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

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

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

<bash />
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>

 

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

 

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

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

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

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

 

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

 

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

<bash />
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로 전달받는다.

 

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

<bash />
onEnter?.()

 

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

<bash />
onEnter && onEnter()

 

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

<bash />
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 부분이다.

<bash />
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의 함수 시그니처이다.

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

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

 

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

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

 

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

<bash />
.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을 찾아주고 이를 렌더링 해주게 된다.

 

3. 테스트 코드

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

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

<bash />
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이라는 텍스트를 가진 엘리먼트가 화면에 존재하는지 확인하는 코드이다.

 

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

<bash />
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 에러 부분이 잘 동작하는지 확인한 코드이다.

 

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

 

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

profile

ghdtjgus

@gugu76

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

검색 태그