[C#] SerialPort로 RS-232 통신 구현하기: COM Port 설정·송수신·DataReceived 처리

C# SerialPort로 RS-232 통신 구현하기: COM Port 설정·송수신·DataReceived 처리#

1장 C# 프로그램에서 RS-232 장비와 통신하려면#

PC에 RS-232 장비를 연결했다고 해서 프로그램이 바로 장비와 통신할 수 있는 것은 아닙니다.

소프트웨어에서는 최소한 다음 조건을 알아야 합니다.

COM Port
Baud Rate
Data Bits
Parity
Stop Bits
Flow Control
Protocol Format

예를 들어 장비 Manual에 다음과 같이 적혀 있다고 하겠습니다.

COM Port
환경에 따라 결정

Baud Rate
19200

Data Bits
8

Parity
None

Stop Bits
1

흔히 다음과 같이 표현합니다.

19200-8-N-1

C# 프로그램은 이 조건을 장비와 동일하게 맞춰야 합니다.

전체 통신 흐름은 다음과 같습니다.

C# Application
        │
        ▼
SerialPort
        │
        ▼
COM Port
        │
        ▼
RS-232 Cable
        │
        ▼
Device

여기서 한 단계라도 잘못되면 사용자 화면에서는 단순히:

장비 응답 없음

으로 보일 수 있습니다.

따라서 RS-232 프로그램에서는 물리 연결과 Serial 설정, byte 송수신, Protocol 해석을 각각 분리해서 확인하는 습관이 중요합니다.

2장 System.IO.Ports.SerialPort를 이해하자#

C#에서는 System.IO.Ports의 SerialPort 클래스를 이용해 Serial Port를 다룰 수 있습니다.

기본 Namespace는 다음과 같습니다.

using System.IO.Ports;

SerialPort에서는 대표적으로 다음 작업을 수행할 수 있습니다.

COM Port 지정

Baud Rate 설정

Data Bits 설정

Parity 설정

Stop Bits 설정

Flow Control 설정

Port Open / Close

byte 송수신

문자열 송수신

DataReceived 이벤트 처리

Read Timeout / Write Timeout 설정

장비 통신 프로그램에서는 이 클래스를 직접 사용하거나 별도의 Wrapper Class로 감싸 사용하는 경우가 많습니다.

예:

SerialPortManager
    │
    ├─ Open
    ├─ Close
    ├─ Send
    ├─ Receive
    └─ Reconnect

처럼 관리하면 규모가 커졌을 때 유지보수가 쉬워집니다.

3장 먼저 COM Port를 찾아야 한다#

Windows에서 Serial Port는 일반적으로 다음과 같은 이름을 사용합니다.

COM1
COM3
COM5
COM10

USB-RS232 Adapter를 사용하면 연결 위치나 Driver 상태에 따라 COM 번호가 달라질 수도 있습니다.

C#에서는 현재 사용 가능한 Port를 확인할 수 있습니다.

string[] ports = SerialPort.GetPortNames();

foreach (string portName in ports)
{
    Console.WriteLine(portName);
}

출력 예:

COM1
COM4
COM7

그러나 중요한 점이 있습니다.

COM4가 존재한다.

와:

COM4가 내가 통신하려는 장비다.

는 같은 의미가 아닙니다.

현장에서는 다음 정보를 함께 확인하는 것이 좋습니다.

장치 관리자

USB-RS232 Adapter 모델

COM Port 번호

장비명

Cable 번호

Switch 또는 제어반 위치

운영 문서

USB Serial Adapter를 여러 개 사용하는 환경이라면 프로그램 설정에 단순히 COM4만 저장하는 것보다 장비와 Port의 매핑 정보를 별도로 관리하는 것이 좋습니다.

4장 Baud Rate·Parity·Stop Bits를 정확하게 설정한다#

예를 들어 장비 설정이:

19200-8-N-1

이라면 C#에서는 다음과 같이 설정할 수 있습니다.

SerialPort port = new SerialPort();

port.PortName = "COM4";
port.BaudRate = 19200;
port.DataBits = 8;
port.Parity = Parity.None;
port.StopBits = StopBits.One;
port.Handshake = Handshake.None;

각 항목의 의미는 다음과 같습니다.

항목 의미 예
Baud Rate Serial 전송 속도 9600, 19200, 115200
Data Bits 한 문자 데이터 Bit 수 7, 8
Parity 간단한 오류 검사용 Bit None, Even, Odd
Stop Bits 문자 끝을 나타내는 Bit 1, 2
Handshake Flow Control None, RTS/CTS, XON/XOFF

여기서 가장 중요한 원칙은:

일반적인 값을 추측하는 것이 아니라 장비 Manual의 설정값과 정확히 일치시키는 것입니다.

장비가:

9600-8-E-1

인데 프로그램이:

9600-8-N-1

이면 정상 통신하지 않을 수 있습니다.

5장 Port를 열고 byte를 송신한다#

설정이 끝났다면 Port를 엽니다.

try
{
    port.Open();

    Console.WriteLine("Serial port opened.");
}
catch (Exception ex)
{
    Console.WriteLine(ex.Message);
}

Port가 정상적으로 열렸다면 장비에 데이터를 보낼 수 있습니다.

예를 들어 Test Frame을 다음과 같이 만들 수 있습니다.

byte[] request =
{
    0x02,
    0x10,
    0x00,
    0x03
};

전송:

port.Write(
    request,
    0,
    request.Length
);

이때 중요한 것은 실제 장비 Protocol입니다.

장비마다 다음 구조가 전혀 다를 수 있습니다.

STX
Address
Command
Length
Data
Checksum
ETX

또는:

Header
Length
Command
Payload
CRC

일 수 있습니다.

따라서 위 byte 배열은 구조를 이해하기 위한 예일 뿐 실제 장비 Command로 사용해서는 안 됩니다.

6장 문자열보다 byte 기준으로 생각하자#

RS-232 장비 통신을 처음 구현할 때 흔히 다음처럼 접근합니다.

port.WriteLine("OPEN");

장비가 ASCII Protocol을 사용한다면 가능할 수 있습니다.

하지만 Binary Protocol이라면 다릅니다.

예:

02 31 00 04 A7 03

처럼 byte 자체가 의미를 가질 수 있습니다.

따라서 먼저 확인해야 합니다.

ASCII Protocol인가?

Binary Protocol인가?

문자 Encoding은 무엇인가?

Packet 시작은 어떻게 찾는가?

Length Field가 있는가?

CRC·Checksum이 있는가?

Binary Protocol이라면 프로그램 내부에서도 가능한 한:

byte[]

기준으로 처리하는 것이 안전합니다.

문자열 변환은 실제 Protocol이 문자열 기반일 때만 명확한 Encoding을 지정해서 수행하는 편이 좋습니다.

7장 DataReceived 이벤트로 비동기 수신하기#

장비는 항상 프로그램이 Request를 보낸 직후에만 데이터를 보내는 것은 아닙니다.

예를 들어:

센서 상태 변경

장비 Alarm

Card Reader Event

Button 입력

주기적인 상태 보고

처럼 장비가 먼저 데이터를 보내기도 합니다.

C# SerialPort에서는 DataReceived 이벤트를 사용할 수 있습니다.

port.DataReceived += OnDataReceived;

간단한 Handler:

private static void OnDataReceived(
    object sender,
    SerialDataReceivedEventArgs e)
{
    SerialPort port = (SerialPort)sender;

    int count = port.BytesToRead;

    byte[] buffer = new byte[count];

    int read = port.Read(
        buffer,
        0,
        count
    );

    Console.WriteLine(
        $"RX: {ToHex(buffer, read)}"
    );
}

여기서 중요한 점은 DataReceived 이벤트가 발생했다고 해서 Packet 하나가 완성되어 있다는 보장은 없다는 것입니다.

8장 DataReceived 한 번이 Packet 하나는 아니다#

장비가 다음 Frame을 보냈다고 하겠습니다.

02 10 04 41 42 43 44 7A 03

프로그램에서는 다음처럼 들어올 수 있습니다.

첫 번째 Event:

02 10 04

두 번째 Event:

41 42 43

세 번째 Event:

44 7A 03

반대 상황도 가능합니다.

PACKET 1
PACKET 2

두 개가 한 번의 Read에서 같이 들어올 수도 있습니다.

따라서:

DataReceived
=
Packet Received

라고 구현해서는 안 됩니다.

권장 구조는 다음과 같습니다.

SerialPort
    │
    ▼
DataReceived
    │
    ▼
Receive Buffer
    │
    ▼
Frame Boundary 탐색
    │
    ▼
Packet Parser
    │
    ▼
Command / Response 처리

Serial Layer는 byte를 전달하고 Packet Parser가 Protocol을 해석하도록 분리하는 것이 좋습니다.

9장 Receive Buffer에서 Packet을 복원한다#

다음 데이터가 먼저 들어왔다고 하겠습니다.

02 10 03 41

아직 Packet이 완성되지 않았습니다.

조금 뒤:

42 43 7A 03

이 추가로 들어옵니다.

Receive Buffer에서는:

02 10 03 41 42 43 7A 03

이 됩니다.

그다음 Protocol 규칙을 이용해 Packet을 추출합니다.

대표적인 Frame 구분 방식은 다음과 같습니다.

STX·ETX 방식#

STX
DATA
ETX

Length 방식#

Header
Length
Data
Checksum

Fixed Length 방식#

항상 16 byte

Delimiter 방식#

COMMAND\r\n

Protocol마다 방식이 다르므로 Parser를 SerialPort 코드에 직접 섞기보다 별도 Class로 분리하는 것이 좋습니다.

SerialTransport

ReceiveBuffer

PacketParser

DeviceProtocol

처럼 역할을 나눌 수 있습니다.

10장 UI 프로그램에서는 Thread를 특히 조심해야 한다#

WinForms나 WPF Application에서 DataReceived Handler가 실행될 때 바로 UI Control을 수정하면 문제가 발생할 수 있습니다.

예를 들어:

private void OnDataReceived(
    object sender,
    SerialDataReceivedEventArgs e)
{
    statusLabel.Text = "Received";
}

처럼 직접 UI를 수정하는 방식은 Thread 문제를 일으킬 수 있습니다.

WinForms에서는 필요한 경우 UI Thread로 넘겨야 합니다.

예:

BeginInvoke(() =>
{
    statusLabel.Text = "Received";
});

WPF에서는 Dispatcher 등을 사용할 수 있습니다.

따라서 구조적으로는:

Serial Thread
↓
Receive Queue
↓
Parser
↓
Application Event
↓
UI Thread

처럼 통신 처리와 화면 처리를 분리하는 것이 좋습니다.

장비가 많아질수록 이런 구조가 중요해집니다.

11장 Timeout·Exception·재연결을 설계한다#

장비 통신에서는 항상 응답이 오는 것이 아닙니다.

가능한 상황:

장비 전원 OFF

Cable 단선

USB Adapter 분리

잘못된 Baud Rate

장비 Busy

Protocol 오류

따라서 Timeout을 설정할 수 있습니다.

port.ReadTimeout = 1000;
port.WriteTimeout = 1000;

다만 1000ms가 모든 장비에 적절하다는 뜻은 아닙니다.

Timeout은 다음 요소를 기준으로 결정합니다.

장비 최대 응답 시간

Command 종류

Protocol 규격

Retry 횟수

업무 중요도

Exception도 반드시 기록해야 합니다.

try
{
    port.Write(
        request,
        0,
        request.Length
    );
}
catch (TimeoutException ex)
{
    Console.WriteLine(
        $"Timeout: {ex.Message}"
    );
}
catch (IOException ex)
{
    Console.WriteLine(
        $"IO Error: {ex.Message}"
    );
}

재연결도 상태를 나누어야 한다#

USB-RS232 Adapter가 빠졌다가 다시 연결될 수 있습니다.

이때:

Port Open 성공

만 확인해서는 부족합니다.

다음처럼 보는 것이 좋습니다.

COM Port 존재
↓
Port Open
↓
Serial 설정 적용
↓
Test Request
↓
정상 Response
↓
Device Online

즉 Port가 열렸다는 것과 장비가 정상이라는 것은 다른 상태입니다.

12장 Hex Dump 로그를 반드시 남기자#

현장에서 다음 Log만 있다면:

Device communication failed

원인을 찾기 어렵습니다.

반면 다음처럼 기록하면 훨씬 유용합니다.

2026-09-24 14:32:10.125
PORT=COM4
CONFIG=19200-8-N-1

TX
02 31 00 04 35 03

2026-09-24 14:32:10.214

RX
02 06 31 37 03

기록하면 좋은 항목은 다음과 같습니다.

Timestamp

Port Name

Baud Rate

TX / RX

Hex Dump

Packet Length

Device ID

Timeout

Exception

Retry Count

Hex 변환 함수는 간단하게 만들 수 있습니다.

static string ToHex(
    byte[] data,
    int length)
{
    return string.Join(
        " ",
        data.Take(length)
            .Select(b => b.ToString("X2"))
    );
}

이런 Raw Log가 있으면:

송신하지 못한 것인가?

장비가 응답하지 않은 것인가?

byte는 왔는데 Parser가 실패한 것인가?

Checksum이 틀린 것인가?

를 구분할 수 있습니다.

13장 C# RS-232 프로그램 구조를 분리하자#

작은 Test Program에서는 하나의 Class에 모든 기능을 넣어도 동작합니다.

하지만 실제 장비 프로그램은 빠르게 복잡해집니다.

Port Open

Receive

Packet Parse

Command 생성

Retry

UI

Database

Logging

이 모든 것이 하나의 Form이나 Class에 들어가면 유지보수가 어려워집니다.

다음처럼 분리할 수 있습니다.

SerialPortManager
│
├─ Port 검색
├─ Open
├─ Close
└─ Reconnect

SerialTransport
│
├─ Send
└─ Receive

ReceiveBuffer
│
└─ byte 누적

PacketParser
│
├─ Frame Boundary
├─ Length 검사
└─ Checksum 검사

DeviceProtocol
│
├─ Command 생성
└─ Response 해석

DeviceService
│
└─ 실제 업무 처리

이런 구조라면 나중에 Transport가:

RS-232
↓
RS-485

또는:

Serial
↓
TCP

로 변경되어도 Protocol Layer를 재사용하기 쉬워집니다.

14장 간단한 통합 예제#

다음은 Port를 열고 Test Frame을 보내고 비동기로 데이터를 받는 기본 구조입니다.

using System;
using System.IO.Ports;
using System.Linq;

public class SerialExample
{
    private readonly SerialPort port;

    public SerialExample(string portName)
    {
        port = new SerialPort
        {
            PortName = portName,
            BaudRate = 19200,
            DataBits = 8,
            Parity = Parity.None,
            StopBits = StopBits.One,
            Handshake = Handshake.None,
            ReadTimeout = 1000,
            WriteTimeout = 1000
        };

        port.DataReceived += OnDataReceived;
    }

    public void Open()
    {
        if (!port.IsOpen)
        {
            port.Open();
        }
    }

    public void Send(byte[] data)
    {
        if (!port.IsOpen)
        {
            throw new InvalidOperationException(
                "Serial port is not open."
            );
        }

        port.Write(
            data,
            0,
            data.Length
        );

        Console.WriteLine(
            $"TX: {ToHex(data, data.Length)}"
        );
    }

    private void OnDataReceived(
        object sender,
        SerialDataReceivedEventArgs e)
    {
        try
        {
            int count = port.BytesToRead;

            if (count <= 0)
            {
                return;
            }

            byte[] buffer = new byte[count];

            int read = port.Read(
                buffer,
                0,
                count
            );

            Console.WriteLine(
                $"RX: {ToHex(buffer, read)}"
            );

            HandleReceivedBytes(
                buffer,
                read
            );
        }
        catch (Exception ex)
        {
            Console.WriteLine(
                $"Receive error: {ex.Message}"
            );
        }
    }

    private void HandleReceivedBytes(
        byte[] data,
        int length)
    {
        // Receive Buffer에 누적한 뒤
        // Protocol 규칙에 따라 Packet을 추출한다.
    }

    private static string ToHex(
        byte[] data,
        int length)
    {
        return string.Join(
            " ",
            data.Take(length)
                .Select(b => b.ToString("X2"))
        );
    }

    public void Close()
    {
        if (port.IsOpen)
        {
            port.Close();
        }
    }
}

사용 예:

SerialExample serial =
    new SerialExample("COM4");

serial.Open();

byte[] request =
{
    0x02,
    0x10,
    0x00,
    0x03
};

serial.Send(request);

이 예제의 핵심은 특정 장비를 제어하는 것이 아닙니다.

다음 구조를 만드는 것입니다.

Port 설정
↓
Open
↓
byte 송신
↓
비동기 수신
↓
Buffer
↓
Packet Parser
↓
Application

15장 장애는 계층별로 확인한다#

C# 프로그램에서 장비 응답이 없다고 하겠습니다.

바로 Code를 수정하기보다 다음 순서로 확인합니다.

물리 연결#

TX

RX

GND

DTE / DCE

Cable

USB-RS232 Adapter

장비 전원

운영체제#

COM Port 존재 여부

Driver 상태

Port 점유

USB 연결 상태

Serial 설정#

Baud Rate

Data Bits

Parity

Stop Bits

Flow Control

송수신#

실제로 TX byte가 나갔는가?

RX byte가 들어오는가?

Timeout인가?

Protocol#

STX / ETX

Address

Command

Length

Data

Checksum / CRC

이 순서로 보면:

C# SerialPort 문제

라고 생각했던 장애가 실제로는 Cable이나 Baud Rate 문제였다는 것을 빠르게 찾을 수 있습니다.

16장 흔히 하는 구현 실수#

DataReceived 한 번을 Packet 하나라고 생각한다#

Event 발생 단위와 Protocol Packet 단위는 다를 수 있습니다.

Receive Buffer가 필요합니다.

Binary 데이터를 ReadExisting으로 처리한다#

ReadExisting()은 문자열 중심 처리에 편리하지만 Binary Protocol에서는 byte 단위 Read()를 사용하는 편이 명확합니다.

Encoding만 바꾸면 모든 데이터가 해결된다고 생각한다#

Binary Protocol에는 Encoding 개념을 적용할 필요가 없는 영역이 많습니다.

먼저 Raw byte를 확인해야 합니다.

UI에서 SerialPort를 직접 관리한다#

작은 프로그램에서는 가능하지만 Device가 늘어나면 Form Code가 복잡해집니다.

통신 Layer를 분리하는 것이 좋습니다.

Timeout을 무조건 길게 설정한다#

장애 감지가 늦어질 수 있습니다.

장비의 정상 Response Time을 기준으로 설계해야 합니다.

Port Open 성공을 장비 Online으로 판단한다#

COM Port가 열렸다는 사실과 Device가 Protocol에 정상 응답한다는 사실은 다릅니다.

17장 자기 점검#

C#에서 사용할 수 있는 COM Port는 어떻게 확인하는가#

SerialPort.GetPortNames()를 이용해 운영체제가 인식한 Serial Port 목록을 가져올 수 있습니다.

19200-8-N-1은 무엇을 의미하는가#

Baud Rate 19200, Data Bits 8, Parity None, Stop Bits 1을 의미합니다.

DataReceived가 발생하면 Packet이 완성된 것인가#

아닙니다. 여러 Event에 하나의 Packet이 나뉘거나 여러 Packet이 한꺼번에 들어올 수 있습니다.

Binary Protocol에서 ReadExisting을 그대로 사용해도 되는가#

문자열 기반 Protocol에서는 편리하지만 Binary Protocol에서는 byte 단위 Read()와 Receive Buffer를 사용하는 편이 안전합니다.

Port가 정상적으로 열렸는데 장비가 응답하지 않는다면#

Cable·TX/RX·Baud Rate·Parity·Flow Control·전송 Frame·Device Address와 Protocol을 순서대로 확인해야 합니다.

18장 이 글을 마치며#

C#에서 RS-232 장비와 통신하는 전체 흐름은 다음과 같습니다.

COM Port 탐색
↓
Port 식별
↓
Baud·Data·Parity·Stop 설정
↓
SerialPort Open
↓
byte 송신
↓
DataReceived
↓
Receive Buffer
↓
Packet Parser
↓
Device Protocol
↓
Application

특히 다음 내용을 기억하면 됩니다.

C# SerialPort에서 가장 먼저 해야 할 일은 장비와 동일한 Baud Rate·Data Bits·Parity·Stop Bits·Flow Control을 설정하는 것입니다.

RS-232 장비가 Binary Protocol을 사용한다면 문자열보다 Raw byte를 기준으로 송수신하고 Hex Dump 로그를 남기는 것이 장애 분석에 유리합니다.

DataReceived 이벤트는 Packet 완성을 의미하지 않으므로 byte를 Receive Buffer에 누적한 뒤 STX·ETX·Length 등 Protocol 규칙을 기준으로 Packet을 추출해야 합니다.

WinForms·WPF 같은 GUI 프로그램에서는 Serial 수신 Thread와 UI Thread를 분리하고 필요한 경우 UI Thread로 안전하게 전달해야 합니다.

Port Open 성공, byte 수신 성공, Protocol Response 성공, 실제 Device 동작 성공은 서로 다른 상태이므로 단계별로 구분해서 판단해야 합니다.

결국 C#으로 RS-232 장비를 제어한다는 것은 SerialPort.Write()와 Read()를 호출하는 것만을 의미하지 않습니다.

COM Port에서 들어오는 불규칙한 byte Stream을 안정적으로 수집하고, 장비 Protocol의 Packet으로 복원하며, Timeout·Exception·재연결까지 포함해 장시간 운영할 수 있는 통신 구조를 만드는 것이 핵심입니다.

이 페이지의 목차