CustomMockService npm 패키지 만들기 - 2

정성엽·2025년 7월 27일
0

INTRO

이전 포스팅에 이어서 아이디에이션 과정과 개발 과정을 포스팅해보려고 한다


1. XMLHttpRequest 인터셉트 구현

이전 포스팅에서 설명한 것과 같이 XMLHttpRequest 방식과 Fetch API 방식을 모두 구현해서 최대한 많은 환경을 커버하는 것을 목표로 하고 있다.

(XMLHttpRequest는 다른 블로거분의 포스팅을 참고하면 어떤 용도로 사용하는지 쉽게 이해할 수 있을 것 같다)

💡 어떻게 사용하도록 할까?

일단 필자가 생각한 Usage는 다음과 같다.

CustomMockService.get("/api/v1", mockData);
CustomMockService.get("/api/v1", params, mockData);
CustomMockService.post("/api/v1", requestBody, mockData);

위의 방식처럼 endpoint, reuqest, 그리고 반환할 mockData 를 전달받도록 하는게 좋을 것 같다.

다만, 위처럼 받을 경우에는 사용법에 대한 숙지가 덜되어있다면 문제가 발생할수도 있을 것 같았다.

그래서 다음과 같이 객체 형식으로 받는게 더 좋을 것 같다고 판단했다.

CustomMockService.get({endpoint : "api/v1", response : mockData});
CustomMockService.get({endpoint : "api/v1", params, response : mockData});
CustomMockService.post({endpoint : "api/v1", request : postRequest, response : mockData});

💡 구현 아이디어

구현 아이디어는 다음과 같다.

위처럼 사용하기 위해서는 CustomMockService라는 클래스를 생성하고, 그 내부에서 static으로 객체를 관리하려고 한다.

또한, get, post, patch, delete 는 모두 static으로 외부에 노출시키는게 지금 아이디어로는 적절하다고 생각한다.

따라서 다음과 같이 구현하려고 한다.

export class CustomMockService {
  private static getMockMapping = new Map<string, any>();
  private static postMockMapping = new Map<string, any>();
  private static patchMockMapping = new Map<string, any>();
  private static deleteMockMapping = new Map<string, any>();
  ...
  
  static post({
    endPoint,
    request = null,
    response,
  }: {
    endPoint: string;
    request?: any;
    response: any;
  }) {
    this.postMockMapping.set(`${endPoint}`, { request, response });
  }
}

이런식으로 내부에서 Map으로 각 모킹 데이터를 관리하고 키로는 endpoint를 사용하면 될 것 같다.


2. XHR 응답 추출하기

XMLHttpRequest를 활용한 API 호출을 Mock으로 대체하기 위해서는 XHR의 라이프사이클을 이해하고 적절한 시점에서 요청 정보를 추출해야 한다.

XHR 요청은 크게 두 단계로 나뉜다.

1. xhr.open(): 요청 준비 단계
- HTTP 메서드와 URL이 설정되는 단계
- 아직 실제 네트워크 요청은 발생하지 않음
2. xhr.send(): 요청 실행 단계
- 실제 네트워크 요청이 시작
- Request Body가 전송된다.

즉, httpMethod와 url, 그리고 params는 xhr.open 에서 추출해야하고 requestbody는 xhr.send 에서 추출해야 한다.

그래서 코드를 작성해보면 다음과 같다.

