해당 글은 HTTP 프로토콜의 기본 개념과 특징, 브라우저에서 HTTP 요청을 보내는 주요 방법인 XMLHttpRequest, Fetch API, axios를 비교하고, HTTP 1.0 / 1.1 / 2.0의 차이를 정리한 문서입니다.
HyperText Transfer Protocol의 약자로 웹에서 클라이언트(브라우저 등)와 서버가 요청과 응답을 주고받기 위한 애플리케이션 레벨 프로토콜을 의미합니다.
텍스트 안에 링크(참조)가 있어서, 사용자가 원하는 대로 점프하면서(non-linear) 읽을 수 있는 문서를 뜻합니다. HTML의 <a href="...">링크</a> 가 대표적인 HyperText 요소인데요.
처음 설계될 때의 목표가 하이퍼텍스트 문서를 전송하는 프로토콜이었기 때문에 단순히 텍스트만 전송하는게 아니라 다른 문서로 링크 정보를 포함한 텍스트(hypertext)를 전송해 링크들을 통해 전 세계 문서들이 서로 연결되는 것을 목표로 했다고 합니다.
예를 들어, 브라우저가 HTTP를 사용해, 링크들이 존재하는 HTML 문서를 서버에서 받아오고, 사용자가 링크를 클릭할 때마다 다시 HTTP로 다음 문서를 받아오는 과정 전체를 말합니다. 조금 더 단계별로 보자면 다음과 같습니다.
초기 JavaScript에서 HTTP 요청을 보내는 유일한 방법이었습니다.
특징
예시코드
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/users');
xhr.onload = function() {
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
console.log(data);
}
};
xhr.onerror = function() {
console.error('요청 실패');
};
xhr.send();
문제점
xhr1.onload = function() {
xhr2.onload = function() {
xhr3.onload = function() {
// 깊어지는 중첩...
};
};
};
브라우저 표준 HTTP 클라이언트 API이며, Promise 기반으로 설계되어 있습니다. 최신 브라우저와 Node.js 환경에서도 널리 사용됩니다.
과거에는 Node.js에 fetch가 없어서 node-fetch 같은 패키지를 사용해야 했지만, Node 18부터는 전역 fetch가 기본 제공되며 Node 21에서 안정화되었습니다. 다만 구 버전 Node를 쓰는 환경에서는 여전히 polyfill이나 axios 등의 라이브러리가 필요합니다.
fetch()의 두 번째 인자로 전달하는 RequestInit 객체에는 다양한 옵션이 있습니다. 궁금해서 찾아봤는데요. 설명만 간단히 적고 넘어가도록 하겠습니다.

