Next App Router 서버 사이드에서 MSW 이슈
현재(2024.03.06) 시점에서 Next 14의 App Router에서 SSR이 필요한 컴포넌트에서 api 요청을 MSW에서 정상적으로 catch하지 못하는 문제가 있다.
이전에 작성했던 [React] Suspense 어떻게 사용할까?에서도 이와 관련한 이슈가 있었는데 당시에는 제대로 파악하지 못했다. 개인 프로젝트를 진행하던 중에
동일한 이슈가 발생하고 있었고, Support Next.js 13 (App Router)에서 이슈에 대해 파악할 수 있었다. 문제는 App router에서 Node.js
환경에서의 mocking이 제대로 동작하지 않아서 발생했다. Next.js의 instrumentation
이나 별도의 mocking 서버를 이용해서 문제 상황을 우회할 수는 있다.
그래서 이번 기회에 Page, App Router에서 동작을 직접 비교해보고, 임시 해결 방안에 대해 정리해 보고자 한다. 그리고 일부 해결 방안이라고 나오는 몇몇 방법들은 여전히 동일한
문제를 발생시키고 있었는데 이 글을 보는 사람들은 나와 동일한 삽질을 하지 않았으면 하는 바람이다.
문제 발생 상황
SSR을 적용한 프론트엔드 프로젝트에선 브라우저 뿐만 아니라 prefetch된 데이터를 위해서 프론트엔드 서버 내에서 백엔드로 api 요청을 보내게 된다. MSW는 브라우저 환경 뿐만 아니라 Node.js 환경에서도 mocking을 지원하고 있어서 Browser integration과 Node.js integration 을 모두 적절히 설정해 준다면 문제 없이 동작할 수 있을 거라고 생각했다. MSW 문서에서 안내한대로 browser와 server setup code를 작성하고, 앱 전체에 MSW를 init하기 위해서 최상위의 layout에서 각 실행 환경에 맞게 분기해줬다.
// @/mocks/index.ts
export async function setup() {
if (typeof window === 'undefined') {
const { server } = await import('./node');
server.listen({ onUnhandledRequest: 'bypass' });
} else {
const { worker } = await import('./browser');
worker.start({ onUnhandledRequest: 'bypass' });
}
}
// @/app/mock-provider.tsx
'use client';
import { useState, useEffect } from 'react';
const isMockEnabled = process.env.NEXT_PUBLIC_API_MOCKING === 'enabled';
export default function ApiMockProvider({ children }: { children: React.ReactNode }) {
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const init = async () => {
if (isMockEnabled) {
const { setup } = await import('@/mocks/index');
await setup();
setIsLoaded(true);
}
}
if (!isLoaded) {
init();
}
}, [isLoaded]);
if (!isLoaded) {
return null;
}
return <>{children}</>;
}
이런 식으로 코드를 작성하고, root layout에서 mock-provider을 불러와서 MSW를 초기화하도록 했다.
이 로그를 봤을 땐 아무런 문제가 없는 줄 알았다. 하지만 실제 mock api를 컴포넌트에서 사용하려고 했더니 오류가 발생하기 시작했다. 처음엔 mock-provider
에서 이런 식으로
msw를 초기화했다고 하더라도, 클라이언트 컴포넌트이기 때문에 서버에서 prerender되는 Node.js 환경을 제대로 커버하지 못하겠구나라는 생각이었다. 실제로 전역 레벨에서 MSW를
init해야하는데 App Router의 root layout은 페이지 컴포넌트와 프로세스가 분리되어 있어 api mocking을 제대로 하지 못하는 이슈가 있었다. msw github에서 이미
이슈가 논의 중이었고, Next.js 팀과 이 문제를 해결하려고 하고 있다고 하니 나중엔 해결되지 않을까 생각한다.
그래서 browser에서만 사용하면 문제가 없겠구나라고 생각했다. 하지만 prerender되는 컴포넌트가 아니고, 데이터를 브라우저 쪽에서 요청하는 상황에서도 오류가 발생했다.
Nextjs의 app router에서 msw를 적용했다는 여러 블로그들의 포스트를 봤을 때 브라우저에서만큼은 문제가 없다고 해서 여기서 삽질을 엄청 했던 것 같다. Next 13, 14 버전
차이인지 명확한 원인을 찾지 못했지만 위의 코드대로 실행했을 때 msw가 setup을 완료하기 이전에 setIsLoaded(true)
가 실행되면서 mocking 전에 이미 children 노드들이
렌더링되면서 mocking되지 않은 곳으로 요청을 보내는 것이었다.
'use client';
import { useState, useEffect } from 'react';
const isMockEnabled = process.env.NEXT_PUBLIC_API_MOCKING === 'enabled';
export default function ApiMockProvider({ children }: { children: React.ReactNode }) {
const [isLoaded, setIsLoaded] = useState(!isMockEnabled);
useEffect(() => {
function init() {
if (typeof window !== 'undefined' && isMockEnabled) {
import('@/mocks/browser')
.then(({ worker }) => worker.start())
.then(() => setIsLoaded(true));
}
}
init();
}, []);
if (!isLoaded) {
return null;
}
return <>{children}</>;
}
이런식으로 browser setup 코드를 직접 불러와서 worker.start
가 마무리된 이후에 isLoaded
를 true
로 바꿔 children이 렌더링되도록 시점을 정확히 명시해주니까 브라우저에서는
msw로 api mocking이 제대로 동작하게 되었다.
추가로 여기까지 오는 과정에서도 한가지 문제가 있었는데 Package path ./browser is not exported from package /node_modules/msw
이런 오류가 발생했던 것이다. 이는 Next.js가
import를 Node.js 환경에서 시도하게 되는데 msw/browser
는 브라우저 환경에서 동작하는 해야하는 차이 때문에 발생한 것이었다. 클라이언트 컴포넌트이고, useEffect 내에서 dynamic
import를 해주는 것이라 문제되지 않을거라 생각했는데 typeof window !== 'undefined'
조건을 추가하고 나서야 제대로 동작했다.
그럼 Page Router는 MSW와 잘 작동하는지 궁금해서 몇가지 실험을 해봤다.
- Next.js 13 이후: 모든 MSW 버전과 호환되지 않음
- Next.js 13 이전: MSW 1.x.x와는 호환이 됨
msw를 setup하는 코드는 위의 @/mocks/index.ts
와 동일하게 사용하고 _app.tsx
에서 해당 코드를 불러서 사용하면 된다.
// pages/_app.tsx
...
import { setup } from '@/mocks';
if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled') {
setup();
}
...
다만 초기 로드시 setup이 마무리되기전까지 block되지 않아서 오류가 발생한다. 새로고침하면 setup이 마무리된 시점이기 때문에 더이상 오류가 발생하지 않지만 조금 더 최적화할 수 있는 방법이 있는지 찾아봐야겠다.
그럼 Next.js 13 이후에는 서버 사이드 요청을 mocking하지 못하는 것일까? 임시적인 방편이겠지만 사용 가능한 몇가지 방법이 있다.
임시 방편
서치하면서 몇가지 임시 방편이 있었는데,독립적인 mocking server를 설정하는 방법이 현재로는 가장 괜찮았다. 이외에도 instrumentation
, route handlers
를 사용하는 방법도
제시되었지만 다음의 이유로 실제로 사용하긴 어려울 것 같다.
instrumentation
는 현재 msw import 이슈 때문에 제대로 작동하지 않는 것으로 보인다.
route handlers
는 하나의 프로젝트에서 프론트, 백엔드를 통합해서 운영하는 방식을 지원하기 위해서 Next.js에서 제공하는 api로 보여 mocking을 위해서 사용하는 것에 약간
부적합해보인다. 물론 mocking을 위해서 사용하려면 사용할 수 있겠지만 프론트와 백엔드를 별도의 프로젝트로 나눠서 운영하는 서비스의 경우에는 production 배포에선
route handler로의 요청이 노출되지 않도록 별도의 조치를 해야할 것이다.
이런 이유에서 다른 방법은 따로 다루지 않고, mocking server를 별도로 세팅하는 방법만 간단히 다뤄보려고 한다.
mocking server
@mswjs/http-middleware
를 통해 정의한 handler를 별도의 mocking server에서 가로채서 처리하는 방법이다. 여기서는 express
를 사용했지만 다른 프레임워크 어떤 것이든 상관없다.
yarn add @mswjs/http-middleware express --save-dev
만약 typescript 환경이라면 @types/express
와 tsx
도 추가로 설치해준다. 그리고 별도의 mocking 서버 세팅 코드를 추가한다.
// mocks/http.ts
import express from 'express';
import { createMiddleware } from '@mswjs/http-middleware';
import { handlers } from './handlers';
const app = express();
const port = 8080;
app.use(express.json());
app.use(createMiddleware(...handlers));
app.listen(port, () => console.log(`Mock server is running on port: ${port}`));
마지막으로 http.ts
파일을 실행해서 server를 띄울 수 있는 script를 package.json에 추가해서 mocking을 사용할 때 이번에 세팅한 새로운 서버를 같이 띄워둔다.
{
"scripts": {
"mock": "npx tsx watch ./src/mocks/http.ts"
}
}
별도의 mocking 서버에서 cors 세팅까지 해준다면 이전에 사용했던 browser 사이드의 msw 초기화코드까지 이쪽으로 통합할 수 있을 것이다. 다만 하나의 서버를 추가로 세팅해서 관리해야하고, mocking할 때와 하지 않을 때 api 서버 주소를 본인 환경에 맞게 세팅해주는 등 번거로움이 있다.
마치며
개인적으로 지금 진행하는 프로젝트에선 msw를 굳이 사용하지 않게 될 거 같다. intrumentation은 실험적 기능이고, mocking server는 1인 풀스택 프로젝트라서 결국 api 코드도 직접 짜야 되기 때문에 2중으로 일을 하게 될 것 같고, route handler는 mocking을 위해 사용하는게 원래 의도랑 동떨어진 것 같은 느낌이기 때문이다. 대신에 유닛 테스트나, e2e 테스트 쪽에 좀 더 집중하려고 한다.
Next 14까지 나오면서 App router가 좀 더 안정적이라 production 단계에서도 사용하기 문제가 없을 줄 알았다. 하지만 여전히 production 단계에서 고려해야될 문제들이 남아있는 느낌이다. msw도 next와 논의 중이라고 하니 어떻게 결론이 날지 계속해서 지켜봐야겠다.
여기의 각 브랜치에서 버전별 이슈를 확인해볼 수 있다.
참조
- https://github.com/mswjs/msw/issues/1644