이걸 쓰게 된 계기

유튜브를 보다가 fetch, axios 대신에 ky, got을 써봐라 라는 숏츠를 보고 궁금증이 생겼었다. 개발을 하면서 fetch로 해본건 맨 처음 프로젝트말고 없었고 그 이후에는 axios라는 라이브러리를 활용하여 개발했었다. 실제로 후배들을 가르칠 때에도 fetch는 가볍게 한 다음에 axios를 가르쳤었다.

이 영상을 보고 나서 axios말고 대체방안이 있다는 것을 알았다. 사실 알긴 했지만 굳이..?라는 느낌이 강했다. 하지만 npm trend라던지 실제 레퍼런스를 보면 압도적으로 axios가 많은 것을 볼 수 있다.

Untitled

그래서 이 참에 한 번 axios 기능을 fetch로 만들면 어떻게 될까 궁금증이 생겨 도전 해볼 겸 공부를 하게 되었다.

또한 최근 면접 질문 중에 axios interceptor을 말하다가 그러면 fetch로 구현은 어떤 식으로 할 수 있을까요? 이랬을 때 약간 얼타서 얘기를 한 것이 있기 때문에 실제 사용은 뒤로 하겠지만 그래도 원리를 아는 것이 중요한 것 같아 스터디를 시작하게 되었다.

근데..? 추후에 나오겠지만 이거를 공부하다가 너무 양이 방대해지는 느낌이 나서 2주치로 나눠야겠다는 생각을 가지게 되었다.

기본적인 부분은 가볍게만 다루고 넘어가도록 하겠다. 어느 정도는 다 알고 계시는 고수분들 인 것 같아서.. 그리고 전 백엔드를 못합니다.

fetch vs axios

fetch

Promise 기반으로 비동기 통신 지원 및 별도의 설치 없이 바로 사용 가능

fetch함수는 응답 상태에 대해 Promise를 Reject하지 않는다. 측 try-catch를 해도 error로 들어가지 않는다. 그래서 다음과 같이 처리를 해야 한다.

(async () => {
  try {
    const res = await fetch('<https://omdbapi.com/?apikey=&s=avengers>') // Error: 401 Unauthorized!
    const data = await res.json() // { Response: 'False', Error: 'No API key provided.' }
    if (data.Response === 'False') {
      return console.error(data.Error) // 'No API key provided.'
    }
    console.log(data)
  } catch (err) {
    console.error('Error:', err)
  }
})()

next의 경우 fetch를 라이브러리단에서 확장해서 next prop이 사용가능하다. → 캐싱과 관련해서는 추후에 공부할 예정, next.js의 app-router가 안정이 된다면 fetch를 사용해야 된다 → axios의 경우에는 직접 캐싱 관련 설정을 해주어야 된다고 한다.

const response = await fetch('/products', {
	method: 'GET',
	headers: {},
	next: { tag: ['product', 'list'], revalidate: 300 }, // 캐싱키와 5분 동안 revalidate를 막아두는 속성
	// 캐시된 데이터를 다시 검증하는 주기로서 5분마다 캐시된 데이터의 유효성을 확인하는 것
});

// 기본적으로 아무 설정도 안하면 SSG로 동작
// 이 요청은 수동으로 무효화될 때까지 캐시
// getStaticProps와 유사
// force-cache는 기본값이며 생략가능
fetch(URL, { cache: 'force-cache' });

// 이 요청은 매 요청마다 다시 가져오라는 것
// getServerSideProps와 유사
fetch(URL, { cache: 'no-store' });

// 이 요청은 10초의 수명으로 캐시
// getStaticProps의 revalidate 옵션과 유사
fetch(URL, { next: { revalidate: 10 } });

axios

Axios는 Node.js와 브라우저 환경 모두에서 사용할 수 있도록 설계되어 있어, fetch와 뒤에 소개할 ky에 비해 더 무겁다. 실제 폴더를 뜯어보면 다 들어가 있다. Node.js에서의 사용방식은 기존 프론트엔드에서 작성하는 방식과 비슷하다.

// Node.js에서 사용하는 방법 
// 외부 API와 통신 
const express = require('express');
const axios = require('axios');
const app = express();
// 핸들러 정의하기
app.get('/weather/:city', async (req, res) => {
  try {
    const city = req.params.city;
    const apiKey = process.env.WEATHER_API_KEY;
    // Axios를 사용하여 외부 날씨 API에 GET요청 보내기 
    const weatherResponse = await axios.get(`https://api.weatherservice.com/v1/current?city=${city}&key=${apiKey}`);
    // JSON 형식으로 보내기
    res.json(weatherResponse.data);
  } catch (error) {
    res.status(500).json({ error: 'Error fetching weather data' });
  }
});