interface RequestInit {
/** 요청의 body를 설정하기 위한 BodyInit 객체 또는 null입니다. */
body?: BodyInit | null; // ReadableStream | XMLHttpRequestBodyInit;
/** 요청이 브라우저의 캐시와 어떻게 상호작용할지 나타내는 문자열로, request의 cache 값을 설정합니다. */
cache?: RequestCache; // "default" | "force-cache" | "no-cache" | "no-store" | "only-if-cached" | "reload";
/** 자격 증명(쿠키, 인증 정보 등)을 항상 보낼지, 아예 보내지 않을지, 같은 출처에만 보낼지를 나타내는 문자열로, request의 credentials를 설정합니다. */
credentials?: RequestCredentials; // "include" | "omit" | "same-origin";
/** request의 headers를 설정하기 위한 Headers 객체, 일반 객체 리터럴, 또는 [키, 값] 튜플 배열입니다. */
headers?: HeadersInit;
/** 요청으로 가져올 리소스의 암호학적 해시값으로, request의 integrity를 설정합니다. */
integrity?: string;
/** request의 keepalive를 설정하기 위한 boolean 값입니다. */
keepalive?: boolean;
/** 요청의 HTTP 메서드를 설정하는 문자열입니다. */
method?: string;
/** 요청이 CORS를 사용할지, 동일 출처 URL로만 제한될지를 나타내는 문자열로, request의 mode를 설정합니다. */
mode?: RequestMode; // RequestMode = "cors" | "navigate" | "no-cors" | "same-origin";
/** 요청의 우선순위를 나타냅니다. */
priority?: RequestPriority;
/** 리다이렉트를 따를지, 리다이렉트를 만나면 에러로 처리할지, 혹은 리다이렉트 응답 자체를(불투명하게) 반환할지를 나타내는 문자열로, request의 redirect를 설정합니다. */
redirect?: RequestRedirect; // "error" | "follow" | "manual";
/** 동일 출처 URL, "about:client", 혹은 빈 문자열 중 하나의 값을 가지는 문자열로, request의 referrer를 설정합니다. */
referrer?: string;
/** request의 referrerPolicy를 설정하기 위한 리퍼러 정책입니다. */
referrerPolicy?: ReferrerPolicy; // "" | "no-referrer" | "no-referrer-when-downgrade" | "origin" | "origin-when-cross-origin" | "same-origin" | "strict-origin" | "strict-origin-when-cross-origin" | "unsafe-url";
/** request의 signal을 설정하기 위한 AbortSignal입니다. */
signal?: AbortSignal | null;
/** 항상 null만 허용됩니다. request를 어떤 Window와도 연결하지 않기 위해 사용됩니다. */
window?: null;
}
특징
Promise 기반의 현대적인 비동기 API입니다.
fetch()는 Promise를 반환하므로, then/catch 또는 async/await 패턴과 자연스럽게 어울린다.
Request / Response 객체 중심으로 설계되었습니다.
Request에는 method, headers, body, mode, credentials 등 많은 프로퍼티가 있고, RequestInit 옵션이 이 값들을 설정하는 역할을 합니다다. Response에는 status, ok, headers, body 와 함께 json(), text() 등 바디 파서 메서드가 존재합니다.
CORS, Origin, 캐시 등 설정이 가능합니다.
가볍고 의존성이 없습니다.
브라우저가 제공하는 기능이기 때문에, 별도 번들 크기 증가 없이 사용할 수 있습니다.
🧐 왜 네트워크 요청을 Promise로 구현했을까?
fetch는 HTTP 요청을 보내고 응답을 받아오는 함수입니다. 브라우저 → 서버까지 패킷이 오고 가야하고 이후 서버에서 처리도 필요한 뿐더러 중간에 네트워크 지연, 손실, 재전송 등도 일어날 수 있습니다.
즉, fetch를 호출하는 그 순간에는 결과를 바로 알 수 없는데요. 이를 동기 방식으로 만들면 서버 응답이 올 때 까지 사용자는 아무 것도 할 수 없습니다.
이에 비동기 처리를 위해 Promise로 구현하지 않았을까 생각합니다!
예시코드
// GET 요청
fetch('/api/users')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));
// async/await
async function getUsers() {
try {
const response = await fetch('/api/users');
const data = await response.json();
return data;
} catch (error) {
console.error(error);
}
}
문제점
HTTP 에러(4xx/5xx)도 reject가 아닙니다.
fetch()는 네트워크 레벨 에러(DNS 실패, 연결 끊김 등)에서만 Promise를 reject 합니다. 따라서 404, 500 응답도 정상응답으로 취급되기 때문에 개발자가 response.ok 나 response.status를 직접 체크해야 합니다. (response.ok가 false여도 Promise는 fulfilled 상태이므로, HTTP 에러를 한 군데서 잡지 못하고 분리된 상태로 관리해야 한다는 점이 직관적이지는 않다고 생각합니다)
try {
const res = await fetch('/api/users');
if (!res.ok) {
// 4xx, 5xx는 여기서 직접 처리
throw new Error(`HTTP error: ${res.status}`);
}
const data = await res.json();
} catch (e) {
// 네트워크 에러, 위에서 throw한 에러 둘 다 여기로
}
응답을 받으면 항상 res.json(), res.text(), res.blob() 등으로 직접 파싱해야 합니다.
진행률, 업로드/다운로드 이벤트는 직접 스트림을 다뤄야 합니다.
a. XHR은 onprogress 같은 이벤트로 진행률을 쉽게 받을 수 있지만, fetch는 ReadableStream 기반으로 직접 스트림을 읽으면서 구현해야 해서 코드가 복잡해집니다.
axios는 브라우저와 Node.js 모두에서 사용할 수 있는 Promise 기반 HTTP 클라이언트 라이브러리입니다. 브라우저에서는 내부적으로 XMLHttpRequest, Node.js에서는 http 모듈을 사용해 동작합니다.
특징
브라우저 + Node.js 양쪽에서 동일한 API를 전송합니다.
axios는 “isomorphic”이라고 표현합니다. HTTP 클라이언트라, 브라우저에선 XHR 위에서, Node에선 http 모듈 위에서 돌아가면서도 개발자는 똑같은 API (axios.get, axios.post 등)를 사용합니다.
Promise 기반 + 자동 JSON 변환을 진행합니다.
fetch처럼 Promise 기반이라 async/await과 자연스럽게 어울립니다. 응답이 JSON이면 response.data에 자동으로 파싱된 객체가 들어가고, 요청 시 JS 객체를 보내면 자동으로 JSON 문자열로 변환해 줍니다.
4xx/5xx 상태 코드를 자동으로 reject합니다.
axios는 HTTP 에러 상태(예: 404, 500)를 받으면 Promise를 reject합니다. 그래서 try/catch 안에서 네트워크 에러 + HTTP 에러를 한 번에 처리하기 쉽습니다.
인터셉터(Interceptors) 제공합니다.
요청/응답을 가로채는 함수를 등록할 수 있습니다.
예시 코드
export const externalInstance = axios.create({
baseURL: ENV.FAST_API_BASE_URL,
timeout: 10000,
});
export const get = async <T>(...args: Parameters<typeof externalInstance.get>) => {
const response = await externalInstance.get<T>(...args);
return response.data;
};
// ..
문제점
외부 의존성 + 번들 크기 증가합니다.
axios는 서드파티 라이브러리라서 프로젝트에 직접 설치해야 합니다. fetch는 브라우저 내장이라 0KB지만, axios는 약 수십 KB 수준의 번들 크기를 추가로 가져옵니다.
GET /index.html HTTP/1.0\r\nHeader: value\r\n\r\n[요청 1: index.html]
1. TCP 연결 열기 (3-way handshake)
2. GET /index.html 전송
3. 응답(HTML) 받기
4. TCP 연결 닫기
[요청 2: style.css]
1. TCP 연결 다시 열기
2. GET /style.css 전송
3. 응답(CSS) 받기
4. TCP 연결 닫기
[요청 3: app.js]
... (계속 반복)
Persistent Connection(커넥션 유지, Keep-Alive 기본)
a. 1.1에서는 기본이 연결을 재사용하는 것이기 때문에 하나의 TCP 연결 위에서 여러 요청/응답을 순차 처리가 가능해졌습니다.
Host 헤더 필수
a. www.example.com 헤더가 반드시 포함되어야 합니다. 하나의 IP에서 여러 도메인을 동시에 서비스하는 가상 호스팅이 가능해져서,서버/호스팅 비용을 크게 줄일 수 있었습니다.
b. http 1.1이면 Host: 도메인 네임, http 2/3이면 authority를 보면됩니다.

