유튜브를 보다가 fetch, axios 대신에 ky, got을 써봐라 라는 숏츠를 보고 궁금증이 생겼었다. 개발을 하면서 fetch로 해본건 맨 처음 프로젝트말고 없었고 그 이후에는 axios라는 라이브러리를 활용하여 개발했었다. 실제로 후배들을 가르칠 때에도 fetch는 가볍게 한 다음에 axios를 가르쳤었다.
이 영상을 보고 나서 axios말고 대체방안이 있다는 것을 알았다. 사실 알긴 했지만 굳이..?라는 느낌이 강했다. 하지만 npm trend라던지 실제 레퍼런스를 보면 압도적으로 axios가 많은 것을 볼 수 있다.
그래서 이 참에 한 번 axios 기능을 fetch로 만들면 어떻게 될까 궁금증이 생겨 도전 해볼 겸 공부를 하게 되었다.
또한 최근 면접 질문 중에 axios interceptor을 말하다가 그러면 fetch로 구현은 어떤 식으로 할 수 있을까요? 이랬을 때 약간 얼타서 얘기를 한 것이 있기 때문에 실제 사용은 뒤로 하겠지만 그래도 원리를 아는 것이 중요한 것 같아 스터디를 시작하게 되었다.
근데..? 추후에 나오겠지만 이거를 공부하다가 너무 양이 방대해지는 느낌이 나서 2주치로 나눠야겠다는 생각을 가지게 되었다.
기본적인 부분은 가볍게만 다루고 넘어가도록 하겠다. 어느 정도는 다 알고 계시는 고수분들 인 것 같아서.. 그리고 전 백엔드를 못합니다.
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는 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 등)에 대한 클래스를 제공한다. 이 모듈을 통해 FormData
, Blob
과 같은 객체를 환경에 맞게 가져오는 형식으로 보인다.
// 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
}
XMLHttpRequest
객체를 사용하여 HTTP 요청을 처리합니다. 이는 브라우저 환경에서만 사용할 수 있는 API로 설계 되어 있는 듯XMLHttpRequest
를 사용하지 않고, 대신 http
또는 https
모듈을 사용하여 요청을 보냅니다.// 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>