HTTP의 특징 — 비연결성과 무상태
앞 글에서 HTTP의 일은 통로가 뚫린 다음에 시작되고, 메시지를 소켓에 넘기는 순간 끝난다고 정리했다. 그러면 곧바로 다음 질문이 남는다.
요청·응답 한 쌍이 끝난 뒤, 서버는 그 클라이언트에 대해 무엇을 붙들고 있는가?
HTTP의 답은 "아무것도"에 가깝다. 그리고 이 "아무것도"가 두 겹으로 되어 있다는 것이 이 글의 내용이다. 연결을 붙들지 않는 것이 비연결성이고, 기억을 붙들지 않는 것이 무상태다. 이 둘은 자주 한 덩어리로 뭉쳐 설명되는데, 사실 목적도 다르고 지금 적용되는 정도도 다르다.
순서는 이렇게 간다 — 두 특징이 서로 다른 축이라는 것(정의) → 왜 붙들지 않기로 했는가(이유) → 붙들지 않은 대가를 어디서 치를 것인가(판단).
HTTP의 정의·메시지 형식·계층 경계는 HTTP — 무엇을 정하는 규약인가에서 다뤘다. 이 글은 그 내용을 전제로 시작한다.
1. 정의 — 두 특징은 서로 다른 축이다
1-1. 붙들지 않는 것이 두 가지다
먼저 둘을 갈라놓는 것부터 한다. 뭉뚱그리면 뒤의 논의가 전부 흐려진다.
| 비연결성 (Connectionless) | 무상태 (Stateless) | |
|---|---|---|
| 붙들지 않는 것 | 연결 | 맥락 |
| 한 문장 | 요청·응답이 끝나면 연결을 유지하지 않는다 | 이전 요청에서 무슨 일이 있었는지 기억하지 않는다 |
| 목적 | 서버 자원을 아끼기 위해 | 서버를 교체 가능하게 만들기 위해 |
| 층위 | 통로를 어떻게 다룰 것인가 | 요청을 어떻게 해석할 것인가 |
| 지금도 그대로인가 | 완화됐다 (Keep-Alive) | 그대로다 |
마지막 줄이 중요하다. 두 특징은 운명을 같이하지 않았다. 비연결성은 실용적인 이유로 상당히 느슨해진 반면, 무상태는 오히려 더 강하게 지켜지는 쪽으로 갔다. 그래서 "HTTP는 연결도 안 하고 기억도 안 한다"는 식으로 한 묶음으로 외우면 곧 현실과 어긋난다.
그럼 무엇이 어디까지 완화됐는지 보자. 그전에 비연결성이라는 말부터 정리해야 한다. 이 단어가 가장 자주 오해되는 지점이기 때문이다.
1-2. 오해 정정 ① — 비연결성은 "연결 없이 보낸다"가 아니다
"비연결성"이라는 이름 때문에 연결을 맺지 않고 데이터를 그냥 던진다는 그림을 떠올리기 쉽다. 그건 HTTP가 아니라 UDP의 성격이다.
HTTP는 정반대다. 믿을 만한 양방향 바이트 스트림이 이미 뚫려 있다는 것을 전제로 시작한다. 연결이 없으면 한 글자도 보낼 수 없다. 요청을 보내려면 연결이 반드시 먼저 있어야 하고, 그 위에서만 메시지가 오간다.
그러면 "비"연결성이라는 말은 무엇을 부정하는 것인가?
비연결성이 부정하는 것은 연결의 존재가 아니라, 연결의 지속이다.
정확히는 응용 계층의 태도를 가리키는 말이다. 연결은 쓰되, 그것을 이 클라이언트와의 지속적인 관계로 취급하지 않겠다는 것. 통로가 필요할 때 빌려 쓰고, 볼일이 끝나면 미련 없이 놓는다. 전송 계층에 연결이 있느냐 없느냐를 말하는 게 아니다.
연결 자체를 만들고 유지하는 쪽의 이야기는 TCP는 어떻게 신뢰성을 만드는가에서 다룬다.
1-3. 연결을 안 끊으면 응답이 끝난 걸 어떻게 아는가
초기 HTTP는 이 태도를 아주 단순하게 구현했다. 응답을 다 보내면 서버가 연결을 끊었다. 클라이언트 입장에서는 편했다. 연결이 닫히는 순간이 곧 "메시지 끝"이었기 때문이다. 별도의 표시가 필요 없었다.
여기서 문제가 생긴다. 연결을 매번 새로 만드는 것이 아까워 하나의 연결을 계속 쓰기로 하면, 메시지의 끝을 알려주던 신호가 사라진다. 연결이 안 닫히니 클라이언트는 응답이 끝난 건지 아직 더 올 게 있는 건지 알 수 없다.
그래서 메시지 안에 끝을 명시하는 방법이 필요해졌다. 두 가지가 있다.
① 길이를 미리 알려준다 — Content-Length
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 23
{"id":42,"status":"ok"}
"바디는 정확히 23바이트다." 클라이언트는 23바이트를 읽고 나면 더 기다리지 않고 다음 응답을 기대하면 된다. 연결이 열려 있든 말든 상관없다.
② 길이를 모를 때는 덩어리로 쪼갠다 — Transfer-Encoding: chunked
응답을 만들면서 동시에 내보내야 하는 경우, 다 만들기 전에는 총 길이를 알 수 없다. 이때는 덩어리마다 자기 길이를 앞에 붙이고, 마지막에 길이 0인 덩어리로 끝을 알린다.
HTTP/1.1 200 OK
Content-Type: application/json
Transfer-Encoding: chunked
9
{"id":42,
e
"status":"ok"}
0
9와 e는 뒤따르는 덩어리의 길이(16진수)이고, 0이 "더 없다"는 뜻이다. 길이를 미리 몰라도 끝을 알려줄 수 있다.
정리하면 이렇다.
연결 종료가 담당하던 "여기까지"라는 신호를, 메시지 자신이 떠안게 된 것이다.
이 변화가 뒤에 나올 이야기의 핵심을 미리 보여준다. 각 요청·응답이 스스로 완결되게 만들면, 연결은 있어도 그만 없어도 그만인 것이 된다.
1-4. 오해 정정 ② — Keep-Alive는 원칙을 깬 것이 아니다
연결을 재사용하는 방식이 표준이 되면서 자주 나오는 말이 있다. "그럼 HTTP는 더 이상 비연결성이 아닌 것 아닌가?"
<!-- IMAGE: 위쪽에 비연결성과 무상태 두 축의 정의·목적을 나란히 대비. 아래쪽에 HTTP/1.0의 연결-요청응답-종료 반복 타임라인과 HTTP/1.1의 연결 하나 위에 요청응답 여러 번 후 유휴 타임아웃 타임라인을 대비. 같은 연결로 온 요청들도 서로를 기억하지 못한다는 표시 포함 -->예외를 만든 게 아니라 최적화를 얹은 것이다. 근거는 세 가지다.
- 각 요청은 여전히 독립적으로 완결된다. 1-3절에서 본 것처럼 메시지가 스스로 끝을 알리므로, 앞 요청과 뒤 요청 사이에 이어지는 것이 없다.
- 연결은 유휴 시간이 지나면 서버가 그냥 닫는다. 클라이언트에게 물어보지도, 무엇을 정리하지도 않는다.
- 닫혀도 아무것도 잃지 않는다. 다음 요청은 새 연결을 맺고 그대로 처리된다.
세 번째가 결정적이다. 닫혀도 잃는 것이 없다는 사실 자체가 원칙이 살아 있다는 증거다. 만약 연결이 끊겼을 때 무언가가 사라진다면, 그건 서버가 그 연결에 무언가를 매달아 두었다는 뜻이 된다. 그때야말로 비연결성이 깨진 것이다.
연결은 다시 만들기 비싼 통로를 잠깐 재활용하는 캐시일 뿐, 서버가 붙들고 있는 관계가 아니다.
캐시는 비워져도 정확성에 영향을 주지 않는다. 연결도 그렇다. 그래서 완화된 것은 "매번 새로 만든다"는 구현 방식이지, "관계를 붙들지 않는다"는 태도가 아니다.
연결 재사용이 실제로 어떤 문제를 남겼고 그 뒤 버전들이 그걸 어떻게 다뤘는지는 HTTP 버전별 차이에서 다룬다.
1-5. 같은 연결로 와도 서버는 앞의 요청을 기억하지 않는다
여기서 두 축의 차이가 눈에 보인다. 연결 하나로 두 요청이 연달아 갔다고 하자.
GET /me HTTP/1.1
Host: api.example.com
Cookie: session=8f3d9a1c
Accept: application/json
GET /me/orders HTTP/1.1
Host: api.example.com
Cookie: session=8f3d9a1c
Accept: application/json
방금 /me로 자기가 누구인지 확인받은 클라이언트가, 바로 다음 요청에서 똑같은 인증 정보를 다시 실어 보낸다. 같은 연결인데도 그렇다.
빠뜨리면 어떻게 될까. 두 번째 요청에서 Cookie 줄을 지우면 서버는 그냥 401을 돌려준다. "방금 그 사람"이라는 정보가 서버 어디에도 없기 때문이다.
연결이 이어져 있다는 것과 맥락이 이어져 있다는 것은 전혀 다른 문제다.
비연결성은 완화됐지만 무상태는 그대로라는 말의 의미가 이것이다. 통로는 재활용해도, 그 통로를 타고 온 요청들은 서로를 모른다. 서버가 보기에 매 요청은 처음 보는 요청이다.
그러면 왜 이렇게까지 기억하지 않기로 한 걸까.
2. 이유 — 왜 붙들지 않기로 했는가
2-1. 연결을 붙들지 않는 이유 — 자원
이쪽은 비교적 단순하다. 연결 하나는 공짜가 아니다. 서버 쪽에 소켓과 파일 디스크립터, 송수신 버퍼, 연결 상태를 담는 자료구조가 잡힌다.
문서를 하나 받아 읽는 동안 사용자는 대개 아무 요청도 보내지 않는다. 그동안 연결을 열어두면 아무 일도 하지 않는 연결이 자원을 점유한다. 동시 접속자가 늘어날수록 이 낭비는 그대로 동시 처리 가능한 사용자 수의 상한이 된다.
그래서 초기의 선택은 "볼일이 끝나면 즉시 놓는다"였다. 다만 1-4절에서 봤듯 이 선택은 연결을 새로 만드는 비용과 맞바꾼 것이었고, 그 균형점이 나중에 조정된다. 유휴 타임아웃은 그 조정의 결과다. 잠깐은 붙들되, 놀고 있는 시간이 길어지면 놓는다.
2-2. 기억을 붙들지 않는 이유 — 서버를 교체 가능하게 만들기 위해
이쪽이 훨씬 중요하다. 자원 절약 같은 미시적 이유가 아니라, 웹이 지금처럼 커질 수 있게 만든 설계 결정이기 때문이다.
핵심은 한 문장이다.
서버가 요청 간 맥락을 붙들지 않으면, 서버들이 서로 교체 가능한 부품이 된다.
어떤 요청이 어느 서버로 가든 결과가 같다면, 서버는 이름을 가진 존재가 아니라 번호표를 뽑고 들어오는 일꾼이 된다. 이 성질에서 네 가지가 따라 나온다.
<!-- IMAGE: 왼쪽은 세션을 서버 메모리에 뒀을 때(사용자가 서버 3에만 묶임, Sticky Session, 무너지는 네 가지), 오른쪽은 상태를 밖으로 뺐을 때(세 서버 모두 처리 가능, 공유 세션 스토어 / 토큰, 성립하는 네 가지) -->- 부하 분산이 실제로 작동한다. 로드 밸런서가 지금 가장 한가한 서버로 요청을 보낼 수 있다. "이 사용자는 저 서버여야 한다"는 제약이 없다.
- 수평 확장이 즉시 먹힌다. 서버를 한 대 더 붙이면 그 순간부터 기존 사용자의 요청도 그리로 갈 수 있다. 예열이나 인계 과정이 필요 없다.
- 장애가 격리된다. 서버 한 대가 죽어도 그 순간 처리 중이던 요청만 실패한다. 다음 요청은 다른 서버가 받아 정상 처리한다.
- 무중단 배포가 된다. 서버를 한 대씩 내렸다 올려도 사용자는 아무것도 눈치채지 못한다.
네 가지가 전부 같은 뿌리에서 나온다. "이 사용자는 반드시 저 서버가 처리해야 한다"는 묶임이 없다는 것.
2-3. 반대로 하면 넷이 순서대로 무너진다
이 논리는 뒤집어 보면 더 선명하다. 세션을 서버 메모리에 두는 순간 어떻게 되는지 보자. 로그인 정보를 서버 3의 메모리 안에 담아두었다고 하자.
- 이제 그 사용자의 요청은 반드시 서버 3으로 가야 한다. 다른 서버는 그를 모른다. 그래서 로드 밸런서에 Sticky Session을 걸게 된다. 부하 분산이라는 이름만 남고 실제 분산은 사라진다. 서버 3이 아무리 바빠도 그리로 보내야 한다.
- 부하가 몰려 서버를 늘려도 기존 사용자는 새 서버로 옮겨갈 수 없다. 새로 접속하는 사용자만 그리로 간다. 급한 불을 꺼야 할 때 확장이 즉시 듣지 않는다.
- 서버 3이 죽으면 거기 붙어 있던 사용자 전원이 로그아웃된다. 서버 한 대의 장애가 사용자 경험의 장애로 그대로 번진다.
- 배포할 때마다 그 서버의 세션이 통째로 사라진다. 배포를 조심스럽게, 사용자가 적은 시간에 하게 된다.
네 가지 장점이 나온 순서 그대로 무너진다. 우연이 아니라 전부 "묶임"이라는 하나의 원인에서 나온 결과이기 때문이다.
2-4. 오해 정정 ③ — "REST는 무상태인데 로그인은 어떻게 하나"
여기서 늘 걸리는 지점이 있다. 무상태라면서 로그인은 유지되고, DB에는 사용자와 주문이 멀쩡히 저장돼 있다. 모순처럼 보인다.
모순이 아니다. 무상태가 금지하는 것을 잘못 잡고 있어서 그렇다.
무상태가 금지하는 것은 상태 그 자체가 아니라, 상태가 특정 서버의 메모리에 있는 것이다.
DB에 데이터가 있는 건 당연하다. 그건 어느 서버에서 접근해도 같은 곳이므로 서버를 특정 사용자에게 묶지 않는다. 문제가 되는 건 서버 인스턴스마다 다른 내용을 들고 있어서, 어느 서버로 가느냐에 따라 결과가 달라지는 상태다.
그래서 "HTTP는 상태가 없다"보다는 이렇게 쓰는 편이 정확하다.
서버 인스턴스가 요청과 요청 사이에 맥락을 붙들지 않는다.
같은 이유로 "상태를 없앤다"는 표현도 정확하지 않다. 상태는 없어지지 않는다. 로그인 여부, 장바구니, 진행 중인 작업은 어딘가에 반드시 있어야 한다. 무상태 설계가 하는 일은 그 상태를 서버 인스턴스가 아닌 곳으로 옮겨서, 서버를 다시 교체 가능하게 만드는 것이다.
그럼 어디로 옮기는가. 그게 3장의 주제다.
3. 판단 — 대가를 어디서 치를 것인가
3-1. 매 요청이 자기를 다시 소개해야 한다
무상태는 공짜가 아니다. 서버가 기억하지 않으니 필요한 정보를 매 요청이 전부 다시 실어 날라야 한다.
1-5절의 두 요청을 다시 보면 Host, Cookie, Accept가 그대로 반복되고 있다. 실제 브라우저 요청에는 여기에 User-Agent, Referer, 각종 Sec- 헤더, 여러 개의 쿠키가 더 붙는다. 이미지 하나를 받는 요청에도 똑같이 붙는다. 바디가 없는 요청인데 헤더만 수백 바이트가 되는 일이 흔하다.
이건 무상태의 직접적인 대가다. 기억을 안 하기로 한 대신, 매번 자기소개를 다시 하는 비용을 내는 것이다.
프로토콜이 이 비용을 그냥 두지는 않았다. 다만 어떻게 갚았는지는 이 글의 범위가 아니다. 무상태라는 성질 자체는 그대로 두고 전송 방식만 손보는 방향이었다는 것까지만 적어두고, 상세는 HTTP 버전별 차이로 넘긴다.
3-2. 상태를 어디로 옮길 것인가 — 조회할 것인가, 실어 보낼 것인가
2-4절에서 "상태를 서버 밖으로 옮긴다"고 했다. 옮기는 방향은 크게 둘이고, 갈림길은 하나다. 요청에 담긴 것이 열쇠인가, 정보 그 자체인가.
① 세션 ID — 담긴 것은 열쇠뿐이다
쿠키에 든 session=8f3d9a1c는 아무 의미도 없는 문자열이다. 이 사용자가 누구인지, 무슨 권한을 가졌는지는 여기에 들어 있지 않다. 그래서 서버는 어딘가를 조회해야 응답할 수 있다.
여기서 갈린다. 그 "어딘가"가 자기 메모리라면 2-3절의 붕괴가 그대로 일어난다. 공유 스토어라면 어느 서버가 받아도 같은 곳을 보므로 교체 가능성이 유지된다. 세션 방식이 무상태를 깨는 게 아니라, 세션을 어디에 두느냐가 가른다.
② 토큰 — 담긴 것이 정보 그 자체다
토큰에는 사용자 식별자와 권한 같은 내용이 직접 들어 있고, 위조되지 않았다는 것을 서명으로 확인한다. 서버는 조회 없이 요청만 보고 처리할 수 있다.
둘의 대비는 결국 비용을 어디서 낼 것인가다.
| 세션 ID | 토큰 | |
|---|---|---|
| 요청에 실리는 것 | 열쇠 (짧다) | 정보 (길다) |
| 서버가 하는 일 | 조회한다 | 검증한다 |
| 비용이 나는 곳 | 스토어 조회 부하 | 요청마다의 전송량 |
| 상태를 쥔 곳 | 공유 스토어 | 클라이언트 |
두 방식의 상세와 선택 기준은 로그인 유지의 계보와 쿠키·세션 기반 인증에서, 세션을 서버 밖에 두는 구성은 로드 밸런서 뒤의 세션 문제에서 다룬다.
3-3. 무상태가 어긋나는 영역
무상태의 장점만 쌓고 끝내면 그림이 반쪽이다. 무상태가 잘 맞지 않는 영역이 분명히 있고, 그때는 다른 도구를 쓰는 것이 맞다.
① 실시간 양방향 통신
채팅에서 20바이트짜리 메시지를 보낼 때마다 인증 정보와 각종 헤더가 붙어 수백 바이트가 되는 것은 자주 지적되는 문제다. 그런데 이건 증상이고, 원인은 따로 있다.
헤더가 반복되는 이유는 서버가 아무것도 기억하지 않아서 매 요청이 자기를 다시 증명해야 하기 때문이다.
여기에 앞 글에서 본 또 하나의 제약이 겹친다. 항상 클라이언트가 먼저 말해야 한다. 상대가 보낸 메시지를 받으려면 계속 되물어야 하고, 되물을 때마다 다시 자기소개를 해야 한다. 두 제약이 함께 작동하면서 비용이 곱해진다.
결국 이렇게 정리된다.
관계를 유지하는 것 자체가 요구사항인 영역에서는, 관계를 붙들지 않는 것이 미덕인 프로토콜이 어긋난다.
채팅·알림·협업 편집은 "이 사용자와 이 서버가 지금 연결되어 있다"는 상태를 유지하는 것이 기능의 본질이다. 그래서 이 영역은 HTTP의 절차를 우회하거나(폴링·SSE), 아예 다른 프로토콜로 넘어가는(WebSocket) 쪽으로 갔다. 각각의 동작과 선택 기준은 폴링 vs 롱폴링 vs SSE vs WebSocket에서 다룬다.
② 요청마다 큰 컨텍스트가 필요한 경우
권한 정보가 방대하거나, 진행 중인 작업 상태가 큰 경우다. 3-2절의 두 방향 어느 쪽을 골라도 비용을 낸다. 요청에 실어 보내면 전송량이 늘고, 스토어에 두면 조회 부하가 늘어난다. 무상태에서는 이 비용을 없앨 수 없고 어느 쪽으로 낼지만 고를 수 있다.
③ 여러 단계로 이어지는 작업
여러 화면에 걸친 신청서 작성이나 결제 흐름처럼 중간 상태가 있어야 하는 경우다. 중간 상태를 서버 인스턴스 밖에 두어야 하므로 설계 비용이 든다. 임시 저장소를 두거나, 매 단계마다 지금까지의 입력을 다 실어 보내거나, 미완성 레코드를 만들어두거나. 어떤 방식이든 서버가 기억해줬다면 필요 없었을 일이다.
3-4. 무효화의 역설 — 무상태를 끝까지 지키면 제어권을 잃는다
무상태의 대가가 가장 날카롭게 드러나는 지점은 따로 있다. 이런 상황이다.
사용자의 토큰이 유출됐다. 지금 당장 그 토큰을 못 쓰게 만들려면?
방법이 없다. 서버는 그 토큰을 발급한 사실조차 기억하지 않는다. 요청이 올 때마다 서명만 확인해서 "유효하다"고 판단할 뿐이다. 유효 기간이 끝나기 전에는 막을 수단이 없다.
그래서 무효화 목록을 두게 된다. 그런데 그 순간,
서버가 다시 상태를 갖게 된다.
무효화 목록은 어딘가에 저장되어야 하고, 요청마다 조회되어야 한다. 조회 없이 처리하려고 토큰을 골랐는데, 제어권을 되찾으려니 조회가 다시 들어온다. 3-2절의 표에서 오른쪽 열을 골랐는데 왼쪽 열의 비용이 따라 들어오는 셈이다.
이 역설은 해소되지 않는다. 선택만 있다.
- 무상태를 끝까지 지킨다 → 즉시 무효화를 포기한다. 대신 유효 기간을 짧게 가져가서 위험에 노출되는 창을 줄인다.
- 제어권을 되찾는다 → 무상태를 일부 포기한다. 조회 비용을 받아들이되, 조회 대상을 최소화한다.
현실의 설계는 대체로 둘 사이 어딘가에 자리를 잡는다. 어느 쪽이든 "무상태냐 아니냐"가 아니라 "어디까지 무상태로 갈 것이냐"의 문제로 다뤄야 한다는 것이 이 절의 요점이다.
유효 기간을 어떻게 나누고 탈취에 어떻게 대응하는지는 액세스 토큰과 리프레시 토큰과 토큰 탈취 대응에서 다룬다.
3-5. 정리
이 글을 네 줄로 압축하면 이렇다.
- 비연결성과 무상태는 다른 축이다. 하나는 연결을, 하나는 맥락을 붙들지 않는다.
- 비연결성은 완화됐지만 무상태는 그대로다. 연결이 이어져 있어도 요청들은 서로를 모른다.
- 무상태의 목적은 서버를 교체 가능하게 만드는 것이다. 부하 분산·수평 확장·장애 격리·무중단 배포가 전부 여기서 나온다.
- 상태는 없어지지 않는다. 서버 인스턴스 밖으로 옮겨질 뿐이고, 옮긴 곳에서 비용을 낸다.
처음의 질문에 답하면 이렇게 된다.
서버는 연결도 기억도 붙들지 않는다. 통로는 잠깐 빌려 쓰고 놓으며, 맥락은 애초에 자기 안에 두지 않는다. 그래서 서버는 언제든 다른 서버로 대체될 수 있다.
관련 글
- HTTP — 무엇을 정하는 규약인가 — 이 글의 전제. 특히 "항상 클라이언트가 먼저 말한다"는 규칙.
- HTTP 버전별 차이 — 헤더가 매 요청 반복되는 비용을 프로토콜이 어떻게 갚았는가.
- 로그인 유지의 계보 · 쿠키·세션 기반 인증 — 상태를 서버 밖으로 옮기는 첫 번째 방향.
- 액세스 토큰과 리프레시 토큰 · 토큰 탈취 대응 — 두 번째 방향과 무효화의 역설.
- 로드 밸런서 뒤의 세션 문제 · 수직 확장과 수평 확장 — 교체 가능성이 실제로 쓰이는 자리.
- 폴링 vs 롱폴링 vs SSE vs WebSocket — 무상태가 어긋나는 영역에서의 선택지.