사전 3방향 핸드셰이크
프로토콜

3방향 핸드셰이크

gabury1고친 사람 github-actions[bot]

3방향 핸드셰이크는 두 컴퓨터가 데이터를 주고받기 전에 연결을 맺어 줍니다. 신호를 세 번 주고받아 양쪽이 서로의 시작 번호를 확인합니다. 웹 요청도 대개 이 절차를 거친 뒤에 나갑니다.

쉽고 빠른 이해

3방향 핸드셰이크는 데이터를 보내기 전에 상대와 연결을 맺는 인사입니다. 웹 페이지를 새 연결로 청할 때도 요청보다 이 인사가 먼저 오갑니다.

데이터에는 바이트마다 번호가 붙습니다. 받는 쪽은 번호를 보고 빠진 조각과 두 번 온 조각을 가려냅니다. 번호는 양쪽이 연결마다 새로 고른 시작 번호에서 셉니다. 상대의 시작 번호를 모르면 셀 기준이 없습니다. 그래서 데이터보다 먼저 서로의 시작 번호를 알리고 받았다고 답합니다.

어떻게 도는가:

  1. 클라이언트가 「연결하자, 내 시작 번호는 이것」이라고 보냅니다
  2. 서버가 「네 번호 받았다, 내 시작 번호는 이것」이라고 답합니다
  3. 클라이언트가 「네 번호도 받았다」고 답하면 연결이 열립니다

세 번째 차례는 하는 일이 하나 더 있습니다. 길에서 헤매다 늦게 닿은 옛 연결 요청에 서버가 답해도, 클라이언트가 이 차례에 거절할 수 있습니다.

대가는 기다림입니다. 첫 요청을 보내기 전에 신호가 한 번 갔다 올 만큼 기다려야 합니다. 그래서 한 번 맺은 연결을 여러 요청에 다시 쓰곤 합니다.

상세

3방향 핸드셰이크(three-way handshake)는 TCP(Transmission Control Protocol, 전송 제어 프로토콜)가 연결을 맺는 절차입니다. TCP 는 데이터를 빠짐없이 순서대로 전하는 일을 맡는 프로토콜입니다. 이 일을 하려면 데이터를 보내기 전에 양쪽이 맞춰 둘 값이 있습니다. 그 값을 맞추는 교환이 3방향 핸드셰이크입니다.

보통은 한쪽이 먼저 청합니다. 다른 쪽은 그 청에 답합니다. 이 문서는 먼저 청하는 쪽을 클라이언트, 기다리다 답하는 쪽을 서버라고 부릅니다. 양쪽이 한꺼번에 청해도 절차는 돕니다. 그 경우는 「교환 순서」 절의 「동시 개방」 소절이 다룹니다.

바이트마다 붙는 시퀀스 번호

TCP 는 보낼 데이터를 조각으로 나눠 보냅니다. 이 조각을 세그먼트라고 부릅니다. 세그먼트는 제어 정보를 적는 헤더와 데이터를 담는 본문으로 되어 있습니다. 본문이 비어 헤더만 가는 세그먼트도 있습니다.

TCP 는 보내는 데이터의 바이트마다 번호를 붙입니다. 이 번호가 시퀀스 번호(sequence number)입니다. 세그먼트 헤더에는 그 세그먼트에 담긴 첫 바이트의 번호를 적습니다. 예외가 하나 있습니다. 아래에 나오는 시작 번호를 알리는 세그먼트는 데이터의 번호 대신 그 시작 번호를 적습니다.

받는 쪽은 이 번호로 두 가지를 압니다. 번호가 비면 빠진 조각이 있습니다. 같은 번호가 또 오면 다시 보낸 조각이 겹쳐 온 것입니다.

받는 쪽은 어디까지 받았는지를 답으로 알립니다. 이 답을 확인 응답(acknowledgment)이라고 부릅니다. 확인 응답에는 다음에 받기를 기다리는 번호를 적습니다. 101 을 적으면 100 번까지는 다 받았다는 뜻입니다.

헤더의 플래그 SYN 과 ACK

세그먼트 헤더에는 한 비트짜리 표시 칸이 여럿 있습니다. 이 칸을 제어 비트 또는 플래그라고 부릅니다. 칸을 켜면 그 성격을 띤 세그먼트라는 뜻입니다.

핸드셰이크에는 두 플래그가 쓰입니다. SYN(synchronize, 동기화)은 「내 시작 번호에 맞춰 달라」는 표시입니다. ACK(acknowledgment, 확인 응답)는 확인 응답 번호 칸에 값을 담았다는 표시입니다.

세그먼트는 켠 플래그 이름으로 부릅니다. SYN 을 켠 세그먼트는 SYN 입니다. ACK 만 켠 세그먼트는 ACK 입니다. 둘을 함께 켠 세그먼트는 SYN-ACK 라고 부릅니다.

연결마다 새로 고르는 시작 번호

시퀀스 번호는 0 에서 시작하지 않습니다. 양쪽이 연결을 맺을 때마다 시작 번호를 새로 고릅니다. 이 번호를 초기 시퀀스 번호, 줄여서 ISN(Initial Sequence Number)이라고 부릅니다. 크기는 32비트입니다.

새로 고르는 까닭은 옛 연결과 헷갈리지 않으려는 것입니다. 연결은 양쪽의 주소와 포트 한 쌍으로 가려집니다. 포트는 한 컴퓨터 안에서 어느 프로그램에 온 연결인지 가르는 번호입니다.

같은 한 쌍으로 연결을 끊었다가 곧 다시 맺는 일이 있습니다. 그때 옛 연결의 세그먼트가 아직 네트워크를 떠돌 수 있습니다. 연결마다 ISN 을 새로 고르면 늦게 닿은 그 세그먼트가 어느 연결의 것인지 헷갈릴 가능성이 줄어듭니다.

ISN 은 정해진 시간 안에 겹치지 않게 고릅니다. 공격자가 짐작할 수 없게도 고릅니다. 고르는 규칙은 「보장과 가정」 절에 있습니다.

