안녕하세요. IT 엘도라도 에 오신 것을 환영합니다.
글을 쓰는 것은 귀찮지만 다시 찾아보는 것은 더 귀찮습니다.
완전한 나만의 것으로 만들기 위해 지식을 차곡차곡 저장해 보아요.   포스팅 둘러보기 ▼

리액트 (React)

[React] react-router 동작 원리 간단히 알아보기

피그브라더 2020. 11. 14. 16:17

이번 포스팅에서는 React 웹 애플리케이션을 개발할 때 클라이언트 사이드 라우팅을 위해 많이 사용하는 패키지들의 동작 원리에 대해 간단히 한 번 알아볼 것이다. 대표적으로 가장 많이 사용하는 패키지는 react-router-dom이며, 이는 react-router 패키지에 의존하는 패키지로서 브라우저에서 클라이언트 사이드 라우팅을 수행할 수 있도록 해준다. 한편, 라우팅과 관련된 정보들을 Redux의 스토어에 저장하는 방식을 필요로 하는 경우 connected-react-router 패키지를 사용하기도 한다. 따라서 필자는 이 세 패키지의 핵심 요소들에 대해 그 동작 원리를 간단히 살펴보기로 했다. 동작 원리는 직접 해당 패키지의 JavaScript 코드를 뜯어보며 파악하였다. 혹시 잘못된 부분이 있다면 댓글로 지적 바란다.

 

* React의 기본 개념 및 Context API에 대한 이해를 전제로 한다. Context API의 공식 설명은 다음 링크를 참고하자.

 

Context – React

A JavaScript library for building user interfaces

ko.reactjs.org

 

1. 클라이언트 사이드 라우팅

클라이언트 사이드 라우팅이란 서버에게 별다른 요청을 보내지 않고 클라이언트의 브라우저 단에서만 여러 페이지들을 왔다 갔다 방문할 수 있는 기능을 말한다. 클라이언트 사이드 라우팅의 구현에 있어 가장 중요한 핵심 세 가지는 다음과 같다.

 

  • 현재 URL에 맞는 UI(즉, 컴포넌트)를 렌더링 할 수 있어야 한다.
  • 페이지의 리로드 없이 다른 페이지를 방문할 수 있는 내비게이션 기능이 있어야 한다.
  • 사용자의 액션(앞으로 가기, 뒤로 가기 등)에 의해 URL이 변경될 때 이를 감지하고 처리할 수 있어야 한다.

 

그렇다면 이제 react-router 패키지와 react-router-dom 패키지가 이러한 클라이언트 사이드 라우팅을 어떻게 구현하고 있는지 하나씩 차근차근 살펴보자. 그리고 나면 이러한 라우팅 관련 정보들을 Redux의 스토어에서 관리하기 위한 connected-react-router 패키지에 대해서도 간단히 한 번 살펴보자.

 

2. <BrowserRouter>, <Router> 컴포넌트

<BrowserRouter>는 React 웹 애플리케이션 개발 시 클라이언트 사이드 라우팅을 위해 라우팅 관련 컴포넌트들의 최상단에 위치시켜야 하는 컴포넌트로, react-router-dom 패키지에 속해 있다. 그런데 사실 이는 react-router 패키지에 속해 있는 <Router> 컴포넌트를 래핑 한 컴포넌트이다. <BrowserRouter> 컴포넌트는 <Router> 컴포넌트를 렌더링 할 때 props로 history 객체를 전달하는데, 이 객체는 history 패키지의 createBrowserHistory() 함수를 호출함으로써 생성된다. 해당 history 객체는 HTML5 history API 기반으로 브라우저에서 쉽게 내비게이션 기능을 구현할 수 있도록 각종 API를 제공하는 역할을 수행한다. history 패키지와 관련한 보다 자세한 내용은 이곳을 참고하자.

 

그렇다면 이제 <Router> 컴포넌트의 내부 구현을 한 번 살펴보자. <Router> 컴포넌트는 마운트 되는 순간에 props로 전달받은 history 객체의 프로퍼티인 location 객체를 자신의 지역 상태에 저장한다. 그리고 props로 전달받은 history 객체를 구독하여(history.listen 메소드) 브라우저의 현재 URL이 변경될 때마다 자신의 지역 상태에 해당하는 location 객체가 새로운 location 객체로 대체되도록 한다. 즉, 브라우저의 현재 URL에 관한 정보를 <Router> 컴포넌트가 지역 상태로서 실시간으로 추적하겠다는 의미인 것이다. 여기까지가 <Router> 컴포넌트 인스턴스가 생성될 때의 로직이다.

 

