특히 한 파일에서 퍼널의 모든 흐름을 제어하고 파악할 수 있는 API를 제시한 것은 놀라운 일입니다.
그러나 이 Funnel은 그와 동시에 해결하지 못한 여러가지 문제를 가졌습니다. 대표적으로는 이런 문제가 있을 거에요
1. 악의적 사용자를 막기 위한 대책을 제공하지 못한다.
2. 특정 환경, 라이브러리에 매우 의존적이다.
3. 중첩 퍼널에 대한 고려가 되어있지 않다.
1, 2, 3에 대한 해결책과 더불어 기존 퍼널보다 더욱 우아한 방법이 있을까를 여러 방면으로 생각해보았습니다. 이 과정에서 저는 여러가지 해결을 시도했고 처참하게 망하기도 했습니다.
대부분은 히스토리 스택의 제약조건을 고려하지 못하여 실패한 케이스였는데요 멘토님의 조언을 통해 이번에는 제약조건을 우선 고려하고 해결방법을 고민하는 형태로 접근 방식을 바꿔보았습니다.
결국은 히스토리 스택에 쌓인 스택들에 접근할 방법이 없으니 "주어진 현재 스텝" 만을 단일 원천으로 삼아 구현하는 형태로 다시 돌아왔어요
악의적 사용자를 잘 막는 방법
악의적 사용자를 막는 가장 대표적인 케이스는 가드 컴포넌트를 두는 것입니다. HOC 혹은 그냥 단순히 부모컴포넌트를 두기 등 이미 "로그인"과 같은 기능을 구현할 때에 같이 따라오는 패턴이기 때문에 매우 익숙합니다.
다만 여기에서 고민이 되었던 점은 선언적으로 악의적 사용자를 막는 것과 더불어 접근해선 안되는 페이지에 대한 대응을 페이지별로 개인화하는 방법이었습니다.
저는 이 부분에 대해서 접근 가능한지에 대한 여부를 판단하는 것과 접근 불가능할 때의 처리를 분리하는 것이 좋다고 생각하여 그렇게 구현을 하기로 했습니다.
특정 환경, 라이브러리에 의존적이다.
이 부분은 코어 로직과 세부사항에 대한 구현을 분리하는 것을 통해 해결 가능합니다. Tanstack Query가 가장 대표적인 예시라고 할 수 있는데요
Tanstack Query의 패키지 구성을 살펴보면 코어 패키지인 @tanstack/query-core에 코어 로직이 들어가며 이를 기반으로 @tanstack/react-query @tanstack/solid-query 등 프레임워크의 세부사항에 맞게 구현된 패키지들을 제공합니다.
이와 비슷하게 퍼널또한 어떤 라우터를 사용하는지, 어떤 프레임워크를 사용하는지가 중요하지 않도록 인터페이스 형태로 분리해낸 뒤 해당 인터페이스를 구현하는 어댑터를 제공하는 방식으로 구현하면 됩니다.
다만 저는 리액트만 고려하기로 했습니다.
중첩 퍼널에 대한 고려가 되어있지 않다.
해당 부분은 꽤 까다로운 지점 중 하나입니다. 중첩된 퍼널을 표현하기 위해서는 쿼리스트링 관리를 어떻게 할 것인가에 대한 고민이 조금 더 필요하기 때문입니다.
저같은 경우에는 쿼리스트링키를 외부에서 주입하는 형태로 구현하는 것을 통해 중첩 퍼널을 구현할 수 있지 않을까? 라고 생각을 했습니다.
구현하기
제가 생각한 방법을 토대로 문제를 해결하는 코드를 작성해보았습니다. 최종적으로는 다음과 같은 API가 나왔어요
세부 구현은 재미없으니까.. 다루지 않고 넘어가도록 하겠습니다. 그리고 funnel의 선언과 동작부분을 분리하는 것을 통해 좀 더 유연하게 퍼널을 처리할 수 있지 않을까했는데요
tanstackquery의 구조를 참고하여 항상 동일한 구조를 가진 객체 하나에 의존하는 형태로 구성하는 것을 통해 해당 목표를 달성할 수 있었습니다.
가드의 인터페이스를 설계할 때에는 퍼널에 진입할 수 있는가?에 대한 여부를 판단하는 것과 진입할 수 없을 때의 처리, 그리고 fallback을 각각 필요에 따라 주입하면 적절할 것 같았습니다.
condition을 판단할 때에 async하게 판단하는 것도 가능하게 하면 fallback을 몇초동안 보여줄것인가에 대한 처리도 자연스럽게 녹일 수 있었던 것이 구현을 해보면서 좋았던 지점이었습니다.
또 한편으로는 confirm 을 통해 사용자에게 진입할 수 없다는 것을 안내하는 것도 좋은 방법이 될 것 같다는 생각이 들었는데요 overlay-kit을 활용하면 쉽게 구현할 수 있었습니다.
이렇게요!
퍼널의 렌더링 최적화 어떻게 이룰 수 있을까요?
페이지를 넘나드는 퍼널 구현 대신 한페이지에서 퍼널을 처리하도록 구현하면 상태 공유에 대한 고민을 비교적 적게할 수 있습니다.
상태의 취합을 위해 전역 상태, 외부 스토리지 사용등이 사실상 강제되는 솔루션에 비하여 한페이지안에서 이루어지기 때문에 상태를 쉽게 내려줄 수 있다는 것입니다.
그러나 상태를 단순히 페이지의 맨위에서 가지고 그 상태를 바꾸게 되면 리액트의 특성상 모든 하위 컴포넌트가 리렌더링 되는 문제가 발생합니다.
이것은 비교적 단순한 화면만 표현하는 퍼널에서는 문제가 되지 않지만 일부 상황에서는 문제가 될 수 있습니다.
이를 해결하기 위한 일반적인 해결책으로 전역 상태 사용을 고려할 수 있습니다.
전역 상태로 관리하면 정말 해결될까요?
jotai, zustand, redux 등 대표적인 전역 상태 관리 라이브러리들은 문자그대로 상태를 관리해줍니다. Selector를 통하여 특정 상태를 구독하고 있는 컴포넌트만 상태가 변했을때 리렌더링 시키는 것이 가능하죠
안타깝게도 리액트에서 자체적으로 이런 셀렉터 기능을 지원하지는 않기에 이 문제를 해결하려면 전역 상태를 선택할 수 밖에 없어보입니다.
그러나 전역 상태를 사용하게 되면 상태를 완벽하게 은닉할 수 없기에 특정 컨텍스트에서만 사용되는 퍼널의 상태가 암시적으로 모든 코드 범위에 영향을 받게되는 문제가 발생합니다. 이것은 응집도 측면에서도 좋지 않은 일입니다.
외부 스토리지 사용은 어떨까요?
서버데이터에 의존하며 서버데이터를 업데이트하거나 또는 로컬 스토리지와 같은 외부스토리지를 통해 전역 상태 없이도 상태를 공유할 수 있습니다.
그러나 위 두가지 방법 모두 모든 상황에서 사용 가능한 범용적인 솔루션은 아닙니다. 매번 서버데이터를 업데이트하는 것에 제한이 있을 수 있고 또 보안 측면에서 민감하다면 외부 웹 스토리지 사용이 어려울 수 있습니다.
그렇다면 어떻게 해결해야 할까요?
위 두가지 방법을 사용하지 않으면서도 렌더링을 최소화하면서 은닉화를 달성하고 보안적인 측면에서도 비교적 안전할 수 있는 방법은 무엇이 있을까요?
멘토님은 이 문제에 대해 상태를 "커밋"하는 것과 상태를 변경하는 동작을 분리하는 것을 통해 이룰 수 있다는 의견을 주셨습니다.
화이트보드에 그려주시는 구조도를 보면서 제가 평소에 구현하던 방법과 유사한 지점을 발견했는데요 간단한 코드로 아이디어를 표현하자면 다음과 같습니다.
이 예시에서는 외부에서 초기값이 될 커밋된 밸류와 커밋을 수행할 세터를 전달받고 초기값을 통해 지역 상태를 만든 뒤 컴포넌트 내부에서는 지역 상태만을 변경합니다.
이렇게 초기값을 입력받고 그것을 활용하여 지역상태를 두는 기법을 통해 onCommit을 수행하기 전 렌더링 범위는 Example 컴포넌트만으로 자연스레 좁혀지게 됩니다.
그런 뒤 커밋을 수행하면 한번 전체 퍼널에 대하여 리렌더링이 일어나게되지만 커밋을 했다는 것은 해당 퍼널스텝이 끝났다는 것을 의미하기 때문에 모든 퍼널에 대해 리렌더링이 일어나는 것 자체도 자연스럽습니다.