Next.js Server Actions: JavaScript가 로드되기도 전에 작동하는 폼
Next.js Server Actions: JavaScript가 로드되기도 전에 작동하는 폼
리액트의 고전적인 폼 패턴은 onSubmit, event.preventDefault(), fetch('/api/todos', { method: 'POST', body: JSON.stringify(...) }), 그리고 로딩 상태를 다루기 위한 useState입니다. 작동은 합니다 – 다만 쉽게 잊어버리는 한 가지 조건이 있는데, 빠른 회선을 가진 자신의 노트북에서는 절대 드러나지 않기 때문입니다. 폼이 클릭에 반응하기 시작하려면 JavaScript가 먼저 로드되고, 파싱되고, 실행되어야 합니다. 느린 모바일 연결에서, 큰 페이지가 하이드레이션되는 도중에, 혹은 브라우저 확장 프로그램이 스크립트를 차단했을 때 – 사용자는 "전송"을 클릭하지만 말 그대로 아무 일도 일어나지 않습니다. 오류도 없고 스피너도 없습니다 – onClick이 그저 아직 존재하지 않는 것입니다.
Next.js의 Server Actions는 흔히 정확히 같은 호출을 위한 더 편리한 문법 정도로 소개되곤 합니다 – "fetch인데 API 라우트를 쓸 필요가 없는 것"이라고요. 이런 단순화는 이 메커니즘에서 실제로 새로운 부분을 놓칩니다. <form action={myAction}>은 JavaScript를 필수 조건으로 삼는 것이 아니라, 브라우저가 원래부터 갖고 있던 네이티브 폼 전송 메커니즘에 기반합니다. 이는 내부적으로 어떻게 작동하는지뿐만 아니라, "use server"를 그저 로컬 함수 호출처럼 다루는 팀들이 실무에서 저지르는 실수의 종류까지도 바꿔놓습니다.
"use server" 지시어가 실제로 하는 일
함수에 "use server"를 표시하면 Next.js 컴파일러는 그 함수의 본문을 브라우저가 받는 번들에 남겨두지 않습니다. 본문을 잘라내고 그것을 참조로 바꿉니다 – 서버에서 어떤 함수를 호출해야 하는지를 가리키는 암호화된 식별자입니다. 실제로 클라이언트에 도달하는 것은 작은 스텁입니다. "나를 호출하면, 이 식별자와 직렬화된 인자를 담아 특수한 RSC 엔드포인트로 POST 요청을 보내라"는 것이죠. 로직 자체 – 데이터베이스 쿼리, 검증, 그 함수가 하는 무엇이든 – 는 물리적으로 결코 서버를 떠나지 않습니다.
tsx
// app/actions.ts"use server";import { revalidatePath } from "next/cache";import { db } from "@/lib/db";export async function addTodoAction(prevState: unknown, formData: FormData) {const title = formData.get("title");if (typeof title !== "string" || title.trim().length === 0) {return { error: "제목은 비워둘 수 없습니다." };}await db.todo.create({ data: { title: title.trim() } });revalidatePath("/todos");return { error: null };}
이 함수를 <form action={addTodoAction}>에 연결하면, 일반적인 fetch는 결코 공짜로 주지 않던 일이 일어납니다. JavaScript가 실행되기도 전에 작동한다는 것입니다. 브라우저는 action 속성이 URL을 가리키면 원래부터 폼을 네이티브 POST로 전송할 수 있었습니다 – React 19와 Next.js는 이를 확장해 action이 서버 함수일 수도 있게 하며, JS가 아직 준비되지 않았다면 브라우저는 어차피 자신의 네이티브 경로를 실행합니다. JavaScript가 이미 작동 중이라면, Next.js가 그 이벤트를 가로채 백그라운드에서 fetch를 통해 똑같은 일을 하고, 페이지 새로고침 없이 부드럽게 UI를 업데이트합니다. 이는 여러분이 직접 프로그래밍해야 하는 것이 아니라 프레임워크에 내장된 점진적 향상입니다 – 접근성 글에서 RouteAnnouncer를 다룰 때 언급했던 것과 정확히 같은 메커니즘이지만, 여기서는 내비게이션 대신 데이터 변경(mutation)에 적용된 것입니다.
실전 예제: 수동 fetch 없이 상태를 완전히 처리하는 폼
React 19는 바로 이 패턴을 위해 useActionState를 추가했습니다 – 서버 액션 호출을 그 마지막 실행이 반환한 상태와 연결해 주며, 오류나 로딩 상태를 저장하기 위한 수동 useState가 필요 없습니다.
tsx
"use client";import { useActionState } from "react";import { addTodoAction } from "./actions";const initialState = { error: null };const TodoForm = () => {const [state, formAction, isPending] = useActionState(addTodoAction,initialState,);return (<form action={formAction}><input type="text" name="title" placeholder="새 할 일" required /><button type="submit" disabled={isPending}>{isPending ? "추가 중…" : "추가"}</button>{state.error && <p className="form-error">{state.error}</p>}</form>);};export default TodoForm;
여기서는 요청을 수동으로 관리하기 위한 코드 한 줄 없이 여러 가지 일이 동시에 일어납니다. isPending은 폼 전송의 실제 상태를 반영합니다 – 클라이언트 측 호출뿐 아니라 그 네이티브한, 하이드레이션 이전의 POST까지 포함해서 말이죠. state.error는 서버 함수가 반환한 것을 그대로 담고 있습니다 – JSON 응답 파싱도, fetch를 감싸는 try/catch도 필요 없습니다. 액션 안의 revalidatePath("/todos")는 Next.js에게 그 경로의 캐시를 새로고침하라고 지시하므로, 페이지의 할 일 목록은 클라이언트 상태를 수동으로 새로고침하지 않아도 새 항목을 보여줍니다 – 새 RSC 페이로드를 언제 어떻게 가져올지 결정하는 것은 여러분이 아니라 프레임워크입니다.
함정과 좋은 습관
"use server"가 붙은 모든 함수는 공개 HTTP 엔드포인트이지 사적인 함수가 아닙니다. 이는 Server Actions에서 가장 흔한 보안 실수입니다. UI의 어떤 것도 직접 링크하지 않으니 "숨겨져 있다"고 생각하기 쉽습니다. 그렇지 않습니다 – 컴파일러는 그 함수를 위해 외부에서 호출 가능한 영구적인 액션 식별자를 생성하므로, 그것을 알아낸 사람은 누구든(예를 들어 DevTools에서 네트워크 트래픽을 살펴보는 것만으로도) 여러분의 UI와 그 전제를 완전히 건너뛰고 직접 그 함수를 호출할 수 있습니다. 입력값 검증과 권한 확인(세션, 사용자 역할)은 고전적인 API 엔드포인트에서와 정확히 마찬가지로 액션 자체 내부에서 수행해야 합니다 – "버튼이 로그인하지 않은 사용자에게는 보이지 않는다"는 사실에 절대 의존하지 마십시오.- 인자와 반환값은 직렬화가 가능해야 합니다. Server Actions는 리액트의 직렬화 형식(JSON보다 더 풍부하며,
Date,Map, 폼에서 직접 전달된FormData등을 지원합니다)으로 네트워크를 통해 데이터를 전송하지만, 함수, 클래스 인스턴스, DOM 객체 참조는 여전히 통과시키지 못합니다. 클라이언트에서 호출할 때 추가 인자를 고정하기 위해.bind()를 사용한다면(addTodoAction.bind(null, listId)),listId역시 직렬화가 가능해야 한다는 점을 기억하십시오. - 이는 여전히 실제 네트워크 요청이지 로컬 함수 호출이 아닙니다.
await myAction(data)가 문법적으로 일반 JS 함수 호출과 똑같아 보이기 때문에 이 사실을 잊기 쉽습니다. 내부적으로는 항상 완전한 왕복 과정입니다. 직렬화, HTTP, 반대편에서의 역직렬화까지 말이죠. 텍스트 필드에 입력되는 글자마다(예를 들어 실시간 검증을 위해) 루프 안에서 Server Action을 호출하면, 여러분의 폼은 반응성 있는 입력 필드가 아니라 요청 생성기로 변해버립니다 – 이런 경우에는 다른 어떤 네트워크 호출에서와 마찬가지로 디바운스가 똑같이 필수적입니다. revalidatePath/revalidateTag가 없으면 UI는 오래된 캐시에 머무릅니다. Next.js는 기본적으로 페이지 데이터를 캐시합니다 – 데이터베이스에 무언가를 저장하지만 프레임워크에게 어느 경로를 새로고침할지 알려주지 않는 액션은 흔히 "저장은 됐는데 화면에 안 보인다"는 문제로 이어집니다. 데이터베이스의 데이터는 올바른데, UI는 여전히 오래된 캐시 버전의 페이지를 보여주는 것입니다.
마무리
Server Actions는 "더 예쁜 문법의 fetch"가 아닙니다 – 같은 메커니즘 위에 지어진 서로 다른 두 가지입니다. 첫째, 이는 브라우저의 네이티브 폼 전송에 기반하므로, JavaScript가 로드되기도 전에 폼이 작동합니다 – 이 동작을 위해 여러분이 코드를 한 줄도 작성할 필요가 없으며, <form action={...}>이라는 아키텍처 자체에서 얻어지는 것입니다. 둘째, 그리고 보안 관점에서 더 중요한 점은, "use server"가 표시된 모든 함수는 눈에 보이는 UI 요소가 그것을 호출하든 말든 상관없이 외부에서 호출 가능한 실제 네트워크 엔드포인트가 된다는 것입니다. 검증과 인가를 내부에 넣지 않고 로컬의 신뢰할 수 있는 함수처럼 취급하는 것은, 편리한 문법을 보안이 없는 API 엔드포인트로 바꿔버리는 가장 손쉬운 방법입니다.