이전 좋은 UX를 위한 보이지 않는 에러 다루기 글을 통해 예외 처리에 대해 알아보았다.
이번에는 프로젝트를 진행하면서 겪은 관련 트러블 슈팅을 정리해 본다.
*참고: 스크럼블은 JSESSION 기반 인증 방식과 리액트 쿼리를 이용해 서버 상태를 관리한다.
에러가 눈에 보이는데 잡히지 않는다. 이게 무슨 말인가 싶을 수도 있는데 말 그대로 에러가 발생할 것이라 예상되고, 발생해야 맞는데 에러바운더리로 전달이 되지 않는다.
로그인 후 스쿼드 목록을 조회하면, 서버에서 세션의 유효 여부를 검사한다. 이때, 브라우저 쿠키에서 세션 정보를 임의로 삭제한 뒤 뒤로 가기 후 다시 스쿼드 목록을 조회하면, 캐시 데이터가 `stale` 하면 리페치가 실행되어 `ErrorBoundary`에서 예외를 감지할 것이라 예상했다.
스쿼드 목록은 예상과 달리 정상적으로 표시된다. 하지만 콘솔에는 401 에러가 출력되고, 네트워크 탭에서는 해당 요청이 실패한 것으로 확인된다. 이후 세션 검증이 필요한 다른 GET 요청을 수행하거나 새로고침을 해야만 ErrorBoundary에서 에러가 처리된다.
이 문제에 대해 원인을 분석하고 이해하는 과정에서 `useQuery`와 `useSuspenseQuery`의 사용 목적과 동작 흐름에 대해 다시 알아보면서 꽤나 많은 시간이 필요했다.
staleTime 0으로 리패치가 발생할 것이라 예상했지만, cacheTime도 기본값인 5분으로 메모리에 남아 있다. 이로 인해 쿼리가 stale 상태이더라도 캐시 데이터를 먼저 사용해 UI를 렌더링 하고, 백그라운드에서 리패치가 시작된다.
📌 cacheTime
위 표를 통해 staleTime과 cacheTime의 유효 여부 조합에 따라 리액트 쿼리의 상태 플래그 값이 달라지는 것을 알 수 있다. cacheTime은 캐시 데이터가 메모리에 유지되는 시간을 의미한다. 이 시간이 만료되면 캐시 데이터가 삭제되어 data가 undefined가 되고, 네트워크 요청을 통해 데이터를 다시 가져오게 된다.
isLoading은 캐시에 데이터가 없는 경우에만 true가 되므로, cacheTime이 만료된 상황에서는 isLoading: true가 설정된다. 이를 통해 데이터 유효 여부를 구분할 수 있는 정보로서 활용할 수 있다.
캐시 데이터를 활용한다는 점은 맞지만, 이를 통해 문제를 완전히 해결할 수는 없다. 핵심 원인은 `Suspense`와 `useSuspenseQuery` 조합에서 비롯되는 실행 흐름에 있다. useSuspenseQuery는 queryFn 호출 중 Promise를 던져 Suspense를 트리거하고, 페칭이 성공하면 데이터를 반환하며, 실패하면 에러바운더리에서 처리한다. 여기서 cacheTime이 지나지 않은 경우 메모리에 남아 있는 데이터를 활용한다는 점을 놓쳤다.
예를 들어, `squadList`라는 쿼리 키로 스쿼드 목록이 캐싱 된 상태에서는 이 데이터가 stale해도 cacheTime이 지나지 않았다면 캐시 데이터를 반환하여 UI를 렌더링한다. 동시에 백그라운드 리페치를 실행(데이터가 stale 하므로)하게 되는데, 리페치 중 에러가 발생하더라도 이미 UI는 캐시 데이터를 기반으로 렌더링 된 상태이므로 Suspense가 트리거 되지 않는다. 따라서 예상했던 에러도 발생하지 않는다. 이는 백그라운드 리페치 중 발생한 에러가 렌더링 과정에서 발생한 에러가 아니기 때문이다.
📌 Suspense는 렌더링 중 Promise가 던져지면 트리거 된다. 관련글 바로가기
렌더링과 에러 전달
같은 로직을 useQuery로 변경하면 에러를 즉시 감지할 수 있다. 이는 queryFn 호출 중 발생한 에러가 에러바운더리로 전달되기 때문이다. useQuery는 데이터 페칭 과정을 상태로 관리하며, 이 상태가 컴포넌트 렌더링과 연결된다.
반면, useSuspenseQuery는 데이터 페칭이 시작됨과 동시에 Suspense가 상태를 처리하며, 호출한 컴포넌트에서는 이러한 상태를 명시적으로 다루지 않아 렌더링 흐름에 반영되지 않는다.
1) useQuery를 사용하는 방법과 2) useSuspenseQuery를 유지하면서 명시적으로 에러 처리를 해주는 방법이 있는데 후자를 선택했고, 당장은 적용하지 않기로 결정했다.
프로젝트 전체적으로 useSuspenseQuery를 사용하고 있고, Suspense와 ErrorBoundary를 통해 에러를 처리하는 구조를 유지하고 있기 때문이다. 이를 무시하고 별다른 컨벤션 없이 useQuery로 변경하는 것은 일관성 면에서 적합하지 않다고 판단했다.
다만, 이를 코드에 반영하지 않은 이유는 에러를 놓치면서(?)라도 캐시 데이터를 보여주는 이점에 공감했기 때문이다.
캐시된 데이터는 네트워크 호출을 줄여 비용을 절감할 수 있지만, 데이터가 항상 최신 상태라는 보장이 없기 때문에 결국 다시 호출해야 하는 순간이 온다. 그러나 데이터가 변경되기 전까지는 마지막으로 캐싱된 데이터가 가장 최신 데이터라고 볼 수 있다. 이 데이터를 신뢰해 UI를 렌더링 하면 사용자가 보고 있던 화면이 갑자기 중단되는 문제와 화면 깜빡임을 방지할 수 있다는 장점이 있다.
해결하려는 문제 상황에서는 이미 성공적으로 스쿼드 목록을 불러와 화면에 표시되었고, 에러가 발생하지 않아 인증이 필요 없는 페이지에서는 자유롭게 이동할 수 있었다. 만약 방금까지 잘 표시되던 스쿼드 목록이 갑작스럽게 에러와 함께 사라진다면, 이는 사용자에게 불필요한 혼란을 줄 수 있다.
401 인증 에러가 발생한다면 서비스 내 인증이 필요한 기능을 이용하려 할 때 에러를 트리거하여 로그인을 유도하는 편이 더 나을 것이다. 이 방식은 사용자 이탈을 줄이는 데 효과적일 수 있다. 사용자는 필요한 서비스라면 문제를 해결하면서까지 이용하려고 하지만, 그렇지 않다면 쉽게 포기하고 서비스를 종료할 가능성이 높아지기 때문이다.
이전 글의 ErrorBoundary 항목에서 불필요한 화면 전환에 대해 간략히 소개했다. 실제 코드 예시와 다시 시도했던 과정을 작성해 본다.
{
path: '/squads',
element: (
<Suspense fallback={<Loading />}>
<SquadPage />
</Suspense>
),
}
프로젝트 내 ErrorBoundary는 최상위 App 컴포넌트에만 적용되어 앱의 렌더링 에러를 모두 처리하고 있다. 그리고 스쿼드 목록을 조회하는 SquadPage는 Suspense로 감싸 fallbackUI를 보여주도록 했다. 지금 구조에서는 어디에서 어떤 에러가 발생하더라도 에러바운더리에 설정된 FallbackComponent가 렌더링 되면서 화면이 전환된다.
다른 웹 서비스를 이용했던 경험을 떠올려보면, 화면 전환을 통해 에러를 안내받는 경우는 생각보다 드물다. 보통 버튼을 누르거나 페이지 이동이 이루어질 때, 토스트 메시지나 모달 창을 활용해 에러를 안내하고, 사용자가 추가 액션을 취할 수 있도록 현재 화면을 유지하는 방식을 많이 사용한다.
따라서, 서버 에러처럼 사용자가 해결할 수 없는 문제를 제외하고는, 화면 전환을 최소화하며 현재 화면을 유지하는 것이 더 나은 사용자 경험을 제공할 수 있을 것이라 판단했다.
화면 전환으로 인해 발생하는 또 다른 문제는 부분적인 에러 처리의 부재이다. 한 화면에 여러 컴포넌트를 렌더링하는 경우, 하나의 컴포넌트에서 에러가 발생하면 에러와 무관한 다른 컴포넌트들까지 모두 화면에서 사라져 버리는 상황이 발생할 수 있다.
예를 들어, 쇼핑몰에서 상품 목록은 정상적으로 렌더링되고 있지만, 사이드바의 최근 문의글에서 에러가 발생해 전체 화면이 사라지는 경우를 상상해 볼 수 있다. 이는 서비스에서 우선순위가 낮은 기능(최근 문의글) 때문에 우선순위가 높은 기능(상품 목록)의 제공이 중단되는 문제가 발생한다.
이러한 문제를 해결하기 위해 등장한 에러바운더리는 이름 그대로, 에러의 경계를 적절히 설정해 각 컴포넌트에 독립적으로 적용할 수 있다. 적절히 활용하면, 에러가 발생한 부분만 적절히 처리하고 나머지 서비스는 끊김 없이 유지할 수 있어 사용자 경험을 개선할 수 있다.
처음 시도했을 때는 FallbackComponent를 적용한다는 것 자체가 다른 UI를 렌더링 한다는 의미라고 생각해, 부분적으로 적용하거나 기존 화면을 유지하면서 모달을 띄우는 것이 어떻게 가능할지 의문이 들었다. 이는 ErrorBoundary도 하나의 컴포넌트라는 점과 그 사용 목적에 대한 이해가 부족했기 때문에 제대로 활용할 방법을 찾지 못했던 것이다.
기존 구조는 최상위 App 컴포넌트에 하나의 ErrorBoundary와 FallbackComponent를 설정해 공통적으로 에러를 처리하고 있었다. 예를 들어, 인증 에러가 발생하면 RootErrorFallback 컴포넌트가 렌더링 되어 401 응답 코드에 따라 "로그인이 필요해요"라는 메시지를 화면 전체에 표시했다. 이는 최상위 ErrorBoundary가 에러를 처리하므로 전체 서비스 화면이 해당 컴포넌트로 전환되는 구조였기 때문에 자연스러운 동작이었다.
그러나 에러가 발생하지 않은 컴포넌트에는 영향을 주지 않도록 하려면, 에러가 발생할 가능성이 있는 특정 컴포넌트를 별도의 ErrorBoundary로 감싸고, 해당 영역에만 별도의 FallbackComponent를 렌더링하도록 설정해야 한다. 이렇게 하면 에러가 발생한 영역만 대체 UI를 표시하고, 다른 컴포넌트들은 정상적으로 렌더링을 유지할 수 있다.
모달을 띄우는 방식도 동일한 원리를 적용할 수 있다. 사용 중인 모달 로직을 기준으로 에러가 발생한 컴포넌트를 대체하여, 에러를 처리하는 모달 컴포넌트를 렌더링하도록 하면 된다. 이렇게 하면 전체 화면을 전환하지 않고도 에러를 알리는 사용자 경험을 제공할 수 있다.
단순히 "왜 에러가 잡히지 않고 화면이 렌더링 되는지"라는 궁금증에서 시작했던 문제가, Suspense, ErrorBoundary, React Query에 대해 또 다시 공부하는 계기가 되었다. 이전에 에러 핸들링에 대한 글을 작성할 때는 분명 이해했다고 생각했지만, 실제로 관련된 문제를 해결하는 과정을 통해 머리로 이해하는 것과 직접 경험하는 것의 차이가 크다.
트러블슈팅을 정리하다 보면 결국 React Query로 돌아오는 상황을 마주하는데 관련 문제가 발생하면 해결에 오래 걸린다. 그 이유는 라이브러리가 React의 기본 기능과 어떻게 호환되고 동작하는지에 대한 이해가 부족했기 때문이라고 느낀다. React Query를 비롯한 외부 라이브러리는 React의 동작과 호환되도록 개발되었다. 단순히 사용법만 익히고, 그 내부의 동작 원리와 React의 기능 사이에서 어떤 관계를 맺고 있는지 파악하지 못하면, 예상치 못한 문제가 발생했을 때 해결이 더욱 어려워진다.
문제를 해결하고 정리하면서 반복적으로 떠오른 결론은 상황에 따라 더 적합한 방법이 있으며, 이를 판단하는 과정에는 트레이드 오프가 존재한다는 것이다. 이때 더 나은 판단을 하기 위해서는 React나 Next.js에서 어떻게 동작하고 연결되는지를 이해하는 것이 중요하다. 그리고 어느 정도 컨벤션을 정하여 사용하는 것도 개발 속도나 협업하는데 도움이 될 것이라 생각한다.
좋은 UX를 위한 보이지 않는 에러 다루기 (0) | 2024.12.16 |
---|---|
낙관적 업데이트, 좋아요 말고 어디에 쓸까? (1) | 2024.12.08 |
거절이 취미인 CORS, 이유가 있겠지 (0) | 2024.12.02 |
나만 빼고 다 하는 오픈소스 기여 맛보기 (2) | 2024.09.08 |
얕은 지식으로 시작하는 React 프로젝트 세팅 (0) | 2024.08.08 |