Epic React - React Server Component (5)

김동하·2025년 5월 19일
0

react

목록 보기
24/24
post-thumbnail

Server Actions에 대해

이제 드디어 RSC의 마지막 파트다.

지금까지 우리는 RSC를 통해 서버에서 컴포넌트를 렌더링하는 방식, use Client로 클라이언트 컴포넌트를 참조로 렌더링 하는 방식 그리고 클라이언트 사이드 라우팅까지 끝냈다. 대략적인 현대의 앱 모습은 갖췄는데, 하나가 빠졌다. 바로 form이다.

form으로 사용자와 앱이 상호작용을 해야 진정한 현대의 앱이라고 할 수 있다.

2편에서 추가해 놓았던 EditableText 컴포넌트를 이제야 사용한다. EditableTextSearchDetail의 우주선 데이터를 편집할 수 있는 클라이언트 컴포넌트다.

EditableText에서 우주선의 이름을 편집하고 form으로 제출했을 때, mutation이 발생해야 한다.

여기서 살짝 난관에 부딪히게 되는데, EditableTextSearchDetail의 데이터를 수정하게 되면 좌측의 SearchResult의 목록도 업데이트가 되어야 한다.

RCS 이전의 페칭 후 업데이트 방식을 생각해보면, react-query의 경우 EditableText에서 페칭 이후 invalidateQueries로 동기화를 해줬다. 데이터와 UI의 동기화의 책임을 클라이언트가 가졌던 것이다.

반대로 RCS는 서버 컴포넌트의 렌더링, 즉 데이터와 UI 동기화는 서버에서 맡고 있다.

그럼, 클라이언트 컴포넌트에서 발생한 데이터 mutation을 어떻게 서버에서 알아차리고 새로운 UI로 렌더링할 수 있을까? 그것이 바로 Server Action이다.

1. Actions Reference

서버 액션은 서버 함수의 한 종류이다.

동작 방식은 클라이언트 컴포넌트를 렌더링 하는 방식과 유사한데, RSC 페이로드 안에 어떤 함수를 호출할지에 대한 참조를 추가하고, form이 제출되면 서버가 그 함수에 대한 참조를 읽어서 해당 함수를 실행하는 것이다.

전통적인 form이 action 속성이 가리키는 URL로 함수를 실행시켰다면, 서버 액션은 참조를 활용한다.

자, 그럼 예제를 수정하면서 서버 액션에 대해 알아보자.

1.1 /client/ediable-text.js

먼저 EditableText에서 form을 수정해서 서버 액션과 연결시켜야 한다.

form에서 action 속성에 참조로 지정할 서버 액션을 넣는 것이다. 이를 관리하는 것이 useActionState이다.

EditableText는 부모 컴포넌트인 SearchDetail(서버 컴포넌트)에서 정의된 서버 액션을 action props로 받는다.

useActionState는 내부적으로 reducer 스타일로 동작하며 최신 state를 반환한다. 반환된 formState는 서버 액션이 실행된 후의 반환값일 것이다. 이제 formActionform에 넣어주자.

이제 실질적인 업데이트를 담당하는 서버 액션, action.js를 작성하자.

1.2 /client/action.js

updateShipName은 서버 함수로써 DB에 직접 접근하여 ship name을 변경한다. updateShipName은 서버 환경에서 호출되어야 한다.

이제, ShipDetail에서 updateShipName를 import하여 EditableText에 props로 주면 서버 액션과 form 연결은 완료다. 앱을 실행해보자

1.3 use server

아니나 다를까 앱을 실행하면 클라이언트 에러가 발생하는데, use server 지시어가 없다는 것이다.

기본적으로 RSC에서는 서버 컴포넌트가 클라이언트 컴포넌트에 props로 값을 넘길 때, 오직 직렬화 가능한 값(문자열, 숫자, 객체 등)만 전달 가능하다. 함수는 직렬화가 불가능한데 오직 use server 가 붙은서버 함수만 예외다.

그런데 이상하다. 우리는 App.js을 서버 컴포넌트에서 렌더링하기 때문에 use client 지시어가 없으면 서버 컴포넌트로 간주해야 하는데, 왜 updateShipNameuse server 지시어가 필요할까?