넷이 셋이 되는 까닭

한쪽이 고른 ISN 을 상대는 모릅니다. 네트워크 전체가 따르는 공통 시계에 번호가 묶여 있지 않습니다. TCP 구현마다 ISN 을 고르는 방법도 다를 수 있습니다.

양쪽은 자기 ISN 을 보내야 합니다. 그것이 상대에게 닿았다는 확인도 받아야 합니다. 상대의 ISN 도 받아서 받았다고 답해야 합니다. 방향이 둘입니다. 할 일은 모두 넷입니다.

차례 방향 싣는 말
1 클라이언트 → 서버 SYN · 내 번호는 X
2 서버 → 클라이언트 ACK · 네 번호 X 받았다
3 서버 → 클라이언트 SYN · 내 번호는 Y
4 클라이언트 → 서버 ACK · 네 번호 Y 받았다

2 와 3 은 둘 다 서버가 보냅니다. 한 세그먼트에 합쳐 SYN-ACK 로 보냅니다. 넷이 셋으로 줄어 3방향 핸드셰이크라는 이름이 붙었습니다. 3WHS(three-way handshake)라고 줄여 쓰기도 합니다.

셋 아래로는 못 줄입니다. 첫 SYN 을 받은 서버는 그것이 옛 연결에서 늦게 닿은 SYN 인지 알 길이 없습니다. 그 연결에서 마지막으로 쓴 번호를 기억하면 가릴 수 있습니다. 하지만 늘 기억할 수 있는 것은 아닙니다.

그래서 서버는 SYN 을 보낸 쪽에 이 SYN 이 맞는지 되묻습니다. 되묻는 것이 SYN-ACK 입니다. 클라이언트의 셋째 답이 그 확인입니다. 옛 연결 요청이 혼란을 일으키지 않게 막는 것이 3방향 핸드셰이크의 주된 이유입니다.

왕복 한 번의 비용

신호가 상대에게 갔다 돌아오는 데 걸리는 시간을 왕복 시간, 줄여서 RTT(Round-Trip Time)라고 부릅니다. 널리 쓰이는 TCP 는 핸드셰이크가 끝난 뒤에야 데이터를 주고받습니다. 핸드셰이크는 그래서 네트워크 지연에 RTT 한 번을 더합니다.

짧은 웹 전송에서는 이 RTT 한 번이 전체 지연의 큰 몫이 됩니다. HTTP(HyperText Transfer Protocol)의 지속 연결은 한 번 맺은 연결을 여러 요청에 다시 써서 이 비용을 덜어 줍니다. 데이터베이스 드라이버의 커넥션 풀도 맺어 둔 연결을 다시 씁니다.

SYN 에 데이터를 실어 보내는 것 자체는 허용됩니다. 받는 쪽은 그 데이터가 유효한지 확실해질 때까지 쥐고 있다가 프로그램에 넘깁니다. TCP Fast Open 은 이 방식으로 RTT 한 번을 아끼는 확장입니다. 실험 단계로 나왔습니다.

암호화 연결은 핸드셰이크를 하나 더 치릅니다. TLS(Transport Layer Security, 전송 계층 보안)는 믿을 수 있고 순서가 지켜지는 전송 위에서 돕니다. TLS 는 처음 통신할 때 자기 핸드셰이크로 버전과 암호 알고리즘, 열쇠를 맞춥니다.

UDP(User Datagram Protocol, 사용자 데이터그램 프로토콜)는 최소한의 절차로 메시지를 곧바로 보냅니다. 핸드셰이크가 없습니다. 메시지가 닿는다는 것도, 두 번 오지 않는다는 것도 보장하지 않습니다.

교환 순서

이 절은 클라이언트가 ISN 100 을, 서버가 ISN 300 을 고른 연결 하나를 따라갑니다. 결과부터 적으면 세그먼트 셋이 오간 뒤 양쪽 다 ESTABLISHED 상태에 닿습니다. 세그먼트마다 시퀀스 번호와 확인 응답 번호, 그 직후 양쪽의 상태를 적습니다.

TCP 는 연결마다 지금 어느 단계인지를 상태로 적어 둡니다. 연결 하나의 상태를 담아 두는 기록을 TCB(Transmission Control Block, 전송 제어 블록)라고 부릅니다. 핸드셰이크에 나오는 상태는 다섯입니다.

상태 뜻
CLOSED 연결이 없다. TCB 도 없다
LISTEN 어느 상대에게서 오든 연결 요청을 기다린다
SYN-SENT 연결 요청을 보내고 짝이 맞는 요청을 기다린다
SYN-RECEIVED 연결 요청을 받고 또 보낸 뒤, 그 요청을 받았다는 확인을 기다린다
ESTABLISHED 연결이 열렸다. 받은 데이터를 프로그램에 넘길 수 있다

서버 프로그램은 연결을 받겠다고 알려 LISTEN 이 됩니다. 이것을 수동 개방(passive open)이라고 부릅니다. 클라이언트 프로그램이 연결을 청하는 것은 능동 개방(active open)입니다.

아래 그림의 seq 는 시퀀스 번호, ack 는 확인 응답 번호입니다. Note 는 세그먼트가 떠나거나 닿은 직후의 상태입니다.

sequenceDiagram
    participant 클라이언트
    participant 서버
    Note over 클라이언트: CLOSED
    Note over 서버: LISTEN
    클라이언트->>서버: SYN · seq=100
    Note over 클라이언트: SYN-SENT
    Note over 서버: SYN-RECEIVED
    서버->>클라이언트: SYN-ACK · seq=300 · ack=101
    Note over 클라이언트: ESTABLISHED
    클라이언트->>서버: ACK · seq=101 · ack=301
    Note over 서버: ESTABLISHED

첫째, 클라이언트가 SYN 을 보냅니다. 시퀀스 번호 칸에는 ISN 100 을 적습니다. 100 번부터 번호를 쓰겠다는 뜻입니다. 클라이언트는 SYN-SENT 가 됩니다.

