3방향 핸드셰이크
고친 사람 github-actions[bot]
3방향 핸드셰이크는 두 컴퓨터가 데이터를 주고받기 전에 연결을 맺어 주는 절차입니다. 신호를 세 번 주고받아 양쪽 모두 상대에게 말이 닿는다는 것을 확인합니다. 웹 요청도 대개 이 확인을 거친 뒤에 나갑니다.
쉽고 빠른 이해
3방향 핸드셰이크는 데이터를 보내기 전에 상대와 연결을 맺는 인사입니다. 브라우저가 서버에 페이지를 청할 때도 요청보다 이 인사가 먼저 오갑니다.
보내는 쪽은 데이터를 조각으로 나눠 보냅니다. 조각마다 번호를 붙입니다. 받는 쪽은 번호가 비면 조각이 빠진 것을 압니다. 번호가 거꾸로 오면 순서가 뒤바뀐 것을 압니다.
번호는 양쪽이 연결마다 고른 시작 번호에서 셉니다. 상대의 시작 번호를 모르면 첫 조각부터 셀 수가 없습니다. 인사할 때 서로의 시작 번호를 맞춰 두어야 번호를 셀 기준이 생깁니다. 이 인사가 없으면 보내는 쪽은 상대가 받을 준비가 됐는지도 모릅니다.
어떻게 도는가:
- 클라이언트가 「연결하자, 내 시작 번호는 이것」이라는 신호를 보냅니다
- 서버가 「네 번호 받았다, 내 시작 번호는 이것」이라고 답합니다
- 클라이언트가 「네 번호도 받았다」고 답하면 연결이 열립니다
대가는 기다림입니다. 첫 요청을 보내기 전에 신호가 한 번 오갈 만큼 기다려야 합니다. 서버가 멀수록 이 기다림이 깁니다. 그래서 한 번 맺은 연결을 여러 요청에 다시 씁니다.
모든 통신이 이 인사를 거치지는 않습니다. 도메인 이름을 주소로 바꾸는 질의처럼 질문 하나에 답 하나로 끝나는 통신은 인사 없이 곧바로 보내는 방식을 쓰기도 합니다.
상세
3방향 핸드셰이크(three-way handshake)는 TCP(Transmission Control Protocol, 전송 제어 프로토콜)가 연결을 맺을 때 밟는 절차입니다. TCP 는 데이터를 빠짐없이 순서대로 전하는 일을 맡는 프로토콜입니다. 이 약속을 지키려면 데이터를 보내기 전에 양쪽이 맞춰 둘 것이 있습니다. 그 맞추기가 3방향 핸드셰이크입니다.
전화 통화의 첫머리와 닮았습니다. 「여보세요, 제 말 들리세요?」라고 묻습니다. 상대가 「네, 들려요. 제 말도 들리세요?」라고 되묻습니다. 「네, 들려요」라는 답까지 오가야 두 사람 다 안심하고 본론을 꺼냅니다.
양쪽이 맞춰 두는 시퀀스 번호
TCP 는 보내는 데이터를 세그먼트라는 조각에 나눠 담습니다. 조각은 가는 길에 사라지기도 합니다. 순서가 뒤바뀌어 닿기도 합니다. 받는 쪽은 이것을 알아채야 빠진 조각을 다시 청할 수 있습니다.
세그먼트는 제어 정보를 적는 머리와 데이터를 담는 몸으로 되어 있습니다. 몸이 비어 머리만 가는 세그먼트도 있습니다. 핸드셰이크에서 오가는 세 신호가 바로 이런 세그먼트입니다.
그 기준이 시퀀스 번호입니다. 보내는 쪽은 데이터의 바이트마다 번호를 매깁니다. 세그먼트 머리에는 그 조각의 첫 바이트 번호를 적습니다. 받는 쪽은 번호가 비면 빠진 조각이 있다는 것을 압니다.
받는 쪽은 어디까지 받았는지도 알려 줍니다. 이 답을 확인 응답(acknowledgment)이라고 부릅니다. 확인 응답에는 다음에 받을 바이트 번호를 적습니다. 1000 번 바이트까지 받았으면 1001 을 적는 식입니다.
번호는 0 에서 시작하지 않습니다. 양쪽이 연결마다 시작 번호를 새로 고릅니다. 이 시작 번호를 초기 시퀀스 번호라고 합니다. 앞서 같은 상대와 맺었다 끊은 연결의 세그먼트가 늦게 닿아도 새 연결의 것과 섞이지 않게 하려는 것입니다.
시작 번호는 남이 짐작하기 어렵게 고릅니다. 번호를 맞힌 사람이 가짜 세그먼트를 끼워 넣지 못하게 하려는 것입니다.
이렇게 고른 시작 번호를 상대는 모릅니다. 모르면 첫 세그먼트가 빠졌는지조차 알 수 없습니다. 그래서 데이터를 보내기 전에 시작 번호를 알려야 합니다.
데이터는 양쪽으로 흐릅니다. 번호도 방향마다 따로 있습니다. 그래서 방향마다 「내 시작 번호는 이것」과 「네 시작 번호 받았다」가 하나씩, 모두 넷이 오가야 합니다. 핸드셰이크가 이 넷을 주고받습니다.
세 세그먼트가 오가는 순서
이 소절은 클라이언트가 서버에 연결을 청하는 핸드셰이크 한 번을 따라갑니다. 클라이언트의 초기 시퀀스 번호는 1000, 서버의 것은 5000 으로 둡니다.
세그먼트 머리에는 그 세그먼트의 성격을 알리는 한 비트짜리 칸이 여럿 있습니다. 이 칸을 플래그라고 부릅니다. 칸을 켜면 그 성격을 띤 세그먼트라는 뜻입니다.
핸드셰이크에는 플래그 둘이 쓰입니다. SYN(synchronize, 동기화) 플래그는 「내 시작 번호에 맞춰 달라」는 표시입니다. ACK(acknowledgment, 확인 응답) 플래그는 확인 응답 번호를 담았다는 표시입니다.
핸드셰이크의 세그먼트는 켠 플래그 이름으로 부릅니다. SYN 만 켜면 SYN, 둘 다 켜면 SYN-ACK, ACK 만 켜면 ACK 입니다.
아래 그림에서 seq 는 시퀀스 번호 칸입니다. ack 는 확인 응답 번호 칸입니다.
sequenceDiagram
participant 클라이언트
participant 서버
클라이언트->>서버: SYN · seq=1000
서버->>클라이언트: SYN-ACK · seq=5000 · ack=1001
클라이언트->>서버: ACK · seq=1001 · ack=5001
Note over 클라이언트,서버: 연결이 열린다
첫째 세그먼트는 SYN 플래그만 켭니다. 클라이언트는 이 세그먼트로 자기 시작 번호 1000 을 알립니다. 연결을 청하는 신호도 이것입니다.
둘째 세그먼트는 SYN 과 ACK 를 함께 켠 SYN-ACK 입니다. 서버는 자기 시작 번호 5000 을 알립니다. 확인 응답 칸에는 1001 을 적어 클라이언트의 번호를 받았다고 답합니다.
1000 이 아니라 1001 인 까닭은 SYN 이 번호 하나를 차지하기 때문입니다. SYN 에는 데이터가 없습니다. 그래도 받았다는 답을 받아야 하는 신호라서 바이트 하나처럼 번호를 씁니다. 클라이언트의 첫 데이터는 1001 번부터 붙습니다.
셋째 세그먼트는 ACK 플래그만 켭니다. 확인 응답 칸의 5001 이 서버의 번호를 받았다는 답입니다. 클라이언트는 이 세그먼트를 보내는 순간, 서버는 받는 순간 연결이 열린 것으로 봅니다.
둘이 아니라 셋인 까닭
「양쪽이 맞춰 두는 시퀀스 번호」 소절 끝에서 오가야 할 것은 넷이었습니다. 방향마다 「내 시작 번호」와 「네 번호 받았다」가 하나씩입니다. 아래 표는 이 넷이 어느 세그먼트에 실리는지 보입니다.
| 할 일 | 싣는 세그먼트 |
|---|---|
| 클라이언트가 자기 시작 번호를 알린다 | 첫째 · SYN |
| 서버가 그 번호를 받았다고 답한다 | 둘째 · SYN-ACK 의 ACK |
| 서버가 자기 시작 번호를 알린다 | 둘째 · SYN-ACK 의 SYN |
| 클라이언트가 그 번호를 받았다고 답한다 | 셋째 · ACK |
서버가 보낼 둘을 한 세그먼트에 합친 것이 SYN-ACK 입니다. 그래서 넷이 셋으로 줄었습니다. 더 줄이면 빠지는 것이 생깁니다.
둘에서 멈추면 서버는 자기 시작 번호가 클라이언트에 닿았는지 모릅니다. 셋째 세그먼트가 와야 서버도 확인을 받습니다.
셋째 세그먼트는 늦게 닿은 옛 요청도 걸러 냅니다. 클라이언트가 예전에 보냈던 SYN 이 길에서 오래 헤매다 이제야 서버에 닿는 일이 있습니다. 서버는 이것이 옛 요청인지 모릅니다. 둘만 주고받는 방식이라면 서버는 여기서 연결을 열어 버립니다.
셋을 주고받으면 클라이언트가 이 실수를 막습니다. 아래 그림의 RST(reset, 재설정)는 연결을 받아들이지 않겠다고 알리는 플래그입니다.
sequenceDiagram
participant 클라이언트
participant 서버
Note over 클라이언트,서버: 예전에 보낸 SYN 이 늦게 닿는다
클라이언트->>서버: 옛 SYN · seq=700
서버->>클라이언트: SYN-ACK · ack=701
Note over 클라이언트: 지금 청한 적 없는 번호다
클라이언트->>서버: RST
Note over 서버: 연결을 열지 않고 버린다
클라이언트는 SYN-ACK 의 확인 응답 번호를 봅니다. 지금 청한 연결과 맞지 않으면 ACK 대신 RST 를 보냅니다. 서버는 열려던 연결을 버립니다. 오지 않을 데이터를 기다리느라 메모리를 붙잡는 일이 생기지 않습니다.
연결 상태가 옮겨 가는 모습
TCP 는 연결마다 지금 어느 단계인지를 상태로 적어 둡니다. 핸드셰이크는 이 상태를 몇 번 바꿉니다. 세그먼트 하나가 오갈 때마다 양쪽 상태가 한 칸씩 옮겨 갑니다.
CLOSED 는 연결이 없는 상태입니다. 서버는 연결을 받기 전에 LISTEN 상태로 기다립니다. 정해 둔 포트로 오는 SYN 을 받겠다는 상태입니다. 포트는 한 컴퓨터 안에서 어느 프로그램에 온 연결인지 가르는 번호입니다.
클라이언트는 SYN 을 보내면 SYN-SENT 가 됩니다. 서버는 SYN 에 SYN-ACK 로 답하면 SYN-RECEIVED 가 됩니다. 양쪽 모두 마지막에는 ESTABLISHED 에 닿습니다. 연결이 열려 데이터를 주고받는 상태입니다.
sequenceDiagram
participant 클라이언트
participant 서버
Note over 클라이언트: CLOSED
Note over 서버: LISTEN
클라이언트->>서버: SYN
Note over 클라이언트: SYN-SENT
Note over 서버: SYN-RECEIVED
서버->>클라이언트: SYN-ACK
Note over 클라이언트: ESTABLISHED
클라이언트->>서버: ACK
Note over 서버: ESTABLISHED
netstat 이나 ss 같은 명령은 연결마다 이 상태 이름을 보여 줍니다. 도구에 따라 SYN_SENT 처럼 밑줄로 적기도 합니다. 서버에 SYN-RECEIVED 상태의 연결이 잔뜩 쌓여 있다면 셋째 ACK 가 돌아오지 않는 연결이 많다는 뜻입니다.
연결을 끊을 때는 다른 절차를 밟습니다. 양쪽이 각자 FIN(finish, 종료) 플래그로 끝을 알립니다. 상대는 그것을 받았다고 답합니다. 이 네 번의 교환이 4방향 핸드셰이크입니다.
코드에서 핸드셰이크가 일어나는 곳
백엔드 코드는 핸드셰이크를 직접 다루지 않습니다. 운영체제의 커널이 대신 치릅니다. 커널은 하드웨어와 네트워크를 직접 다루는 운영체제의 중심부입니다.
프로그램은 소켓을 열어 함수 몇 개를 부를 뿐입니다. 소켓은 프로그램이 네트워크 연결을 다루려고 커널에게서 받는 손잡이입니다. 아래는 파이썬으로 적은 것입니다. 다른 언어도 이름만 조금 다른 같은 함수를 부릅니다.
srv.listen() # LISTEN 이 됨
conn, _ = srv.accept() # 다 맺은 연결
sock.connect(addr) # 세 번 오간 뒤 반환
첫 두 줄은 서버 쪽입니다. 셋째 줄은 클라이언트 쪽입니다. 오른쪽 주석이 그 줄을 부른 뒤의 결과입니다.
listen() 을 부르면 커널은 그 포트로 오는 SYN 에 답하기 시작합니다. 서버 프로그램이 아무것도 안 해도 핸드셰이크는 커널 안에서 끝납니다. 다 맺은 연결은 대기열에 쌓입니다. 이 대기열의 길이가 listen 백로그입니다.
accept() 는 핸드셰이크를 하지 않습니다. 대기열에서 이미 맺어진 연결을 하나 꺼낼 뿐입니다. 서버가 바빠서 accept() 를 늦게 부르면 대기열이 찹니다. 가득 찬 뒤에 오는 연결 요청은 받아들여지지 않습니다.
connect() 를 부르면 커널이 SYN 을 보냅니다. 함수는 SYN-ACK 가 와서 ACK 까지 보낸 뒤에 돌아옵니다. connect() 가 오래 걸린다면 핸드셰이크 단계에서 기다리고 있는 것입니다.
첫 요청까지 걸리는 시간
신호가 상대에게 갔다가 돌아오는 데 걸리는 시간을 왕복 시간이라고 합니다. 클라이언트는 SYN 을 보낸 뒤 SYN-ACK 가 돌아올 때까지 왕복 한 번을 기다립니다. 핸드셰이크의 비용이 이 기다림입니다.
클라이언트는 셋째 ACK 를 보내자마자 데이터를 보낼 수 있습니다. ACK 와 같은 세그먼트에 요청을 실어 보내기도 합니다. 그래도 첫 요청이 나가는 것은 왕복 한 번이 지난 뒤입니다. 응답까지 받으려면 왕복이 두 번 듭니다.
암호화 연결을 쓰면 비용이 더 붙습니다. TLS(Transport Layer Security, 전송 계층 보안)는 TCP 연결이 열린 뒤에 자기 인사를 따로 치릅니다. 이 TLS 핸드셰이크에도 왕복이 듭니다.
요청마다 연결을 새로 맺으면 이 왕복을 요청마다 치릅니다. 흔한 대책은 한 번 맺은 연결을 여러 요청에 다시 쓰는 것입니다. 웹 요청의 지속 연결과 데이터베이스 드라이버의 커넥션 풀이 그렇게 합니다.
답이 없거나 거절될 때
연결이 안 될 때 핸드셰이크가 어디서 멈췄는지 보면 원인이 좁혀집니다. SYN 을 보낸 뒤에는 세 경우로 갈립니다.
flowchart TD
S["클라이언트가 SYN 을 보냄"] --> Q1{"SYN 이 서버에 닿았나"}
Q1 -->|아니오| W["답이 없다 · SYN 을 다시 보냄"]
W --> T["끝내 답이 없으면 타임아웃"]
Q1 -->|예| Q2{"그 포트에서 기다리는 프로그램이 있나"}
Q2 -->|있음| OK["SYN-ACK 가 옴 · 연결이 이어짐"]
Q2 -->|없음| R["RST 가 옴 · 연결 거부"]
포트에서 기다리는 프로그램이 없으면 서버의 커널이 곧바로 RST 로 답합니다. 클라이언트 프로그램은 바로 연결 거부 오류를 받습니다. 흔히 보는 Connection refused 가 이것입니다. SYN 은 서버 컴퓨터까지 닿았습니다. 그 포트에 서버 프로그램이 안 떠 있다는 뜻입니다.
방화벽이 SYN 을 조용히 버리면 아무 답도 오지 않습니다. 서버 컴퓨터가 꺼져 있을 때도 같습니다. 클라이언트의 커널은 SYN 을 몇 번 재전송합니다. 다시 보낼 때마다 기다리는 시간을 늘립니다.
재전송을 다 해도 답이 없으면 연결 타임아웃 오류가 납니다. 이 오류는 한참 뒤에야 옵니다. 왕복 한 번 만에 오는 거부와 다른 점입니다.
그래서 오류가 곧바로 나는지 한참 뒤에 나는지만 봐도 원인이 갈립니다. 곧바로 나면 서버 프로그램 쪽을 먼저 봅니다. 한참 뒤에 나면 그 사이의 길을 먼저 봅니다.
반쯤 열린 연결을 노리는 SYN 플러드
서버는 SYN-ACK 를 보낸 뒤 셋째 ACK 를 기다립니다. 그동안 커널은 SYN-RECEIVED 상태인 이 연결의 정보를 메모리에 적어 둡니다. 이렇게 핸드셰이크가 끝나지 않은 연결을 반쯤 열린 연결(half-open connection)이라고 부릅니다.
SYN 플러드는 이 기다림을 노리는 공격입니다. 공격자는 보낸 쪽 주소를 가짜로 적은 SYN 을 쉴 새 없이 보냅니다. 서버의 SYN-ACK 는 엉뚱한 곳으로 갑니다. 셋째 ACK 는 끝내 오지 않습니다.
반쯤 열린 연결을 적어 둘 수 있는 수에는 한도가 있습니다. 이 한도가 가짜 연결로 차면 진짜 사용자의 SYN 을 받을 수 없습니다. 서버 프로그램은 멀쩡히 떠 있어도 새 연결이 안 들어옵니다. 이렇게 서비스를 못 쓰게 만드는 공격을 서비스 거부 공격이라고 합니다.
SYN 쿠키는 이 한도를 쓰지 않고 SYN 을 받는 방법입니다. 서버는 SYN 을 받아도 아무것도 적어 두지 않습니다. 누가 어느 포트로 청했는지를 섞어 만든 값을 자기 초기 시퀀스 번호로 삼아 SYN-ACK 에 싣습니다.
셋째 ACK 의 확인 응답 번호는 서버 시작 번호에 1 을 더한 값입니다(「세 세그먼트가 오가는 순서」 소절). 그래서 진짜 클라이언트의 ACK 가 돌아오면 서버는 그 번호에서 1 을 빼 자기가 보냈던 값을 되찾습니다. 되찾은 값이 이 클라이언트와 맞으면 그제야 연결을 적어 둡니다.
핸드셰이크 없이 보내는 통신
모든 통신이 이 절차를 거치지는 않습니다. UDP(User Datagram Protocol, 사용자 데이터그램 프로토콜)는 연결을 맺지 않습니다. 첫 데이터를 곧바로 보냅니다. 빠짐과 순서를 챙기는 일은 UDP 를 쓰는 프로그램이 맡습니다.
한 번 묻고 한 번 답하면 끝나는 통신은 UDP 를 자주 씁니다. DNS(Domain Name System, 도메인 이름 시스템) 질의가 그 예입니다. 질문 하나를 보내려고 왕복 한 번을 더 쓰는 것이 아깝기 때문입니다.
TCP 를 쓰면서 이 왕복을 줄이는 확장도 있습니다. TCP Fast Open은 전에 연결한 적 있는 클라이언트가 SYN 에 데이터를 실어 보내게 합니다.
관련 항목
3방향 핸드셰이크가 속하는 상위 분류
TCP · 전송 계층 · 프로토콜 · 네트워크 · 인터넷 프로토콜 스위트 · OSI 모형
3방향 핸드셰이크를 이루는 세그먼트와 필드
세그먼트 · TCP 헤더 · TCP 플래그 · 시퀀스 번호 · 초기 시퀀스 번호 · 확인 응답 · RST · FIN
3방향 핸드셰이크가 거쳐 가는 연결 상태
TCP 상태 · 상태 기계 · 반쯤 열린 연결 · TIME_WAIT · CLOSE_WAIT
연결을 맺고 끊는 다른 핸드셰이크
4방향 핸드셰이크 · TLS 핸드셰이크 · TLS · 연결 지향
3방향 핸드셰이크를 커널에 맡기는 소켓 함수
소켓 · listen · accept · connect · listen 백로그 · 커널 · 포트
3방향 핸드셰이크가 늘리는 지연과 그 대책
왕복 시간 · 지연 · 지속 연결 · 커넥션 풀 · 킵얼라이브 · TCP Fast Open · 0-RTT
3방향 핸드셰이크가 실패할 때 보이는 오류
연결 거부 · ECONNREFUSED · 타임아웃 · 재전송 · 방화벽 · 패킷 손실
3방향 핸드셰이크를 노리는 공격과 방어
SYN 플러드 · SYN 쿠키 · 서비스 거부 공격 · IP 스푸핑 · DDoS
TCP 대신 UDP 위에서 도는 통신
3방향 핸드셰이크를 들여다보는 도구
netstat · ss · tcpdump · Wireshark
다른 이름: three-way handshake · 3-way handshake · 쓰리웨이 핸드셰이크 · 세 방향 핸드셰이크 · TCP 핸드셰이크