본문으로 건너뛰기

서버 함수 참조는 어떻게 만들어지고 전달되는가

클라이언트 컴포넌트에서 'use server' 함수를 import해 호출하면 서버에서 실행됩니다. 그런데 서버에서만 실행되는 코드가 클라이언트 번들로 넘어올 리는 없습니다. 그 사이를 잇는 것이 참조(reference) 입니다.

이 글은 그 참조 하나만 봅니다. 무엇이고, 누가 언제 만들고, 어떻게 호출하는지입니다. 기반 문서는 React — 서버 함수이고, 지시어의 세부는 "use server"를 함께 봅니다.

먼저 용어 하나

서버 함수와 서버 액션을 같은 말로 쓰는 경우가 많은데, React 문서는 이렇게 구분합니다.

2024년 9월까지, 우리는 모든 서버 함수를 "서버 액션"으로 불렀습니다. 만약 서버 함수를 action prop으로 전달하거나 action 내부에서 호출된다면 이는 서버 액션이지만, 모든 서버 함수가 서버 액션은 아닙니다.

문서의 명명 규칙은 서버 함수가 여러 용도로 쓰일 수 있다는 점을 반영해 업데이트됐습니다. 이 글에서는 문서를 따라 서버 함수로 씁니다.


1. 클라이언트가 받는 것

서버 함수를 쓰면 클라이언트 컴포넌트가 서버에서 실행되는 비동기 함수를 호출할 수 있습니다. 클라이언트에서 네트워크 요청을 통해 호출하기 때문에 반드시 async여야 합니다. 이 제약은 서버 함수 페이지가 아니라 지시어 쪽 문서에 적혀 있습니다.

기본 네트워크 호출이 항상 비동기적이므로, 'use server'는 비동기 함수에서만 사용할 수 있습니다.

"use server"

참조 이야기로 돌아오면, 문서의 설명은 이렇습니다.

서버 함수가 "use server" 지시어로 정의되면, 프레임워크는 자동으로 서버 함수에 대한 참조를 생성하고 해당 참조를 클라이언트 컴포넌트에 전달합니다. 클라이언트에서 해당 함수를 호출하면, React는 서버에 함수를 실행하라는 요청을 보내고 결과를 반환합니다.

'use server'는 위치가 아니라 노출을 표시합니다

위 인용문은 "서버 함수가 "use server" 지시어로 정의되면"으로 시작하는데, 서버 함수의 정의 자체가 'use server'를 붙인 async 함수라서 되짚기처럼 읽힙니다. 정의는 지시어 쪽 문서에 있습니다.

함수를 클라이언트에서 호출할 수 있음을 표시하기 위해, 비동기 함수의 최상단에 'use server';를 추가하세요. 이를 _서버 함수_라고 부릅니다.

"use server"

그래서 위 인용문에서 새로운 정보는 뒷부분입니다. 지시어를 붙인 다음에 프레임워크가 무엇을 하는가를 말하는 문장입니다.

한 가지가 더 있습니다. 이 지시어는 "서버에서 실행하라"는 표시가 아닙니다.

'use server'서버 측 파일에서만 사용할 수 있습니다.

"use server"

이미 서버인 곳에서만 쓸 수 있습니다. 코드를 서버로 보내는 것이 아니라, 이미 서버에 있는 함수를 클라이언트가 부를 수 있게 여는 표시입니다. 서버 컴포넌트는 지시어 없이도 서버에서 실행됩니다.

'use client'이 경계부터는 클라이언트 — 위치 선언
'use server'이 함수를 클라이언트에 열어 줌 — 노출 선언

두 지시어를 나란히 놓은 것은 이 글의 정리입니다. 이렇게 읽으면 위 인용문은 이런 뜻이 됩니다. 서버에 있는 async 함수를 'use server'로 열어 두면, 프레임워크가 그 함수의 참조를 만들어 클라이언트에 건네줍니다.

클라이언트에서 그 값을 찍어 보면 함수가 아닙니다.

console.log(createNote);
// {$$typeof: Symbol.for("react.server.reference"), $$id: 'createNote'}

보통 함수를 찍으면 본문이 같이 나오는데, 여기에는 본문이 없습니다. 속성 두 개뿐입니다.

하는 일
$$id서버의 어떤 함수인지 지목하는 이름표
$$typeof이 값이 서버 참조임을 알리는 표식