정확히 말하자면 use server 지시어는 서버 컴포넌트에 대한 지시어가 아니라 서버 함수에 대한 지시어다.

즉, use server을 통해서 이 함수는 서버 환경에서 실행해야 한다고 번들러에게 알려주는 것이다.

서버 함수인 updateShipNameuse server 지시어 없이를 ShipDetail(서버 컴포넌트)에서 import 했을 때 서버 로그를 보자.

이전 편에서 클라이언트 컴포넌트가 RSC Payload에 참조로 어떻게 추가되었는지 생각해보면, typeofid가 있었던 것을 기억할 것이다. 그런데 use server 가 없는updateShipName는 일반 함수로 취급되고 있다.

이제 updateShipNameuse server를 추가하고 다시 로그를 보자.

그리고 ShipDetail에서 import할 때의 updateShipName를 보면

무언가 굉장히 많이 생긴 것을 확인할 수 있다. 우리 오랜 친구 loader 에서 소스를 확인해보자.

로더에서 result.source를 보면

import {registerServerReference} from "react-server-dom-esm/server";
registerServerReference(
   updateShipName,
  "file:///Users/dongha.kim/Desktop/dongha/react/react-server-components/client/actions.js",
  "updateShipName");

이렇게 registerServerReference를 사용하여 서버 함수를 참조로 등록한다. 즉, use server 지시어를 보면 해당 모듈을 서버 함수로 인식하고 로더에서 id를 모듈의 경로로 하여 참조를 추가하는 것이다.

여기서 use client와 다른 점이 있다면 use client는 해당 모듈 전체를 참조로 대체하지만 use server 는 기존 모듈은 유지하고 메타데이터만 추가하는 식이다.

use server 지시어를 붙이고 서버 함수를 직렬화하여 클라이언트 컴포넌트인 EditableText에 props로 넘겼다. 제대로 참조가 넘어갔는지 RSC Payload를 확인해보자

우리의 서버 컴포넌트가 직렬화되었다!

