Routify가 javascript file에서 동작하지 않는 이슈
문제 상황
svelte 프로젝트에서 @roxi/routify 라이브러리를 활용하여 SPA를 구현하던 중 인증 실패 시 다시 root 페이지로 렌더링 하기위해 $goto function을 사용하였습니다. 코드로 살펴보면 다음과 같았습니다.
const handleError = async (error) => {
const { config } = error;
const refreshToken = localStorage.getItem('refreshToken');
// ... other code
// 실패 시 $goto('/') 로 root page 이동
alert(error.response.data.body.message);
$goto('/');
}
하지만 $goto function이 동작하지 않아 제가 예상한 root page로 이동이 아닌 alert만 발생하고 root page로 이동하지 않아 애를 먹던 상황입니다.
원인 분석
분명 @roxi/routify는 javascript library였기 때문에 당연히 동작할 수 있을 것 같지만 동작하지 않아 '$goto is not working in routify'라는 키워드로 검색 해보았습니다. 그리고 공식 Github issue에서 다음과 같은 글을 볼 수 있었습니다.
간단하게 해석을 하자면 svelte component 외부에서는 $goto function을 사용할 수 없다는 내용이었습니다.
그렇다면 왜 사용하지 못했을까요? 정답은 내부 동작에 있었습니다.
export const goto = {
subscribe: (run, invalidate) => {
const { router } = contexts
return derived(url, $url =>
/** @type {Goto} */
(pathOrNode, userParams, options) => {
const path =
typeof pathOrNode === 'string' ? pathOrNode : pathOrNode?.path
/** @type {options} */
const defaults = { mode: 'push', state: {} }
options = { ...defaults, ...options }
const newUrl = $url(path, userParams, options)
router.url[options.mode](newUrl, options.state)
return ''
},
).subscribe(run, invalidate)
},
}
해당 코드는 routify의 goto function의 구현 코드입니다. 내부적으로 store의 derived로 url을 기반으로 새로운 store를 파생시켜 url의 상태 변경에 따라 라우팅을 수행합니다. 이는 흔한 component간 통신 수단이며 store 자체는 반응형 상태 저장소로써 역할을 수행합니다. 그렇기 때문에 component 외부에서 이 동작을 수행하려고 하니 동작하지 않았던 것입니다.
그렇다면 왜 이렇게 설계된 것일까요? routify 진영에선 굳이 상태 관리를 활용할 이유가 있었을까요?
공식문서에서 따로 해당 이유가 설명된 부분을 찾진 못해서 GPT를 통해 유추 해봤을 땐 다음과 장점이 있다고 합니다.
1. 반응성: Svelte 스토어는 상태 변화가 발생할 때마다 자동으로 구독 중인 컴포넌트를 업데이트합니다. 라우팅 상태가 변경될 때마다 관련된 뷰나 컴포넌트가 자동으로 반응하여 변경된 상태를 반영할 수 있습니다.
2. 중앙 집중식 상태 관리: 라우팅 상태를 중앙 집중식으로 관리함으로써, 애플리케이션 내 어느 곳에서나 현재 라우트의 상태에 접근하고, 변경 사항을 쉽게 추적할 수 있습니다.
3. 유연성과 확장성: 스토어를 사용하면 라우팅 로직을 애플리케이션의 다른 부분과 쉽게 연결할 수 있습니다. 예를 들어, 사용자 인증 상태에 따라 라우트 접근을 제어하는 등의 복잡한 라우팅 로직을 구현할 때 유용합니다.
필자가 생각하는 이러한 설계 이유
필자는 GPT에서 답변을 준 대로 반응성을 활용한 컴포넌트의 빠른 상태 반영을 통한 빠른 렌더링의 이점과 굳이 이런 이점을 포기하고 JS에서 동작하게 할 이유가 없다고 생각했습니다.
필자의 의견은 OOP에 기반한 사고에서 시작되었습니다. OOP에서는 기본적으로 하나의 class 또는 객체는 하나의 역할만 수행해야합니다. 그렇다면 현재 작성한 파일을 기반으로 역할을 나눠보면 다음과 같습니다.
error handling에 대한 책임
const { config } = error;
const refreshToken = localStorage.getItem('refreshToken');
// ... other code
결과를 이용해 화면에 나타내는 책임
alert(error.response.data.body.message);
렌더링을 통해 화면을 제어하는 책임
$goto('/');
하나의 function에서 너무 많은 일을 처리하고 있습니다. OOP 규칙을 위반하고 있고 그로 인하여 해당 서비스의 view 흐름을 파악하려면 불필요한 configuration code도 찾아봐야합니다. 그리고 view에 대한 역할을 하는 라이브러리들에 변경이 필요할 때도 책임이 흩어져있어 여러 layer를 손봐야할 것입니다.
test 또한 관심사가 분리가 안되어 불필요한 맥락이 들어가 명확한 애플리케이션 스펙을 나타내지 못할 수도 있습니다.
이러한 이유로 js에서 동작하게 만들어도 애플리케이션의 책임이 분리되어 오히려 유지보수성을 떨어트릴 뿐 어떤 이익도 주지 못합니다. 결론적으로 js에서 동작을 하게 설계할 이유가 없는 것이죠.
해결 방법
결론적으로 위 문제는 필자가 생각한 사고를 바탕으로 판단 했을 때 당연히 .svelte에서 동작하는 것이 맞다고 판단하였습니다. .svelte는 MVC 중에서 View를 담당하는 component 코드를 담고 있기 때문에 애플리케이션 동작을 처리하는 .js 파일에선 에러를 던지기만하고 실제로 에러를 받아 view에서 화면처리와 routing을 담당하게 하였습니다.
해결 코드
<script>
const results = async (id) => {
try {
return await getList(id);
} catch(error) {
console.error('error : ' error);
if (error.response.status === 401) {
alert('로그인이 필요합니다.');
$goto('/');
}
$goto('/error');
}
}
</script>
<div class="container">
<!-- other code -->
</div>
이렇게 해결 하였을 때 장점은 책임이 명확히 나뉘고 애플리케이션 동작에 문제가 생겼을 때 원인에 따라 유지보수할 코드가 확실히 갈리게 되었고 small test에서 router나 rendering 과 같은 화면과 관련된 동작을 전혀 신경쓸 이유가 사라져 훨씬 spec 명세가 명확해졌습니다.
etc. 만약 어쩔 수 없이 써야한다면?
정말 써야한다면 layout.svelte에 선언된 goto function을 globalGoto라는 writable 전역 변수에 넣어주고 JS에서 쓰게 해줄 순 있습니다. 다만 필자는 이러한 방식을 쓰기 전에 먼저 본인이 이 설계에 문제가 없는지 고려 후에 사용하는 것을 권장합니다. (모든 것엔 trade off가 있으니 모든 문제가 SRP를 지켜가며 풀리지 않을 수도 있습니다.)