🐁 퍼널 써보니까 좋은데..? 라이브러리로 한번 만들어보자
https://www.youtube.com/watch?v=NwLWX2RNVcw
위 영상에서 본 퍼널의 접근방식이 마음에 들어서 회사 서비스에도 간단하게 구현하여 적용을 해봤습니다. 적용을 해두고보니 DX가 매우 좋았어서 이것을 다른 프로젝트에서도 사용하고 싶어졌는데요.
한가지 사소한(큰) 문제가 있다면 구현이 next.js의 next/router 라이브러리의 useRouter훅에 의존하고있다는 것이었습니다. 즉 next.js 위가 아니면 돌아갈 수 없는 프로젝트라는 것이 가장 큰 문제였고 토스의 toss/slash 라이브러리의 구현에서도 next/router 라이브러리에 의존하고있는것을 확인할 수 있었습니다.
따라서 제 목표를 크게 next.js와 이혼하기 / 어디서나 쉽게 사용할 수 있게 라이브러리로 만들기 두가지로 잡았고 이를 실천하고자 계획을 세웠습니다. 라이브러리로 구현된 코드는 아래에서 확인할 수 있습니다.
https://github.com/xionhub/xion/tree/main/packages/react-hook/src/funnel
npm i @xionhub/react-hook
yarn add @xionhub/react-hook
위 명령어를 통해 라이브러리를 설치한 후 use-funnel 훅을 사용해볼 수 있습니다.
🌕 기능 명세를 뽑기 전 방법 선택
자잘한 부분들은 나중에 추가할 수 있기만 하면 되니까 커다란 틀에서의 로직을 상상해보았습니다. 퍼널을 구현하기 위해서 여러가지 접근 방식을 고려할 수 있고 각 방식에 따라 약간의 트레이드 오프가 존재하였는데요
예컨대 작은 spa 형태로 구현을 하여 관리하는 경우와 쿼리스트링과 브라우저 history 스택을 이용하여 구현하는 방식 두가지를 크게 고려해볼 수 있었습니다. 각 방법의 장단을 표로 정리하면 다음과 같았습니다.
쿼리스트링 방식 | SPA 방식 | |
장점 | 1. 브라우저 뒤로가기 / 앞으로가기를 통해 사용자가 적절한 상태로 이동을 할 수있다. 2. 쿼리스트링 자체를 하나의 상태로 사용할 수도 있기 때문에 context api의 역할을 어느정도 쿼리스트링으로 대체할 수 있게된다. |
1. 오직 리액트 상태로만 구현하면 되기에 상대적으로 구현이 간단하다. 2. 다른 프론트엔드 라이브러리 , 프레임워크도 웬만하면 상태에 대한 개념과 기능은 존재하기때문에 퍼널의 코어한 로직만 구현되어있다면 다른 곳에 가져다 붙이기 쉽다. |
단점 | 1. next.js / react-router-dom등 이미 routing 관련 솔루션이 있는 경우 이들과 통합하는 것이 까다로울 수 있다. | 1. 상태만 가져주면 되기때문에 간편하게 구현할 수 있다. 2. 쿼리스트링 방식의 이점을 가져가지 못한다. 앞으로가기 , 뒤로가기 처리가 까다로워진다. 3. 하위 컴포넌트는 퍼널의 상태를 알 방법이 없다 따라서 context api 등의 추가 솔루션이 필요하게 될 여지가 다분하다. |
지금 와서 생각해보면 SPA 방식으로 가되 뒤로가기 앞으로가기에 대한 처리를 따로 해주는게 나았나 싶기는 합니다만.. 저는 쿼리스트링 방식을 통해 구현을 시도했습니다. 앞으로가기 뒤로가기에 대한 경험에 있어 실제 url도 함께 바뀌어주는게 제 생각에는 더 자연스러웠기 때문입니다.
🌔 코어 기능 생각해보기
퍼널을 구현하는 데 있어서 세부사항을 제외하고 가장 바뀌지 않을 코어한 로직이 뭘까? 라고 생각해보았을 때에 퍼널이라는 것을 이렇게 생각할 수 있다고 보았습니다.
퍼널은 여러 단계를 지니고있으며 그 여러개의 단계 중 현재 도달해있는 단계를 표현해야한다.
사전적인 정의는 아니고 제가 이렇게 생각하기로 마음 먹었습니다. 그렇다면 위 정의를 구현하기 위해 필요한 로직은 무엇일까? 라고 생각해보았을 때에 퍼널이라는 것은 "여러 단계를 지니고" "현재 단계를 표현" 해야한다고 생각했습니다.
즉 여러개의 상태를 가지고 있어야하는 것이 선행조건이며 이러한 경우를 처리할 수 있는 자료구조로 배열 , Map, Object 정도를 떠올릴 수 있었습니다. 또한 단계가 없는 상태는 퍼널에서 존재할 수 없습니다. 단계가 없다면 현재 단계도 없다는 뜻이며 단계와 현재 단계가 존재하지 않는다면 그것은 퍼널이라고 부를 수 없다고 생각했습니다.
export type NonEmptyArray<T> = readonly [T, ...T[]];
그러한 의미에서 비어있지 않은 배열을 요구하는 위 타입이 곧 퍼널은 여러 단계를 지녀야한다는 핵심 로직을 표현한다는 생각이 들었습니다. 사실은 비어있지 않고 여러 상태를 표현할 수만 있다면 배열이든 Map이든 Object든 상관이 없었으며 매번 최악의 경우 O(n)의 탐색비용이 드는 배열보다는 Map, Object 쪽이 더 좋은 선택일 수 있지만..
간결한 문법과 다양한 메서드들이 존재하는 배열이 개발자 입장에서 편리하며 , 퍼널이 성능이슈를 불러올 만큼 많아진 경우를 고려하는 것은 불필요하다고 생각했습니다.
사실 평소에도 어렴풋이 그래도 object는 아무리 동적탐색이 필요하다고 하더라도 길이가 길어지게되면 전부 정직하게 순회하는 배열보단 빠르겠지..? 라고 생각만 해보고 검증을 해보지는 않았는데 정말 퍼널을 배열로 관리하는 게 성능이슈를 불러오지 않을지 한번 시도해보면 확신을 얻을 수 있을 것 같았습니다.
이렇게 지금 궁금한 바와 같이 특정한 작업이 얼마나 걸리는지 파악을 하는 일에 자바스크립트의 console 객체를 활용할 수 있는데요. 자바스크립트의 console 객체는 time() 과 timeEnd() 메서드를 가지고있어서 이를 이용하면 특정 작업을 수행하는 데에 걸리는 시간을 개략적으로 파악할 수 있거든요.
const bigArray = Array.from({ length: 10000 });
console.time('bigArray');
bigArray.map((item, idx) => idx);
console.timeEnd('bigArray');
길이 1만의 배열을 처리하는 데에 평균적으로 0.3ms 정도의 시간이 소요되는 것을 확인할 수 있습니다.
브라우저 콘솔환경에서는 3~4배가량의 시간이 더 소요되지만 대체로 1ms 의 시간조차 걸리지 않는것을 볼 수 있습니다. 연산이 너무 간단해서 그런 것일까요? 조금 더 까다로운 이터레이트를 돌아보면 어떨까요? 실제로 퍼널을 구현하는 로직과 유사한 코드를 짜보겠습니다.
const steps = [
'hello',
'hell',
'heaven',
'real',
'house',
'vscode',
'web',
'ios',
'android',
'hi',
];
const step = 'hi';
console.time('bigArray');
const currentStep = steps.find((item) => item === step);
console.timeEnd('bigArray');
10개의 퍼널을 순회하며 값을 비교하는데에 대략 0.040 ms 정도의 시간이 소요되었습니다.
const objectSteps = {
hello: 'hello',
hell: 'hell',
heaven: 'heaven',
real: 'real',
house: 'house',
vscode: 'vscode',
web: 'web',
ios: 'ios',
android: 'android',
hi: 'hi',
};
const step = 'hi';
console.time('bigArray');
objectSteps[step];
console.timeEnd('bigArray');
이번에는 object 형태로 비교해보겠습니다.
10개정도 수준에서는 array를 통해 순회하는 것과 큰 시간차이가 발생하지 않는 것을 확인할 수 있습니다. 그러니 아무리 복잡한 퍼널이더라도 퍼널의 갯수가 30-40개를 초과하지는 않을 것이라고 생각한다면
배열을 사용해도 대부분의 상황에서 성능적인 손해가 크지 않을것이라 유추할 수 있었습니다. 따라서 퍼널의 핵심로직은 "배열의 형태로 표현된 여러개의 단계를 지닌다." 라고 생각하기로 했습니다.
이제 퍼널을 spa로 구현하느냐 , querystring으로 구현하느냐 , react로 구현하느냐 , next.js의 훅을 이용하여 구현하느냐와 같은 세부사항은 그저 세부사항일 뿐이게 되었는데 이렇게 생각을 해보고 나니 현재단계를 정의하는 것 역시 세부구현에 맡기는것이 더 나을 것 같다는 생각이 들었습니다.
🌓 기능 구현하면서 테스트 코드 작성하기
최근들어 테스트 코드 예찬론을 펼치는 중이긴 한데.. 사실 저는 TDD를 잘 하지는 못하는 사람입니다.
왜 TDD를 못하나요? 라고하면 제가 작성한 테스트 코드가 제대로 동작하는 테스트 코드인지에 대한 확신이 존재하지 않아서 라고 할 수 있습니다. 이가 없으면 잇몸으로 때워야겠죠..? 그래서 저의 테스트 코드 작성법은 아직은 이런 프로세스를 거치고 있습니다.
1. 실제 코드를 작성한다.
2. 해당 코드가 제대로 동작하는지 개발 환경에서 확인한다.
3. 개발환경에서 동작하는 것을 확인했다면 개발환경에서 동작하는 부분을 적절하게 통과하는 테스트 코드를 작성한다.
당장 개발효율성이 많이 떨어지는 방법이긴한데 이 방법을 반복하다보니 점점 테스트 코드에서 명확하게 자신이 생기는 영역들이 늘어나고 있긴하니 언젠가는 TDD로 개발하는 것도 가능하지 않을까? 라고 생각하고 있습니다.
이야기가 조금 옆으로 샜는데 아무튼 funnel 컴포넌트의 동작을 확인하는 코드를 작성해보고자 했습니다.
const StepComponent = ({ name }: { name: string }) => (
<div data-testid={`step-${name}`}>{name}</div>
);
expect(screen.getByTestId('step-second')).toBeInTheDocument();
Dom 상호작용을 수행하는 테스트 코드를 작성함에 있어서 가장 신경써야하는 부분은 테스트를 위한 맥락을 css / js 맥락과 분리하여 생각하는 것이라고 생각하는데 getByRole 을 통하여 더 적절한 역할을 부여하고 가져올 수도 있지만 그보다 테스트를 위한 맥락을 데이터 선택자로 가져오는 것이 더 명확한 것 같다는 생각이 들어 다음과 같이 작성하고 있습니다.
또 테스트 코드를 작성하면서 까다롭다고 생각이 되었던 부분이 바로 history api와 관련된 부분을 테스팅하는 것이었는데요 알다시피 테스트코드가 실행되는 환경은 웹이 아니며 webapi, window 객체는 존재하지 않습니다.
따라서 jest는 라이브러리를 통해 웹환경을 흉내낸 테스트 환경을 제공해줍니다. 여기서 jest 환경에서 제공되는 history 객체가 실제로 history.back등의 메서드 실행 결과를 제대로 기대하기 어려운 문제가 있었습니다.
이 과정에서 제가 테스트를 임하는 방식 자체를 잘못 생각하고 있다는 생각이 들었는데 예컨대 history.back 을 사용했을때 나는 뒤로가기 동작이 일어날 것을 기대하지만 그것은 history api의 실행에 의한 결과이며 테스트할 때에는 history.back에 해당하는 함수가 적절한 인수와 함께 적절한 횟수로 호출되었는지를 검사해야한다는 것을 깨달을 수 있었습니다.
이런 부분과 같이 실제로 history api가 있는 환경에서는 제대로 동작하는 반면 테스트 환경에서는 지식 부족 / 테스트에 대한 이해 부족으로 인하여 제대로 된 테스트를 작성하지 못하는 경우가 빈번하다보니 위와 같이 코드 작성 -> 실제 동작 테스트 -> 그에 맞는 테스트 코드 형태로 작업을 하게 되는 것 같습니다.
🌒 app router 호환... 쉽지 않음
테스트 코드 환경 뿐만 아니라 서버사이드에서도 web api와 window 객체는 존재하지 않습니다. 따라서 window.history , window.location과 같은 web api 역시 모두 서버사이드에서 실행되었을때를 대비한 코드가 필요했는데요
이를 위해 useEffect 안에서 로직을 넣어 항상 클라이언트에서만 실행되도록 하거나 / 매번 if문을 걸어 window 객체가 존재할때에만 접근을 하게하거나 하는 식의 방법을 생각할 수 있지만 조금 더 개발하는 입장에서 편리하게 관리할 방법을 생각해보았습니다. 바로 stub 객체를 두는 방식인데요
import { isClient } from '../is-client/is-client';
export const locationStub: Location = {
ancestorOrigins: {
length: 0,
contains(string: string) {
return true;
},
item(index: number) {
return null;
},
},
hash: '',
host: '',
hostname: '',
href: '',
origin: '',
pathname: '',
port: '',
protocol: '',
search: '',
assign: () => {},
reload: () => {},
replace: () => {},
};
export const safeLocation = () => {
if (!isClient()) return locationStub;
return window.location;
};
이러한 형태로 사전에 미리 인터페이스만 구현하는 객체를 생성해두고 서버사이드에서는 이런 stub 객체를 리턴하는 형태로 함수를 구현해두면 서버사이드 / 클라이언트 사이드에 상관없이 하나의 타입으로만 추론되기 때문에 개발자입장에서 좀 더 편리하게 코드를 작성할 수 있을 것이라고 생각했습니다.
const location = safeLocation();
const history = safeHistory();
위와 같은 방식을 통해 안전하게 webapi 객체들을 가져오는 함수들을 작성하여 퍼널코드를 작성해주었습니다. 그런데 history api에는 약간의 문제아닌 문제가 존재합니다. 바로 url을 바꾸는 일만 수행할 뿐 이에 따라 상태가 자동으로 반영된다거나 리액트와 동기화되지 않는다는 것인데요 history api를 통하여 url이 바뀌더라도 리액트가 리렌더링을 트리거하지는 않습니다. 그렇기에 history api로 경로를 바꿈과 동시에 리렌더링을 트리거시켜주어야 적절하게 경로에 따른 상태를 보여줄 수 있는 것입니다. 따라서 이러한 코드를 작성하였습니다.
const nextStep = (nextQuery: Steps[number]) => {
return () => {
setCurrentStep(() => {
const getSafeParam = safeSearchParams(targetKey);
const newPath = createNextStep(targetKey, nextQuery);
if (getSafeParam !== nextQuery) {
history.pushState('', '', newPath);
}
return nextQuery;
});
};
};
커링 형태로 구현한 nextStep 함수의 리턴된 함수가 호출되는 경우 리액트의 setState 함수가 트리거되며 setState 함수 내부에서 history 스택을 추가해주는 형태로 구현하여 두 함수가 항상 함께 동작하도록 구현해주었어요
지금 와서 생각해보면 setCurrentStep의 콜백함수가 호출될때마다 부수효과를 일으키는 형태가 된 것 같아서 고쳐줘야 될 것 같다는 생각이 드는데 이것은 차치해두고 제가 구현한 nextStep 함수가 app router위에서 사용하는 과정에서 이상한 현상을 겪게되었습니다.
바로 히스토리 스택이 앱라우터에서만 두번씩 쌓이는 버그였는데요 앱라우터가 아닌 환경에서 / 테스트코드 환경에서는 이러한 일이 발생하지 않았기에 빠르게 앱라우터 환경에서 기인한 문제라는 것을 파악할 수 있었습니다.
문제는 왜 이런일이 일어나느냐. 였는데요 퍼널을 쿼리스트링 방식을 통해 구현하면서 next.js와의 완전한 이혼을 위해 자체적으로 history api를 사용한 부분이 화근이된것으로 보였습니다.
위의 nextStep 함수와 같은 구현은 개발자가 스스로 리액트에게 상태를 바꾸겠다라고 알린뒤 url을 같이 바꿔주는 방식이지만 유저가 뒤로가기 / 앞으로가기 버튼을 눌러서 url을 바꾸는 것은 url이 바뀐 것을 감지하여 리액트의 상태를 바꿔주어야하는 일입니다.
이런 까다로운 경우를 구현하기 위해 주로 popstate 이벤트를 이용하는데요 popstate 이벤트의 경우에는 사용자가 세션 기록을 탐색하는 동안 활성 기록 항목이 변경되었을 때 트리거됩니다. 조금 더 쉽게 이야기하면 사용자가 뒤로가기, 앞으로가기 등의 버튼을 눌렀을 때 동작한다고 볼 수 있습니다.
React.useEffect(() => {
const handlePopState = () => {
const nowStep = safeSearchParams(targetKey) ?? '';
setCurrentStep(nowStep);
};
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, []);
따라서 리액트에서는 useEffect를 이용하여 window 객체에 popstate에 대한 이벤트리스너를 부착하는 부수효과를 발생시켜주면 popstate를 리슨하는 것을 통해 url 변경에 맞추어 상태를 변경시켜줄 수 있습니다.
그런데 여기까지 도달하고보니 react-router-dom / next.js도 이런 방법을 통해 구현하고 있나? 라는 생각이 들더라구요 결론부터 말씀드리면 next.js의 app router의 경우에는 그렇게 관리하고 있다. 라는 결론을 얻었습니다.
https://github.com/frontend-article-study/frontend-article-study/blob/main/giljong/nextjs-naviigation.md
이와 관련하여서는 위 마크다운 문서에 제가 정리해두었으니 관심있으시면 한번...ㅎㅎ;;
packages/next/src/client/router.ts
export default class Router implements BaseRouter {
...대충 많은 코드
constructor(...대충 매개변수){
window.addEventListener('popstate', this.onPopState)
}
핵심적인 코드만 보여드리면 이 부분에서 popstate 이벤트를 리슨하는 것을 확인할 수 있습니다. 이렇게 자체적으로 이미 popstate 이벤트를 리슨하고있는데 그 위에 제가 또 popstate 이벤트를 붙여서 history stack이 두배로 쌓이나..? 라는 생각을 하기도 하였는데 페이지라우터는 멀쩡하면서 앱라우터에서만 발생하는 현상이다보니 우선은 if문을 통해 한번만 쌓이도록 변경해두었지만 왜 두배로 쌓이는지는 좀 더 코드를 파봐야할 것 같더라구요.
🌑 제공되는 기능과 한계점
option 객체를 두는 방식을 통하여 사용자가 target이 되는 querystirng의 key를 변경할 수 있거나 초기 시작 step을 정할 수 있도록 커스터마이징 기능을 추가하였습니다.
userOptions: RecursivePartial<FunnelOptionObject<Steps>> = {},
const { targetKey, initialStep } = mergeOptions(userOptions, {
initialStep: array[0],
targetKey: 'step',
});
그 과정에서 옵션객체가 중첩객체의 형태를 띄는 경우 타입스크립트의 Partial 유틸리티타입은 1depth까지만 옵셔널로 만들기에 재귀적으로 모든 프로퍼티를 옵셔널로 만드는 타입을 정의해줬습니다.
다만 현재는 쿼리스트링 처리를 정교하게 해주고 있지않은 탓에 다른 쿼리스트링을 퍼널과 함께사용하거나 중첩으로 퍼널을 사용할 수는 없다는 한계가 존재합니다.
const createNextStep = (queryKey: string, queryValue: string) => {
const path = location.pathname;
return `${path}?${queryKey}=${queryValue}`;
};
이부분은 쿼리를 한번 역직렬화 해준 뒤 -> 타겟이되는 쿼리키밸류만 업데이트해서 리턴해주면 해결할 수 있을 듯 한데 정말 문제는 뒤로가기 , 앞으로가기를 통하여 퍼널이 존재하던 페이지에서 벗어나게되면 window 객체에 붙여둔 popstate 이벤트리스너가 클린업되고 그 이후에 새로운 이벤트핸들러가 붙지 않기 때문에 뒤로가기 ,앞으로가기를 해도 아무런 일이 일어나지 않는다는... 치명적인 문제가... 존재하게 되었습니다만.. 아무튼 next.js와는 이혼에 성공했으니 절반은 성공했다.라고 평가하고 싶습니다
추가로 클린업으로 인한 뒤로,앞으로가기 이슈를 피하기위해서는 어차피 상태가 전역적으로 관리되어야한다는 교훈을 얻을수있었습니다.
🥲 마치며
간단한 기능이라고 생각했지만 프레임워크의 안전한 울타리를 벗어나 직접 구현을 해보면서 history api의 동작 방식 / next.js가 라우팅을 구현한 방법 / addEventListener의 동작 규칙 등을 배울 수 있었습니다.
읽어주셔서 감사합니다.
'best' 카테고리의 다른 글
사내 이벤트 로깅 시스템을 정비하고 패키지화 하기 (1) | 2024.01.25 |
---|---|
High Order Component 를 아시나요? (1) | 2024.01.02 |
husky와 commitlint로 jira 이슈번호 자동화 시키기 (2) | 2023.11.27 |
toss/slash의 use-funnel 훅 내부 구현 탐구하고 직접 구현하기(2) (0) | 2023.11.03 |
toss/slash의 use-funnel 훅 내부 구현 탐구하고 직접 구현하기(1) (1) | 2023.11.03 |