ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • VB.NET TCP 수신 데이터가 잘리거나 붙을 때: 메시지 경계부터 처리하기
    실전 개발/VB.NET · WinForms 2026. 10. 13. 11:40
    반응형

    하이롱! 오늘은 TCP는 순서를 지켜주지만, 메시지 한 줄까지 포장해 주지는 않는다는 오류(?)에 대해서 알아볼꺼에요.

     

    보낸 쪽에서는 한 번에 전송했는데 받는 쪽에서는 두 번에 나뉘거나, 따로 보낸 명령 두 개가 한 번에 들어올 때가 있죠?

     

    해결 순서는 간단합니다. 바이트를 누적하고 → 약속한 경계로 메시지를 나누고 → 완성된 메시지만 디코딩하고 처리합니다. 버퍼 크기나 대기 시간을 조절하기 전에 이 흐름부터 확인하세요.

    Read 한 번이 메시지 한 개는 아니다

    TCP는 바이트 스트림입니다. Write 호출과 Read 호출의 경계가 같다고 보장하지 않습니다. NetworkStream.Read도 요청한 크기까지 현재 읽을 수 있는 바이트 수를 반환합니다.

    보낸 데이터 가능한 수신 조각
    PING\n PI / NG\n
    PING\nSTATUS\n PING\nSTATUS\n
    상태@준비\n 한글 한 글자의 UTF-8 바이트 중간에서 분할

    표의 \n은 줄바꿈(LF)을 뜻합니다. 실제 데이터에 역슬래시와 n 두 글자를 보내는 방식이 아닙니다.

    기존 비동기 소켓 예제는 EndRead로 받은 조각을 바로 UTF8.GetString으로 바꿔 이벤트에 전달합니다. 연결·송수신 흐름을 보는 데는 쓸 수 있지만, 업무 메시지 처리 앞에는 경계를 조립하는 단계가 더 필요합니다.

    버퍼 확대·Sleep·Split만으로 해결되지 않는 이유

    • 버퍼 확대: 한 번에 읽을 수 있는 양은 늘지만, 보내는 쪽의 메시지 경계를 만들어주지는 않습니다.
    • Sleep: 타이밍에 따라 겉으로 괜찮아질 수 있어도 지연·분할·병합 조건에서 보장되지 않습니다.
    • 조각마다 Split: 뒤쪽 메시지가 덜 왔을 때 남은 부분을 다음 수신까지 보관하지 못합니다.

    @가 필드 구분자라면 완성된 메시지를 조립한 뒤 Split("@")을 적용합니다. 명령·값 구분과 메시지 종료를 같은 문제로 취급하면 안 됩니다.

    먼저 송수신 규칙을 정하기

    아래 예제는 양쪽 프로그램이 UTF-8, BOM 없음, 메시지 끝은 LF, 본문에는 실제 줄바꿈 없음에 합의한 경우입니다. CRLF도 받으며 LF 앞의 CR 한 바이트만 제거합니다. LF를 제외한 누적 길이는 예제 기준 최대 4096바이트입니다.

    이미 쓰는 장비에 LF를 임의로 추가하거나, 보내지도 않는 LF를 기다리게 바꾸면 안 됩니다. 장비 매뉴얼의 고정 길이·길이 헤더·STX/ETX·체크섬 등 기존 규칙을 먼저 확인하세요. 이미지나 줄바꿈이 들어가는 본문은 길이 헤더 같은 다른 방식이 필요합니다.

    기존 수신 콜백 앞에 바이트 조립기를 두기

    문자열 조각을 붙이는 대신 메시지 경계까지 바이트를 보관합니다. 한글이 여러 Read에 나뉘어도, 완성된 메시지를 한 번에 디코딩하면 해당 문자 바이트가 함께 들어갑니다.

    Imports System
    Imports System.Collections.Generic
    Imports System.IO
    Imports System.Text
    
    Public Class LineFrames
        Private Const MaximumBytes As Integer = 4096
        Private ReadOnly pending As New List(Of Byte)
        Private ReadOnly utf8 As New UTF8Encoding(False, True)
    
        Public Iterator Function Feed(data As Byte(), count As Integer) As IEnumerable(Of String)
            If data Is Nothing Then Throw New ArgumentNullException("data")
            If count < 0 OrElse count > data.Length Then
                Throw New ArgumentOutOfRangeException("count")
            End If
    
            For i As Integer = 0 To count - 1
                If data(i) = 10 Then ' LF
                    Dim bytes As Byte() = pending.ToArray()
                    pending.Clear()
    
                    Dim length As Integer = bytes.Length
                    If length > 0 AndAlso bytes(length - 1) = 13 Then
                        length -= 1 ' CRLF의 CR만 제거
                    End If
                    Yield utf8.GetString(bytes, 0, length)
                Else
                    If pending.Count >= MaximumBytes Then
                        pending.Clear()
                        Throw New InvalidDataException("메시지 길이 초과")
                    End If
                    pending.Add(data(i))
                End If
            Next
        End Function
    
        Public Sub Finish()
            If pending.Count <> 0 Then
                Throw New InvalidDataException("미완성 메시지에서 연결 종료")
            End If
        End Sub
    End Class

    UTF8Encoding(False, True)는 잘못된 UTF-8을 예외로 알립니다. Feed는 Yield로 완성된 메시지를 하나씩 내보내고, 덜 온 바이트는 pending에 남깁니다. 뒤쪽 메시지가 잘못됐더라도 앞에서 전달한 정상 메시지까지 사라지지 않도록 했습니다.

    Iterator는 Visual Studio 2012 이후 VB 컴파일러에서 지원합니다. Feed의 For Each를 끝내기 전에 다음 읽기를 시작하거나 수신 배열을 재사용하지 마세요. 열거를 중간에 끊거나 같은 결과를 두 번 열거하는 방식도 피합니다.

    LF를 계속 보내지 않는 경우에도 무제한으로 쌓이지 않도록 길이 상한을 둡니다. 길이 초과나 인코딩 오류가 나면 이 예제의 호출부는 해당 연결을 종료하고 조립기를 버리는 정책으로 사용합니다. 예외를 삼킨 채 같은 입력 흐름을 계속 처리하지 않습니다.

    소켓 없이 먼저 분할 조건 확인하기

    위 클래스와 아래 모듈을 VB.NET 콘솔 프로젝트의 같은 .vb 파일에 넣으면 됩니다. 입력을 한 바이트씩 나누므로 한글 중간과 CR/LF 사이도 분리됩니다.

    Module Program
        Sub Main()
            Dim wire As Byte() =
                Encoding.UTF8.GetBytes("PING" & vbLf & "상태@준비" & vbCrLf)
            Dim frames As New LineFrames()
            Dim received As New List(Of String)
    
            ' 한글 바이트와 CRLF까지 한 바이트씩 잘라 넣는다.
            For Each b As Byte In wire
                received.AddRange(frames.Feed(New Byte() {b}, 1))
            Next
            frames.Finish()
    
            If received.Count <> 2 OrElse
               received(0) <> "PING" OrElse received(1) <> "상태@준비" Then
                Throw New Exception("분할 수신 처리 불일치")
            End If
            Console.WriteLine(String.Join(" | ", received.ToArray()))
        End Sub
    End Module

    기대 출력은 PING | 상태@준비입니다. 모두 한 번에 Feed에 넣거나, 어느 위치에서 두 조각으로 나누더라도 같은 메시지 두 개가 나와야 합니다. 실제 네트워크가 꼭 한 바이트씩 전달된다는 뜻이 아니라, 경계 처리 확인용 입력입니다.

    확인할 입력 기대 처리
    두 메시지를 한꺼번에 전달 두 메시지를 순서대로 반환
    메시지 중간에서 나눠 전달 LF가 올 때까지 보관
    CR과 LF를 따로 전달 CRLF를 메시지 끝으로 처리
    종료문자 없이 Finish 호출 미완성 메시지 오류
    4096바이트를 넘는 미완성 메시지 길이 초과 오류
    잘못된 UTF-8 뒤에 LF 디코딩 예외
    정상 메시지 뒤에 잘못된 UTF-8 메시지 정상 메시지를 먼저 전달한 뒤 오류 종료

    EndRead 뒤에 붙이는 연결 클래스

    다음은 BeginRead 기반 레거시 코드에 연결하는 최소 예제입니다. LineConnection은 별도 .vb 파일에 넣고 LineFrames도 같은 프로젝트에 둡니다. 이미 연결된 TcpClient를 전달하고, MessageReceived 이벤트를 등록한 뒤 Start를 한 번 호출합니다.

    Imports System
    Imports System.Diagnostics
    Imports System.Net.Sockets
    Imports System.Threading
    
    Public Class LineConnection
        Private ReadOnly client As TcpClient
        Private ReadOnly stream As NetworkStream
        Private ReadOnly buffer(2047) As Byte
        Private ReadOnly frames As New LineFrames()
        Private started As Integer
        Private closed As Integer
    
        Public Event MessageReceived(message As String)
    
        Public Sub New(connectedClient As TcpClient)
            client = connectedClient
            stream = client.GetStream()
        End Sub
    
        Public Sub Start()
            If Interlocked.CompareExchange(closed, 0, 0) <> 0 Then
                Throw New ObjectDisposedException("LineConnection")
            End If
            If Interlocked.Exchange(started, 1) <> 0 Then
                Throw New InvalidOperationException("이미 수신을 시작했습니다.")
            End If
            Try
                stream.BeginRead(buffer, 0, buffer.Length,
                                 AddressOf ReadCompleted, Nothing)
            Catch ex As Exception
                CloseConnection(ex)
                Throw
            End Try
        End Sub
    
        Private Sub ReadCompleted(ar As IAsyncResult)
            ' 동기 완료 시에도 콜백 재귀를 이어가지 않는다.
            ThreadPool.QueueUserWorkItem(AddressOf ProcessRead, ar)
        End Sub
    
        Private Sub ProcessRead(state As Object)
            Try
                Dim count As Integer = stream.EndRead(CType(state, IAsyncResult))
                If count = 0 Then
                    frames.Finish()
                    CloseConnection(Nothing)
                    Return
                End If
    
                For Each message As String In frames.Feed(buffer, count)
                    RaiseEvent MessageReceived(message)
                Next
    
                stream.BeginRead(buffer, 0, buffer.Length,
                                 AddressOf ReadCompleted, Nothing)
            Catch ex As Exception
                CloseConnection(ex)
            End Try
        End Sub
    
        Public Sub [Stop]()
            CloseConnection(Nothing)
        End Sub
    
        Private Sub CloseConnection(cause As Exception)
            If Interlocked.Exchange(closed, 1) = 0 Then
                client.Close()
                If cause IsNot Nothing Then Trace.TraceError(cause.ToString())
            End If
        End Sub
    End Class

    바뀌는 위치는 EndRead → Feed → MessageReceived 순서입니다. 정상 종료는 Finish로 남은 데이터를 확인한 뒤 닫고, 읽기·디코딩·업무 이벤트 처리 중 예외가 나면 해당 연결을 정리합니다. 이 예제는 TcpClient를 소유하므로 Stop도 연결을 닫습니다.

    콜백에서 작업 큐로 넘기는 이유는 BeginRead가 동기 완료되더라도 콜백 호출이 재귀로 쌓이지 않게 하기 위해서입니다. 다음 읽기는 현재 메시지 열거를 모두 마친 뒤 등록합니다. 이벤트 처리도 작업 스레드에서 실행되므로 WinForms 화면은 BeginInvoke 등으로 전달하고, 긴 작업은 별도 제한된 큐로 분리합니다.

    이 클래스는 메시지 조립과 연결 종료를 보여주는 예제입니다. 인증·권한 확인, 명령별 허용 범위, 타임아웃·재접속 정책은 사용하는 프로토콜에 맞춰 추가합니다. 재접속할 때는 새 TcpClient와 새 LineConnection을 만듭니다. 새 개발이라면 ReadAsync 기반 흐름을 우선 검토하세요.

    실제 Read·EndRead에 연결할 때

    • 조립기는 연결마다 하나씩 만듭니다. 재접속 때 새 인스턴스를 만들고 이전 연결의 남은 바이트를 섞지 않습니다.
    • Read나 EndRead가 반환한 개수만 Feed에 넘깁니다. 배열 전체 길이를 넘기면 이전 수신 흔적까지 처리할 수 있습니다.
    • 0보다 큰 길이로 읽기를 요청했는데 반환값이 0이면 상대의 정상 송신 종료입니다. Finish로 미완성 데이터를 확인하고 수신 루프를 끝냅니다.
    • 네트워크 오류와 프로토콜 오류는 구분해 기록하고 연결 자원을 정리합니다. 종료 후 다음 BeginRead를 다시 등록하지 않습니다.
    • 한 연결에는 하나의 수신 흐름만 둡니다. 이 조립기는 동시에 여러 콜백에서 호출하도록 만들지 않았습니다.
    • 완성된 메시지에서 필드를 나누고 명령·인자 범위를 확인합니다. WinForms 화면 갱신은 UI 스레드로 전달합니다.

    새로 만드는 줄 단위 프로토콜이라면 StreamReader.ReadLine도 선택할 수 있습니다. 다만 같은 스트림에 기존 BeginRead와 StreamReader를 섞으면 StreamReader가 미리 읽은 바이트를 콜백이 받지 못할 수 있습니다. 또 이 글처럼 바이트 길이 제한과 LF 필수 정책이 필요하면 그 조건도 따로 구현해야 합니다.

    최신 .NET의 ReadExactly는 길이 헤더를 읽거나, 이미 알게 된 본문 길이만큼 읽을 때 도움이 됩니다. 메시지 길이를 모르는 상태에서 임의로 4096바이트를 요구하면 상대가 그만큼 보내거나 연결을 끝낼 때까지 기다릴 수 있습니다. .NET Framework에는 해당 API가 없으므로 길이 기반 프로토콜에서는 반환된 바이트 수를 누적하는 반복 읽기가 필요합니다.

    함께 볼 글

    공식 자료

    반응형

    댓글

Designed by Tistory.