keep-alive 를 안 쓰면 악수만 하다 끝난다
상대 서비스는 5ms 만에 처리하는데 우리 쪽에서 재면 40ms 가 나왔다. 사라진 35ms 는 요청마다 연결을 새로 맺는 데 쓰이고 있었다. 설정은 맞았다. 쓰는 법이 틀렸다.
내부 서비스 호출이 생각보다 느렸다. 상대 서비스는 5ms 만에 처리하는데, 우리 쪽에서 재면 40ms 가 나왔다.
네트워크 지연을 재 봐도 1ms 가 안 됐다. 그런데 왕복은 40ms. 사라진 35ms 는 어디로 갔을까?
패킷을 떠 보고서야 알았다. 요청마다 연결을 새로 맺고 있었다. 연결을 맺느라 오가는 왕복이 실제 데이터를 보내는 시간보다 훨씬 길었다.
연결을 다시 쓰지 않으면 매번 악수부터 새로 한다. 그 비용은 요청이 짧을수록 상대적으로 더 커진다.
그래서 내부 서비스 호출처럼 짧고 잦은 길에서 제일 크게 드러난다.
5ms 짜리 조회에 35ms 를 쓰는 이유
새 연결 하나를 맺으려면 왕복이 여러 번 필요하다.
TCP 연결 수립에 왕복이 한 번 든다. 암호화까지 하면 인증서를 주고받고 키를 합의하느라 왕복이 더 붙는다. 그래서 지연이 짧은 내부망에서도 연결을 맺는 데 수 밀리초가 걸린다. 외부망이면 수십 밀리초.
정작 데이터 전송은 그보다 훨씬 짧을 수 있다. 5ms 짜리 조회를 하려고 35ms 를 연결에 쓰는 꼴이다.
연결 재사용을 켜면 이 비용을 한 번만 낸다. 첫 요청에서 연결을 맺고, 다음 요청부터는 그 연결을 다시 쓴다. 왕복이 온전히 데이터 전송에만 쓰인다.
덤으로 얻는 것도 있다. 연결을 맺는 데는 양쪽 다 자원이 든다. 소켓, 파일 디스크립터, 커널 메모리. 매번 맺고 끊으면 그 비용이 끝없이 나가고, 끊긴 연결이 잠시 남아 있는 상태 때문에 포트가 바닥나기도 한다.
흔한 오해가 하나 있다. 요즘 HTTP 클라이언트는 대부분 재사용을 기본으로 켜 둔다. 그런데 클라이언트를 요청마다 새로 만들면 그 안의 연결 풀도 매번 새로 생긴다. 설정은 맞는데 쓰는 법이 틀렸다. 실무에서 제일 흔한 실수가 이거다.
new HttpClient() 가 반복문 안에 있다면
클라이언트를 재사용해야 연결이 재사용된다:
for each request:
client = new HttpClient() # 매번 새 풀
client.get(url) # 매번 새 연결
client = new HttpClient() # 한 번만 만든다
for each request:
client.get(url) # 연결을 재사용
· 풀 설정도 확인한다
호스트당 최대 연결 수 (기본값이 작은 경우가 많다)
유휴 연결 유지 시간
· 서버 쪽 유휴 타임아웃 > 클라이언트 쪽
반대면 서버가 닫는 순간 요청이 실려 실패한다✕ 문제 지점 · ✓ 해법 · 그대로 돌리기 전에 자기 환경에 맞게 고친다.
재사용을 켜고 나서 걸리는 것들
재사용을 켠 뒤에도 챙길 게 남아 있다.
우선 클라이언트 인스턴스를 다시 써야 한다. 방금 말한 그 실수다. 연결 풀은 클라이언트가 들고 있으니, 클라이언트를 버리면 풀도 같이 버려진다.
풀 크기도 봐야 한다. 호스트당 최대 연결 수를 작게 잡아 둔 라이브러리들이 있다. 동시 요청이 그 수를 넘으면 줄을 선다. 재사용은 잘 되는데 대기 때문에 느린, 좀 억울한 상태가 된다.
유휴 연결을 얼마나 붙잡아 둘지도 정해야 한다. 너무 짧으면 재사용 효과가 줄고, 너무 길면 안 쓰는 연결이 자원을 차지한다. 상대 서버 설정과도 맞춰야 한다. 서버 쪽 유휴 타임아웃이 클라이언트 쪽보다 짧으면 경합이 생긴다.
프로토콜 버전도 영향을 준다. 새 버전은 연결 하나로 여러 요청을 동시에 보낼 수 있어서 연결 하나가 훨씬 많은 일을 한다. 필요한 연결 수 자체가 준다.
DB 커넥션 풀과 같은 얘기라는 걸 알아 두면 편하다. 커넥션 풀의 원리가 HTTP 에도 그대로 들어맞는다. 다른 점은 하나다. DB 는 커넥션이 비싸서 적게 두고, HTTP 는 비교적 싸서 더 많이 둔다.
마지막은 로드 밸런서와 엮이는 부분이다. 연결을 오래 붙잡고 있으면 그 연결은 특정 서버에 붙어 있다. 뒤쪽 서버를 늘려도 기존 연결은 옛 서버로 계속 가서, 새 서버로 부하가 안 넘어갈 수 있다.
내부 호출부터 손볼 것
- 클라이언트 인스턴스는 다시 쓴다. 요청마다 새로 만들면 연결 풀도 매번 새로 생긴다.
- 호스트당 최대 연결 수는 기본값이 작아서 대기가 생기는 경우가 많다. 한번 열어 보자.
- 서버 쪽 유휴 타임아웃이 클라이언트 쪽보다 길어야 한다. 반대면 경합이 생긴다.
- 새 프로토콜 버전은 검토해 볼 만하다. 연결 하나로 여러 요청을 처리하면 필요한 연결 수가 준다.
- 새로 맺은 연결 수가 요청 수에 가깝다면 재사용이 안 되고 있다. 재사용률을 지표로 봐야 한다.
- 로드 밸런서 뒤에서 연결이 고르게 퍼지는지도 확인한다. 오래 유지된 연결은 새 서버로 안 갈 수 있다.
- 내부 호출의 응답 시간을 상대 쪽 처리 시간과 나란히 놓아 본다. 차이가 크면 이 문제다.
- 짧고 잦은 호출일수록 효과가 크다. 그런 길부터 보면 된다.
- 연결 수명에 상한이 없으면 오래된 연결이 옛 서버에 고정된다. 그러면 확장해도 부하가 안 넘어간다.
- 우리 인스턴스 수 × 호스트당 연결 수. 상대가 받아야 하는 총 연결 수가 이 값이니 한 번 계산해 두자.
새 연결 수와 요청 수를 비교해 보기
정말 재사용되고 있는가:
· 새로 맺은 연결 수 대 요청 수
둘이 비슷하면 재사용이 안 되고 있다
클라이언트 라이브러리가 대개 지표를 제공한다
· 상대 처리 시간과 우리가 잰 왕복 시간의 차이
차이가 크면 연결 수립에 쓰이고 있다
· 소켓 상태 — 끊긴 연결이 쌓이고 있지 않은지
$ ss -s
TIME-WAIT 이 많으면 매번 맺고 끊는 중이다
· 클라이언트를 어디서 만드는지 코드로 확인
요청 처리 함수 안에서 만들고 있으면 그게 원인이다
· 우리 인스턴스 수 × 호스트당 연결 수
상대가 받아야 하는 총 연결 수를 계산해 본다핵심 한 줄연결을 재사용하지 않으면 매번 악수부터 다시 한다.