둘째, LISTEN 중이던 서버가 SYN 을 받습니다. 서버는 자기 ISN 300 을 고릅니다. 시퀀스 번호 300, 확인 응답 번호 101 인 SYN-ACK 로 답합니다. 서버는 SYN-RECEIVED 가 됩니다.

확인 응답 번호가 101 인 것은 SYN 이 번호 100 을 차지했기 때문입니다. 서버는 이제 101 번을 기다린다고 알리는 것입니다.

셋째, SYN-ACK 를 받은 클라이언트는 ESTABLISHED 가 됩니다. 시퀀스 번호 101, 확인 응답 번호 301 인 ACK 를 보냅니다. 이 ACK 에는 데이터가 없습니다. 서버는 이 ACK 를 받고 ESTABLISHED 가 됩니다.

클라이언트가 이어서 데이터를 보내면 그 세그먼트의 시퀀스 번호도 101 입니다. ACK 는 번호를 차지하지 않기 때문입니다. ACK 까지 번호를 차지하면 ACK 를 받았다는 ACK 가 또 필요해집니다.

SYN 이 번호 하나를 차지하는 까닭

SYN 에는 데이터가 없습니다. 그래도 SYN 은 잃어버리면 다시 보내야 합니다. 받았다는 확인도 받아야 합니다. 다시 보낸 SYN 이 둘 다 닿아도 한 번만 처리되게 하려고 번호 하나를 줍니다.

번호를 차지하는 제어 비트는 둘뿐입니다. 연결을 여는 SYN 과, 연결을 끝낼 때 쓰는 FIN(finish, 종료)입니다.

SYN 의 번호는 첫 데이터 바이트 바로 앞에 놓입니다. 위 예의 번호를 칸으로 늘어놓으면 아래와 같습니다.

block-beta
columns 4
  A["100 · SYN"]
  B["101 · 첫 데이터 바이트"]
  C["102 · 둘째 바이트"]
  D["103 · 셋째 바이트"]

SYN 에 적은 번호가 ISN 입니다. 첫 데이터 바이트는 ISN+1 번입니다. 서버가 SYN-ACK 에 적은 확인 응답 번호 101 은 이 그림의 둘째 칸을 가리킵니다. 위 예에서 클라이언트의 첫 데이터가 101 번인 까닭입니다.

늦게 닿은 옛 SYN

옛 연결에서 떠돌던 SYN 이 늦게 닿는 경우가 있습니다. 그 SYN 을 걸러 내는 플래그가 RST(reset, 재설정)입니다. RST 는 지금 연결에 온 것이 아닌 듯한 세그먼트를 받았을 때 보냅니다.

받는 쪽은 아무 RST 나 따르지 않습니다. 번호가 지금 연결의 것과 맞아떨어지는 RST 만 받아들입니다. 수동 개방으로 SYN-RECEIVED 에 와 있던 쪽이 그런 RST 를 받으면 LISTEN 으로 돌아갑니다.

상황은 이렇습니다. 클라이언트가 ISN 100 인 SYN 을 보내 SYN-SENT 에 있습니다. 그 SYN 이 길에서 늦어지는 사이, 예전 연결이 보냈던 SYN(seq=90)이 서버에 먼저 닿습니다.

sequenceDiagram
    participant 클라이언트
    participant 서버
    Note over 클라이언트: SYN seq=100 을 보낸 뒤 SYN-SENT
    클라이언트->>서버: 옛 SYN · seq=90 · 늦게 닿음
    서버->>클라이언트: SYN-ACK · seq=300 · ack=91
    Note over 클라이언트: 기다리던 ack 가 아니다
    클라이언트->>서버: RST · seq=91
    Note over 서버: LISTEN 으로 돌아감

서버는 이 SYN 이 옛 것인지 알 수 없습니다. 평소대로 SYN-ACK 로 답합니다. 확인 응답 번호는 91 입니다.

클라이언트는 확인 응답 번호 칸이 틀렸다는 것을 알아챕니다. 자기가 보낸 SYN 은 100 번이었기 때문입니다. 클라이언트는 RST 를 돌려보냅니다. RST 의 시퀀스 번호는 받은 확인 응답 번호 91 로 골라 서버가 믿을 만한 값으로 맞춥니다.

RST 를 받은 서버는 LISTEN 으로 돌아갑니다. 그 뒤 클라이언트의 SYN 100 이 닿으면 연결이 평소 순서로 맺어집니다. 이번에 서버가 고른 ISN 은 400 입니다. 마지막 ACK 는 seq=101 · ack=401 입니다.

새 SYN 이 RST 보다 먼저 서버에 닿으면 양쪽이 RST 를 주고받는 더 복잡한 교환이 일어날 수 있습니다.

동시 개방

양쪽이 서로에게 동시에 SYN 을 보내는 경우도 있습니다. 이것을 동시 개방(simultaneous open)이라고 부릅니다. 이때는 누가 클라이언트인지 가를 수 없어 양쪽을 A 와 B 로 부릅니다.

sequenceDiagram
    participant A
    participant B
    A->>B: SYN · seq=100
    B->>A: SYN · seq=300
    Note over A,B: 둘 다 SYN-SENT 에서 상대 SYN 을 받아 SYN-RECEIVED
    A->>B: SYN-ACK · seq=100 · ack=301
    B->>A: SYN-ACK · seq=300 · ack=101
    Note over A,B: 둘 다 ESTABLISHED

양쪽은 자기 SYN 을 보낸 뒤 확인 응답이 없는 SYN 을 받습니다. 그러면 SYN-RECEIVED 로 가서 SYN-ACK 를 보냅니다. 상대의 SYN-ACK 가 닿으면 ESTABLISHED 가 됩니다. 양쪽 모두 CLOSED → SYN-SENT → SYN-RECEIVED → ESTABLISHED 를 밟습니다.

A 의 SYN-ACK 는 시퀀스 번호가 앞서 보낸 SYN 과 똑같은 100 입니다. 그 SYN 이 이미 100 번 칸을 차지했기 때문입니다. 같은 SYN 에 확인 응답만 얹어 보내는 세그먼트라 번호가 그대로입니다.

