Epic React - React Server Component (4)

김동하·2025년 5월 17일
0

react

목록 보기
23/24
post-thumbnail

Client Router에 대해

1. Client Router

이제 서버 컴포넌트와 클라이언트 컴포넌트를 모두 사용할 수 있는 그럴듯한 앱이 되었다. 하지만 잊으면 안 된다. 이 앱은 아주 원시적인 앱이라는 것을.

목록에서 아이템을 클릭하는 것도 <a> 태그고

검색을 통해 새로운 목록을 받아오는 것도 <form> 태그다.

즉, 데이터 페칭이 일어날 때마다 완전히 새로운 UI를 다시 받아와 전체 페이지가 새로고침 된다. 이것은 좋은 유저 경험이 아니다.

게다가 pushStatepopState도 적용되어 있지 않다.

이제 우리는 클라이언트 컴포넌트를 사용할 수 있으니, 서버에서 모든 HTML을 다시 받아오는 전통적인 방식에서 벗어나 RSCs 기반의 SPA처럼 동작하도록 클라이언트 라우팅을 추가해보도록 하자.

1.1 /client/index.js

먼저, navigate 함수를 만들어보자.

이렇게 item을 클릭하면 item.id가 path param으로 붙는다. path param의 상태를 관리하여 변경이 일어날 때마다 새롭게 데이터 패칭을 해주면 된다.

path param에 대한 상태와 서버로 부터 받은 UI 상태를 관리할 useState를 만든다.

navigate 함수는 path param을 받아서 새로운 payload를 호출하고, payload를 받아서 ReactElement로 변환한다.

아직 별다른 것은 없는데, navigate가 전역에서 쓰일 수 있도록 RouterContext가 필요하다.

라우터 관련 모듈이 위치할 router.js을 만들고 context를 생성한다.

그리고 root react element를 감싸주자.

자, 여기까지하면 라우팅에 대한 기본적인 세팅이 완성이다. 이제 <a>를 클릭 시 브라우저의 기본 동작인 페이지 전체 새로 고침을 방지하고, navigate로 작동하게 해야한다.

1.2 /client/router.js

DOM에 document.addEventListener('click', onClick)를 추가할
useLinkHandler를 만들어보자.

navigate가 가능한 경우만 조건을 준 뒤 preventDafult 후 만들어둔 navigate를 실행하게 된다.

이제 앱을 실행시켜보면 새로고침 없이 데이터를 받아오는 걸 확인할수 있다. (브라우저의 새로고침이 작동하지 않는다!)

여기서 더 수정을 하자면 먼저 요소를 클릭할 때마다 보이는 fallback 이미지가 조금 거슬린다. 그리고 replaceStatepushState가 없어서 목록에 있는 아이템을 클릭해서 path param이 변경되어도 브라우저 URL는 변경되지 않는 것이다.

1.2.1 startTransition

먼저 반짝거리는 fallback 이미지를 수정하자. 이건 간단하게 해결 가능하다. 즉시 리렌더링을 하지 않고, 이전 UI를 유지한 상태에서 새 데이터를 준비하는 것이다. startTransition으로 해결할 수 있다.

setContentPromisestartTransition 내에서 실행되면서 우선순위가 낮아지고, React는 이전 UI를 유지한다. 동시에 백그라운드에서 새 contentPromise 데이터 준비하다가 resovle되면 한꺼번에 UI가 변경된다.

index.jsnavigate 안에 위치한 setContentPromise를 수정하고 앱을 다시 실행시켜보면

Suspense에 걸리지 않고 부드럽게 데이터 페칭이 잘 이루어지는 것을 확인할 수 있다.

1.2.2 replaceState, pushState

이제 replaceStatepushState를 사용해서 클라이언트 내비게이션과 URL 동기화를 해야 한다.

URL도 잘 바뀌는 것을 확인했다!

2. Pending UI

클라이언트 라우팅도 어느정도 완성되었으니, 사용자 경험에 대해 생각해보자. 앱은 문제 없이 잘 작동한다. 하지만, 네트워크 지연이 발생한 환경에서도 동일한 사용자 경험을 줄 수 있을까? 느린 네트워크에서의 대응은 언제나 중요하다!

2.1 아직은 낯선 useTransition과 useDeferredValue

사용자가 검색을 했을 때 리스트를 조회하는 API에 지연이 발생했다고 가정하자. delay가 3초가 생겼다.

input은 입력되었지만 네트워크 지연으로 서버 컴포넌트가 느리게 렌더링 됨에 따라 사용자는 3-4초동안 아무런 상호작용도 느끼지 못하게 된다.

이번엔 아이템을 클릭해서 디테일 화면으로 넘어가보자.

디테일 컴포넌트도 마찬가지로 클릭 이후 3-4초동안 반응이 없다! 이는 사용자에게 앱이 멈췄다는 인상을 줄 수 있다. 느린 네트워크 환경에서도 앱이 전환중이라는 느낌을 줘야하는 것이다.

단순히 데이터 페칭이 느린 것이니까, 로딩 스피너를 보여주면 되지 않냐라고 할 수 있겠지만 간단한 문제가 아니다.

두 컴포넌트 모두 서버에서 렌더링 되는 서버 컴포넌트다. 서버 컴포넌트이기 때문에 서버 상태에 따라 UI를 다르게 렌더링할 수 없다.

그래서 여기서 등장한 것이 useTransitionuseDeferredValue다. RouterContext에서 path param을 관리하고 있으니, 현재 검색어와 다음 검색어(data fetching 중인)를 비교하여 Pending UI를 적절하게 보여주면 될 것 같다.

먼저, 디테일 컴포넌트(SearchDetail)를 수정해보자

2.2 /client/serach-detail-pending.js

말했던 것처럼SearchDetail은 서버 컴포넌트다. RouterContext를 사용할 수 없다. 그렇다면 SearchDetail props로 받는 클라이언트 컴포넌트를 만든 뒤, 그곳에서 펜딩 작업을 하면 될 거 같다.

serachDetailPending 이라는 클라이언트 컴포넌트를 만든다.

이제 children으로 SearchDetail를 받으면 된다.

이제 우리는 클라이언트 훅을 사용할 수 있다. RouterContext를 사용하고 있는 useRouter를 호출하자.

우리에게 필요한 건 현재 검색어와 다음 검색어(data fetching 중인)이다. RouterContext에 provide하고 있는index.js 에서 변수명을 수정해보자

locationnextLocation으로 변경하고 location은 어디서 얻느냐? 바로 useDeferredValue다.

useDeferredValue는 특정 값의 업데이트 우선순위를 낮춘다. 즉, nextLocation은 즉각적으로 업데이트 되지만 location은 트랜지션이 완료되어야 업데이트 된다.

이 두 값을 비교하여 Pending UI를 보여주면 된다.

다시 SerachDetailPending으로 가서,

현재와 다음을 비교하여 다르다면 아직 트랜지션 중인 것이니 pending을 준다. (트랜지션 중이면 Opacity로 처리함)

이제 마지막으로 App.js가서 수정하면 완료. 그럼 Pendin UI가 잘 나오나 확인해보자.

트랜지션 중에는 Pending을 보여주고 트랜지션이 완료되기 전까지 이전 UI를 유지하다 트랜지션이 종료되면 새로운 UI로 렌더링되는 것을 확인할 수 있다!

다음으로 검색 컴포넌트도 동일하게 펜딩을 적용해보면

느린 네트워크에서도 화면 전환중임을 정확하게 표현하고 있다!

3. Browser Cache

아직 끝나지 않았다. 이번엔 브라우저 뒤로가기/앞으로가기에 대한 이슈다

우리 앱은 아이템을 선택에 따라 URL이 바뀌지만 뒤로가기/앞으로가기를 했을 때는 반대로 렌더링이 되지 않는다. 한번 기능을 추가해보자.

3.1 /client/index.js

간단하게 window api인 popState로 구현 가능하다.

index.js 에서 useEffect를 추가한다. handlePopState의 내부 로직은 서버 컴포넌트 렌더링 로직과 동일하다. 이제 앱을 확인해보자

브라우저 히스토리에 따라 앱이 잘 렌더링 되는 것을 확인했다!

그런데 뭔가 다른 점이 느껴지는데, 뒤로가기/앞으로가기를 하게 되면 전체 페이지가 새로고침되는 것이다.

전체 페이지가 새로고침되면서 전체 UI를 다시 받아와서 fallback이 매번 뜬다.

분명 startTransition으로 상태 업데이트를 지연시켰는데도 왜 이런 문제가 발생하는 걸까? 원인은 브라우저 캐시에 있다.

3.2 cache me if you can

일반적인 SPA에선 브라우저가 앞뒤로 페이지 이동 시 페이지의 내용, DOM 상태, 스크롤 위치, 입력값 등등을 캐시한다.

하지만, 우리의 예제는 pushStatereplaceState로 URL 업데이트를 제어하고 popstate로 앞뒤 브라우저 이동을 제어하기 때문에 popstate 이벤트 발생할 때마다 리액트는 새로운 서스펜스를 만들어야 한다고 판단하여 fallback UI와 함께 페이지가 새로고침 되는 것처럼 보인다.

그래서 브라우저 캐시를 흉내내기 위해서는 자체적으로 페이지를 캐싱해야 한다!

먼저 캐시를 관리한 useContentCache를 만든다.

useContentCacheuseSyncExternalStore를 반환하는 훅이다. useSyncExternalStore은 간단하게 말해 리액트와 의존성이 없는 외부 스토어의 값을 감지하여 렌더링을 할 수 있게 하게 한다. 여기서는 {key, page content}를 관리하는 Map을 구독하여 렌더링과 연결할 것이다.

이제 contentCache를 구현하자.

contentCacheObservableMap의 인스턴스로, ObservableMapMap을 커스텀하면서 내부적으로 listeners를 가진다.

인자로 listener(여기서는 렌더링 트리거)를 받는 subscribe를 만들고

set, delete 는 페이지 변경이 있을 때마다 추가, 삭제 하면서 emitChange를 호출한다.

그리고 emitChangelisteners를 순회하며 등록된 렌더링 트리거를 호출하게 되고, 리렌더링이 발생하게 된다.

그럼, UI를 받아오는 index.js를 수정하면 된다.

3.3 /client/index.js

처음 진입했을 때는 key가 없을 것이다. key를 만들고 contentCache에 등록하자.

브라우저 히스토리의 state에 key가 있는지 보고 없으면 replaceState로 key를 등록한다.

브라우저 히스토리에 잘 저장되었다.

그리고 그 key와 초기 UI를 contentCache에 추가해주면 초기 설정은 완료다. 이제 브라우저 히스토리 이벤트가 발생했을 때, 캐시를 교체하자

3.3.1 popState할 때

현재 popState 이벤트 로직이다. popState가 일어날 때마다 새로운 nextLocation(path param)을 가져와 data fetching으로 UI를 가져온다.

이제 window.history.state?.key에 해당하는 UI가 있는지를 확인할 것이다.

키가 없다면 새로 페칭을 하고 캐시에 저장한다.

그리고 키를 상태 관리한다.

root UI는 contentCache에서 관리하니까 더 이상 contentPromise를 상태 관리하지 않아도 된다. 과감하게 제거하고

캐시에 있는 UI를 렌더링하면 된다.

3.3.2 replaceState, pushState할 때

현재 navigate 로직이다. replaceState, pushState할 때 현재 페이지의 키를 브라우저 히스토리에 추가해야한다.

크게 달라지는 것은 없고 key를 새로 생성하여 브라우저 state에 추가한다.

popState와 마찬가지로 cache와 key를 업데이트하면 끝이다.

뒤로가기/앞으로가기를 해도 전체 페이지 새로고침없이 기존의 캐시된 UI를 사용하는 것을 확인할 수 있다!

4. 정리

이로써 SPA 기반 RSC를 완성했다. 서버 컴포넌트와 클라이언트 컴포넌트의 조합, 클라이언트 라우팅.. 갈수록 리액트가 UI에 관해 디테일해진다는 느낌을 받았다. 어디까지 갈 건가 리액트..

참고 : epic-react

profile
프론트엔드 개발

0개의 댓글