서버 쪽에서 함수마다 id를 만들어 클라이언트에 넘겨두고, 클라이언트가 호출하면 그 id를 되돌려 받아 어떤 함수인지 판별해 실행한 뒤 결과만 돌려줍니다. 경계를 넘는 일이 세 번 있습니다.

언제방향실려 가는 것
참조 생성번들러가 빌드할 때, 또는 React가 렌더링할 때서버 → 클라이언트$$id, $$typeof
호출사용자가 버튼을 누르거나 폼을 제출할 때클라이언트 → 서버$$id, 직렬화된 인수
응답서버가 id로 함수를 찾아 실행한 뒤서버 → 클라이언트직렬화된 반환값

인수와 반환값이 직렬화되는 것은 "use server" 문서의 서술입니다.

클라이언트에서 서버 함수를 호출하면, 전달된 모든 인수의 직렬화된 사본을 포함한 네트워크 요청을 서버로 전송합니다. 서버 함수가 값을 반환하면, 그 값을 직렬화하여 클라이언트로 반환합니다.

"use server"

세 번 오가는 동안 함수 본문은 한 번도 클라이언트로 가지 않습니다. 이 참조가 누구 손에서 언제 만들어지는지가 다음 절입니다.

보충 — 왜 "참조"라고 부르는가

이 설명은 문서에 없습니다. 용어가 익숙한 뜻과 어긋나는 자리라 따로 정리합니다.

자바스크립트에서 참조는 보통 같은 메모리에 있는 객체를 가리키는 값을 뜻합니다. const b = a로 두 이름이 하나의 객체를 가리키는, 그 참조입니다.

그런데 서버 함수 참조가 가리키는 것은 다른 컴퓨터에 있는 코드입니다. 같은 메모리는커녕 같은 프로세스도 아닙니다. 익숙한 뜻으로 읽으면 말이 안 되는 게 당연합니다.

옷 보관소 번호표에 가깝습니다. 번호표는 코트가 아닙니다. 얇고, 주머니에 넣어 다닐 수 있고, 그 자체로는 따뜻하지 않습니다. 하지만 카운터에 내밀면 코트가 나옵니다. 코트는 보관소를 떠난 적이 없습니다.

함수 본문도 서버를 떠나지 않습니다. 서버에서만 쓰라고 만든 코드라서 브라우저로 보낼 수가 없습니다. 그래서 그 자리에 본문 대신 번호표를 놓아 둡니다. $$id가 번호이고, $$typeof가 "이건 번호표다"라는 표시입니다.


2. 참조를 만드는 두 가지 경로

문서는 서버 함수를 서버 컴포넌트에서 생성해 props로 전달하거나, 클라이언트 컴포넌트에서 가져와 사용할 수 있다고 설명합니다. 겉보기에는 그냥 두 가지 작성법이지만, 참조를 만드는 주체와 시점이 다릅니다.

경로 1 — 서버 컴포넌트 안에서 만들기

서버 컴포넌트는 "use server" 지시어로 서버 함수를 정의할 수 있습니다.

// 서버 컴포넌트
import Button from './Button';

function EmptyNote() {
async function createNote() {
// 서버 함수
'use server';
await db.notes.create();
}

return <Button onClick={createNote} />;
}

React가 EmptyNote 서버 컴포넌트를 렌더링할 때, createNoteAction 함수에 대한 참조를 생성하고, 그 참조를 Button 클라이언트 컴포넌트에 전달합니다.

버튼을 클릭하면 React는 제공된 참조를 통해 함수를 실행하도록 서버에 요청을 보냅니다.

'use client';

export default function Button({ onClick }) {
console.log(onClick);
// {$$typeof: Symbol.for("react.server.reference"), $$id: 'createNoteAction'}
return <button onClick={() => onClick()}>Create Empty Note</button>;
}

Button은 자기가 받은 prop이 서버 함수인지도 모릅니다. 그냥 onClick이라는 이름의 무언가를 호출할 뿐입니다.

원문에서 이름이 어긋나는 지점

위 예제는 함수를 createNote로 정의했는데, 인용한 설명과 console.log 주석에서는 createNoteAction이라고 씁니다. 한국어 번역본이 그렇습니다. 영문 원본은 정의부터 createNoteAction이라 네 군데가 모두 일관되므로, 번역 과정에서 생긴 오류로 보입니다.

$$id에 들어가는 것은 두 경로 모두 그냥 함수 이름입니다. 참조를 누가 언제 만드는지와는 상관이 없습니다.