// XHR 데이터에서 추출
static patchXHR() {
  const OriginalXHR = window.XMLHttpRequest;
  (window.XMLHttpRequest as any) = function () {
    const xhr = new OriginalXHR();

    let httpMethod: string;
    let requestUrl: string;
    let urlParams: URLSearchParams;

    const originalOpen = xhr.open;
    xhr.open = function (
    m: string,
     u: string,
     async?: boolean,
     user?: string,
     password?: string
    ) {
      httpMethod = m;
      requestUrl = u;
      urlParams = new URLSearchParams(new URL(u).search);

      return originalOpen.call(this, m, u, async || true, user, password);
    };

    const originalSend = xhr.send;
    xhr.send = function (body?: any) {
      const requestBody = body; // 나중에 생각
      const mockData = CustomMockServer.findMockData(httpMethod, requestUrl);

      if (mockData) {
        console.log("Mock 데이터 존재 O -> 가짜 응답 반환", mockData);
        CustomMockServer.returnMockResponse(xhr, mockData);
      }

      console.log("Mock 데이터 X -> 실제 요청 진행");
      return originalSend.call(this, body);
    };

    return xhr;
  };
}
// 관련 함수
private static findMockData(httpMethod: string, requestUrl: string) {
  let mapping;

  switch (httpMethod) {
    case "GET":
      mapping = CustomMockServer.getMockMapping.get(requestUrl);
      break;
    case "POST":
      mapping = CustomMockServer.postMockMapping.get(requestUrl);
      break;
    case "PATCH":
      mapping = CustomMockServer.patchMockMapping.get(requestUrl);
      break;
    case "DELETE":
      mapping = CustomMockServer.deleteMockMapping.get(requestUrl);
      break;
  }
  return mapping;
}

private static returnMockResponse = (xhr: XMLHttpRequest, mockData: any) => {
  Object.defineProperty(xhr, "readyState", { value: 4 });
  Object.defineProperty(xhr, "status", { value: 200 });
  Object.defineProperty(xhr, "responseText", {
    value: JSON.stringify(mockData.response),
  });

  if (xhr.onreadystatechange) {
    xhr.onreadystatechange.call(xhr, new Event("readystatechange"));
  }
  if (xhr.onload) {
    xhr.onload.call(xhr, new ProgressEvent("load"));
  }
};

여기서 한가지 중요한 부분은 목데이터 응답을 생성하는 부분이다.
xhr 객체는 기본적으로 ReadOnly이므로 defineProperty 속성을 통해 강제로 값을 변경해야한다.

물론 위에서 아직 완전한 완성본은 아니기 때문에, status 등의 값은 임의로 200으로 생성하도록 만들어놨다.

이처럼 defineProperty를 통해 목데이터 응답을 실제 서버 응답처럼 변경했다면 이벤트를 발생시킴으로써 다른 라이브러리가 이 변화를 인식할 수 있도록 해야한다.

xhr.onreadystatechangexhr.onload 부분이 이에 해당한다.

(공식문서를 참고해보면 자세히 설명되어있다)


3. 테스트

이제 XHR 부분을 한번 테스트해보자

아쉽게도 일렉트론 환경에서 테스트를 진행해봤는데, 일렉트론에서 IPC 통신 방법을 사용하는 경우에는 API 모킹을 못할 것 같다..

(그 이유는 나중에 다루도록 하겠다)

그래서 일단 이전에 진행했던 웹 어플리케이션 환경에서 테스트를 진행해보자

async function startApp() {
  // Mock 데이터 등록 먼저 진행하고
  dataMocking();

  // 그 다음 MockServer 실행
  CustomMockServer.run();

  createRoot(document.getElementById("root")!).render(
    <StrictMode>
      <App />
    </StrictMode>,
  );
}

void startApp();
import { CustomMockServer } from "@sung-yeop/custom-mock-service";

export const dataMocking = () => {
  // POST /auth/login 모킹 (전체 URL)
  CustomMockServer.post({
    endPoint: "https://dev-api.ceo.popi.today/auth/login",
    response: {
      success: true,
      status: 200,
      data: {
        accessToken:
          "eyJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJwb3BpX21hbmFnZXJfZGIiLCJzdWIiOiIxIiwicm9sZSI6IlVTRVIiLCJpYXQiOjE3NDYzODE4ODYsImV4cCI6MTc0NjM4NTQ4Nn0.ZIcwk29mLjMhMDHz6s_xzWTqN_Z8MXN8iMzu0IliePVlq7zaL_P5iNDwFWA8G7CCiRimFjZBnHgJJUF4IS6qVg",
      },
      timestamp: "2025-05-05T03:04:46.939793",
    },
  });

  console.log("Mock 데이터 등록 완료");
};

