시리얼 통신 앱의 함정 - 1 byte 단위, 타임아웃, 플로우 컨트롤, 재접속, USB 변환, UI 프리즈를 먼저 정리
· 업데이트: · 小村 豪 · 시리얼 통신, RS-232, C#, .NET, Windows 개발, 장치 연계
수정 이력(초판, 2026년 03월 19일 공개)
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635193)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
小村 豪 (2026). 「시리얼 통신 앱의 함정 - 1 byte 단위, 타임아웃, 플로우 컨트롤, 재접속, USB 변환, UI 프리즈를 먼저 정리」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635193 https://staging.comcomponent.com/ko/blog/2026/03/19/001-serial-communication-app-pitfalls/
- DOI(최신 버전)
- 10.5281/zenodo.21635193
- DOI(이 버전)
- 10.5281/zenodo.21635194
장치 연계, 계측기, 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
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Windows 앱 외주·수탁 개발을 의뢰하기 전에 정리해야 할 것
Windows 앱의 외주·수탁 개발을 의뢰하기 전에, 기존 소프트웨어 개수, 장치 연계, COM/ActiveX, 배포·업데이트, 보수와 관련해 정리해야 할 포인트를 설명합니다.
Windows 앱의 작업 트레이 상주와 토스트 알림 ── NotifyIcon의 함정과 AppNotification 선택 기준
업무용 Windows 앱을 작업 트레이(알림 영역)에 상주시키고 토스트 알림으로 사용자에게 알리는 구현을 정리합니다. NotifyIcon의 올바른 사용법과 「닫으면 트레이로」설계, 탐색기 재시작 시 재등록, 토스트 API 3종(Windows Ap...
VB6 애플리케이션은 언제까지 동작할까 ── 런타임 지원 현황과 현실적인 .NET 이전 진행 방법
VB6 애플리케이션은 언제까지 동작할까요. VB6 런타임의 지원 정책(Windows 11도 지원 대상)과 IDE 지원은 이미 종료되었다는 비대칭적인 현황을 정리하고, 전면 재작성・자동 변환・단계적 이전의 판단표, 이전 전 자산 파악, VB6와 ....
업무 앱의 일본 연호・공휴일・마감일 처리 ── 연호 개원에 강한 설계와 JapaneseCalendar・영업일 계산의 실무
장표에 「레이와 8년」을 표시하고, 공휴일을 제외한 영업일을 계산하며, 20일 마감 익월 말일 지급 ── 일본 업무 앱 특유의 날짜 처리에는 연호 개원, 공휴일의 법 개정, 월말의 반올림이라는 「나중에 바뀌는 사양」이 숨어 있습니다. 이 글에서는...
Windows 앱의 중복 실행 방지 ── 네임드 Mutex와 재실행 시 창 활성화
업무용 Windows 앱의 정석 요건인 「같은 앱을 두 번 실행시키지 않는다」를 네임드 Mutex로 구현하는 방법을 정리합니다. Global\ 과 Local\ 네임스페이스의 차이로 인한 RDP 환경의 함정, 소유 스레드 제약과 AbandonedM...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 시리얼 통신에서 커맨드와 응답이 가끔 어긋나는 원인은 무엇인가요?
- 시리얼 통신은 순서가 있는 byte stream이며 메시지 경계는 멋대로 붙지 않기 때문입니다. 한 번 Write한 내용이 상대 쪽에서는 두 번에 나뉘어 도착하거나 다른 데이터와 연결되어 도착할 수 있습니다. 「한 번의 Read = 1 메시지」로 여기면 도중 수신으로 쉽게 망가지므로, 수신은 우선 버퍼에 축적하고 거기에서 parser가 프레임을 잘라내는 구성으로 나누는 것이 대책입니다.
- .NET의 SerialPort.DataReceived 이벤트를 메시지 도착 알림으로 써도 되나요?
- 위험합니다. DataReceived는 수신 byte마다 발화한다고는 할 수 없고, UI 스레드도 아닙니다. 「뭔가 온 듯하다」의 알림으로 치고, 핸들러 안에서는 무거운 처리를 하지 않으며, UI 갱신은 반드시 UI 스레드로 되돌리는 편이 안전합니다. 프레임 잘라내기는 별도의 parser에 맡기는 구성이 안정됩니다.
- 시리얼 통신의 타임아웃은 어떻게 설계해야 하나요?
- 타임아웃은 1개로는 부족하고, 의미별로 나누는 편이 안정됩니다. 포트를 열 때까지의 open timeout, 프레임 도중에 byte가 오지 않는 inter-byte timeout, 커맨드 발행부터 응답 완료까지의 response timeout, 재접속 대기 간격의 reconnect backoff입니다. 타임아웃은 「느릴 때의 보험」이 아니라 상태 전이를 진행시키기 위한 규칙으로 가지면 원인 구분도 쉬워집니다.
- USB-시리얼 변환기에서 뽑아 끼운 뒤 복귀되지 않는 것은 왜인가요?
- USB-시리얼에서는 일시적으로 포트가 사라지고, 이전 핸들이 무효가 되며, COM 번호가 바뀔 수 있기 때문입니다. Open() 재시도만으로는 부족하고, 재접속은 session 재생성으로 생각해야 합니다. 즉 pending request의 fail 처리, reader/writer 정지, 수신 버퍼와 parser 상태의 리셋, backoff 후의 reopen, 장치 초기화의 재실행까지 묶어서 다루는 편이 안전합니다.