
Next.js 15.5 Middleware Node.js 런타임 지원과 캐시 동작 분석
Next.js 15.5에서 Middleware가 Node.js 런타임을 지원하게 되면서 변경된 점과 cache 공유 여부를 직접 테스트해본 결과를 공유합니다.
시작하며
Next.js 15.5가 출시되면서 Middleware에서 Node.js 런타임이 정식으로 지원되기 시작했습니다. 15.2 버전부터 실험적으로 제공되던 이 기능이 드디어 안정화되어, 이제 프로덕션 환경에서도 안심하고 사용할 수 있게 되었습니다.
이번 업데이트로 인한 변화와 특히 궁금했던 middleware cache가 rendering 과정과 공유되는지에 대해 직접 테스트해본 결과를 공유합니다.
Next.js 15.5 이전의 Middleware
Edge Runtime의 한계
Next.js 15.2 버전부터 실험적 기능으로 Node.js 런타임이 지원되기 시작했지만, 15.5 버전에서 정식으로 안정화되었습니다. 그 이전까지는 기본적으로 Edge Runtime 환경에서만 동작했습니다. Edge Runtime은 Vercel에서 개발한 경량화된 JavaScript 실행 환경으로, Middleware에서 빠른 처리를 위해 설계되었습니다.
Edge Runtime의 특징:
- 제한된 API: Web Standard API만 사용 가능 (fetch, Request, Response, URL, 웹 표준 crypto 등)
- 작은 번들 크기: 최대 1MB 제한 (복잡한 라이브러리 사용 시 쉽게 초과)
- 빠른 Cold Start: 경량화된 환경으로 빠른 시작 시간
- 격리된 환경: Node.js 내장 모듈(fs, path, Node.js crypto 등) 사용 불가
export const runtime = 'edge' // 15.2 이전에는 유일한 옵션
export function middleware(request: NextRequest) {
// ❌ Node.js API 사용 불가
// const fs = require('fs') // Error!
// ❌ 많은 npm 패키지 사용 불가
// import bcrypt from 'bcryptjs' // Error!
// ✅ Web Standard API만 사용 가능
const url = new URL(request.url)
return fetch('https://api.example.com')
}
커뮤니티의 불만
많은 개발자들이 Edge Runtime의 제약에 불만을 표출했습니다. 특히 기존 Node.js 생태계와의 호환성 문제가 심각했습니다.
Next Js GitHub Discussion에서 많은 개발자들이 Node.js API 지원을 요청했고, 주요 불만 사항은 다음과 같았습니다:
- 인증 라이브러리 문제: passport, bcrypt, next-auth의 MongoDB adapter 등 사용 불가
- 데이터베이스 연결 문제: Redis, MongoDB 등 Node.js 기반 드라이버 사용 불가
- crypto 모듈 부재: JWT 검증, 암호화 작업 시 Node.js crypto 모듈 필요
- 생태계 호환성: 대부분의 npm 패키지가 Edge Runtime과 호환되지 않음
실제로 많은 개발자들이 우회 방법을 사용해야 했습니다:
// 기존 우회 방법: Middleware에서 API Route 호출
export async function middleware(request: NextRequest) {
// DB 직접 연결 불가, API Route를 통해 우회
const response = await fetch(`${request.nextUrl.origin}/api/authenticate`, {
headers: { cookie: request.headers.get('cookie') || '' }
})
// 성능 저하 및 불필요한 네트워크 호출 발생
}
다른 프레임워크와의 비교
공통 로직 처리 방식
다른 웹 프레임워크들은 비즈니스 로직과 공통 로직을 분리할 수 있는 다양한 방법을 제공합니다:
Spring Framework (Java)
// AOP를 활용한 횡단 관심사 처리
@Aspect
@Component
public class AuthenticationAspect {
@Before("@annotation(RequiresAuth)")
public void checkAuthentication(JoinPoint joinPoint) {
// 인증 로직
}
}
// Interceptor를 통한 전처리
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// 모든 요청 전 실행
return true;
}
}
Express.js (Node.js)
// Middleware 체인으로 유연한 처리
app.use(authMiddleware)
app.use(loggingMiddleware)
app.use(corsMiddleware)
// 특정 라우트에만 적용
app.get('/protected', authMiddleware, (req, res) => {
// 비즈니스 로직
})
Django (Python)
# Middleware 클래스로 구조화
class AuthenticationMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# 요청 전처리
response = self.get_response(request)
# 응답 후처리
return response
# Decorator로 세밀한 제어
@login_required
@cache_page(60 * 15)
def view_function(request):
# 비즈니스 로직
pass
Next.js의 특수한 상황
Next.js는 다른 프레임워크와 달리 독특한 렌더링 아키텍처를 가지고 있습니다:
// Next.js의 병렬 렌더링
// Page, Layout, Metadata가 동시에 처리됨
app/
layout.tsx // 병렬 실행
page.tsx // 병렬 실행
metadata.ts // 병렬 실행
이러한 병렬 처리 구조 때문에:
- 공통 로직 중복 실행 문제: 각 컴포넌트에서 동일한 인증 체크를 수행하면 중복 연산 발생
- 데이터 일관성 문제: 병렬 실행으로 인해 상태 공유가 어려움
- 성능 최적화 어려움: 각 컴포넌트가 독립적으로 데이터를 fetching
물론 동일한 렌더링 과정(Rendering Phase, 즉 layout, page, metadata) 내에서는 Request Memoization을 통해 중복 요청을 방지할 수 있습니다:
const user = await fetch('/api/user') // 실제 호출
const user = await fetch('/api/user') // 메모이제이션으로 캐시된 결과 사용
export async function generateMetadata() {
const user = await fetch('/api/user') // 마찬가지로 캐시된 결과 사용
return { title: `Welcome ${user.name}` }
}
const user = await fetch('/api/user') // 모두 동일한 캐시된 결과 사용
Rendering 과정에서 중복 처리 로직이 반복되는 문제를 해결하기 위해 많은 개발자들이 Middleware에서 전처리 로직을 추가하는 것을 고려하게 됩니다:
export async function middleware(request: NextRequest) {
// 인증 체크를 한 곳에서 처리
const isAuthenticated = await checkAuth(request)
// 사용자 권한을 미리 확인
const userRole = await getUserRole(request)
// A/B 테스트 그룹 결정
const testGroup = getABTestGroup(request)
// 모든 결과를 Headers로 전달
const response = NextResponse.next()
response.headers.set('X-Auth-Status', isAuthenticated.toString())
response.headers.set('X-User-Role', userRole)
response.headers.set('X-Test-Group', testGroup)
return response
}
이렇게 Middleware가 유일한 중앙 집중식 전처리 지점 역할을 하게 되었습니다.
Next.js 15.5 Middleware 업데이트
Next.js 15.5에서 Middleware의 Node.js 런타임이 정식 지원되면서 (15.2부터 실험적으로 제공) 앞서 언급한 Edge Runtime의 제약사항들이 대부분 해소되었습니다:
export const runtime = 'nodejs' // 이제 안정적으로 사용 가능!
export function middleware(request: NextRequest) {
// ✅ Node.js API 모두 사용 가능
const fs = require('fs')
const crypto = require('crypto')
// ✅ 기존 npm 패키지들 대부분 호환
// bcrypt, jsonwebtoken, mongodb 등
}
하지만 한 가지 의문이 들었습니다. Middleware에서 사용한 API 호출이나 데이터가 캐시되어 Page Rendering 과정과 공유된다면, 중복 연산을 줄이고 성능을 크게 개선할 수 있을 것입니다. 이것이 가능한지 직접 테스트해보기로 했습니다.
Cache 공유 테스트
테스트 Repository
next-request-test 레포지토리를 만들어 간단한 테스트를 진행했습니다.
테스트 방법
동일한 API 엔드포인트(/api/current-time
)를 Middleware와 Page에서 기본 fetch로 호출하여, Next.js의 자동 request memoization이 동작하는지 확인했습니다.
export const runtime = 'nodejs'
export async function middleware(request: NextRequest) {
if (request.nextUrl.pathname === '/cache') {
// 동일한 API를 기본 옵션으로 두 번 호출
const timeResponse = await fetch('http://localhost:3000/api/current-time')
const timeResponse2 = await fetch('http://localhost:3000/api/current-time')
const time = await timeResponse.json()
const time2 = await timeResponse2.json()
console.log('[Middleware] First fetch:', time)
console.log('[Middleware] Second fetch:', time2)
}
}
export const dynamic = 'force-dynamic'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
export async function generateMetadata() {
const timeResponse = await fetch('http://localhost:3000/api/current-time')
const time = await timeResponse.json()
console.log('[generateMetadata] Fetch:', time)
return { title: `Cache Test - ${time.currentTime}` }
}
export default async function CachePage() {
const timeResponse = await fetch('http://localhost:3000/api/current-time')
const time = await timeResponse.json()
console.log('[Page] Fetch:', time)
return (
<div>
<h1>Cache Test Page</h1>
<p>Current Time: {time.currentTime}</p>
</div>
)
}
테스트 결과
프로덕션 빌드 후 테스트한 실제 결과입니다:
Middleware is running
Current time API is being called
Current time API is being called
middleware cache1 { currentTime: '2025-08-23T03:04:41.176Z' }
middleware cache2 { currentTime: '2025-08-23T03:04:41.185Z' } // timestamps are different -> not cached
RootLayout is rendering
Layout2 is rendering
GenerateMetadata is rendering
Current time API is being called
layout cache1 { currentTime: '2025-08-23T03:04:41.204Z' } // timestamps are different from middleware -> not cached
layout cache2 { currentTime: '2025-08-23T03:04:41.204Z' } // timestamps are the same -> cached
page cache1 { currentTime: '2025-08-23T03:04:41.204Z' }
page cache2 { currentTime: '2025-08-23T03:04:41.204Z' }
generateMetadata cache1 { currentTime: '2025-08-23T03:04:41.204Z' }
generateMetadata cache2 { currentTime: '2025-08-23T03:04:41.204Z' }
핵심 발견:
- Middleware 내에서는 Request Memoization이 동작하지 않음: 두 번의 fetch 호출이 모두 다른 시간 반환
- Rendering Phase 내에서는 Request Memoization이 완벽하게 동작: layout, page, generateMetadata에서 모두 동일한 시간 반환 (실제 API 호출은 1회만 발생)
- Middleware와 Rendering Phase 간에는 캐시가 공유되지 않음: 완전히 다른 시간값
마무리
Next.js 15.5에서 Middleware의 Node.js 런타임이 정식 지원되면서 Edge Runtime의 많은 제약이 해소되었습니다. 이제 기존 Node.js 생태계의 라이브러리들을 자유롭게 활용할 수 있습니다.
하지만 이번 실험을 통해 Middleware와 Rendering Phase 간의 Request Memoization은 여전히 분리되어 있다는 점을 확인했습니다. 이는 성능 최적화 관점에서 중요한 요소입니다.
개발자들은 이런 특성을 이해하고, Middleware에서 처리한 데이터를 Page에서 재사용해야 할 때는 Headers나 Cookies를 통한 명시적인 전달 방식을 고려해야 합니다.