Next.js에서 MSW 도입하기... 그런데 dynamic import를 곁들인

트러블슈팅 · 2025. 3. 10.

Next.js의 이중환경

Next.js에서 작성된 코드는 서버(Node.js)와 브라우저라는 두 가지 다른 환경에서 실행된다. SSR(또는 SSG)단계에서는 Node.js 환경에서 코드가 실행되고, CSR단계에서는 브라우저 환경에서 코드가 실행된다. 따라서, alert()나 localStorage같은 브라우저 전용 API는 브라우저 환경에서만, fs같은 Node.js 전용 API는 서버환경에서만 호출 가능하다.

MSW

Mock Service Worker는 자바스크립트 API 모킹 라이브러리이다. 모킹 API를 이용하면 API가 준비되지 않은 상태에서도 프론트 엔드 개발이 가능하기 때문에 실제 개발에서 유용하다. 다른 모킹 라이브러리리들은 주로 코드 레벨에서 요청을 가로채는 반면 MSW는 브라우저 전용 API인 Service Woker API를 사용하여 네트워크 레벨에서 작동을 지원하므로, 더 현실적인 테스트 환경을 제공하는 강점이 있다.

추천 아티클 1 Next.js에서 MSW(Mock Service Worker)로 네트워크 Mocking하기

추천 아티클 2 Mocking으로 프론트엔드 DX를 높여보자

문제 상황

수영장 정보사이트 어푸는 SSR과 CSR을 동시에 사용하는 하이브리드 렌더링방식으로 개발할 예정이었기 때문에, 모킹을 Node.js 환경과 브라우저 환경 모두에서 설정할 필요가 있었습니다.

따라서 다음과 같이 설정해주었다.

// 주의 잘못된 코드입니다. 절대 따라하지마세요.
// node환경에서 모 킹설정 : rsc 최상위 layout에 server.listen()함수 실행
//mocks/node.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

//layout.tsx
import { server } from '@/mocks/node'
...
if (process.env.NODE_ENV === 'development') {
  server.listen()
}
...

// 브라우저 환경에서 모킹 설정
//mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

export const worker = setupWorker(...handlers)

//providers/msw-provider.tsx
// latout 컴포넌트에 프로바이더를 추가해줍니다. msw시작에 시간시 소요되므로
// 시작이 완료된 뒤 페이지가 렌더링 되도록 합니다.
'use client'

import { ReactNode, useEffect, useState } from 'react'
import { worker } from "@/mocks/browser"

export function MSWProvider({ children }: { children: ReactNode }) {


  useEffect(() => {
    async function enableMocking() {
      if (
        typeof window !== 'undefined' &&
        process.env.NODE_ENV === 'development'
      ) {
        await worker.start()
      }
    }

    enableMocking()
  }, [])

  return <>{children}</>
}

그런데, 빌드에러가 발생하였다.

Build Error
Module not found: Package path ./browser is not exported from package /.../frontend/node_modules/msw ...

해결방법

에러 메세지 Module not found: Package path ./browser is not exported from package 와 키워드 msw Next.js 구글링해서 해결방법을 찾을 수 있었다. dynamic import을 통해msw/browser모듈을 브라우저 환경인 경우에서만 불러와 해결했다.

'use client'

import { ReactNode, useEffect, useState } from 'react'

export function MSWProvider({ children }: { children: ReactNode }) {

  useEffect(() => {
    async function enableMocking() {
      if (
        typeof window !== 'undefined' &&
        process.env.NODE_ENV === 'development'
      ) {
        const { worker } = await import('@/mocks/browser') // 수정된 부분
        // useEffect훅안에서 클라이언트에서 동적으로 임포트 합니다.
        await worker.start()
      }
    }

    enableMocking()
  }, [])

  return <>{children}</>
}

문제원인

이는 msw/browser에서 setupWorkerimport하는 과정에 있었다. 빌드에러시 발생한 안내 메세지대로msw/package.json을 살펴보면 msw/browser를 node.js 환경에서 import하는 경우 경로가 null임으로 모듈을 불러올 수 없다. 이는 msw/browser 모듈이 브라우저 전용 API를 사용하기 때문에, node.js 환경에서 실행될 경우 런타임 에러를 일으키기 때문에, 이를 사전에 방지한 것이다. 따라서, Next.js의 번들러가 코드를 빌드 시에 이는 node.js 환경이므로 mock/browser 모듈을 불러오지 못하게 되는 것이다.

