어느날 갑자기 되던게 안된다. - REACT Router; 조건에 따른 createBrowserRouter

February 28, 2025

이게 왜 안되지?

it doesn't work.. why?
한번 쯤은 공감했을 '이게 왜 되지?' 혹은 '이게 왜 안되지?' 로 글을 시작해보려합니다.
Why doesn't it work... then, why did it work?

사내에서, React Router v6를 사용하여 인증 상태에 따라 라우팅을 다르게 처리하는 시스템을 구현했고, 이 코드가 1년 동안 잘 작동했지만 갑자기 문제를 일으키기 시작했습니다.
로그인 이후 토큰이 있음에도 불구하고 로그인 화면이 그대로 보이거나, 로그아웃을 했는데 홈 화면이 계속 표시되는 등의 이상한 현상이 발생했죠 🥹
이 글에서는 React Router v6에서 인증 상태에 따른 라우팅을 구현하면서 겪었던 문제와 그 해결 과정을 공유하고자 합니다.

글의 코드는 원본코드가 아닌, 최대한 비슷하게 재현한 코드들입니다.

이슈 상황

인증 상태는 맞는데 화면이 안 바뀐다?

React Router v6를 사용해 토큰 기반 인증 시스템을 구현하면서 이상한 현상이 발생했습니다. 코드 상으로는 모든 것이 정상적으로 작동하는 것처럼 보였지만, 실제 화면은 이를 반영하지 않았습니다.

기존 코드

application의 상태(token, role)에 따라 전체 경로를 동적으로 결정할 수 있게끔 작성되어있었습니다.