app.listen(3000, () => console.log('Server running on port 3000'));

<aside> 💡 어떻게 설계되어있을까?

platform 모듈: platform 모듈은 다양한 환경(브라우저, Node.js 등)에 대한 클래스를 제공한다. 이 모듈을 통해 FormDataBlob과 같은 객체를 환경에 맞게 가져오는 형식으로 보인다.

// 1. 환경 감지
'use strict';

import utils from '../utils.js';
import AxiosError from '../core/AxiosError.js';
import transitionalDefaults from './transitional.js';
import toFormData from '../helpers/toFormData.js';
import toURLEncodedForm from '../helpers/toURLEncodedForm.js';
import platform from '../platform/index.js'; // 플랫폼 관련 유틸리티를 가져옴
import formDataToJSON from '../helpers/formDataToJSON.js';

/**
 * 주어진 값을 안전하게 문자열로 변환하는 함수
 * 
 *
 * @param {any} rawValue - 문자열화할 값.
 * @param {Function} parser - 문자열을 JavaScript 객체로 파싱하는 함수.
 * @param {Function} encoder - 값을 문자열로 변환하는 함수.
 *
 * @returns {string} rawValue의 문자열화 버전.
 */
function stringifySafely(rawValue, parser, encoder) {
  if (utils.isString(rawValue)) {
    try {
      (parser || JSON.parse)(rawValue);
      return utils.trim(rawValue);
    } catch (e) {
      if (e.name !== 'SyntaxError') {
        throw e;
      }
    }
  }

  return (encoder || JSON.stringify)(rawValue);
}

const defaults = {

  transitional: transitionalDefaults,

  adapter: ['xhr', 'http', 'fetch'], // 다양한 요청 방식을 지원하기 위한 어댑터 설정

  transformRequest: [function transformRequest(data, headers) {
    const contentType = headers.getContentType() || '';
    const hasJSONContentType = contentType.indexOf('application/json') > -1;
    const isObjectPayload = utils.isObject(data);

    if (isObjectPayload && utils.isHTMLForm(data)) {
      data = new FormData(data); // HTML Form 데이터 처리
    }

    const isFormData = utils.isFormData(data);

    if (isFormData) {
      return hasJSONContentType ? JSON.stringify(formDataToJSON(data)) : data;
    }

    if (utils.isArrayBuffer(data) ||
      utils.isBuffer(data) ||
      utils.isStream(data) ||
      utils.isFile(data) ||
      utils.isBlob(data) ||
      utils.isReadableStream(data)
    ) {
      return data; // 다양한 데이터 유형을 처리
    }
    if (utils.isArrayBufferView(data)) {
      return data.buffer;
    }
    if (utils.isURLSearchParams(data)) {
      headers.setContentType('application/x-www-form-urlencoded;charset=utf-8', false);
      return data.toString();
    }

    let isFileList;

    if (isObjectPayload) {
      if (contentType.indexOf('application/x-www-form-urlencoded') > -1) {
        return toURLEncodedForm(data, this.formSerializer).toString(); // URL 인코딩된 폼 데이터 처리
      }

      if ((isFileList = utils.isFileList(data)) || contentType.indexOf('multipart/form-data') > -1) {
        const _FormData = this.env && this.env.FormData; // 환경에서 FormData 클래스를 가져옴

        return toFormData(
          isFileList ? {'files[]': data} : data,
          _FormData && new _FormData(), // 환경에 따른 FormData 인스턴스 생성
          this.formSerializer
        );
      }
    }

    if (isObjectPayload || hasJSONContentType ) {
      headers.setContentType('application/json', false);
      return stringifySafely(data);
    }

    return data;
  }],

  transformResponse: [function transformResponse(data) {
    const transitional = this.transitional || defaults.transitional;
    const forcedJSONParsing = transitional && transitional.forcedJSONParsing;
    const JSONRequested = this.responseType === 'json';

    if (utils.isResponse(data) || utils.isReadableStream(data)) {
      return data;
    }

    if (data && utils.isString(data) && ((forcedJSONParsing && !this.responseType) || JSONRequested)) {
      const silentJSONParsing = transitional && transitional.silentJSONParsing;
      const strictJSONParsing = !silentJSONParsing && JSONRequested;

      try {
        return JSON.parse(data);
      } catch (e) {
        if (strictJSONParsing) {
          if (e.name === 'SyntaxError') {
            throw AxiosError.from(e, AxiosError.ERR_BAD_RESPONSE, this, null, this.response);
          }
          throw e;
        }
      }
    }

    return data;
  }],

  /**
   * 요청을 중단하기 위한 밀리초 단위의 타임아웃. 0으로 설정하면 (기본값) 타임아웃이 생성되지 않음.
   */
  timeout: 0,

  xsrfCookieName: 'XSRF-TOKEN',
  xsrfHeaderName: 'X-XSRF-TOKEN',

  maxContentLength: -1,
  maxBodyLength: -1,

  env: {
    FormData: platform.classes.FormData, // 플랫폼에서 FormData 클래스를 감지하여 설정
    Blob: platform.classes.Blob // 플랫폼에서 Blob 클래스를 감지하여 설정
  },

  validateStatus: function validateStatus(status) {
    return status >= 200 && status < 300; // 유효한 HTTP 상태 코드 검증
  },

  headers: {
    common: {
      'Accept': 'application/json, text/plain, */*',
      'Content-Type': undefined
    }
  }
};