TCP 구현은 동시 개방을 반드시 지원해야 합니다(MUST). 괄호 안의 (MUST)는 반드시 그렇게 해야 한다는 뜻입니다. (SHOULD)는 그렇게 하기를 권한다는 뜻입니다. (MAY)는 해도 된다는 뜻입니다.

오가는 세그먼트는 SYN 둘과 SYN-ACK 둘, 넷입니다.

연결 맺기의 상태 전이

앞의 세 경우를 상태만 모아 그리면 아래와 같습니다. 화살표 이름은 「받은 것 · 그 응답으로 보낸 것」입니다.

stateDiagram-v2
    state "SYN-SENT" as SS
    state "SYN-RECEIVED" as SR
    CLOSED --> LISTEN: 수동 개방
    CLOSED --> SS: 능동 개방 · SYN 보냄
    LISTEN --> SR: SYN 받음 · SYN-ACK 보냄
    SS --> SR: SYN 받음 · SYN-ACK 보냄
    SS --> ESTABLISHED: SYN-ACK 받음 · ACK 보냄
    SR --> ESTABLISHED: SYN 에 대한 ACK 받음
    SR --> LISTEN: RST 받음 · 수동 개방에서 온 경우

SYN-RECEIVED 에서 RST 를 받았을 때 어디로 가는지는 그 연결이 어디서 왔느냐에 달렸습니다. 수동 개방으로 LISTEN 에서 왔으면 LISTEN 으로 돌아갑니다. 능동 개방으로 SYN-SENT 에서 왔으면 연결이 거부된 것입니다. 그 연결은 CLOSED 로 갑니다. 이 때문에 TCP 구현은 SYN-RECEIVED 에 어느 길로 왔는지를 반드시 기억해야 합니다(MUST).

예시

이 절의 실물은 둘입니다. 하나는 tcpdump 가 찍은 rlogin 연결의 첫 네 줄입니다. 다른 하나는 리눅스의 listen()·connect()·accept() 밑에서 이 교환이 일어나는 때입니다.

tcpdump 가 찍은 rlogin 연결의 첫머리

tcpdump 는 네트워크를 지나는 패킷을 잡아 한 줄씩 찍는 도구입니다. 아래는 rtsg 라는 호스트가 csam 이라는 호스트에 원격 로그인 프로그램 rlogin 으로 연결을 여는 첫 네 줄입니다.

IP rtsg.1023 > csam.login: Flags [S], seq 768512:768512, win 4096, opts [mss 1024]
IP csam.login > rtsg.1023: Flags [S.], seq, 947648:947648, ack 768513, win 4096, opts [mss 1024]
IP rtsg.1023 > csam.login: Flags [.], ack 1, win 4096
IP rtsg.1023 > csam.login: Flags [P.], seq 1:2, ack 1, win 4096, length 1

한 줄은 보낸 쪽 > 받는 쪽: Flags [플래그], seq …, ack … 꼴입니다. rtsg.1023 은 rtsg 의 1023 번 포트입니다. csam.login 은 csam 의 login 포트입니다. 플래그 기호는 S 가 SYN, . 이 ACK 입니다.

줄 방향 플래그 seq ack 핸드셰이크에서
1 rtsg → csam S 768512 없음 SYN
2 csam → rtsg S. 947648 768513 SYN-ACK
3 rtsg → csam . 없음 1 ACK
4 rtsg → csam P. 1:2 1 첫 데이터 1바이트

첫 줄은 rtsg 의 SYN 입니다. 시퀀스 번호 768512 가 rtsg 의 ISN 입니다. 768512:768512 는 「768512 부터 768512 앞까지」라는 뜻입니다. 담긴 데이터가 없습니다. 확인 응답도 붙지 않았습니다.

둘째 줄은 csam 의 SYN-ACK 입니다. csam 의 ISN 은 947648 입니다. ack 768513 은 rtsg 의 ISN 에 1 을 더한 값입니다. rtsg 의 SYN 이 번호 하나를 차지했기 때문입니다.

셋째 줄은 rtsg 의 ACK 입니다. ack 1 은 작은 수로 찍혔습니다.

tcpdump 가 번호를 찍는 규칙 때문입니다. tcpdump 는 한 대화의 첫 패킷에서만 번호를 원래 값으로 찍습니다. 그 뒤로는 처음 번호와의 차이를 찍습니다. ack 1 은 그래서 csam 의 ISN 947648 에 1 을 더한 값입니다. -S 옵션을 주면 원래 번호로 찍습니다.

넷째 줄에서 rtsg 가 첫 데이터 1바이트를 보냅니다. 상대 번호로 1 번입니다. 방향마다 첫 데이터 바이트가 1 로 찍힙니다. P 는 PSH(push) 플래그로, 핸드셰이크와는 상관없습니다.

win 4096 은 반대 방향으로 받을 수 있는 버퍼가 4096바이트 남았다는 뜻입니다. opts [mss 1024] 는 SYN 과 SYN-ACK 에 실린 옵션입니다. 한 세그먼트에 담을 최대 크기인 MSS(Maximum Segment Size, 최대 세그먼트 크기)로 1024바이트를 청합니다.

리눅스 소켓 함수 밑의 핸드셰이크

이 소절은 리눅스에서 서버와 클라이언트 프로그램이 부르는 함수를 그 밑에서 오가는 세그먼트와 맞대어 놓습니다. 프로그램은 소켓을 열어 함수를 부를 뿐입니다. 소켓은 프로그램이 네트워크 연결을 다루려고 운영체제에게서 받는 통신 창구입니다. SYN·SYN-ACK·ACK 는 운영체제의 중심부인 커널이 주고받습니다.

서버 쪽은 네 단계를 밟습니다. socket() 으로 소켓을 만듭니다. bind() 로 주소와 포트를 붙입니다. listen() 으로 연결을 받겠다고 알립니다. accept() 로 연결을 하나씩 꺼냅니다.

