Next.js Server Action 요청 흐름과 Next-Action 스캔 요청

운영 중인 Next 서버에 Server Reference ID did not match 에러 로그가 쌓였고, 인프라에서 Next-Action 헤더가 붙은 요청을 로드밸런서에서 403으로 우선 조치한 상태에서 이 조치를 유지할지 논의하면서 Server Action이 어떤 요청으로 동작하는지부터 다시 정리했다.
Next 15.5 기준이다. RSC 페이로드와 prefetch는 Next.js App Router 동적 렌더링과 페이지 전환 지연 참고.


1. 'use server'는 무엇을 표시하는가

헷갈렸던 점

  • 'use server'는 “이 함수는 서버에서만 돈다”는 표시가 아닌가?
  • 서버 값이 필요한 클라이언트 컴포넌트에 'use server'를 붙이는 것인가?

정리

'use server'는 브라우저가 이 함수를 호출할 수 있게 하라는 지시다. 함수 본문은 서버에서 실행되고, 브라우저에는 서버로 요청을 보내는 코드만 내려간다.

이렇게 브라우저가 호출할 수 있게 된 함수를 Server Function이라고 부른다.

Server Action은 그 Server Function을 React의 액션 경로로 호출하는 것을 가리킨다. Next 문서는 <form action>, <button formAction>, 클라이언트 트랜지션으로 호출되는 Server Function을 Server Action이라고 정의한다.

붙이는 대상은 async 함수. 파일 맨 위에 붙이면 그 파일의 export 함수 전부가, async function 본문 첫 줄에 붙이면 그 함수 하나가 Server Function이 된다. 네트워크 호출이 항상 비동기이므로 React는 async 함수에만 허용한다. 'use server' 파일에서 async가 아닌 함수를 export하면 Next 빌드가 아래 에러로 멈춘다.

Only async functions are allowed to be exported in a "use server" file.

서버 전용 헬퍼에는 import 'server-only'를 쓴다.

cookies(), headers()처럼 서버에서만 동작하는 API를 쓰는 함수에 'use server'를 붙이면, 클라이언트 컴포넌트가 그 함수를 import해도 빌드가 통과한다. React 문서가 'use server' 모듈은 클라이언트 코드에서 import할 수 있다고 명시하기 때문이다. 이 경우 런타임에 Server Action 요청이 나간다. import 'server-only'를 쓰면 같은 import가 빌드 에러가 된다. Next가 이 패키지 이름을 내부에서 처리하므로 설치하지 않아도 동작한다.

원하는 것 쓰는 것
서버 값으로 그리는 컴포넌트 지시어 없음(서버 컴포넌트)
브라우저 상호작용이 있는 컴포넌트 'use client'
브라우저가 호출해서 서버 작업을 시키는 함수 'use server'
서버에서만 쓰는 헬퍼 함수 import 'server-only'

Server Function을 서버 컴포넌트나 미들웨어에서만 부르면 일반 함수 호출이다. HTTP 요청이 생기지 않는다.

2. Server Action 호출은 어떤 요청이 되는가

헷갈렸던 점

  • Next-Action 헤더는 빌드할 때 함수에 넣는 것인가, 호출할 때 붙는 것인가?
  • 요청은 어느 서버로 가는가?

정리

빌드 단계와 호출 단계로 나뉜다.

빌드할 때 Next는 클라이언트 번들에서 Server Function을 아래 코드로 바꾼다. 함수 본문은 서버 번들에만 남는다. Next 문서는 이것을 “함수 구현을 액션 ID와 디스패처로 이루어진 참조로 교체한다”고 설명한다.

export const saveSort = createServerReference("<빌드 때 만든 ID>", callServer, ...)

브라우저에서 이 함수를 호출하면 Next 클라이언트 라우터가 요청을 보낸다.

fetch(state.canonicalUrl, {
  method: 'POST',
  headers: { 'Next-Action': actionId, ... },
  ...
})
  • 요청 주소는 별도 API 경로가 아니라 지금 보고 있는 페이지 URL이다.
  • 요청을 받는 쪽은 그 페이지를 내려준 Next 서버다. 함수 본문은 이 서버에서 실행된다.
  • 헤더를 붙이는 것은 함수가 아니라 Next 클라이언트 런타임이다.

3. Next 서버는 요청을 헤더로 구분한다

Next 클라이언트 라우터는 <Link>, router.push로 이동할 때와 Server Action을 호출할 때 모두 같은 Next 서버로 요청을 보낸다. 서버는 헤더를 보고 무엇을 돌려줄지 정한다.