다음으로 <Router> 컴포넌트의 렌더링 로직을 살펴보자. <Router> 컴포넌트는 현재의 URL과 관련된 몇몇 정보들을 Context로 구성해서 해당 Context의 Provider 컴포넌트(이하 <RouterContext.Provider>)를 렌더링 한다. 이는 트리의 깊은 곳에 위치하는 각종 라우팅 관련 컴포넌트(<Switch>, <Route> 등)들이 어디서든 브라우저의 현재 URL과 관련된 정보들을 참조할 수 있도록 하기 위함이다. 이때 Context로 구성되는 정보로는 match 객체, location 객체, history 객체가 있다. location 객체와 history 객체는 앞서 언급한 것과 동일한 객체이며, match 객체는 앞서 언급한 location 객체의 정보를 바탕으로 현재 URL이 루트 URL('/')과 매치되는지 비교한 결과를 나타내는 객체이다. 참고로 이러한 비교 작업은 해당 <Router> 컴포넌트가 렌더링 될 때마다 이뤄지므로 match 객체는 매번 새로운 참조값으로 생성된다.

 

그렇다면 <Router> 컴포넌트의 자식 컴포넌트들은 브라우저의 현재 URL이 변경될 때마다 항상 리렌더링 되는 것일까? 지금까지 설명한 구조로 봐서는 이론적으로 그렇다. 그러나 코드를 뜯어본 결과, <RouterContext.Provider> 컴포넌트는 다시 또 다른 Context에 해당하는 Provider 컴포넌트(이하 <RouterHistoryContext.Provider>)를 렌더링 하도록 구현되어 있었다. 그리고 <RouterHistoryContext.Provider> 컴포넌트가 제공하는 Context는 앞서 언급한 history 객체 그 자체였다. 그런데 history 객체는 참조값이 변하지 않는 가변 객체이다. 따라서 리렌더링의 전파 흐름은 <RouterHistoryContext.Provider> 컴포넌트까지 전달되었다가 이 컴포넌트의 리렌더링이 이뤄지지 않으므로 여기서 전파 흐름이 끊기게 된다. 대신 <Router> 컴포넌트의 지역 상태(location 객체)가 변경되는 경우는 그러한 전파 흐름이 끊기더라도 RouterContext를 구독하는 각종 라우팅 관련 컴포넌트(<Switch>, <Route> 등)들은 리렌더링이 유발된다. 이는 Context API의 특징이다.

 

지금까지 설명한 것들을 그림으로 나타내면 다음과 같다. 그림을 보며 다시 한번 글을 읽으면 정리가 잘 될 것이다.

 

 

3. <Switch> 컴포넌트

<Switch>는 브라우저의 현재 URL과 매칭 되는 첫 번째 <Route> 자식 엘리먼트를 렌더링 하기 위한 컴포넌트로, react-router 패키지에 속해 있다. 이는 <RouterContext.Consumer> 컴포넌트를 렌더링함으로써 RouterContext를 참조한다. 이때 RouterContext의 location 객체 정보와 children props로 전달받은 각 자식 엘리먼트의 path props 정보를 하나씩 비교한다. 그리고 그 과정에서 첫 번째로 매칭 되는 <Route> 자식 엘리먼트를 렌더링 하게 되는 것이다. 이때 props로는 RouterContext의 location 객체와 매칭 정보를 담은 computedMatch 객체를 넘겨준다. 사실 <Switch> 컴포넌트는 필수가 아니기에, <Route> 컴포넌트는 이러한 props를 받을 수도 있고 안 받을 수도 있다는 말이 된다. 각 경우에 대해 <Route> 컴포넌트의 내부 구현이 어떻게 되는지에 대해서는 아래에서 살펴보자.

 

 

4. <Route> 컴포넌트

<Route>는 props로 전달받는 path의 값이 브라우저의 현재 URL과 매칭 될 때 특정 컴포넌트를 렌더링 하는 컴포넌트로, react-router 패키지에 속해 있다. 이는 <RouterContext.Consumer> 컴포넌트를 렌더링함으로써 RouterContext를 참조한다. 이때 RouterContext의 location 객체 정보와 props로 전달받은 path 값을 비교한다. 만약 매칭이 된다면 component props로 전달받은 컴포넌트를 렌더링 하고, 아니라면 null을 렌더링 한다. 매칭 되는 경우 props로는 RouterContext와 동일한 구성의 값들(match 객체, location 객체, history 객체)을 넘겨준다. 다만 이때 넘겨주는 match 객체는 RouterContext에 저장된 것이 아닌, 방금 매칭 작업을 수행하여 만들어낸 match 객체이다. 또한 만약 직속 상위에 <Switch> 컴포넌트가 존재하는 경우라면, props로 전달받는 computedMatch 객체가 이미 매칭 정보를 담고 있으므로 매칭 작업을 여기서 다시 수행하지 않으며 이 객체를 그대로 렌더링 할 엘리먼트에 match props로 넘겨주게 된다.

 

 

