proxy.ts 와 라우트 핸들러, 그리고 배포
Article
11강에서 Server Actions를 배우며 API Route를 만들 필요가 없다고 했다. 데이터를 바꾸는 일은 그걸로 충분하다.
그런데 두 가지가 남았다.
우리 화면이 아닌 곳에서 부르는 주소가 필요할 때가 있다. 결제사가 보내는 웹훅, 모바일 앱이 읽는 JSON, CMS가 던지는 알림 같은 것이다. 브라우저에 렌더링할 화면이 없으니 page.js로는 안 된다.
그리고 요청이 페이지에 닿기 전에 가로채야 할 때가 있다. 로그인하지 않은 사람을 대시보드 근처에도 못 오게 막는 일이다.
route.js — 화면 없는 주소
page.js 대신 route.js를 두면 그 폴더는 화면이 아니라 엔드포인트가 된다.
// app/api/posts/route.js
export async function GET(request) {
const { searchParams } = request.nextUrl;
const page = searchParams.get("page") ?? "1";
const posts = await getPosts({ page: Number(page) });
return Response.json(posts);
}
export async function POST(request) {
const body = await request.json();
if (!body.title) {
return Response.json({ error: "제목이 없다" }, { status: 400 });
}
const post = await db.post.create({ data: body });
return Response.json(post, { status: 201 });
}
HTTP 메서드 이름으로 함수를 내보낸다. GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.
특별한 문법이 없다. **표준 Request와 Response**다. Express를 써봤다면 req, res 대신 이걸 쓴다고 생각하면 된다.
| page.js | route.js | |
|---|---|---|
| 돌려주는 것 | JSX | Response |
| 누가 부르나 | 브라우저 주소창 | 코드 · 외부 서비스 |
| 같은 폴더에 | 공존 불가 | 공존 불가 |
| 동적 세그먼트 | params 를 await | 두 번째 인자로 받아 await |
동적 주소도 똑같다. 5강의 규칙이 그대로 적용된다.
// app/api/posts/[id]/route.js
export async function GET(request, { params }) {
const { id } = await params; // 여기서도 await
const post = await getPost(id);
if (!post) return new Response("Not Found", { status: 404 });
return Response.json(post);
}
웹훅을 받는다
14강에서 revalidateTag는 라우트 핸들러에서도 부를 수 있다고 했다. 여기가 그 자리다.
// app/api/revalidate/route.js
import { revalidateTag } from "next/cache";
export async function POST(request) {
const secret = request.headers.get("authorization");
if (secret !== `Bearer ${process.env.REVALIDATE_SECRET}`) {
return Response.json({ error: "권한 없음" }, { status: 401 });
}
revalidateTag("posts", "max");
return Response.json({ revalidated: true });
}
CMS에서 글을 발행하면 이 주소를 두드린다. 그러면 캐시가 오래된 것으로 표시되고, 다음 방문자부터 새 글이 보인다.
updateTag는 여기서 못 쓴다. Server Action 전용이기 때문이다. 14강에서 본 그 제약이다.
비밀 확인을 빼먹으면 안 된다. 이 주소는 인터넷에 열려 있다. 11강에서 Server Action을 두고 한 말이 라우트 핸들러에도 그대로 적용된다.
middleware 가 proxy 가 됐다
이름이 바뀌었다. Next.js 16에서 middleware.ts는 deprecated고 **proxy.ts**가 됐다.
// proxy.js — 프로젝트 루트 (app 폴더와 같은 높이)
import { NextResponse } from "next/server";
export function proxy(request) {
const session = request.cookies.get("session");
if (!session) {
return NextResponse.redirect(new URL("/login", request.url));
}
}
export const config = {
matcher: "/dashboard/:path*",
};
파일 이름도, 내보내는 함수 이름도 proxy다. 아무것도 반환하지 않으면 요청이 그대로 흘러간다.
옛 코드는 코드모드로 옮긴다. 파일 이름과 함수 이름을 바꿔준다.
npx @next/codemod@canary middleware-to-proxy .
matcher 를 빼먹으면 CSS 까지 막힌다
config.matcher가 없으면 프록시는 모든 요청에서 실행된다. 페이지뿐 아니라 _next/static의 자바스크립트, _next/image의 이미지, public/ 폴더의 파일까지.
로그인 검사를 넣어뒀다면 CSS와 이미지도 로그인 페이지로 리다이렉트된다. 화면이 발가벗겨진다.
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
(?!…)는 정규식의 부정 전방탐색이다. 괄호 안의 것들로 시작하지 않는 경로에만 적용한다.
- 요청 도착
- next.config 의 headers
- next.config 의 redirects
- proxy.js
- 정적 파일 (public · _next)
- app/ 의 라우트
- 동적 라우트 [id]
프록시로 인증을 끝냈다고 생각하면 안 된다
여기가 이 강의에서 가장 중요한 문장이다.
Server Action은 별도의 라우트가 아니다. 11강에서 봤듯 그 액션이 쓰인 페이지 주소로 POST 요청이 간다.
그래서 matcher가 어떤 경로를 제외하면, 그 경로에서 호출되는 Server Action도 프록시를 거치지 않는다.
프록시로 충분해 보이는 것
착각
- "/dashboard 를 막았으니 안전하다"
- matcher 를 고치면 조용히 뚫린다
- 액션을 다른 라우트로 옮기면 뚫린다
실제로 필요한 것
겹겹이
- 프록시는 빠른 1차 차단
- Server Action 안에서 다시 확인
- 라우트 핸들러 안에서 다시 확인
- 데이터를 만지는 곳에서 확인
11강에서 "버튼을 숨기는 건 보안이 아니다"라고 했다. 여기에 하나 더 붙는다. 프록시로 막는 것도 보안의 전부가 아니다.
프록시는 쿠키가 있는지 정도만 싸게 확인한다. 진짜 검사는 데이터를 만지는 함수 안에서 한다.
배포한다
만든 것을 올린다.
npm run build # .next 폴더에 결과물이 생긴다
npm start # 그 결과물로 서버를 띄운다
npm run dev가 아니다. 12강에서 봤듯 개발 서버는 모든 페이지를 요청마다 다시 그린다. 정적 페이지도, 프리페치도, 캐시도 프로덕션에서만 제대로 동작한다.
| 올리는 곳 | 필요한 것 |
|---|---|
| Vercel | 저장소를 연결하면 끝 |
| Node.js 서버 | next build → next start |
| Docker | output: "standalone" 으로 빌드 |
| 정적 호스팅 | output: "export" — 서버 기능을 전부 포기 |
마지막 줄을 주의한다. output: "export"는 HTML 파일만 뽑는다. Server Actions도, 라우트 핸들러도, 프록시도, "use cache"도 동작하지 않는다. 우리가 배운 것의 절반이 사라진다.
- 01
빌드가 통과하는가
13강의
Uncached data was accessed outside of <Suspense>같은 에러는 빌드할 때만 나온다. - 02
환경변수를 옮겼는가
.env.local은 저장소에 올라가지 않는다. 배포 플랫폼에 따로 넣는다.NEXT_PUBLIC_이 붙은 것만 브라우저로 간다. - 03
빌드 출력의 ○ 와 ƒ 를 확인했는가
정적이어야 할 페이지가
ƒ로 나온다면 어딘가에서cookies()를 부르고 있다. - 04
배포 직후가 느린 것은 정상이다
14강에서 봤듯 빌드 ID 가 바뀌면 캐시가 전부 무효화된다.
18강을 마치며
여기까지 왔다. 고생했다.
1강에서 "React만으로는 부족한 것"이라 했다. 라우팅이 없고, 서버 렌더링이 없고, 데이터를 가져올 자리가 마땅치 않았다. 이제 그 셋을 전부 갖췄다.
이 시리즈를 관통한 것이 넷 있다. 하나씩 짚고 끝낸다.
폴더 이름이 곧 규칙이다
설정 파일을 쓴 적이 없다. app 아래 폴더를 만들면 주소가 생기고(2강), 약속된 이름을 두면 로딩과 에러 화면이 생기고(4강), 대괄호를 치면 변수가 되고(5강), 괄호와 골뱅이를 붙이면 주소를 만들지 않는 폴더가 됐다(7강).
우리가 Next.js를 부르지 않는다. Next.js가 우리 코드를 찾아 부른다.
서버가 먼저, 브라우저는 나중
console.log가 터미널에 찍히는 것(3강)에서 시작했다. 서버가 컴포넌트를 실행해 HTML과 RSC Payload를 만들고, 클라이언트 컴포넌트의 자리는 비워둔다(8강). 그래서 클라이언트가 서버를 import 할 수 없고, children으로 받아야 한다(9강). 그리고 이 순서가 있기에 HTML을 조각내어 흘려보낼 수 있다(15강).
순서를 이해하면 나머지는 따라온다.
요청이 있어야 아는 값은 Promise 다
params를 await 하는 게 낯설었다(5강). useSearchParams를 <Suspense>로 감싸야 하는 것도 이상했다(6강). 12강에서 이유가 드러났다. await 하기 전까지는 미리 그릴 수 있다. 13강의 "use cache"는 이 선을 컴포넌트 단위로 옮긴 것이다.
캐시는 켜는 것이다
10강의 요청 메모이제이션은 한 번의 렌더링 동안만 산다. 오래 저장하는 캐시는 꺼져 있다(12강). "use cache"로 켜고(13강), 태그를 붙여 사건이 일어나면 버린다(14강).
옛 글은 "Next.js가 알아서 캐싱한다"고 말한다. 지금은 아니다. 우리가 고른다.
이 시리즈의 코드는 전부 Next.js 16 기준이다. 검색해서 나오는 예제 대부분은 15 이전이라 문법이 다르다. 세 가지 표식만 기억하면 옛 글을 알아볼 수 있다.
params.id를await없이 쓴다fetch가 기본으로 캐싱된다고 말한다middleware.ts와priorityprop이 나온다
여기서 멈추지 않았으면 한다. 만들다 보면 이 시리즈가 다루지 않은 것들을 만난다. 인증, 테스트, 국제화, 모니터링. 그것들은 대개 Next.js의 문제가 아니라 웹의 문제다. 지금까지 배운 경계 감각이 거기서도 그대로 쓰인다.
어디서 실행되는가. 언제 실행되는가. 누가 볼 수 있는가.
이 세 질문을 계속 던지면 된다.
정리하면
route.js는 화면 없는 주소를 만든다. **표준Request와Response**를 쓴다.page.js와route.js는 같은 폴더에 둘 수 없다.- 웹훅에서는
revalidateTag를 쓴다.updateTag는 Server Action 전용이다. - 라우트 핸들러도 인터넷에 열려 있다. 비밀을 확인한다.
middleware.ts는 **proxy.ts**가 됐다. 코드모드로 옮긴다.matcher를 빼먹으면 CSS와 이미지까지 가로챈다.- Server Action은 별도 라우트가 아니다. 프록시로 인증을 끝냈다고 믿으면 안 된다. 액션 안에서 다시 확인한다.
- 프록시에서 DB를 조회하지 않는다. 앱 런타임 바깥에서 돌 수 있다.
- 배포 전에
npm run build를 돌려본다. 빌드에서만 나오는 에러가 있다. output: "export"는 서버 기능을 전부 포기하는 선택이다.
이 시리즈는 여기서 끝난다. 읽어줘서 고맙다.