파이프라이닝(Pipelining) 도입
a. 하나의 연결에서 요청1, 요청2, 요청3을 응답 기다리지 않고 연속해서 보내고, 서버가 순서대로 응답 내려주는 방식입니다. 지연시간을 줄이려는 시도였지만, 프록시/중간 장비들이 제대로 지원을 안해서 실무에서는 거의 안쓰이게 됐다고 합니다.
Chunked Transfer Encoding
a. 응답의 전체 길이를 미리 모를 때, 조각(chunk) 으로 나눠서 스트리밍 전송하는 방식을 의미합니다. 서버가 데이터를 생성되는 대로 조금씩 보내줄 수 있어서, 긴 응답도 조금 더 빠르게 사용자에게 보여주기 좋아졌다고 합니다.
[처음 HTML 요청]
1. TCP 연결 1개 열기
2. GET /index.html 전송
3. 응답(HTML) 받음
(연결 유지)
[같은 연결에서 추가 요청]
4. GET /style.css 전송
5. 응답(CSS) 받음
6. GET /app.js 전송
7. 응답(JS) 받음
... 필요할 때까지 같은 연결을 재사용
재연결 덕분에 TCP handshake 비용이 감소하고, TCP가 time이 지날수록 속도가 올라가는 slow start 효과도 살릴 수 있었습니다.
TCP 연결 1개
- 스트림 #1: index.html
- 스트림 #3: app.js
- 스트림 #5: style.css
- 스트림 #7: image.png
각 스트림이 프레임 단위로 쪼개져서:
[1번 프레임][3번 프레임][5번 프레임][1번 프레임][7번 프레임]
이런 식으로 섞여서 전송 → 수신 측에서 스트림 ID 보고 재조립
TCP 레벨 HOL Blocking은 여전히 존재
a. HTTP/2는 HTTP 레벨 HOL은 많이 줄였지만, 결국 모든 스트림이 하나의 TCP 연결을 공유하기 때문에 그 TCP에서 패킷이 유실되면 → 재전송이 끝날 때까지 모든 스트림이 영향을 받습니다.
→ 이 한계를 더 줄이려고, 아예 TCP 대신 QUIC(UDP 기반) 를 사용하는 HTTP/3가 등장했습니다.
구현 복잡도와 디버깅 난이도 증가
a. 이진 프레이밍, 스트림 ID, 플로우 제어, HPACK 등 내부 구조가 복잡해서, 구현/디버깅 난도가 올라감.
Server Push 오남용 시 오히려 비효율
a. 필요 없는 리소스를 푸시하면 네트워크 낭비가 발생합니다. 브라우저 캐시와의 상호작용도 복잡해서, 실제로는 많은 팀들이 Server Push를 쓰지 않거나 꺼두는 경우가 많다고 합니다.
HTTP/3는 HTTP/2가 여전히 해결하지 못했던 TCP 레벨 Head-of-Line Blocking 문제를 해결하기 위해 등장했습니다. HTTP/2는 하나의 TCP 연결 위에서 멀티플렉싱을 통해 HTTP 레벨의 HOL 문제는 줄였지만, TCP 자체의 특성 때문에 패킷 손실이 발생하면 모든 스트림이 함께 지연되는 한계가 있었습니다. HTTP/3는 이 문제를 해결하기 위해 TCP 대신 UDP 기반의 QUIC 프로토콜을 사용합니다.
HTTP/3의 통신은 UDP 기반의 QUIC 연결을 수립하는 것에서 시작됩니다. 초기 연결 과정에서 QUIC 핸드셰이크가 수행되며, 이 과정에서 TLS 1.3 암호화 설정이 함께 이루어집니다. 연결이 수립되면 하나의 QUIC 연결 위에 여러 개의 스트림이 생성됩니다.
각 HTTP 요청과 응답은 서로 다른 QUIC 스트림으로 처리되며, 스트림 데이터는 다시 작은 패킷 단위로 분할되어 전송됩니다. 이 패킷들은 하나의 UDP 연결 위에서 섞여(interleave) 전송되지만, 수신 측에서는 스트림 ID를 기준으로 각 스트림의 데이터를 독립적으로 재조립합니다.
이 과정에서 특정 스트림의 패킷이 손실되더라도 해당 스트림만 재전송이 발생하며, 다른 스트림의 데이터 전송은 영향을 받지 않습니다. 또한 네트워크 환경이 변경되더라도 Connection ID를 통해 동일한 연결을 유지할 수 있으며, 이전 연결 정보가 있다면 0-RTT 방식으로 즉시 데이터 전송이 가능합니다.
구글

네이버

유튜브

참고자료
https://axios-http.com/docs/intro?utm_source=chatgpt.com
다음 글은 궁금했던 UDT, TCP의 차이점으로 돌아오겠습니닷