본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 5. · 17 Views
HTTP 프로토콜 완벽 가이드
웹 개발의 근간이 되는 HTTP 프로토콜을 초급 개발자도 쉽게 이해할 수 있도록 설명합니다. 요청과 응답의 구조부터 상태 코드, 캐시 메커니즘까지 실무에서 꼭 알아야 할 핵심 내용을 다룹니다.
목차
1. HTTP의 특성
김개발 씨는 오늘 처음으로 백엔드 API를 호출하는 코드를 작성했습니다. 로그인 기능을 구현했는데, 분명히 로그인에 성공했는데도 다음 페이지에서 사용자 정보가 사라져버리는 현상을 발견했습니다.
"분명히 로그인했는데 왜 서버가 저를 모르는 척하는 거죠?"
HTTP는 HyperText Transfer Protocol의 약자로, 웹에서 데이터를 주고받는 규약입니다. 가장 중요한 특성은 **무상태(Stateless)**와 요청-응답(Request-Response) 기반이라는 점입니다.
마치 건망증이 심한 식당 직원처럼, HTTP 서버는 이전 손님이 누구였는지 기억하지 못합니다. 이 특성을 이해하면 세션, 쿠키, 토큰 같은 인증 메커니즘이 왜 필요한지 알 수 있습니다.
다음 코드를 살펴봅시다.
// HTTP 요청-응답 기본 예제
const response = await fetch('https://api.example.com/users/1', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
// 무상태 특성을 보완하기 위한 인증 토큰
'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...'
}
});
// 서버는 매 요청마다 토큰을 확인해야 합니다
const user = await response.json();
console.log(user); // { id: 1, name: '김개발' }
김개발 씨는 입사 첫 주에 로그인 기능을 맡게 되었습니다. PHP나 JSP 시절의 전통적인 웹 개발을 배운 적이 없는 그에게, 서버가 자신을 기억하지 못한다는 사실은 꽤 충격적이었습니다.
"잠깐, 제가 분명히 아이디와 비밀번호를 보냈고, 서버가 '로그인 성공'이라고 응답했잖아요. 그런데 왜 다음 요청에서 서버가 저를 모른다는 거죠?" 선배 개발자 박시니어 씨가 커피 한 잔을 건네며 설명을 시작했습니다.
"HTTP의 가장 중요한 특성을 이해해야 해요. 바로 무상태라는 거죠." 무상태란 무엇일까요?
쉽게 비유하자면, HTTP 서버는 마치 기억력이 없는 안내 데스크 직원과 같습니다. 손님이 와서 "3층 회의실이 어디예요?"라고 물으면 친절하게 안내해줍니다.
하지만 그 손님이 5분 후에 다시 와서 "아까 말씀드린 회의실에 프로젝터가 있나요?"라고 물으면, 직원은 "죄송한데, 누구시죠?"라고 되물을 수밖에 없습니다. 왜 이렇게 설계했을까요?
사실 이것은 확장성을 위한 의도적인 선택입니다. 만약 서버가 모든 클라이언트의 상태를 기억해야 한다면 어떻게 될까요?
서버 한 대에 1만 명의 사용자가 접속하면, 1만 개의 상태를 메모리에 저장해야 합니다. 트래픽이 늘어나 서버를 10대로 늘리면, 사용자 A가 1번 서버에 로그인했다가 2번 서버로 요청이 가면 또다시 로그인해야 하는 문제가 생깁니다.
무상태 설계는 이런 문제를 원천적으로 해결합니다. 각 요청은 독립적이므로, 어떤 서버가 처리하든 상관없습니다.
서버 입장에서는 이전 요청을 기억할 필요가 없으니 메모리도 절약됩니다. 두 번째 특성인 요청-응답 구조도 중요합니다.
HTTP 통신은 항상 클라이언트가 먼저 요청을 보내고, 서버가 이에 응답하는 방식으로 이루어집니다. 서버가 먼저 클라이언트에게 데이터를 보낼 수 없습니다.
이것은 마치 식당에서 손님이 주문해야 음식이 나오는 것과 같습니다. 주방장이 갑자기 "오늘 스테이크 맛있으니까 드세요!"라며 음식을 가져다주지 않듯이요.
"그럼 채팅 앱이나 실시간 알림은 어떻게 구현하는 거예요?" 김개발 씨가 물었습니다. 박시니어 씨가 웃으며 답했습니다.
"좋은 질문이에요. 그래서 WebSocket이나 Server-Sent Events 같은 기술이 나온 거예요.
HTTP의 한계를 보완하기 위해서요." 위의 코드를 살펴보면, fetch 함수로 GET 요청을 보내고 있습니다. 중요한 것은 Authorization 헤더입니다.
서버가 우리를 기억하지 못하므로, 매 요청마다 "저는 인증된 사용자입니다"라는 증거를 함께 보내야 합니다. 이것이 바로 토큰 기반 인증의 핵심입니다.
실무에서는 이 개념을 정확히 이해해야 합니다. 예를 들어 쇼핑몰에서 장바구니 기능을 구현할 때, 사용자가 페이지를 이동해도 장바구니 내용이 유지되어야 합니다.
하지만 HTTP는 무상태이므로, 장바구니 정보를 쿠키, 세션, 또는 데이터베이스에 저장해야 합니다. 김개발 씨는 고개를 끄덕였습니다.
"아, 그래서 매 요청마다 토큰을 보내는 거군요! 서버가 저를 기억하지 못하니까, 제가 스스로를 증명해야 하는 거네요." 박시니어 씨가 미소 지었습니다.
"정확해요. 이제 HTTP의 기본을 이해한 거예요."
실전 팁
💡 - 무상태 특성 때문에 인증 정보는 매 요청마다 헤더에 포함해야 합니다
- 서버 확장 시 세션 공유 문제가 생기면 JWT 같은 토큰 기반 인증을 고려하세요
2. HTTP 메시지 구조
김개발 씨는 개발자 도구의 Network 탭을 열어보았습니다. 요청과 응답 데이터가 주르륵 지나가는데, 'Request Headers', 'Response Headers'라는 섹션에 알 수 없는 정보들이 가득합니다.
"이게 다 뭐예요? 저는 그냥 fetch만 호출했을 뿐인데..."
HTTP 메시지는 시작줄, 헤더, 빈 줄, 본문의 네 부분으로 구성됩니다. 마치 편지를 쓸 때 받는 사람, 제목, 본문을 구분하는 것처럼, HTTP도 정해진 형식을 따릅니다.
요청 메시지와 응답 메시지는 구조는 비슷하지만 시작줄의 내용이 다릅니다.
다음 코드를 살펴봅시다.
// HTTP 요청 메시지 구조 (개념적 표현)
/*
GET /api/users/1 HTTP/1.1 <- 시작줄 (메서드 + 경로 + 버전)
Host: api.example.com <- 헤더 시작
Content-Type: application/json
Authorization: Bearer token123
<- 빈 줄 (헤더와 본문 구분)
<- 본문 (GET은 보통 비어있음)
*/
// HTTP 응답 메시지 구조
/*
HTTP/1.1 200 OK <- 시작줄 (버전 + 상태코드 + 상태메시지)
Content-Type: application/json <- 헤더 시작
Content-Length: 52
{"id": 1, "name": "김개발"} <- 본문
*/
박시니어 씨가 김개발 씨 옆으로 의자를 끌고 와 앉았습니다. "HTTP 메시지 구조를 한번 자세히 볼까요?
개발자 도구에서 보이는 이 정보들이 사실 엄청나게 유용해요." HTTP 메시지는 마치 공식 서신과 같습니다. 회사에서 공문을 보낼 때 정해진 양식이 있듯이, HTTP도 반드시 따라야 하는 형식이 있습니다.
가장 먼저 **시작줄(Start Line)**이 옵니다. 요청 메시지의 경우, 시작줄에는 "무엇을 어디에 어떤 버전으로 요청하는지"가 담깁니다.
예를 들어 GET /api/users/1 HTTP/1.1이라면, GET 메서드로 /api/users/1 경로에 HTTP 1.1 버전으로 요청한다는 뜻입니다. 응답 메시지의 시작줄은 조금 다릅니다.
HTTP/1.1 200 OK처럼 버전, 상태 코드, 상태 메시지가 담깁니다. 이 한 줄만 봐도 요청이 성공했는지 실패했는지 알 수 있습니다.
그다음에는 **헤더(Headers)**가 옵니다. 헤더는 메시지에 대한 메타데이터입니다.
편지로 치면 보내는 사람 주소, 받는 사람 주소, 등기 여부 같은 정보에 해당합니다. 자주 보는 헤더들을 살펴볼까요?
Host는 요청을 보내는 서버의 도메인입니다. 하나의 서버가 여러 도메인을 호스팅할 수 있기 때문에 필수입니다.
Content-Type은 본문의 데이터 형식을 알려줍니다. JSON이면 application/json, HTML이면 text/html이 됩니다.
Content-Length는 본문의 크기를 바이트 단위로 알려줍니다. 서버나 클라이언트는 이 값을 보고 데이터를 얼마나 더 읽어야 하는지 알 수 있습니다.
헤더 다음에는 반드시 빈 줄이 와야 합니다. 이 빈 줄이 헤더와 본문을 구분하는 경계선 역할을 합니다.
HTTP 파서는 이 빈 줄을 만나면 "아, 이제부터는 본문이구나"라고 인식합니다. 마지막으로 **본문(Body)**입니다.
본문에는 실제로 전송하려는 데이터가 담깁니다. 로그인 요청이라면 아이디와 비밀번호가, 응답이라면 사용자 정보나 상품 목록 같은 데이터가 들어갑니다.
김개발 씨가 질문했습니다. "그런데 GET 요청은 본문이 비어있더라고요.
왜 그런 거예요?" "좋은 관찰이에요." 박시니어 씨가 답했습니다. "GET은 데이터를 '가져오는' 요청이라 보통 본문이 필요 없어요.
대신 필요한 정보는 URL의 쿼리 파라미터로 보내죠. 반면 POST는 데이터를 '보내는' 요청이라 본문에 데이터를 담아요." 실무에서 이 구조를 알면 디버깅이 훨씬 쉬워집니다.
API 호출이 실패했을 때, 시작줄에서 상태 코드를 확인하고, 헤더에서 Content-Type이 맞는지 확인하고, 본문에서 에러 메시지를 확인하면 대부분의 문제를 찾을 수 있습니다. 김개발 씨는 다시 개발자 도구를 열어보았습니다.
이제 그 복잡해 보이던 정보들이 조금씩 이해되기 시작했습니다.
실전 팁
💡 - 개발자 도구의 Network 탭에서 요청/응답 헤더를 자주 확인하는 습관을 들이세요
- Content-Type 불일치는 API 통신 실패의 흔한 원인입니다
3. HTTP 메서드
김개발 씨는 REST API 문서를 읽다가 고개를 갸웃거렸습니다. 같은 URL인데 어떤 건 GET이고, 어떤 건 POST이고, DELETE도 있습니다.
"URL만 다르면 되는 거 아니에요? 왜 이렇게 복잡하게 메서드를 나눠놓은 거죠?"
HTTP 메서드는 요청의 목적을 나타냅니다. GET은 데이터 조회, POST는 새로운 데이터 생성, PUT은 데이터 전체 수정, DELETE는 데이터 삭제를 의미합니다.
마치 도서관에서 책을 빌리고, 반납하고, 예약하는 행위가 다르듯이, 같은 자원에 대해서도 어떤 동작을 할지 명확히 구분하는 것입니다.
다음 코드를 살펴봅시다.
// GET - 데이터 조회 (서버 상태 변경 없음)
const user = await fetch('/api/users/1');
// POST - 새로운 데이터 생성
const newUser = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '김개발', email: 'kim@dev.com' })
});
// PUT - 데이터 전체 수정
await fetch('/api/users/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '김시니어', email: 'kim@senior.com' })
});
// DELETE - 데이터 삭제
await fetch('/api/users/1', { method: 'DELETE' });
박시니어 씨가 화이트보드 앞으로 걸어갔습니다. "REST API를 제대로 설계하려면 HTTP 메서드를 확실히 이해해야 해요.
이건 그냥 규칙이 아니라 의미론적 약속이에요." HTTP 메서드를 이해하는 가장 쉬운 방법은 도서관에 비유하는 것입니다. 도서관에서 책을 열람하는 것은 GET입니다.
책을 보기만 할 뿐, 도서관의 상태는 변하지 않습니다. 같은 책을 열 번 봐도 책이 닳거나 사라지지 않습니다.
이처럼 GET 요청은 서버의 데이터를 변경하지 않습니다. 이것을 안전한(Safe) 메서드라고 합니다.
도서관에 새 책을 기증하는 것은 POST입니다. 기증하면 도서관에 새로운 책이 추가됩니다.
같은 책을 두 번 기증하면 두 권이 됩니다. POST 요청도 마찬가지로, 호출할 때마다 새로운 리소스가 생성될 수 있습니다.
책의 정보를 전면 수정하는 것은 PUT입니다. 예를 들어 책의 제목, 저자, 출판사를 모두 새로운 정보로 교체하는 것입니다.
PUT은 해당 자원을 완전히 대체합니다. 중요한 특성이 있는데, PUT은 **멱등(Idempotent)**합니다.
같은 요청을 한 번 보내든 열 번 보내든 결과가 같습니다. 책을 폐기하는 것은 DELETE입니다.
한번 삭제하면 해당 책은 더 이상 존재하지 않습니다. DELETE도 멱등합니다.
이미 삭제된 책을 다시 삭제하려고 해도 결과는 같습니다. 책이 없다는 것이죠.
"잠깐요," 김개발 씨가 손을 들었습니다. "그럼 PATCH는 뭐예요?
가끔 보이던데요." "좋은 질문이에요." 박시니어 씨가 답했습니다. "PATCH는 부분 수정이에요.
PUT이 책 전체를 새 책으로 바꾸는 거라면, PATCH는 책의 일부분, 예를 들어 분류 번호만 수정하는 거죠." 실무에서 이 구분이 왜 중요할까요? 첫째, 캐싱입니다.
GET 요청은 캐시될 수 있습니다. 브라우저는 같은 GET 요청에 대해 서버에 다시 물어보지 않고 저장된 응답을 재사용할 수 있습니다.
하지만 POST 요청은 캐시되지 않습니다. 둘째, 브라우저 동작입니다.
브라우저에서 뒤로 가기를 눌렀을 때, GET 요청은 자동으로 다시 실행됩니다. 하지만 POST 요청은 "양식을 다시 제출하시겠습니까?"라는 경고가 뜹니다.
결제 같은 중요한 작업이 실수로 두 번 실행되는 것을 막기 위해서입니다. 셋째, API 설계의 명확성입니다.
메서드만 봐도 이 API가 데이터를 조회하는지, 생성하는지, 수정하는지, 삭제하는지 알 수 있습니다. 팀원 간의 소통이 쉬워지고, API 문서도 간결해집니다.
김개발 씨가 정리했습니다. "결국 같은 /users라는 URL이어도, GET /users는 목록 조회, POST /users는 새 사용자 생성, DELETE /users/1은 1번 사용자 삭제라는 뜻이군요!" 박시니어 씨가 고개를 끄덕였습니다.
"완벽해요. 이제 RESTful API를 설계할 준비가 된 거예요."
실전 팁
💡 - GET 요청에는 민감한 정보를 쿼리 파라미터로 보내지 마세요 (URL에 노출됨)
- 멱등성을 활용해 네트워크 오류 시 재시도 로직을 안전하게 구현할 수 있습니다
4. HTTP 상태 코드
김개발 씨가 작성한 코드가 에러를 뱉었습니다. 콘솔에 "HTTP 500 Internal Server Error"라는 메시지가 떴습니다.
"500이라고요? 그게 무슨 뜻이에요?" 옆에서 지켜보던 박시니어 씨가 말했습니다.
"상태 코드를 알면 에러의 절반은 해결한 거예요."
HTTP 상태 코드는 서버가 요청을 어떻게 처리했는지 알려주는 세 자리 숫자입니다. 2xx는 성공, 3xx는 리다이렉션, 4xx는 클라이언트 오류, 5xx는 서버 오류를 의미합니다.
마치 병원에서 검사 결과를 숫자 코드로 받는 것처럼, 상태 코드만 봐도 문제의 원인이 어디에 있는지 대략 파악할 수 있습니다.
다음 코드를 살펴봅시다.
// 상태 코드에 따른 에러 처리 예제
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
switch (response.status) {
case 200: // OK - 성공
return await response.json();
case 201: // Created - 생성 성공
return await response.json();
case 400: // Bad Request - 잘못된 요청
throw new Error('요청 형식이 올바르지 않습니다');
case 401: // Unauthorized - 인증 필요
throw new Error('로그인이 필요합니다');
case 404: // Not Found - 리소스 없음
throw new Error('사용자를 찾을 수 없습니다');
case 500: // Internal Server Error - 서버 오류
throw new Error('서버에 문제가 발생했습니다');
}
}
박시니어 씨가 포스트잇에 숫자들을 적기 시작했습니다. "상태 코드는 세 자리 숫자인데, 첫 번째 숫자만 봐도 대략적인 상황을 알 수 있어요." 상태 코드를 이해하는 가장 쉬운 방법은 택배 배송에 비유하는 것입니다.
2xx는 "배송 완료"입니다. 고객이 주문한 물건이 무사히 도착했다는 뜻입니다.
가장 흔한 200 OK는 "요청이 성공적으로 처리되었습니다"라는 의미입니다. 201 Created는 "새로운 것이 만들어졌습니다"로, POST 요청으로 새 리소스를 생성했을 때 반환됩니다.
204 No Content는 "성공했지만 돌려줄 데이터는 없습니다"입니다. DELETE 성공 시 자주 사용됩니다.
3xx는 "주소가 변경되었습니다"입니다. 택배 기사가 배송지에 갔더니 "이사했음, 새 주소로 가세요"라는 메모가 붙어있는 상황입니다.
301 Moved Permanently는 영구적으로 주소가 바뀐 경우입니다. 검색 엔진은 이 코드를 보면 새 주소로 인덱스를 업데이트합니다.
302 Found는 임시로 다른 주소를 사용하는 경우입니다. 304 Not Modified는 조금 특별한데, "지난번에 보낸 것과 똑같으니 캐시된 거 쓰세요"라는 뜻입니다.
4xx는 "고객님 잘못입니다"입니다. 주문서에 주소를 잘못 적었거나, 없는 상품을 주문했거나, 결제가 안 된 경우입니다.
400 Bad Request는 요청 형식이 잘못된 경우입니다. JSON 문법 오류 같은 것이죠.
401 Unauthorized는 인증이 필요하다는 뜻입니다. "로그인하세요"라는 의미입니다.
403 Forbidden은 인증은 됐지만 권한이 없다는 뜻입니다. "관리자만 접근할 수 있어요." 404 Not Found는 가장 유명한 코드입니다.
요청한 리소스가 존재하지 않습니다. 5xx는 "저희 잘못입니다"입니다.
택배 회사의 시스템이 다운되었거나, 창고에 불이 났거나 하는 상황입니다. 500 Internal Server Error는 서버에서 예상치 못한 오류가 발생했다는 뜻입니다.
보통 버그가 있을 때 나타납니다. 502 Bad Gateway는 서버 앞단의 프록시가 백엔드 서버에서 잘못된 응답을 받았을 때 발생합니다.
503 Service Unavailable은 서버가 일시적으로 요청을 처리할 수 없는 상태입니다. 점검 중이거나 과부하일 때 나타납니다.
김개발 씨가 물었습니다. "그러면 에러가 났을 때 4xx인지 5xx인지 보면 누구 잘못인지 바로 알 수 있겠네요?" "맞아요." 박시니어 씨가 답했습니다.
"4xx가 뜨면 요청을 보내는 쪽, 즉 프론트엔드 코드나 API 호출 방식을 확인해야 해요. 5xx가 뜨면 백엔드 로그를 확인해야 하고요." 실무에서 특히 주의할 점이 있습니다.
404와 403을 구분하는 것입니다. 보안상의 이유로, 리소스가 존재하지만 접근 권한이 없을 때도 404를 반환하는 경우가 있습니다.
해커에게 "이 리소스는 존재하지만 너는 접근할 수 없어"라는 정보를 주지 않기 위해서입니다. 또 하나, 상태 코드와 함께 오는 응답 본문도 꼭 확인하세요.
같은 400이라도 "필수 필드가 누락되었습니다"와 "이메일 형식이 올바르지 않습니다"는 전혀 다른 문제입니다. 김개발 씨가 다시 에러 메시지를 확인했습니다.
500 에러였으니 이건 서버 쪽 문제입니다. 백엔드 팀에 문의해야겠군요.
실전 팁
💡 - 401과 403을 혼동하지 마세요. 401은 인증 필요, 403은 권한 없음입니다
- 프론트엔드에서는 5xx 에러 시 사용자에게 "잠시 후 다시 시도해주세요" 메시지를 보여주는 것이 좋습니다
5. 주요 HTTP 헤더
김개발 씨는 API 연동 중 이상한 문제를 발견했습니다. Postman에서는 잘 되는데 브라우저에서는 CORS 에러가 납니다.
에러 메시지에는 'Access-Control-Allow-Origin' 헤더가 없다고 합니다. "헤더가 도대체 뭐길래 이렇게 중요한 거죠?"
HTTP 헤더는 요청과 응답에 대한 부가 정보를 전달합니다. 요청 헤더는 클라이언트가 서버에게 자신에 대한 정보를 알려주고, 응답 헤더는 서버가 응답에 대한 메타데이터를 제공합니다.
마치 택배를 보낼 때 운송장에 적는 정보들처럼, 헤더는 본문 데이터를 올바르게 처리하기 위한 맥락을 제공합니다.
다음 코드를 살펴봅시다.
// 주요 요청 헤더 예제
const response = await fetch('/api/data', {
method: 'POST',
headers: {
// 본문의 데이터 형식
'Content-Type': 'application/json',
// 원하는 응답 형식
'Accept': 'application/json',
// 인증 정보
'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...',
// 브라우저/클라이언트 정보
'User-Agent': 'Mozilla/5.0...',
// 캐시 제어
'Cache-Control': 'no-cache'
},
body: JSON.stringify({ name: '김개발' })
});
// 응답 헤더 확인
console.log(response.headers.get('Content-Type'));
console.log(response.headers.get('Set-Cookie'));
박시니어 씨가 화면을 가리키며 설명했습니다. "CORS 에러를 만났군요.
이건 헤더를 이해하면 해결할 수 있어요." HTTP 헤더는 마치 편지 봉투와 같습니다. 봉투에는 보내는 사람, 받는 사람, 등기 여부, 취급 주의 스티커 같은 정보가 적혀 있습니다.
실제 편지 내용은 봉투 안에 있지만, 우체국에서는 봉투의 정보만으로 편지를 배달합니다. 가장 자주 사용하는 요청 헤더부터 살펴볼까요?
Content-Type은 본문의 형식을 알려줍니다. application/json이면 JSON 데이터, application/x-www-form-urlencoded면 폼 데이터, multipart/form-data면 파일 업로드입니다.
서버는 이 헤더를 보고 본문을 어떻게 파싱할지 결정합니다. Accept는 클라이언트가 원하는 응답 형식입니다.
"저는 JSON으로 받고 싶어요"라고 서버에게 알려주는 것입니다. 서버가 여러 형식을 지원할 때 유용합니다.
Authorization은 인증 정보를 담습니다. 가장 흔한 형식은 Bearer 토큰입니다.
서버는 이 토큰을 검증해서 사용자를 식별합니다. User-Agent는 클라이언트 정보입니다.
브라우저 종류, 버전, 운영체제 등이 담겨 있습니다. 서버는 이 정보로 모바일용 페이지를 보여주거나 통계를 수집합니다.
이제 응답 헤더를 볼까요? Content-Length는 응답 본문의 크기를 바이트 단위로 알려줍니다.
브라우저는 이 값을 보고 다운로드 진행률을 계산합니다. Set-Cookie는 서버가 클라이언트에게 쿠키를 저장하라고 지시합니다.
세션 ID나 사용자 설정 같은 정보를 저장하는 데 사용됩니다. Cache-Control은 캐싱 정책을 정의합니다.
max-age=3600이면 1시간 동안 캐시해도 된다는 뜻이고, no-store면 절대 캐시하지 말라는 뜻입니다. 그리고 김개발 씨가 만난 CORS 관련 헤더가 있습니다.
Access-Control-Allow-Origin은 어떤 도메인에서 이 리소스에 접근할 수 있는지 지정합니다. 브라우저는 이 헤더가 없거나 현재 도메인이 허용 목록에 없으면 응답을 차단합니다.
이것이 바로 CORS 에러의 원인입니다. "왜 Postman에서는 되고 브라우저에서는 안 되는 거예요?" 김개발 씨가 물었습니다.
"CORS는 브라우저의 보안 정책이에요." 박시니어 씨가 설명했습니다. "악성 사이트가 사용자 몰래 은행 API를 호출하는 것을 막기 위한 거죠.
Postman은 브라우저가 아니라서 이 정책이 적용되지 않아요." 실무에서는 이런 헤더들을 잘 이해하고 있어야 합니다. API 통신 문제의 상당수가 헤더 설정 오류에서 발생합니다.
Content-Type이 맞지 않아서 서버가 본문을 파싱하지 못하거나, Authorization 헤더가 빠져서 401 에러가 나거나, CORS 헤더가 없어서 브라우저가 응답을 차단하거나 하는 경우가 많습니다. 김개발 씨는 백엔드 팀에 연락해서 CORS 헤더를 추가해달라고 요청했습니다.
문제 해결!
실전 팁
💡 - CORS 에러가 나면 서버의 응답 헤더를 먼저 확인하세요
- Content-Type을 명시하지 않으면 서버가 본문을 잘못 해석할 수 있습니다
6. 쿠키와 캐시 메커니즘
김개발 씨가 로그인 기능을 테스트하고 있습니다. 브라우저를 껐다 켜도 로그인이 유지됩니다.
"서버가 무상태라면서요? 어떻게 저를 기억하는 거죠?" 그리고 또 다른 의문이 생겼습니다.
이미지가 한 번 로드되면 다음부터는 훨씬 빠르게 뜹니다. "이것도 뭔가 저장되는 건가요?"
쿠키와 캐시는 HTTP의 무상태 특성을 보완하는 메커니즘입니다. 쿠키는 서버가 클라이언트에게 저장하라고 보내는 작은 데이터로, 세션 유지나 사용자 설정 저장에 사용됩니다.
캐시는 응답 데이터를 저장해두었다가 재사용하는 것으로, 네트워크 요청을 줄이고 성능을 향상시킵니다.
다음 코드를 살펴봅시다.
// 쿠키 설정 (서버 응답 헤더)
// Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict; Max-Age=3600
// JavaScript에서 쿠키 읽기/쓰기
document.cookie = "theme=dark; path=/; max-age=86400";
console.log(document.cookie); // "theme=dark; sessionId=abc123"
// 캐시 제어 헤더 예제
// Cache-Control: public, max-age=31536000 <- 1년간 캐시 가능
// Cache-Control: no-store <- 캐시 금지
// Cache-Control: no-cache <- 캐시 전 서버 검증 필요
// ETag를 활용한 조건부 요청
// 서버 응답: ETag: "abc123"
// 클라이언트 재요청: If-None-Match: "abc123"
// 서버 응답: 304 Not Modified (본문 없음)
박시니어 씨가 브라우저 개발자 도구를 열었습니다. "Application 탭을 보세요.
여기에 쿠키가 저장되어 있어요." 쿠키는 마치 도장 스탬프와 같습니다. 카페에서 음료를 살 때마다 도장을 받고, 10개가 모이면 무료 음료를 받는 것처럼, 서버는 클라이언트에게 쿠키라는 도장을 찍어줍니다.
다음에 클라이언트가 방문할 때 이 도장을 보여주면, 서버는 "아, 이 손님이구나"라고 알아볼 수 있습니다. 쿠키는 어떻게 동작할까요?
서버가 응답을 보낼 때 Set-Cookie 헤더를 포함합니다. 브라우저는 이 헤더를 보고 쿠키를 저장합니다.
이후 같은 도메인으로 요청을 보낼 때마다, 브라우저는 자동으로 저장된 쿠키를 Cookie 헤더에 담아 보냅니다. 쿠키에는 여러 옵션이 있습니다.
HttpOnly는 JavaScript에서 쿠키에 접근하지 못하게 합니다. XSS 공격으로부터 쿠키를 보호합니다.
Secure는 HTTPS 연결에서만 쿠키를 전송합니다. SameSite는 다른 사이트에서 온 요청에 쿠키를 포함할지 결정합니다.
CSRF 공격을 방지합니다. Max-Age는 쿠키의 유효 기간을 초 단위로 지정합니다.
"그럼 캐시는 쿠키랑 다른 건가요?" 김개발 씨가 물었습니다. "네, 목적이 달라요." 박시니어 씨가 답했습니다.
"쿠키는 상태 유지가 목적이고, 캐시는 성능 향상이 목적이에요." 캐시는 마치 냉장고와 같습니다. 매번 마트에 가는 대신 냉장고에 저장해둔 음식을 꺼내 먹는 것처럼, 브라우저는 이미 받은 응답을 저장해두었다가 재사용합니다.
캐시를 제어하는 핵심 헤더는 Cache-Control입니다. max-age=3600은 "이 응답을 1시간 동안 캐시해도 됩니다"라는 뜻입니다.
public은 CDN 같은 중간 서버도 캐시할 수 있다는 뜻이고, private는 브라우저만 캐시할 수 있다는 뜻입니다. no-store는 절대 캐시하지 말라는 뜻이고, no-cache는 캐시는 하되 사용 전에 서버에 확인하라는 뜻입니다.
특히 흥미로운 것은 조건부 요청입니다. 서버가 응답과 함께 ETag 헤더를 보냅니다.
이것은 응답의 버전 식별자입니다. 나중에 브라우저가 같은 리소스를 다시 요청할 때, If-None-Match 헤더에 저장된 ETag 값을 보냅니다.
서버는 현재 리소스의 ETag와 비교해서, 같으면 304 Not Modified를 응답합니다. 이때 본문은 비어있습니다.
브라우저는 캐시된 데이터를 그대로 사용합니다. 실무에서 캐시 전략은 매우 중요합니다.
정적 파일(이미지, CSS, JS)은 긴 캐시 시간을 설정하되, 파일명에 해시를 넣어 변경 시 새로 받을 수 있게 합니다. API 응답은 상황에 따라 다르지만, 실시간성이 중요한 데이터는 캐시하지 않거나 짧은 시간만 캐시합니다.
김개발 씨가 정리했습니다. "쿠키는 서버가 클라이언트를 기억하기 위한 것이고, 캐시는 같은 데이터를 반복해서 받지 않기 위한 것이군요!"
실전 팁
💡 - 민감한 정보를 담은 쿠키에는 반드시 HttpOnly와 Secure 옵션을 설정하세요
- 배포 시 캐시 문제로 업데이트가 반영되지 않으면 파일명에 해시를 추가하세요
7. HTTP 버전 발전
김개발 씨는 성능 최적화 회의에 참석했습니다. 선임 개발자가 "HTTP/2로 전환하면 페이지 로딩이 빨라질 거예요"라고 말합니다.
그 옆에서 다른 개발자가 "요즘은 HTTP/3까지 나왔잖아요"라고 덧붙입니다. 숫자가 올라가면 뭐가 좋아지는 걸까요?
HTTP는 1.0에서 시작해 1.1, 2, 3까지 발전해왔습니다. 각 버전은 이전 버전의 한계를 극복하기 위해 등장했습니다.
HTTP/1.1은 연결 재사용을, HTTP/2는 다중화와 헤더 압축을, HTTP/3는 UDP 기반의 QUIC 프로토콜을 도입하여 성능을 크게 향상시켰습니다.
다음 코드를 살펴봅시다.
// HTTP 버전별 동작 비교 (개념적 예시)
// HTTP/1.1 - 순차적 요청, 연결 재사용
// Connection: keep-alive
// 요청1 -> 응답1 -> 요청2 -> 응답2 -> 요청3 -> 응답3
// HTTP/2 - 멀티플렉싱 (하나의 연결에서 동시 요청)
// 스트림 1: 요청1 -----> 응답1
// 스트림 2: 요청2 -----> 응답2
// 스트림 3: 요청3 -----> 응답3
// HTTP/3 - QUIC 프로토콜 (UDP 기반, 연결 설정 간소화)
// 0-RTT 연결 가능, 패킷 손실 시에도 다른 스트림 영향 없음
// 서버에서 HTTP/2 지원 확인
// curl -I --http2 https://example.com
// HTTP/2 200
박시니어 씨가 회의실 화이트보드에 타임라인을 그리기 시작했습니다. "HTTP의 역사를 알면 왜 이런 기술들이 나왔는지 이해할 수 있어요." HTTP/1.0은 1996년에 등장했습니다.
이 시절에는 요청할 때마다 TCP 연결을 새로 맺어야 했습니다. 마치 전화할 때마다 새 전화기를 사는 것과 같았습니다.
비효율적이었죠. HTTP/1.1은 1997년에 나왔습니다.
가장 큰 변화는 **지속 연결(Keep-Alive)**입니다. 한 번 연결을 맺으면 여러 요청에 재사용할 수 있게 되었습니다.
하지만 여전히 문제가 있었습니다. Head-of-Line Blocking 문제입니다.
이것은 마치 은행 창구와 같습니다. 앞 사람의 업무가 끝나야 다음 사람이 처리됩니다.
앞 사람이 복잡한 업무를 보면 뒤 사람들은 모두 기다려야 합니다. HTTP/1.1에서는 요청 A가 끝나야 요청 B의 응답을 받을 수 있었습니다.
개발자들은 이 문제를 우회하기 위해 도메인 샤딩을 사용했습니다. 이미지는 img.example.com에서, CSS는 css.example.com에서 받는 식으로 여러 연결을 동시에 맺는 것이죠.
하지만 이것은 임시방편에 불과했습니다. HTTP/2는 2015년에 등장했습니다.
핵심은 멀티플렉싱입니다. 하나의 연결에서 여러 요청과 응답을 동시에 주고받을 수 있게 되었습니다.
마치 고속도로에 여러 차선이 생긴 것과 같습니다. 앞 차가 느려도 옆 차선으로 추월할 수 있습니다.
또한 **헤더 압축(HPACK)**도 도입되었습니다. HTTP/1.1에서는 매 요청마다 비슷한 헤더를 반복해서 보냈습니다.
HTTP/2에서는 이전에 보낸 헤더와 차이점만 전송합니다. 서버 푸시도 가능해졌습니다.
클라이언트가 HTML을 요청하면, 서버가 "CSS와 JS도 필요할 테니 미리 보내줄게요"라고 선제적으로 리소스를 전송할 수 있습니다. 하지만 실제로는 캐시와의 충돌 문제로 많이 사용되지는 않습니다.
HTTP/3는 2022년에 표준화되었습니다. 가장 큰 변화는 TCP 대신 QUIC 프로토콜을 사용한다는 것입니다.
QUIC은 UDP 위에서 동작합니다. 왜 TCP를 버렸을까요?
TCP는 연결을 맺는 데 여러 번의 왕복이 필요합니다(3-way handshake). TLS까지 더하면 더 많아집니다.
QUIC은 이것을 1번의 왕복으로 줄였고, 이전에 연결한 적 있는 서버라면 0-RTT, 즉 왕복 없이 바로 데이터를 보낼 수도 있습니다. 또한 TCP의 Head-of-Line Blocking 문제도 해결했습니다.
HTTP/2에서 멀티플렉싱을 지원했지만, TCP 레벨에서는 패킷이 손실되면 모든 스트림이 막혔습니다. QUIC은 스트림별로 독립적이어서, 하나의 스트림에서 패킷 손실이 발생해도 다른 스트림은 영향받지 않습니다.
"그러면 우리도 바로 HTTP/3를 써야 하나요?" 김개발 씨가 물었습니다. 박시니어 씨가 답했습니다.
"이미 대부분의 CDN과 대형 서비스에서 지원하고 있어요. 하지만 기업 환경에서는 UDP 트래픽을 차단하는 방화벽도 있어서 호환성 체크가 필요해요." 실무에서는 대부분 인프라(CDN, 로드밸런서)에서 자동으로 처리됩니다.
하지만 HTTP 버전별 특성을 이해하면 성능 이슈를 디버깅하거나 최적화할 때 큰 도움이 됩니다.
실전 팁
💡 - 대부분의 최신 브라우저와 CDN은 HTTP/2, HTTP/3를 자동으로 지원합니다
- 개발자 도구의 Protocol 열에서 현재 사용 중인 HTTP 버전을 확인할 수 있습니다
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
서비스 메시 완벽 가이드
마이크로서비스 간 통신을 안전하고 효율적으로 관리하는 서비스 메시의 핵심 개념부터 실전 도입까지, 초급 개발자를 위한 완벽한 입문서입니다. Istio와 Linkerd 비교, 사이드카 패턴, 실무 적용 노하우를 담았습니다.
EFK 스택 로깅 완벽 가이드
마이크로서비스 환경에서 로그를 효과적으로 수집하고 분석하는 EFK 스택(Elasticsearch, Fluentd, Kibana)의 핵심 개념과 실전 활용법을 초급 개발자도 쉽게 이해할 수 있도록 정리한 가이드입니다.
Grafana 대시보드 완벽 가이드
실시간 모니터링의 핵심, Grafana 대시보드를 처음부터 끝까지 배워봅니다. Prometheus 연동부터 알람 설정까지, 초급 개발자도 쉽게 따라할 수 있는 실전 가이드입니다.
분산 추적 완벽 가이드
마이크로서비스 환경에서 요청의 전체 흐름을 추적하는 분산 추적 시스템의 핵심 개념을 배웁니다. Trace, Span, Trace ID 전파, 샘플링 전략까지 실무에 필요한 모든 것을 다룹니다.
CloudFront CDN 완벽 가이드
AWS CloudFront를 활용한 콘텐츠 배포 최적화 방법을 실무 관점에서 다룹니다. 배포 생성부터 캐시 설정, HTTPS 적용까지 단계별로 알아봅니다.