상황 요청 응답
첫 진입, 새로고침 일반 GET HTML + RSC 페이로드
<Link>, router.push 이동 GET + RSC: 1 RSC 페이로드
Server Action 호출 POST + Next-Action: <ID> 함수 결과. 액션 안에서 revalidatePath, revalidateTag를 불렀으면 화면 갱신용 RSC 페이로드도 함께

window.location.href, 일반 <a> 태그, 새로고침은 Next 라우터를 거치지 않으므로 첫 진입과 같이 HTML을 받는다.

서버 컴포넌트의 렌더 결과는 HTML이 아니라 RSC 페이로드다.

0:["$","div",null,{"className":"title","children":"역삼동 원룸"}]

HTML은 첫 진입 때 Next 서버가 이 페이로드와 클라이언트 컴포넌트 코드를 가지고 한 번 더 렌더해서 만든다(SSR). 클라이언트 컴포넌트도 이때 서버에서 HTML로 그려진다. 화면 전환 때는 이 변환을 하지 않고, 브라우저의 React가 페이로드로 DOM을 직접 고친다. Next 문서도 이후 이동에서는 클라이언트 컴포넌트가 서버 HTML 없이 브라우저에서만 렌더된다고 설명한다.

4. 하이드레이션

헷갈렸던 점

  • 하이드레이션은 RSC 페이로드로 하는 것인가, JS 번들로 하는 것인가?
  • mismatch가 나면 경고만 뜨고 클라이언트 기준으로 맞춰지는가?

정리

하이드레이션은 둘 다 쓴다. RSC 페이로드는 트리 구조와 클라이언트 컴포넌트에 넘길 props를 주고, JS 번들은 클라이언트 컴포넌트 코드를 준다.

  1. React가 RSC 페이로드로 트리를 구성한다.
  2. 클라이언트 컴포넌트 자리에서 JS 번들의 코드를 실행해 렌더한다. 상태가 초기화된다.
  3. 렌더 결과를 서버 HTML로 만든 DOM과 맞춘다. DOM을 새로 만들지 않고 재사용한다.
  4. 이벤트를 연결하고 useEffect를 실행한다.

서버 컴포넌트 부분은 상태와 이벤트가 없어서 DOM을 트리에 연결하는 것 외에 할 일이 없다.

mismatch는 어긋난 종류에 따라 처리가 다르다.

어긋난 것 React의 처리 화면
텍스트, 태그 구조 가장 가까운 Suspense 경계 안쪽을 버리고 브라우저에서 새로 렌더. 경계가 없으면 루트 전체 브라우저 렌더 결과
속성(className, style 등) 고치지 않음. 개발 환경에서만 경고 서버 HTML 값이 남음

React 18 업그레이드 가이드가 첫 줄의 근거다. 텍스트 mismatch를 노드 단위로 고치던 동작을 없애고, 가장 가까운 <Suspense> 경계까지 클라이언트 렌더로 되돌린다고 적혀 있다. 둘째 줄은 hydrateRoot 문서의 “속성 차이가 패치된다는 보장은 없다”는 문장과, React 소스의 경고 문구 “This won’t be patched up”이 근거다.

운영 환경에서는 사용자 화면에 에러가 보이지 않는다. React가 복구 가능한 에러로 처리해 onRecoverableError로 넘기고 콘솔에만 남긴다. 속성 mismatch는 화면이 서버 값으로 남기 때문에 경고를 놓치면 버그로 이어진다.

5. Server Reference ID did not match 로그

헷갈렸던 점

  • 이 로그는 등록되지 않은 Server Action ID로 요청했을 때 나는 것인가?

정리

에러 전문은 아래와 같다.

The Server Reference ID did not match the expected format. Received "...".

Next 소스(server/app-render/action-utils.js)를 보면 두 에러를 구분한다.

에러 조건
The Server Reference ID did not match the expected format ID 길이가 정해진 길이(42자)와 다름
Failed to find Server Action 길이는 맞지만 빌드 산출물에 등록되지 않은 ID

로그가 쌓인 원인은 외부 스캐너였다. 서버는 POST 요청에 Next-Action 헤더가 있으면 Server Action 처리 경로로 보낸다(server/lib/server-action-request-meta.js). 헤더 값이 실제 함수 ID인지는 그 뒤에 검사한다. 그래서 브라우저 없이 curl 같은 도구로 아무 페이지 URL에 헤더를 붙여 POST를 보낼 수 있다. 스캐너는 RSC 역직렬화 취약점을 노리고 형식에 맞지 않는 값을 넣어 보냈고, Next가 형식 검사에서 거부하면서 에러 로그를 남겼다.