// HTTP 메소드별로 기본 헤더 설정
utils.forEach(['delete', 'get', 'head', 'post', 'put', 'patch'], (method) => {
  defaults.headers[method] = {};
});

export default defaults;

// platform에서 환경을 감지
// 이건 브라우저
export default {
  isBrowser: true,
  classes: {
    URLSearchParams,
    FormData,
    Blob
  },
  protocols: ['http', 'https', 'file', 'blob', 'url', 'data']
};
// 이게 노드
export default {
  isNode: true,
  classes: {
    URLSearchParams,
    FormData,
    Blob: typeof Blob !== 'undefined' && Blob || null
  },
  protocols: [ 'http', 'https', 'file', 'data' ]
};

// 감지하는 곳 platform/common/util.js
// 브라우저 환경인지 확인하는 변수
const hasBrowserEnv = typeof window !== 'undefined' && typeof document !== 'undefined';

/**
 * 표준 브라우저 환경에서 실행 중인지 확인하는 함수
 *
 * 이는 Axios가 웹 워커와 리액트 네이티브에서 실행될 수 있게 해줍니다.
 * 두 환경 모두 XMLHttpRequest를 지원하지만, 완전히 표준 글로벌 변수를 지원하지는 않습니다.
 *
 * 웹 워커:
 *  typeof window -> undefined
 *  typeof document -> undefined
 *
 * 리액트 네이티브:
 *  navigator.product -> 'ReactNative'
 * nativescript:
 *  navigator.product -> 'NativeScript' 또는 'NS'
 *
 * @returns {boolean} 표준 브라우저 환경 여부
 */
const hasStandardBrowserEnv = (
  (product) => {
    // 브라우저 환경이면서 navigator.product가 'ReactNative', 'NativeScript', 'NS'가 아닐 경우
    return hasBrowserEnv && ['ReactNative', 'NativeScript', 'NS'].indexOf(product) < 0
  })(typeof navigator !== 'undefined' && navigator.product); // navigator.product가 정의된 경우에 해당 값을 가져옴

/**
 * 표준 브라우저 웹 워커 환경에서 실행 중인지 확인하는 함수
 *
 * 비록 `hasStandardBrowserEnv` 메서드가 웹 워커에서 Axios가 실행될 수 있음을 나타내지만,
 * 웹 워커는 여전히 `typeof window !== 'undefined' && typeof document !== 'undefined'`의 판단으로
 * 필터링됩니다. 이는 웹 워커에서 FormData를 Axios가 포스트할 때 문제를 일으킬 수 있습니다.
 */
const hasStandardBrowserWebWorkerEnv = (() => {
  // WorkerGlobalScope가 정의되어 있고, self가 WorkerGlobalScope의 인스턴스이며,
  // self.importScripts가 함수인지 확인
  return (
    typeof WorkerGlobalScope !== 'undefined' &&
    // eslint-disable-next-line no-undef
    self instanceof WorkerGlobalScope &&
    typeof self.importScripts === 'function'
  );
})();

// 현재 페이지의 URL을 가져오거나, 브라우저 환경이 아닐 경우 로컬호스트를 기본값으로 설정
const origin = hasBrowserEnv && window.location.href || '<http://localhost>';

// 환경 감지 결과를 export
export {
  hasBrowserEnv,
  hasStandardBrowserWebWorkerEnv,
  hasStandardBrowserEnv,
  origin
}
// 2. 환경별 어댑터
// 브라우저용(xhr.js)
import utils from './../utils.js';
import settle from './../core/settle.js';
import transitionalDefaults from '../defaults/transitional.js';
import AxiosError from '../core/AxiosError.js';
import CanceledError from '../cancel/CanceledError.js';
import parseProtocol from '../helpers/parseProtocol.js';
import platform from '../platform/index.js';
import AxiosHeaders from '../core/AxiosHeaders.js';
import progressEventReducer from '../helpers/progressEventReducer.js';
import resolveConfig from "../helpers/resolveConfig.js";

