테스트 코드를 만들 때 가장 경계해야 하는 지점은 의외로 “목 데이터를 얼마나 잘 만들었는가”가 아니다. 진짜 어려운 문제는 목 데이터가 실 서비스 코드의 흐름을 침범하지 않게 만드는 것이다.
처음에는 화면 컴포넌트에서 조건을 하나 넣는 방식이 가장 쉬워 보인다.
const data = isTest ? mockData : await fetchRealData();
간단한 프로토타입에는 쓸 수 있지만, 같은 분기가 여러 화면으로 퍼지면 테스트와 제품의 책임을 구분하기 어려워진다.
화면은 테스트 여부를 알게 되고, 도메인 훅은 목 데이터 분기를 떠안고, API 함수는 실 서버와 테스트 서버 사이에서 흔들린다. 어느 순간부터 테스트를 위해 만든 코드가 실제 제품 코드의 책임을 흐리기 시작한다.
이번에 만든 구조의 목표는 정반대였다.
테스트 모드는 존재하되, 화면과 도메인 코드는 모르게 한다. 목 데이터는 사용할 수 있되, 실 API 호출 코드를 바꾸지 않는다. 테스트 시나리오는 쉽게 켜고 끌 수 있되, 운영 환경에는 노출되지 않는다.
이를 위해 테스트 기능을 API 요청 경계에 몰아넣었다.
구조를 한 문장으로 요약하면 이렇다.
브라우저 콘솔에서 테스트할 endpoint를 켜면, API 클라이언트와 API 프록시가 해당 요청만 가로채 목 응답을 만들거나 실제 응답을 패치한다.
이 글에서는 그 구조를 설계한 이유와 내부 구현을 하나씩 정리한다.
문제: 목 데이터는 편해야 하지만 실 코드는 오염되면 안 된다
프론트엔드에서 테스트 데이터를 다루는 방식은 보통 세 갈래로 나뉜다.
화면 컴포넌트 안에서 목 데이터를 직접 import한다.
API 함수 안에서 환경 변수나 플래그로 목 응답을 반환한다.
네트워크 계층에서 요청을 가로채 응답을 바꾼다.
첫 번째 방식은 빠르게 확인하기 좋지만 화면이 테스트 상태를 직접 알게 된다. 화면 코드가 테스트 모드를 알게 된다. 실제 UI 상태와 테스트 상태가 섞이고, 나중에는 “이 조건이 운영에서도 필요한 조건인지, 테스트를 위한 조건인지”를 구분하기 어려워진다.
두 번째 방식은 조금 낫지만 여전히 API 함수가 무거워진다. API 함수는 원래 endpoint와 요청, 응답 타입을 설명하는 곳이어야 한다. 그런데 여기에 테스트 시나리오가 쌓이면 API 함수가 “실제 통신 코드”인지 “테스트 fixture 매니저”인지 모호해진다.
세 번째 방식은 구현 비용이 조금 더 들지만 책임이 깔끔하다. 화면은 실제 API를 호출한다. application hook도 실제 API를 호출한다. query option도 그대로 둔다. 대신 요청이 지나가는 경계에서만 “이 요청은 목으로 처리할 것인가?”를 결정한다.
이번 구현은 세 번째 방식을 택했다.
전체 구조
테스트 모드는 크게 네 층으로 나뉜다.
단계 맡은 역할
브라우저 콘솔 test.add(path)로 테스트할 경로 선택
localStorage와 cookie 브라우저와 서버 요청에서 읽을 테스트 상태 저장
TestModeProvider 화면 상태 동기화와 TEST MODE 표시
API interceptor와 proxy 대상 요청의 전체 응답 대체 또는 실제 응답 일부 수정
핵심 파일은 다음과 같다.
lib/test/mock-remote.ts
components/providers/test-mode-provider.tsx
store/test/use-test-store.ts
store/test/index.ts
lib/api-client.ts
lib/server/api/proxy.ts
app/layout.tsx
이 구조에서 화면 코드는 테스트 모드를 몰라도 된다. 화면은 평소처럼 API를 호출한다. 테스트 모드 여부는 API 요청이 출발하거나 프록시를 지나는 순간에만 판단된다.
테스트 모드는 local/development에서만 열린다
이번 설계에서는 테스트 기능을 로컬·개발 환경으로 제한한다. 그래서 가장 바깥에 환경 가드를 둔다.
export const isTestModeAvailable = () =>
env.apiEnvironment === "local" || env.apiEnvironment === "development";
이 함수는 lib/test/mock-remote.ts의 시작점이다. 목 path를 읽고 쓰는 모든 함수는 이 조건을 먼저 확인한다.
export const readMockRemotePaths = (cookieHeader?: string | null) => {
if (!isTestModeAvailable()) {
return [];
}
// cookie 또는 localStorage에서 활성화된 mock path를 읽는다.
};
이 가드는 운영 환경에서 테스트 기능이 활성화되지 않도록 하는 조건이다. 코드가 운영 번들에서 제거되는지는 별도로 확인해야 한다. 서버에서도 자체 환경 설정을 검증해야 하며, 클라이언트의 쿠키만으로 테스트 모드를 허용해서는 안 된다.
테스트 기능은 개발 생산성을 위한 장치이지, 제품 기능이 아니다. 그래서 환경 경계가 가장 먼저 와야 한다.
콘솔에서 테스트를 켜는 이유
테스트 기능은 개발자와 QA가 빠르게 써야 한다. 버튼이나 관리자 화면을 만들 수도 있지만, 그 자체가 또 하나의 제품 코드가 된다.
이번 구현에서는 브라우저 콘솔을 테스트 모드의 조작 인터페이스로 사용했다.
test.add("/orders/cart/createCart");
test.remove("/orders/cart/createCart");
test.toggle("/orders/cart/createCart");
test.list();
test.clear();
PASS 본인인증 팝업처럼 path 기반 목이 아닌 기능도 같은 인터페이스로 켤 수 있다.
test.add("본인인증");
test.remove("본인인증");
콘솔 API는 TestModeProvider에서 설치한다.
Object.defineProperty(window, "test", {
configurable: true,
get: () => testConsoleApi,
set: (value) => {
if (typeof value === "string") {
setTestMocks(value);
}
},
});
이렇게 해두면 함수 호출과 대입형 사용을 모두 지원할 수 있다.
test("/orders/cart/createCart");
test = "/orders/cart/createCart";
개발자 입장에서는 사용법이 짧고, QA 입장에서는 재현 절차에 그대로 적을 수 있다.
1. 개발 환경 접속
2. 콘솔에서 test.add("/orders/cart/createCart") 실행
3. 장바구니 담기 시나리오 진행
테스트 진입 장벽을 낮추는 데는 이런 작은 사용성이 꽤 중요하다.
활성화된 목 path는 localStorage와 cookie에 같이 저장한다
목 인터셉터를 브라우저에서만 쓴다면 localStorage만으로 충분하다. 하지만 Next.js 환경에서는 SSR 요청도 있다. 서버에서 렌더링되는 API 요청은 브라우저 localStorage를 읽을 수 없다.
그래서 활성화된 목 path를 두 곳에 저장한다.
browserLocalStorage.setItem(
storageKeys.local.testMockRemotePaths,
JSON.stringify(normalizedPaths),
);
writeMockRemoteCookie(normalizedPaths);
localStorage는 클라이언트 상태 동기화에 유리하고, cookie는 서버 요청에서 읽을 수 있다.
const writeMockRemoteCookie = (paths: string[]) => {
if (typeof document === "undefined") {
return;
}
document.cookie = [
`${storageKeys.local.testMockRemotePaths.key}=${encodeURIComponent(JSON.stringify(paths))}`,
"path=/",
`max-age=${MOCK_REMOTE_COOKIE_MAX_AGE_SECONDS}`,
"samesite=lax",
].join("; ");
};
그리고 루트 레이아웃에서는 서버 요청의 cookie를 읽어 초기 테스트 상태를 주입한다.
const getInitialActiveMockRemotePaths = async () => {
if (env.apiEnvironment !== "local" && env.apiEnvironment !== "development") {
return [];
}
const [{ headers }, { readMockRemotePaths }] = await Promise.all([
import("next/headers"),
import("@/lib/test/mock-remote"),
]);
const requestHeaders = await headers();
return readMockRemotePaths(requestHeaders.get("cookie"));
};
그 값은 TestModeProvider로 전달된다.
<TestModeProvider initialActiveMockRemotePaths={initialActiveMockRemotePaths} />
이 덕분에 클라이언트 렌더링 요청과 서버 렌더링 요청이 같은 테스트 상태를 공유한다.
path 정규화: /api가 붙어도 같은 endpoint로 본다
테스트 기능은 사용하기 쉬워야 한다. 그런데 실제 요청 경로는 상황에 따라 조금씩 달라질 수 있다.
예를 들어 같은 요청이 다음처럼 표현될 수 있다.
/orders/cart/createCart
/api/orders/cart/createCart
https://app.example.com/api/orders/cart/createCart
사용자가 콘솔에 어떤 형태로 입력하든 같은 endpoint로 판단해야 한다. 그래서 path를 정규화하고 후보군을 만든다.
export const normalizeMockRemotePath = (path: string) => {
const trimmed = path.trim();
if (!trimmed) {
return "";
}
try {
const parsedUrl = URL.canParse(trimmed)
? new URL(trimmed)
: new URL(
trimmed,
typeof window === "undefined"
? "https://app.example.com"
: window.location.origin,
);
return parsedUrl.pathname;
} catch {
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
};
그리고 /api prefix가 붙은 경우와 빠진 경우를 모두 후보로 둔다.
const createPathCandidates = (path: string) => {
const normalizedPath = normalizeMockRemotePath(path);
if (!normalizedPath) {
return [];
}
const withoutApiPrefix = normalizedPath.replace(/^\/api(?=\/)/, "");
const withApiPrefix = `/api${withoutApiPrefix}`;
return Array.from(
new Set([normalizedPath, withoutApiPrefix, withApiPrefix].filter(Boolean)),
);
};
이 처리가 들어가면 사용자는 내부 프록시 경로를 정확히 몰라도 된다.
test.add("/orders/cart/createCart");
test.add("/api/orders/cart/createCart");
둘 다 같은 목 핸들러를 켠다.
목 핸들러 등록은 타입 안전하게 한다
목 응답을 쉽게 추가하려면 등록 API가 단순해야 한다. 하지만 단순함 때문에 타입을 포기하면 나중에 응답 구조가 실제 API와 어긋나기 쉽다.
그래서 defineMockRemote를 만들었다.
export const defineMockRemote = <TPath extends MockRemotePath>(
path: TPath,
handler: MockRemoteHandler<
MockRemoteRequest<TPath>,
MockRemoteResponse<TPath>
>,
): MockRemoteDefinition => ({
handler: handler as MockRemoteHandler,
path: normalizeMockRemotePath(path),
});
이 함수는 path를 기준으로 request와 response 타입을 연결한다.
실제 등록 코드는 이렇게 짧다.
const mockRemoteDefinitions: readonly MockRemoteDefinition[] = [
defineMockRemote(ordersCartCreateCartPath, ({ request }) => ({
code: "200",
data: {
cartTpCd: request?.cartTpCd ?? "ORD",
cartVerNo: `mock-cart-${Date.now()}`,
},
message: "OK",
status: 200,
timestamp: new Date().toISOString(),
})),
];
여기서 중요한 점은 ordersCartCreateCartPath를 문자열로 직접 쓰지 않는다는 것이다.
import { ordersCartCreateCartPath } from "@/option/orders/indexPaths";
이미 생성된 endpoint path const를 사용한다. 프로젝트 규칙상 브라우저 이동이나 팝업처럼 API fetch가 아닌 곳에서 endpoint path가 필요할 때도 remote를 직접 import하지 않고 option/<group>/indexPaths.ts의 generated path const를 사용한다. 테스트 목 등록도 같은 원칙을 따른다.
덕분에 endpoint 문자열이 바뀌면 타입과 import 경로를 통해 흔적이 드러난다. 목 데이터가 실제 API 계약에서 조용히 멀어지는 일을 줄일 수 있다.
전체 mock과 patch mock을 나눈 이유
목 처리는 두 종류로 나뉜다.
첫 번째는 전체 응답을 대체하는 mock이다.
defineMockRemote(ordersCartCreateCartPath, ({ request }) => ({
code: "200",
data: {
cartTpCd: request?.cartTpCd ?? "ORD",
cartVerNo: `mock-cart-${Date.now()}`,
},
message: "OK",
status: 200,
timestamp: new Date().toISOString(),
}));
이 방식은 서버 호출 자체가 필요 없는 시나리오에 적합하다. 예를 들어 장바구니 생성처럼 특정 성공 응답만 있으면 다음 화면으로 넘어갈 수 있는 경우다.
두 번째는 실제 응답을 받은 뒤 일부만 바꾸는 patch다.
const mockRemotePatchDefinitions: readonly MockRemotePatchDefinition[] = [
defineMockRemotePatch(menusDetailGetMenuDetailPath, (response) => ({
...response,
data: response.data
? {
...response.data,
menuBasicInfo: response.data.menuBasicInfo
? {
...response.data.menuBasicInfo,
menuTpCd: "HNH",
}
: response.data.menuBasicInfo,
}
: response.data,
})),
];
이 방식은 실 서버 데이터의 대부분은 그대로 쓰고, 테스트하고 싶은 조건만 바꿀 때 좋다.
예를 들어 메뉴 상세 응답에서 menuTpCd만 HNH로 바꾸면, 실제 서버 응답의 나머지 필드는 그대로 유지하면서 하프앤하프 메뉴 UI를 테스트할 수 있다.
이 구분은 실무에서 중요하다.
모든 것을 mock으로 대체하면 테스트는 쉬워지지만 실제 응답과 멀어진다. 반대로 모든 것을 실 서버에 의존하면 테스트하기 어려운 edge case를 만들 수 없다. 그래서 전체 mock과 patch mock을 모두 지원하게 했다.
API 클라이언트에서 adapter로 요청을 가로챈다
클라이언트 요청의 핵심은 Axios request interceptor다.
apiClient.interceptors.request.use(async (config) => {
const requestConfig = config as InternalApiRequestConfig;
const serverCookieHeader =
typeof window === "undefined" ? await getServerCookieHeader() : undefined;
const mockRemoteAdapter = createMockRemoteAdapter(
requestConfig,
serverCookieHeader,
);
if (mockRemoteAdapter) {
requestConfig.adapter = mockRemoteAdapter;
}
return requestConfig;
});
화면 계층의 테스트 분기를 줄이는 지점이다.
화면 코드나 application hook에서는 여전히 apiClient.get, apiClient.post를 호출한다. 테스트 모드 여부를 알지 못한다. 단지 요청이 나가기 직전에 interceptor가 “이 요청은 mock 대상인가?”를 판단한다.
대상이라면 Axios adapter를 바꾼다.
import { AxiosError } from "axios";
export const createMockRemoteAdapter = (
config: InternalAxiosRequestConfig,
cookieHeader?: string,
): AxiosAdapter | null => {
if (!isTestModeAvailable()) {
return null;
}
const path = normalizeMockRemotePath(config.url ?? "");
if (
!path ||
isMockRemotePatchActive(path, cookieHeader) ||
!findMockRemoteHandler(path) ||
!isMockRemotePathActive(path, config.headers, cookieHeader)
) {
return null;
}
return async () => {
const method = config.method?.toUpperCase() ?? "GET";
const result = await createMockRemoteResult({
body: parseBody(config.data),
cookieHeader,
headers: config.headers,
method,
params: config.params,
path,
url: config.url ?? path,
});
const response = {
config,
data: result.data,
headers: {},
status: result.status,
statusText: result.statusText,
};
if (config.validateStatus && !config.validateStatus(response.status)) {
throw new AxiosError(
`Request failed with status code ${response.status}`,
response.status >= 500
? AxiosError.ERR_BAD_RESPONSE
: AxiosError.ERR_BAD_REQUEST,
config,
undefined,
response,
);
}
return response;
};
};
Axios adapter는 실제 네트워크 호출을 수행하는 가장 아래쪽 실행 단위다. 여기를 교체하면 API 함수나 화면 코드를 바꾸지 않고도 응답만 바꿀 수 있다.
이게 “테스트 코드는 API 경계에서 처리한다”는 설계의 핵심이다.
서버 프록시에서도 같은 목 로직을 사용한다
Next.js 앱에서는 모든 요청이 브라우저에서만 나가지 않는다. 인증이 필요한 API는 local API proxy를 거칠 수 있다. SSR 과정에서 발생하는 요청도 있다.
그래서 서버 프록시에서도 같은 mock remote 로직을 사용한다.
export const proxyApiRequest = async (request: Request, path: string[]) => {
const method = request.method.toUpperCase();
const params = readProxyParams(request);
const body = BODYLESS_METHODS.has(method)
? undefined
: await request.arrayBuffer();
const normalizedPath = `/${path.join("/")}`;
const cookieHeader = request.headers.get("cookie") ?? "";
const shouldUsePatch = isMockRemotePatchActive(normalizedPath, cookieHeader);
const mockResult = shouldUsePatch
? null
: await createMockRemoteResult({
body: parseProxyBody(body),
cookieHeader,
headers: createMockProxyHeaders(request),
method,
params,
path: normalizedPath,
url: request.url,
});
if (mockResult) {
return createMockProxyResponse(mockResult);
}
// mock 대상이 아니면 실제 upstream 호출
};
전체 mock 대상이면 upstream API를 호출하지 않고 바로 JSON 응답을 만든다.
const createMockProxyResponse = (result: {
data: unknown;
status: number;
statusText: string;
}) =>
NextResponse.json(result.data, {
headers: {
"cache-control": "no-store",
},
status: result.status,
statusText: result.statusText,
});
patch mock 대상이면 실제 upstream 응답을 받은 뒤 payload만 바꾼다.
const patchedPayload = await applyMockRemotePatch({
body: parseProxyBody(body),
cookieHeader: request.headers.get("cookie") ?? "",
data: payload,
headers: createMockProxyHeaders(request),
method,
params,
path: `/${path.join("/")}`,
url: request.url,
});
이 구조가 좋은 이유는 서버와 클라이언트가 같은 판단 기준을 공유한다는 점이다.
목 path를 읽는 곳도 readMockRemotePaths, 목 응답을 만드는 곳도 createMockRemoteResult, patch를 적용하는 곳도 applyMockRemotePatch다. 실행 위치만 다를 뿐 정책은 하나다.
patch 요청은 프록시로 보낸다
patch mock은 전체 mock보다 조금 더 섬세하다. 실제 응답을 받아야 하므로 요청이 반드시 프록시를 지나야 한다.
그래서 API 클라이언트의 request interceptor에서 patch 대상인지 먼저 확인한다.
const shouldUseMockPatchProxy = isMockRemotePatchActive(
requestConfig.url,
serverCookieHeader,
);
const shouldUseProxy =
shouldUseLocalApiProxy(requestConfig.url, authRequirement, transportMode) ||
shouldUseMockPatchProxy;
if (shouldUseProxy) {
requestConfig.url = toLocalApiProxyPath(requestConfig.url);
}
일반적으로 인증이 필요 없는 direct 요청이라도 patch mock이 켜져 있으면 local API proxy로 보낸다. 그래야 서버 프록시가 upstream 응답을 받고 patch를 적용할 수 있다.
즉, patch mock은 “응답을 직접 만들지 않고, 실제 응답을 통과시킨 뒤 일부만 바꾸는 모드”다.
요청 context를 넘겨서 handler를 유연하게 만든다
목 핸들러는 단순히 response만 반환하지 않는다. 요청 정보를 함께 받는다.
export type MockRemoteContext<TRequest = unknown> = Readonly<{
body: TRequest | undefined;
headers: unknown;
method: string;
path: string;
params: unknown;
request: TRequest | undefined;
url: string;
}>;
이 context 덕분에 mock 응답을 요청에 따라 바꿀 수 있다.
defineMockRemote(ordersCartCreateCartPath, ({ request }) => ({
code: "200",
data: {
cartTpCd: request?.cartTpCd ?? "ORD",
cartVerNo: `mock-cart-${Date.now()}`,
},
message: "OK",
status: 200,
timestamp: new Date().toISOString(),
}));
이 프로젝트의 API 규칙에서는 GET/DELETE의 query params, POST/PUT/PATCH의 body를 handler의 request로 전달한다. 모든 HTTP API가 이 규칙을 따르는 것은 아니므로 적용할 API 계약을 먼저 확인해야 한다.
const normalizedMethod = method.toUpperCase();
const request =
normalizedMethod === "GET" || normalizedMethod === "DELETE" ? params : body;
이렇게 하면 handler 작성자가 HTTP method별 데이터 위치를 매번 신경 쓰지 않아도 된다.
콘솔 조작과 UI 상태를 동기화한다
테스트 기능이 콘솔에만 있으면 한 가지 문제가 생긴다.
지금 테스트 모드가 켜져 있는지 화면에서 알기 어렵다.
그래서 TestModeProvider는 활성화된 목 path 또는 PASS 인증 목이 있으면 화면 전체에 TEST MODE 워터마크를 표시한다.
if (!isAvailable || !visibleIsTest) {
return null;
}
return (
<div
aria-hidden="true"
className="pointer-events-none fixed inset-0 z-[2147483647] select-none"
>
<div className="absolute inset-0 overflow-hidden opacity-[0.08]">
<div className="absolute top-1/2 -left-16 flex w-[140vw] -translate-y-1/2 -rotate-24 flex-wrap justify-center gap-x-12 gap-y-8 text-red-600">
{TEST_MODE_WATERMARK_ITEMS.map((item) => (
<span className="text-[28px] font-black tracking-normal" key={item}>
TEST MODE
</span>
))}
</div>
</div>
</div>
);
여기서 pointer-events-none을 준 것도 중요하다. 테스트 모드 표시가 화면 위에 떠 있어도 실제 UI 클릭을 막지 않는다.
또한 html/body에 dataset을 심는다.
if (visibleIsTest) {
document.documentElement.dataset.testMode = "true";
document.body.dataset.testMode = "true";
return;
}
필요하면 CSS나 디버깅 도구에서 현재 테스트 상태를 쉽게 확인할 수 있다.
테스트 기능은 숨어 있으면 위험하다. 특히 목 응답을 켜둔 채로 다른 시나리오를 테스트하면 헷갈릴 수 있다. 그래서 화면에 아주 약하지만 분명한 신호를 남겼다.
storage 이벤트와 custom event를 같이 쓴다
테스트 path가 바뀌면 React 상태도 즉시 바뀌어야 한다.
다른 탭에서 localStorage가 바뀌는 경우에는 storage 이벤트가 발생한다. 하지만 같은 탭에서 localStorage를 바꾸면 storage 이벤트가 발생하지 않는다.
그래서 직접 custom event를 함께 발행한다.
window.dispatchEvent(
new CustomEvent<string[]>(MOCK_REMOTE_CHANGED_EVENT, {
detail: normalizedPaths,
}),
);
구독 함수는 둘 다 듣는다.
export const subscribeMockRemotePathsChange = (
listener: (paths: string[]) => void,
) => {
const handleStorage = (event: StorageEvent) => {
if (event.key !== storageKeys.local.testMockRemotePaths.key) {
return;
}
listener(readMockRemotePaths());
};
const handleCustomEvent = (event: Event) => {
listener((event as CustomEvent<string[]>).detail ?? readMockRemotePaths());
};
window.addEventListener("storage", handleStorage);
window.addEventListener(MOCK_REMOTE_CHANGED_EVENT, handleCustomEvent);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(MOCK_REMOTE_CHANGED_EVENT, handleCustomEvent);
};
};
이렇게 하면 같은 탭, 다른 탭 모두 상태가 자연스럽게 맞춰진다.
Redux store는 UI 동기화만 맡는다
테스트 상태는 Redux에도 들어간다.
export type TestState = Readonly<{
activeMockRemotePaths: string[];
updatedAt: string | null;
}>;
하지만 Redux가 진짜 source of truth는 아니다. 실제 API 인터셉터와 서버 프록시가 읽는 값은 localStorage/cookie다.
Redux는 화면 표시와 React hook 사용성을 위한 계층이다.
const testSlice = createSlice({
name: "test",
initialState,
reducers: {
replaceMockRemotePaths: (state, action: PayloadAction<string[]>) => {
state.activeMockRemotePaths = action.payload;
state.updatedAt = now();
},
},
selectors: {
selectActiveMockRemotePaths: (state) => state.activeMockRemotePaths,
selectIsTestMode: (state) => state.activeMockRemotePaths.length > 0,
selectTestUpdatedAt: (state) => state.updatedAt,
},
});
useTestStore는 localStorage/cookie를 쓰는 함수와 Redux dispatch를 함께 묶는다.
const setMockRemotePaths = useCallback(
(paths: string | string[]) => {
const nextPaths = writeMockRemotePaths(parseMockRemotePathInput(paths));
dispatch(testActions.replaceMockRemotePaths(nextPaths));
return nextPaths;
},
[dispatch],
);
이 책임 분리가 중요하다.
API 계층은 React store에 의존하지 않는다. 서버 프록시는 Redux를 모른다. 화면만 Redux를 통해 현재 테스트 상태를 보기 좋게 구독한다.
PASS 본인인증 팝업도 같은 콘솔 API로 묶는다
이번 테스트 모드에는 API 응답 mock만 있는 것이 아니다. PASS 본인인증 팝업도 테스트하기 쉽게 만들었다.
본인인증은 일반 API 응답보다 테스트하기 까다롭다. 외부 팝업이 열리고, 인증 결과가 postMessage로 돌아오는 흐름이기 때문이다.
로컬/개발 환경에서는 다음처럼 켤 수 있다.
test.add("본인인증");
내부 구현은 window.open을 감싼다.
const enablePassAuthPopupMock = () => {
if (typeof window === "undefined" || passAuthMockOriginalOpen) {
return;
}
passAuthMockOriginalOpen = window.open.bind(window);
window.open = (url, target, features) => {
if (!isKgAuthRequestUrl(url)) {
return passAuthMockOriginalOpen?.(url, target, features) ?? null;
}
const popup = createPassAuthMockPopup();
window.setTimeout(() => {
if (!popup.closed) {
postPassAuthMockMessage(url);
}
}, 0);
return popup;
};
};
중요한 점은 모든 팝업을 가로채지 않는다는 것이다.
const isKgAuthRequestUrl = (url: string | URL | undefined) => {
try {
return (
new URL(String(url ?? ""), window.location.href).pathname ===
"/kgauth/request"
);
} catch {
return false;
}
};
/kgauth/request만 처리하고, 다른 window.open 호출은 원래 동작으로 넘긴다.
인증 성공 메시지는 실제 팝업이 보내는 것처럼 부모 window에 message 이벤트로 전달한다.
window.dispatchEvent(
new MessageEvent("message", {
data: {
code: "AU-000",
data: { ...passAuthMockSession },
message: "OK",
},
origin,
}),
);
이 방식으로 성공 결과를 받았을 때의 화면 동작을 시험한다. 합성 MessageEvent는 실제 팝업의 출처, source, 팝업 차단, 교차 출처 통신까지 재현하지 않는다. 실제 인증 연동은 별도로 확인해야 한다.
필요하면 세션 값도 바꿀 수 있다.
__examplePassAuthMock.setSession({
birth: "19950101",
ci: "TEST_CI_CUSTOM",
gender: "F",
name: "김테스트",
phone: "01098765432",
});
이 기능은 브라우저 수동 테스트뿐 아니라 Playwright 같은 E2E 테스트에서도 유용하다.
import path from "node:path";
import { test } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.addInitScript({
path: path.resolve("scripts/test/pass-auth-popup-stub.js"),
});
});
scripts/test/pass-auth-popup-stub.js는 앱 코드에 의존하지 않고 같은 방식으로 window.open과 postMessage 흐름을 만든다.
실제 테스트 시나리오
예를 들어 장바구니 생성 API를 목으로 처리하고 싶다고 해보자.
콘솔에서 다음을 실행한다.
test.add("/orders/cart/createCart");
이때 내부에서는 다음 일이 일어난다.
path가 /orders/cart/createCart로 정규화된다.
등록된 mock handler가 있는지 확인한다.
localStorage와 cookie에 활성 path를 저장한다.
example-test-mock-remote-paths-change 이벤트를 발행한다.
TestModeProvider가 상태를 갱신하고 워터마크를 표시한다.
이후 해당 API 요청이 발생하면 Axios adapter 또는 API proxy가 목 응답을 반환한다.
이제 화면에서는 평소처럼 장바구니 담기 버튼을 누르면 된다.
화면 컴포넌트는 아무것도 모른다. application hook도 아무것도 모른다. API 호출부도 특별한 테스트 분기를 갖지 않는다.
테스트가 끝나면 끈다.
test.remove("/orders/cart/createCart");
또는 전체를 지운다.
test.clear();
실제 응답 일부만 바꾸는 시나리오
메뉴 상세 API는 전체 응답을 목으로 만들기 부담스러울 수 있다. 필드가 많고, 실제 데이터와 함께 봐야 하는 UI가 있을 수 있기 때문이다.
이럴 때 patch mock을 켠다.
test.add("/menus/detail/getMenuDetail");
요청은 프록시로 이동하고, 프록시는 실제 upstream API를 호출한다. 그 다음 응답 payload에 patch handler를 적용한다.
defineMockRemotePatch(menusDetailGetMenuDetailPath, (response) => ({
...response,
data: response.data
? {
...response.data,
menuBasicInfo: response.data.menuBasicInfo
? {
...response.data.menuBasicInfo,
menuTpCd: "HNH",
}
: response.data.menuBasicInfo,
}
: response.data,
}));
이 방식의 장점은 “테스트하고 싶은 조건만” 바꿀 수 있다는 점이다.
실제 메뉴명, 가격, 옵션, 이미지, 프로모션 정보는 서버 응답을 그대로 쓰고, 메뉴 타입만 하프앤하프로 바꾼다. 따라서 UI는 훨씬 현실적인 데이터 위에서 edge case를 테스트할 수 있다.
로그는 테스트 흐름을 추적하는 최소한의 장치다
목 응답이 적용됐는지 확인하기 어렵다면 테스트 모드는 금방 불신받는다.
그래서 mock 또는 patch가 적용될 때 logger를 남긴다.
logger.info("Mock remote response", {
method: normalizedMethod,
mode: "mock",
path: normalizedPath,
url: url ?? normalizedPath,
});
patch도 마찬가지다.
logger.info("Mock remote patch", {
method: normalizedMethod,
mode: "patch",
path: normalizedPath,
url: url ?? normalizedPath,
});
브라우저 콘솔에서 다음을 확인할 수 있다.
Mock remote response { method: "POST", mode: "mock", path: "/orders/cart/createCart" }
Mock remote patch { method: "GET", mode: "patch", path: "/menus/detail/getMenuDetail" }
테스트할 때는 “내가 지금 진짜 서버를 보고 있는지, 목 응답을 보고 있는지”가 명확해야 한다. 워터마크가 화면의 신호라면, 로그는 요청 단위의 신호다.
왜 MSW 대신 이 구조를 택했나
MSW는 훌륭한 도구다. 브라우저와 Node 테스트 환경에서 네트워크를 가로채는 표준적인 선택지다.
하지만 이번 요구사항에서는 몇 가지 조건이 있었다.
첫째, 이미 앱 안에 API proxy와 Axios client가 있고 인증 재발급, auth redirect, global error popup 같은 공통 처리가 들어 있다.
둘째, SSR 요청과 클라이언트 요청이 같은 테스트 상태를 공유해야 했다.
셋째, QA나 개발자가 브라우저 콘솔에서 특정 endpoint만 빠르게 켜고 끄는 사용성이 필요했다.
넷째, 실제 API 응답을 받은 뒤 일부만 바꾸는 patch mode가 필요했다.
이 조건에서는 앱의 API 경계에 직접 붙는 방식이 더 자연스러웠다.
이번 선택은 MSW의 기능 부족을 근거로 한 결론이 아니다. 기존 Axios 클라이언트와 서버 프록시에 정책을 연결하는 것이 이 프로젝트의 구조에 맞았다는 판단이다. 직접 만든 계층의 유지보수 비용도 함께 부담한다.
둘 중 하나가 항상 더 좋은 것은 아니다. 중요한 것은 팀이 실제로 어떤 테스트를 자주 하는지다.
이번 경우에는 콘솔에서 켜고, 화면에서 바로 확인하고, SSR/CSR에서 동일하게 동작하고, 필요하면 실제 응답 일부만 patch하는 사용성이 더 중요했다.
적용 범위와 확인할 조건
이 글의 코드는 프로젝트 의존성이 있는 발췌 예시다. 설명용 도메인은 app.example.com으로 바꿨다. 화면과 도메인의 테스트 분기는 줄였지만 API 클라이언트·서버 프록시·Provider에는 테스트 기반 코드가 추가되었다.
같은 origin의 localStorage와 쿠키는 탭별로 독립적이지 않다. 테스트 상태를 바꾼 뒤 이미 받은 Query 캐시가 남아 있으면 재요청이나 무효화도 필요하다. SSR 캐시 역시 요청별 테스트 상태가 섞이지 않는지 확인해야 한다.
커스텀 Axios adapter가 4xx·5xx 응답을 resolve하면 기본 adapter의 오류 흐름과 달라질 수 있다. 위 수정 예시는 validateStatus에 따라 AxiosError를 던지도록 보완했다. 취소와 시간 초과·지연까지 기본 adapter와 같게 재현하는 코드는 아니므로, 해당 시나리오는 추가 구현과 검증이 필요하다. Axios 요청 설정
이 구조의 장점
가장 큰 장점은 실 코드 침범이 적다는 것이다.
화면 컴포넌트는 테스트 모드를 모른다. application hook도 테스트 모드를 모른다. domain layer도 테스트 모드를 모른다.
목 처리는 API 요청 경계에서 끝난다.
두 번째 장점은 테스트 시나리오를 켜고 끄기 쉽다는 것이다.
test.add("/orders/cart/createCart");
test.add("/menus/detail/getMenuDetail");
test.add("본인인증");
test.list();
test.clear();
복잡한 설정 파일을 수정하거나 개발 서버를 재시작할 필요가 없다.
세 번째 장점은 SSR과 CSR을 함께 다룬다는 것이다.
localStorage만 쓰면 서버 요청을 처리할 수 없다. cookie만 쓰면 클라이언트 상태 동기화가 불편하다. 둘을 함께 쓰면 서버와 브라우저가 같은 테스트 모드를 공유할 수 있다.
네 번째 장점은 전체 mock과 patch mock을 구분한다는 것이다.
완전히 가짜 응답이 필요한 경우에는 mock을 쓰고, 실제 응답 기반 edge case가 필요하면 patch를 쓴다.
다섯 번째 장점은 테스트 상태가 화면에 드러난다는 것이다.
워터마크는 작지만 중요하다. 테스트 모드를 켜둔 상태로 다른 화면을 보다가 혼동하는 일을 줄여준다.
조심해야 할 점
이 구조에도 주의할 점은 있다.
첫째, mock handler는 계속 관리되어야 한다. endpoint가 바뀌거나 응답 타입이 바뀌면 목 응답도 같이 갱신해야 한다.
이를 위해 path const와 MockRemoteRequest<TPath>, MockRemoteResponse<TPath>를 사용해 타입 연결을 걸었다.
둘째, patch mock은 실제 서버에 의존한다. 서버 데이터가 없거나 upstream API가 실패하면 patch도 의미가 없다.
그래서 patch mock은 “실제 응답 대부분은 유지하고 특정 조건만 바꾸고 싶을 때” 사용해야 한다. 서버 없이 독립적으로 테스트해야 하는 시나리오는 전체 mock이 낫다.
셋째, 콘솔 API는 사용성이 좋지만 발견 가능성이 낮다.
그래서 팀 문서나 QA 가이드에 다음 정도는 꼭 적어두는 것이 좋다.
## 테스트 모드
- 목 API 켜기: `test.add("/orders/cart/createCart")`
- 목 API 끄기: `test.remove("/orders/cart/createCart")`
- 전체 끄기: `test.clear()`
- 현재 목록 보기: `test.list()`
- PASS 본인인증 목 켜기: `test.add("본인인증")`
넷째, 운영 환경 가드는 반드시 유지해야 한다.
isTestModeAvailable() 같은 환경 가드는 테스트 편의 기능의 안전장치다. 이 조건이 느슨해지면 테스트 기능이 제품 표면으로 새어 나올 수 있다.
추가로 개선할 수 있는 부분
현재 구조에서 더 확장한다면 다음 방향을 생각해볼 수 있다.
첫째, 등록된 mock handler 목록을 콘솔에서 조회할 수 있게 만들 수 있다.
test.handlers();
현재는 handler가 없는 path를 입력하면 저장되지 않는다. 이 자체는 안전하지만, 사용자가 어떤 path를 켤 수 있는지 알기 어렵다.
둘째, 시나리오 이름 기반 alias를 둘 수 있다.
test.add("장바구니 생성 성공");
test.add("하프앤하프 메뉴");
path를 모르는 QA도 시나리오 단위로 테스트할 수 있다.
셋째, mock response에 delay를 줄 수 있다.
test.delay("/orders/cart/createCart", 1500);
로딩 UI, 중복 클릭 방지, timeout 근처 UX를 테스트할 때 유용하다.
넷째, error response mock을 더 쉽게 만들 수 있다.
defineMockRemoteError(path, {
status: 500,
code: "500",
message: "서버 오류가 발생했습니다.",
});
현재 구조에서도 handler가 임의 응답을 반환할 수 있지만, 에러 시나리오를 자주 만든다면 별도 helper가 있으면 좋다.
마무리
테스트 코드는 제품 코드를 도와야지, 제품 코드의 책임을 흐리면 안 된다.
이번 구현은 그 기준을 지키기 위해 테스트 기능을 API 경계에 모았다. 화면은 실제 API를 호출한다. application과 domain layer도 테스트 모드를 모른다. 목 응답과 patch는 Axios interceptor와 API proxy에서 결정된다.
콘솔 API는 테스트 진입을 가볍게 만들고, localStorage와 cookie의 이중 저장은 CSR과 SSR을 동시에 다룬다. TEST MODE 워터마크와 logger는 지금 보고 있는 화면과 요청이 테스트 상태인지 명확하게 알려준다. PASS 본인인증처럼 외부 팝업이 필요한 흐름도 같은 테스트 모드 안에 묶어, 실제 사용자 흐름을 크게 우회하지 않고 테스트할 수 있게 했다.
결국 좋은 테스트 도구는 눈에 띄지 않아야 할 곳에서는 조용하고, 필요할 때는 아주 쉽게 손에 잡혀야 한다.
이번 구조는 그 균형을 목표로 했다.
화면의 호출 코드를 유지하면서 API 경계에 테스트 기능을 모은다. 그 작은 경계 덕분에 테스트는 쉬워지고, 제품 코드는 깨끗하게 남는다.
Assisted by AI