TCP/IP 송수신 구조
TCP/IP 송수신 구조
네이버에서 1.4MB 파일을 다운로드한다고 상상해보자. 해당 파일은 스트림 형태로 내려가 세그먼트화된 1000 개 이상의 세그먼트로 분리된 다음, 패킷 형태로 감싸서 아래 계층까지 내려가 결국 스위치와 라우터를 거쳐 요청한 사용자의 프로세스로 흘러들어가게된다.
이를 더 깊게 생각해본다면, TCP로 통신을 시도할려고 한다음, 연결이라는 과정을 거친다. 연결은 마치 통화하는 것과 같다. 통화를 하면, 연결 수신음이 들린 뒤 서로 대화를 하지 않는가? 이와 똑같다.
TCP/IP 구조를 이용하여 방금 설명한 네트워크의 흐름을 깊게 들어가보면, 서버에 HDD가 있을것이다. 그 저장 장치(HDD)안에 파일을 서버의 버퍼로 복사한다. 이때, 버퍼는 개발자가 임시로 설정한 크기만큼 잠시 담아둘 수 있는 공간이다. 그래서 전달되는 크기는 1.4MB 전체일 수도 있겠지만, 그보다 더 클 수도 더 작을 수도 있다. 이는 이것을 설정한 개발자 마음이다.
그런 다음 다시 또 서버의 버퍼에서 Socket의 버퍼로 또 카피가 일어난다. 이때, Socket이 Buffer가 있다면, Buffered IO이라 부르며, 없다고 하면 Unbuffered IO 이라 부른다. 그리고 이 Socket 버퍼에서 입력과 출력이 발생한다. Socket 버퍼로 입력이 들어간 File 데이터 일부분들이 아랫단 즉, TCP/IP 계층으로 출력이 발생하게된다. 그리고 Copy 일어나게 하는 상황을 Send라고 하며, 그걸 받는 버퍼는 Receive 라 한다.
서버는 사용자에게 보내는 프로세스 Socket Buffer에 안에 있는 이 데이터가 바로 Stream 이다. Stream은 연속적인 물줄기처럼 연속적으로 이어져있다. 물론 버퍼 크기만큼 분할되었을지 몰라도 파일 데이터의 전송 흐름은 계속 이어지고, 어플리케이션 상에서는 끊어지는 형태가 어느정도 가려져 있기에 Stream 이라는 이름이 붙여진 것 같다. 반면, 패킷이나 세그먼트 같은 경우 일일이 분할된 데이터에 Encapsulation 이라는 과정이 있기때문에 커널 입장에서는 분할되었다고 보는 것이다.
나아가 그 stream들은 Socket을 거쳐 아래 TCP로 내려갈 때, 그 스트림들이 일정한 크기로 잘리게된데, 이때 그 일정한 크기의 데이터들에게 TCP 헤더를 붙이면 이를 Segment 라 한다. 이때 TCP 커널 스택에서 TCP 버퍼에 스트림에 담고, 이를 일정한 크기로 자른 다음, TCP 헤더를 붙이는 프로세스를 실행시킨다.
그리고 그 Segment들은 1,2,3... 과 같이 번호가 부여된다. 그리고 그 세그먼트들을 다시 또 3계층으로 넘어가 IP Header로 Encapsulation이 일어나면, 이를 우리는 패킷이라 부르는 것이다. 또 이제 2곌층으로 넘어가면 ethernet header 붙여지면, 이는 이제 frame이 된다.
frame header는 계속 바뀌게된다. 더 정확히 말하면, frame의 목적지 주소와 출발지 주소가 바뀐다. IP header에 최종 출발지와 목적지 정보가 있다면(물론 이것도 중간에 방화벽이라든가, NAC가 있다면 바뀌기 때문에 얘기가 좀 달라지긴한다), frame은 계속 라우터를 거치면서, 라우터들의해 MAC 주소들이 바뀌는데, 만약 공유기에서 ISP 라우터에 패킷을 보내기전에 먼저 IP header의 출발지 주소와 frame header의 출발지 mac 주소를 본인 껄로 다 바꾸고, 목적지 IP는 그대로 둔채, 목적지 MAC 주소를 ISP 라우터로 바꾸게된다. 그럼 ISP 라우터들은 각종 또 다른 라우터 들을 거치면서 출발지와 목적지 MAC 주소를 계속 바꾸게 된다. 그렇게 바꾸고 바꾸다가 결국 서버 LAN과 연결되어 있는 최종 라우터에 도착할 것이다.
해당 라우터는 다시 출발지를 자신의 MAC 주소로 바꾸고, 목적지 MAC을 대상 서버의 MAC으로 바꾼다. 그렇게 해서 결국 아랫단 L2 장비에게 넘어가게 되고, L2 단에서는 또 스위치 테이블이라는 것을 통해 목적지 MAC 주소에 따른 port쪽으로 프레임을 스위칭하게되는 것이다. 그리고 프레임이 결국 최종 호스트 PC에게 잘 도착했다면, 호스트 PC의 NIC로 흘러 들어와 frame을 까보고 목적지 MAC이 본인의 MAC인지 확인한 다음, IP 헤더를 까보고, TCP 헤더도 까본다. 이 과정을 Decapsulation 이라 한다. 앞에서는 계속 싸고 싸는 Encapsulation 과정을 거쳤는데 말이다. 암튼, 그렇게 확인해본 TCP 헤더의 포트 번호를 통해 어떤 프로세스 포트 번호인지 확인해본다. 그리고 해당 프로세스로 전달하기 전에 흩어진 세그먼트들을 다시 순서대로 이어붙어야한다. 이때, 순서대로 이어붙이는 주체는 TCP 커널 스택이라는 놈이다.
운영체제 커널 내 TCP/IP 스택은
- TCP 모듈(TCP 연결 관리는 어떻게 할지, 시퀀스·ACK 처리 로직은 어떻게 할지 등)
- IP 모듈(IP 헤더 처리, 라우팅 등)
- ARP 모듈(MAC 주소 해석)
- ICMP, NAT, 방화벽 모듈 등등
이 분리된 소스로 구성돼 있고, 각 계층 간 API·함수 호출로 상호 작용한다.
해당 커널 모듈이 해당 패킷들을 순서에 맞게 재조립한다. 재조립한 일련의 스트림을 소켓 버퍼에 담고, 사용자의 프로세스 버퍼가 해당 소켓 버퍼에 담긴 스트림을 receive 한다.(복사한다) 그렇게 계속 채워지는 소켓 버퍼와 그걸 퍼서 자신의 버퍼에 옮기는 프로세스 버퍼의 동작이 거의 동시에 이루어진다.
이때, TCP 모듈은 패킷이 잘 도착했다는 의미로, 상대방에게 ACK 플래그 비트가 담긴 패킷을 보낸다. 물론 ack 라는 패킷이 서버에게 가기 전까지는 서버는 wait 한다. 이를 wait for ack 라 한다. 이러한 과정이 파일이 온전히 전송될 때까지 계속 반복된다. 그리고
소켓 버퍼의 스트림들이 온전히 사용자 프로세스의 버퍼로 가야 소켓 버퍼는 그 안을 비울 수 있게되는데, 이때 비워서 생기는 여유 공간이 있다. 그리고 이 여유 공간의 메모리 size를 우리는 window size 라고 부른다. 라고 강사님께서 말씀하시는데 우리가 통상 윈도우 사이즈라 함은 TCP 커널 스택의 버퍼이다. 이는 TCP Header를 살펴보면 window size 라고 나와있다.
이 window size 만큼 사용자가 서버에서 온 세그먼트를 더 받을지 말지를 결정하게된다. 그럼 이 정보를 서버도 공유해야하지 않는가? 그래서 방금 말한 ACK 패킷안에 사용자 자신의 소켓 버퍼의 여유 공간 정보도 같이 포함시켜 서버쪽으로 보낸다. 그럼 서버는 그 정보에 맞게 자신이 보낼 프레임의 양을 조절하게된다.
대표적인 TCP 통신 네트워크 수준의 장애
1. 패킷 loss(유실)
2. 재전송 + 중복된 ACK: 1번 2번 세그먼트에 대한 ACK 패킷 응답이 느려져서 서버는 해당 세그먼트들을 사용자가 잘 못 받았다고 생각하고, 다시 보냈는데 그 사이에 사용자는 해당 ACK 패킷을 보내버렸고, 다시 받은 1번 과 2번 세그먼트에 대한 ACK 응답을 다시 또 보내는 상황 => 서로 Sync가 잘 안 맞음
3. out of order: 패킷의 순서가 안 맞는 상태
4. zero window: 사용자 프로세스의 스트림 처리 속도가 socket 버퍼 메모리의 채워지는 속도를 따라잡지 못해 해당 버퍼의 window의 여유 공간이 사라진 상태
# TCP 세그먼트 재조립 시뮬레이션 파이썬 스크립트
# ---------------------------
# 세그먼트 정의
# ---------------------------
class Segment:
"""
TCP 세그먼트를 단순화한 객체
"""
def __init__(self, start_seq, data: bytes):
"""
세그먼트의 시작 시퀀스 번호와 데이터를 초기화합니다.
end_seq는 start_seq + len(data) - 1로 계산됩니다.
:param start_seq: 세그먼트의 시작 시퀀스 번호 (정수)
:param data: 세그먼트에 담긴 데이터 (바이트형)
"""
self.start_seq = start_seq
self.data = data
self.end_seq = start_seq + len(data) - 1
def __repr__(self):
"""
세그먼트 객체를 문자열로 표현할 때, 시퀀스 범위와 데이터를 디코딩하여 표시합니다.
:return: 세그먼트의 문자열 표현
"""
# 데이터가 UTF-8로 디코딩 가능한지 확인하고, 불가능하면 '???'로 표시
try:
data_str = self.data.decode('utf-8')
except UnicodeDecodeError:
data_str = '???'
return f"<Seg [{self.start_seq}-{self.end_seq}] '{data_str}'>"
# ---------------------------
# 세그먼트 추가 및 병합 함수
# ---------------------------
def add_segment(segments, new_seg):
"""
새 세그먼트를 수신 큐에 추가하고, 시퀀스 번호 기준으로 정렬 및 병합합니다.
:param segments: 현재 재조립 큐에 저장된 세그먼트 리스트
:param new_seg: 새로 도착한 세그먼트 (Segment 객체)
"""
# 1. 새 세그먼트를 리스트에 추가
segments.append(new_seg)
# 2. 시퀀스 번호 기준으로 세그먼트를 정렬
segments.sort(key=lambda seg: seg.start_seq)
# 3. 인접하거나 겹치는 세그먼트를 병합
merged_segments = [] # 병합된 세그먼트를 저장할 리스트
current = segments[0] # 첫 번째 세그먼트를 현재 세그먼트로 설정
for next_seg in segments[1:]:
# 현재 세그먼트와 다음 세그먼트가 겹치거나 인접한지 확인
if next_seg.start_seq <= current.end_seq + 1:
# 겹치거나 인접한 경우, 데이터를 병합
overlap = (current.end_seq + 1) - next_seg.start_seq
if overlap < 0:
overlap = 0 # 겹치는 부분이 없을 경우
# 병합된 데이터: 현재 세그먼트의 데이터에 다음 세그먼트의 중복되지 않는 부분을 추가
merged_data = current.data + next_seg.data[overlap:]
# 병합된 세그먼트로 현재 세그먼트를 갱신
current = Segment(current.start_seq, merged_data)
else:
# 겹치지 않는 경우, 현재 세그먼트를 병합 리스트에 추가하고 다음 세그먼트로 전환
merged_segments.append(current)
current = next_seg
# 마지막 현재 세그먼트를 병합 리스트에 추가
merged_segments.append(current)
# 병합된 세그먼트로 재조립 큐를 갱신
segments.clear()
segments.extend(merged_segments)
# ---------------------------
# 연속된 데이터 추출 함수
# ---------------------------
def get_contiguous_data(segments, start_seq=0):
"""
지정된 시작 시퀀스 번호부터 빈틈 없이 이어지는 바이트 스트림을 추출합니다.
:param segments: 현재 재조립 큐에 저장된 세그먼트 리스트
:param start_seq: 추출을 시작할 시퀀스 번호 (기본값: 0)
:return: 연속된 바이트 스트림 (bytes)
"""
if not segments:
return b""
data_buf = b"" # 최종적으로 반환할 연속된 데이터 버퍼
expect_seq = start_seq # 현재 기대 시퀀스 번호
for seg in segments:
if seg.start_seq > expect_seq:
# 중간에 빈 구간이 생긴 경우, 그 지점에서 중단
break
# 현재 세그먼트의 시작 시퀀스가 기대 시퀀스보다 작은 경우(이미 일부 수신된 경우)
offset = expect_seq - seg.start_seq
if offset < 0:
offset = 0 # 겹치는 부분이 없으면 0
# 세그먼트에서 가져올 데이터 부분
part_data = seg.data[offset:]
data_buf += part_data
# 기대 시퀀스 번호 갱신
expect_seq = seg.start_seq + len(seg.data)
return data_buf
# ---------------------------
# 테스트 시뮬레이션
# ---------------------------
if __name__ == "__main__":
# 재조립 큐를 초기화
reassembly_queue = []
# 여러 세그먼트를 순서 뒤죽박죽으로 생성
seg1 = Segment(0, b"Hello") # [0-4]
seg2 = Segment(10, b"World!") # [10-15]
seg3 = Segment(5, b" ") # [5-5] (공백 1바이트)
seg4 = Segment(6, b"TCP ") # [6-9]
seg5 = Segment(16, b" This is a test") # [16-30]
# 세그먼트를 재조립 큐에 추가
add_segment(reassembly_queue, seg1)
add_segment(reassembly_queue, seg2)
add_segment(reassembly_queue, seg3)
add_segment(reassembly_queue, seg4)
add_segment(reassembly_queue, seg5)
# 재조립 큐의 상태 출력
print("[After all segments inserted]")
for seg in reassembly_queue:
print(seg)
# 연속된 데이터를 추출
data = get_contiguous_data(reassembly_queue, start_seq=0)
print("\n[Contiguous Data from seq=0]")
print(data)
print("As String:", data.decode('utf-8', errors='ignore'))
# ---------------------------
# Out-of-order 세그먼트 도착 시뮬레이션
# ---------------------------
print("\n--- Out-of-Order Arrival Simulation ---")
# 새로운 재조립 큐 초기화
reassembly_queue = []
# 세그먼트를 Out-of-order로 추가
add_segment(reassembly_queue, seg1) # [0-4] "Hello"
add_segment(reassembly_queue, seg2) # [10-15] "World!"
add_segment(reassembly_queue, seg5) # [16-30] " This is a test"
add_segment(reassembly_queue, seg4) # [6-9] "TCP "
add_segment(reassembly_queue, seg3) # [5-5] " " (늦게 도착)
# 재조립 큐의 상태 출력
print("[After all segments inserted in Out-of-order]")
for seg in reassembly_queue:
print(seg)
# 연속된 데이터를 추출
data = get_contiguous_data(reassembly_queue, start_seq=0)
print("\n[Contiguous Data from seq=0]")
print(data)
print("As String:", data.decode('utf-8', errors='ignore'))
위 코드 예시는 실제 TCP 스택의 세그먼트 재조립 과정을 파이썬 코드로 간단하게 작성한 것이다. 다만, 내가 직접 작성한게 아닌, 우리의 똑똑한 AI 친구 Chatgpt가 해주었다 ㅎㅎ
간단하게만 말하면,
먼저, 재조립 큐에 세그먼트를 하나하나 넣고, 그걸 앞에 먼저 온 세그먼트와 비교하여 순서가 맞는지 확인하고 순서에 맞게 재정렬을 한다. 이때, 겹치는 부분이 있다면, 그 둘을 하나로 합치고, 먼저 얘가 있다면 얘는 다시 맨 뒤로 보낸다. 이를 위해 Queue 자료구조를 활용하였다. 그렇게 이쁘게 정렬된 세그먼트들을 하나의 스트림으로 만들어 이를 순서큐에 보내 프로세스 버퍼가 이를 가져올 수 있도록 하는 것을 표현한 것이다.
말로만 들은 재조립 과정을 이렇게 코드로 명시화시키니 나름 선명해지는 느낌이 있지만, 아직 제대로 된 커널 스택을 까본 것이 아니라서 여기까지만 하겠다. 추후에 커널 스택까지도 볼 여유가 있다면 해보겠다.
출처
외워서 끝내는 네트워크 핵심이론 - 기초 강의 | 널널한 개발자 - 인프런
널널한 개발자 | TCP/IP에서 HTTP까지! 네트워크에 대한 기본 이론이 부족한 분들이 '외워서'라도 전공 이론을 이해하고자 희망하는 분들을 위해 준비한 강의입니다. 할 수 있습니다!, 네트워크, 외
www.inflearn.com