
Keep-Alive: 전화 끊지 말고 기다려
매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

웹사이트가 느린 이유를 파고들다가 Network 탭을 열어봤습니다.
결과:
logo.png - 300ms (Handshake에 250ms)
style.css - 280ms (Handshake에 240ms)
app.js - 290ms (Handshake에 245ms)
...
"Handshake만 100번 했네. Keep-Alive 안 켜져 있어."
사이트 로딩이 경쟁사보다 몇 배 느린 상황을 분석해보니 원인이 명확했다.
그때부터 TCP 연결과 Keep-Alive를 공부했습니다."서버는 빠른데 연결을 매번 끊어서 느린 거야."
처음엔 이 단순한 설정 하나가 어떻게 성능을 그렇게 좌우할 수 있는지 도저히 납득이 안 갔습니다. "연결을 유지한다"는 게 얼마나 중요한 건지, 왜 HTTP/1.0은 기본으로 꺼져 있었는지, 그리고 Keep-Alive를 켜면 서버가 죽지 않을까 하는 걱정까지 머릿속이 복잡했습니다.
무엇보다 "왜 이렇게 비효율적으로 만들었어?"라는 질문이 가장 컸습니다.
나중에 알고 보니 HTTP/1.0 시절엔 웹페이지가 HTML 하나만 받아오면 끝이었고, 서버도 동시 연결을 많이 감당하지 못했습니다. 그래서 "빨리 끊는 게" 오히려 효율적이었습니다. 하지만 웹이 발전하면서 한 페이지에 수십, 수백 개 파일을 받아야 하는 시대가 오자 이 방식은 병목이 되었습니다.
시니어의 비유가 완전히 와닿았습니다:
"아, 연결 재사용이구나!"HTTP/1.0 (연결 끊음): "친구한테 100개 질문 있어. 전화 건다 → 질문 1개 → 끊는다 전화 건다 → 질문 1개 → 끊는다 (100번 반복)
전화 거는 시간이 대답 듣는 시간보다 길어."
HTTP/1.1 (Keep-Alive): "전화 한 번 걸어. 질문 1, 2, 3... 100 다 물어봄. 다 끝나면 끊어.
전화 걸기 1번!"
그때 깨달았습니다. 결국 Keep-Alive는 "인프라 효율화"였습니다. 전화를 걸고 끊는 행위(TCP Handshake)는 실제 대화(데이터 전송)보다 훨씬 비쌌습니다. 이걸 100번 반복하느니, 한 번만 걸고 계속 대화하는 게 당연히 효율적이었습니다.
또 다른 비유로 택배를 떠올려봤습니다. 집 앞에 택배 기사가 와서 물건 하나 주고 가고, 5분 뒤 다시 와서 물건 하나 주고 가는 식이면 얼마나 비효율적일까요? 차라리 한 번 와서 물건 100개를 한꺼번에 전달하는 게 훨씬 낫습니다.
Client → Server: SYN (연결 요청)
Server → Client: SYN-ACK (알았어, 준비됨)
Client → Server: ACK (OK, 시작!)
→ 왕복 2번 (RTT x 2)
서울 → 미국 서버
RTT (Round Trip Time) = 200ms
Handshake = 200ms x 2 = 400ms
400ms는 엄청 깁니다!
이 400ms가 얼마나 큰 숫자인지 실감이 안 날 수도 있습니다. 하지만 사용자 경험 관점에서 보면 치명적입니다. Google은 검색 결과 표시가 0.5초 늦어지면 트래픽이 20% 감소한다는 연구 결과를 발표했습니다. 400ms는 그 절반에 가까운 시간입니다.
더 문제는 이게 "단 하나의 파일"을 받기 위한 준비 시간일 뿐이라는 겁니다. 실제 웹페이지는 HTML, CSS, JavaScript, 이미지, 폰트 등 수십~수백 개 리소스를 받아와야 합니다. Keep-Alive 없이 각각 연결을 맺는다면? 지옥입니다.
또한 RTT는 물리적 거리에 비례합니다. 서울-미국은 200ms지만, 서울-호주는 300ms 넘게 걸릴 수 있습니다. 빛의 속도로도 어쩔 수 없는 물리 법칙이기에, 이 비용을 줄이는 유일한 방법은 "연결을 재사용"하는 것입니다.
요청 1:
1. TCP Handshake (400ms)
2. logo.png 요청
3. 응답
4. 연결 끊음
요청 2:
1. TCP Handshake (400ms) ← 또!
2. style.css 요청
3. 응답
4. 연결 끊음
...100개 파일 → 100번 Handshake
총 시간: 400ms x 100 = 40초!
HTTP/1.0 시절 웹 개발은 이 문제를 회피하기 위해 온갖 트릭을 동원했습니다. CSS Sprite 기법(여러 이미지를 하나로 합치기), 파일 번들링, 인라인 스타일 등이 바로 그것입니다. 지금 생각하면 우회적인 해결책이었지만, 당시엔 필수였습니다.
나는 이 시절을 직접 겪진 않았지만, 레거시 코드를 보면서 "왜 이렇게 복잡하게 만들었지?"라고 의문을 가졌던 순간들이 있었습니다. 알고 보니 Keep-Alive가 없던 시절의 고통을 최소화하려는 노력이었습니다.
1. TCP Handshake (400ms) ← 단 1번!
2. logo.png 요청
3. 응답
4. style.css 요청 ← 연결 유지
5. 응답
6. app.js 요청
...
100. 모든 요청 완료
101. 연결 끊음
총 시간: 400ms + (파일 전송 시간)
처음 이걸 이해했을 때, "왜 진작 이렇게 안 했어?"라는 생각이 들었습니다. 하지만 곰곰이 생각해보니 트레이드오프가 있었습니다. 연결을 유지한다는 건 서버 입장에선 그 연결에 대한 메모리와 파일 디스크립터를 계속 점유한다는 뜻입니다.
1990년대 초반 웹 서버는 지금처럼 강력하지 않았고, 동시 연결 수천 개를 감당하기 어려웠습니다. 그래서 "빨리 끊는 게" 더 안전했습니다. 하지만 하드웨어가 발전하고, 웹페이지가 복잡해지면서 Keep-Alive의 이점이 압도적으로 커졌습니다.
결국 이 설정 하나가 웹 성능의 판도를 바꾼 셈입니다. HTTP/1.1이 1997년에 나왔으니, 거의 30년 가까이 우리는 Keep-Alive의 혜택을 누리고 있습니다.
GET /logo.png HTTP/1.0
Connection: keep-alive
서버 응답:
HTTP/1.0 200 OK
Connection: keep-alive
Keep-Alive: timeout=5, max=100
timeout=5: 5초간 idle이면 끊음max=100: 최대 100개 요청까지GET /logo.png HTTP/1.1
(Connection 헤더 없어도 자동 Keep-Alive)
연결 끊고 싶을 때만:
Connection: close
이 Connection: close 헤더를 언제 쓸까요? 몇 가지 경우가 있습니다:
대부분의 경우 Keep-Alive가 기본이라 신경 쓸 일이 없지만, 프록시나 로드 밸런서를 거치면서 Connection 헤더가 변형되는 경우가 있습니다. 이게 바로 실제로 제일 골치 아픈 부분입니다.
처음엔 이 둘을 같은 거라고 착각했습니다. 하지만 완전히 다른 레이어의 개념입니다.
목적: 연결이 살아있는지 확인
동작: 주기적으로 빈 패킷 전송 (보통 2시간마다)
설정: OS 레벨 (sysctl)
예시:
net.ipv4.tcp_keepalive_time = 7200 # 2시간
net.ipv4.tcp_keepalive_intvl = 75 # 75초마다 재시도
net.ipv4.tcp_keepalive_probes = 9 # 9번 실패하면 끊음
목적: 여러 HTTP 요청에 같은 TCP 연결 재사용
동작: 요청/응답 끝나도 연결 유지
설정: HTTP 헤더 (Connection, Keep-Alive)
예시:
Connection: keep-alive
Keep-Alive: timeout=5, max=100
나는 이 차이를 이해하는 데 시간이 꽤 걸렸습니다. TCP Keep-Alive는 "연결이 죽었는지 감지"하는 헬스체크고, HTTP Keep-Alive는 "연결을 재활용"하는 최적화 기법입니다. 목적이 완전히 다릅니다.
실제로 TCP Keep-Alive를 직접 조정할 일은 거의 없습니다. 대부분 OS 기본값(2시간)이면 충분합니다. 반면 HTTP Keep-Alive는 서버/프록시 설정에서 자주 만집니다.
const http = require('http');
const server = http.createServer((req, res) => {
res.setHeader('Connection', 'close'); // ❌ 매번 끊음
res.end('Hello');
});
server.keepAliveTimeout = 0; // Keep-Alive 꺼짐
server.listen(3000);
결과: 느림
const server = http.createServer((req, res) => {
// Connection 헤더 명시 안 해도 자동 Keep-Alive
res.end('Hello');
});
server.keepAliveTimeout = 5000; // 5초간 유지
server.maxRequestsPerSocket = 100; // 최대 100개 요청
server.listen(3000);
결과: 빠름 ⚡
Node.js의 keepAliveTimeout 기본값은 5초입니다. 이게 적당한 값인지는 트래픽 패턴에 따라 다릅니다. 일반적으로:
maxRequestsPerSocket도 중요합니다. 이 값을 너무 크게 잡으면 하나의 연결이 너무 오래 살아서 메모리 누수 위험이 있고, 너무 작게 잡으면 Keep-Alive 효과가 줄어듭니다. 100~1000 사이가 적당합니다.
HTTP Keep-Alive를 이해하고 나니, 데이터베이스 연결 풀(Connection Pool)도 똑같은 개념이라는 걸 깨달았습니다.
// ❌ 나쁜 예
async function getUser(id) {
const connection = await mysql.createConnection({
host: 'localhost',
user: 'root',
password: 'pass'
});
const [rows] = await connection.query('SELECT * FROM users WHERE id = ?', [id]);
await connection.end(); // 매번 끊음!
return rows[0];
}
// 100번 호출 → 100번 연결 생성/종료
// ✅ 좋은 예
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: 'pass',
connectionLimit: 10 // 10개 연결 유지
});
async function getUser(id) {
const [rows] = await pool.query('SELECT * FROM users WHERE id = ?', [id]);
return rows[0]; // 연결 반납 (끊지 않음)
}
// 100번 호출 → 10개 연결 재사용
데이터베이스 연결을 맺는 비용은 TCP Handshake보다 훨씬 큽니다. 인증, 권한 확인, 세션 초기화 등 여러 단계를 거치기 때문입니다. 그래서 Connection Pool 없이 운영하면 성능이 끔찍하게 나빠집니다.
나는 이 둘이 같은 원리라는 걸 깨닫고 나서, "인프라 설계의 공통 패턴"이라는 관점으로 바라보게 되었습니다. 결국 "비싼 리소스는 재사용하라"는 철학입니다.
Keep-Alive 적용 전후를 이론적으로 계산해보면 차이가 명확하다:
100개 파일 로드
평균 응답 시간: 4.2초
Handshake 총 시간: 38초
100개 파일 로드
평균 응답 시간: 1.1초
Handshake 총 시간: 0.4초 (1번만!)
약 3.8배 빨라진다.
Keep-Alive를 적용하면 성능이 크게 개선된다고 한다. 사용자는 단지 "빨라졌네"라고 느낄 뿐이지만, 개발자 입장에선 엄청난 차이다. 서버 부하도 줄어들고, 네트워크 비용도 줄어든다.
특히 모바일 환경에서 차이가 극명하다. 4G LTE 환경에서 RTT가 50100ms 정도 되는데, Keep-Alive 없이 100개 파일 받으면 510초가 추가로 소요된다. 사용자 이탈로 직결되는 수치다.
동시 접속 10,000명
Keep-Alive timeout = 60초
→ 10,000개 TCP 연결 유지
→ 메모리 소비 ↑
해결책: timeout 짧게 설정 (5~10초)
사용자가 페이지 다 받고 떠남
→ 서버는 60초간 연결 유지
→ 낭비!
해결책: 적절한 max requests 설정
timeout을 120초로 설정하면 동시 접속자가 많을 때 파일 디스크립터가 고갈될 수 있습니다. Linux의 ulimit -n 기본값이 1024인데, Keep-Alive 연결이 쌓이면서 넘어버리는 상황이 생기기 때문입니다.
timeout을 10초로 줄이고, ulimit을 65536으로 올리면 이 문제가 해결됩니다. Keep-Alive는 마법이 아니라 트레이드오프라는 걸 이해해야 합니다.
실제로 쓸 수 있는 설정 예시:
http {
# Keep-Alive 활성화
keepalive_timeout 65; # 65초간 유지
keepalive_requests 100; # 연결당 최대 100개 요청
# Upstream 서버와도 Keep-Alive
upstream backend {
server 127.0.0.1:3000;
keepalive 32; # 32개 연결 풀 유지
}
server {
location / {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection ""; # Keep-Alive 유지
}
}
}
여기서 proxy_set_header Connection "";가 중요합니다. 이게 없으면 Nginx가 백엔드 서버로 요청할 때마다 연결을 끊어버립니다. 처음엔 이걸 몰라서 "왜 Nginx 쓰는데도 느리지?"라고 고민했습니다.
keepalive 32;는 Nginx가 백엔드 서버와 유지하는 연결 풀 크기입니다. 백엔드 서버가 여러 대면 이 값을 늘려야 합니다. 보통 백엔드 서버 수 x 10 정도로 설정합니다.
Network 탭 → 파일 클릭 → Timing 탭
Queueing: 0.5ms
Stalled: 0.2ms
DNS Lookup: 0ms ← 재사용!
Initial connection: 0ms ← Keep-Alive!
SSL: 0ms ← 재사용!
Request sent: 0.1ms
Waiting (TTFB): 50ms
Content Download: 10ms
Connection 0ms = Keep-Alive 작동 중!
브라우저는 보통 한 도메인당 6개 연결만 동시에 엽니다. 이게 무슨 의미일까요?
example.com에서 100개 파일 다운로드
Keep-Alive OFF:
파일 1~6: 병렬 다운로드 (각각 새 연결)
파일 7~12: 대기 → 1~6 끝나면 시작 (각각 새 연결)
...
→ 총 100번 연결 생성
Keep-Alive ON:
파일 1~6: 병렬 다운로드 (6개 연결)
파일 7~12: 같은 6개 연결 재사용
...
→ 총 6개 연결만 유지
이 제한은 HTTP/1.1 시절 서버 부하를 줄이기 위한 일종의 매너였습니다. 하지만 Keep-Alive와 결합하면 엄청난 효율을 발휘합니다.
예전엔 이 제한을 우회하기 위해 도메인 샤딩(Domain Sharding) 기법을 썼습니다:
img1.example.com
img2.example.com
img3.example.com
각 도메인당 6개씩, 총 18개 연결을 열 수 있었습니다. 하지만 HTTP/2 이후론 이런 트릭이 불필요해졌습니다.
연결 1개
요청 1 → 응답 1
요청 2 → 응답 2 (순차적)
문제: Head-of-Line Blocking
연결 1개
요청 1, 2, 3 동시 전송
응답 3, 1, 2 순서 무관하게 수신
Keep-Alive 필수!
HTTP/2는 Keep-Alive를 한 단계 더 발전시켰습니다. HTTP/1.1에선 연결 하나에 요청을 순차적으로 보냈지만, HTTP/2는 하나의 연결에 여러 요청을 동시에(Multiplexing) 보냅니다.
이게 가능한 이유는 HTTP/2가 "스트림" 개념을 도입했기 때문입니다. 각 요청/응답이 독립적인 스트림 ID를 가지고, 하나의 TCP 연결 안에서 인터리빙됩니다.
나는 처음 이걸 이해했을 때 "그럼 브라우저 6개 연결 제한이 의미 없네?"라고 생각했습니다. 맞습니다. HTTP/2에선 도메인당 1개 연결만 써도 충분합니다. 오히려 여러 연결 열면 오버헤드만 늘어납니다.
HTTP/3는 TCP 대신 QUIC을 사용합니다. QUIC은 UDP 기반이라 3-Way Handshake가 없습니다.
HTTP/1.1 + TLS:
TCP Handshake (1 RTT) + TLS Handshake (1~2 RTT) = 2~3 RTT
HTTP/3 + QUIC:
QUIC Handshake (0~1 RTT) = 0~1 RTT
첫 연결: 1 RTT 재연결: 0 RTT (세션 재개)
QUIC의 0-RTT는 정말 마법 같습니다. 이전에 연결했던 서버면 Handshake 없이 바로 데이터 전송이 가능합니다. Keep-Alive의 극한까지 끌어올린 셈입니다.
하지만 QUIC도 연결을 유지하는 건 마찬가지입니다. 다만 TCP보다 연결 복구가 빠르고(Connection Migration), 모바일 환경에서 IP 변경에 강합니다.
결국 Keep-Alive라는 철학은 HTTP/3에서도 계속됩니다. "비싼 연결은 재사용하라"는 원칙은 변하지 않습니다.
CDN을 사용하면 Keep-Alive 효과가 배가됩니다.
사용자 → CDN (서울) → Origin 서버 (미국)
Without CDN:
사용자 ↔ 미국 서버 (RTT 200ms)
Handshake = 400ms
With CDN:
사용자 ↔ 서울 CDN (RTT 10ms)
Handshake = 20ms
CDN ↔ 미국 서버는 Keep-Alive 연결 풀 유지
CDN은 Origin 서버와 장시간 Keep-Alive 연결을 유지합니다. 사용자 입장에선 서울 CDN까지만 Handshake하면 되니 엄청나게 빨라집니다.
CDN 도입 전후 RTT를 비교해보면 차이가 극명하다:
Keep-Alive와 CDN을 결합하면 성능이 크게 개선된다고 한다. 물리적 거리를 줄이고 연결을 재사용하는 두 원리가 합쳐지기 때문이다.
Client → LB → Server
LB timeout: 10초
Server timeout: 60초
→ LB가 먼저 끊어버림!
해결: LB timeout ≥ Server timeout
AWS ALB의 기본 timeout이 60초인데, 백엔드 서버의 Keep-Alive timeout을 120초로 설정하면 문제가 생긴다. ALB가 60초에 연결을 끊었는데 백엔드 서버는 아직 살아있다고 착각해서 응답을 보내버리고, 클라이언트는 502 에러를 받는다.
교훈: 항상 LB timeout을 서버 timeout보다 크거나 같게 설정하세요.
Client ↔ Proxy ↔ Server
Proxy가 Connection: close 강제
→ Keep-Alive 무효화
해결: Proxy 설정 확인
Squid 프록시를 쓸 때 기본 설정이 Connection: close인 경우가 있다. 백엔드 서버가 Keep-Alive를 아무리 켜도 프록시가 다 끊어버린다. 설정 파일에서 persistent_connection_after_error on을 추가하면 해결된다.
Nginx 설정에서 이 한 줄을 빼먹으면 Keep-Alive가 예상대로 동작하지 않는다:
proxy_set_header Connection "";
이게 없으면 Nginx가 백엔드로 요청할 때 Connection: close를 보냅니다. 백엔드 입장에선 "왜 매번 끊지?"하고 당황합니다.
HTTP/2를 쓴다고 Keep-Alive를 신경 안 써도 되는 건 아닙니다. 오히려 더 중요합니다.
도메인당 6개 연결
각 연결마다 Keep-Alive
총 6개 TCP 연결 유지
도메인당 1개 연결
하나의 연결에 모든 요청 Multiplexing
총 1개 TCP 연결 유지 (Keep-Alive 필수!)
HTTP/2는 단 하나의 연결에 의존하기 때문에, 그 연결이 끊기면 모든 요청이 멈춥니다. 그래서 Keep-Alive 설정이 더 중요해집니다.
또한 HTTP/2는 Server Push 기능이 있는데, 이것도 Keep-Alive 연결이 있어야만 동작합니다. 서버가 클라이언트 요청 없이 리소스를 미리 푸시하려면, 열려있는 연결이 필요하기 때문입니다.
| 항목 | HTTP/1.0 | HTTP/1.1 |
|---|---|---|
| 기본값 | OFF | ON |
| 헤더 | Connection: keep-alive | (생략 가능) |
| 끊기 | Connection: close | Connection: close |
| Timeout | 명시 필요 | 서버 기본값 |
| 성능 | 느림 | 빠름 ⚡ |
처음엔 "왜 연결을 유지해야 해?"라고 의문이었습니다.
지금은 이해합니다:
"연결 맺는 비용 >> 데이터 전송 비용"제가 배운 교훈:
TCP 연결도 마찬가지입니다. 매번 새로 만들지 말고, 이미 있는 걸 아껴 쓰세요. 그게 바로 Keep-Alive의 본질입니다.
결국 이거였습니다. 웹 성능 최적화의 가장 기본이면서도 가장 강력한 무기. 설정 하나로 3.8배 빨라질 수 있다는 사실. Keep-Alive 없는 웹은 상상할 수 없습니다.