(현재는 서버 함수 경로가 전체 경로로 나오는데 프로덕션 환경에서는 번들러가 경로를 해시 또는 ID로 변환다.

1.3.1 use client vs use server

정리하자면, use client가 붙은 모듈은 클라이언트 번들에 포함된다. 서버 컴포넌트에서 use client 컴포넌트를 import하면, 번들러는(여기서는 loader가) RSC Payload에 해당 클라이언 컴포넌트의 참조만 포함시킨다.

브라우저는 RSC Payload에 있는 클라이언트 컴포넌트 참조를 보고 해당 js 파일을 다운로드하여 hydration하게 된다.

반면 use server가 붙은 모듈을 만나면 번들러(여기서 마찬가지로 loader가) 클라이언트 번들에 포함시키지 않고 서버 액션으로 취급하여 참조만 RSC Payload에 추가한다.

그리고 실제 form 제출 시, 네트워크를 통해 서버에 해당 액션의 참조와 formData가 전송되고 서버는 이 참조를 보고 실제 함수를 실행시키는 것이다.

2. Client Side

이제 서버 함수를 클라이언트 컴포넌트에 넘기는 것까지 완성했다.

그럼 앱을 실행시켜서 form을 제출해보자.

또다시 에러가 등장했다. 에러를 그대로 직역하면 서버 함수를 클라이언트에서 호출할 때, 그 호출을 서버로 전달하는 방법이 런타임에 구현되어 있지 않았다는 것이다.

즉, 클라이언트에서 RSC Payload를 역직렬화할 때, 서버 함수를 어떻게 처리해야 하는지에 대한 옵션을 줘야 하는것이다. 이를 callServer라고 한다.

2.1 /client/index.js

쉽게 말해 데이터 mutaion이 일어났을 때, API를 찔러서 데이터를 업데이트 발생시켜야 하는 로직이 필요한 것이다.

생각해보면 EditableText에서 form의 액션은 서버 함수인 updateShipName에 위임되었다.

클라이언트에서 form을 제출했을 때, 서버에서 updateShipName가 실행되어야할 트리거가 필요한 것이다.

index.js로 돌아가서 createFromFetchcallServer를 추가하자.

그럼 callServer를 구현해보자.

callServer는 직렬화할 받은 참조 id를 받아 특정 end point에 fetch한다.
(여기서 getGlobalLocation()는 특정 아이템의 id로, path param이다)

그리고 반환값을 다시 createFromFetch로 스트리밍 촵촵하여 리액트 엘리먼트 요소로 변환한다.

이제 앱을 다시 실행해보자

우주선 이름을 변경하고 제출해보면

서버 코드를 수정하지 않았기 때문에 당연히 에러가 나오지만 우리가 원하는대로 form에서 변경된 이름이 서버에 전달되는 것을 확인할 수 있다.

이제 서버 사이드 코드를 수정하자.

3. Server Side

자, 서버에서는 어떻게 이 요청을 보고 서버 함수를 실행시킬 수 있을까?

callServer로 요청했던 리퀘스트의 해더를 다시 살펴보자.

리퀘스트의 해더에는 로더가 만들어서 참조에 추가했던 서버 함수의 id 즉, 서버 함수의 모듈 경로가 있다. 저걸 활용하여 촵촵할 수 있지 않을까? 먼저 POST /aciont/:shipId?를 만들어보자.

3.1 /server/app.js

POST /aciont/:shipId?callServer가 호출하는 api다. 서버 함수를 가져오기 위해서는 전체 경로로 되어 있는 serverReference에서 filepathfilename을 분리하여 모듈을 import한다.

서버 함수인지까지 체크해주고 서버 함수가 잘 로드되나 확인해보자.

잘 가져오고 있다. 이제 formData를 가져와 서버 함수와 촵촵하자.

callServer에서 body에 넣은 formData를 가져오고, react-server-dom-esm/server에서 제공하는 decodeReply으로 디코딩한다.

마지막으로 서버 함수를 실행하면 끝. returnValue를 확인해보자.

updateShipName 에서 정의한 반환값이 잘 나오고 있다. 드디어 끝이 보인다!

이제 DB가 업데이트 되었으니 서버에서 UI를 업데이트 해야한다.

pipe로 Payload를 스트리밍하는 renderAppreturnValue를 넘기고

renderAppreturnValue를 받아 renderToPipeableStream에 넣어주면 된다.

이제 앱을 실행해보자.

서버 함수를 사용하여form을 제출하면 업데이트된 state 까지 응답으로 받았다!

3.2 정리

지금까지 내용을 다시 한번 정리해보자.

먼저, 서버 함수 없이 클라이언트 컴포넌트에서 form 변경 시 데이터 페칭을 하면 되지 않을까? 가 질문의 시작이었다.

나의 답은 해도 된다. 하지만 RSC가 추구하는 방향은 클라이언트와 서버의 명확한 책임 분리다. 상태가 복잡한 인터렉션은 클라이언트의 책임을, 데이터 패칭과 UI 렌더링은 서버의 책임을 나누는 것이다.

서버 함수를 사용하여 form으로 mutation을 일으키면, 서버에서 데이터 변이와 UI 렌더링을 한 번에 처리할 수 있다. 즉, 여러 컴포넌트가 같은 데이터를 써도 서버가 최신 UI를 보장하여 관리 포인트를 줄일 수 있다.

그럼 다음 질문은 클라이언트 컴포넌트에서 서버 함수를 어떻게 호출해야할까? 이다.

먼저 서버 함수에 use server가 있어야만 번들러가 직렬화할 수 있다. 번들러는 use server 지시어를 보고 함수 id와 모듈 경로를 참조로 RCS Payload에 추가한다. 이를 통해 서버는 form이 제출되었을 때, 서버에서 해당 서버 함수의 참조를 보고 함수를 실행할 수 있다.

그렇다면 form 제출은 런타임 환경에서 실행되는데, 어떻게 서버에서 서버 함수를 실행할 수 있을까?

createFromFetchcallServer을 등록하는 것이다. createFromFetch가 직렬화된 RSC Payload를 역직렬화하는 과정에 서버 함수를 보면 참조된 함수 id를 넘겨 action api를 호출하게 된다. 그럼 서버에선 참조된 서버 함수 모듈의 경로를 파싱하여 서버 함수를 실행한다. 그리고 결과를 다시 직렬화화여 클라이언트에 전송한다.

자, 어느정도 정리가 끝났으면 다음 스텝으로 넘어가자.

4. Revalidation

서버 함수로 mutation은 잘 일어났지만, 다른 서버 컴포넌트의 데이터는 최신화가 되지 않았다. 서버에서 변경이 일어나면 Revalidation을 통해 해당 데이터를 소비하는 다른 컴포넌트들도 리렌더링을 해줘야한다.

그런데 분명, renderApp을 통해 새로운 UI를 렌더링 했을 텐데 왜 반영이 안 된 걸까? 이유를 확인해보자.

4.1 다시 callServer로

원인은 callServer다. 서버 함수 호출 후 반환받은 값 중 returnValue만 반환하고 있다.

현재 root를 어떻게 렌더링하고 있는지 다시 확인해보자.

const contentPromise = contentCache.get(contentKey)
...
return use(contentPromise).root

브라우저 캐시를 흉내낸 contentCache에 키로 접근하여 UI를 가져오고 있다. 즉, callServer 내부에서도 contentCache.set(contentKey, promise)을 하여 새로운 UI 트리로 렌더링하게 해야 한다.

요컨대 callServer 내부에 아래와 같은 캐시 업데이트 로직이 필요한 것이다.

const key = window.history.state?.key ?? generateKey()
contentCache.set(key, actionResponsePromise)
startTransition(() => setContentKey(key))

하지만 문제가 있다. 현재 callServer 는 root 컴포넌트 외부에 있다. 즉, hook을 사용할 수 없는 모듈이다.

root 컴포넌트 외부에서 상태 관리를 제어하여 root 컴포넘트에 영향을 줘야 하는 상황이다. 이것을 어떻게 해결해야할까?

4.2 클로저 어게인

function increment() {
  throw new Error('Counter가 렌더링되기 전에 호출되었습니다')
}

function Counter() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    increment = () => setCount((c) => c + 1)
  }, [])

  return <button onClick={increment}>{count}</button>
}

