React 19 Server Action과 Action 훅 역할 정리
React 19와 Next.js App Router에는 "서버에서 작업을 실행한다"는 하나의 흐름에 여러 이름이 붙어 있다. Server Function, Server Action, useActionState, useOptimistic, 그리고 use와 Suspense.
Next.js App Router에서 layout과 page는 기본적으로 서버에서 실행되는 Server Component이고, "use client"를 붙인 파일이 브라우저 상호작용을 담당하는 Client Component다. 궁금했던 건 그다음이다. 사용자가 버튼을 눌렀을 때 서버 쪽 작업을 어떻게 실행하고, 그 결과를 UI에 어떻게 연결하는가.
Server Function 전부가 Server Action은 아니었다
"use server"로 표시해 클라이언트에서 호출할 수 있게 만든 서버 함수를 Server Function이라고 한다. 그중 <form action>, <button formAction>, 클라이언트 transition(상태 전환을 비동기로 묶어 처리하는 React의 장치) 같은 React의 action 메커니즘을 통해 호출되는 것이 Server Action이다. 모든 Server Function이 Server Action인 것은 아니다.
React가 2024년 9월까지는 Server Function 전체를 Server Action이라고 불렀다. 그래서 예전 글과 문서에는 두 용어가 섞여 있다.
파일 맨 위에 "use server"를 붙인 함수는 이런 모양이다.
"use server";
export async function updateUserName(name: string) {
await db.user.update({
where: {
id: 1,
},
data: {
name,
},
});
}이 함수의 구현은 서버에서 실행된다. onClick={() => updateUserName("태훈")}처럼 Client에서 직접 호출하는 형태는 Server Function 호출이다. 같은 함수를 <form action={updateUserName}>처럼 action 메커니즘에 연결하면 Server Action이 된다. 서버에서 실행된다는 점은 같고 호출 경로만 다르다.
Client에서 호출하면 서버 쪽에서 인증, 검증, 비즈니스 로직, DB 업데이트가 차례로 이어진다.
Server Action이 네트워크 요청을 없애는 것은 아니다. 코드에서는 함수 호출처럼 보이지만 실제로는 Client에서 Server로 경계를 넘는 요청이 발생한다. 원격 함수를 로컬 함수처럼 호출하는 RPC에 가깝다.
Server Component는 UI를 만들고 Server Action은 작업을 실행한다
Server Component는 서버에서 데이터를 읽어 UI를 렌더링한다. Server Action은 브라우저에서 시작된 사용자 동작을 받아 서버에서 DB 변경 같은 작업을 실행한다. 이름이 비슷해서 헷갈리지만 둘이 하는 일은 겹치지 않는다.
"use server"는 Server Component 선언이 아니다
"use client"는 그 파일을 Client Component 경계로 만든다. 브라우저에서 실행될 수 있는 코드가 된다.
"use server"는 성격이 다르다. Server Function을 정의하고, 그중 action 메커니즘으로 호출되는 것이 Server Action이 된다.
"use server";
export async function updateUser() {
// ...
}"use server"가 "이 파일은 Server Component다"라는 뜻은 아니다. Server Component를 표시하는 지시어는 아예 존재하지 않는다. 기본이 Server Component이기 때문이다.
export default async function Page() {
// ...
}지시어 없이 이것만으로 Server Component다.
Server Action은 서버의 진입점에 가깝다
Server Action에 비즈니스 로직을 전부 몰아넣기보다 계층을 나누는 구조가 자연스럽다.
Server Action은 서버 로직의 본체라기보다 서버로 들어오는 진입점(entry point)이다.
useActionState에 인자 없는 함수를 넘기면 state가 갱신되지 않는다
React 19에는 Action과 React State를 잇는 useActionState가 있다.
const [state, action, isPending] =
useActionState(updateUser, initialState);state는 Action의 결과, action은 Action을 실행하는 함수, isPending은 Action이 실행 중인지 여부다.
useActionState에 넘기는 함수는 첫 번째 인자로 이전 state를, 두 번째 인자로 action에 전달된 값을 받는다. <form action={formAction}>에 연결하면 두 번째 인자는 FormData다.
앞에서 정의했던 인자 없는 updateUser()를 그대로 넘기면 state가 갱신되지 않는다. state가 갱신되려면 이런 형태여야 한다.
"use server";
export async function updateUser(
prevState: { success: boolean; error: string | null },
formData: FormData,
) {
const name = formData.get("name");
await db.user.update({
where: { id: 1 },
data: { name },
});
return { success: true, error: null };
}초기 state는 두 번째 인자로 넘긴다.
const [state, formAction, isPending] =
useActionState(updateUser, {
success: false,
error: null,
});받아온 formAction을 폼에 연결하고, isPending으로 제출 버튼을 잠근다.
<form action={formAction}>
<input name="name" />
<button disabled={isPending}>
저장
</button>
</form>폼을 제출하면 isPending이 true가 되고, Server Action이 DB를 바꾼 뒤 반환한 값이 state로 들어오면서 UI가 다시 렌더링된다.
useActionState는 Form 전용 API가 아니다. Form에서 자주 쓰이지만 버튼 클릭이나 다른 Action에도 쓴다.
useOptimistic은 서버 응답 전의 화면을 맡는다
useOptimistic은 서버 작업이 끝나기 전에 임시 UI 상태를 보여주는 기능이다. 좋아요 수가 10일 때 사용자가 버튼을 누르면 서버 응답을 기다리지 않고 화면에 바로 11을 보여준다.
서버 작업이 성공하면 임시 상태와 실제 상태의 값이 일치한다. 실패하면 임시 상태는 사라지고 실제 상태로 돌아간다.
두 훅은 경쟁이 아니라 역할 분담이었다
useActionState와 useOptimistic은 둘 중 하나를 고르는 관계가 아니다. useActionState는 Action의 결과와 pending, error 상태를 맡고, useOptimistic은 즉각적인 화면 반응을 맡는다.
use는 pending Promise를 만나면 렌더링을 멈춘다
use는 Promise 같은 값을 렌더링 도중에 읽게 해준다.
const user = use(userPromise);Promise가 아직 완료되지 않았다면 React는 해당 렌더링을 계속 진행할 수 없다.
그 대기 구간을 Suspense(하위 컴포넌트가 준비될 때까지 대체 UI를 보여주는 React 컴포넌트)가 받아준다.
<Suspense fallback={<Loading />}>
<UserProfile />
</Suspense>UserProfile 안에서는 Promise를 그냥 값처럼 읽는다.
function UserProfile() {
const user = use(userPromise);
return <h1>{user.name}</h1>;
}Promise가 pending이면 UserProfile 렌더링이 중단되고 <Loading />이 보인다. Promise가 완료되면 React가 렌더링을 다시 시도해 UserProfile을 렌더링하고 커밋한다.
삭제 버튼 하나에 Server Action부터 Optimistic UI까지 전부 들어간다
관리자 페이지 /users에 사용자 목록과 삭제 버튼이 있다.
페이지를 처음 방문하면 Next.js 서버가 Server Component를 실행해 DB에서 사용자 목록을 읽고, RSC Payload(서버에서 만든 컴포넌트 트리를 직렬화한 데이터)와 HTML을 브라우저로 보낸다. 페이지 안의 삭제 버튼은 Client Component다.
사용자가 삭제 버튼을 누르면 Server Action이 실행되고 DB에서 해당 사용자가 지워진다. 실행 중에는 isPending이 true이고, 버튼 라벨을 "삭제 중..."으로 둔다.
성공하면 Action의 반환값이 클라이언트로 돌아온다. 그런데 DB가 바뀌었다고 화면의 Server Component가 저절로 다시 렌더링되지는 않는다. Action 안에서 updateTag, revalidatePath, refresh를 호출하거나 쿠키를 변경했을 때에만 Next.js가 현재 라우트를 서버에서 다시 렌더링해, 같은 응답에 새 RSC Payload를 함께 담아 보낸다. 아무것도 하지 않은 Action은 반환값만 들고 돌아오고 화면은 그대로다.
여기에 Optimistic UI(서버 응답 전에 성공을 가정하고 화면을 먼저 바꾸는 방식)를 얹으면 클릭하는 순간 목록이 10명에서 9명으로 줄고, Server Action이 DB DELETE를 마칠 때까지 사용자는 기다리지 않는다. 실패하면 줄었던 목록은 다시 10명으로 돌아온다.