클라이언트 쪽은 한 단계입니다. connect() 로 연결을 시도합니다.

sequenceDiagram
    participant CP as 클라이언트 프로그램
    participant CK as 클라이언트 커널
    participant SK as 서버 커널
    participant SP as 서버 프로그램
    SP->>SK: listen()
    CP->>CK: connect()
    CK->>SK: SYN
    SK->>CK: SYN-ACK
    CK->>SK: ACK
    CK-->>CP: connect() 성공
    SP->>SK: accept()
    SK-->>SP: 맺어진 연결의 새 소켓
함수 부르는 쪽 핸드셰이크에서
listen() 서버 소켓을 연결 요청을 받는 소켓으로 바꾼다. 수동 개방 · 상태는 LISTEN
connect() 클라이언트 상대 주소의 소켓에 연결을 시도한다. 능동 개방 · SYN 이 나간다
accept() 서버 맺어진 연결 대기열에서 첫 연결을 꺼내 새 소켓을 돌려준다

connect() 가 성공한 소켓과 accept() 가 돌려준 소켓만 데이터를 보낼 수 있습니다. 듣기만 하는 소켓이나 아직 연결되지 않은 소켓으로는 데이터를 못 보냅니다.

리눅스는 들어오는 연결을 대기열 둘에 나눠 담습니다. 핸드셰이크가 아직 안 끝난 연결이 담기는 쪽을 이 문서는 SYN 대기열이라고 부릅니다. 핸드셰이크를 마치고 accept() 를 기다리는 연결이 담기는 쪽은 accept 대기열입니다. 연결 하나는 SYN 대기열에 들어갔다가 셋째 ACK 가 닿으면 accept 대기열로 옮겨 갑니다.

flowchart TD
    SYN["클라이언트의 SYN 이 닿는다"]
    subgraph Q1["SYN 대기열 · 핸드셰이크가 안 끝난 연결"]
        H["한도는 tcp_max_syn_backlog"]
    end
    subgraph Q2["accept 대기열 · 맺어진 연결"]
        E["한도는 listen 의 백로그 인자"]
    end
    SYN --> H
    H -->|셋째 ACK 가 닿는다| E
    E -->|accept 가 꺼낸다| APP["서버 프로그램이 받은 새 소켓"]

두 대기열의 한도는 따로 정합니다. SYN 대기열의 최대 길이는 /proc/sys/net/ipv4/tcp_max_syn_backlog 가 정합니다. accept 대기열의 길이는 listen() 의 인자인 백로그(backlog)가 정합니다.

이 인자가 가리키는 대기열은 한 번 바뀌었습니다. 리눅스 2.2 부터는 accept 대기열 쪽을 가리킵니다. 그 전에는 핸드셰이크가 안 끝난 연결의 수를 가리켰습니다.

백로그가 /proc/sys/net/core/somaxconn 값보다 크면 소리 없이 그 값으로 깎입니다. 이 값의 기본은 리눅스 5.4 부터 4096 입니다. 그 전에는 128 이었습니다.

accept() 는 핸드셰이크를 하지 않습니다. accept 대기열에 이미 맺어진 연결을 꺼낼 뿐입니다. 대기열이 비었으면 연결이 생길 때까지 호출한 쪽을 붙잡아 둡니다. 소켓을 논블로킹으로 바꿔 두었다면 EAGAIN 이나 EWOULDBLOCK 오류로 곧바로 돌아옵니다.

실패

이 절은 연결이 안 맺어지는 경우를 모읍니다. 대상은 클라이언트가 부른 connect() 와 서버의 대기열 둘입니다. 무엇이 돌아오는지와 그것이 얼마나 빨리 오는지를 적습니다.

클라이언트가 SYN 을 보낸 뒤 돌아오는 것은 둘로 갈립니다. RST 가 오면 곧바로 거부됩니다. 아무 답이 없으면 SYN 을 다시 보내다 한참 뒤에 포기합니다. 아래 그림은 이 갈림만 담습니다.

flowchart TD
    S["클라이언트가 SYN 을 보냄"] --> Q{"무엇이 돌아오나"}
    Q -->|SYN-ACK| OK["연결이 맺어짐"]
    Q -->|RST| R["곧바로 거부"]
    Q -->|아무것도 안 옴| W["간격을 두 배로 늘려 SYN 을 다시 보냄"]
    W -->|답을 또 기다림| Q
    W -->|정한 한도를 넘김| T["포기 · 타임아웃"]

실패가 하나 더 있습니다. 서버의 SYN 대기열이 가짜 요청으로 차 있으면 멀쩡한 서버도 새 연결을 못 받습니다. 이것은 클라이언트가 아니라 서버 쪽에서 일어나는 일이라 위 그림에 없습니다. 「SYN 플러드」 소절이 따로 다룹니다.

받는 프로그램이 없을 때

SYN 이 닿은 포트에서 기다리는 프로그램이 없으면 그 포트에는 연결이 없습니다. 상태로는 CLOSED 입니다. 연결이 없는 곳에 RST 가 아닌 세그먼트가 오면 RST 로 답합니다. 기존 연결과 맞지 않는 SYN 은 이 방법으로 거절됩니다.

이 RST 가 싣는 번호에는 규칙이 둘입니다. 받은 SYN 에 ACK 가 없으므로 시퀀스 번호 칸에는 0 을 적습니다. 그러고 ACK 를 켜서 확인 응답 번호를 싣습니다.

확인 응답 번호는 받은 시퀀스 번호에 세그먼트 길이를 더한 값입니다. SYN 이 번호 하나를 차지하므로 seq=100 인 SYN 에는 ack=101 인 RST 가 돌아갑니다.

SYN-SENT 의 클라이언트가 받아들일 만한 확인 응답 번호가 붙은 RST 를 받으면 연결을 CLOSED 로 돌립니다. 프로그램에는 「connection reset」 오류를 알립니다. 「connection refused」라는 말은 능동 개방으로 SYN-RECEIVED 에 간 연결이 RST 를 받았을 때 씁니다.