경로 2 — 파일에서 가져오기

클라이언트 컴포넌트는 "use server" 지시어를 사용하는 파일에서 서버 함수를 가져올 수 있습니다.

'use server';

export async function createNote() {
await db.notes.create();
}

지시어가 함수 안이 아니라 파일 맨 위에 있습니다. 경로 1과 다른 점이고, 문서가 요구하는 조건입니다.

서버 함수를 클라이언트 코드에서 가져오기 위해, 지시어를 모듈 수준에서 사용해야 합니다.

"use server"

파일 최상단에 붙이면 그 파일의 모든 export가 서버 함수가 됩니다.

번들러가 EmptyNote 클라이언트 컴포넌트를 빌드할 때, 번들에서 createNote 함수에 대한 참조를 생성합니다.

'use client';
import { createNote } from './actions'; // 실제로 들어오는 것은 참조

function EmptyNote() {
return <button onClick={() => createNote()} />;
}

import 문은 평범한데, 번들에 실려 오는 것은 함수가 아니라 참조입니다. 맨 앞에서 찍어 본 로그가 바로 이 경로의 예제였습니다.

무엇이 다른가

경로 1: 서버 컴포넌트에서경로 2: 파일에서 import
참조를 생성하는 주체React번들러
생성 시점서버 컴포넌트를 렌더링할 때빌드할 때
클라이언트에 닿는 경로propsimport

같은 $$typeof를 가진 같은 종류의 객체인데, 만들어지는 시점이 빌드 타임과 런타임으로 갈립니다. 경로 2의 참조는 빌드 산출물에 이미 들어 있고, 경로 1의 참조는 그 서버 컴포넌트가 렌더링돼야 존재합니다.


3. 참조를 호출하는 두 가지 방법

참조가 클라이언트에 도달한 뒤에는 그것을 호출하는 문제가 남습니다. 방법이 둘이고, 번들이 도착하기 전에도 되는지가 갈립니다.

호출 방식호출하는 주체번들 로드 전
<form action>브라우저의 폼 제출제출됨
onClickReact가 네트워크 요청으로호출되지 않음

onClick 경로는 앞 절에서 이미 봤습니다. <Button onClick={createNote} />가 그것이고, 클릭하면 React가 참조를 들고 서버에 요청을 보냅니다. 여기서는 폼 쪽만 봅니다.

폼 쪽은 "use server" 문서가 이렇게 설명합니다.

서버 함수를 폼 action에 전달하여, React는 폼을 점진적 향상할 수 있습니다. 이것은 자바스크립트 번들을 로드하기 전에 양식을 제출할 수 있다는 것을 의미합니다.

"use server"

보충 — 점진적 향상이란

용어가 성능 개선처럼 들리는데 설계 방식의 이름입니다. 강조점은 향상이 아니라 바닥에 있습니다. 기능을 층으로 쌓되 바닥 층만으로도 일이 끝나야 한다는 것입니다. 위에 얹는 것은 더 나은 경험이지, 없으면 못 쓰는 것이 아닙니다.

useActionState 문서가 괄호로 정의를 답니다.

점진적 향상(JavaScript가 로드되기 전에 폼이 작동하도록 허용)

useActionState

자바스크립트를 꺼서가 아니라 아직 안 와서입니다. HTML은 도착했는데 번들은 오는 중인 구간이 있고, 그 사이에 사용자가 제출 버튼을 누르면 점진적 향상이 없을 때는 아무 일도 일어나지 않습니다. 느린 네트워크나 큰 번들에서 이 구간이 길어집니다. 엣지 케이스 대비가 아니라 첫 인상 대비입니다.

여기서 바닥 층은 <form>입니다. 브라우저가 원래부터 하던 일이라 자바스크립트가 필요 없습니다. 서버 함수를 action에 넘기면 그 바닥이 그대로 살아 있고, 번들이 도착한 뒤에는 React가 가로채서 페이지 갱신 없이 처리합니다. 같은 코드로 두 층을 얻습니다.

이름을 바꾸는 서버 함수 updateName이 있다고 하겠습니다.

<form action={updateName}>
<input type="text" name="name" />
</form>
보충 — <form action>이란

action은 원래 URL을 받는 HTML 속성입니다. 브라우저가 입력값을 모아 그 주소로 보내고, 서버가 돌려준 페이지로 이동합니다. 자바스크립트 없이 동작하는 기능입니다.