// msw/package.json (2.7.0)
{
  "name": "msw",
  "version": "2.7.0",
  "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.",
  "main": "./lib/core/index.js",
  "module": "./lib/core/index.mjs",
  "types": "./lib/core/index.d.ts",
  "exports": {
    ".": {
      "types": "./lib/core/index.d.ts",
      "require": "./lib/core/index.js",
      "import": "./lib/core/index.mjs",
      "default": "./lib/core/index.js"
    },
    "./browser": {
      "types": "./lib/browser/index.d.ts",
      "browser": {
        "require": "./lib/browser/index.js",
        "import": "./lib/browser/index.mjs"
      },
      "node": null,
      "require": "./lib/browser/index.js",
      "import": "./lib/browser/index.mjs",
      "default": "./lib/browser/index.js"
    },
...

'use client' 지시어 로 해결하지 못하는 이유

그런데 'use client' 지시어를 추가해 해당 컴포넌트가 클라이언트 컴포넌트임을 알려주었는데도 왜 문제가 발생했을까? 이는 'use client' 지시어는 컴포넌트의 실행 환경을 클라이언트로 지정하는 것이지, 빌드 프로세스에는 영향을 주지 않기 때문이다. Next.js의 빌드 프로세스는 다음 순서로 진행된다:

  1. 빌드타임: 모든 컴포넌트(서버/클라이언트)를 분석하고 각각 서버 번들과 클라이언트 번들로 분리
  2. 런타임:
    • 서버에서 정적 HTML 생성 (서버 컴포넌트)
    • 클라이언트에서 하이드레이션 수행 (클라이언트 컴포넌트)

따라서 'use client' 지시어가 있더라도 해당 컴포넌트의 정적 임포트는 빌드 시점에 분석되어야 하며, 브라우저 전용 라이브러리인 msw/browser는 빌드 시점에서 문제를 일으킨다.

회고/아쉬웠던 점

사실은 공식 홈페이지에 문제를 예방할 수 있는 Browser intergration 가이드가 잘 작성되어있었는데. 이를 읽지 않았던것이 가장 큰 원인이었다. MSW를 도입하기전 관련 기술블로그 아티클을 잘 읽은 반면, 공식 홈페이지를 읽지 않는 기초적인 실수를 저질렀다. 하지만, 에러를 해결하면서, dynamic Import, node.js 환경과 브라우저 환경의 차이, Next.js의 빌드과정, package.json에서 export 설정과 같은 개념들을 학습할 수 있어서 나름 알찼던것 같다. 앞으로 새 라이브러리를 도입할 때 공식홈페이지를 꼭 하루이틀동안 정독하는 습관이 필요함을 느꼈다.

트러블 슈팅 : 조건부 렌더링에 따른 서버사이드 렌더링 이슈

'use client'

import { ReactNode, useEffect, useState } from 'react'

export function MSWProvider({ children }: { children: ReactNode }) {
  const [isReady, setIsReady] = useState(false)

  useEffect(() => {
    async function enableMocking() {
      if (
        typeof window !== 'undefined' &&
        process.env.NODE_ENV === 'development'
      ) {
        const { worker } = await import('@/mocks/browser') // 수정된 부분
        // useEffect훅안에서 클라이언트에서 동적으로 임포트 합니다.
        await worker.start()
      }
    }

    enableMocking().then(() => setIsReady(true))
  }, [])

  if (!isReady) return null

  return <>{children}</>
}

MSW 모킹 서버를 시작하는 enableMocking()함수가 완료되기 전까지는 일정 시간이 소요된다. 따라서, 서버 실행이전에 API요청이 있게되면 요청에 실패할 수 있습니다. 따라서 MSW 모킹 서버 준비 상태에 따라 하위 컴포넌트들을 조건부 렌더링하게 코드를 작성했다. 하지만 이 때문에, 루트레이아웃 하위의 컴포넌트 전체에서, 서버사이드 렌더링이 의도한대로 작동하지 못하는 이슈가 있었다. 따라서, 상위 코드처럼 조건부 렌더링 부분을 없애주어 이를 해결했다. 다행히 우리 프로젝트에서는 리액트 쿼리를 사용하고 있었기 때문에, 혹시 모킹 서버 준비 완료 이전에 요청을 하여 요청에 실패하더라도 자동으로 재시도하기 때문에 큰 문제는 없었다.

마치 SPA처럼 빈 바디와 스크립트들을 전송받은 결과. (이슈 해결 이전)

<!DOCTYPE html>
<html lang="ko">
    <head>
        <meta charSet="utf-8"/>
        ...
    </head>
    <body class="__variable_4d318d __variable_ea5f4b __variable_fde3a9 __variable_981c89 font-pretendard antialiased">
        <script src="/_next/static/chunks/webpack.js?v=1737521313207" async=""></script>
        <script>
            (self.__next_f = self.__next_f || []).push([0])
        </script>
          ... 무수히 긴 스크립트들 body태그 아래에 html태그는 없었다...
    </body>
</html>