이 두 이름은 TCP 가 가르는 두 상황의 이름입니다. 운영체제가 프로그램에 돌려주는 오류 이름은 이와 따로 정해져 있습니다. 리눅스의 connect() 는 상대 주소에서 아무도 기다리지 않으면 ECONNREFUSED 로 실패합니다. 상황으로는 위의 reset 쪽입니다. 그런데 리눅스가 돌려주는 이름은 refused 쪽입니다.

서버 프로그램이 떠 있어도 거부될 수 있습니다. 리눅스에서 accept 대기열이 가득 찼을 때 연결 요청이 오면 클라이언트가 ECONNREFUSED 를 받을 수 있습니다. 재전송을 지원하는 프로토콜이면 그 요청을 무시해 나중의 재시도가 성공하게 할 수도 있습니다.

답이 없을 때

SYN 이 길에서 사라지거나 서버 컴퓨터가 꺼져 있으면 아무 답도 오지 않습니다. 방화벽이 SYN 을 소리 없이 버릴 때도 그렇습니다. 클라이언트는 기다리다 SYN 을 다시 보냅니다.

얼마나 기다렸다 다시 보낼지를 정하는 값이 RTO(Retransmission Timeout, 재전송 타임아웃)입니다. 왕복 시간을 아직 한 번도 못 쟀으면 RTO 를 1초로 두기를 권합니다(SHOULD). 3초나 1초보다 큰 다른 값을 써도 됩니다(MAY).

RTO 가 지나도 답이 없으면 SYN 을 다시 보내고 RTO 를 두 배로 늘려야 합니다(MUST). 두 배씩 늘리는 데 상한을 둘 수 있습니다. 그 상한은 60초 이상이어야 합니다. RTO 가 1초에서 시작한다면, 상한에 걸리지 않는 동안 기다리는 간격은 아래처럼 늘어납니다.

보낸 차례 보낸 뒤 기다리는 시간 첫 SYN 부터 흐른 시간
첫 SYN 1초 1초
재전송 1 2초 3초
재전송 2 4초 7초
재전송 3 8초 15초
재전송 4 16초 31초
재전송 5 32초 63초
재전송 6 64초 127초

SYN 을 기다리다 RTO 가 지났고 RTO 가 3초보다 작았다면, 핸드셰이크가 끝나 데이터를 보내기 시작할 때 RTO 를 3초로 되돌려야 합니다(MUST).

언제 포기할지도 정해져 있습니다. 같은 SYN 을 다시 보내는 일은 적어도 3분 동안 이어지게 한도를 잡아야 합니다(MUST). 프로그램은 물론 그보다 일찍 시도를 접을 수 있습니다. 연결 시도는 RST 를 받았을 때나 ICMP(Internet Control Message Protocol) 의 Port Unreachable 메시지를 받았을 때도 실패합니다.

리눅스는 tcp_syn_retries 로 SYN 재전송 횟수를 정합니다. 기본값은 6 입니다. 약 127초 동안 다시 보내는 것에 해당합니다. 바로 위에 적은 3분보다 짧은 값입니다.

리눅스 3.7 전에는 기본값이 5 였습니다. 그때는 약 180초에 해당했습니다.

재전송을 다 해도 답이 없으면 리눅스의 connect() 는 ETIMEDOUT 으로 실패합니다. 서버가 너무 바빠 새 연결을 못 받을 때도 이 오류가 날 수 있습니다.

오류가 오는 빠르기로 원인을 가를 수 있습니다. 곧바로 ECONNREFUSED 가 오면 SYN 은 서버 컴퓨터까지 닿았고 RST 가 돌아온 것입니다. 한참 뒤에 ETIMEDOUT 이 오면 대개 SYN 이나 그 답이 길에서 사라졌거나 서버가 답하지 못한 것입니다.

셋째 ACK 가 안 올 때

서버는 SYN-ACK 를 보낸 뒤 SYN-RECEIVED 에서 셋째 ACK 를 기다립니다. 이렇게 핸드셰이크가 안 끝난 연결을 반쯤 열린 연결(half-open connection)이라고 부릅니다. SYN 대기열에 담겨 있는 연결이 이것입니다. 서버는 그동안 이 연결의 TCB 를 메모리에 들고 있습니다.

ACK 가 안 오면 서버는 SYN-ACK 를 다시 보냅니다. 리눅스는 tcp_synack_retries 로 그 횟수를 정합니다. 기본값은 5 입니다.

반쯤 열린 연결을 얼마 뒤에 버릴지는 TCP 표준에 없습니다. 그래도 흔히 쓰이는 운영체제들은 수명에 한도를 둡니다. 4.4 BSD-Lite 에서는 간격을 늘려 가다 모두 511초 뒤에 멈추는 동작을 볼 수 있습니다. 이 코드에서 갈라져 나온 운영체제 일부는 지금도 그렇게 합니다.

SYN 플러드

SYN 플러드는 반쯤 열린 연결을 노리는 공격입니다. 흔한 구현에서 서버는 SYN 을 받을 때마다 TCB 를 마련합니다. 운영체제는 메모리를 지키려고 한꺼번에 들고 있을 TCB 수에 대개 한도를 둡니다. 공격은 이 두 동작에 기댑니다.

공격자는 SYN 을 짧은 시간에 쏟아붓습니다. 보낸 쪽 IP(Internet Protocol) 주소는 흔히 가짜로 적습니다. 서버의 SYN-ACK 는 답하지 않을 주소로 갑니다. 셋째 ACK 는 끝내 오지 않습니다.

가짜 반쯤 열린 연결이 한도를 채우면 진짜 연결 요청이 거절됩니다. 이 공격은 네트워크나 호스트 메모리 전체를 바닥내려는 것이 아닙니다. 특정 포트에서 기다리는 프로그램의 SYN 대기열 하나를 채우려는 것입니다.