이렇게 외부에서 정의한 함수를 컴포넌트 내부에서 useEffect로 재정의하게 되면 외부함수이면서 컴포넌트에 상태에 접근할 수 있는 함수가 된다.(일반적으로 추천하는 패턴은 아니라고 한다.)

이제 위와 같은 패턴으로 callServer 내부를 변경해보자.

4.3 /client/index.js

먼저 root 외부에 updateContentKey를 만든다. 이걸 useEffect로 재정의할 것이다.

이제 외부에서 root의 상태에 접근이 가능하다. 이제 callServer에서 updateContentKey를 호출하여 캐시와 키를 업데이트하자.

여기까지 form으로 서버 액션이 호출되고 업데이트된 UI를 키와 캐시에 저장하는 것까지 됐다. 한번 앱을 실행하여 확인해보자.

좌측 UI까지 제대로 렌더링 되었다!

4.4 contentKey를 늦게 업데이트 해야한다.

여기서 딱 하나만 더 수정해보자면 form 제출 후 callServer에서 요청이 발생했을 때, 전체적으로 생기는 fallback이다. startTransition으로 지연했는데도 왜 fallback이 생길까?

원인은 createFromFetch의 응답 스트리밍이 끝나기 전에 updateContentKey(contentKey)가 트리거되어 UI를 업데이트 하기 시작해서이다.

코드를 하나씩 살펴보자.

서버 액션 요청 보내고 응답으로 RSC Payload를 스트림 형태로 받는다. 그리고 updateContentKey를 실행하는데,

updateContentKey는 key를 업데이트한다. startTransition으로 묶여 있지만, 현재 우선수위가 높은 작업이 없으니까 즉시 실행한다.

상태가 없데이트 되고, root가 다시 실행되면서 캐시에서 키로 UI를 가져오는데, 아직 응답받은 RSC Payload가 resolve 되지 않아 Suspense에 걸려 fallback이 나온다.

즉, callServer에서 캐시는 즉각 업데이트 하고, 키는 promise가 끝나고 업데이트 해야 깜빡거리는 fallback 없앨 수 있다.

onStreamFinshed 함수는 clone().text()를 통해 전체 text(여기서는 응답받은 stream)을 모두 읽은 후 다음 작업을 수행하게 해준다.

이 함수에 updateContentKey를 콜백으로 넘기면 된다.

