안녕하세요, 자바파커입니다.
운영 중인 Next.js 앱에서 "어떤 요청이 어디서 느려지는지" 감이 잡히지 않을 때가 있죠.
console.log는 Vercel 대시보드 어딘가에 흩어지고, 서버 액션과 Route Handler 가 섞이면 요청 흐름을 한눈에 보기 힘듭니다.
결론부터 말씀드리면 — Next.js 15 에서는 루트의 middleware.ts 하나면 모든 요청 앞단에 로깅 훅을 꽂을 수 있습니다. Edge Runtime 제약만 이해하면 요청 ID 주입, 응답 헤더 태깅, 외부 로그 서비스 전송까지 깔끔하게 통합됩니다.
1. 2023년 글을 2026년에 다시 쓰는 이유
3년 전 이 글의 초판은 server.js 에서 Express 미들웨어로 로그를 찍는 구조였습니다. 당시에는 그게 정석이었지만 지금은 상황이 완전히 달라졌습니다.
| 구분 | Pages Router (구) | App Router (Next.js 15) |
|---|---|---|
| 로깅 진입점 | server.js + Express |
middleware.ts (루트) |
| 실행 환경 | Node.js 서버 | Edge Runtime 기본 |
| 파일 위치 | pages/_middleware.ts (폐기) |
프로젝트 루트 |
| 배포 | 커스텀 서버 필요 | Vercel 서버리스/Edge 자동 |
| 관찰 도구 | 파일 로그, PM2 | Vercel Log Drains, Sentry, Datadog |
pages/_middleware.ts 는 Next.js 12.2 에서 deprecated 되었고, 커스텀 server.js 를 쓰면 Vercel 의 Edge 최적화를 포기하게 됩니다. 그래서 이제는 루트 middleware.ts 가 표준입니다.
2. 기본 middleware.ts 뼈대
프로젝트 루트 (= app/ 과 같은 레벨) 에 middleware.ts 파일 하나만 만들면 됩니다.
// middleware.ts
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
export function middleware(request: NextRequest) {
const start = Date.now()
const { pathname, search } = request.nextUrl
const method = request.method
console.log(`[req] ${method} ${pathname}${search}`)
const response = NextResponse.next()
response.headers.set("x-response-time", `${Date.now() - start}ms`)
return response
}
export const config = {
matcher: [
// _next 정적 리소스와 favicon 은 제외
"/((?!_next/static|_next/image|favicon.ico).*)",
],
}핵심 포인트 세 가지입니다.
NextRequest/NextResponse— Web 표준Request의 확장체입니다.request.nextUrl로 파싱된 URL 에 접근합니다.matcher— 정규식 기반 경로 필터. 정적 자산을 빼야 로그가 폭주하지 않습니다.- Edge Runtime 기본 — 별도 설정 없이 전역 엣지에서 실행됩니다.
3. Edge Runtime 제약 — 먼저 알고 가야 합니다
미들웨어는 기본적으로 Edge Runtime 에서 돌아갑니다. Node.js API 를 그대로 쓸 수 없다는 뜻입니다.
| 동작 | Edge Runtime |
|---|---|
console.log |
사용 가능 (Vercel 로그로 수집) |
fs, path, net |
불가 |
process.env |
빌드 타임에 인라인된 값만 |
fetch |
사용 가능 (Web 표준) |
Node Buffer |
제한적 |
| 실행 시간 | 응답 전송 전까지 수 ms 이내 권장 |
즉, 파일 시스템에 로그를 쓰거나 Winston 같은 Node 전용 로거를 바로 불러오면 빌드가 깨집니다. 외부 전송은 fetch 로 해야 합니다.
정 Node API 가 필요하면
export const runtime = 'nodejs'를 선언해 Node 런타임으로 전환할 수 있습니다. 단, 콜드 스타트와 비용이 늘어나니 되도록 Edge 를 유지하세요.
4. 실전 패턴 1 — 요청 ID 주입
분산 추적을 하려면 요청마다 고유 ID 가 있어야 합니다. 미들웨어에서 생성해 요청 헤더와 응답 헤더에 동시에 심어 주면 Route Handler, Server Component, 클라이언트 모두 같은 ID 를 볼 수 있습니다.
// middleware.ts
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
export function middleware(request: NextRequest) {
const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID()
// 다운스트림에서 읽을 수 있도록 요청 헤더에 주입
const requestHeaders = new Headers(request.headers)
requestHeaders.set("x-request-id", requestId)
const response = NextResponse.next({
request: { headers: requestHeaders },
})
// 클라이언트가 디버깅용으로 볼 수 있도록 응답 헤더에도 노출
response.headers.set("x-request-id", requestId)
console.log(
JSON.stringify({
level: "info",
msg: "request",
id: requestId,
method: request.method,
path: request.nextUrl.pathname,
ua: request.headers.get("user-agent"),
ip: request.headers.get("x-forwarded-for"),
})
)
return response
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
}Server Component 에서는 next/headers 의 headers() 로 같은 ID 를 읽습니다.
// app/any/page.tsx
import { headers } from "next/headers"
export default async function Page() {
const h = await headers()
const requestId = h.get("x-request-id")
// ... 로그 남길 때 이 ID 를 같이 전송
}포인트는 JSON 한 줄로 로그를 찍는 것 입니다. Vercel Log Drains, Datadog, Logflare 모두 구조화된 JSON 을 그대로 파싱합니다.
5. 실전 패턴 2 — Vercel Log Drains 연동
Vercel 에 배포하면 console.log 는 자동으로 Runtime Logs 에 적재됩니다. 대시보드의 Observability → Logs 에서 실시간 조회가 가능하고, Pro 플랜 이상에서는 Log Drains 로 외부 저장소(S3, Datadog, Axiom, BetterStack 등) 에 스트리밍할 수 있습니다.
설정 자체는 코드가 아니라 Vercel 대시보드에서 합니다.
- Project → Settings → Log Drains
- Destination 선택 (Datadog / Axiom / Custom Webhook)
- 필터 조건 설정 (미들웨어만, 특정 경로만 등)
애플리케이션 코드에서 할 일은 JSON 로그를 일관된 스키마로 찍는 것 뿐입니다. 위 4번 섹션의 JSON 포맷을 그대로 쓰면 Log Drains 에서 그대로 인덱싱됩니다.
6. 실전 패턴 3 — Sentry 로 에러 추적
Sentry 는 2025년부터 Next.js 15 App Router 와 Edge Runtime 을 공식 지원합니다. 설치는 한 번입니다.
npx @sentry/wizard@latest -i nextjs마법사가 sentry.edge.config.ts, sentry.server.config.ts, sentry.client.config.ts 세 파일을 생성합니다. 미들웨어에서 예외를 Sentry 로 보내려면 다음 패턴을 씁니다.
// middleware.ts
import * as Sentry from "@sentry/nextjs"
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
export async function middleware(request: NextRequest) {
try {
// ... 인증/리다이렉트 등 로직
return NextResponse.next()
} catch (error) {
Sentry.captureException(error, {
tags: { layer: "middleware" },
extra: { path: request.nextUrl.pathname },
})
return NextResponse.json({ error: "internal" }, { status: 500 })
}
}sentry.edge.config.ts 덕분에 Edge Runtime 에서도 정상 작동합니다. 4번 섹션의 요청 ID 를 Sentry tag 로 함께 보내면 로그-트레이스 상관관계 분석이 됩니다.
7. 흔한 실수 체크리스트
- 정적 자산까지 로깅 —
matcher누락으로_next/static/*가 전부 찍혀 로그 요금 폭탄 - 동기
setTimeout등으로 응답 지연 — 미들웨어는 모든 요청의 앞단 입니다. 외부 전송은waitUntil대신fetch를await하지 말고 fire-and-forget 패턴으로 - 환경변수 런타임 변경 기대 — Edge 에서는 빌드 시점에 인라인됩니다. 런타임 변경은 Node 런타임에서만
pages/_middleware.ts혼용 — 완전히 제거된 API 입니다. 루트middleware.ts하나만
FAQ
Q1. Edge Runtime 에서 console.log 가 로컬 터미널에 안 보이면?
로컬 next dev 는 터미널에 그대로 출력됩니다. 만약 보이지 않는다면 matcher 에서 해당 경로가 제외됐거나, 빌드 캐시 이슈일 확률이 높습니다. .next 폴더 삭제 후 재시작해 보세요. 배포 환경에서는 Vercel 대시보드의 Runtime Logs 를 확인해야 합니다.
Q2. Sentry 말고 Datadog APM 을 쓰고 싶어요.
Datadog 은 서버 런타임용 Node 라이브러리(dd-trace) 와 Edge 용 로그 수집을 구분합니다. 미들웨어(Edge) 에서는 Vercel Log Drains → Datadog Logs 경로를 쓰고, Route Handler 에서 export const runtime = 'nodejs' 를 선언한 구간만 dd-trace 로 APM 계측하는 분리 전략이 실무에서 가장 깔끔합니다.
Q3. 미들웨어 로깅이 성능에 영향을 주지 않나요?
console.log 한 줄과 헤더 주입 정도는 1ms 미만 입니다. 다만 외부 API 호출을 await 하면 전체 응답이 그만큼 지연됩니다. 외부 전송은 Log Drains 같은 비동기 경로를 쓰고, 미들웨어는 로컬 버퍼링만 담당하게 설계하세요.
관련 포스팅
- Next.js 미들웨어 기본 개념 정리 — 이 글이 로깅 중심이라면, 원리부터 보고 싶다면 이쪽을 추천합니다.
참고 자료
- Next.js 공식 문서 — Middleware
- Next.js Edge Runtime API Reference
- Vercel — Runtime Logs & Log Drains
- Sentry for Next.js
여기까지가 2026년 현재 Next.js 15 에서 미들웨어 로깅을 실무에 적용하는 최소 구성입니다. 여러분은 어떤 로그 스택을 쓰고 계신가요? Axiom, BetterStack, Datadog 중 궁합이 좋았던 조합이 있다면 댓글로 공유해 주세요. 궁금한 점이나 막히는 부분도 편하게 남겨 주시면 같이 풀어 보겠습니다.