목차
이미지 50개가 붙은 페이지를 연다. 이미지 하나하나는 작은데 페이지 전체가 뜨는 데는 한참 걸린다. 서버 CPU도 한가하고 네트워크도 안 막혔다.
요청을 보내기 전에 해야 할 일이 있다
앞에서 요청 메시지를 봤다. 그 글자를 선에 흘려보내면 되는 것처럼 이야기했는데, 사실 그 전에 상대와 연결을 맺어야 한다.
HTTP는 자기가 직접 선을 깔지 않는다. TCP라는 아래층 위에 얹혀 있고, TCP는 데이터를 주고받기 전에 서로 준비됐는지 확인하는 절차를 밟는다. 세 번 주고받는다고 해서 3-way 핸드셰이크라고 부른다.
여기서 비용의 정체가 드러난다. 실제 요청을 보내기 전에 왕복이 이미 한 번 일어났다. 그리고 HTTPS라면 그 위에 암호화 준비를 위한 왕복이 더 붙는다.
이 왕복은 서버가 아무리 빨라도 안 줄어든다. 거리 때문에 생기는 시간이기 때문이다. 서울에서 서버가 미국에 있으면 한 왕복에 붙는 시간은 물리적으로 정해져 있다. CPU를 늘려도, 코드를 최적화해도 그대로다.
그래서 성능 이야기에서 “왕복 횟수”가 자주 나온다. 처리 시간은 서버가 줄일 수 있지만 왕복 시간은 못 줄인다. 줄이는 방법은 하나뿐이다 - 왕복을 덜 하는 것. 이 글에 나오는 모든 시도가 결국 이 한 문장이다.
그래서 연결을 안 끊는다
초기 HTTP는 요청 하나마다 연결을 새로 맺고 응답을 받으면 끊었다. 이미지 50개면 연결을 50번 맺고 50번 끊는다. 준비 왕복만 50번이다.
해결은 단순하다. 한 번 맺은 연결을 안 끊고 계속 쓴다. 이게 keep-alive, 또는 지속 연결이다.
HTTP/1.1부터는 이게 기본 동작이다. 별도로 켜지 않아도 연결을 유지한다. 끊고 싶을 때만 Connection: close를 보낸다.
그런데 연결을 영원히 붙들 수는 없다. 서버가 동시에 유지할 수 있는 연결 수에는 한계가 있고, 아무도 안 쓰는 연결이 자리만 차지하면 손해다. 그래서 일정 시간 아무 요청도 없으면 서버가 끊는다. 이 시간을 서버 설정으로 정한다.
이 타임아웃이 클라이언트와 서버에서 어긋나면 성가신 오류가 난다. 서버는 방금 끊었는데 클라이언트는 아직 살아 있다고 믿고 요청을 밀어넣는 순간이 생긴다. 그래서 보통 클라이언트 쪽 유휴 타임아웃을 서버 쪽보다 짧게 잡는다. 값 자체는 제품마다 기본값이 달라서 쓰는 서버 문서를 봐야 한다.
그래도 한 줄로만 나간다
연결을 재사용해서 준비 비용은 아꼈다. 그런데 이미지 50개는 여전히 느리다. 이유가 하나 더 있다.
HTTP/1.1의 한 연결에서는 요청 하나를 보내고 그 응답을 다 받아야 다음 요청을 보낼 수 있다. 창구 하나에 줄을 선 것과 같다.
앞 요청이 오래 걸리면 뒤가 전부 기다린다. 뒤의 것들이 아무리 작아도 소용없다. 앞 사람이 창구를 붙들고 있으면 뒤는 못 간다. 이걸 **머리 막힘(Head-of-Line blocking)**이라고 부른다.
이걸 풀어보려는 시도가 없었던 건 아니다. 응답을 안 기다리고 요청을 연달아 밀어 넣는 파이프라이닝이 규격에 있었다. 다만 응답은 여전히 보낸 순서대로 돌아와야 해서 앞이 막히면 뒤가 막히는 건 그대로였고, 중간 장비들의 처리도 제각각이라 실제로는 널리 쓰이지 못했다.
그래서 브라우저가 택한 우회로는 연결을 여러 개 여는 것이다. 창구를 늘려버린 셈이다. 다만 무한정 열지는 않는다. 같은 호스트에 대한 동시 연결 수에 상한을 두는데, 이 상한은 브라우저마다 다르다.
HTTP/2는 한 연결에 여러 대화를 섞는다
창구를 늘리는 건 우회지 해결이 아니다. 연결마다 준비 비용이 다시 붙고, 서버가 감당할 연결 수도 늘어난다.
HTTP/2의 답은 다르다. 연결은 하나만 두고, 그 안에서 여러 요청을 동시에 흘린다.
요청과 응답을 작은 조각으로 쪼개고, 각 조각에 “몇 번 대화의 것인지” 번호를 붙여 섞어 보낸다. 받는 쪽이 번호를 보고 다시 모은다. 그래서 응답이 보낸 순서대로 안 돌아와도 된다. 먼저 준비된 것이 먼저 나간다. 이걸 멀티플렉싱이라고 부른다.
번호를 붙이고 조각을 나누려면 메시지가 정해진 틀에 담겨야 한다. 그래서 HTTP/2는 사람이 읽던 텍스트 형식을 버리고 이진 형식으로 바꿨다. 요청과 응답 글에서 본 메서드·경로·헤더·바디는 그대로다. 담는 그릇만 바뀌었다.
덤으로 하나 더 있다. 페이지 하나가 만드는 요청 수십 개는 헤더가 거의 똑같다. 같은 Host, 같은 User-Agent, 같은 쿠키가 매번 실려 나간다. HTTP/2는 이 반복되는 헤더를 압축해서 보낸다. 앞 글에서 본 “무상태의 대가로 같은 정보를 매번 보낸다”를 조금 덜어주는 셈이다.
HTTP/2가 못 푼 것
멀티플렉싱으로 HTTP 층의 머리 막힘은 사라졌다. 그런데 문제가 한 층 아래에 그대로 남아 있다.
TCP는 보낸 순서대로 빠짐없이 전달하는 것을 보장한다. 그래서 중간의 조각 하나가 유실되면, 뒤에 잘 도착한 조각들이 있어도 그 하나가 다시 올 때까지 위로 올려보내지 않는다.
HTTP/2는 요청 여러 개를 한 TCP 연결에 몰아넣었다. 그래서 조각 하나가 유실되면 그 연결에 실린 모든 요청이 같이 멈춘다. 연결을 하나로 줄인 게 여기서는 불리하게 작용한다.
머리 막힘을 HTTP 층에서 걷어냈더니 TCP 층에서 다시 나타난 것이다. 손실이 거의 없는 유선 환경에서는 잘 안 드러나지만, 모바일처럼 유실이 잦은 환경에서는 눈에 띈다.
HTTP/3은 아래층을 바꿨다
TCP가 문제라면 TCP를 안 쓰면 된다. HTTP/3의 선택이 그거다. UDP 위에 QUIC이라는 층을 새로 얹었다.
UDP는 순서도 재전송도 보장하지 않는 단순한 층이다. 그래서 QUIC이 필요한 것만 골라 직접 구현한다. 핵심은 대화(스트림)별로 순서를 따로 관리한다는 점이다. 한 대화의 조각이 유실돼도 다른 대화는 계속 간다.
하나 더 있다. TCP 위에서 HTTPS를 쓰면 연결 준비 왕복과 암호화 준비 왕복이 따로 일어난다. QUIC은 둘을 합쳐서 한 번에 끝낸다. 시작할 때 드는 왕복 횟수가 줄어든다.
그리고 QUIC은 연결을 IP 주소가 아니라 자기가 붙인 식별자로 구분한다. 그래서 와이파이에서 LTE로 넘어가 IP가 바뀌어도 연결이 안 끊긴다. TCP에서는 IP가 바뀌면 다른 연결이라 처음부터 다시 맺어야 했다.
버전이 올라간다고 앞 버전이 없어지지는 않는다. 클라이언트와 서버는 서로 지원하는 것 중 높은 쪽으로 맞춘다. 그래서 HTTP/3을 켜도 안 되는 환경에서는 조용히 아래 버전으로 내려간다. 어느 버전으로 붙었는지는 브라우저 개발자도구의 Network 탭에서 프로토콜 열로 확인할 수 있다.
같은 문제의 다른 층
여기까지의 이야기를 한 문장으로 줄이면 이렇다. 비싼 연결을 만들었으면 버리지 말고 다시 써라.
이 문장이 익숙하다면 맞다. 애플리케이션과 DB 사이에도 똑같은 문제가 있고, 똑같은 답이 나와 있다. 커넥션 풀 글이 다루는 그것이다.
| 브라우저 ↔ 서버 | 앱 ↔ DB | |
|---|---|---|
| 비싼 것 | TCP·TLS 준비 왕복 | 인증·세션 생성 |
| 답 | 연결을 안 끊고 재사용 | 연결을 미리 만들어 빌려 쓴다 |
| 상한 | 호스트당 동시 연결 수 | 풀 크기 |
| 안 쓰면 | 유휴 타임아웃으로 끊는다 | 유휴 연결을 정리한다 |
층이 다를 뿐 같은 문제다. 한쪽을 이해하면 다른 쪽이 거의 공짜로 따라온다. 연결은 어디서든 비싸고, 비싼 것은 재사용한다.
실무에서 무엇을 정하나
이 글의 내용 대부분은 서버나 브라우저가 알아서 한다. 그래도 손이 가는 자리가 있다.
버전을 켜는 건 대체로 앞단의 일이다. 애플리케이션 코드를 안 고치고 Nginx 같은 리버스 프록시나 CDN 설정에서 켠다. 앞단이 HTTP/2로 받고 뒤쪽 앱과는 HTTP/1.1로 말하는 구성이 흔하다.
HTTP/1.1 시절의 최적화가 HTTP/2에서는 손해가 될 수 있다. 요청 수를 줄이려고 이미지 여러 장을 한 장으로 합치거나 JS 파일을 하나로 묶는 기법이 있었다. 요청이 비쌌기 때문이다. 멀티플렉싱이 되면 요청 수의 부담이 줄어드는 대신, 작은 것 하나만 바뀌어도 합쳐진 큰 파일을 통째로 다시 받아야 하는 단점이 남는다. 무조건 어느 쪽이 낫다기보다 전제가 바뀌었으니 다시 재봐야 한다는 쪽이다.
연결 유지 설정은 앞뒤가 맞아야 한다. 로드밸런서, 리버스 프록시, 앱 서버가 줄줄이 서 있으면 유휴 타임아웃도 줄줄이 있다. 어느 한 칸이 짧으면 거기서 끊긴다. 원인 모를 간헐적 오류의 흔한 정체다.
측정 없이 버전만 올리고 빨라졌다고 말하지 않는다. 효과는 왕복 지연, 손실률, 파일 개수와 크기에 따라 크게 달라진다. 회선이 좋고 자원이 몇 개 없는 페이지라면 체감이 거의 없을 수도 있다.
정리
| 문제 | 요청을 보내기 전에 연결 준비 왕복이 든다. 거리 때문이라 서버 성능으로 못 줄인다 |
| keep-alive | 연결을 안 끊고 재사용. HTTP/1.1의 기본 |
| 남은 문제 | 한 연결에서 요청이 한 줄로 선다 - 앞이 막히면 뒤가 막힌다 |
| HTTP/2 | 연결 하나에 여러 대화를 섞는다. 헤더도 압축한다 |
| 그래도 | TCP가 순서를 보장해서, 조각 하나 유실에 전부 멈춘다 |
| HTTP/3 | UDP 위 QUIC. 대화별로 순서를 따로 관리하고, 준비 왕복도 합친다 |
| 같은 문제 | DB 커넥션 풀과 뿌리가 같다. 비싼 연결은 재사용한다 |
여기까지가 잘 보내는 법이었다. 그런데 아무리 잘 보내도 안 보내는 것보다 빠를 수는 없다. 이미 받아둔 이미지를 다음에 또 받을 이유가 있나. 다음 글은 요청을 아예 안 보내는 이야기다.