Next.js 라우트 핸들러와 서버 함수 구분
앱 라우터에서 서버로 무언가를 보내는 방법이 둘입니다. app/api/.../route.ts 에 라우트 핸들러를 만들거나, "use server" 를 붙인 서버 함수를 부르는 것입니다. 폼 하나를 붙일 때마다 어느 쪽을 쓸지 정해야 하는데, 「둘 다 서버에서 도니 아무거나」로 두면 나중에 갈립니다.
라우트 핸들러는 예전 API 라우트의 새 이름이라는 생각
Pages 라우터를 쓰던 사람은 pages/api 가 app/api 로 옮겨 온 것으로 이해하기 쉽습니다. 라우트 핸들러 문서도 그 관계를 인정합니다. 라우트 핸들러는 app 디렉터리 안에서만 쓸 수 있고 Pages 의 API 라우트에 해당하므로 둘을 함께 쓸 필요가 없다고 적습니다.
다만 앱 라우터에는 없던 선택지가 하나 늘었습니다. 서버 함수입니다. 화면에서 데이터를 바꾸는 일에는 굳이 엔드포인트를 만들지 않아도 됩니다. 그래서 질문이 「API 를 어떻게 만드나」가 아니라 **「이 동작에 공개된 주소가 필요한가」**로 바뀝니다.
주소가 필요한 일과 필요 없는 일
라우트 핸들러는 웹 표준의 Request 와 Response 로 주소 하나를 만드는 일입니다. 문서의 정의도 그렇습니다. 그래서 다음 경우에 맞습니다.
- 외부 서비스가 부르는 웹훅 수신 (결제 대행사, 발급 대행사, 메신저 플랫폼)
- HTML 이 아닌 응답을 내보내는 곳 (RSS, 사이트맵, CSV 내보내기, 이미지)
- 모바일 앱이나 다른 서비스가 쓰는 공개 API
- 헬스 체크처럼 사람이 아니라 기계가 부르는 주소
서버 함수는 반대입니다. 우리 화면에서만 부르고, 주소가 노출될 이유가 없는 동작입니다. 저장, 삭제, 상태 변경이 여기 해당합니다. 서버 함수에도 실제로는 요청이 오가지만 그 주소는 프레임워크가 관리합니다.
그래서 저는 질문을 이렇게 씁니다. 우리 화면 말고 다른 무언가가 이 동작을 불러야 하는가. 그렇다면 라우트 핸들러, 아니라면 서버 함수입니다.
조회는 컴포넌트에서, 변경은 서버 함수에서
한 가지를 더 가려야 합니다. 화면에 데이터를 보여 주는 일은 둘 중 어느 쪽도 아닐 때가 많습니다. 서버 컴포넌트가 렌더링하면서 직접 읽으면 되기 때문입니다.
app/api/products/route.ts 를 만들고 서버 컴포넌트에서 fetch("/api/products") 로 부르는 코드를 종종 봅니다. 같은 서버 안에서 자기 자신에게 HTTP 요청을 한 번 더 보내는 구조입니다. 서버 컴포넌트에서 데이터베이스나 라이브러리를 직접 부르면 그 왕복이 사라집니다.
React 문서도 서버 함수를 데이터 조회에 권하지 않는다고 적습니다. 프레임워크가 서버 함수를 한 번에 하나씩 처리하고 결과를 캐시하지 않기 때문입니다. 정리하면 이렇습니다.
| 하는 일 | 자리 |
|---|---|
| 화면에 보여 줄 데이터 읽기 | 서버 컴포넌트에서 직접 |
| 우리 화면에서 데이터 바꾸기 | 서버 함수 |
| 외부가 부르는 주소 | 라우트 핸들러 |
라우트 핸들러를 만들면 따라오는 것
주소를 만든다는 것은 누구나 부를 수 있는 입구를 하나 여는 일입니다. 서버 함수와 달리 주소를 우리가 정하므로 찾기도 쉽습니다. 그래서 라우트 핸들러에는 최소한 다음이 따라와야 합니다.
첫째, 호출자 확인. 웹훅이라면 서명 검증, 내부 전용 주소라면 토큰 검사입니다. 이 블로그의 캐시 무효화 엔드포인트도 헤더의 토큰을 상수 시간 비교로 확인하고, 틀리면 401 을 돌려줍니다.
둘째, 입력 검증. 본문과 쿼리 문자열은 모두 호출자가 정한 값입니다. 서버 함수의 인자와 같은 취급이 필요합니다.
셋째, 응답 형식과 상태 코드. 라우트 핸들러는 우리가 Response 를 직접 만들므로 상태 코드와 헤더를 정확히 정할 수 있습니다. 이것은 부담이 아니라 장점입니다. 캐시 헤더가 필요한 응답, 예를 들어 사이트맵이나 피드를 라우트 핸들러로 두면 신선도를 직접 제어할 수 있습니다.
프레임워크 밖에서 부르는 쪽을 생각하기
Next.js 문서는 이 패턴을 「Backend for Frontend」라고 부릅니다. HTML 이 아닌 콘텐츠를 내보내는 공개 엔드포인트를 만들고 데이터 소스에 접근하는 구조입니다.
제 기준으로는 여기에 버전 관리의 부담이 붙습니다. 라우트 핸들러의 주소와 응답 모양은 한 번 밖으로 나가면 우리 화면만 고쳐서는 바꿀 수 없습니다. 웹훅을 등록해 둔 외부 서비스, 모바일 앱, 고객사의 스크립트가 그 모양에 묶입니다. 서버 함수는 우리 코드 안에서만 쓰이므로 이름과 인자를 자유롭게 바꿀 수 있습니다.
그래서 새 동작을 만들 때는 서버 함수로 시작하고, 밖에서 부를 필요가 생기는 순간 라우트 핸들러를 추가하는 순서가 안전하다고 봅니다. 반대 순서, 즉 일단 API 부터 만들어 두고 우리 화면이 그것을 부르는 구조는 왕복과 버전 부담을 처음부터 떠안습니다.
여기까지가 확실한 부분
라우트 핸들러가 app 디렉터리 전용이고 Pages 의 API 라우트에 대응한다는 점, 웹 표준 Request·Response 를 쓴다는 점, Backend for Frontend 패턴에 대한 문서의 설명은 Next.js 16 문서 기준입니다. 서버 함수를 데이터 조회에 권하지 않는다는 설명은 react.dev 문서 기준입니다. 「서버 함수로 시작하고 필요할 때 라우트 핸들러를 추가한다」는 순서는 제 판단이고, 외부 연동이 먼저 정해진 프로젝트라면 반대가 맞을 수 있습니다.
함께 읽기
- Next.js 병렬 라우트와 인터셉트 라우트: 모달 패턴과 default.js사진 목록에서 사진을 누르면 모달로 크게 보여 주고, 같은 주소를 새 탭에서 열면 전체 페이지로 보여 주는 화면이 있습니다. 주소는 /photo/123 하나인데 보이는 모양이 둘입니다.
- Next.js 데이터 가져오기 순서: 순차와 병렬, 그리고 Suspense 위치서버 컴포넌트에서 데이터를 읽는 코드는 이렇게 생깁니다.
- Next.js 다국어 경로 설계: 쿠키와 하위 경로공개하지 않고 내부에서만 여는 제품 카탈로그 데모에 언어 전환을 붙였습니다. 한국어, 영어, 중국어, 일본어, 독일어, 프랑스어 여섯 개입니다. 고른 언어는 쿠키에 저장하고, 쿠키가 없으면 브라우저의 Accept-Language 헤더를 봅니다. 주소는 나누지 않았습니다. /products/<기종> 하나로 여섯 언어를 모…
- Next.js 이미지 최적화: next/image와 빌드 때 굽기제품 카탈로그 데모를 만들면서 이미지 처리 방식을 정해야 했습니다. 화면에 나가는 사진은 제품 사진 몇 장과 로고뿐이고, 모두 저장소에 들어 있는 파일입니다. 요청마다 달라질 것이 없습니다.
- Next.js 환경 변수: NEXT_PUBLIC_ 인라인과 로드 순서같은 Docker 이미지를 스테이징과 운영에 함께 올리는 구성이 있습니다. 환경 변수는 각 환경의 설정으로 주입합니다. 서버 쪽 값은 환경에 맞게 잘 바뀌는데, 브라우저에서 쓰는 분석 도구 ID 만 두 환경에서 같은 값이 나옵니다. 스테이징의 방문 기록이 운영 통계에 섞입니다.