React 서버 함수(use server)와 인증
관리자만 들어올 수 있는 화면에 상품을 지우는 버튼이 있습니다.
// app/admin/products/actions.js
"use server";
import { db } from "@/lib/db";
export async function deleteProduct(id) {
await db.product.delete({ where: { id } });
}// app/admin/products/page.jsx
export default async function AdminProducts() {
const session = await getSession();
if (!session?.isAdmin) redirect("/login");
// ... 목록과 함께 <DeleteButton id={p.id} /> 를 그린다
}페이지 맨 위에서 관리자인지 확인했으니 이 버튼은 관리자만 볼 수 있습니다. 그래서 deleteProduct 도 관리자만 부를 수 있다고 생각하기 쉽습니다. 실제로는 이 함수 안에 아무 확인이 없어서, 요청만 만들 수 있다면 로그인하지 않은 사람도 부를 수 있는 구조입니다.
서버 함수는 서버 코드를 그냥 import 해서 부르는 것이라는 생각
"use server" 를 붙인 함수는 클라이언트 컴포넌트에서 import 해서 평범한 async 함수처럼 부를 수 있습니다. await deleteProduct(id) 한 줄이면 서버에서 데이터베이스가 바뀝니다. API 경로를 만들고 fetch 를 쓰던 것에 비하면 서버와 클라이언트의 경계가 사라진 것처럼 느껴집니다.
그래서 서버 함수를 「서버 쪽 코드 조각을 클라이언트에서 호출하는 문법」으로 이해하게 됩니다. 함수는 페이지 안에서만 쓰이고, 페이지는 관리자만 여니, 함수도 페이지의 보호를 함께 받는다고 여깁니다.
경계는 사라지지 않았습니다. 보이지 않게 됐을 뿐입니다.
서버 함수 호출은 네트워크 요청이다
'use server' 문서는 호출이 실제로 무엇인지 적습니다.
When calling a Server Function on the client, it will make a network request to the server that includes a serialized copy of any arguments passed. If the Server Function returns a value, that value will be serialized and returned to the client.
await deleteProduct(id) 는 함수 호출처럼 보이지만, 프레임워크가 그 자리에 HTTP 요청을 만들어 넣습니다. 인자는 직렬화돼 요청 본문에 실리고, 서버는 그 요청을 받아 함수를 실행한 뒤 결과를 직렬화해 돌려보냅니다. "use server" 는 「이 함수를 서버에서 실행하라」가 아니라 **「이 함수를 외부에서 호출할 수 있는 입구로 만들어라」**에 가깝습니다.
서버 컴포넌트 글에서 "use server" 가 서버 컴포넌트 표시가 아니라고 짚었습니다. 서버 컴포넌트는 브라우저로 나가지 않는 코드이고, 서버 함수는 브라우저에서 들어올 수 있는 입구입니다. 방향이 반대입니다.
페이지의 보호가 서버 함수에 닿지 않는 이유
입구라는 점을 알고 나면 처음 코드의 구멍이 보입니다. 페이지의 관리자 확인은 그 페이지를 그리는 요청에서만 실행됩니다. 서버 함수 호출은 별도의 요청이고, 그 요청은 페이지 컴포넌트를 거치지 않습니다.
Next.js 16 문서는 이것을 분명히 적습니다.
A page-level authentication check does not extend to the Server Actions defined within it.
그리고 서버 함수가 UI 밖에서도 호출될 수 있다고 경고합니다.
Server Functions are reachable via direct POST requests, not just through your application's UI.
Next.js 는 서버 함수를 가리키는 ID 를 암호화된 예측 불가능한 값으로 만들어 빌드 사이에 주기적으로 다시 계산하고, 쓰이지 않는 서버 함수는 클라이언트 번들에서 빼는 식으로 노출을 줄입니다. 문서는 이것이 위험을 줄일 뿐이고, 여전히 각 서버 함수 안에서 인증과 권한을 확인하라고 덧붙입니다. 화면에 버튼이 없다는 것은 호출을 막는 장치가 아닙니다.
인자는 전부 클라이언트가 정한다
두 번째 함정은 인자입니다. React 문서의 보안 절 첫 문장입니다.
Arguments to Server Functions are fully client-controlled. For security, always treat them as untrusted input, and make sure to validate and escape arguments as appropriate.
deleteProduct(id) 의 id 는 우리 코드가 버튼에 넣어 준 값이지만, 요청을 직접 만드는 사람은 아무 값이나 넣을 수 있습니다. 다른 회사의 상품 id, 숫자 대신 객체, 빈 문자열이 모두 들어올 수 있습니다. 타입스크립트의 타입은 컴파일할 때만 존재하므로 이 요청을 막지 못합니다.
그래서 서버 함수 안에서 확인할 것은 셋입니다.
"use server";
export async function deleteProduct(rawId) {
const session = await getSession();
if (!session?.isAdmin) throw new Error("권한이 없습니다"); // 1. 누구인가 (인증)
const id = ProductId.parse(rawId); // 2. 값이 올바른가 (검증)
const product = await db.product.findUnique({ where: { id } });
if (!product || product.companyId !== session.companyId) { // 3. 이 대상에 대해 해도 되는가 (권한)
throw new Error("찾을 수 없습니다");
}
await db.product.delete({ where: { id } });
}세 번째를 빠뜨리기 쉽습니다. 로그인한 관리자라도 다른 회사의 상품 id 를 넣으면 지워지는 코드라면, 인증은 통과했지만 권한은 확인하지 않은 것입니다. Next.js 문서는 이 경우를 IDOR(안전하지 않은 직접 객체 참조) 취약점으로 부르며 인증과 별도로 권한을 확인하라고 안내합니다.
돌려주는 값도 경계를 넘는다
반대 방향도 같습니다. 서버 함수의 반환값은 직렬화되어 브라우저로 갑니다. 데이터베이스에서 읽은 사용자 행을 통째로 돌려주면, 화면에서 이름만 쓰더라도 비밀번호 해시나 내부 메모 필드까지 네트워크 응답에 실립니다. 개발자 도구의 네트워크 탭에서 누구나 볼 수 있습니다.
그래서 저는 서버 함수의 반환값을 「화면에 필요한 필드만 골라 만든 새 객체」로 두는 편이 맞다고 봅니다. Next.js 문서의 점검 목록에도 「반환값이 클라이언트에 필요한 것만 담도록 걸러졌는가」가 들어 있습니다.
변경에 쓰고 조회에는 쓰지 않는다
서버 함수가 fetch 보다 편하다 보니 데이터를 읽는 데도 쓰고 싶어집니다. React 문서는 이 용도를 권하지 않습니다.
Server Functions are designed for mutations that update server-side state; they are not recommended for data fetching. Accordingly, frameworks implementing Server Functions typically process one action at a time and do not have a way to cache the return value.
프레임워크가 서버 함수를 한 번에 하나씩 처리하고 결과를 캐시하지 않는다는 점이 이유입니다. 목록을 읽는 서버 함수를 여러 컴포넌트가 동시에 부르면 요청이 줄을 서고, 같은 내용도 매번 새로 가져옵니다. 데이터 조회는 서버 컴포넌트에서 렌더링하면서 읽는 쪽이 맞습니다. 서버 함수는 저장, 삭제, 상태 변경처럼 무언가를 바꾸는 일에 둡니다.
form 과 함께 쓸 때 얻는 것
서버 함수가 가장 자연스러운 자리는 <form action> 입니다.
<form action={updateName}>
<input name="name" />
<button>저장</button>
</form>React 문서에 따르면 form 의 action 으로 넘긴 서버 함수는 자동으로 transition 안에서 호출되고, 제출이 성공하면 form 이 초기화됩니다. useActionState 를 쓰면 처리 중 상태와 마지막 결과를 받을 수 있습니다.
Next.js 문서는 한 가지를 더 적습니다. 서버 컴포넌트에서 이렇게 만든 form 은 자바스크립트가 아직 로드되지 않았거나 꺼져 있어도 제출된다는 점입니다. 브라우저의 기본 form 제출이 곧 서버 함수 호출이 되기 때문입니다. 느린 네트워크에서 페이지가 막 뜬 순간에도 저장 버튼이 동작합니다.
이 성질은 앞의 보안 이야기와 같은 사실의 다른 면이라고 봅니다. 자바스크립트 없이도 호출된다는 것은, 우리 화면 없이도 호출될 수 있다는 뜻입니다.
여기까지가 확실한 부분
서버 함수 호출이 네트워크 요청이라는 점, 인자가 클라이언트 통제라는 보안 설명, 조회에 권하지 않는 이유, form action 에서의 동작은 react.dev 문서 기준입니다. 직접 POST 로 호출될 수 있다는 점, 페이지 인증이 서버 함수로 확장되지 않는다는 점, 암호화된 ID 와 IDOR 안내, 자바스크립트 없이 form 이 제출된다는 점은 Next.js 16 문서 기준입니다. 2024년 9월 전까지는 서버 함수 전체를 「Server Actions」라고 불렀기 때문에, 오래된 자료에서는 같은 기능이 그 이름으로 나옵니다.
함께 읽기
- React useMemo와 useCallback은 언제 써야 할까?리뷰에서 이런 코드를 자주 만납니다.
- React Error Boundary와 try/catch의 차이대시보드에 위젯이 여섯 개 있습니다. 그중 매출 차트 위젯이 서버에서 예상과 다른 모양의 데이터를 받아 렌더링 중에 오류를 냅니다.
- React useActionState와 useOptimistic으로 폼 다루기견적 요청 폼을 보내는 코드입니다.
- React 커스텀 훅은 state를 공유할까?장바구니 아이콘과 장바구니 페이지가 모두 담긴 상품 수를 보여 줘야 해서, 로직을 커스텀 훅으로 뺐다고 해 보겠습니다.
- React Context와 리렌더링로그인한 사용자 정보와 장바구니를 앱 어디서나 쓰려고 컨텍스트 하나를 만들었다고 해 보겠습니다.