일단 사용해보니 endPoint 앞쪽에 prefix를 제거하는 로직을 추가하지 않아서 전체 엔드포인트로 맞춰놓고 모킹을 시도해봤다.

결과는 사진과 같이 제대로 API 요청을 인터셉트하여 네트워크 탭에는 요청이 생기지 않는 모습을 볼 수 있었다.

하지만, 라우팅이 제대로 되지 않는 모습을 볼 수 있었다.

const handleLogin = () => {
  login({ username, password })
    .then(() => navigate("/popup-list"))
    .catch(() => setIsOpenModal(true));
};

관련 로직을 살펴보면 다음과 같이 login이라는 요청이 성공하면 페이지 라우팅을 진행해야한다.

그래서 패키지 내부에서 로깅을 추가해보니 다음과 같이 콘솔 로그가 찍히는 모습을 볼 수 있었다.

즉, API 모킹은 정상적으로 되었으나 .then 이 작동하지 않는 문제였다.

const login = async ({ username, password }: LoginRequest) => {
  try {
    const response = await postLoginMutation.mutateAsync({
      username,
      password,
    });
    console.log("응답 : ", response);
    setLogin(response.data.accessToken);
  } catch (error) {
    throw new Error(`로그인 오류 ${ErrorMessage(error)}`);
  }
};

위 코드와 같이 내부 login 로직에서 응답을 확인하는 로깅을 찍어봤으나 이또한 로깅이 되지 않았다.

따라서, 비동기 문제라고 판단이 되었고 이를 해결할 방법을 찾아야했다.


4. 문제 해결

우리는 이벤트 드리븐 방식으로 목 데이터를 반환하도록 코드를 구성했다.

이전에 소개했던 목데이터 반환 방법을 보면 다음과 같다.

// 기존에 작성했던 코드
private static returnMockResponse = (xhr: XMLHttpRequest, mockData: any) => {
  Object.defineProperty(xhr, "readyState", { value: 4 });
  Object.defineProperty(xhr, "status", { value: 200 });
  Object.defineProperty(xhr, "responseText", {
    value: JSON.stringify(mockData.response),
  });

  if (xhr.onreadystatechange) {
    xhr.onreadystatechange.call(xhr, new Event("readystatechange"));
  }
  if (xhr.onload) {
    xhr.onload.call(xhr, new ProgressEvent("load"));
  }
};

XHR 동작 순서
1. readystatechange (readyState: 1) // OPENED
2. readystatechange (readyState: 2) // HEADERS_RECEIVED
3. readystatechange (readyState: 3) // LOADING
4. readystatechange (readyState: 4) // DONE
5. load // 성공 완료
6. loadend // 최종 완료 (성공/실패 무관)

우선 위 코드에서 Response를 반환하기 위해 defineProperty 를 통해 readyState를 4로 변경하여 DONE 상태로 변경한다.

이후, XHR 객체의 onreadystatechange, onload가 존재할 경우 각 메서드에 매칭되는 이벤트를 발행시키는 방법을 사용했다.

하지만, 위의 코드로는 동작하지 않아서 여러 방면으로 찾아보다가 다음과 같은 라이브러리를 확인할 수 있었다.

function onloadend() {
  if (!request) {
    return;
  }
  // Prepare the response
  var responseHeaders = 'getAllResponseHeaders' in request ? parseHeaders(request.getAllResponseHeaders()) : null;
  var responseData = !responseType || responseType === 'text' ||  responseType === 'json' ?
      request.responseText : request.response;
  var response = {
    data: responseData,
    status: request.status,
    statusText: request.statusText,
    headers: responseHeaders,
    config: config,
    request: request
  };

  settle(function _resolve(value) {
    resolve(value);
    done();
  }, function _reject(err) {
    reject(err);
    done();
  }, response);

  // Clean up request
  request = null;
}