tsx
export default function Navigation() { const { token } = useContext(AppContext) const { role } = useRole() // 권한,역할을 담은 정보 - 비동기 데이터를 가져와 가공하는 hook const router = useMemo(() => { const routes: RouteObject[] = [] if (!token) { routes.push({ path: '/*', element: <LoginView /> }) } else if (role) { routes.push({ path: '/*', element: <HomeView /> }) } else { routes.push({ path: '/*', element: <NotFoundView /> }) } return createBrowserRouter([ { id: 'root', element: <BaseLayout />, children: routes }, ]) }, [token, role]) return <RouterProvider router={router} /> }

상세 상황

이슈가 발현되었던 구체적인 상황은 다음과 같았습니다:

  • token과 role 관련 상태는 정상적으로 업데이트됨
  • Router 객체의 와일드카드 경로 element도 조건에 맞게 정상적으로 변경됨 (로그 확인)
  • 다른 특정 메뉴나 경로 이동은 정상 작동
  • 그러나 홈 화면(와일드카드 경로)에서는 인증 상태와 맞지 않는 화면이 표시됨
  • 로그인 후에도 로그인 화면/ Empty View가 보임
  • 로그아웃 후에도 홈 화면이 보이는 경우도 있음
  • 타 메뉴 이동후, 모든 것이 정상적으로 작동함

이슈 원인 추측과 분석

비동기 상태 업데이트로 인한 Race Condition

  • useRole hook은 token을 기반으로 역할 및 권한 데이터를 비동기적으로 가져와 상태를 업데이트합니다. 하지만 useMemo는 해당 상태가 아직 설정되기 전에 실행될 수 있으며, 이로 인해 router 객체가 초기 상태(role === undefined)를 기준으로 생성될 수 있습니다. 이는 비동기 작업과 렌더링 사이에 경쟁 조건(race condition)을 발생시킬 수 있습니다.

Stale Closure로 인한 오래된 데이터 참조

  • useMemo는 의존성 배열([token, role])을 기반으로 캐싱된 router를 반환합니다. 하지만 role이 비동기적으로 업데이트될 때, useMemo가 참조하는 클로저(Closure)는 여전히 오래된 상태를 캡처하고 있을 수 있습니다.
  • 이로 인해 라우터가 최신 상태를 반영하지 못하고, 초기 렌더링에서 잘못된 경로(EmptyView)를 표시했을 수 있습니다.
  • 결과적으로, router가 초기에 EmptyView를 포함한 상태로 생성되고, 이후 roleAuthMap이 설정되어도 캐싱된 router가 갱신되지 않아 LoginView나 잘못된 화면이 표시되었을 수 있습니다.

RouterProvider의 내부 상태 갱신 문제

  • useMemo를 통해 새로운 라우터 객체가 생성되더라도, RouterProvider가 이 변경을 완전히 인식하지 못하는 경우가 있을 수 있습니다.
  • 따라서, 라우터 설정은 로그상으로는 올바르게 변경되었지만, 실제 라우팅 동작은 이전 상태를 유지하는 현상이 발생할 수 있습니다.

이슈 해결방법 및 평가

해결방법 1 - key prop 을 추가하여 강제 렌더링

  • key prop을 사용하여 RouterProvider의 부모 컴포넌트를 강제로 다시 렌더링하면, RouterProvider는 새로운 router 객체를 사용하여 라우팅을 업데이트합니다. 즉, key prop은 RouterProvider의 내부 상태 갱신 문제를 해결하는 역할을 합니다.
tsx
useEffect(() => { if (!token) { setKey('noToken') } setKey(role ? 'auth' : 'noAuth') }, [token, role]) // key prop을 추가하여 token이나 role이 변경될 때마다 RouterProvider를 강제로 다시 렌더링 return <RouterProvider key={key} router={router} />

해결방법 1 - 평가

  • key 값이 너무 자주 변경되면 불필요한 렌더링이 발생하여 성능 저하를 일으킬 수 있습니다.
  • key prop을 추가하고 관리하는 과정에서 코드 복잡성이 증가할 수 있습니다.

해결 방법 2 - loader 함수를 이용한 조건부 라우팅 (실패 사례)

  • loader 함수를 사용하여 각 경로에 접근하기 전에 인증 상태와 권한을 확인하고, 조건에 따라 리다이렉트를 수행하도록 구현했습니다.
  • /login 경로에 접근할 때, token과 role이 존재하면 / 경로로 리다이렉트합니다.
  • / 경로에 접근할 때, token이 존재하지 않으면 /login 경로로 리다이렉트합니다.
  • role의 존재 여부에 따라 authorizedRoutes 또는 EmptyView를 렌더링합니다.
tsx
{ index: true, loader: async () => { if (!token) { return redirect('/login'); } return null; }, element: <HomeView /> }, { path: 'login', loader: async () => { if (token) { return redirect('/'); } return null; }, element: <LoginView /> },

해결방법 2 - 평가

  • loader 함수 간의 순환 참조 (Cycle)
    • /login 경로의 loader 함수는 token과 role이 존재하면 / 경로로 리다이렉트하고, / 경로의 loader 함수는 token이 존재하지 않으면 /login 경로로 리다이렉트합니다.
    • 이로 인해 token과 role의 상태에 따라 두 loader 함수가 서로를 호출하는 순환 참조가 발생합니다.
    • 특히, 뒤로 가기 버튼을 누르면 브라우저는 이전 URL을 로드하려고 시도하고, 이 과정에서 loader 함수들이 다시 실행되면서 순환 참조가 반복됩니다.
    • 결과적으로, 뒤로 가기를 시도할 때마다 항상 홈 화면이 표시되는 문제가 발생합니다

해결 방법 3 - PublicOnlyRoute 및 ProtectedRoute 컴포넌트를 사용하여 조건부 라우팅 처리

  • 인증 여부와 권한에 따라 라우팅을 제어하는 로직을 PublicOnlyRouteProtectedRoute 컴포넌트로 분리하여 코드의 재사용성과 가독성을 높였습니다.
  • PublicOnlyRoute는 로그인하지 않은 사용자만 접근할 수 있는 경로를 보호하고, ProtectedRoute는 로그인한 사용자만 접근할 수 있는 경로를 보호합니다.
  • Outlet 컴포넌트를 활용하여 중첩된 라우팅 구조를 효율적으로 관리합니다.
tsx
function PublicOnlyRoute({ children }: PropsWithChildren) { const { token } = useContext(AppContext) const { role } = useRole() const [searchParams] = useSearchParams() const redirect = searchParams.get('redirect') if (token && roleAuthMap) { return <Navigate to={redirect || '/'} replace /> } return children } function ProtectedRoute({ children }: PropsWithChildren) { const { token } = useContext(AppContext) const { role } = useRole() const location = useLocation() if (!token) { // ... 내부 로직 return <Navigate to={`/login?redirect=${encodedRedirect}`} replace /> } if (!role) { return <EmptyView /> } return children }

사용은 이렇게 하였습니다.

tsx
const publicRoutes: RouteObject[] = [ ..., { path: 'login', element: ( <PublicOnlyRoute> <LoginView /> </PublicOnlyRoute> ), }, ];

권한이 있어야 접속이 가능한 routes 들도 정의를 합니다.

tsx
const authorizedRoutes: RouteObject[] = [...aRoutes, ...bRoutes, ...cRtoues]

또한, 권한이 있어야 접속이 가능한 routes들은 아래처럼 outlet을 사용하였습니다.
최종적으로 createBrowserRouter 부분을 보면 다음과 같습니다.

tsx
const router = createBrowserRouter([ { id: 'root', element: <BaseLayout />, children: [ ...publicRoutes, { path: '/', element: ( <ProtectedRoute> <Outlet /> </ProtectedRoute> ), children: [ // authorizedRoutes 추가 ], }, ], }, ])

해결방법 3 - 평가

  • PublicOnlyRouteProtectedRoute 컴포넌트를 사용하여 인증 여부와 권한에 따라 라우팅을 제어함으로써, key prop을 사용하여 컴포넌트를 강제로 다시 렌더링할 필요가 없어졌습니다.
  • 불필요한 렌더링을 방지하고 성능을 향상시킬 수 있습니다.
  • 라우팅 로직을 재사용 가능한 컴포넌트로 분리하여 코드 유지보수가 용이합니다.

해결방법 선택과 이유

앞서 언급한 여러 해결 방법 중에서 PublicOnlyRouteProtectedRoute 컴포넌트를 사용하여 조건부 라우팅을 처리하는 방법(해결 방법 3)을 최종적으로 선택했습니다. 그 이유는 다음과 같습니다.

React Router v6의 Maintainer @brophdawg11 는 다음과 같이 언급합니다.

"The important thing to remember is that the main idea behind the Data APIs brought over from Remix is to decouple routing/data fetching from rendering." (reference)

즉, React Router v6는 렌더링 시점의 정보가 라우팅 트리에 영향을 미치지 않도록 설계되었습니다. 이는 라우팅과 데이터 패칭을 렌더링과 분리하여 예측 가능하고 효율적인 애플리케이션 동작을 가능하게 하기 위함입니다.

따라서 기능 플래그 기반의 조건부 라우팅을 구현할 때, 라우트를 동적으로 정의하는 방식은 React Router v6의 설계 철학에 어긋납니다. 대신, 접근 권한을 조건부로 허용해야 합니다. 이는 loader 함수를 사용하여 기능 플래그를 확인하고, 조건에 따라 접근을 허용하거나 리다이렉션을 수행하는 방식으로 구현할 수 있습니다.

하지만 loader 함수를 사용하는 대신 컴포넌트 수준에서 접근 권한을 제어하는 방식이 코드 분리, 유연성, 테스트 용이성 측면에서 더 효과적이라고 판단했습니다. PublicOnlyRouteProtectedRoute 컴포넌트를 사용하여 라우팅 로직과 접근 제어 로직을 분리함으로써 코드의 가독성과 유지보수성을 높일 수 있었습니다.

결론: 지금까지 잘 작동했던 코드가 갑자기 문제를 일으키는 순간

개발을 하다 보면 가장 당혹스러운 순간 중 하나는 오랫동안 안정적으로 작동하던 코드가 어느 날 갑자기 문제를 일으키기 시작하는 때입니다. (물론 안되어야하는게 될때는 등골이 싸늘해지기도 합니다만..🙄)
이 경우도 마찬가지였습니다. 1년 동안 아무 문제 없이 작동하던 인증 라우팅 시스템이 갑자기 이상하게 동작하기 시작했습니다. (정말 바뀐거라곤, eslint 의 마이너 버전을 올린것과, json의 형식을 config.js로 바꾼것 밖에 없긴 합니다만..)

돌이켜 보면, 초기 구현 방식에는 처음부터 몇 가지 잠재적인 문제가 있었습니다. useRole hook이 비동기적으로 역할 정보를 가져올 때, 그 완료시점과 React의 렌더링 사이클 사이에 미묘한 타이밍 이슈가 있었던 것입니다.

특히, React Router v6의 핵심 원칙인 "라우팅/데이터 패칭과 렌더링의 분리"를 위반하고 있었습니다. 초기 코드에서는 렌더링 시점에 token과 role 상태에 따라 라우팅 트리를 동적으로 생성했습니다. 이는 렌더링 시점의 정보(hooks를 통해 가져온 상태)가 라우팅 트리에 직접적인 영향을 미치도록 하여, React Router v6가 추구하는 설계 철학에 어긋나는 방식이었습니다.

성장: 보다 견고한 해결책을 향해

이러한 분석을 바탕으로, 최종 해결책인 PublicOnlyRouteProtectedRoute 컴포넌트 접근 방식이 더 견고한 이유를 이해할 수 있습니다:

  • 명확한 책임 분리: 라우팅 결정 로직을 전용 컴포넌트로 분리함으로써, 복잡성을 관리하고 각 컴포넌트의 역할을 명확히 했습니다.

  • 선언적 접근 방식: Navigate 컴포넌트를 사용하여 리다이렉션을 처리함으로써, 명령형 코드 대신 React의 선언적 패러다임을 따랐습니다.

  • 중첩 라우팅의 활용: Outlet을 사용한 중첩 라우팅 구조는 더 예측 가능한 라우팅 동작을 제공합니다.

이러한 접근 방식은 비동기 상태 업데이트와 관련된 타이밍 이슈를 더 잘 처리할 수 있으며, React Router의 내부 동작 방식과도 더 잘 조화된다고 생각합니다.

교훈: 잠재적 문제를 인식하고 선제적으로 대응하기

이번 경험을 통해 "잘 작동하는 코드"가 "완벽한 코드"를 의미하지 않음을 다시금 깨달았습니다. 특히 비동기 처리, 상태 관리, 라우팅과 같이 복잡한 영역에서는 예측하기 어려운 문제가 발생할 수 있습니다.

코드가 "왜" 작동하는지 깊이 이해하려는 노력을 통해 잠재적인 문제를 미리 발견하고, 견고한 아키텍처를 구축할 수 있습니다.

이번 문제 해결은 단순한 버그 수정을 넘어, 애플리케이션의 핵심을 재검토하고 개선하는 기회였습니다. 그 결과, 더욱 안정적이고 유지보수하기 쉬운 코드를 얻을 수 있었습니다.



📚 Reference

틀린 부분이 있다거나, 더 좋은 접근방법/solution 이 있다면 아래 댓글 (GitHub Discussions) 을 많이 활용해주세요.

frontend
  • GitHub
  • Linkedin

© 2025 miori