MSW 도입기: MSW는 어떻게 요청을 가로챌까?
최근 프론트엔드 테스트코드를 작성하는 작업을 하고 있다. 한번도 실무에서 테스트코드를 작성해본 적이 없기에 그야말로 맨땅에 헤딩하는 식으로 작업을 진행 중이다. 그중 가장 최근에 MSW를 도입하게 되면서 여러 시행착오를 겪으며 배운 것이 있어 정리해 보려고 한다.
왜 MSW를 도입했나?
처음엔 단순히 API 응답을 단순 객체로 만들어 react-query 모킹에 data로 주입하는 식으로 코드를 작성했다. 그러나 이렇게 하니 간단한 요청과 응답이 있는 컴포넌트는 괜찮지만 복잡한 요청과 응답, 그에 따른 조건부 렌더링과 추가적인 요청이 이뤄져야 하는 컴포넌트에서는 엄청나게 복잡하고 유지보수가 어려운 모킹이 줄줄이 붙게 되었다.
또, mock data를 테스트 코드 안에 그대로 두고 있어 테스트 코드 파일이 길어지고 가독성이 매우 저하되었으며 mock data를 사실상 재활용할 수 없었다.
그래서 다음과 같은 이유로 MSW를 도입하게 되었다:
- HTTP 요청 단위로 mock을 정의할 수 있어서 실제 API 흐름과 매우 유사한 방식으로 테스트를 구성할 수 있다.
- mock 데이터를 체계적으로 관리할 수 있고, 재사용할 수 있다.
왜 fetch client가 실제 네트워크 요청만 보낼까
그렇게 MSW를 도입하면서, 예상치 못한 에러를 반복적으로 마주하게 되었는데, 그것은 개발 환경에서는 잘 작동하던 fetch client가 테스트 코드에서는 예상치 못한 undefined 오류를 발생시키거나, 실제 네트워크 요청만 날아가고 mock 응답은 도달하지 않는 현상이 반복되었다.
처음에는 서버 handler를 잘못 작성한 줄 알고 한땀한땀 console.log를 달아 모니터링을 했으나 애초에 서버에 요청 자체가 들어오지 않았기 때문에 답답함과 의문만 커져갔다.
MSW는 어떻게 요청을 가로챌까?
MSW(Mock Service Worker)는 테스트나 개발 환경에서 실제 API 호출을 대체할 수 있도록 요청을 intercept해 응답을 반환한다.
테스트 환경(예: Jest)에서 MSW는 @mswjs/interceptors라는 라이브러리를 이용해 GlobalThis.fetch를 감싸서 자체 핸들러로 라우팅하는 방식으로 동작한다. 실제 코드를 보면 다음과 같은 방식으로 기존 fetch를 감싸는 것을 쉽게 알 수 있다.
export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
globalThis.fetch = async (input, init) => {
const isRequestHandled = await handleRequest({
//...
});
if (isRequestHandled) {
return responsePromise;
}
return pureFetch(request).then(async (response) => {
//...
return response;
});
};
}
위와 같이 사용자가 정의한 MSW 핸들러 목록에서 현재 요청과 일치하는 mock 응답을 찾고, 일치하는 핸들러가 없으면 pureFetch를 호출해 실제 네트워크 요청을 보낸다.
다만 브라우저 환경에서는 fetch를 덮어쓸 수 없기 때문에, Service Worker를 등록해서 네트워크 요청을 가로채는 방식으로 다르게 작동한다. 나는 주로 MSW를 테스트 환경에서 사용했기 때문에 위의 내용을 이해하는 게 중요했다.
모듈은 import 될 때 평가된다
문제의 본질은 fetch client의 import가 너무 일찍 실행되어 버린다는 것이었다. jest환경에서는 코드 실행 전, 테스트를 포함한 애플리케이션이 시작되기 전에 모듈을 불러와 평가한다. 이 말은 즉, import가 선언된 시점에 해당 모듈 내부의 코드가 이미 실행되어 필요한 값이나 함수들이 초기화된 상태라는 뜻이다.
예를 들어, 아래와 같이 모노레포의 내부 패키지에서 fetch client를 정의하고 import 하고 있다.
// api.ts
const fetchClient = createFetchClient({
baseUrl: process.env.NEXT_PUBLIC_API_ENDPOINT,
});
// SomeComponent.tsx
import { fetchClient } from './api';
하지만 이 fetch client는 내부적으로 브라우저의 fetch를 사용하고 있다. 문제는 fetchClient가 컴포넌트가 import되면서 너무 일찍 평가되었고, 그 시점엔 아직 MSW가 fetch를 가로채기 전이라서, 결국 패치되지 않은 원래의 fetch가 클라이언트에 고정돼 버린다. 그래서 msw의 handler가 등록된 서버로 요청이 가지 않고 실제 네트워크 요청만 날아가게 돼 제대로 테스팅이 안된 것이었다.
핵심 원인 — 모듈 평가 타이밍과 MSW의 fetch 패치
이 문제를 이해하면서 다음과 같은 중요한 사실을 배웠다.
- MSW는 런타임에 fetch를 패치한다
- 반면 MSW는 테스트 실행 중간에 fetch를 monkey-patch* 방식으로 교체한다. 따라서 이미 평가된 클라이언트는 MSW가 개입하기 전의 fetch를 계속 사용하게 된다.
- Jest는 모듈을 미리 평가한다
- Jest 테스트 실행 시, 테스트 코드에 등장하는 모든 모듈을 테스트 실행 이전에 미리 평가하고 캐싱한다. 그래서 import 시점에 이미 fetch client가 초기화되어 fetch 함수는 그 당시의 것을 고정해버린다.
해결책 — 동적 import로 평가 시점 제어하기
이 문제를 해결하기 위한 가장 간단하고 효과적인 방법은 정적 import를 동적 import로 바꾸는 것이었다.
// component.test.ts
const { default: ProductOrderForm } = await import('@/components/forms/ProductOrderForm');
await import(...)를 사용하면, 모듈은 실제 코드가 실행될 때 비로소 평가된다. 덕분에 이 시점까지는 MSW가 fetch를 패치할 시간을 확보할 수 있다. 이렇게 하면 fetch client가 생성될 때, 내부적으로 사용하는 fetch는 이미 MSW에 의해 가로채진 상태가 되어 mock 응답이 정상적으로 동작하게 된다.
배운 것
- 자바스크립트 모듈은 기본적으로 정적으로 평가되며, 평가 시점은 import 위치에서 결정된다.
- 테스트 환경은 실제 앱과는 다르며, 모듈 평가 순서와 런타임 패치 순서가 충돌할 수 있다.
- MSW는 fetch를 런타임에 교체하므로, 패치 이후에 fetch client가 생성되도록 해야 한다.
- 동적 import를 활용해서 평가 시점을 지연시켜 정적 import로 인한 문제를 해결할 수 있다.