위 코드는 Axios Adapter 구현 코드 중 일부인데, 모든 코드를 한번에 보고 이해할 수는 없었으나,onloadend 라는 이벤트가 발생하는 경우 settle 이라는 함수를 통해서 resolve 혹은 reject를 시키는 모습을 볼 수 있다.

그래서 코드를 다음과 같이 onloadend 이벤트를 발행시키도록 수정했다.

 private static returnMockResponse = (xhr: XMLHttpRequest, mockData: any) => {
    setTimeout(() => {
      Object.defineProperty(xhr, "readyState", {
        value: 4,
        configurable: true,
      });
      Object.defineProperty(xhr, "status", { value: 200, configurable: true });
      Object.defineProperty(xhr, "statusText", {
        value: "OK",
        configurable: true,
      });
      Object.defineProperty(xhr, "responseText", {
        value: JSON.stringify(mockData.response),
        configurable: true,
      });
      Object.defineProperty(xhr, "response", {
        value: JSON.stringify(mockData.response),
        configurable: true,
      });

      const readystateEvent = new Event("readystatechange");
      if (xhr.onreadystatechange)
        xhr.onreadystatechange.call(xhr, readystateEvent);

      const loadEvent = new ProgressEvent("load");
      if (xhr.onload) xhr.onload.call(xhr, loadEvent);

      const loadendEvent = new ProgressEvent("loadend"); // 추가된 부분
      if (xhr.onloadend) xhr.onloadend.call(xhr, loadendEvent);

      console.log("Mock 응답 완료");
    }, 50);
  };

그 결과 useAuth.ts 에서 response 결과가 잘 나오는 모습을 확인할 수 있었다.

즉, Axios는 Promise 객체를 사용하여 resolve 혹은 reject를 판단하는 과정에서 사용되는 이벤트가 XHR의 loadend 이벤트라는 의미이다.

여기서 개발하면서 느낀 것 중하나가 setTimeout을 이용한 비동기 설정을 추가해야만 실제 서버에서 응답을 받아오는 순서처럼 동작한다는 것이다.

실제 응답 순서를 생각해보면 다음과 같다.

xhr.open() (요청 설정)
-> xhr.send() (서버로 요청 전송 / 함수 즉시 반환)
-> 서버에서 처리 중 (시간 소요 / setTimeout으로 구현)
-> 서버가 응답을 보냄
-> xhr.onloadend 등의 이벤트 핸들러 실행
-> 응답 데이터를 처리하여 클라이언트에 반환

즉, setTimeout을 이용하여 실제 서버 동작과 유사하게 개발하는게 중요하다는 것을 잊지말자


OUTRO

지금 생각해보면 목서비스 패키지를 만드는게 꽤 번거로운 작업들이 많은 것 같다.

부수적인 역할이지만 로컬 스토리지에 데이터를 저장하는 기능을 추가할 수도 있을 것 같고,
endpoint도 쉽게 정의하여 사용할 수 있도록 수정해야하고, status, application 타입, 파일 통신 등의 기능을 제공하기 위해서는 가야할길이 꽤 먼 것 같다.

또한, 실제로 테스팅을 해보니 API 모킹 구현체들을 run이 실행된 이후에 등록되도록 호출해야한다.

관련 컴포넌트를 제공할지 아니면 run에다가 handler들을 모아둔 이후 실행시킬지 고민중이다.

일단 코드를 한쪽에 몰아써서 구현하느라 이해하기 어려운 것 같아서 리팩토링 작업부터 진행하고 XHR을 이용한 모킹부분부터 마무리하도록 해야겠다

profile
코린이

0개의 댓글