카테고리 없음

[Next.js] MSW와 Instrumentation을 활용하여 Server-side api 요청 모킹하기

Withlaw 2024. 4. 25. 21:10

* 개발 환경 정보

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 환경변수를 활용하여 아래와 같이 분기처리 해줘야 한다.

(https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation#importing-runtime-specific-code)

// 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: