나만의 작은 도서관
[Network] TCP 더 자세히 알아보기 본문
이전 내용
해당 글은 아래 링크의 확장입니다.
https://pledge24.tistory.com/286
[Network] TCP vs UDP
목차TCP, TCP 통신 과정UDPTCP와 UDP 작동 비교 예제TCP와 UDP 비교표 신뢰할 수 있는 전송 프로토콜: TCP TCP(Transmission Control Protocol)는 호스트 간 접속과 송수신의 신뢰성이 보장되는 환경에서 통신하기
pledge24.tistory.com
순서 보장을 위한 TCP의 시퀀스 번호(SEQ)
TCP는 데이터의 전송 순서를 보장하는 기능이 있다. 네트워크를 타고 오면서 순서가 뒤섞여 버려도 수신 측에서 기존 순서대로 재정렬할 수 있어야 한다는 것이다.
그렇다면 TCP는 재정렬을 어떻게 하는 걸까? 바로 헤더에 적힌 시퀀스 번호(이하 SEQ)를 활용하는 것이다. 보내는 쪽의 TCP가 시퀀스 번호가 증가하는 방향으로 세그먼트를 만든다면, 받는 쪽의 TCP가 각 세그먼트에 적힌 시퀀스 번호를 기준으로 오름차순 정렬만 하면 전송 순서를 맞출 수 있게 된다.
SEQ(Sequence Number) 필드 값 결정 원리

시퀀스 번호는 1씩 증가?
시퀀스 번호는 TCP 헤더 중 Sequence Number라는 32bit 크기의 필드에 저장된다. 송신 측에서 각 세그먼트의 Sequence Number 필드에 값을 설정하면, 수신 측에서 이를 읽어 정렬하는 것이다. 그렇다면 시퀀스 번호는 세그먼트를 만들 때마다 1씩 증가시키는 걸까?
그렇지 않다.
TCP는 SEQ값에 좀 더 “의미 있는” 값을 넣기 위해 0, 1, 2, … 처럼 단순히 1씩 증가시키는 방식을 사용하지 않는다. SEQ는 바이트 오프셋으로, 해당 호스트가 보낸 바이트 중 몇 번째 바이트인지에 대한 정보를 담고 있다. 예를 들어, 호스트 A가 전송한 세그먼트가 SEQ=401이라면, 해당 세그먼트는 “호스트 A가 전송한 401번째 바이트로 시작하는 페이로드를 담고 있음"을 의미하게 된다. (이 설명은 ISN를 0으로 설정한 설명으로, 실제랑은 약간 다름. ISN에 대한 내용은 뒤에 나옴)
들쭉날쭉한 SEQ 증가량
그래서 각 세그먼트의 SEQ 증가량은 이전 세그먼트의 페이로드 크기에 영향을 받아 들쭉날쭉 할 수도 있다. 물론 TCP는 MSS(Maximum Segment Size, 이더넷 환경에서는 보통 1460바이트)를 초과하는 크기의 페이로드를 만들 수 없기 때문에 말도 안 되게 들쭉날쭉한 경우는 생기지 않는다.

페이로드 길이에 대한 정보는 왜 TCP 헤더에 없을까?
TCP 헤더를 자세히 보면 이상하게도 페이로드 길이에 대한 정보가 없다. 길이를 알아야 페이로드를 추출할 텐데 왜 길이 정보가 없는 걸까? 그 이유는 IP 헤더의 TotalLength 필드로 페이로드 길이를 구할 수 있기 때문. TCP는 IP 위에서 동작하는 프로토콜이기 때문에 수신 측에선 항상 IP 헤더의 필드인 TotalLength 값을 얻을 수 있다. 그렇게 얻은 값을 통해 페이로드 길이를 계산하면 된다. 계산 방식은 아래와 같다.

두 호스트는 SEQ를 공유할까? 그렇지 않다.
‘하나의 TCP로 연결된 두 호스트는 SEQ도 공유하지 않을까?’ 하고 생각했었다. 그런데 그렇지는 않았다. TCP 연결은 사실 하나의 양방향 스트림이 아닌 두 개의 단방향 스트림이 합쳐진 형태로 봐야 한다고 한다. 그래서 하나의 TCP안에 (호스트 A → 호스트 B) 방향의 단방향 스트림과 (호스트 B → 호스트 A) 방향의 단방향 스트림이 별도로 존재하며 각각이 독립적인 통신을 한다.
따라서 SEQ도 독립적으로 적용된다. 만약 호스트 A가 일방적으로 데이터를 보냈다면, 호스트 B의 SEQ는 증가하지 않고, 호스트 A의 SEQ만 잔뜩 증가되어 있을 거라는 것이다.
ISN(Initial Sequence Number)
사실 SEQ는 ISN이라는 초기값이 존재한다. 즉, 데이터를 전송하지 않은 초기 시점에 SEQ값이 0이 아닐 수 있다는 것이다. ISN이 결정되는 시점은 서로 연결하는 시점인 3-way handshake이며, 클라이언트가 SYN 패킷에 자신의 ISN을 실어 보내고, 서버가 SYN-ACK에 자신의 ISN을 실어 보내는 식으로 ISN 정보를 공유한다. 예시는 아래와 같다.

ISN가 0 또는 1에서 난수값으로 바뀐 이유?
옛날에는 ISN의 값을 0 또는 1로 설정해 두었다. 그런데 이게 Sequence Number Prediction Attack이라는 공격을 가능하게 했기 때문에 RFC 6528 이후로는 0 또는 1 대신, 해시 기반의 난수를 ISN으로 사용하고 있다.
여기서 나온 Sequence Number Prediction Attack에 대해 잠깐 설명하자면, 공격자(Attacker)가 SEQ를 추측해서 가짜 세그먼트를 끼워 넣는 방식을 말한다. 모든 TCP의 시퀀스 번호가 0 또는 1로 시작하니 대충 때려 맞추는 게 가능하다는 아이디어에서 나온 공격 방식이다.
받았음을 알리는 TCP의 ACK
TCP는 전송한 데이터가 반드시 도착함을 보장하는 프로토콜이다. 이를 위해 TCP는 받은 데이터가 있으면 반드시 보낸 대상에게 ‘받았음을 알리는 ACK’를 전송한다.
ACK(Acknowledgement Number) 필드 값 결정 원리

SEQ와 동일하게 ACK도 TCP 헤더에 존재하는데, Acknowledgement Number(이하 ACK)라는 32bit 크기의 필드에 저장되어 있다. 데이터 수신 측에서 ACK 필드에 값을 설정해 전송하면, 데이터 송신 측에서 이를 읽어 전송한 데이터가 정상적으로 수신되었음을 인지하게 된다. 그렇다면 ACK 값은 어떻게 결정되는 걸까?
TCP에서 ACK의 필드 값은 다음에 받아야 할 바이트 오프셋으로 설정된다. 예를 들어, 데이터 수신 측이 데이터 송신 측에게 보내는 세그먼트가 ACK=501이라면, “500번째 바이트까지 잘 받았으니, 501번째 바이트부터 달라”는 의미가 된다. (SEQ 단어를 활용해서 다시 말하면 “SEQ=500까지 수신완료. 다음 SEQ=501.”이 된다)

성능 최적화를 위한 ACK 활용 방식 변화
ACK를 기다리지 않겠다 - 슬라이딩 윈도우(Sliding Window)

TCP의 naive 한 모델인 Stop-and-wait(위 그림에서 왼쪽)는 전송한 데이터의 ACK가 나에게 도착할 때까지 다음 데이터를 보내지 않고 기다리는 방식이다. 이 방식은 전송한 세그먼트 개수 : ACK 개수 = 1 : 1이며, 송신 측 입장에서 RTT만큼 기다려야 다음 데이터를 전송할 수 있기 때문에 RTT가 길어지면 대부분의 시간을 ACK를 기다리는 시간으로 채워지면서 처리량(throughput)이 낮아진다는 문제가 있다. 예를 들어 패킷 전송 시간이 1ms, RTT(Round Trip Time)가 100ms라면, 1ms동안 보내고, 99ms를 그냥 놀게 되는 꼴이다.
이 문제의 근본적인 원인은 ACK를 기다리는 데에 있기 때문에 문제를 해결하려면 ACK를 기다리지 않고 세그먼트를 연달아 전송하는 방식이 필요했다. 그래서 나온 방식이 바로 “슬라이딩 윈도우”이다.
슬라이딩 윈도우 ?

슬라이딩 윈도우의 작동 방식은 간단하다. ACK 대기 없이 전송할 수 있는 최대 바이트 개수인 “윈도우”를 설정하고, ACK를 받아 수신이 확정된 바이트만큼 윈도우를 오른쪽으로 슬라이딩하는 것이다. SEQ와 ACK는 바이트 오프셋을 사용한다고 했다. 슬라이딩 윈도우도 이를 활용하는 것이고, 예를 보여주면 아래와 같다.

슬라이딩 윈도우 적용으로 인한 처리량 극대화
처리량 극대화는 슬라이딩 윈도우를 사용하는 가장 큰 이유이자 가장 본질적인 장점이다. Stop-and-wait 모델이 ACK를 받을 때까지 걸리는 RTT 시간 동안 기다릴 수밖에 없는 반면, 슬라이딩 윈도우는 BDP(Bandwidth-Delay Product, 일반적으로 대역폭 × RTT) 이상으로 윈도우 크기를 맞춰놓고 데이터를 전송하면 네트워크 파이프를 가득 채워 전송할 수 있게 되는, 즉, 처리량을 극대화시킬 수 있다.

유실에 강한 ACK - 누적 ACK(Cumulative ACK)

위 그림은 슬라이딩 윈도우 방식을 사용한 TCP 통신에서 발생한 상황이다. 송신 측 호스트가 Seg 1, 2, 3, 4를 연달아 보냈고, 수신 측 호스트가 수신 완료에 대한 사실을 알리기 위해 ACK 1, 2, 3, 4를 보냈지만 ACK 1, 2가 네트워크에서 유실된 상태. 이 상황에서 TCP는 어떻게 복원해야 할까?
기존의 방식 vs 누적 ACK
기존의 방식이었다면 송신측은 ACK 1, 2가 도착하지 않았기 때문에 Seg 1, 2가 정상적으로 도착하지 않았다고 인지한다.(Seg X가 정상적으로 도착했음을 알리는 역할이 ACK X니까) 그래서 실제론 Seg 1, 2가 수신측에 정상적으로 도착했음에도 불구하고 송신 측에서 이를 알 턱이 없기 때문에 Seg 1, 2를 재전송하게 된다.
그럼 누적 ACK라는 건 뭔가 다를까? 일단 누적 ACK이란 “특정 세그먼트를 정상적으로 수신했음”을 알리는 기존 방식과 달리 “N번째 바이트까지 정상 수신. N+1번째 바이트 수신 필요”를 알리는 방식이다. 예를 들어, 누적 ACK 방식에서 세그먼트가 ACK=401이라면, “400번째 바이트까지 전부 수신 완료. 이제 401번 바이트부터 받아야 한다.”는 의미가 된다.
위 상황에 대입하면 ACK 1, 2가 네트워크 상에서 유실되었음에도 ACK 4인 ACK=401 하나가 “Seg 1~4 정상 수신”이라는 사실을 한꺼번에 증명하게 되는데, 이는 앞서 ACK 1,2가 유실되었음에도 Seg 1~4의 정상 수신 했음을 알리는 결과를 얻게 된다.
누적 ACK의 장점들

누적 ACK를 사용함으로써 얻는 장점을 정리해 보면 크게 3가지 정도 있다.
첫째, ACK 유실에 강하다는 점이다.
위 상황에서 보았듯, ACK도 결국 IP 패킷이라 네트워크 상에서 손실될 수 있다. 누적 ACK를 사용하면 뒤쪽 ACK가 앞쪽 ACK의 결과를 전부 커버하기 때문에 네트워크 상에서 ACK 몇 개가 사라져도 제대로 “정상 수신 했음”을 알릴 수 있게 된다. 이는 데이터 송신 측 호스트가 멀쩡히 도착한 데이터를 재전송하는 불상사를 막아준다.
둘째, ACK 트래픽을 감소시키기 용이하다는 점이다.
'앞쪽 ACK가 유실되어도 뒤쪽 ACK가 전달되기만 하면 된다'는 건 일부러 ACK를 빼먹고 안 보내도 된다는 말이 된다. 실제로 TCP는 ‘delayed ACK’라고 모든 세그먼트마다 ACK를 보내지 않고 모아서 한 번만 보내는 방식을 사용하여 ACK 트래픽을 줄인다. (생각보다 많이 모아서 보내진 않았다. RFC 1122/5681를 기준으로 최대 2개 세그먼트마다 1번의 ACK라고 하니 )
셋째, 송신 측 상태 관리가 단순해진다는 점이다.
기존 방식의 경우, ACK 하나가 세그먼트 하나의 수신 완료 정보를 가지고 있었기 때문에 ‘Seg X는 수신 완료 되었는가?’에 대한 정보를 저장한 비트맵이 필요했다. 반면 누적 ACK의 경우, 단순하게 수신한 ACK값 하나만 있으면 되기 때문에 비트맵 같은 건 사용하지 않아도 된다.
누적 ACK의 약점을 보완한 두 가지 메커니즘 - 빠른 재전송(Fast Retransmit), SACK(Selected ACK)
누적 ACK에 대한 장점을 늘어놓자마자 약점을 이야기하는 건 좀 그렇지만 누적 ACK은 “중간이 비면 약하다”는 약점이 있다. 이 약점을 보완하기 위해 등장한 메커니즘이 빠른 재전송과 SACK이다.
빠른 재전송(Fast Retransmit)

빠른 재전송이 나타나게 된 배경
위 상황은 delayed ACK를 사용하지 않는 누적 ACK가 적용된 TCP 통신 시나리오이다. delayed ACK를 사용하지 않아 수신 측이 각 세그먼트마다 ACK를 보내고 있는데, Seg 2의 유실로 Seg 3, Seg 4, Seg 5의 응답으로 ACK=101을 보내고 있다.
누적 ACK는 ACK 유실에 강한 거지 Seg 유실에 강한 건 아니기 때문에 Seg가 유실되면 기존 지침대로 RTO 타임아웃에 의해 재전송이 이루어진다.
문제는 RTO 타임아웃 시간이 RTT의 평균과 분산을 고려하여 보수적으로 결정되기 때문에 수백 ms ~ 수 초라는 굉장히 긴 시간으로 설정되어 재전송이 한참 뒤에 실행된다는 것이다. 그래서 온전히 RTO에 맡겨 재전송을 하게 되면 고작 패킷 하나가 손실되었다는 이유로 연결 전체가 수백 ms씩 멈춰버리게 된다. 이를 해결하기 위해 타임아웃보다 빠르게 Seg 유실을 알아챌 수 있는 다른 방식이 필요했다.

그래서 나타난 메커니즘이 바로 “빠른 재전송(Fast Retransmit)”이다.
빠른 재전송은 중복 ACK가 일정 횟수 이상, 즉, 중복 ACK가 3번 더 도착했을 때 재전송을 해버리는 메커니즘이다. 위 상황을 기준으로 하면, ACK 101이 Seg 2 유실 이후 중복 ACK가 3번 도착하는데, 중복 ACK가 1, 2번 더 도착할 때는 재전송을 하지 않고 정확히 3번 더 도착했을 때 재전송을 해버리는 것이다.
그럼 여기서 “왜 하필 3번 더냐?” 의문이 들 수 있는데, 2개로는 부족하기 때문이다. 네트워크 상에서 패킷이 뒤섞인 채로 도착하는 일은 흔하다. 그래서 유실이 아니라 단순히 늦게 도착하는 경우일 수도 있는데 중복 ACK가 1~2개 더 도착했다는 이유로 재전송을 하면 멀쩡한 데이터를 또 보내는 꼴이 되어버리기 때문이다. 그래서 “뒤섞여 도착한 경우”와 “손실”을 구분하기 위해 통계적으로, 그리고 경험적으로 결정된 타협점이 바로 3번이었던 것이다.(RFC 5681에 명시된 규칙)
결론적으로, 3번의 중복 ACK를 받은 경우 어지간한 상황에서 Seg 유실인 상황이며, 이때 해당 Seg를 재전송하면 기존 RTO 기반 재전송 방식보다 훨씬 빠르게 재전송되기 때문에 “빠른 재전송”은 누적 ACK의 “패킷 손실을 늦게 알아챈다”는 약점을 보완해 준다.
SACK(Selective ACK)

빠른 재전송이 누적 ACK의 “패킷 손실을 늦게 알아챈다”는 약점을 보완해 준다면, SACK는 “어떤 패킷을 손실했는지 정확히 모른다”는 약점을 보완해 준다.
위 그림과 같이 Seg 2, 4가 동시에 유실되고, Seg 1, 3, 5, 6이 도착했다고 가정해 보자. 수신자는 Seg 1, 3, 5, 6에 대한 ACK를 송신자에게 전송해야 하는데 Seg 2가 유실되었기 때문에 Seg 3, 5, 6이 도착했음에도 불구하고 보낼 수 있는 정보는 ACK=101 하나뿐이다. 이 세그먼트를 송신자가 여러 번 받아봤자 Seg 3, 5, 6이 정상적으로 도착했음을 알 수 있는 방법은 없다는 것이다. 그래서 송신자는 어쩔 수 없이 Seg 2, 3, 4, 5, 6을 전부 재전송(Go-Back-N)하게 된다.
SACK는 이 문제, 그러니까 “유실이 발생한 세그먼트 뒤에 정상적으로 수신한 세그먼트를 알 수 없어 받은 세그먼트를 송신자가 재전송하는 문제”를 해결한다. 방식은 TCP 헤더의 옵션 필드를 활용해서 이 정보를 추가로 알려주는 방식이다. ACK 필드는 그대로 두되, 옵션 영역에 “이미 받아둔 비연속 구간들의 좌표”를 덧붙여 보내는 것이다.
SACK 방식의 TCP 헤더를 송신자가 수신하게 되면 송신자는 즉시 "101-200과 301-400, 이 두 구간만 재전송하면 된다"고 추론할 수 있다. 누적 ACK만 있었을 때는 "101 이후를 전부 다시 보낸다(Go-Back-N)"는 보수적 선택을 해야 했지만, SACK 덕분에 정확히 빠진 구멍만 채우는 선택적 재전송이 가능해진 것이다.
결론적으로 SACK는 중간 세그먼트가 유실된 상황에서 수신자가 이미 받은 세그먼트에 대한 정보를 TCP 헤더 옵션 필드에 추가함으로써 “어떤 패킷을 손실했는지 정확히 모른다”는 약점을 보완하고, 수신된 세그먼트를 재전송하는 불필요한 작업을 하지 않도록 막아준다.
SACK 유의사항
이러한 SACK에 대해 몇 가지 짚어둘 디테일이 있다.
첫 번째로 SACK는 3-way handshake에서 합의가 되어야 사용할 수 있다는 것이다.
SACK는 3-way handshake의 SYN 패킷에 SACK-Permitted라는 옵션을 실어 보내고, 양쪽 모두 동의해야 사용가능한 상태로 되기 때문에 통신 도중 갑작스레 SACK 방식으로 쓰겠다고 해봤자 허용되지 않는다.
두 번째로 양쪽 OS가 모두 SACK를 지원해야 한다는 것이다 .
SACK는 한쪽이라도 OS가 지원하지 않는다면 연결은 누적 ACK로만 동작하게 된다. 물론 요즘 OS는 기본으로 활성화되어 있긴 하다.
세 번째로 SACK 블록은 최대 4개로 제한된다는 것이다 .
TCP 옵션 필드는 무한정 늘어나지 않고 총길이가 40바이트로 제한되어 있다. 수신한 세그먼트 정보를 담는 SACK 블록 하나당 8바이트를 차지하므로 SACK 블록만으로 채우면 8*5 = 40이므로 5개를 넣을 수 있는 공간인데, 실제로는 4개가 최대이다.(RFC 2018 기준) 다만 실무에서는 보통 Timestamps 옵션(10바이트 + 2바이트 패딩 = 12바이트)을 같이 쓰기 때문에 Timestamps를 사용하면 40 - 12 = 28바이트가 남고, 여기서 SACK 헤더 2바이트와 블록당 8바이트임을 고려하면 (28 - 2) / 8 = 3개가 실질적 한계이다.
'Common > CS-네트워크' 카테고리의 다른 글
| [Network] 블로킹/논블로킹 방식, 동기/비동기 방식 (0) | 2025.01.11 |
|---|---|
| [Network] protoBuf와 직렬화/역직렬화 (1) | 2024.06.28 |
| [Network] 빅 엔디안(Big Endian)과 리틀 엔디안(Little Endian) (0) | 2024.06.28 |
| [Network] TCP vs UDP (1) | 2024.06.28 |
| [Network] 1분 간단 질문. TCP Handshake란 무엇인가요? (0) | 2024.06.27 |