4.4.1 왜 await이 안 되는가

상당히 헷갈렸던 부분인데,

그냥 await 처리하고 그 다음 updateContentKey를 하면 안될까 생각했는데, 이렇게 되면 RSC의 스트리밍의 장점이 사라진다.

즉, createFromFetch()는 RSC 스트림을 파싱하는 함수이고 actionResponsePromise는 스트림을 처리하면서 채워지는 promise다.

React는 이 promise를 use(contentPromise).root로 소비하면서 곧바로 렌더링을 한다. 스트리밍 도중에 렌더링도 같이 하는 것이다.

await을 하게 되면 createFromFetch가 끝날 때까지 use(contentPromise).root에는 아무 것도 없으니 RSC을 사용할 이유가 없는 것이다.

앞서 말했던, content 캐시는 즉각 업데이트하고 content 키는 프로미스가 promise가 끝나고 업데이트 해야 한다와 비슷한 맥락이다.

아무튼 이제 다시 앱을 확인해보자.

아주 완벽하게 fallback 없이 자연스럽게 변경되는 것을 확인할 수 있다!!

4.5 History Revalidation

자, 마지막 이슈가 남았다. 이번 편을 거의 3일 째 쓰고 있는데, 마치 하나의 앱을 만드는 것처럼 마지막 이슈에 대해 쓰려니까 벅차오르고 한다..

아무튼, 다수의 우주선들을 업데이트하고 브라우저 뒤로가기/앞으로가기를 실행해보자

디테일에서 업데이트를 하고 뒤로가기를 하면 브라우저는 이전에 캐싱된 목록을 보여준다. 그리고 다시 앞으로 가기를 해야 제대로된 목록이 나온다.

popstate로 뒤로가기를 했을 때 이전 캐시를 참조하는 것이 브라우저의 원래 동작 방식이다. 즉, 브라우저 뒤로가기를 할 때 새롭게 목록을 갱신할 것인가는 전적으로 프로덕트 팀의 결정이다.

그럼, 뒤로가기 시에도 캐시의 유무 상관없이 UI를 새로 가져오자.

handlePopState 내부에 RCS Payload를 가져오는 함수들을 if문 밖으로 뺀다.

앱을 실행시켜보자.

이제, 뒤로 가기를 해도 업데이트된 UI를 렌더링하는 것을 확인할 수 있다.

5. 정리

드디어 끝났다. 여태까지 했던 것들을 정리해보자.

  1. CSR 기반 SPA 만들기

    • 전통적인 SPA를 다시 살펴보면서 RSC는 무엇을 개선하려고 했는지 알 수 있었다.
  2. RSC 페이로드 알아보기

    • RSC만의 독특한 페이로드를 알아보았다.
  3. 클라이언트 컴포넌트란?

    • 노드 로더을 활용하여 클라이언트 컴포넌트가 RSC에서 어떻게 쓰이는지 흐름을 살폈다.
  4. 클라이언트 라우터 만들기

    • RSC에서 돌아가는 클라이언트 라우터를 직접 개발하여, RSC 기반 SPA를 완성했다.
  5. 서버 액션(함수)

    • 서버 함수로 신박한 데이터 패칭 및 UI 업데이트를 완성했다.

서버 컴포넌트가 뭔지 안다, 쓸 수 있다 정도로만 학습했는데 이번 기회에 하나씩 뜯어보며 딥다이브 해본 거 같다.

React18 이전에는 상태와 데이터 패칭은 좀 더 리액트 외부 라이브러리들, 리액트 쿼리나 리덕스, SWR 등등에 위임하는 느낌이었는데 React18 이후 리액트 하나로 모든 것을 다 처리할 수 있을 정도로 생태계를 정교하게 구축해가는 것 같았다.

특히, 스트리밍, 서스펜스, 트랜지션 등등 UI에 있어서는 단언 최고다 라는 생각... 이번 서버 컴포넌트를 학습하면서 굳이 넥스트를 써야할까? 라는 의문이 생겼고 동시에 리믹스를 공부해보고 싶다는 생각이 들었다.

이제 리액트는 완전히 다른 방향으로 나아가는 것 같다. 좀 더 딥다이브 해야징

참고: epic-react

profile
프론트엔드 개발

0개의 댓글