막히는 것은 그 포트로 들어오는 새 연결뿐입니다. 서버가 밖으로 여는 연결과 이미 맺어진 연결은 영향을 받지 않습니다.

가짜 주소는 SYN-ACK 에 답하지 않는 주소여야 공격이 먹힙니다. 살아 있는 호스트의 주소를 쓰면 그 호스트가 RST 를 보냅니다. 그러면 서버는 그 TCB 를 곧바로 풀어 진짜 연결이 들어올 틈이 생깁니다.

대표적인 대응은 다섯입니다.

대응 하는 일 걸리는 점
SYN 대기열 늘리기 SYN 대기열의 한도를 키운다 Lemon 의 측정에서 FreeBSD 4.4 는 이 대기열이 수백을 넘으면 자료구조와 탐색이 비효율적이었다
SYN-RECEIVED 타이머 줄이기 반쯤 열린 연결을 더 빨리 버린다 진짜 연결 일부도 못 맺게 될 수 있다. 공격자는 쏟아붓는 빈도만 비례해 올리면 된다
가장 오래된 반쯤 열린 TCB 덮어쓰기 대기열이 차면 새 SYN 이 가장 오래된 것을 밀어낸다 공격 속도가 빠르거나 대기열이 작으면 실패한다
SYN 캐시 SYN 에 온전한 TCB 대신 작은 상태만 준다. 연결이 다 맺어지면 그때 TCB 를 마련한다 Lemon 의 측정에서 공격을 받는 동안 진짜 연결을 맺는 시간이 15% 쯤 길어졌다
SYN 쿠키 SYN-RECEIVED 에 상태를 아예 남기지 않는다 일부 TCP 옵션과 맞지 않는다. 아래 소절이 다룬다

SYN 쿠키

SYN 쿠키는 SYN-RECEIVED 에 상태를 아예 남기지 않는 방법입니다. 남겨 둘 상태의 대부분을 SYN-ACK 의 시퀀스 번호에 담아 보냅니다. 번호를 만드는 방식은 구현마다 다릅니다. 받은 ISN 에 주소·포트 쌍과 비밀 값의 해시를 섞는 방식을 씁니다.

진짜 클라이언트라면 셋째 ACK 의 확인 응답 번호가 그 번호에 1 을 더한 값으로 돌아옵니다. 서버는 그 값에서 상태를 되살려 TCB 를 만듭니다.

sequenceDiagram
    participant 클라이언트
    participant 서버
    클라이언트->>서버: SYN
    Note over 서버: TCB 를 안 만든다 · 상태를 담은 번호를 만든다
    서버->>클라이언트: SYN-ACK · seq=그 번호
    클라이언트->>서버: ACK · ack=그 번호+1
    Note over 서버: ack 에서 상태를 되살려 TCB 를 만든다

SYN 쿠키에는 대가가 있습니다. 쿠키를 만드는 방식이 옵션을 고려하지 않으면 일부 TCP 옵션과 함께 쓸 수 없습니다. SYN 에 실려 온 데이터도 다루지 못합니다.

이런 대가 때문에 SYN 쿠키는 대개 기본으로 켜 두지 않습니다. 공격으로 보이는 부하가 걸렸을 때나 관리자가 켰을 때만 씁니다. 리눅스에서는 서버가 SYN 쿠키를 켰을 때 클라이언트의 connect() 타임아웃이 아주 길어질 수 있습니다.

쿠키를 쓰는 동안에는 서버에 반쯤 열린 연결의 상태가 없습니다. 그래서 셋째 ACK 가 사라지면 서버는 그 연결이 있는 줄 모릅니다. 클라이언트는 연결이 맺어졌다고 여깁니다.

서버가 먼저 말을 거는 프로토콜이 여기에 걸립니다. 명세대로 구현한 SMTP(Simple Mail Transfer Protocol) 서버는 핸드셰이크 뒤에 「서비스 준비됨」 메시지를 먼저 보냅니다. 그 메시지가 끝내 오지 않습니다.

리눅스의 tcp_syncookies 기본값은 1 입니다. 소켓의 SYN 대기열이 넘칠 때 쿠키를 보낸다는 뜻입니다. 쿠키를 켜 두면 SYN 대기열에 논리적인 최대 길이가 없어져 tcp_max_syn_backlog 설정이 무시됩니다.

리눅스에서 이 기능은 쓰더라도 마지막 수단으로 쓰기를 권하는 기능입니다. TCP 규칙을 어기는 방식입니다. TCP 확장과 부딪힐 수 있습니다. 클라이언트와 중계 장비에도 문제를 일으킬 수 있습니다.

보장과 가정

이 절은 핸드셰이크가 무엇을 약속하는지와 그 약속이 기대는 전제를 적습니다. 대상은 ISN 을 고르는 규칙과 세그먼트 수명입니다. 전제가 깨지면 무엇이 무너지는지를 함께 적습니다.

결과부터 적으면 약속은 둘입니다. 핸드셰이크가 끝나면 양쪽이 서로의 ISN 을 압니다. 자기 ISN 이 상대에게 닿았다는 확인도 받았습니다. 그리고 늦게 닿은 옛 SYN 이 연결을 잘못 여는 일이 줄어듭니다.

이 약속은 네 가정 위에 섭니다. 아래 표의 MSL(Maximum Segment Lifetime, 최대 세그먼트 수명)은 세그먼트가 네트워크에 머무를 수 있는 최대 시간입니다.

가정 가정이 깨지면
ISN 이 MSL 안에서 겹치지 않는다 옛 세그먼트와 새 세그먼트가 같은 번호를 받아 받는 쪽이 헷갈릴 수 있다
공격자가 ISN 을 짐작할 수 없다 보안 문제가 생긴다
번호를 잊은 호스트는 MSL 동안 세그먼트를 안 보낸다 옛 데이터를 새 것으로, 새 데이터를 옛 중복으로 오인할 수 있다
SYN 의 보낸 쪽 주소가 진짜다 SYN 플러드가 먹힌다

겹치지 않는 ISN