React 19에서 여기에 함수를 넘길 수 있게 됐습니다. 폼이 제출되면 React가 그 함수를 부르고, 입력값을 FormData로 모아 첫 번째 인자에 넣어 줍니다.

onSubmit으로 처리하던 방식과의 차이는 preventDefault()입니다. 그쪽은 브라우저의 기본 제출을 막고 자바스크립트가 대신했기 때문에, 자바스크립트가 없으면 아무 일도 일어나지 않습니다. action은 기본 제출을 살려 둡니다. 아래 이야기가 성립하는 이유입니다.

번들이 도착하기 전에도 이 폼은 제출됩니다. 그리고 그 제출이 서버 함수에 가 닿는다는 것은, 그 함수를 가리키는 무언가가 이미 HTML에 실려 왔다는 뜻입니다. 번호표가 화면과 함께 도착해 있는 셈입니다.

여기까지가 문서입니다

HTML에 정확히 무엇이 심기는지는 문서에 없습니다. "자바스크립트 번들을 로드하기 전에 제출할 수 있다"까지가 문서의 서술이고, 바로 위 한 문장은 이 글의 해석입니다.

제출은 되는데, 그 순간 자바스크립트가 어디까지 와 있느냐에 따라 뒤이어 벌어지는 일이 달라집니다.

보충 — Hydration이란

이 설명은 문서에 없습니다. 문서는 Hydration을 이미 아는 것으로 두고 용어만 씁니다.

서버가 만들어 보낸 HTML은 완성된 화면이지만 정적인 문자열입니다. 글자와 태그는 있는데 onClick 같은 동작은 HTML로 표현되지 않습니다. 버튼이 보여도 눌리지 않는 이유입니다.

브라우저가 자바스크립트 번들을 받아 실행하면, React가 이미 화면에 있는 DOM을 훑으면서 자기 컴포넌트 트리와 맞춰 보고 이벤트 핸들러를 붙입니다. 이 과정이 Hydration입니다. 화면을 새로 그리는 것이 아니라 이미 있는 화면에 동작을 입히는 것이라, 겉보기에는 아무 변화가 없다가 어느 순간부터 버튼이 눌리기 시작합니다.

① HTML 도착 화면이 보인다. 버튼도 보인다. 눌러도 반응 없음
↓ 번들 다운로드
② 번들 로드 완료
↓ React가 DOM에 이벤트 핸들러를 붙임
③ Hydration 완료 버튼이 눌린다

얹을수록 늘어나는 것

<form action> 위에 두 가지를 얹을 수 있습니다. 위 칸은 아래 칸이 없어도 동작합니다.

번들 로드 전 제출그 위에 더해지는 것
<form action>
+ useActionState결과를 화면에 반영하고, Hydration 전 제출을 미뤄 뒀다 실행
+ permalink번들 로드 전 제출을 해당 URL로 리디렉션

각 층이 무엇을 메우는지 보면 순서가 보입니다.

<form action>만 있으면 제출은 서버에 닿지만 결과를 다룰 수단이 없습니다. 브라우저가 응답 페이지를 받아 그리는 것으로 끝나서, 실패했는지 진행 중인지를 화면에서 표현할 방법이 없습니다.

useActionState를 얹으면 그 결과를 state로 받습니다. 대기 상태도 함께 옵니다. 그리고 Hydration 전에 들어온 제출이 버려지지 않습니다. 다만 이건 자바스크립트가 도착한 뒤의 이야기입니다.

permalink는 반대로 자바스크립트가 오지 않은 채 제출된 경우를 위한 것입니다. 그때는 브라우저가 페이지를 이동하는 것 말고 할 수 있는 일이 없으므로, 어디로 갈지를 미리 정해 둡니다.

점진적 향상의 출발점은 <form action>이고, 나머지 둘은 그 위에 얹는 층입니다.

점진적 향상을 지원하며 서버 함수의 결과를 기반으로 UI를 업데이트하려면, useActionState를 사용하세요.

"use server"

일찍 누른 제출은 버려지지 않습니다

같은 폼을 useActionState로 호출하면 대기 상태와 마지막 응답에 접근할 수 있는데, 여기에 한 가지가 더 붙습니다.

const [state, submitAction, isPending] = useActionState(updateName, { error: null });

return <form action={submitAction}>...</form>;

