Nitro 서버 엔진: 파일 기반 API 라우팅, 캐싱과 하이브리드 렌더링, 여러 런타임 배포
이 글의 핵심
Nitro는 한 번 작성한 서버 코드를 Node·Bun·Lambda·Cloudflare Workers 등 여러 런타임에 배포할 수 있게 해 주는 유니버설 서버 엔진입니다. 파일 기반 라우팅, 라우트 규칙 기반 캐싱, Unstorage 기반 스토리지, WebSocket을 다루면서, 런타임마다 달라지는 제약이 어디서 드러나는지 함께 설명합니다.
이 글의 핵심
Nitro는 UnJS 프로젝트가 만든 서버 엔진으로, Nuxt와 Analog, SolidStart 같은 풀스택 프레임워크의 서버 쪽을 담당합니다. HTTP 핸들러와 미들웨어, 빌드 출력, 배포 대상별 어댑터를 하나로 묶어, 같은 소스를 Node 서버, 서버리스 함수, Edge Worker 형태로 다시 빌드해 배포할 수 있게 합니다.
배포 대상이 다르면 서버 코드가 받는 입력부터 다릅니다. AWS Lambda는 API Gateway 이벤트 객체를 받고, Cloudflare Workers는 웹 표준 Request를 받아 Response를 돌려주며, 전통적인 Node 서버는 http.IncomingMessage를 다루고 파일 시스템과 긴 TCP 연결을 자유롭게 씁니다. Nitro는 이 차이를 프리셋(preset)과 런타임 어댑터 뒤로 숨기고, 개발자에게는 파일 기반 라우트와 defineEventHandler 같은 일관된 API를 제공합니다.
다만 Nitro가 숨겨 주는 것은 입력과 출력의 형식이지, 플랫폼의 제약 자체가 아닙니다. Edge에서는 Node 전용 모듈을 쓸 수 없고, 서버리스 함수에는 실행 시간 제한이 있으며, 장시간 연결이 필요한 WebSocket은 플랫폼마다 지원 여부가 다릅니다. 그래서 같은 코드를 여러 곳에 배포할 계획이라면 가장 제약이 많은 대상을 기준으로 코드를 작성하고, 그 프리셋으로 빌드하는 단계를 CI에 넣어 두는 것이 안전합니다.
이 글의 설정 예제는 Nitro 2(nitropack 패키지) 기준입니다. Nitro 3에서는 패키지 이름이 nitro로 바뀌고 일부 설정 키가 정리되므로, 버전을 올릴 때는 마이그레이션 문서를 함께 확인해야 합니다.
Nitro의 구성 요소
프리셋과 빌드 출력
Nitro는 서버 코드를 하나의 번들로 빌드한 뒤, 설정의 preset에 따라 출력 형태를 바꿉니다. node-server는 node .output/server/index.mjs로 실행하는 독립 서버를, vercel은 Vercel Functions 디렉터리 구조를, aws-lambda는 Lambda 핸들러를, cloudflare-module은 Workers 모듈 형식을 만듭니다. Vercel이나 Netlify처럼 빌드 환경을 감지할 수 있는 곳에서는 프리셋을 지정하지 않아도 자동으로 선택됩니다.
// nitro.config.ts (Nitro 2)
import { defineNitroConfig } from 'nitropack/config';
export default defineNitroConfig({
preset: 'node-server',
});
Nuxt에서는 nuxt.config.ts의 nitro: { preset: '...' }로 같은 값을 넘기거나, 빌드 시 NITRO_PRESET 환경 변수로 지정합니다. 빌드 과정에서 라우트 파일들이 라우트 테이블과 미들웨어 체인으로 컴파일되고 의존성도 번들에 포함되므로, .output 디렉터리만 배포하면 node_modules 없이 실행됩니다.
H3 이벤트 모델
Nitro의 HTTP 계층은 UnJS의 h3 라이브러리입니다. 핸들러는 요청마다 event 객체를 받아 헤더, 쿠키, 본문, 라우트 파라미터를 h3 유틸리티 함수(getQuery, readBody, getCookie 등)로 읽고, 반환한 값이 그대로 응답이 됩니다. 객체를 반환하면 JSON으로 직렬화되고, 문자열은 텍스트로, 웹 표준 Response나 스트림도 반환할 수 있습니다.
Express의 req/res와 비슷한 역할이지만, 응답을 res.send()로 쓰는 대신 반환값으로 표현하고, 런타임별 요청 객체를 직접 다루지 않고 유틸리티 함수를 거치게 한 것이 핵심입니다. 이 간접 계층 덕분에 같은 핸들러가 Node의 IncomingMessage에서도, Workers의 Request에서도 동작합니다.
Unstorage
UnJS의 unstorage는 키-값 저장소를 하나의 API(getItem, setItem, removeItem 등)로 다루게 해 주는 라이브러리입니다. 메모리, 파일 시스템, Redis, S3, Cloudflare KV 등 수십 가지 드라이버를 같은 코드로 쓸 수 있어서, 로컬에서는 파일 시스템을, 배포 환경에서는 Redis나 KV를 쓰는 구성을 설정만 바꿔 만들 수 있습니다.
파일 기반 라우팅
디렉터리와 URL
Nuxt 프로젝트에서는 server/api/ 아래 파일이 /api/ 경로로, server/routes/ 아래 파일이 접두사 없는 경로로 매핑됩니다. 단독 Nitro 프로젝트에서는 routes/와 api/ 디렉터리를 같은 방식으로 씁니다. 파일 이름의 [id]는 동적 세그먼트, [...slug]는 나머지 경로 전체를 받는 캐치올이고, index.get.ts, index.post.ts처럼 파일 이름 끝에 HTTP 메서드를 붙이면 그 메서드에만 응답합니다.
// server/api/users/[id].get.ts → GET /api/users/:id
export default defineEventHandler((event) => {
const id = getRouterParam(event, 'id');
return { id };
});
Nuxt와 Nitro는 defineEventHandler, getRouterParam 같은 h3 함수를 자동으로 import하므로 import 문 없이 쓸 수 있습니다. 명시적으로 쓰고 싶다면 import { defineEventHandler, getRouterParam } from 'h3';를 추가합니다.
리소스가 늘어나면 다음처럼 메서드별 파일로 나누면 어떤 URL과 메서드가 어느 파일에 있는지 디렉터리만 보고 알 수 있습니다.
server/
api/
v1/
users/
index.get.ts # GET /api/v1/users
index.post.ts # POST /api/v1/users
[id].get.ts # GET /api/v1/users/:id
middleware/
01-request-id.ts
utils/
db.ts # server/utils의 export는 자동 import 대상
미들웨어
server/middleware/의 파일은 모든 요청에서 라우트 핸들러보다 먼저, 파일 이름 순서대로 실행됩니다. 요청 ID 부여, 로깅, 인증 헤더 확인 같은 공통 처리에 씁니다.
// server/middleware/01-request-id.ts
import { randomUUID } from 'node:crypto';
export default defineEventHandler((event) => {
const id = getRequestHeader(event, 'x-request-id') ?? randomUUID();
event.context.requestId = id;
setResponseHeader(event, 'x-request-id', id);
// 아무것도 반환하지 않아야 다음 핸들러로 진행
});
미들웨어가 값을 반환하면 그것이 응답이 되어 요청이 그대로 끝나므로, 통과시키려면 반환하지 않아야 합니다. event.context는 같은 요청 안에서 이후 핸들러까지 전달되는 저장소입니다. 위 예제의 node:crypto는 Edge 프리셋에서 쓸 수 없으므로, 여러 런타임을 대상으로 한다면 웹 표준 전역 함수인 crypto.randomUUID()를 쓰는 편이 이식성이 높습니다. 이런 작은 차이가 “로컬에서는 되는데 Edge 배포에서만 실패하는” 문제의 흔한 원인입니다.
미들웨어는 모든 요청에서 실행되므로, 특정 경로에만 필요한 인증 같은 처리는 미들웨어 안에서 event.path로 걸러내거나 해당 핸들러에서 공통 유틸리티 함수를 호출하는 편이 낫습니다.
입력 검증과 오류 응답
// server/api/v1/users/index.post.ts
import { z } from 'zod';
const BodySchema = z.object({
email: z.string().email(),
name: z.string().min(1),
});
export default defineEventHandler(async (event) => {
const result = await readValidatedBody(event, (body) => BodySchema.safeParse(body));
if (!result.success) {
throw createError({
statusCode: 400,
statusMessage: 'Validation Error',
data: result.error.flatten(),
});
}
return { ok: true, user: result.data };
});
createError로 만든 오류를 던지면 Nitro가 상태 코드와 JSON 오류 본문으로 변환해 응답합니다. 프로덕션 빌드에서는 처리되지 않은 예외의 스택이 응답에 노출되지 않지만, data에 넣은 내용은 그대로 클라이언트에 전달되므로 내부 정보를 넣지 않도록 주의합니다. readBody는 요청 본문 전체를 메모리에 읽으므로, 큰 업로드는 스트림으로 처리하거나 객체 스토리지에 직접 올리는 서명 URL 방식으로 우회해야 합니다.
경로별 캐싱과 하이브리드 렌더링
routeRules
routeRules는 경로 패턴마다 렌더링 방식, 캐시, 헤더, CORS, 리다이렉트를 지정하는 설정입니다. Nuxt에서는 페이지와 API 모두에 적용되고, Nitro가 이를 빌드 결과와 런타임 동작에 반영합니다.
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true },
'/docs/**': { swr: 3600 },
'/dashboard/**': { ssr: false },
'/api/**': {
cors: true,
headers: { 'cache-control': 'no-store' },
},
},
});
prerender: true는 빌드할 때 HTML을 미리 생성해 정적 파일로 배포합니다. 내용이 배포 시점에만 바뀌는 랜딩 페이지나 문서에 맞습니다. swr: 3600은 서버가 렌더링 결과를 캐시해 두고 3600초 동안 캐시된 응답을 바로 돌려주며, 만료된 뒤 첫 요청에는 이전 응답을 주면서 백그라운드에서 새로 렌더링합니다. Vercel이나 Netlify에서는 이 규칙이 플랫폼의 ISR·CDN 캐시 기능으로 변환되기도 하므로, 같은 설정이라도 캐시가 어느 계층에 저장되는지는 프리셋에 따라 다릅니다. ssr: false는 그 경로를 서버에서 렌더링하지 않고 클라이언트에서만 그리게 합니다. 로그인 뒤에만 보이는 관리 화면처럼 검색 엔진 노출이 필요 없는 영역에서 서버 부하를 줄이는 데 씁니다.
API 경로는 기본적으로 캐시하지 않는 것이 안전합니다. 사용자별 데이터를 돌려주는 API가 공용 캐시에 저장되면 다른 사용자에게 같은 응답이 전달되는 사고가 납니다. 공개 데이터만 돌려주는 GET API에 CDN 캐시를 쓰고 싶다면, 그 경로에만 s-maxage를 명시적으로 지정합니다.
HTTP 캐시와 핸들러 캐시
CDN 같은 HTTP 캐시는 URL과 Vary에 지정된 헤더로 캐시 키를 만듭니다. 쿠키로 개인화되는 응답이 공용 캐시에 저장되지 않게 하려면 Cache-Control: private을 쓰거나 해당 경로를 캐시 대상에서 빼야 합니다. 캐시 무효화는 CDN이나 호스팅이 제공하는 퍼지 기능에 의존하는 경우가 많으므로, 캐시 설정을 넓히기 전에 “캐시에 실리면 안 되는 응답”이 무엇인지부터 정해 두는 것이 순서입니다.
HTTP 캐시와 별개로, Nitro는 핸들러나 함수 결과를 스토리지에 캐시하는 defineCachedEventHandler와 defineCachedFunction을 제공합니다. 외부 API 응답이나 계산 비용이 큰 데이터를 짧은 시간 재사용할 때 쓰며, 결과는 기본적으로 cache 스토리지 마운트에 저장됩니다.
// server/api/exchange-rates.get.ts
export default defineCachedEventHandler(
async () => {
return await $fetch('https://api.example.com/rates');
},
{ maxAge: 60, swr: true, name: 'exchange-rates' },
);
swr: true이면 만료된 캐시를 먼저 돌려주고 백그라운드에서 갱신합니다. 캐시 키는 기본적으로 요청 경로로 만들어지므로, 사용자마다 결과가 다른 핸들러에 그대로 쓰면 다른 사용자의 데이터가 섞입니다. 그런 경우 getKey 옵션으로 사용자 식별자를 키에 넣거나, 사용자와 무관한 부분만 defineCachedFunction으로 캐시합니다. 기본 캐시 스토리지가 메모리라면 서버리스 환경에서는 인스턴스마다 캐시가 따로 있고 인스턴스가 사라지면 함께 사라지므로, 캐시 효과를 기대하려면 cache 마운트를 Redis나 KV로 바꿔야 합니다.
스토리지와 KV
useStorage
Nitro 핸들러에서는 useStorage()로 unstorage 인스턴스를 얻고, 마운트 이름으로 저장소를 구분합니다. 설정에서 마운트마다 드라이버를 지정하고, 개발 환경용 설정은 devStorage로 따로 둘 수 있습니다.
// nitro.config.ts
import { defineNitroConfig } from 'nitropack/config';
export default defineNitroConfig({
storage: {
data: { driver: 'redis', url: process.env.REDIS_URL },
},
devStorage: {
data: { driver: 'fs', base: './.data/kv' },
},
});
// server/api/counter/[key].get.ts
export default defineEventHandler(async (event) => {
const key = getRouterParam(event, 'key') ?? 'default';
const storage = useStorage('data');
const current = Number(await storage.getItem(`counter:${key}`)) || 0;
await storage.setItem(`counter:${key}`, current + 1);
return { key, count: current + 1 };
});
이 카운터는 읽기와 쓰기 사이에 다른 요청이 끼어들 수 있으므로, 동시에 요청이 몰리면 증가분이 사라집니다. unstorage는 여러 저장소를 같은 API로 감싸는 대신 원자적 증가나 트랜잭션 같은 저장소별 기능은 제공하지 않습니다. 정확한 카운터가 필요하면 Redis의 INCR처럼 저장소의 원자 연산을 직접 쓰거나 DB 트랜잭션을 써야 합니다.
키에는 환경과 테넌트 접두사를 붙여 충돌을 막고, 세션이나 캐시처럼 수명이 있는 데이터는 드라이버가 지원하는 TTL을 지정합니다. 오래 보관하고 조회 조건이 다양한 데이터는 KV보다 관계형이나 문서 DB가 맞습니다.
Cloudflare KV
Cloudflare에 배포할 때는 cloudflare-kv-binding 드라이버로 마운트를 지정하고, binding 값을 wrangler.toml에 선언한 KV 네임스페이스의 바인딩 이름과 맞춥니다. Workers 환경에는 파일 시스템이 없으므로 fs 드라이버를 그대로 두면 배포 후에만 오류가 납니다. Cloudflare KV는 쓰기가 전 세계에 반영되기까지 최대 수십 초가 걸릴 수 있는 최종 일관성 모델이라서, 재고나 잔액처럼 읽은 직후의 정확성이 중요한 데이터에는 맞지 않습니다. 이런 데이터는 Durable Objects나 D1 같은 강한 일관성 저장소를 씁니다.
WebSocket
Nitro는 UnJS의 CrossWS로 여러 런타임에서 같은 형태의 WebSocket 핸들러를 쓸 수 있게 합니다. Nitro 2에서는 실험 기능이므로 설정에서 켜야 합니다.
// nitro.config.ts
import { defineNitroConfig } from 'nitropack/config';
export default defineNitroConfig({
experimental: {
websocket: true,
},
});
Nuxt에서는 nitro: { experimental: { websocket: true } }로 같은 옵션을 전달합니다.
// server/routes/_ws.ts
export default defineWebSocketHandler({
upgrade(request) {
const token = new URL(request.url).searchParams.get('token');
if (!isValidToken(token)) { // 직접 구현한 토큰 검증 함수
throw new Response('Unauthorized', { status: 401 });
}
},
open(peer) {
peer.subscribe('chat');
},
message(peer, message) {
peer.publish('chat', JSON.stringify({ from: peer.id, text: message.text() }));
},
close(peer) {
peer.unsubscribe('chat');
},
});
upgrade 훅에서 Response를 던지면 핸드셰이크가 거절됩니다. 브라우저의 WebSocket API는 연결 시 임의의 헤더를 보낼 수 없어서 토큰을 쿼리 문자열이나 쿠키로 전달하는 경우가 많은데, 쿼리 문자열은 프록시와 서버 액세스 로그에 남으므로 짧은 수명의 일회용 토큰을 쓰는 편이 안전합니다.
peer.publish는 같은 토픽을 구독한 다른 피어에게 메시지를 보냅니다. 이 Pub/Sub은 서버 프로세스 하나 안에서만 동작하므로, 인스턴스를 여러 개 띄우면 다른 인스턴스에 연결된 사용자에게는 메시지가 가지 않습니다. 수평 확장이 필요하면 Redis Pub/Sub 같은 외부 브로커로 인스턴스 간에 메시지를 중계해야 합니다. 일반 서버리스 함수 플랫폼은 장시간 연결을 유지하지 못하는 경우가 많고, Cloudflare에서는 Durable Objects 기반 설정이 필요하므로 대상 프리셋의 WebSocket 지원 여부를 먼저 확인해야 합니다.
서버에서 클라이언트로만 데이터를 보내면 되는 경우(알림, 진행 상황)라면 Server-Sent Events가 더 단순합니다. 일반 HTTP 응답을 스트리밍하는 방식이라 프록시와 CDN을 통과하기 쉽고, 브라우저의 EventSource가 연결이 끊기면 자동으로 다시 연결합니다. h3의 createEventStream으로 만들 수 있습니다.
배포 대상별 고려 사항
Vercel과 Netlify 같은 플랫폼에서는 Nitro 출력이 서버리스 함수나 Edge 함수로 배포됩니다. 트래픽에 따라 자동으로 확장되는 대신 실행 시간, 요청·응답 크기, 메모리에 제한이 있고 영구적인 로컬 파일 시스템이 없습니다. 그래서 파일 업로드는 객체 스토리지에 직접 올리는 서명 URL 방식으로, 장시간 연결은 별도 서비스로 분리하는 구성이 흔합니다.
AWS Lambda(aws-lambda 프리셋)는 API Gateway나 Lambda Function URL 뒤에서 실행됩니다. 요청이 없던 인스턴스가 처음 요청을 받을 때 초기화 시간(콜드 스타트)이 더해지므로 번들 크기를 작게 유지하는 것이 좋고, 동시 실행 수만큼 인스턴스가 늘어나기 때문에 인스턴스마다 DB 연결 풀을 만들면 DB 연결 수가 순식간에 한도를 넘을 수 있습니다. RDS Proxy 같은 연결 풀러나 HTTP 기반 DB 드라이버를 쓰는 이유가 이것입니다.
환경 변수를 읽는 시점도 플랫폼마다 다릅니다. Nitro의 runtimeConfig에 기본값을 선언해 두면 실행 시 NITRO_ 접두사(Nuxt는 NUXT_)가 붙은 환경 변수로 덮어쓸 수 있는데, 빌드할 때 process.env를 직접 읽어 번들에 박아 넣은 값은 배포 후 환경 변수를 바꿔도 반영되지 않습니다. Cloudflare Workers처럼 process.env가 기본으로 없는 런타임에서는 useRuntimeConfig(event)로 읽어야 합니다.
문제 해결
| 증상 | 확인할 것 |
|---|---|
| 로컬은 되는데 배포 후 404 | app.baseURL과 호스팅의 서브패스 배포 설정, 라우트 파일 이름의 메서드 접미사(.get.ts)와 실제 요청 메서드 |
| Edge 배포에서만 런타임 오류 | node:* 모듈이나 이에 의존하는 패키지 사용 여부. 웹 표준 API로 바꾸거나 해당 라우트를 Node 런타임으로 |
| 캐시가 적용되지 않음 | swr·isr 규칙이 실제 응답 헤더에 어떻게 나타나는지, 프리셋이 캐시를 어느 계층에 두는지, CDN 캐시 키 |
| WebSocket만 실패 | 실험 기능 활성화 여부, 프리셋의 WebSocket 지원, 프록시의 Upgrade 헤더 전달과 타임아웃 |
| 메모리 사용량 급증 | 큰 요청 본문을 readBody로 한 번에 읽는 코드, 메모리 드라이버에 쌓이는 캐시 |
| 배포 후 환경 변수가 반영되지 않음 | 빌드 시 process.env를 직접 읽어 값이 번들에 고정되었는지, runtimeConfig와 접두사 규칙 |
같이 보면 좋은 글
- Nuxt 3로 풀스택 Vue 앱 만들기
- WebSocket 실시간 통신
- Cloudflare Workers 시작하기
- Edge Computing 실전 가이드 | Cloudflare Workers· Vercel Edge