지속 연결
고친 사람 github-actions[bot]
지속 연결은 한 번 맺은 네트워크 연결을 요청 하나에 쓰고 버리지 않고 다음 요청에도 다시 쓰는 방식입니다. 연결을 맺는 데 드는 시간을 요청마다 치르지 않게 해 줍니다. 브라우저와 서버가 웹 요청을 주고받을 때 기본으로 쓰는 방식입니다.
쉽고 빠른 이해
무슨 일을 하나 — 연결 하나로 요청 여럿을 나릅니다. 브라우저는 문서를 받은 연결로 이어서 스타일 파일과 그림도 받습니다.
왜 하나 — 연결을 새로 맺으려면 상대와 신호를 몇 번 주고받아야 합니다. 요청마다 연결을 맺으면 그 기다림이 요청 수만큼 쌓입니다.
어떻게 도나
- 첫 요청 때 연결을 한 번 맺습니다
- 응답을 보낸 뒤에도 연결을 닫지 않습니다. 다음 요청은 열린 연결로 바로 나갑니다
- 한쪽이 그만 쓰겠다고 알리거나 한동안 요청이 없으면 연결을 닫습니다
대가 — 쉬는 연결에도 서버가 메모리를 내줍니다. 오래 쉰 연결은 모르는 새 끊겨 있어서 다음 요청이 실패하기도 합니다. 한 연결 안에서는 앞 응답이 늦으면 뒤 요청도 기다립니다.
언제 이득인가 — 같은 상대에게 요청을 이어서 보낼 때 이득입니다. 요청이 드물어 서버가 쉬는 연결을 닫은 뒤에야 다음 요청이 오면 아끼는 것 없이 대가만 치릅니다.
상세
이 절은 지속 연결이 무엇을 아끼는지부터 봅니다. 연결 하나를 맺을 때 무엇이 드는지 따라간 뒤, 한 연결을 요청 셋이 나눠 쓰는 모습을 그림 한 장으로 봅니다.
뒤쪽에서는 연결을 열어 두는 바람에 새로 생기는 문제를 다룹니다. 응답이 어디서 끝나는지 알리는 방법, 연결을 닫는 때, 열어 둔 연결이 치르는 대가가 차례로 나옵니다.
전화로 용건 셋을 전하는 방법은 둘입니다. 용건 하나를 말할 때마다 끊고 다시 걸 수 있습니다. 한 번 걸어 놓고 셋을 이어서 말할 수도 있습니다. 지속 연결은 뒤의 방법입니다.
HTTP(HyperText Transfer Protocol, 하이퍼텍스트 전송 프로토콜)는 웹에서 요청과 응답을 주고받는 규칙입니다. 브라우저는 페이지 하나를 그리려고 문서와 스타일 파일, 스크립트, 그림을 따로따로 요청합니다. 페이지 하나에 요청이 수십 개 나가는 일도 흔합니다.
HTTP 메시지는 TCP(Transmission Control Protocol, 전송 제어 프로토콜) 연결 위로 오갑니다. TCP 는 두 컴퓨터 사이에 연결을 세우고 바이트를 순서대로 나르는 규칙입니다. HTTP 요청을 보내려면 이 연결이 먼저 있어야 합니다.
응답 하나를 보낸 뒤에도 TCP 연결을 닫지 않고 같은 상대의 다음 요청에 다시 쓰는 방식을 지속 연결이라고 부릅니다. 영어로는 persistent connection 입니다. 반대편에는 요청 하나마다 연결을 맺고 닫는 방식이 있습니다.
연결 하나를 맺는 비용
TCP 는 연결을 맺을 때 신호를 세 번 주고받습니다. 클라이언트가 연결을 청하는 신호, 서버가 받아들이는 신호, 클라이언트가 받았다고 답하는 신호입니다. 이 절차가 3방향 핸드셰이크입니다.
신호가 상대까지 갔다가 돌아오는 데 걸리는 시간을 왕복 시간이라고 부릅니다. 핸드셰이크는 요청 앞에 왕복 시간 하나를 더 붙입니다. 상대가 멀리 있을수록 이 한 번이 길어집니다.
신호는 셋인데 왕복이 하나인 까닭은 셋째 신호에 있습니다. 첫째 신호가 가고 둘째 신호가 돌아오는 것이 왕복 한 번입니다. 셋째 신호는 돌아올 답을 기다리지 않습니다. 클라이언트는 셋째 신호를 보내면서 요청을 바로 실어 보낼 수 있습니다.
암호화 연결인 TLS(Transport Layer Security, 전송 계층 보안)를 쓰면 기다림이 더 늘어납니다. TCP 연결을 맺은 뒤 양쪽이 암호 열쇠를 맞추는 주고받음이 이어서 붙기 때문입니다. 요청은 이 주고받음까지 끝나야 나갑니다.
갓 맺은 연결은 속도도 늦습니다. TCP 는 새 연결에서 한 번에 보내는 양을 적게 잡습니다. 그 뒤 상대가 잘 받는 것을 확인하면서 조금씩 늘립니다. 이 방식을 느린 시작이라고 부릅니다. 네트워크가 얼마나 감당하는지 모르는 채로 한꺼번에 쏟아 길을 막지 않으려는 것입니다.
연결을 닫는 일에도 뒷정리가 남습니다. TCP 가 한 번에 실어 나르는 데이터 덩어리를 세그먼트라고 부릅니다. 닫힌 연결의 세그먼트가 늦게 도착해 같은 주소로 새로 맺은 연결에 섞이면 데이터가 뒤엉킵니다.
그래서 TCP 는 연결을 먼저 닫은 쪽에 그 연결의 기록을 한동안 남겨 둡니다. 이 기다리는 상태가 TIME_WAIT입니다. 기다리는 동안 그 기록은 연결을 가리던 주소와 포트 번호 짝을 붙잡고 있습니다. 연결을 짧게 자주 닫으면 이런 기록이 쌓여 새 연결에 쓸 포트 번호가 모자라기도 합니다.
요청마다 연결을 새로 맺으면 이 비용을 요청 수만큼 치릅니다. 지속 연결은 핸드셰이크를 첫 요청 때 한 번만 치릅니다. 나머지 요청은 보내는 양이 이미 늘어난 연결로 나갑니다.
아래 그림은 요청마다 연결을 새로 맺는 쪽입니다. 다음 절에 있는 지속 연결 그림과 견주면 핸드셰이크가 몇 번 들어가는지가 다릅니다.
sequenceDiagram
participant 클라이언트
participant 서버
클라이언트->>서버: 연결을 맺는다 · 핸드셰이크
Note over 클라이언트,서버: TLS 를 쓰면 암호 열쇠를 맞추는 주고받음이 이어서 붙는다
클라이언트->>서버: 요청 1
서버-->>클라이언트: 응답 1
Note over 클라이언트,서버: 연결을 닫는다
클라이언트->>서버: 연결을 맺는다 · 핸드셰이크
클라이언트->>서버: 요청 2
서버-->>클라이언트: 응답 2
Note over 클라이언트,서버: 연결을 닫는다 · 요청 3 도 처음부터 되풀이한다
기본값이 된 지속 연결
HTTP/1.0 에서는 서버가 응답 하나를 보내면 연결을 닫는 것이 기본이었습니다. 연결을 열어 두고 싶은 클라이언트는 요청에 Connection 헤더를 달고 값으로 keep-alive 를 적어 부탁했습니다. 서버가 받아들이면 응답에도 같은 헤더를 달았습니다.
이 헤더 값 때문에 지금도 지속 연결을 킵얼라이브라고 부르는 일이 많습니다. TCP 에도 킵얼라이브라는 기능이 따로 있습니다. 조용한 연결에 작은 신호를 보내 상대가 아직 있는지 묻는 기능입니다. 이름만 같을 뿐 연결을 다시 쓰는 일과는 관계가 없습니다.
HTTP/1.1 은 기본을 뒤집었습니다. 따로 말하지 않으면 응답 뒤에도 연결을 열어 둡니다. 그만 쓰려는 쪽이 Connection 헤더에 close 를 적어 보냅니다. 양쪽은 close 를 실은 요청에 대한 응답, 또는 close 를 실은 응답을 끝으로 연결을 닫습니다.
아래 그림은 HTTP/1.1 에서 한 연결로 요청 셋이 오가는 모습입니다.
sequenceDiagram
participant 클라이언트
participant 서버
클라이언트->>서버: 연결을 맺는다 · 핸드셰이크
클라이언트->>서버: 요청 1 · 문서
서버-->>클라이언트: 응답 1
클라이언트->>서버: 요청 2 · 스타일 파일
서버-->>클라이언트: 응답 2
클라이언트->>서버: 요청 3 · Connection 헤더에 close
서버-->>클라이언트: 응답 3
Note over 클라이언트,서버: 응답 3 을 끝으로 연결을 닫는다
핸드셰이크는 맨 위에 한 번뿐입니다. 요청 2 와 요청 3 은 열린 연결로 바로 나갑니다. 요청 3 에 close 가 실려 있어서 양쪽은 응답 3 뒤에 연결을 닫습니다.
응답의 끝을 알리는 길이
연결을 매번 닫던 때에는 연결이 닫히는 것이 곧 응답이 끝났다는 신호였습니다. 받는 쪽은 연결이 끊길 때까지 읽으면 됐습니다. 연결을 열어 두면 이 신호가 사라집니다.
그래서 지속 연결에서는 메시지가 스스로 길이를 밝혀야 합니다. 가장 흔한 방법은 Content-Length 헤더에 본문의 바이트 수를 적는 것입니다. 받는 쪽은 헤더가 끝나는 빈 줄 뒤로 그만큼만 읽습니다. 그 뒤에 오는 바이트는 다음 응답으로 읽습니다.
아래는 한 연결로 응답 둘이 이어서 도착한 모습입니다. // 뒤는 설명이고 실제 메시지에는 없습니다.
HTTP/1.1 200 OK
Content-Length: 5 // 본문은 5바이트
hello // 5바이트를 읽고 멈춘다
HTTP/1.1 404 Not Found // 응답 2 시작
Content-Length: 0
받는 쪽은 hello 다섯 바이트를 읽은 뒤 바로 다음 줄을 새 응답의 첫 줄로 읽습니다. 길이 한 줄이 두 응답 사이의 경계를 대신 그어 줍니다.
본문 길이를 미리 모르는 응답도 있습니다. 서버가 응답을 만들어 가면서 보낼 때가 그렇습니다. 이럴 때는 본문을 조각으로 나눠 조각마다 앞에 길이를 적습니다. 끝은 길이 0 인 조각으로 알립니다. 이 방식을 청크 전송 인코딩이라고 부릅니다.
길이를 밝힐 방법이 아무것도 없으면 서버는 연결을 닫아 끝을 알립니다. 그 연결은 다음 요청에 다시 쓸 수 없습니다.
세 경우를 모으면 아래와 같습니다. 끝을 어떻게 읽느냐에 따라 연결을 다시 쓸 수 있는지가 갈립니다.
flowchart TD
A["응답이 길이를 어떻게 밝혔나"]
A -->|Content-Length| B["그 바이트 수만 읽는다 · 연결을 다시 쓴다"]
A -->|조각으로 나눠 보냄| C["길이 0 인 조각까지 읽는다 · 연결을 다시 쓴다"]
A -->|밝히지 않음| D["연결이 닫힐 때까지 읽는다 · 연결을 다시 못 쓴다"]
Content-Length 와 청크 전송 인코딩, 길이를 알리는 방법이 둘이라 생기는 문제도 있습니다. 앞에 선 프록시와 뒤의 서버가 한 메시지의 끝을 서로 다르게 읽을 수 있습니다. 그러면 한쪽이 본문으로 본 바이트를 다른 쪽은 다음 요청으로 읽습니다. 이 틈을 노린 공격을 HTTP 요청 스머글링이라고 부릅니다.
한 연결 위의 줄서기
HTTP/1.1 의 지속 연결에서 요청과 응답은 한 번에 하나씩 오갑니다. 클라이언트는 응답이 다 온 뒤에 다음 요청을 보냅니다. 앞 응답이 늦으면 뒤 요청은 연결이 빌 때까지 기다립니다.
파이프라이닝은 응답을 기다리지 않고 요청을 연달아 보내는 방법입니다. 서버는 응답을 요청이 온 순서대로 돌려줘야 합니다. 앞 응답이 늦으면 다 만든 뒤 응답도 나가지 못합니다.
아래는 요청 셋을 파이프라이닝으로 보낸 모습입니다. 요청은 한꺼번에 나가지만 응답 2 와 응답 3 은 늦은 응답 1 뒤에 섭니다.
sequenceDiagram
participant 클라이언트
participant 서버
클라이언트->>서버: 요청 1
클라이언트->>서버: 요청 2
클라이언트->>서버: 요청 3
Note over 서버: 응답 2 · 3 은 다 만들었지만 응답 1 을 기다린다
서버-->>클라이언트: 응답 1 · 늦게 나온다
서버-->>클라이언트: 응답 2
서버-->>클라이언트: 응답 3
이렇게 맨 앞이 막혀 뒤가 통째로 서는 일을 헤드 오브 라인 블로킹이라고 부릅니다. 이 문제 때문에 브라우저는 대개 파이프라이닝을 쓰지 않습니다. 대신 한 서버에 지속 연결을 여러 개 엽니다. 연결마다 요청을 하나씩 실어 동시에 받습니다.
HTTP/2 는 한 연결 안에서 요청 여럿을 섞어 나릅니다. 보내는 쪽은 요청과 응답을 번호 붙인 조각으로 나눠 보냅니다. 받는 쪽은 번호를 보고 조각을 다시 모읍니다. 이 방식을 멀티플렉싱이라고 부릅니다.
sequenceDiagram
participant 클라이언트
participant 서버
클라이언트->>서버: 요청 1 · 요청 2
서버-->>클라이언트: 응답 1 조각 1
서버-->>클라이언트: 응답 2 조각 1
서버-->>클라이언트: 응답 2 조각 2 · 끝
Note over 클라이언트: 응답 2 를 먼저 다 모았다
서버-->>클라이언트: 응답 1 조각 2 · 끝
파이프라이닝 그림과 달리 응답 1 이 늦어도 응답 2 는 먼저 끝납니다. 한 연결로 동시에 여러 요청을 처리하니 연결을 여러 개 열 까닭이 줄어듭니다.
HTTP/3 는 같은 일을 TCP 대신 QUIC(퀵) 위에서 합니다. QUIC 은 연결을 맺고 데이터를 나르는 일을 TCP 대신 맡는 전송 프로토콜입니다. 어느 버전이든 한 번 맺은 연결을 오래 쓴다는 생각은 같습니다.
연결을 닫는 때
서버는 쉬는 연결을 끝없이 들고 있지 않습니다. 한동안 요청이 없으면 스스로 닫습니다. 이렇게 쉬는 연결을 기다려 주는 한도를 유휴 타임아웃이라고 부릅니다.
지속 연결이 닫히는 계기를 모으면 셋입니다.
| 계기 | 닫는 쪽 |
|---|---|
Connection: close 를 보내거나 받았다 |
어느 쪽이든 |
| 유휴 타임아웃 동안 요청이 없었다 | 대개 서버 |
| 연결 하나로 받기로 정한 요청 수를 채웠다 | 서버 |
셋째 계기는 서버 설정으로 정합니다. 한 연결이 끝없이 살아 있지 않도록 서버가 일부러 끊는 것입니다.
닫는 순간이 엇갈리는 문제가 있습니다. 서버가 쉬는 연결을 닫는 바로 그때 클라이언트가 그 연결로 요청을 보낼 수 있습니다. 그러면 요청은 서버에 닿지 못하고 연결이 끊겼다는 오류가 납니다. 클라이언트는 그 요청이 서버에서 처리됐는지 알 수 없습니다.
여러 번 해도 한 번 한 것과 결과가 같은 요청을 멱등한 요청이라고 부릅니다. 조회하는 GET 은 두 번 보내도 결과가 같습니다. 결제를 만드는 POST 는 두 번 보내면 결제가 둘 생길 수 있습니다.
그래서 클라이언트는 끊긴 연결에서 실패한 요청 가운데 멱등한 요청만 새 연결로 자동 재시도합니다. 멱등하지 않은 요청은 실패를 호출한 쪽에 돌려주고 판단을 맡깁니다.
아래는 닫기와 요청이 엇갈린 뒤 멱등한 요청을 다시 보내는 모습입니다.
sequenceDiagram
participant 클라이언트
participant 서버
Note over 서버: 유휴 타임아웃 · 연결을 닫는다
클라이언트-x서버: 요청 · 서버에 닿지 못한다
Note over 클라이언트: 연결이 끊겼다는 오류
Note over 클라이언트: 멱등한 요청이면 다시 보낸다 · 아니면 호출한 쪽에 돌려준다
클라이언트->>서버: 새 연결을 맺는다 · 핸드셰이크
클라이언트->>서버: 같은 요청
서버-->>클라이언트: 응답
열어 둔 연결의 대가
프로그램은 연결 하나를 소켓으로 쥡니다. 소켓은 프로그램이 연결을 읽고 쓸 때 쓰는 손잡이입니다. 서버는 요청이 없는 쉬는 연결에도 소켓과 메모리를 내줍니다.
운영체제는 열린 파일과 소켓에 번호를 매깁니다. 이 번호를 파일 디스크립터라고 부릅니다. 운영체제는 한 프로세스가 여는 번호 수에 상한을 둡니다. 쉬는 연결이 쌓여 상한에 닿으면 서버는 새 연결을 받지 못합니다.
연결 중간에 선 장비도 문제를 만듭니다. 방화벽과 NAT(Network Address Translation, 네트워크 주소 변환) 장비는 지나가는 연결을 표에 적어 둡니다. 그리고 오래 조용한 줄은 지웁니다. 줄이 지워진 연결로 보낸 요청은 버려집니다.
양 끝의 컴퓨터는 이 일을 모르고 연결이 살아 있다고 믿습니다. 그래서 한동안 조용하다가 들어온 첫 요청만 실패하는 증상이 납니다. 새벽처럼 요청이 드문 때 자주 보입니다.
sequenceDiagram
participant 클라이언트
participant 장비 as NAT 장비
participant 서버
클라이언트->>장비: 요청 1
장비->>서버: 요청 1
서버-->>장비: 응답 1
장비-->>클라이언트: 응답 1
Note over 장비: 오래 조용해 이 연결의 줄을 지운다
클라이언트->>장비: 요청 2
Note over 장비: 줄이 없어 요청 2 를 버린다
Note over 클라이언트,서버: 양 끝은 연결이 살아 있다고 믿는다
HTTP 클라이언트와 데이터베이스 드라이버는 지속 연결을 여럿 모아 두고 요청마다 빌려 씁니다. 이 장치를 커넥션 풀이라고 부릅니다. 풀은 두 방법으로 위의 증상을 피합니다. 쉬는 연결을 서버나 중간 장비보다 먼저 닫습니다. 아니면 빌려주기 전에 연결이 살아 있는지 확인합니다.
로드 밸런서는 여러 서버 앞에서 요청을 나눠 주는 장비입니다. 로드 밸런서가 연결 단위로 서버를 고르면, 오래 사는 연결은 처음 고른 서버에 계속 붙어 있습니다. 서버를 새로 늘려도 이미 열린 연결은 옮겨 가지 않습니다. 그래서 새 서버에는 요청이 늦게 찹니다.
지속 연결이 이득을 보는 조건
지금까지의 비용과 대가를 맞대면 지속 연결이 언제 남는 장사인지 보입니다. 같은 상대에게 요청을 이어서 보낼 때입니다. 페이지 하나에 딸린 파일을 받는 브라우저, 주문을 받을 때마다 같은 재고 서버에 재고를 묻는 주문 서버가 그렇습니다.
요청이 드문 경우는 다릅니다. 상대에게 요청을 한 번만 보내고 마는 프로그램은 열어 둔 연결을 다시 쓸 일이 없습니다. 요청 사이 간격이 유휴 타임아웃보다 길면 다음 요청 전에 서버가 연결을 닫아 버립니다. 이럴 때는 맺는 비용을 아끼지 못하고 쉬는 연결의 대가만 치릅니다.
관련 항목
지속 연결을 다루는 HTTP 버전
HTTP · HTTP/1.0 · HTTP/1.1 · HTTP/2 · HTTP/3 · RFC 9110 · RFC 9112
지속 연결이 아끼는 연결 수립 비용
TCP · 세그먼트 · 3방향 핸드셰이크 · 왕복 시간 · TLS · TLS 핸드셰이크 · 느린 시작 · 혼잡 제어 · TIME_WAIT
지속 연결에서 메시지의 끝을 알리는 헤더와 인코딩
Connection 헤더 · Content-Length · 청크 전송 인코딩 · Transfer-Encoding · 메시지 프레이밍 · HTTP 요청 스머글링
한 연결로 요청 여럿을 나르는 방식
파이프라이닝 · 다중화 · QUIC · 헤드 오브 라인 블로킹
지속 연결을 모아 두고 다시 쓰는 구성 요소
커넥션 풀 · HTTP 클라이언트 · 데이터베이스 드라이버 · 프록시 · 리버스 프록시
열어 둔 연결을 붙들거나 끊는 자원과 장비
소켓 · 파일 디스크립터 · 유휴 타임아웃 · 방화벽 · NAT · 연결 추적 · 킵얼라이브 · 로드 밸런서
끊긴 연결에서 요청을 다시 보낼지 가르는 개념
다른 이름: persistent connection · HTTP persistent connection · 영속 연결 · 퍼시스턴트 커넥션