시리얼 통신 앱의 함정 - 1 byte 단위, 타임아웃, 플로우 컨트롤, 재접속, USB 변환, UI 프리즈를 먼저 정리
· 小村 豪 · 시리얼 통신, RS-232, C#, .NET, Windows 개발, 장치 연계
장치 연계, 계측기, PLC, 바코드 리더, USB-시리얼 변환. 시리얼 통신은 옛날 기술처럼 보여도, Windows 앱 현장에서는 여전히 꽤 평범하게 쓰이고 있습니다.
조금 위험한 점은, 시리얼 통신이 COM 포트 1개와 Read / Write 1회만으로 시작할 수 있다는 것입니다. 소통 확인은 금세 통하는데, 실전에 올리면 다음과 같은 증상이 나기 쉽습니다.
- 가끔 커맨드와 응답이 어긋난다
- 하루에 한 번만 굳는다
- USB를 뽑아 끼운 뒤에만 복귀되지 않는다
- UI가 가끔 멈춘다
- 로그를 보면 “Timeout”만 남아 있다
시리얼 통신 앱에서 정말 어려운 것은 송수신 API 그 자체가 아닙니다. 어려운 것은 경계, 타임아웃, 상태 전이, 재접속, 관측 가능성입니다.
1. 먼저 결론
실무 쪽의 표현으로 먼저 정리하면, 잡아 두고 싶은 것은 다음입니다.
- 시리얼 통신은 순서가 있는 byte stream이며, 메시지 경계는 멋대로 붙지 않습니다
Read(100)했다고 해서 100 byte 정확히 돌아온다고는 할 수 없습니다.NET의DataReceived는 수신 byte마다 발화한다고는 할 수 없고, 게다가 UI 스레드도 아닙니다ReadLine()/WriteLine()은 상대가 정말로 행 기반 텍스트 프로토콜일 때만 순수합니다- 타임아웃은 1개로는 부족합니다.
open,inter-byte,response,reconnect같은 의미를 나누는 편이 안정됩니다 - 송신은 어디서든
Write할 수 있게 하기보다 single writer로 치우치는 편이 잘 무너지지 않습니다 - USB-시리얼에서는 뽑아 끼움, 재열거, COM 번호 변화, 재접속 실패를 처음부터 전제로 두는 편이 평화롭습니다
요컨대, 시리얼 통신 앱의 어려운 지점은 「포트를 열 수 있는가」가 아니라 byte 열을 어떻게 의미 있는 메시지로 변환하고, 그 주변의 시간과 상태를 어떻게 관리하는가입니다.
2. 시리얼 통신은 「메시지」가 아니라 「순서가 있는 byte stream」
앱 쪽에서 보면 시리얼 통신은 「커맨드를 하나 보내고 응답을 하나 받는」 것처럼 보입니다. 다만 아래 층에서는 실제로 순서가 있는 byte 열이 흐르고 있을 뿐입니다.
즉, 이쪽이 한 번 Write한 내용이 상대 쪽에서는 다음처럼 보일 가능성이 있습니다.
Read한 번으로 도착- 두 번에 나뉘어 도착
- 다른 데이터와 연결되어 도착
이 전제를 벗어나면, 앱 쪽에서 「이번 Read가 이번 응답일 것」이라고 착각하기 시작합니다. 이 착각이 시리얼 통신 앱의 첫 지뢰가 되기 쉽습니다.
| 흔한 착각 | 실제 |
|---|---|
Read(16)이면 16 byte 딱 돌아온다 |
도착 상황이나 타임아웃에 따라 도중까지만 받을 수 있습니다 |
DataReceived = 1 메시지 도착 |
이벤트는 byte 단위로 보장되지 않고, UI 스레드도 아닙니다 |
Write가 반환 = 상대가 처리 완료 |
대부분의 경우 송신 측이 버퍼에 쌓을 수 있었다는 것에 가깝습니다 |
| COM 목록 = 지금 접속된 진실 | 열거 순서는 부정이며, 열거 결과가 stale할 수도 있습니다 |
그래서 시리얼 통신에서는 메시지 경계를 프로토콜로서 직접 정의할 필요가 있습니다. 고정 길이 프레임, 구분 문자 기반, 길이 + payload + checksum 등 형태는 뭐든 좋지만, 애매한 채 구현에 들어가면 나중에 거의 확실히 고생합니다.
3. 가장 먼저 정해야 할 것
시리얼 통신 앱을 만들기 전에, 최소한 다음은 먼저 정해 두는 편이 안전합니다.
3.1 프레임 경계
어느 byte 열을 1 메시지로 간주할지를 정합니다. 고정 길이인가, 개행 구분인가, 길이 포함인가, checksum / CRC가 있는가. 여기가 애매하면 수신 측은 「아직 부족한」 것인지 「망가진」 것인지 판단할 수 없습니다.
3.2 텍스트인가, 바이너리인가, 그 혼재인가
ASCII / UTF-8의 행 프로토콜인가, 순수 바이너리인가, 양쪽이 섞이는가를 먼저 정합니다. 특히 「커맨드부는 문자열, payload는 바이너리, 말미만 개행」 같은 혼재는 어디까지를 decode하고, 어디부터를 생 byte로 다룰지를 명시하지 않으면 금세 경계가 무너집니다.
3.3 타임아웃의 의미
타임아웃은 1개가 아니라 의미별로 나누어 생각하는 편이 안전합니다.
- open timeout: 포트를 열 때까지
- inter-byte timeout: 프레임 도중에 byte가 오지 않는 시간
- response timeout: 커맨드 발행부터 응답 완료까지
- reconnect backoff: 재접속의 대기 간격
타임아웃은 「느릴 때의 보험」이 아니라 상태 전이를 진행시키기 위한 규칙으로 가지고 있으면 안정됩니다.
3.4 플로우 컨트롤과 라인 상태
다음은 명시해 두는 편이 좋은 설정입니다.
BaudRateDataBitsParityStopBitsHandshakeDTR/RTS
여기를 “8N1로 대충 맞는다”로 끝내면 상대 장치에 따라서는 평범하게 멈춥니다.
3.5 책임 분리
다음을 누가 담당할지를 나눕니다.
- 누가 읽는가
- 누가 쓰는가
- 누가 파싱하는가
- 누가 업무 상태에 반영하는가
시리얼 통신은 UI와 통신을 섞을수록 무너지기 쉬워집니다.
3.6 시작·정지·재접속의 상태 전이
최소한 Closed, Opening, Ready, WaitingResponse, Fault, Reconnecting 정도의 상태는 설계해 두는 편이 안전합니다. 뽑아 끼운 직후에는 상대가 아직 기동 중일지도 모르고, 이전 pending request를 끌고 가서는 안 되는 경우도 있습니다.
3.7 로그와 조사성
나중에 가장 곤란해지는 것은 거의 여기입니다. 최소한, open / close / reopen의 시각, 사용한 포트 설정, 송수신 프레임의 hex dump, checksum / CRC 오류, frame timeout / response timeout, 재접속 이유는 남기고 싶은 부분입니다.
4. 흔한 함정
4.1 한 번의 Read = 1 메시지로 여긴다
가장 많은 것이 이것입니다. 예를 들어 상대가 헤더, 길이, payload, CRC로 구성된 프레임을 돌려준다고 합시다. 이때 Read(buffer, 0, expectedLength)를 한 번 호출하고, 그 반환값을 그대로 1 프레임이라고 생각해 버리면, 도중 수신으로 쉽게 망가집니다.
흔한 망가짐은 다음입니다.
- 길이만 읽히고 payload가 아직 오지 않음
- 1 프레임 반만 도착하고, 후반이 다음
Read로 넘어감 - 2 프레임이 모여 도착해서 처음 1개만 처리하고 나머지를 버림
대책은 단순한데, 수신은 우선 축적하고, 거기에서 parser가 프레임을 잘라 내는 형태로 나누는 것입니다.
4.2 DataReceived를 그대로 업무 이벤트로 삼는다
.NET의 SerialPort.DataReceived는 편리해 보이지만, 이것을 「1 메시지 도착 알림」으로 여기면 위험합니다. 실무상으로는 DataReceived를 「뭔가 온 듯하다」의 알림으로 치고, 핸들러 안에서는 무거운 처리를 하지 않는 편이 안전합니다. UI 갱신도 반드시 UI 스레드로 되돌리는 편이 좋습니다.
4.3 어디서든 Write해도 된다고 생각한다
UI의 버튼, 감시 타이머, 재접속 처리, keepalive가 각각 직접 Write하는 구성은 무너지기 쉽습니다. 시리얼은 byte stream이므로, 설계에 따라서는 커맨드의 끼어들기나 응답 대기 중의 연타 송신이 일어납니다. 특히 request-response형이나 RS-485 계에서는 single writer로 치우치는 편이 꽤 안정됩니다.
4.4 ReadLine() / WriteLine()로 전부 통과시킨다
행 기반 텍스트 프로토콜이라면 ReadLine() / WriteLine()은 편리합니다. 다만 편리한 것은 정말로 행 프로토콜일 때뿐입니다. NewLine의 불일치, payload 중의 개행, 문자 코드 차이, 바이너리 혼재 등이 있으면 금방 경계가 무너집니다.
4.5 타임아웃을 설계하지 않고 기본값 그대로 둔다
동기 read를 안이하게 두면 평범하게 무한 대기가 됩니다. 더 성가신 것은 설정한 timeout이 모든 읽기 방법에 적용된다고는 할 수 없다는 점입니다. UI 스레드에서 동기 read한다, 1개의 timeout만으로 전부를 표현하려 한다, retry만 늘린다 같은 구현은 막히기 쉽습니다.
4.6 RTS/CTS, XON/XOFF, DTR/RTS를 가볍게 본다
핸드셰이크나 제어선은 실기기 상대로는 꽤 영향을 미칩니다. 설정 불일치가 있으면 송신이 가끔 멈춘다, 일정량을 넘으면 누락된다, 연 직후만 동작이 다르다 같은 증상이 나오기 쉽습니다. 실기기에 따라서는 DTR/RTS의 변화를 기동이나 모드 전환의 의미로 보고 있는 경우도 있습니다.
4.7 Open() 재시도만으로 재접속한 셈이 된다
특히 USB-시리얼에서는 일시적으로 포트가 사라진다, 이전 핸들이 무효가 된다, 이전의 pending request가 의미를 잃는다 같은 일이 평범하게 일어납니다. 재접속은 적어도 session 무효화, pending request의 fail, reader / writer 정지, backoff 후의 reopen, 장치 초기화의 재실행까지 묶어서 다루는 편이 안전합니다.
4.8 COM 포트 열거를 진실이라고 생각한다
GetPortNames()는 편리하지만, 목록에 나온 것과 opening할 수 있는 것은 같지 않습니다. 이전의 COM7을 맹신한다, 열거 결과의 선두를 자동 선택한다, 목록에 나온 시점에 유효하다고 본다 같은 구현은 운영에서 곤란해지기 쉽습니다.
4.9 송수신 로그가 얇다
TimeoutException, IOException, Port closed만으로는 거의 아무것도 알 수 없습니다. 송수신 시각, port profile, 송수신 hex dump, parser error, 어느 request에 대한 response인가, reconnect의 계기를 알 수 있게 해 두면 구분은 꽤 진행됩니다.
5. 베스트 프랙티스
가장 잘 듣는 것은 책임을 나누는 것입니다.
reader: port에서 byte 열을 읽기만writer: outbound queue에서 순서대로 쓰기만parser: byte 열에서 frame을 잘라내기만protocol: request와 response의 대응이나 checksum을 다룬다app state: 업무 상태를 갱신하기만
수신 처리는 Read의 반환 단위를 그대로 업무 단위로 삼지 말고, 일단 버퍼에 축적한 다음 parser가 frame을 잘라내는 구성이 안정됩니다. 송신은 1개의 worker에 집약하고, 실제의 Write를 single writer로 치우치는 편이 순서 어긋남을 줄일 수 있습니다.
타임아웃도 하나의 숫자로 끝내기보다 open, inter-byte, response, reconnect의 의미별로 나누는 편이 원인 구분이 쉬워집니다. port 설정은 그 자리의 코드 값보다 profile로서 가지고, startup 시에 로그에 남겨 두면 현지 조사가 꽤 편해집니다.
재접속은 단순한 reopen이 아니라 session 재생성으로 생각하는 편이 안정됩니다. 수신 버퍼, parser 상태, pending request, 초기화 시퀀스, readiness 판정까지 포함해 다시 만들면, 「가끔만 망가지는」 재접속 버그를 줄이기 쉬워집니다.
마지막으로 생 로그와 요약 로그를 양쪽 가지는 것을 추천합니다. raw hex dump나 open / close의 이력은 조사에 강하고, request id나 retry 횟수의 요약은 운영에 강합니다.
6. 먼저 볼 체크리스트
- 메시지 경계는 명문화되어 있는가
- 수신은 byte 축적 → frame 잘라내기로 되어 있는가
DataReceived를 메시지 도착 취급하고 있지 않은가- UI 스레드에서 동기 I/O하고 있지 않은가
- 송신은 single writer로 되어 있는가
- timeout이 1개가 아니라 의미별로 나뉘어 있는가
Handshake/ DTR / RTS가 명시되어 있는가- reconnect에서 session을 다시 만들고 있는가
- raw hex dump를 남기고 있는가
- 실기기 뽑아 끼움이나 중간 절단을 시험하고 있는가
이 중에서 의심스러운 항목이 여러 개 있다면, 실전 투입 전에 한 번 정리하는 편이 안전합니다.
7. 정리
잡아 두고 싶은 포인트를 정리하면 다음입니다.
- 시리얼 통신은 메시지가 아니라 byte stream
Read단위와 메시지 단위는 일치하지 않는다- 경계는 프로토콜로서 정의할 필요가 있다
DataReceived를 그대로 업무 이벤트로 삼으면 무너지기 쉽다- 송수신은 책임을 분리하고, 송신은 single writer로 치우친다
- timeout은 의미별로 분할하고, 재접속은 session 단위로 설계한다
- raw hex dump를 포함한 로그가 이후의 조사를 꽤 편하게 한다
즉, 시리얼 통신 앱에서는 포트를 여는 것보다 byte 열을 어떻게 해석하고 시간과 상태를 어떻게 제어하는가가 훨씬 중요합니다. 여기를 처음에 나누어 설계하는 것만으로도 「가끔만 망가지는」 타입의 통신 불량은 꽤 줄어듭니다.
8. 참고 자료
- Microsoft Learn,
SerialPort.DataReceivedEvent - Microsoft Learn,
SerialPort.ReadMethod - Microsoft Learn,
SerialPort.ReadTimeoutProperty - Microsoft Learn,
SerialPort.BaseStreamProperty - Microsoft Learn,
SerialPort.NewLineProperty - Microsoft Learn,
HandshakeEnum - Microsoft Learn,
SerialPort.DtrEnableProperty - Microsoft Learn,
SerialPort.RtsEnableProperty - Microsoft Learn,
SerialPort.GetPortNamesMethod - Microsoft Learn,
SerialPortClass - Microsoft Learn,
COMMTIMEOUTSstructure - Microsoft Learn,
DCBstructure - Microsoft Learn,
CreateFilefunction - pySerial API, Serial API Reference
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
C#을 Native AOT로 네이티브 DLL로 만드는 방법 - UnmanagedCallersOnly로 C/C++에서 호출하기
.NET의 Native AOT와 UnmanagedCallersOnly로 C# 클래스 라이브러리를 네이티브 DLL로 발행해 C/C++에서 in-process로 호출하는 구성을, 핸들 기반 수명 관리와 에러 코드, C ABI 설계 요령으로 정리합니다.
Generic Host / BackgroundService를 데스크톱 앱에 가지고 들어오는 이유 - 기동・수명・graceful shutdown의 정리가 꽤 편해진다
WPF나 WinForms 같은 데스크톱 앱에서 Generic Host와 BackgroundService를 도입해 기동, 상주 처리, graceful shutdown, DI, 로그, 설정의 입구를 한 곳으로 모으는 설계 정리법과 안티패턴을 실무 시...
FileSystemWatcher 사용법과 주의점 - 누락, 중복 알림, 완료 판정의 함정
Windows .NET 파일 감시에서 FileSystemWatcher의 이벤트를 완료 알림으로 오인하기 쉬운 함정과, 재스캔 요청·원자적 claim·idempotency를 축으로 누락과 중복을 견디는 안전한 설계 패턴을 정리합니다.
ClickOnce란 무엇인가 - 구조, 업데이트, 어울리는 장면・어울리지 않는 장면을 실무 시점에서 정리
ClickOnce가 무엇이고 매니페스트, 캐시, 업데이트, 서명이 어떻게 맞물려 동작하는지를 Mermaid 그림과 함께 정리하고, 사내용 .NET 데스크톱 앱 배포에서 어울리는 안건과 어울리지 않는 안건을 실무 시점에서 판단할 수 있도록 도와드립니다.
Windows 앱에서 자식 프로세스를 안전하게 다루기 위한 체크리스트 - Job Object, 종료 전파, 표준 입출력, watchdog의 베스트 프랙티스
Windows 앱이 자식 프로세스에 의존할 때, 기동 API보다 프로세스 트리의 소유권과 종료 절차의 설계가 안정성을 좌우합니다. Job Object로 수명을 묶고, 종료 전파를 분리하며, stdout/stderr를 비동기로 흘리고 watchdo...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
저자 프로필
기사 저자의 프로필 페이지입니다.
Go Komura
합동회사 코무라소프트 대표
Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.
공개 링크