TCP 스트림
TCP 프로토콜은 데이터가 끊이지 않고 흐르는 스트림과 같다.
-
스트림 특성: 데이터의 흐름 그 자체로서 어디서부터 어디까지가 데이터 종단인지 알 수 없다.
-
TCP 계층: 네트워크 상황에 따라 운영체제는 데이터를 단편화(Fragmentation)하여 나눠보낼 수 있고, 작은 데이터 여러 개의 데이터를 통합(Colaescing)하여 한꺼번에 보낼 수 있다.
패킷 경계 문제
패킷의 경계 문제로 데이터 잘림과 데이터 뭉침이 있다.
-
데이터 잘림: 수신 버퍼가 작거나, 네트워크 중간 경로에서 패킷이 쪼개진 경우에 발생한다.
-
데이터 뭉침: 송신 측에서 효율을 위해 작은 패킷들을 모아서 보낼 때 발생한다.
패킷 규격 설계
데이터의 길이나 종단을 표시하는 것을 어플리케이션 프로토콜(Application Protocol)이라고 한다.
-
구분자 방식(Delimiter): 데이터 끝에 특정 문자를 붙이는 방식으로 구현이 매우 쉽다. 다만 데이터 내용물 안에 구분자와 똑같은 문자가 포함되면 패킷이 깨진다.
-
고정 길이 방식(Fixed Length) 모든 패킷을 무조건 지정된 데이터 크기로 맞추는 방식으로 읽기가 단순하다. 다만 실제 데이터가 작아도 지정된 데이터 크기로 보내야 하므로 대역폭 낭비가 심할 수 있다.
-
헤더와 바디 방식(Header And Body): 데이터 앞에 실제 데이터의 길이를 기록하는 헤더를 붙이는 방식이다.
| 구성 요소 | 크기 | 설명 |
|---|---|---|
| Length(Header) | 4 Bytes(int) | 뒤에 올 데이터의 순수 크기 기록 |
| Command(Header) | 2 Bytes(short) | 어떤 요청인지 구분 |
| Payload(Body) | N Bytes | 실제 전송하려는 데이터 내용물 |
바이트 오더링과 엔디언
헤더에 데이터 길이를 담을 때 주의할 점은 CPU마다 숫자를 바이트로 저장하는 순서가 다를 수 있다는 것이다.
-
리틀 엔디안(Little Endian): 하위 바이트부터 저장한다.
-
빅 엔디안(Big Endian): 상위 바이트부터 저장한다.
데이터를 보낼 때 IPAddress.HostToNetworkOrder() 메소드를 사용하여 숫자를 네트워크 표준으로 바꾸고 받을 때 다시 복원하는 과정이 필요하다.
패킷 수신 알고리즘
데이터가 잘려올 수 있기 때문에, Read() 메소드는 여러 번에 걸쳐 호출되어야 한다.
예제 코드는 다음과 같다.
// 4바이트 헤더(길이)를 완전히 읽을 때까지 반복하는 예시 로직
byte[] headerBuffer = new byte[4];
int totalRead = 0;
while (totalRead < 4)
{
int read = stream.Read(headerBuffer, totalRead, 4 - totalRead);
if (read == 0) throw new Exception("연결 종료");
totalRead += read;
}
// 이제 headerBuffer에 담긴 길이를 해석해서 Body를 읽는다.
-
수신 버퍼 크기: 너무 작으면 Read() 메소드에 자주 진입되며, 너무 크면 메모리가 낭비된다.
-
Network Stream 특성: Write() 메소드는 보냈다고 해서 즉시 상대방에게 전달된 것이 아니다. 운영체제의 송신 버퍼에 담긴 것이며, 실제 전송 시점은 운영체제가 결정한다. Flush() 메소드를 호출해도 TCP에서는 즉시 전송이 보장되지 않을 수 있다.