사실, TIFY 프로젝트에서 프론트 리드님이 toss-slash 라이브러리 참고하면서 우리 프로젝트에 맞게 퍼널 컴포넌트 커스텀하신 걸 보고 나도 toss-slash 라이브러리에 대해서 깊게 파보고 싶다는 생각이 들었다.
그래서 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 에러 부분이 잘 동작하는지 확인한 코드이다.
그래서 이렇게 테스트 코드를 간단하게 짜봤고, 동작시키니 다 통과한 것을 알 수 있었다.
이번에는 진짜 간단한 수준만 테스트해 봤는데 점차 테스트도 심화적으로 해보면서 익혀봐야겠다.
'프로젝트 개발일지 > toss-slash 라이브러리' 카테고리의 다른 글
[toss-slash 라이브러리] arrayIncludes 함수와 테스트 코드 (toss 라이브러리에 이슈 올리기) (1) | 2024.01.13 |
---|---|
[toss-slash 라이브러리] localStorage와 테스트 코드 (1) | 2024.01.13 |
[toss-slash 라이브러리] assert 함수와 테스트 코드 (2) | 2024.01.12 |