웹 개발을 하다 보면 매일 HTTP 통신을 쓰지만, 정작 그 안에서 무슨 일이 일어나는지 제대로 이해하는 경우는 드물다. 「GET으로 데이터를 가져오고, POST로 데이터를 보낸다」 정도만 알고 넘어가는 경우가 많다. GET과 POST는 요청의 종류를 나타내는 이름이고, 뒤의 「HTTP 메서드」 절에서 다룬다.
하지만 HTTP를 깊이 이해하면 API 설계, 성능, 보안에서 훨씬 나은 결정을 내릴 수 있다. 이 글에서는 HTTP가 왜 생겼는지부터 실무에서 꼭 알아야 할 핵심 개념까지 차례로 정리한다.
HTTP는 문서를 링크로 잇기 위해 탄생했다
문서 링크의 혁명
1989년, CERN(유럽 입자물리학 연구소)의 Tim Berners-Lee는 한 가지 문제에 부딪혔다. 과학자들이 쓴 논문과 연구 자료를 공유할 때, 참고 문헌이나 관련 문서를 찾아가는 것이 너무 불편했던 것이다.
그는 하이퍼텍스트(Hypertext) 개념을 떠올렸다. 문서 안에 다른 문서로 연결되는 링크를 넣으면 어떨까? 이것이 지금 웹 페이지에서 링크를 만들 때 쓰는 <a> 태그의 시작이다. 태그는 HTML에서 꺾쇠로 감싸 「여기는 링크」처럼 표시하는 표지이고, <a>가 바로 링크 표지다.
이 아이디어를 실현하려고 세 가지가 필요했다.
- HTTP (HyperText Transfer Protocol) - 문서를 전송하는 규약(프로토콜)
- HTML (HyperText Markup Language) - 링크가 들어간 문서 형식. 「마크업」은 글에 태그로 표시(마크)를 붙인다는 뜻이다
- URL (Uniform Resource Locator) - 문서의 위치를 나타내는 주소
HTTP의 진화
초기 HTTP는 HTML 문서 하나를 요청하고 받는 수준이었다. 웹이 커지면서 HTTP도 함께 바뀌었다.
HTTP/0.9 (1991) → 단순 문서 전송
HTTP/1.0 (1996) → 헤더 추가, 상태 코드 도입
HTTP/1.1 (1997) → 지속 연결, 파이프라이닝
HTTP/2 (2015) → 멀티플렉싱, 헤더 압축
HTTP/3 (2022) → QUIC 프로토콜, UDP 기반
한 줄씩 짚으면 이렇다. 헤더와 상태 코드는 뒤에서 따로 다루니 여기서는 이름만 기억해 둔다.
- 헤더 — 요청 · 응답에 붙이는 부가 정보 칸이다. 「HTTP 헤더와 바디」 절에서 다룬다
- 상태 코드 — 응답이 성공인지 실패인지 알려 주는 세 자리 번호다. 「HTTP 상태 코드」 절에서 다룬다
- 지속 연결 — 한 번 연 연결을 끊지 않고 다음 요청에도 다시 쓴다
- 파이프라이닝 — 앞 요청의 응답을 기다리지 않고 다음 요청을 이어서 보낸다
- 멀티플렉싱 — 연결 하나에 여러 요청과 응답을 섞어 동시에 흘려 보낸다. 헤더 압축은 헤더를 압축해 보내는 크기를 줄이는 것이다
- QUIC · UDP — HTTP/3은 아래에서 설명할 TCP 대신 UDP 위에 만든 QUIC이라는 규약을 쓴다
지금의 HTTP는 문서 전송을 넘어 실시간 통신, 영상 스트리밍, REST API 같은 여러 용도로 쓰인다.
HTTP는 TCP 연결 위에서 요청과 응답을 주고받는다
TCP 위에서 동작하는 HTTP
HTTP는 혼자 동작하지 않는다. 네트워크 통신은 일을 여러 층(계층)으로 나눠 맡기는데, HTTP는 TCP 위에 올라가 동작한다. 「위에 올라간다」는 HTTP가 자기 메시지를 TCP에게 맡겨 보내고, TCP는 다시 아래층에 맡긴다는 뜻이다.
HTTP/HTTPS] -->|사용| B[전송 계층
TCP] B -->|사용| C[인터넷 계층
IP] C -->|사용| D[네트워크 인터페이스 계층
Ethernet/WiFi] style A fill:#2d4a5e,stroke:#4a90a4,stroke-width:2px,color:#e8f4f8 style B fill:#2d3e50,stroke:#4a7c8a,stroke-width:2px,color:#e8f4f8 style C fill:#2d3545,stroke:#4a6870,stroke-width:2px,color:#e8f4f8 style D fill:#2d2f3a,stroke:#4a5460,stroke-width:2px,color:#e8f4f8
위에서 아래로 칸마다 하는 일은 이렇다.
- 애플리케이션 계층(HTTP) — 「무엇을 달라 / 무엇을 줄게」라는 내용을 정한다. 그림의 HTTPS는 HTTP를 암호화해 보내는 방식이고, 뒤의 「HTTPS」 절에서 다룬다
- 전송 계층(TCP) — 그 내용이 끊기지 않고 순서대로 도착하게 한다
- 인터넷 계층(IP) — 어느 기계로 보낼지 주소를 보고 길을 찾는다
- 네트워크 인터페이스 계층(Ethernet/WiFi) — 랜선이나 무선으로 바로 옆 기계까지 실제로 넘긴다
HTTP 통신이 시작되기 전에 TCP 3-Way 핸드셰이크가 먼저 일어난다. 연결을 거는 쪽을 클라이언트(보통 브라우저), 받는 쪽을 서버라고 부른다.
SYN · ACK는 TCP가 연결을 열 때 주고받는 신호의 이름이다. 클라이언트가 「연결하자」(SYN)를 보내고, 서버가 「좋다, 나도 연결하자」(SYN-ACK)로 답하고, 클라이언트가 「확인했다」(ACK)로 마무리한다. 이 세 번이 끝나야 클라이언트와 서버가 「통신 준비 완료」를 서로 확인한 상태가 되고, 그다음에 HTTP 요청이 그 연결 위로 간다. 왜 두 번이 아니라 꼭 세 번이어야 하는지는 이 글에서 다루지 않는다.
참고: MDN - HTTP 개요. MDN은 Mozilla가 운영하는 웹 기술 문서 사이트다.
HTTP는 무상태(Stateless) 프로토콜
HTTP의 가장 중요한 특징 중 하나는 무상태성이다. 서버는 이전 요청을 기억하지 않는다.
[상태를 유지하는 경우]
1번째 요청: "안녕, 나는 홍길동이야"
서버: "아, 홍길동이구나. 기억할게!"
2번째 요청: "내 정보 보여줘"
서버: "너는 홍길동이니까 네 정보를 보여줄게"
---
[무상태인 경우 - HTTP]
1번째 요청: "안녕, 나는 홍길동이야"
서버: "알겠어" → (응답 후 홍길동을 잊음)
2번째 요청: "내 정보 보여줘"
서버: "너 누구야? 다시 말해봐"
위쪽은 서버가 앞 요청을 기억하는 세계, 아래쪽이 실제 HTTP다. HTTP 규약 자체는 1번째 요청과 2번째 요청을 이어 주지 않는다. 그래서 매번 「너 누구니?」를 다시 확인해야 하는 번거로움이 생긴다. 이 문제를 푸는 것이 쿠키와 세션이다. 둘의 동작은 뒤의 「쿠키와 세션」 절에서 자세히 본다. HTTP가 왜 일부러 기억하지 않게 설계됐는지는 이 글에서 다루지 않는다.
HTTP 메서드는 요청이 무슨 일을 하려는지 알려 준다
주요 HTTP 메서드
HTTP는 클라이언트가 어떤 작업을 하려는지 알려 주려고 메서드를 쓴다. 이름은 같지만 자바 클래스 안의 메서드와는 다르다. 요청 맨 앞에 붙는 GET · POST 같은 단어 하나가 HTTP 메서드다.
메서드가 일을 하는 대상을 리소스라고 부른다. 「123번 사용자 정보」, 웹 페이지, 그림 파일처럼 주소(URL)가 가리키는 대상 하나다. 아래 표의 CRUD 매핑 칸은 각 메서드가 데이터를 다루는 네 가지 기본 동작(CRUD — 만들기 · 읽기 · 고치기 · 지우기) 중 무엇에 해당하는지다.
| 메서드 | 용도 | CRUD 매핑 |
|---|---|---|
| GET | 리소스 조회 | Read |
| POST | 리소스 생성, 데이터 처리 | Create |
| PUT | 리소스 전체 수정 | Update |
| PATCH | 리소스 부분 수정 | Update |
| DELETE | 리소스 삭제 | Delete |
| OPTIONS | 지원하는 메서드 조회 | - |
| HEAD | GET과 같지만 바디 없이 헤더만 | - |
HEAD 줄의 헤더는 부가 정보, 바디는 실제 내용이다. 둘은 「HTTP 헤더와 바디」 절에서 다룬다.
POST vs PUT - 멱등성의 차이
실무에서 가장 헷갈리는 부분이 POST와 PUT의 차이다. 핵심은 멱등성(Idempotent)이다.
멱등성이란? 같은 요청을 여러 번 보내도 결과가 같은 성질이다.
POST (멱등하지 않다)
- 같은 요청을 3번 보내면 리소스가 3개 생성됨
예시: 계좌이체 1만원을 3번 요청
결과: 3만원 이체
---
PUT (멱등하다)
- 같은 요청을 3번 보내도 최종 상태는 같음
예시: "잔액을 70만원으로 변경"을 3번 요청
결과: 잔액은 70만원 (같음)
POST 예시에서 「이체」가 리소스 생성인 이유는, 이체 요청 하나가 서버에 「이체 기록」 한 건을 새로 만들기 때문이다. 세 번 보내면 이체 기록이 세 건 생기고 돈도 세 번 나간다. PUT은 「70만원으로 바꿔라」라는 최종 상태를 통째로 보내므로, 몇 번을 보내도 결과는 70만원 하나다.
PUT은 고치기만 하는 것이 아니다. 그 주소에 리소스가 없으면 새로 만들고, 있으면 보낸 내용으로 통째로 바꾼다(새로 만들었으면 서버는 201 Created로 답한다). 그래서 같은 PUT을 세 번 보내도 첫 번째가 만들고 나머지 두 번은 같은 내용으로 다시 바꿀 뿐이라, 리소스는 하나만 남는다. 아래 그림의 「리소스 1개만 존재」가 이 뜻이다.
그런데 같은 요청이 왜 여러 번 갈까? 네트워크가 불안정하면 요청은 서버에 도착했는데 응답이 늦거나 사라질 수 있다. 클라이언트는 응답을 못 받았으니 실패한 줄 알고, 사용자가 버튼을 다시 누르거나 프로그램이 같은 요청을 다시 보낸다. 서버 입장에서는 같은 요청이 두 번, 세 번 들어온다.
PATCH는 멱등하다고 보장되지 않는다. HTTP 규약을 적어 둔 표준 문서(RFC) 중 PATCH를 정한 RFC 5789는 PATCH를 「안전하지도 멱등하지도 않다」고 정해 두었다. HTTP에서 「안전하다」는 서버의 상태를 바꾸지 않는, 읽기만 하는 요청이라는 뜻이다(GET · HEAD · OPTIONS가 그렇다). 「나이를 1 늘려라」 같은 PATCH는 세 번 보내면 세 번 늘어난다. 반대로 GET은 규약이 「조회만 하는 메서드」로 정해 두었으니 서버를 그 뜻대로 짜면 몇 번을 불러도 상태가 바뀌지 않는다. 다만 규약은 약속이라, 서버 코드가 GET에서 데이터를 바꾸면 멱등성은 깨진다.
실무 적용:
- 결제, 이체 같은 중요한 작업 → 멱등성 필수 (PUT으로 설계할 수 있는지 먼저 보고, 안 되면 같은 요청이 두 번 와도 한 번만 처리하는 중복 방지 로직을 서버에 둔다)
- 댓글 작성, SNS 팔로우 → 멱등성 고려 (중복 클릭 방지 — 버튼이 두 번 눌려도 한 번만 반영되게 막는다)
- 단순 조회 → 규약상 멱등하다 (GET)
이체를 PUT으로 설계한다는 것은, 앞에서처럼 「이체 기록을 새로 만들어라」(POST)로 보내지 않고 클라이언트가 이체 번호를 먼저 정해 「이 번호의 이체 기록을 이 내용으로 둬라」(PUT /transfers/이체번호)로 보내는 것이다. 같은 번호로 세 번 와도 PUT은 그 자리를 같은 내용으로 덮을 뿐이라 기록은 하나다.
HTTP 메시지는 헤더와 바디로 나뉜다
헤더는 메타데이터, 바디는 실제 데이터
HTTP 메시지는 크게 헤더와 바디 두 부분으로 이뤄진다. 헤더에는 메타데이터 — 데이터 자체가 아니라 데이터에 대한 설명 — 가 들어가고, 바디에는 실제 데이터가 들어간다.
[개념]
헤더 = 편지 봉투에 적힌 정보 (보내는 사람, 받는 사람, 우편번호)
바디 = 편지 안에 담긴 실제 내용
자주 사용하는 HTTP 헤더
헤더는 한 줄에 하나씩 이름: 값 꼴로 적는다.
1. Content-Type - 바디의 데이터 형식 지정
Content-Type: application/json
Content-Type: text/html
Content-Type: image/png
Content-Type: multipart/form-data
값은 MIME 타입이라는 이름표이고 「큰 분류/세부 종류」 꼴로 적는다. 한 줄씩 보면 이렇다.
application/json— 바디가 JSON({"name": "홍길동"}처럼 중괄호와 따옴표로 데이터를 적는 글자 형식)이다text/html— 바디가 HTML 문서다image/png— 바디가 PNG 그림이다multipart/form-data— 바디가 여러 조각(글자 입력값, 파일)으로 나뉘어 있다. 파일 업로드 때 쓴다
2. Authorization - 인증 정보 전달
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
인증은 요청을 보낸 사람이 누구인지 확인하는 일이다. Bearer는 「이 토큰을 들고 있는 사람을 인증된 사람으로 봐 달라」는 방식 이름이고(Bearer 토큰), 뒤의 긴 글자가 로그인할 때 서버가 내려준 토큰이다. 헤더 이름은 Authorization(인가)인데 실제로 나르는 것은 인증 정보다. 이름이 어긋나 있어서 뒤의 「401과 403」 절에서 한 번 더 짚는다.
3. Cookie - 클라이언트가 저장한 쿠키 전송
Cookie: sessionId=abc123; userId=12345
쿠키는 서버가 브라우저에게 「저장해 뒀다가 다음에 올 때 같이 보내」라고 넘겨준 작은 데이터다. 값은 이름=값을 ;로 이어 적는다. 이 줄은 sessionId가 abc123, userId가 12345라는 쿠키 두 개를 보낸다. 쿠키가 어떻게 생기고 쓰이는지는 「쿠키와 세션」 절에서 본다.
multipart/form-data의 비밀
파일을 올릴 때 왜 application/json이 아니라 multipart/form-data를 쓸까?
그림 파일은 사람이 읽는 글자가 아니라 바이트를 그대로 늘어놓은 바이너리 데이터다. 그런데 JSON은 글자로 적는 형식이라 바이트를 그대로 넣을 수 없다. 그래서 JSON에 파일을 담으려면 먼저 Base64로 바꿔야 한다. Base64는 바이트를 영문자 · 숫자 · 기호 몇 개만으로 된 글자로 바꾸는 방식인데, 3바이트를 글자 4개로 바꾸기 때문에 크기가 4/3배, 즉 약 33% 커진다.
JSON (텍스트 기반)
이미지 바이너리 → Base64 인코딩 → 용량 33% 증가
→ 1MB 파일이 1.33MB로 증가
→ 인코딩/디코딩 시간 추가 소요
multipart/form-data (바이너리 그대로)
이미지 바이너리 → 그대로 전송 → 용량 최소화
→ 1MB 파일은 1MB 그대로
→ 추가 처리 없음
위 블록의 「인코딩」은 바이트를 Base64 글자로 바꾸는 일이고, 「디코딩」은 받는 쪽에서 그 글자를 다시 원래 바이트로 되돌리는 일이다. 보내는 쪽과 받는 쪽 모두 이 변환을 한 번씩 더 해야 한다.
multipart/form-data는 바디를 여러 조각으로 나누고 조각마다 형식을 따로 적을 수 있어서, 파일 조각에는 바이트를 바꾸지 않고 그대로 싣는다.
결론: 바이너리 데이터는 multipart/form-data로 보내는 것이 효율적이다.
HTTP 요청과 응답은 시작 줄 · 헤더 · 빈 줄 · 바디 순서로 적힌다
HTTP 요청 메시지
새 사용자를 만드는 요청을 예로 든다.
POST /api/users HTTP/1.1 ← 요청 라인
Host: example.com ← 헤더 시작
Content-Type: application/json
Authorization: Bearer abc123
Cookie: sessionId=xyz789
← 빈 줄 (구분자)
{"name": "홍길동", "age": 30} ← 바디
한 줄씩 따라가면 이렇다.
POST /api/users HTTP/1.1— 요청 라인이다. 세 부분이 띄어쓰기로 나뉜다.POST는 메서드,/api/users는 리소스의 경로(주소에서https://example.com을 뺀 뒷부분),HTTP/1.1은 HTTP 버전이다Host: example.com— 요청을 받을 서버의 이름(호스트)이다. 여기서 서버는 기계가 아니라 요청을 받는 프로그램을 가리킨다. 한 기계에 서버 프로그램 여러 개가 같은 IP 주소를 나눠 쓸 수 있어서, 그중 누구에게 온 요청인지 이 헤더로 가린다Content-Type·Authorization·Cookie— 앞 절에서 본 헤더들이다- 빈 줄 — 헤더가 끝났다는 표시다
{"name": "홍길동", "age": 30}— 바디다. 이름이 홍길동이고 나이가 30인 사용자를 JSON으로 적었다
조회(GET) 요청은 보통 바디 없이 요청 라인과 헤더만 보낸다. HTTP 규약(RFC 9110)은 GET 요청에 실린 바디에 정해진 의미가 없다고 적고, 서버가 지원한다고 밝히지 않았으면 보내지 말라고 한다.
HTTP 응답 메시지
HTTP/1.1 200 OK ← 상태 라인
Content-Type: application/json ← 헤더 시작
Set-Cookie: sessionId=xyz789
Content-Length: 32
← 빈 줄 (구분자)
{"id": 123, "name": "홍길동"} ← 바디
HTTP/1.1 200 OK— 상태 라인이다. HTTP 버전, 상태 코드(200), 그 코드의 짧은 이름(OK— 성공)이 차례로 온다. 상태 코드는 다음 절에서 다룬다Content-Type: application/json— 바디가 JSON이라는 뜻이다Set-Cookie: sessionId=xyz789— 서버가 브라우저에게 「이 쿠키를 저장해 둬」라고 시키는 헤더다. 브라우저는 다음 요청부터Cookie: sessionId=xyz789를 붙여 보낸다Content-Length: 32— 바디의 길이를 바이트 수로 적는다. 바디{"id": 123, "name": "홍길동"}은 영문 · 기호가 한 글자 1바이트, 한글이 UTF-8(글자를 바이트로 적는 규칙인 인코딩 중 하나)에서 한 글자 3바이트라 23 + 9 = 32바이트다- 빈 줄 뒤의 JSON — 바디다
핵심: 헤더와 바디는 빈 줄로 구분된다.
응답: HTTP/1.1 200 OK] C --> G[Host: example.com
Content-Type: application/json
Authorization: Bearer token] D --> H[헤더와 바디 구분자] E --> I[실제 데이터
JSON, HTML, 이미지 등] style A fill:#2d4a5e,stroke:#4a90a4,stroke-width:2px,color:#e8f4f8 style B fill:#2d3e50,stroke:#4a7c8a,stroke-width:2px,color:#e8f4f8 style C fill:#2d3e50,stroke:#4a7c8a,stroke-width:2px,color:#e8f4f8 style D fill:#4a3a2a,stroke:#8a6a4a,stroke-width:2px,color:#e8f4f8 style E fill:#2d3e50,stroke:#4a7c8a,stroke-width:2px,color:#e8f4f8
HTTP 상태 코드는 첫 자리로 결과의 종류를 알려 준다
상태 코드 분류
HTTP 응답은 상태 코드로 결과를 알려 준다. 첫 자리 숫자가 종류를 가른다.
| 범위 | 의미 | 예시 |
|---|---|---|
| 1xx | 정보 (Informational) | 100 Continue |
| 2xx | 성공 (Success) | 200 OK, 201 Created |
| 3xx | 리다이렉션 (Redirection) | 301 Moved Permanently |
| 4xx | 클라이언트 오류 (Client Error) | 400 Bad Request, 404 Not Found |
| 5xx | 서버 오류 (Server Error) | 500 Internal Server Error |
- 1xx 정보 — 아직 처리 중이라는 중간 알림이다.
100 Continue는 「요청 앞부분은 받았고 거절하지 않았으니 바디를 계속 보내라」는 뜻이다 - 3xx 리다이렉션 — 「찾는 것은 다른 주소에 있으니 그리로 가라」는 뜻이다. 브라우저는 응답에 적힌 새 주소로 다시 요청한다
참고: MDN - HTTP 상태 코드
자주 보는 상태 코드
| 상태 코드 | 의미 | 설명 |
|---|---|---|
| 2xx - 성공 | ||
200 OK |
요청 성공 | 요청이 정상적으로 처리됨 |
201 Created |
리소스 생성 성공 | 새로운 리소스가 생성됨 |
204 No Content |
성공, 반환 내용 없음 | 요청은 성공했지만 응답 바디가 없음 |
| 3xx - 리다이렉션 | ||
301 Moved Permanently |
영구 이동 | 리소스가 영구적으로 이동됨 (SEO에 영향) |
302 Found |
임시 이동 | 리소스가 일시적으로 다른 위치에 있음 |
304 Not Modified |
수정되지 않음 | 캐시된 리소스를 그대로 사용 가능 |
| 4xx - 클라이언트 오류 | ||
400 Bad Request |
잘못된 요청 | 요청 구문이 잘못됨 |
401 Unauthorized |
인증 필요 | 인증되지 않은 사용자 |
403 Forbidden |
권한 없음 | 접근 권한이 없음 |
404 Not Found |
리소스 없음 | 요청한 리소스를 찾을 수 없음 |
| 5xx - 서버 오류 | ||
500 Internal Server Error |
서버 내부 오류 | 서버에서 예상치 못한 오류 발생 |
502 Bad Gateway |
게이트웨이 오류 | 게이트웨이나 프록시 서버가 잘못된 응답 수신 |
503 Service Unavailable |
서비스 이용 불가 | 서버가 일시적으로 요청을 처리할 수 없음 |
표에 나온 말 셋을 풀면 이렇다.
- SEO — 검색 결과에 내 사이트가 더 잘 보이게 하는 일이다(SEO). 주소를 옮겼을 때 301을 주면 검색 엔진 로봇이 원래 주소를 새 주소로 바꿔 기억한다. 302(임시 이동)는 새 주소를 기억하지 않는다
- 캐시된 리소스 — 브라우저가 전에 받아 보관해 둔 응답이다(캐시). 304는 「바뀐 게 없으니 보관해 둔 것을 써라」는 뜻이다
- 게이트웨이 · 프록시 서버 — 클라이언트와 실제 서버 사이에 끼어 요청을 대신 넘겨 주는 중간 서버다(프록시 서버). 502는 이 중간 서버가 뒤쪽 서버에게서 잘못된 응답을 받았다는 뜻이다
401 vs 403 - 헷갈리는 차이
많은 개발자가 헷갈려 하는 두 상태 코드의 차이는 이렇다.
401 Unauthorized = "너 누구야? 로그인 먼저 해"
└─ 인증(Authentication) 필요
└─ 예시: 로그인 안 한 상태에서 마이페이지 접근
403 Forbidden = "너 누군지 아는데, 권한이 없어"
└─ 인가(Authorization) 실패
└─ 예시: 일반 사용자가 관리자 페이지 접근
인증은 「누구인가」를 확인하는 일이고, 인가는 「그 사람이 이것을 해도 되는가」를 정하는 일이다. 헷갈리는 이유는 이름이 어긋나 있어서다. 401의 이름 Unauthorized는 「인가되지 않음」으로 읽히지만 실제 뜻은 「인증이 필요하다」이고, 앞에서 본 Authorization 헤더도 이름은 인가지만 나르는 것은 인증 정보다. 이름이 아니라 뜻으로 외운다 — 401은 「누군지 모른다」, 403은 「누군지 알지만 안 된다」다.
쿠키와 세션으로 무상태인 HTTP 위에서 로그인을 유지한다
쿠키 vs 세션
HTTP는 무상태 프로토콜이지만, 쿠키와 세션으로 상태를 유지할 수 있다.
쿠키 (Cookie)
└─ 클라이언트의 브라우저에 저장
└─ 클라이언트가 직접 접근 가능
└─ 보안에 취약할 수 있음
세션 (Session)
└─ 서버의 메모리/DB에 저장
└─ 클라이언트는 세션ID만 보유
└─ 상대적으로 안전
쿠키에는 서버가 넘겨준 값(예: userId=12345)이 브라우저에 그대로 저장된다. 브라우저에 있으니 사용자나 브라우저 안에서 도는 자바스크립트(자바와 이름만 비슷한 다른 언어로, 웹 페이지의 동작을 맡는다)가 값을 읽거나 바꿀 수 있고, 그래서 보안에 취약할 수 있다.
세션은 사용자 정보를 서버의 메모리나 DB에 두고, 브라우저에는 그 정보를 찾는 번호(세션ID)만 쿠키로 준다. 정보 자체는 서버 밖으로 나가지 않으니 상대적으로 안전하다. 다만 세션ID를 빼앗기면 이야기가 달라진다. 이 문제는 아래 「세션 하이재킹」에서 다룬다.
참고: MDN - HTTP 쿠키
로그인 세션 동작 방식
(브라우저) participant S as 서버 C->>S: 1. POST /login
(아이디, 비밀번호) S->>S: 2. 인증 성공
세션 생성 (ID: abc123) S->>C: 3. Set-Cookie: sessionId=abc123 C->>C: 4. 쿠키 저장 Note over C,S: 다음 요청부터 브라우저가 자동으로 쿠키 전송 C->>S: 5. GET /mypage
Cookie: sessionId=abc123 S->>S: 6. 세션 확인
(abc123 = 홍길동) S->>C: 7. 200 OK
(홍길동의 데이터)
- 브라우저가 아이디와 비밀번호를 POST로 보낸다
- 서버가 비밀번호를 확인한 뒤 세션을 만든다. 「세션을 만든다」는 서버 메모리에
abc123 → 홍길동같은 칸을 하나 마련한다는 뜻이다 - 서버가 응답에
Set-Cookie: sessionId=abc123을 실어 그 번호를 브라우저에게 준다 - 브라우저가 쿠키를 저장한다
- 다음 요청부터는 브라우저가 알아서
Cookie: sessionId=abc123을 붙여 보낸다. 개발자가 코드로 붙이지 않아도 된다 - 서버는
abc123칸을 찾아 「이 요청은 홍길동」임을 안다 - 홍길동의 데이터를 돌려준다
세션 하이재킹 대응 방법
만약 해커가 세션ID를 탈취하면 그 사용자인 척할 수 있다. 서버는 쿠키에 적힌 번호만 보고 사람을 알아보기 때문이다. 빼앗기는 길은 대표적으로 둘이다. 암호화되지 않은 통신을 중간에서 엿보는 것, 그리고 페이지에 심은 악성 스크립트로 브라우저의 쿠키를 읽어 가는 것이다. 이를 막는 방법은 이렇다.
1. HTTPS 사용
HTTP (평문) → 세션ID가 그대로 노출
HTTPS (암호화) → 세션ID가 암호화되어 전송
평문은 암호화하지 않은 글 그대로라는 뜻이다. HTTPS는 HTTP를 암호화해 보내는 방식이고, 아래 「HTTPS」 절에서 다룬다.
2. HttpOnly 쿠키 속성
자바스크립트로 쿠키를 읽지 못하게 막는다.
Set-Cookie: sessionId=abc123; HttpOnly
→ 브라우저 안의 자바스크립트는 원래 document.cookie로 쿠키를 읽을 수 있다. HttpOnly가 붙은 쿠키는 읽을 수 없어서, XSS 공격(페이지에 악성 스크립트를 심어 다른 사용자 브라우저에서 실행시키는 공격)으로 쿠키를 빼내기 어려워진다.
3. Secure 쿠키 속성
Set-Cookie: sessionId=abc123; Secure
→ HTTPS 연결에서만 쿠키를 보낸다.
4. 세션 타임아웃
일정 시간(예: 30분) 동안 활동이 없으면 세션 만료
→ 탈취된 세션도 곧 무효화됨
HTTPS는 암호화와 서버 신원 확인으로 HTTP를 안전하게 만든다
HTTP vs HTTPS
HTTP (HyperText Transfer Protocol)
└─ 평문 전송
└─ 데이터가 그대로 노출
└─ 중간에서 가로채기 가능
HTTPS (HTTP Secure)
└─ 암호화 전송 (TLS/SSL)
└─ 데이터 보호
└─ 서버 신원 확인
HTTPS는 HTTP 메시지를 TLS라는 규약으로 암호화된 연결 위에서 주고받는 것이다. SSL은 TLS의 예전 이름이라 둘을 함께 적었다.
참고: MDN - HTTPS
HTTPS의 두 가지 역할
1. 암호화 - 데이터를 안전하게 전송
평문: "비밀번호는 1234입니다"
암호화: "xK9#mP2@qL7$nR4..."
2. 신원 확인 - 이 서버가 진짜인지 확인
진짜 은행 사이트처럼 꾸민 가짜 사이트(피싱 사이트)를 걸러내는 역할이다. 가짜 사이트는 진짜 사이트의 이름으로 된 올바른 인증서를 내놓지 못하고, 브라우저는 인증서를 확인해서 경고를 띄운다. 어떻게 확인하는지가 다음 절이다.
인증서 체인의 비밀
HTTPS는 인증서 체인(Certificate Chain)으로 서버의 신원을 보증한다. 인증서는 「이 서버는 example.com이 맞다」라고 적고 인증 기관(CA)이 서명해 준 전자 문서다. 서명은 「내가 보증한다」는 도장이고, 그 도장이 진짜인지는 누구나 확인할 수 있다. 인증서 체인은 이 보증이 사슬처럼 이어진 것이다.
최상위 인증 기관] -->|서명| B[Intermediate CA
중간 인증 기관] B -->|서명| C[서버 인증서
example.com] D[브라우저] -.신뢰.-> A D -->|검증| C C -->|체인 확인| B B -->|체인 확인| A E[결과: 안전한 사이트] style A fill:#2d4a3a,stroke:#4a8a6a,stroke-width:3px,color:#e8f4f8 style B fill:#2d3e4a,stroke:#4a7c8a,stroke-width:2px,color:#e8f4f8 style C fill:#2d3545,stroke:#4a6870,stroke-width:2px,color:#e8f4f8 style D fill:#3d3a4a,stroke:#6a6a8a,stroke-width:2px,color:#e8f4f8 style E fill:#2a4a3a,stroke:#4aaa6a,stroke-width:2px,color:#e8f4f8
최상위 인증 기관(Root CA)이 중간 인증 기관(Intermediate CA)의 인증서에 서명하고, 중간 인증 기관이 서버 인증서에 서명한다.
검증 과정:
1. 사용자가 https://example.com 접속
2. 서버가 인증서 전송
"나는 example.com이고, 이 인증서로 증명합니다"
(자기 인증서를 맨 앞에, 그것을 서명한 중간 기관의 인증서를 그 뒤에 붙여 보낸다)
3. 브라우저 검증
(1) 서버 인증서의 서명을 Intermediate CA로 검증
(2) Intermediate CA의 서명을 Root CA로 검증
(3) Root CA는 브라우저가 이미 신뢰함
4. 검증 성공 → 자물쇠 아이콘 표시
서명하는 쪽은 인증서 내용을 해시로 짧게 줄인 값을 자기만 가진 열쇠(비밀키)로 잠가 서명으로 붙인다. 누구에게나 나눠 준 짝 열쇠(공개키)로만 그 잠금이 풀린다 — 이 두 열쇠는 다음 절에서 다시 본다. 「서명을 검증한다」는 서명한 쪽의 공개키로 서명을 풀어 나온 해시 값과, 인증서 내용을 직접 해시한 값이 같은지 보는 일이다. 같으면 그 인증서는 서명한 쪽이 보증한 것이 맞다. 서버 인증서를 검증할 때 쓰는 중간 기관의 공개키는 서버가 함께 보낸 중간 기관 인증서 안에 들어 있다. 3단계의 (3)이 성립하는 이유는 브라우저가 믿을 만한 Root CA의 인증서 목록을 처음부터 들고 나오기 때문이다. 사슬을 따라 올라가서 그 목록에 있는 Root CA에 닿으면 믿는다.
가짜 인증서는 왜 통하지 않을까?
해커가 가짜 인증서를 만들어도 CA의 디지털 서명을 위조할 수 없다.
디지털 서명 원리:
- Root CA는 비밀키로 서명
- 브라우저는 공개키로 검증
- 해커는 Root CA의 비밀키를 모름
- 따라서 정상적인 서명을 만들 수 없음
결과:
브라우저가 "이 사이트는 안전하지 않습니다" 경고 표시
비밀키와 공개키는 짝을 이루는 열쇠 두 개다. 한쪽으로 잠근 것은 다른 쪽으로만 풀린다. CA는 비밀키를 숨겨 두고 공개키는 누구에게나 나눠 준다. 브라우저가 CA의 공개키로 서명을 풀었을 때 내용이 맞아떨어지면, 그 서명은 CA의 비밀키로 만든 것이다. 해커는 비밀키가 없으니 CA의 공개키로 풀리는 서명을 만들 수 없고, 아무렇게나 만든 서명은 풀어도 내용이 맞지 않아 검증에 실패한다.
실무에서는 멱등성 · 상태 코드 · 보안 헤더 · 쿠키 속성 네 가지를 챙긴다
아래 예시 중 2~4번은 Express로 쓴 서버 코드다. Express는 Node.js에서 자바스크립트로 웹 서버를 만들 때 쓰는 프레임워크다. 요청을 받고 응답을 보내는 반복되는 부분은 Express가 맡고, 나는 「이 요청이 오면 이렇게 답한다」만 채워 넣는다. 예시에 나오는 app은 express()를 불러 만든 서버 객체이고(만드는 줄은 생략했다), req는 들어온 요청, res는 내보낼 응답 객체다.
1. API 설계 시 멱등성 고려하기
# 이렇게 하지 않는다: POST로 수정
POST /api/users/123/update
{
"name": "홍길동"
}
# 이렇게 한다: PUT 사용
PUT /api/users/123
{
"name": "홍길동"
}
/api/users/123은 「123번 사용자」라는 리소스의 주소다. 위쪽은 수정을 POST로 보내서, 주소에 update라는 동작 이름까지 붙였다. 아래쪽은 같은 리소스 주소에 PUT을 써서 「이 사용자를 이 내용으로 바꿔라」를 나타낸다. 앞에서 본 대로 PUT은 멱등해서, 네트워크 문제로 두 번 가도 결과가 같다. 일부만 바꾸는 PATCH도 쓸 수 있지만, PATCH는 멱등하다고 보장되지 않으니 같은 요청이 두 번 와도 괜찮은지 따로 확인한다.
2. 적절한 상태 코드 사용하기
// 이렇게 하지 않는다: 모든 응답에 200 사용
res.status(200).json({ error: "사용자를 찾을 수 없음" });
// 이렇게 한다: 의미 있는 상태 코드
res.status(404).json({ error: "사용자를 찾을 수 없음" });
res.status(200)— 응답의 상태 코드를 200으로 정한다.json({ error: "..." })— 중괄호 안의 객체를 JSON으로 바꿔 바디에 담아 보낸다
위쪽은 실패했는데 200(성공)을 보낸다. 받는 쪽 프로그램은 보통 상태 코드부터 보고 성공인지 판단하므로, 200을 받으면 성공으로 처리하고 넘어가 버린다. 실패를 알아채려면 바디를 열어 error가 있는지 일일이 뒤져야 한다. 아래쪽처럼 404를 주면 바디를 열기 전에 「찾는 게 없다」를 안다.
3. 보안 헤더 설정하기
// Express.js 예시
app.use((req, res, next) => {
// HTTPS만 허용
res.setHeader('Strict-Transport-Security', 'max-age=31536000');
// 브라우저가 Content-Type을 멋대로 추측하지 못하게 막기
res.setHeader('X-Content-Type-Options', 'nosniff');
// 클릭재킹 방지
res.setHeader('X-Frame-Options', 'DENY');
next();
});
app.use((req, res, next) => { ... })— 모든 요청이 실제 처리 코드에 닿기 전에 이 함수를 먼저 거치게 등록한다(미들웨어).(req, res, next) => { ... }는 매개변수 셋을 받는 함수를 짧게 적는 화살표 함수 문법이다res.setHeader('Strict-Transport-Security', 'max-age=31536000')— 응답에 헤더 하나를 붙인다. 이 헤더를 받은 브라우저는 앞으로 이 사이트에 HTTPS로만 접속하고, HTTP로 접속하려 하면 HTTPS로 바꿔 보낸다.max-age의 단위는 초라서 31536000초, 즉 1년 동안 이를 기억한다res.setHeader('X-Content-Type-Options', 'nosniff')— 브라우저가 바디 내용을 보고 형식을 추측하지 말고Content-Type에 적힌 형식을 그대로 따르게 한다. 예를 들어text/plain(그냥 글자)으로 보낸 응답에 HTML이 섞여 있어도 HTML로 그리지 않는다. 사용자가 올린 파일이 웹 페이지로 실행되는 XSS를 막는 데 도움이 된다res.setHeader('X-Frame-Options', 'DENY')— 이 페이지를 어떤 페이지 안의 프레임으로도 띄우지 못하게 막는다(같은 사이트 안에서도 막는다). 클릭재킹은 공격자 페이지에 내 사이트를 프레임으로 겹쳐 띄우고 사용자가 엉뚱한 버튼을 누르게 속이는 공격인데, 프레임으로 못 띄우면 이 수법이 막힌다next()— 이 함수가 할 일을 마쳤으니 다음 처리 단계로 요청을 넘긴다. 부르지 않으면 요청이 여기서 멈춘다
4. 쿠키 보안 강화하기
// 안전한 쿠키 설정
res.cookie('sessionId', 'abc123', {
httpOnly: true, // JavaScript 접근 차단
secure: true, // HTTPS에서만 전송
sameSite: 'strict', // CSRF 공격 방지
maxAge: 1800000 // 30분 후 만료
});
res.cookie('sessionId', 'abc123', { ... })— 응답에Set-Cookie헤더를 붙여sessionId=abc123쿠키를 내려준다. 세 번째 인자{ 이름: 값, ... }은 자바스크립트에서 그 자리에서 객체를 만드는 문법(객체 리터럴)이고, 여기에 쿠키 속성을 담는다httpOnly: true— 앞에서 본 HttpOnly 속성이다. 자바스크립트가 쿠키를 읽지 못한다secure: true— Secure 속성이다. HTTPS 요청에만 쿠키를 싣는다sameSite: 'strict'— 쿠키를 만든 사이트에서 시작된 요청에만 쿠키를 싣는다. 다른 사이트가 사용자 몰래 요청을 대신 보내게 하는 CSRF 공격에는 쿠키가 실리지 않는다maxAge: 1800000— 쿠키가 살아 있는 시간이다. Express에서 이 값의 단위는 밀리초라서 1,800,000밀리초 = 1,800초 = 30분이다
정리
HTTP는 단순해 보이지만 그 안에 웹의 핵심 원리가 담겨 있다. 이 글에서 다룬 내용을 정리하면 이렇다.
- HTTP는 하이퍼텍스트 링크를 위해 탄생했고, 계속 진화하고 있다
- TCP 3-Way 핸드셰이크 후 HTTP 통신이 시작된다
- HTTP는 무상태 프로토콜이며, 쿠키와 세션으로 상태를 유지한다
- POST/PUT을 고를 때 멱등성을 따진다. PATCH는 멱등하다고 보장되지 않는다
- 헤더는 메타데이터, 바디는 실제 데이터다
- 상태 코드로 요청 결과를 명확히 전달한다
- HTTPS는 암호화와 신원 확인 두 가지 역할을 한다
- 인증서 체인으로 서버의 진위를 확인한다
참고 자료
- MDN - HTTP 개요
- MDN - HTTP 요청 메서드
- MDN - HTTP 상태 코드
- MDN - HTTP 쿠키
- MDN - HTTPS
- RFC 5789 - PATCH Method for HTTP
- RFC 9110 - HTTP Semantics
자신만의 철학을 만들어가는 중입니다.
댓글남기기