C# - .NET 7 Preview 5 신규 기능 - System.IO.Stream ReadExactly / ReadAtLeast
System.IO.Stream에 반가운 기능이 추가되었군요. ^^
System.IO.Stream - ReadExactly and ReadAtLeast
; https://devblogs.microsoft.com/dotnet/announcing-dotnet-7-preview-5/#readexactly-and-readatleast
TCP 프로토콜의 경우, message-oriented의 UDP와는 달리 stream-oriented 방식이기 때문에 패킷이 분할돼 오는 것을 감안해야 합니다. 예를 들어 볼까요? ^^
여러분들이 프로토콜을 다음과 같은 식으로 만들었다고 가정해 보겠습니다.
int size 0~4바이트: contents 길이
byte [] contents: size에 명시한 만큼의 데이터
그럼 보내는 쪽에서는 처음 4바이트에 contents 버퍼의 데이터 크기를 실어 보내고, 이어서 contents를 보내게 될 것입니다.
// 서버에서 클라이언트로 데이터 송신
string data = "12345";
byte[] contents = Encoding.UTF8.GetBytes(data);
byte[] header = BitConverter.GetBytes(contents.Length);
socket.Send(header);
socket.Send(contents);
그리고 받는 측에서는 다음과 같이 코딩을 하면,
// 클라이언트에서 서버로부터 데이터 수신
byte[] buf = new byte[4];
socket.Receive(buf, 0, 4, SocketFlags.None);
int len = BitConverter.ToInt32(buf, 0);
buf = new byte[len];
socket.Receive(buf, 0, len, SocketFlags.None); // buf에는 문자열 "12345"가 utf-8 인코딩된 바이트를 수신
대개의 경우 저런 상황이라면 정상적으로 수신이 될 것입니다. 하지만, 서버와 클라이언트를 원격지에 두고 contents의 길이를 늘린다면 이제 상황은 달라집니다.
예를 들어 송신 측에서 1_000_000 바이트의 데이터를 보내도 수신 측의 Receive는 그것을 전부 받지 못하고 분할 수신이 될 수 있습니다. 이런 이유로 인해 Receive의 경우는 대개 다음과 같은 식으로 코딩하는 것이 일반적입니다.
public int Read(byte[] buffer, int offset, int size)
{
int totalRead = 0;
int readRemains = size;
while (true)
{
int readLen = 0;
try
{
readLen = _socket.Receive(buffer, offset, readRemains, SocketFlags.None);
}
catch (SocketException ex) { }
if (readLen == 0)
{
_socket.Close();
break;
}
totalRead += readLen;
if (totalRead == size)
{
break;
}
offset += readLen;
readRemains -= readLen;
}
return totalRead;
}
저렇게 helper 메서드를 하나 만들어 두고 다시 이것을 활용해,
// Read 메서드를 이용한 데이터 수신
byte[] buf = new byte[4];
Read(buf, 0, 4);
int len = BitConverter.ToInt32(buf, 0);
buf = new byte[len];
Read(buf, 0, len);
수신하면 정상적으로 buf 바이트 배열에는 송신자가 약속한 크기의 데이터가 들어가게 됩니다.
.NET 7부터 추가된 Stream 타입의 ReadExactly 메서드가 바로 저런 Read의 부차적인 코드를 없애줍니다. ^^ 그래서 다음과 같은 식으로 코드를 바꿔 처리할 수 있습니다.
// Visual Studio 2022 17.2의 경우 실습 방법
NetworkStream ns = new NetworkStream(socket);
ns.ReadExactly(buf, 0, 4);
len = BitConverter.ToInt32(buf, 0);
buf = new byte[len];
ns.ReadExactly(buf, 0, len);
참 쉽죠? ^^
이와 함께 ReadAtLeast는 이름에서 의미하는 것처럼 최소 지정한 바이트만큼을 읽어들이는 것을 보장합니다. 이건 어느 때 유용할까요? 이전에 예를 든 코드를 다시 보면, 무조건 2번의 Read 메서드를 호출해야만 했는데요, 하지만 여러분들의 응용 프로그램이 대개의 경우 한 번의 Receive로 처리할 정도로 작은 데이터가 주를 이룬다고 가정하면 ReadAtLeast 한 번의 호출로 끝나는 코드를 다음과 같이 만들 수 있습니다.
NetworkStream ns = new NetworkStream(socket);
int readBytes = ns.ReadAtLeast(buf, 4); // 최소 4바이트를 읽어들이지만, 이미 버퍼에 있는 4바이트 이상의 데이터를 더 읽어서 반환하는 것도 가능
len = BitConverter.ToInt32(buf, 0);
if (readBytes < (len + 4))
{
ns.ReadExactly(buf, 0, len - (readBytes - 4));
}
물론, 비동기 버전도 함께 Stream 레벨에 추가되었습니다. ^^
namespace System.IO;
public partial class Stream
{
public void ReadExactly(Span<byte> buffer);
public void ReadExactly(byte[] buffer, int offset, int count);
public ValueTask ReadExactlyAsync(Memory<byte> buffer, CancellationToken cancellationToken = default);
public ValueTask ReadExactlyAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default);
public int ReadAtLeast(Span<byte> buffer, int minimumBytes, bool throwOnEndOfStream = true);
public ValueTask<int> ReadAtLeastAsync(Memory<byte> buffer, int minimumBytes, bool throwOnEndOfStream = true, CancellationToken cancellationToken = default);
}
(
첨부 파일은 이 글의 예제 코드를 포함합니다.)
참고로 이런 helper 메서드는 이미 다른 언어의 라이브러리들도 제공하고 있었습니다. 가령 GoLang의 경우 이와 유사한 ReadFull / ReadAtLeast 함수가 있습니다.
func ReadFull(r Reader, buf []byte) (n int, err error)
; https://pkg.go.dev/io#ReadFull
func ReadAtLeast(r Reader, buf []byte, min int) (n int, err error)
; https://pkg.go.dev/io#ReadAtLeast
또한 닷넷과 GoLang 모두 ReadExactly(ReadFull) 메서드는 ReadAtLeast에 적절한 인자를 전달하는 방식으로 처리를 대행합니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]