5. <Link> 컴포넌트

<Link>는 페이지의 깜빡임(리로드) 없이 내비게이션을 수행하기 위한 컴포넌트로, react-router-dom 패키지에 속해 있다. 결과적으로 말하자면 <a> 태그로 렌더링 되지만, 일반적인 <a> 태그와는 조금 다르게 동작하도록 구현이 된다. 먼저, preventDefault() 함수를 호출하여 <a> 태그의 기본 동작을 막는다. <a> 태그의 기본 동작은 href 어트리뷰트로 설정된 경로의 페이지를 리로드 하는 것이다. 이는 SPA에서 원하는 클라이언트 사이드 라우팅의 동작과 맞지 않는다. 다음으로, <a> 태그의 기본 동작을 막은 대신 클릭 시에 RouterContext에 존재하는 history 객체를 이용하여 내비게이션을 수행하도록 구현한다. 이를 위해서는 <Link> 컴포넌트도 <RouterContext.Consumer> 컴포넌트를 렌더링 하여 RouterContext를 참조해야 한다. 기본적으로는 클릭 시에 history.push()와 같은 함수를 이용해서 현재 URL을 바꾸도록 구현이 될 것이다. 이 이상의 자세한 내용까진 여기서 다루지 않겠다.

 

6. 동작 원리 요약

지금까지 설명한 각 컴포넌트들의 역할을 종합하여 동작 원리를 요약하자면 다음과 같다.

 

  1. 브라우저를 켜서 처음 서버에 접속하면 <Router> 컴포넌트의 지역 상태가 history.location 객체로 초기화된다.
  2. 이제 유저는 <Link> 컴포넌트에 의해 렌더링 된 <a> 태그를 클릭하거나 브라우저의 특정 액션(앞으로 가기, 뒤로 가기 등)을 수행함으로써 현재 URL을 바꿀 수 있다. → 페이지 리로드 없는 내비게이션
  3. 그러면 앞서 history 객체를 이용하여 설정해둔 구독 메커니즘에 의해 <Router> 컴포넌트의 지역 상태인 location 객체가 새로운 것으로 변경된다. → 현재 URL 관련 정보를 <Router> 컴포넌트의 지역 상태로 관리 (현재 URL 구독)
  4. 이로 인해 <Router> 컴포넌트가 리렌더링 되고, 그 결과 RouterContext의 값이 새로 구성되면서, 트리의 하위에 존재하는 각종 라우팅 관련 컴포넌트들이 리렌더링 된다. → 현재 URL에 맞는 UI 렌더링
    • <Switch> 컴포넌트는 현재 URL과 자식 엘리먼트들의 path props 값을 다시 매칭 해서 렌더링 할 엘리먼트를 다시 선택한다.
    • <Route> 컴포넌트는 현재 URL과 path props 값을 다시 매칭 해서 match 객체, location 객체, history 객체를 렌더링 할 컴포넌트에게 넘겨준다. 이때 match 객체와 location 객체는 history 객체와 달리 참조값이 다른 새로운 객체이다.

 

여기까지 이해했다면 스스로에게 박수를 쳐주자. React 웹 애플리케이션에서의 라우팅은 더 이상 마법과 같은 일이 아니게 되었다.

 

7. connected-react-router 패키지

React 웹 애플리케이션 개발자 중에서 Redux를 사용하는 분들의 경우 위와 같은 라우팅 기능을 사용할 때 약간 아쉬운 점이 있을 수 있다. <Router>에서 관리하는 지역 상태들이 Redux의 스토어에 존재한다면 어느 컴포넌트에서도 해당 정보들을 쉽게 참조할 수 있을 텐데 하는 아쉬움 때문에 말이다. 그렇다면 Redux와 라우팅 기능을 함께 사용하는 것도 가능은 한 걸까? 물론이다. Redux와 라우팅 기능을 연동하기 위해서는 다음과 같은 조건들이 충족되어야 한다.

 

  • 브라우저가 열리면 해당 URL을 Redux 스토어의 초기 상태로 설정한다.
  • 유저가 특정 액션(버튼을 클릭하거나 브라우저의 뒤로 가기 기능을 사용하는 등)을 취하여 현재 URL을 바꾸면 Redux 스토어의 상태도 함께 바꿔준다. → One-way Binding
  • 반대로, 액션의 디스패치에 의해 Redux 스토어의 상태가 바뀌면 브라우저의 현재 URL도 함께 바꿔준다. → One-way Binding

 