// XMLHttpRequest가 정의되어 있는지 확인하여 브라우저 환경에서 실행 가능한지 판단
const isXHRAdapterSupported = typeof XMLHttpRequest !== 'undefined';

// XMLHttpRequest를 사용하여 요청을 보내는 함수
export default isXHRAdapterSupported && function (config) {
  return new Promise(function dispatchXhrRequest(resolve, reject) {
    const _config = resolveConfig(config); // 설정을 정리
    let requestData = _config.data; // 요청 데이터
    const requestHeaders = AxiosHeaders.from(_config.headers).normalize(); // 요청 헤더 정규화
    let {responseType} = _config; // 응답 타입
    let onCanceled;

    function done() {
      // 요청 취소 토큰이 존재할 경우 구독 취소
      if (_config.cancelToken) {
        _config.cancelToken.unsubscribe(onCanceled);
      }

      // AbortController의 signal이 존재할 경우 이벤트 리스너 제거
      if (_config.signal) {
        _config.signal.removeEventListener('abort', onCanceled);
      }
    }

    // XMLHttpRequest 객체 생성
    let request = new XMLHttpRequest();

    // 요청 초기화: 메서드, URL, 비동기 여부 설정
    request.open(_config.method.toUpperCase(), _config.url, true);

    // 요청 타임아웃 설정 (밀리초)
    request.timeout = _config.timeout;

    function onloadend() {
      if (!request) {
        return;
      }

      // 응답 준비
      const responseHeaders = AxiosHeaders.from(
        'getAllResponseHeaders' in request && request.getAllResponseHeaders()
      );
      const responseData = !responseType || responseType === 'text' || responseType === 'json' ?
        request.responseText : request.response; // 응답 데이터 처리
      const response = {
        data: responseData,
        status: request.status,
        statusText: request.statusText,
        headers: responseHeaders,
        config,
        request
      };

      // 응답 처리 완료
      settle(function _resolve(value) {
        resolve(value);
        done();
      }, function _reject(err) {
        reject(err);
        done();
      }, response);

      // 요청 정리
      request = null;
    }

    if ('onloadend' in request) {
      // onloadend 이벤트 사용 가능 시 사용
      request.onloadend = onloadend;
    } else {
      // onloadend가 없을 경우 readyState 변경 이벤트 사용
      request.onreadystatechange = function handleLoad() {
        if (!request || request.readyState !== 4) {
          return;
        }

        // 상태가 0인 경우 처리 (파일 프로토콜의 경우)
        if (request.status === 0 && !(request.responseURL && request.responseURL.indexOf('file:') === 0)) {
          return;
        }
        // onloadend를 다음 '틱'에서 호출
        setTimeout(onloadend);
      };
    }

    // 요청이 중단된 경우 처리
    request.onabort = function handleAbort() {
      if (!request) {
        return;
      }

      reject(new AxiosError('Request aborted', AxiosError.ECONNABORTED, _config, request));

      // 요청 정리
      request = null;
    };

    // 네트워크 오류 처리
    request.onerror = function handleError() {
      reject(new AxiosError('Network Error', AxiosError.ERR_NETWORK, _config, request));

      // 요청 정리
      request = null;
    };

    // 타임아웃 처리
    request.ontimeout = function handleTimeout() {
      let timeoutErrorMessage = _config.timeout ? 'timeout of ' + _config.timeout + 'ms exceeded' : 'timeout exceeded';
      const transitional = _config.transitional || transitionalDefaults;
      if (_config.timeoutErrorMessage) {
        timeoutErrorMessage = _config.timeoutErrorMessage;
      }
      reject(new AxiosError(
        timeoutErrorMessage,
        transitional.clarifyTimeoutError ? AxiosError.ETIMEDOUT : AxiosError.ECONNABORTED,
        _config,
        request));

      // 요청 정리
      request = null;
    };

    // 데이터가 정의되지 않은 경우 Content-Type 제거
    requestData === undefined && requestHeaders.setContentType(null);

    // 요청 헤더 추가
    if ('setRequestHeader' in request) {
      utils.forEach(requestHeaders.toJSON(), function setRequestHeader(val, key) {
        request.setRequestHeader(key, val);
      });
    }

    // 필요 시 withCredentials 설정
    if (!utils.isUndefined(_config.withCredentials)) {
      request.withCredentials = !!_config.withCredentials;
    }

    // 필요 시 응답 타입 설정
    if (responseType && responseType !== 'json') {
      request.responseType = _config.responseType;
    }

    // 다운로드 진행 상황 처리
    if (typeof _config.onDownloadProgress === 'function') {
      request.addEventListener('progress', progressEventReducer(_config.onDownloadProgress, true));
    }

    // 업로드 진행 상황 처리
    if (typeof _config.onUploadProgress === 'function' && request.upload) {
      request.upload.addEventListener('progress', progressEventReducer(_config.onUploadProgress));
    }

    // 요청 취소 처리
    if (_config.cancelToken || _config.signal) {
      onCanceled = cancel => {
        if (!request) {
          return;
        }
        reject(!cancel || cancel.type ? new CanceledError(null, config, request) : cancel);
        request.abort();
        request = null;
      };

      _config.cancelToken && _config.cancelToken.subscribe(onCanceled);
      if (_config.signal) {
        _config.signal.aborted ? onCanceled() : _config.signal.addEventListener('abort', onCanceled);
      }
    }

    const protocol = parseProtocol(_config.url);

    // 지원하지 않는 프로토콜 처리
    if (protocol && platform.protocols.indexOf(protocol) === -1) {
      reject(new AxiosError('Unsupported protocol ' + protocol + ':', AxiosError.ERR_BAD_REQUEST, config));
      return;
    }

    // 요청 전송
    request.send(requestData || null);
  });
}