서버 함수 함께 useActionState를 사용하는 경우, React는 Hydration이 완료되기 전에 입력된 폼 제출을 자동으로 다시 실행합니다. 즉, 사용자는 앱이 Hydration 되기 전에도 앱과 상호작용을 할 수 있습니다.

별도 설정이 없습니다. useActionState를 쓰면 따라오는 동작입니다.

인용문 첫 구절의 "서버 함수 함께"는 한국어 번역본의 표기 그대로입니다.

번들보다 제출이 빨랐다면

useActionState에는 세 번째 인수가 있습니다.

const [, submitAction] = useActionState(updateName, null, `/name/update`);

permalink가 useActionState에 제공될 때, 자바스크립트 번들이 로드되기 전에 폼이 제출되면 React는 제공된 URL로 리디렉션합니다.


정리

  • 클라이언트 컴포넌트가 받는 것은 함수 본문이 아니라 {$$typeof: Symbol.for("react.server.reference"), $$id: ...} 형태의 참조입니다.
  • 호출하면 React가 그 참조를 통해 서버에 실행을 요청하고 결과를 반환합니다. 네트워크 요청이라 서버 함수는 반드시 async여야 합니다.
  • 참조를 만드는 경로는 둘입니다. 서버 컴포넌트 안에서 정의하면 React가 렌더링할 때 만들어 props로 전달하고, 'use server' 파일에서 import하면 번들러가 빌드할 때 번들에 만듭니다.
  • 참조를 호출하는 방법은 둘입니다. <form action>은 브라우저가 제출하므로 번들이 로드되기 전에도 제출되고, onClick 같은 경로는 React가 요청을 보내므로 번들이 있어야 합니다.
  • 점진적 향상의 출발점은 <form action>입니다. useActionState를 얹으면 Hydration 완료 전에 들어온 제출을 React가 미뤄 뒀다 실행합니다.
  • 세 번째 인수로 permalink를 주면 번들 로드 전 제출은 해당 URL로 리디렉션됩니다.
  • 서버 액션은 서버 함수의 부분집합입니다. action prop으로 전달되거나 action 안에서 호출될 때만 서버 액션입니다.

예상 질문

클라이언트가 import한 것은 함수인가요?

아닙니다. console.log로 찍으면 {$$typeof: Symbol.for("react.server.reference"), $$id: ...} 객체입니다. 함수 본문은 클라이언트로 가지 않고, 호출하면 React가 이 참조로 서버에 실행 요청을 보냅니다.

서버 컴포넌트에서 만드는 것과 파일에서 import하는 것은 무엇이 다른가요?

참조를 생성하는 주체와 시점이 다릅니다. 전자는 React가 서버 컴포넌트를 렌더링할 때 만들어 props로 전달하고, 후자는 번들러가 클라이언트 컴포넌트를 빌드할 때 번들에서 만듭니다.

서버 함수는 왜 반드시 async여야 하나요?

클라이언트에서 네트워크 요청을 통해 호출하기 때문입니다. 이 제약은 서버 함수 페이지가 아니라 "use server" 문서에 적혀 있습니다.

'use server'는 서버에서 실행하라는 뜻인가요?

아닙니다. 이미 서버에 있는 함수를 클라이언트에서 호출할 수 있게 여는 표시입니다. 문서는 이 지시어를 서버 측 파일에서만 쓸 수 있다고 못박습니다. 서버 컴포넌트는 지시어 없이도 서버에서 실행됩니다.

서버 함수와 서버 액션은 같은 말인가요?

포함 관계입니다. 2024년 9월까지는 모든 서버 함수를 서버 액션이라 불렀지만, 지금은 action prop으로 전달하거나 action 내부에서 호출할 때만 서버 액션입니다. 모든 서버 함수가 서버 액션은 아닙니다.

useActionState는 대기 상태를 받는 용도 아닌가요?

그것도 맞지만 두 가지가 더 있습니다. 서버 함수와 함께 쓰면 Hydration 완료 전에 입력된 폼 제출을 자동으로 다시 실행하고, 세 번째 인수로 permalink를 주면 번들 로드 전 제출을 해당 URL로 리디렉션합니다.

점진적 향상을 하려면 useActionState가 필요한가요?

아닙니다. 서버 함수를 <form action>에 전달하는 것만으로 번들 로드 전 제출이 됩니다. useActionState는 그 위에서 결과를 다루는 층이고, 세 번째 인수 permalink는 다시 그 위에 얹는 것입니다.


참고 자료