짧게 요약하자면 Two-way Binding, 즉 양방향 바인딩이다. Redux 스토어의 상태가 바뀌면 브라우저의 현재 URL도 바꿔주고, 브라우저의 현재 URL이 바뀌면 Redux 스토어의 상태도 바꿔주는 것이다. 이렇게 둘을 연동하면 둘이 언제나 같은 값을 가지고 있음이 보장된다. 또한 이제 현재 URL 정보가 Redux 스토어 내에서 관리되므로 Redux가 제공하는 시간 여행 기능도 마찬가지로 사용할 수 있는 이점이 생긴다.

 

connected-react-router가 이러한 맥락에서 등장한 패키지이다. 공식 문서에 따르면, 이는 v4 혹은 v5 버전의 react-router 패키지를 위한 Redux 바인딩 패키지이다. 쉽게 말해서 <Router> 컴포넌트의 상태와 Redux 스토어의 상태를 동기화하여, 언제나 그 둘이 같은 값을 표현하고 있도록 한 것이다. <Router> 컴포넌트의 상태를 Redux 스토어의 상태로 '끌어올리는' 것이 아님에 주의하자. 이 패키지는 react-router 패키지를 그대로 사용하기 때문에 <Router> 컴포넌트는 여전히 자신의 지역 상태를 가지고 있다.

 

connected-react-router 패키지를 사용하려면 라우팅 관련 컴포넌트들의 최상단에 <ConnectedRouter> 컴포넌트를 위치시켜야 한다. 그러면 먼저 <ConnectRouter> 컴포넌트의 내부 구현에 관한 핵심부터 알아보자. 다음과 같이 세 가지이다.

 

  • 여러 층의 컴포넌트가 react-router 패키지의 <Router> 컴포넌트를 래핑
  • props로 받은 history 객체를 구독 : 현재 URL이 변할 때 Redux 스토어의 상태도 바꾼다. → dispatch({ location, action })
  • Redux의 Context로부터 읽은 스토어 객체를 구독 : Redux 스토어의 상태가 변할 때 현재 URL도 바꾼다. → history.push({ pathname, search, hash, state })

 

※ react-router-dom 패키지의 <BrowserRouter> 컴포넌트와는 달리, 직접 history 패키지의 createBrowserHistory() 함수를 통해 history 객체를 생성하여 props로 넘겨줘야 한다. connected-react-router는 브라우저 DOM 환경만을 한정한 패키지가 아니기 때문이다.

※ Redux의 Provider 컴포넌트가 제공하는 Context는 { store, subscription } 구조이다.

 

다음으로, Redux 스토어의 상태 구조도 한 번 알아보자. 이 패키지를 사용하는 경우 Redux 스토어 상태의 router 프로퍼티는 location과 action으로 이뤄진 객체를 가리키게 된다. 이 둘은 history 객체의 프로퍼티로서 존재하는 것들이다. 따라서 이 둘을 초기화해주려면 리듀서 생성 시 history 객체를 넘겨주도록 설정해줘야 한다. 한편, location 객체는 pathname, search, hash, state 등의 프로퍼티를 가진다. 이 값들은 Redux 스토어의 상태를 현재 URL에 연동시킬 때 그 둘이 같은지 따지기 위해 살피는 값들에 해당한다. 만약 같다면 Redux 스토어의 상태가 바뀌었더라도 history.push() 함수를 굳이 호출하지 않을 것이다.

 

connected-react-router 패키지는 기본적으로 react-router 패키지를 그대로 가져다 사용하되, Redux와 관련한 몇 가지 구독 메커니즘만 추가해준 것이다. 그래서 개념적으로만 설명해도 충분하다 판단하였다. 혹시 더 자세히 궁금해지는 게 있다면 직접 패키지의 JavaScript 코드를 한 번 뜯어보기 바란다. 필자의 경험으로는 코드를 뜯어보는 것만으로도 많은 공부가 된다. 그러면 도움이 되었길 바란다.

 

 

 

 

 

 

* connected-react-router 라이브러리의 동작 원리를 설명함에 있어 다음 링크의 내용을 참고하였습니다.

https://read.reduxbook.com/markdown/part2/09-routing.html

https://github.com/supasate/connected-react-router