// Node.js용(http.js)
export default isHttpAdapterSupported && function httpAdapter(config) {
  // 비동기 요청을 처리하기 위해 wrapAsync 함수를 사용
  return wrapAsync(async function dispatchHttpRequest(resolve, reject, onDone) {
    // 요청에 필요한 데이터와 구성 요소 추출
    let {data, lookup, family} = config;
    const {responseType, responseEncoding} = config;
    const method = config.method.toUpperCase(); // HTTP 메서드
    let isDone;
    let rejected = false;
    let req;

    // 사용자 정의 DNS 조회 함수가 제공된 경우 처리
    if (lookup) {
      // callbackify를 사용하여 lookup 함수를 콜백 기반으로 변환
      const _lookup = callbackify(lookup, (value) => utils.isArray(value) ? value : [value]);
      // Node.js 20.x에서 필요한 opt.all 옵션을 지원하기 위한 핫픽스
      lookup = (hostname, opt, cb) => {
        _lookup(hostname, opt, (err, arg0, arg1) => {
          if (err) {
            return cb(err); // 오류 발생 시 콜백 호출
          }

          // 주소를 변환하여 콜백 호출
          const addresses = utils.isArray(arg0) ? arg0.map(addr => buildAddressEntry(addr)) : [buildAddressEntry(arg0, arg1)];

          // opt.all이 true인 경우 모든 주소 반환
          opt.all ? cb(err, addresses) : cb(err, addresses[0].address, addresses[0].family);
        });
      }
    }

    // AxiosRequest 클래스가 구현될 때까지 임시로 사용할 이벤트 발행기
    const emitter = new EventEmitter();

    // 요청이 완료되었을 때 호출되는 함수
    const onFinished = () => {
      if (config.cancelToken) {
        config.cancelToken.unsubscribe(abort); // 취소 토큰 구독 취소
      }

      if (config.signal) {
        config.signal.removeEventListener('abort', abort); // AbortController의 이벤트 리스너 제거
      }

      emitter.removeAllListeners(); // 모든 이벤트 리스너 제거
    }

    // 요청 완료 처리
    onDone((value, isRejected) => {
      isDone = true;
      if (isRejected) {
        rejected = true;
        onFinished(); // 요청이 거부된 경우 처리
      }
    });

    // 요청이 취소된 경우 처리
    function abort(reason) {
      emitter.emit('abort', !reason || reason.type ? new CanceledError(null, config, req) : reason);
    }

    emitter.once('abort', reject); // 첫 번째 abort 이벤트 발생 시 reject 호출

    // 요청 취소 처리
    if (config.cancelToken || config.signal) {
      config.cancelToken && config.cancelToken.subscribe(abort); // 취소 토큰 구독
      if (config.signal) {
        // AbortController가 사용 중인 경우 처리
        config.signal.aborted ? abort() : config.signal.addEventListener('abort', abort);
      }
    }

    // ... (여기까지만 다루겠습니다. 너무 빡셉니다 이거 600줄 넘어요)
  });
}

</aside>