ISN 은 끝에 닿으면 0 으로 돌아가는 32비트 카운터로 고릅니다. 이 카운터를 시계라고 부릅니다. 대개 적어도 약 4마이크로초마다 1 씩 늡니다.

이 시계는 정확할 필요가 없습니다. 재부팅 뒤 이어질 필요도 없습니다. TCP 구현은 ISN 을 반드시 이런 시계로 골라야 합니다(MUST).

시계가 한 바퀴 도는 데 약 4.55시간이 걸립니다. MSL 보다 훨씬 긴 시간입니다. MSL 동안 같은 ISN 이 다시 나오지 않게 하려는 설계입니다.

MSL 은 2분으로 잡습니다. 이 값은 공학적으로 고른 값입니다. 경험에 따라 바뀔 수 있습니다. 네트워크에서 세그먼트가 사는 시간은 수십 초를 넘지 않을 가능성이 높습니다.

전송 속도가 높은 네트워크에서는 연결이 시작하자마자 번호를 금세 써 버려 MSL 안에 번호가 겹칠 수 있습니다. 그런 네트워크에는 Timestamp 옵션을 구현하기를 권합니다.

짐작할 수 없는 ISN

경로 밖의 공격자가 ISN 을 짐작하거나 맞힐 수 있으면 보안 문제가 생깁니다. 경로 밖의 공격자는 두 컴퓨터 사이를 오가는 세그먼트를 볼 수 없는 공격자입니다.

ISN 은 아래 식으로 만들기를 권합니다(SHOULD).

ISN = M + F(localip, localport, remoteip, remoteport, secretkey)

M 은 앞의 4마이크로초 시계입니다. F 는 양쪽 주소·포트와 비밀 키를 넣어 값을 내는 의사 난수 함수입니다. 연결 정보와 비밀 데이터를 이어 붙여 암호학적 해시를 내는 식으로 만들 수 있습니다.

F 는 밖에서 계산할 수 없어야 합니다(MUST NOT). 계산할 수 있으면 공격자가 다른 연결의 ISN 을 보고 이 연결의 번호를 짐작할 수 있습니다.

기억을 잃은 호스트의 침묵

호스트가 재부팅 등으로 쓰던 번호를 잊을 수 있습니다. 그 호스트는 적어도 MSL 동안 TCP 세그먼트를 내보내지 않아야 합니다. 이 기다림을 quiet time 이라고 부릅니다.

구현은 이 기다림을 어길 수도 있습니다. 그러면 일부 수신자가 옛 데이터를 새 것으로 받거나 새 데이터를 옛 중복으로 버릴 위험을 집니다.

오늘날 인터넷에서는 이 위험이 무시해도 될 만큼 낮다고 봅니다. ISN 과 임시 포트를 무작위로 고르게 되었습니다. 링크가 빨라져 세그먼트가 네트워크에 머무는 시간이 줄었습니다. 재부팅도 자주 MSL 보다 오래 걸립니다. 이런 까닭으로 대부분의 구현에는 quiet time 이 필요하다고 여기지 않습니다.

핸드셰이크 뒤에 사라지는 상대

핸드셰이크는 연결을 맺는 순간의 도달만 확인합니다. 맺은 뒤에 한쪽이 상대 모르게 연결을 닫을 수 있습니다. 한쪽이 장애나 재부팅으로 기억을 잃을 수도 있습니다.

이런 연결도 half-open 이라고 부릅니다. 「실패」 절의 반쯤 열린 연결은 아직 SYN-RECEIVED 에 머문 연결이었습니다. 같은 이름이 이미 ESTABLISHED 였던 연결에도 쓰입니다. 어느 단계의 연결인지를 함께 봐야 합니다.

이렇게 어긋난 연결은 어느 방향으로든 데이터를 보내려 하면 RST 로 끊어집니다. 이런 연결은 드물 것으로 봅니다.

관련 항목

3방향 핸드셰이크가 속하는 상위 분류

TCP · 전송 계층 · 프로토콜 · 네트워크 · 인터넷 프로토콜 스위트 · OSI 모형

3방향 핸드셰이크를 이루는 세그먼트와 필드

세그먼트 · TCP 헤더 · TCP 플래그 · 시퀀스 번호 · 초기 시퀀스 번호 · 확인 응답 · RST · FIN · MSS

3방향 핸드셰이크가 거쳐 가는 연결 상태

TCP 상태 · 상태 기계 · TCB · 반쯤 열린 연결 · 동시 개방 · TIME_WAIT · CLOSE_WAIT

3방향 핸드셰이크의 시작 번호가 기대는 전제

MSL · quiet time · Timestamp 옵션 · 의사 난수 함수 · 암호학적 해시

연결을 맺고 끊는 다른 핸드셰이크

4방향 핸드셰이크 · TLS 핸드셰이크 · TLS · 연결 지향

3방향 핸드셰이크를 커널에 맡기는 소켓 함수

소켓 · listen · accept · connect · listen 백로그 · somaxconn · 커널 · 포트

3방향 핸드셰이크가 늘리는 지연과 그 대책

왕복 시간 · 지연 · 지속 연결 · 커넥션 풀 · 킵얼라이브 · TCP Fast Open · 0-RTT

3방향 핸드셰이크가 실패할 때 보이는 오류

연결 거부 · ECONNREFUSED · ETIMEDOUT · 타임아웃 · 재전송 · RTO · 지수 백오프 · 방화벽 · 패킷 손실 · ICMP

3방향 핸드셰이크를 노리는 공격과 방어

SYN 플러드 · SYN 쿠키 · SYN 캐시 · 서비스 거부 공격 · IP 스푸핑 · DDoS

TCP 대신 UDP 위에서 도는 통신

UDP · QUIC · HTTP/3 · DNS

3방향 핸드셰이크를 들여다보는 도구

netstat · ss · tcpdump · Wireshark

다른 이름: three-way handshake · 3-way handshake · 쓰리웨이 핸드셰이크 · 세 방향 핸드셰이크 · TCP 핸드셰이크 · 3WHS · three message handshake