Server Action과 Route Handler 차이, 그리고 두 캐시 계층 정리
서버에서 무언가를 실행하는 방법이 여러 개다. Route Handler로 HTTP endpoint를 열 수도 있고, Server Action으로 서버 함수를 직접 호출할 수도 있다. 그리고 그 뒤에는 서로 다른 캐시가 두 벌 놓여 있다. 브라우저의 TanStack Query 캐시와 Next.js 서버 쪽 캐시다.
둘을 같은 것으로 착각하면 데이터를 바꿔놓고도 화면이 안 바뀌는 상황을 만난다.
Next.js 서버와 Backend API 서버를 꼭 나눠야 하는 건 아니었다
작은 서비스라면 브라우저 아래에 Next.js가 있고, 그 아래에 바로 Database가 붙는 구성으로 끝난다. UI 렌더링, Route Handler, Server Action, 서버 측 데이터 접근을 Next.js 한 곳에서 처리한다.
서비스가 커지면 그 사이에 Backend API 서버를 하나 더 둔다. 브라우저에서 Next.js로, Next.js에서 Backend API로, 거기서 Database로 내려가는 모양이다. 대규모라면 Backend 안쪽이 다시 Service와 Repository 계층으로 쪼개진다.
Backend를 따로 두는 이유 중 하나는 클라이언트가 여러 개일 때다. Web만 쓰는 게 아니라 Mobile, Admin, 외부 파트너까지 같은 API를 쓴다면 Backend가 한가운데 있어야 한다.
다른 이유는 배포와 확장이다. Frontend와 Backend를 서로 독립적으로 배포할 수 있고, 트래픽 특성이 다르면 각각 따로 서버를 늘릴 수 있다. 복잡한 도메인 로직을 Next.js 바깥으로 빼두는 효과도 있다.
HTTP API는 데이터를 가져오는 수단이 아니라 통신 계약이었다
HTTP API는 프론트엔드가 데이터를 받아오는 통로이기 전에, 서로 다른 시스템 사이의 명시적인 계약에 가깝다.
GET /users
POST /orders
PATCH /users/1
DELETE /users/1이 목록 자체가 Web, Mobile, Admin, Partner, 다른 Backend가 공유하는 약속이다. Server Action이 생겼다고 HTTP API가 필요 없어지는 게 아닌 이유가 여기 있다.
Mutation은 개념이고 Server Action은 호출 방식이다
Mutation은 데이터를 변경하는 작업을 가리킨다. Create, Update, Delete가 대표적이다.
Mutation과 Server Action은 같은 층위가 아니다. Mutation은 무엇을 하는가를 나타내고, Server Action은 서버 작업을 어떤 방식으로 호출하는가에 가깝다. 그래서 하나의 Mutation을 구현하는 방법이 여러 갈래로 갈린다.
Route Handler와 Server Action은 인터페이스가 달랐다
App Router에서 Route Handler는 파일 경로로 endpoint가 정해진다.
app/api/users/route.ts그 안에 HTTP 메서드 이름의 함수를 만들면 그대로 endpoint가 된다.
export async function GET() {
const users = await getUsers();
return Response.json(users);
}브라우저가 GET /api/users로 요청을 보내면 Route Handler가 받아 JSON을 돌려준다. 주소가 겉으로 드러난다.
Server Action은 서버에서 실행되는 함수를 React와 Next.js의 Action 메커니즘(<form action>이나 transition을 통해 서버 함수를 호출하는 장치)으로 부르는 방식이다. 파일 맨 위에 "use server"를 붙인다.
"use server";
export async function updateUserName(name: string) {
await db.user.update({
...
});
}Client Component에서 이 함수를 호출하면 네트워크 경계를 넘어 서버에서 실행된다. 호출하는 쪽 코드만 보면 그냥 함수 호출이다.
둘 다 서버에서 작업을 수행하지만 목적과 인터페이스가 다르다. 여러 종류의 클라이언트가 함께 쓸 공개 API 계약이 필요하면 HTTP API가 맞고, 특정 UI의 동작과 서버 작업을 그대로 이어 붙이는 경우엔 Server Action이 편하다.
mutationFn 자리에 무엇이 들어가는지가 관건이었다
TanStack Query의 useMutation()은 데이터를 변경하는 작업의 상태와 생명주기를 관리하는 도구다. 요청 자체를 어떻게 보내는지는 mutationFn에 맡긴다.
const mutation = useMutation({
mutationFn: updateUser,
});mutationFn은 변수를 받아 Promise를 반환하는 함수면 된다. 그래서 여기 들어가는 게 HTTP API를 호출하는 fetch 함수일 수도 있고, Server Action일 수도 있다. Server Action도 async 함수라 타입상으로는 맞는다. 다만 TanStack Query 공식 문서가 이 조합을 명시하고 있지는 않다.
역할로 보면 TanStack Query는 Server State의 fetch와 cache, sync, mutation 생명주기를 관리하고, Server Action은 서버 작업을 호출하는 방식이다.
브라우저의 QueryClient는 Server Action이 닿을 수 없는 곳에 있다
브라우저의 QueryClient와 Server Action은 같은 메모리 공간에 있지 않다. QueryClient는 브라우저에, Server Action은 서버에 있는 서로 다른 실행 환경이다. Server Action 안에서 브라우저의 queryClient를 직접 조작하는 그림은 애초에 성립하지 않는다.
그래서 캐시 무효화는 Client Component 쪽에서 한다. Server Action을 기다린 다음, 돌아온 자리에서 쿼리를 무효화한다.
const queryClient = useQueryClient();
async function handleDelete() {
await deleteUser();
queryClient.invalidateQueries({
queryKey: ["users"],
});
}invalidateQueries는 v5에서 객체 형태로 인자를 받는다. 흐름은 클라이언트에서 출발해 서버를 찍고 다시 클라이언트로 돌아온다.
캐시는 두 벌이고, 서로를 모른다
TanStack Query 캐시는 브라우저 안에서 Server State를 들고 있다. ["users"] 같은 쿼리 키 단위로 QueryClient에 쌓인다.
Next.js의 캐시와 재검증은 서버 쪽 데이터와 렌더링 결과를 대상으로 한다. 사는 곳부터 다르다.
queryClient.invalidateQueries()와 revalidatePath()는 이름이 비슷해서 같은 일로 보이지만 같은 작업이 아니다. 각각 다른 캐시 계층을 건드린다.
updateTag와 revalidateTag는 이름만 닮았다
Server Action에서 DB를 변경한 뒤 Next.js 쪽 캐시를 재검증해야 하는 경우가 있다. DB 변경과 화면 갱신 사이에 재검증 단계가 한 번 더 들어간다.
updateTag("users")
revalidatePath("/users")함수마다 동작이 다르다. updateTag, revalidatePath, refresh를 호출하면 Next.js가 현재 라우트를 서버에서 다시 렌더링해 같은 응답에 새 RSC Payload를 담아 보낸다. updateTag는 Next.js 16에서 Server Action 전용으로 추가된 API이고, 쓴 직후 바로 읽는 동작을 보장한다.
revalidateTag는 다르다. 단일 인자 형태 revalidateTag(tag)는 deprecated 됐고, revalidateTag("users", "max")처럼 cacheLife 프로필을 두 번째 인자로 넘겨야 한다. 이 경우 stale-while-revalidate로 동작해서 그 Action의 응답에는 재렌더링이 포함되지 않고 이후 요청부터 적용된다.
한 번의 Server Action이 만드는 영향은 세 갈래로 벌어진다.