[Next.js] MSW와 Instrumentation을 활용하여 Server-side api 요청 모킹하기
* 개발 환경 정보
Next.js: 14.1
msw: 2.2
1. MSW api mocking 동작 원리 살펴보기
- msw는 가장 먼저 글로벌 fetch를 몽키패치 한다.
- 이를 통해 msw가 네트워크 요청을 가로채야 할지 판단하는데, 해당하는 경우 핸들러를 사용하여 모의 응답을 생성한다.
- 해당하지 않는 경우에는 본래 네트워크 요청을 진행한다.
export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
constructor() {
super(FetchInterceptor.symbol)
}
protected async setup() {
const pureFetch = globalThis.fetch
// ...생략
globalThis.fetch = async (input, init) => {
const requestId = createRequestId()
const request = new Request(resolvedInput, init)
// ...생략
}
}
- https://github.com/mswjs/interceptors/blob/main/src/interceptors/fetch/index.ts
- Node.js 환경에서는 아래와 같이 http 요청을 인터셉트하는 서버를 설정한다.
- 인터셉트 할 요청에 대해 응답을 처리하는 핸들러 함수들을 전달하여 msw 서버를 실행하면 서버 환경에서 fetch api를 모킹 할 수 있다.
export class SetupServerCommonApi
extends SetupApi<LifeCycleEventsMap>
implements SetupServerCommon
{
constructor(
interceptors: Array<{ new (): Interceptor<HttpRequestEventMap> }>,
handlers: Array<RequestHandler>,
) {
super(...handlers)
// ...생략
this.init()
}
/**
* Subscribe to all requests that are using the interceptor object
*/
private init(): void {
this.interceptor.on('request', async ({ request, requestId }) => {
const response = await handleRequest(
this.handlersController.currentHandlers(),
)
if (response) {
request.respondWith(response)
}
return
})
this.interceptor.on(
'response',
({ response, isMockedResponse, request, requestId }) => {
this.emitter.emit(
isMockedResponse ? 'response:mocked' : 'response:bypass',
{
response,
request,
requestId,
},
)
},
)
}
// ...생략
}
- https://github.com/mswjs/msw/blob/main/src/node/SetupServerCommonApi.ts
2. Next.js의 'Instrumentation' 기능을 사용하여 MSW API 모킹하기
- Next.js는 최근까지 계속 업데이트가 진행되고 있으며, Instrumentation 기능은 최신 버전에서만 사용 가능하다.
- 이전 버전에서는 최상위의 _app.ts에서 전역적으로 Node 모듈을 패치하여 API를 모킹 할 수 있었는데, RSC를 사용하는 환경에서는 내부 프로세스 동작 방식이 달라 Next.js가 제공하는 특별한 함수를 사용하여 API를 모킹 한다. (이 방법 역시 아직 안정화되지 않은 듯하다)
- MSW 메인테이너의 말에 의하면, Next.js App router는 두 종류의 프로세스가 내부에서 실행되는데, 하나는 특정 포트에서 지속적으로 유지되고, 다른 하나는 랜덤 포트에서 스폰되어 작업을 마치면 종료된다. (https://github.com/mswjs/msw/issues/1644)
(개인적인 생각으로는 Next.js가 SSR, ISR 등 렌더링 전략에 따라 다른 환경의 프로세스를 실행하는 것 같음. 실제로 확인해 본 결과 ssr과 dynamic route를 처리하는 프로세스는 서로 다른 프로세스 id를 가짐)
- 간헐적으로 프로세스가 계속 재생성되기 때문에 해당 프로세스에서 발생하는 server-side api 요청에 대한 모킹을 지원하기 위한 노드 모듈 패칭 설정 방법을 적용하기 어려웠으나, 최근 업데이트에서 내부 서버가 하나의 노드 프로세스만 유지하도록 변경하였다고 한다. (https://github.com/mswjs/msw/issues/1644#issuecomment-1729906255)
- Next.js 프레임워크에서 제공하는 Instrumentation 기능은 앱의 모니터링과 로깅을 통합하는 프로세스로써 앱의 동작을 추적하고 문제를 디버깅 할 수 있도록 도와준다. (https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation)
- Instrumentation의 register 함수는 RSC가 렌더링 되기 전, 그리고 미들웨어나 다른 route handlers 등이 실행되기 전, 거의 next 서버 실행 직후에 호출 되기 때문에 msw의 노드 모듈 패치를 우선적으로 적용할 수 있다. 그리고 next.js 앱 내의 모든 프로세스에서 실행되기 때문에 msw 서버 설정을 글로벌하게 주입 할 장소로 적당하다.
- 하지만 Next.js가 몽키패치(cache 등 적용)한 fetch를 복구하는 과정으로 인해 msw의 몽키패칭이 무효화되어 사용하기에 어려움이 있었는데(https://github.com/mswjs/msw/issues/1644#issuecomment-1631888899), 최근 업데이트에서는 수정된 fetch가 계속 보존되도록 하여 Instrumentation를 통해 서버 api 요청 모킹이 가능해졌다(https://github.com/vercel/next.js/discussions/56446#discussioncomment-8687193).
- 다만 Instrumentation 기능을 사용하기 위해 호출하는 register 함수가 서버 런타임 동안에 재평가되지 않기 때문에 만약 개발 도중에 msw 핸들러 업데이트를 적용하려면 앱을 재시작해야 한다. (https://twitter.com/kettanaito/status/1749496339556094316)
- 결과적으로, msw를 사용하여 server-side mocking을 기술적으로 구현 할 수는 있지만, HMR 기능이 지원되지 않아 개발자환경(DX)이 저하 될 수 있다.
- 이와 관련하여 Next.js 개발 팀은 Next.js 앱 내에서 발생하는 모든 요청이 처리 되기 전에 로직을 실행 할 수 있는 'onRequest' 훅을 추가 할 계획이라고 한다(https://github.com/vercel/next.js/discussions/56446#discussioncomment-7208529).
3. 예제를 통해 살펴보기
- 참고: https://github.com/mswjs/examples/tree/with-next/examples/with-next
- msw 핸들러 및 서버 설정
// mocks/nodes.js
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
export const handlers = [
http.get('https://api.example.com/msw', () => {
return HttpResponse.json({
data: 'MSW TEST',
})
}),
]
export const server = setupServer(...handlers)
- Instrumentation 설정
- *register 함수는 모든 런타임에서 호출되기 때문에 특정 런타임(Edge or Node.js)을 지원하지 않는 경우에는 NEXT_RUNTIME 환경변수를 활용하여 아래와 같이 분기처리 해줘야 한다.
// instrumentation.js
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { server } = await import('./mocks/node.js')
server.listen()
}
}
- 그리고 Instrumentation 기능은 아직 실험 중인 기능이기 때문에 아래와 같이 Next.js 환경 설정에서 'instrumentationHook' 옵션을 추가해준다.
- 추가로 Next.js 앱을 빌드할 때 node.js 전용 모듈은 클라이언트 번들에 포함되지 않는데, 웹팩은 이 모듈에 접근하려 하기 때문에 모듈 참조 에러를 일으킨다. 이를 해결하기 위해 msw의 브라우저 모듈과 노드 모듈을 각 런타임에서만 접근할 수 있도록 아래와 같이 웹팩 설정을 추가한다.
(https://github.com/mswjs/msw/issues/1801#issuecomment-1793911389)
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
...(process.env.NODE_ENV === 'test' && {
// test 환경에서만 api를 mocking 하기 위해 아래와 같은 옵션 적용
experimental: {
instrumentationHook: true,
},
webpack(config, { isServer }) {
if (isServer) {
if (Array.isArray(config.resolve.alias)) {
config.resolve.alias.push({ name: 'msw/browser', alias: false })
} else {
config.resolve.alias['msw/browser'] = false
}
} else {
if (Array.isArray(config.resolve.alias)) {
config.resolve.alias.push({ name: 'msw/node', alias: false })
} else {
config.resolve.alias['msw/node'] = false
}
}
return config
},
}),
};
export default nextConfig
- *캐시 비활성화시 dynamic routing 프로세스에서 mocking이 적용되지 않는 문제 발생함
-> 로깅해서 확인해본 결과 edge 런타임 프로세스가 실행되는데 이 과정에서 msw 노드 서버가 활성화되지 않음. 위에서 msw 메인테이너가 말한 간헐적으로 계속 재실행되는 프로세스인 듯함. 이 환경에서는 노드 모듈 코드를 실행하기가 어려워 보임.
-> 그냥 캐시 활성화해서 사용하고 프로덕션 배포시 먼저 캐시 초기화 후 릴리즈하는 방식으로 해야할 듯함.
- msw로 서버 api를 모킹하는 동안 혹시 모를 사이드 이펙트를 방지하기 위해 Next.js cache 기능도 비활성화 해준다. (https://nextjs.org/docs/app/building-your-application/deploying#configuring-caching)
- 이 기능은 Next.js 14.1 버전 이상부터 사용 가능하다.
- 아래와 같이 CachHandler의 setter가 아무 동작도 하지 않도록 코드를 수정한다.
// cache-handler.js
const cache = new Map()
module.exports = class CacheHandler {
constructor(options) {
this.options = options
}
async get(key) {
// This could be stored anywhere, like durable storage
return cache.get(key)
}
async set(key, data, ctx) {
// This could be stored anywhere, like durable storage
// cache.set(key, {
// value: data,
// lastModified: Date.now(),
// tags: ctx.tags,
// })
}
async revalidateTag(tag) {
// Iterate over all entries in the cache
for (let [key, value] of cache) {
// If the value's tags include the specified tag, delete this entry
if (value.tags.includes(tag)) {
cache.delete(key)
}
}
}
}
- 그리고 아래 캐시 옵션을 nextConfig에 추가한다.
- 이 캐시 옵션 역시 test 환경에서만 활성화 되도록 적용한다.
const cacheConfig = {
...(process.env.NODE_ENV === 'test' && {
acheHandler: require.resolve('./cache-handler.js'),
cacheMaxMemorySize: 0, // disable default in-memory caching
})
}
- 마지막으로 아래와 같이 test 환경에서 실행되도록하는 스크립트를 추가한다.
"dev:test" : "export NODE_ENV=test && next dev: