시리얼 통신 앱의 함정 - 재접속과 로그 설계까지

· 업데이트: · · 시리얼 통신, RS-232, C#, .NET, Windows 개발, 장치 연계

수정 이력(14건, 최종 수정 2026년 09월 03일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635194)
byte stream과 프레임 경계의 관계, 타임아웃을 나누는 방법, single writer 설계, 응답 엇갈림과 세션 재생성 등을 그림으로도 따라갈 수 있도록 Mermaid 그림을 14점 추가했습니다(본문 500~750자당 그림 1개의 규칙에 맞춘 것입니다). 기존 상태 전이 그림에도 캡션을 붙였습니다. 본문 문장은 바꾸지 않았습니다.
글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
외부 리뷰(1283건) 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조하세요.
조립 예에서, 송신이 실패했을 때 워커를 멈추지 않고 빠져나가던 것을 고쳤습니다. 기기 무응답, 케이블 탈락, 쓰기 타임아웃으로 `SendAsync`가 예외를 던지면, `cts.Cancel()`과 `Task.WhenAll`을 건너뛰고 `using` 폐기에 도달합니다. `SerialPort`가 닫힌 뒤에도 reader/writer가 그 스트림을 만지러 가고, 그 예외는 아무도 관측하지 않습니다. 상주 앱에서는 실패한 조작의 워커만 남습니다. 취소와 대기를 `finally`로 옮기고, 워커 쪽 예외는 본래 실패 이유를 덮지 않도록 기록에만 남기도록 했습니다.
`SingleWriter`의 송신 큐에 상한이 없었습니다. UI·타이머·워커가 장치 왕복보다 빨리 쌓으면, 프레임과 `TaskCompletionSource`가 한없이 쌓이고, 장치는 응답하는데 메모리만 계속 늘어납니다. 상한이 있는 채널로 바꾸고, 넘치면 보내는 쪽에 돌려주도록 했습니다(오래된 것을 버리는 방식은, 쌓은 쪽이 Task를 기다리고 있으므로 쓰지 않습니다).
`FrameParser`에 조립 시간 초과를 넣었습니다. 노이즈나 가짜 `STX`로 `LEN`이 큰 값이 되면, parser는 그것을 「아직 부족하다」고 계속 다루고, 그 뒤에 도착하는 올바른 프레임까지 바뀐 payload로 빨아들입니다. CRC에서 떨어질 때까지 아무것도 올라오지 않으므로, 통신량이 적은 기기에서는 몇 분의 무반응으로 보입니다. 3.3에서 든 inter-byte timeout을 구현하고, 넘으면 `STX`를 1바이트만 버리고 다시 읽도록 했습니다. 재동기화 발생을 알리는 이벤트와, 시간 초과 값의 정하는 방법도 덧붙였습니다.
`Frame.Build`가 256바이트 이상 payload를 거르지 않았습니다. LEN은 1바이트이므로 캐스트하면 값이 순환하는 한편, payload는 통째로 복사되므로, 받는 쪽은 잘린 길이로 프레임을 자르고 payload 중간을 CRC로 읽습니다. 이후 프레임 경계도 한꺼번에 무너지므로, 길이를 검사해 거르도록 했습니다.
응답과 타임아웃의 경쟁을 원자적으로 만들었습니다. `WhenAny`가 타임아웃 쪽을 고른 직후에 응답이 도착하면, `OnFrameReceived`가 먼저 응답 대기 1건을 집어 성공으로 완료합니다. 그다음 `TrySetException`은 효과가 없는데 `throw`만 통과하므로, 호출 쪽은 결과를 받았는데 워커만 떨어지고 있었습니다. `CompareExchange`로 집었을 때만 타임아웃으로 다룹니다.
응답 대기 중에 정지를 요청받았을 때, 취소된 `Task.Delay`가 먼저 끝나므로, 정상적인 종료 처리가 타임아웃으로 취급되던 것을 고쳤습니다. 타임아웃으로 다루기 전에 취소를 확인하도록 했습니다.
응답이 타임아웃한 뒤에 그대로 다음 명령을 보내던 것을 고쳤습니다. 이 프레임에는 요청 ID가 없으므로, 늦게 도착한 이전 명령의 응답이 다음 명령의 응답으로 호출 쪽에 넘어갑니다. 3.6의 상태 전이 그림대로 Fault로 떨어뜨리고 세션을 다시 만들도록 했고, 애초에 요청 ID를 두는 편이 본질적인 해결이라는 점도 적었습니다.
`SingleWriter`에서, `WriteAsync`가 예외를 던졌을 때 `TaskCompletionSource`가 미완료인 채로 남아, `SendAsync`를 기다리는 호출 쪽이 영원히 기다리는 문제를 고쳤습니다. 쓰기 실패 시 그 1건을 완료하고, 워커가 멈출 때는 큐에 남은 분도 한꺼번에 완료하도록 했습니다.
수신을 쌓은 뒤 잘라 내는 구현과, 송신을 single writer로 모으는 구현을 C#으로 추가했습니다(CRC-16/MODBUS, 프레임 분할·연결 대응, 응답 대응과 타임아웃까지). 프레임 어긋남 3패턴 그림, 상태 전이 그림, hex dump를 포함한 로그 행 형식 예, 용어표와 전제 표를 더했습니다.
최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635193)

이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.

Go Komura (2026). 「시리얼 통신 앱의 함정 - 재접속과 로그 설계까지」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635193 https://comcomponent.com/ko/blog/serial-communication-app-pitfalls/

DOI(최신 버전)
10.5281/zenodo.21635193
DOI(이 버전)
10.5281/zenodo.22217468

장치 연계, 계측기, PLC, 바코드 리더, USB-시리얼 변환. 시리얼 통신은 오래된 기술처럼 보여도, Windows 앱 현장에서는 아직 꽤 흔히 쓰입니다.

조금 위험한 점은, 시리얼 통신이 COM 포트 하나Read / Write 한 번만으로도 시작할 수 있다는 것입니다. 연결 확인은 금방 되는데, 실전에 올리면 다음 같은 증상이 나오기 쉽습니다.

  • 가끔 명령과 응답이 어긋난다
  • 하루에 한 번만 멈춘다
  • USB를 뽑았다가 꽂은 뒤에만 복귀하지 않는다
  • UI가 가끔 멈춘다
  • 로그를 보면 “Timeout”만 남아 있다

시리얼 통신 앱에서 정말 어려운 것은 송수신 API 자체가 아니라, 경계, 타임아웃, 상태 전이, 재접속, 관측 가능성 쪽입니다.

연결은 되는데 실전에서 깨진다시리얼 통신은 COM 포트 하나와 Read와 Write만으로 시작할 수 있어 연결 확인은 금방 되지만, 실전에서는 응답 어긋남이나 멈춤, 복귀 실패 같은 증상이 나오며, 진짜 난관은 경계·타임아웃·상태 전이·재접속·관측 가능성임을 나타내는 그림.연결 확인은 금방 된다실전에서 「가끔」 깨진다응답 어긋남·멈춤·복귀하지 않음난관은 송수신 API 자체가 아니다경계·타임아웃·상태 전이·재접속·관측 가능성

그림1: 연결 확인 너머에 있는, 시리얼 통신 앱의 진짜 난관.

이 글의 대상 독자와 전제

항목 내용
대상 독자 장치나 계측기와 시리얼로 연결되는 Windows 앱을 만드는 분. 연결 확인은 됐는데, 실전에서 「가끔」 깨지는 상태를 줄이고 싶은 분을 가정합니다
전제로 하는 지식 C#으로 앱을 작성할 수 있을 것. 시리얼 통신 자체의 경험은 전제로 두지 않습니다
전제로 하는 환경 .NETSystem.IO.Ports.SerialPort를 전제로 쓰지만, 경계, 타임아웃, 상태 전이의 사고방식은 언어에 의존하지 않습니다
다루지 않는 것 전기적 결선 이야기, 특정 장치의 프로토콜 사양

이 글에서 쓰는 용어

용어 한 줄 설명
PLC Programmable Logic Controller. 생산 설비 제어에 쓰는 산업용 컨트롤러
RS-232 / RS-485 시리얼 통신의 전기적 규격. RS-232는 1대1, RS-485는 같은 선에 여러 대를 매달 수 있습니다. RS-485에서는 누가 언제 보낼지를 정하지 않으면 충돌합니다
8N1 포트 설정의 약칭. 데이터 비트 8, 패리티 없음(None), 스톱 비트 1의 조합을 가리킵니다
DTR / RTS 제어선. 원래는 통신 준비 완료나 송신 요구를 전하는 선이지만, 실제 기기에서는 이 변화를 기동이나 모드 전환의 신호로 쓰는 경우가 있습니다
흐름 제어 너무 많이 보내지 못하게 막는 방식. RTS/CTS는 제어선으로, XON/XOFF는 데이터 안의 특수 문자로 중지·재개를 전합니다
keepalive 통신 상대가 살아 있는지를 확인하려고, 주기적으로 보내는 가벼운 명령
프레임 메시지 하나분의 byte 열. 어디부터 어디까지가 한 프레임인지는 프로토콜 쪽에서 정합니다
single writer 송신을 워커 하나로 모으는 설계. 어디서든 Write할 수 있는 상태로 두지 않는다는 뜻입니다

1. 먼저 결론

실무에 가까운 말로 먼저 정리해 둡니다.

  • 시리얼 통신은 순서가 있는 byte stream이며, 메시지 경계는 저절로 붙지 않습니다
  • Read(100)했다고 해서 100 byte가 딱 맞게 돌아온다고 할 수는 없습니다
  • .NETDataReceived수신 byte마다 발생한다고 할 수 없고, 게다가 UI 스레드도 아닙니다
  • ReadLine() / WriteLine()은, 상대가 정말로 행 기반 텍스트 프로토콜일 때만 그대로 통합니다
  • 타임아웃은 하나로 부족합니다. open, inter-byte, response, reconnect처럼 의미를 나누는 편이 안정됩니다
  • 송신은 어디서든 Write할 수 있게 두기보다, single writer로 모으는 편이 잘 깨지지 않습니다
  • USB-시리얼에서는 뽑았다 꽂기, 재열거, COM 번호 변화, 재접속 실패를 처음부터 전제로 두는 편이 편합니다

요컨대, 시리얼 통신 앱의 난관은 「포트를 여는가」가 아니라, byte 열을 어떻게 의미 있는 메시지로 바꾸고, 그 주변의 시간과 상태를 어떻게 관리하는가입니다.

이 글의 지식 맵

이 기사는 장치 연계 등에 쓰이는 C#의 시리얼 통신 앱에서 가끔씩만 발생하는 결함을 막기 위한 설계 가이드입니다. 시리얼 통신은 순서가 있는 byte stream일 뿐 메시지 경계를 갖지 않으므로, DataReceived 이벤트를 1 메시지 도착 알림으로 취급하는 것은 권장되지 않으며, 수신을 일단 축적한 뒤 프레임 파서로 잘라내는 설계가 권장됩니다. 송신은 1개의 워커로 집약하는 single writer로 몰아가고, timeout은 open·inter-byte·response·reconnect backoff라는 의미별로 나누어 설계합니다. 프레임에 request ID가 없는 프로토콜에서는 응답 타임아웃 후에 혼선이 일어날 수 있으므로, 단순한 재접속이 아니라 수신 버퍼와 parser 상태까지 다시 만드는 session 재생성이 필요해집니다.

시리얼 통신 앱의 함정 지식 맵시리얼 통신이 메시지 경계를 갖지 않는 byte stream이라는 점을 출발점으로, 프레임 파서와 single writer 설계가 그 경계와 송신 순서를 어떻게 다루는지, timeout의 종류별 분할과 request ID의 유무가 응답 혼선과 session 재생성으로 어떻게 이어지는지를 보여주는 그림입니다.이용한다전제로 한다구현을 담당한다사용은 비권장권장되는 대응권장되는 대응이용한다이용한다이용한다이용한다이용한다원인이 될 수 있다방지한다권장되는 대응이용한다전제로 한다권장되는 대응전제로 한다시리얼 통신프레임 파서(축적 후 잘라내는 설계)single writer(단일 기록 지점)byte stream(순서 있는 byte열)프레임 경계SerialPort.DataReceived 이벤트CRC 기반 프레임 검증inter-byte timeout타임아웃 유형 분리응답 타임아웃백오프를 적용한 재연결응답 오인 위험요청 IDsession 재생성(재연결 설계)흐름 제어·DTR/RTShex dump를 포함한 송수신 로그

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 18건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

2. 시리얼 통신은 「메시지」가 아니라 「순서가 있는 byte stream」

앱 쪽에서 보면, 시리얼 통신은 「명령을 하나 보내고, 응답을 하나 받는」 것처럼 보입니다. 다만 아래 층에서는, 실제로는 순서가 있는 byte 열이 흐르고 있을 뿐입니다.

이쪽이 한 번 Write한 내용도, 상대 쪽에서는 이렇게 보일 수 있습니다.

  • Read 한 번에 도착한다
  • 두 번에 나뉘어 도착한다
  • 다른 데이터와 붙어 도착한다

이 전제를 놓치면, 앱 쪽에서 「이번 Read가 이번 응답일 것」이라고 믿기 시작합니다. 이 착각이 시리얼 통신 앱의 첫 지뢰가 되기 쉽습니다.

한 번의 Write가 도착하는 방식은 세 가지이쪽이 한 번 Write한 내용도, 상대 쪽에서는 Read 한 번에 도착한다고 할 수 없고, 두 번에 나뉘어 도착하거나, 다른 데이터와 붙어 도착할 수 있음을 나타내는 그림.Write 한 번Read 한 번에 도착한다두 번에 나뉘어 도착한다다른 데이터와 붙어 도착한다「이번 Read가 이번 응답」이라고 할 수는 없다

그림2: 한 번의 Write가 상대 쪽에서 어떻게 보일지는, 도착해 보기 전에는 모릅니다.

흔한 착각 실제
Read(16)이면 16 byte가 딱 맞게 돌아온다 도착 상황이나 타임아웃에 따라 중간까지밖에 못 받을 수 있습니다
DataReceived = 메시지 하나 도착 이벤트는 byte마다 보장되지 않고, UI 스레드도 아닙니다
Write가 반환됐다 = 상대가 처리를 끝냈다 많은 경우, 송신 쪽이 버퍼에 쌓을 수 있었다는 쪽에 가깝습니다
COM 목록 = 지금 연결된 진실 열거 순서는 정해져 있지 않고, 열거 결과가 stale할 수도 있습니다

이 때문에, 시리얼 통신에서는 메시지 경계를 프로토콜로서 직접 정의할 필요가 있습니다. 고정 길이 프레임, 구분자 기반, 길이 + payload + checksum처럼 형태는 무엇이든 좋지만, 애매한 채로 구현에 들어가면 나중에 거의 확실히 힘들어집니다.

경계는 직접 정의한다시리얼 통신에서는 메시지 경계를 프로토콜로서 직접 정의해야 하며, 고정 길이 프레임, 구분자 기반, 길이와 payload와 checksum처럼 형태는 무엇이든 좋지만, 애매한 채로 구현에 들어가면 힘들어짐을 나타내는 그림.메시지 경계를 직접 정의고정 길이 프레임구분자 기반길이+payload+checksum애매한 채로면 힘들다

그림3: 메시지 경계는 아래 층에서는 붙지 않으므로, 프로토콜로서 먼저 정합니다.

3. 가장 먼저 정해야 할 것

시리얼 통신 앱을 만들기 전에, 적어도 여기에 적은 것은 먼저 정해 둡니다.

3.1 프레임 경계

어느 byte 열을 메시지 하나로 볼지를 정합니다. 고정 길이인지, 개행 구분인지, 길이가 붙는지, checksum / CRC가 있는지. 여기가 애매하면, 수신 쪽은 「아직 부족한지」 「깨졌는지」를 판단할 수 없습니다.

3.2 텍스트인지, 바이너리인지, 그 혼재인지

ASCII / UTF-8 행 프로토콜인지, 순수 바이너리인지, 둘 다 섞이는지를 먼저 정합니다. 특히 「명령부는 문자열, payload는 바이너리, 끝만 개행」 같은 혼재는, 어디까지를 decode하고 어디부터를 raw byte로 다룰지를 명시하지 않으면 경계가 바로 무너집니다.

3.3 타임아웃의 의미

타임아웃은 하나가 아니라, 의미마다 나누어 생각하는 편이 안전합니다.

  • open timeout: 포트를 열 때까지
  • inter-byte timeout: 프레임 도중 byte가 오지 않는 시간
  • response timeout: 명령을 낸 뒤 응답이 끝날 때까지
  • reconnect backoff: 재접속 대기 간격

타임아웃은 「느릴 때의 보험」이 아니라, 상태 전이를 진행하는 규칙으로 두면 안정됩니다.

타임아웃은 의미마다 나눈다타임아웃은 포트를 열 때까지의 open, 프레임 도중 byte가 오지 않는 inter-byte, 명령을 낸 뒤 응답이 끝날 때까지의 response, 재접속 대기 간격인 reconnect backoff로 나누고, 상태 전이를 진행하는 규칙으로 둠을 나타내는 그림.타임아웃은 하나로 부족하다open: 열 때까지inter-byte: 무음response: 응답 완료reconnect backoff상태 전이를 진행하는 규칙

그림4: 네 종류의 타임아웃을 나누면, 상태 전이 규칙으로 다룰 수 있습니다.

3.4 흐름 제어와 라인 상태

명시해 두고 싶은 설정은 이 근처입니다.

  • BaudRate
  • DataBits
  • Parity
  • StopBits
  • Handshake
  • DTR / RTS

여기를 “8N1이면 대충 맞는다”로 끝내면, 상대 장치에 따라서는 그대로 멈춥니다.

3.5 책임 분리

누가 무엇을 맡을지를 나눕니다.

  • 누가 읽는가
  • 누가 쓰는가
  • 누가 파싱하는가
  • 누가 업무 상태에 반영하는가

시리얼 통신은, UI와 통신을 섞을수록 잘 깨집니다.

책임을 나누고 UI와 통신을 섞지 않는다누가 읽는가, 누가 쓰는가, 누가 파싱하는가, 누가 업무 상태에 반영하는가를 나누고, UI와 통신을 섞을수록 잘 깨짐을 나타내는 그림.책임 분리읽는 쪽과 쓰는 쪽파싱하는 쪽업무 상태에 반영하는 쪽UI와 통신을 섞을수록 잘 깨진다

그림5: 읽기·쓰기·파싱·반영을 나누고, UI와 통신을 섞지 않습니다.

3.6 시작·정지·재접속의 상태 전이

최소한, Closed, Opening, Ready, WaitingResponse, Fault, Reconnecting 정도의 상태는 설계에 넣어 두어야 합니다. 뽑았다 꽂은 직후, 상대가 아직 기동 중일 수도 있고, 이전 pending request를 끌고 가면 안 되는 경우도 있습니다.

Open 요구open 성공 + 초기화 시퀀스 완료open 실패 / 권한 오류 / 초기화 타임아웃명령 송신대응하는 응답 프레임을 수신response timeoutI/O 오류 / 단선 검출pending request를 fail시키고 backoff 시작backoff 경과상한 도달 / 수동 정지Close 요구ClosedOpeningReadyFaultWaitingResponseReconnecting

그림6: 연결 세션의 상태 전이. Fault에서 Ready로 바로 돌아가는 선은 없습니다.

이 그림에서 중요한 점은, Fault에서 Ready로 바로 돌아가는 선이 없다는 것입니다. 이상 뒤에는 반드시 ReconnectingOpening을 지나, 수신 버퍼, parser 상태, pending request, 초기화 시퀀스를 다시 만든 뒤에 Ready로 돌아갑니다. 여기를 지름길로 가면, 4.7의 「Open()을 다시 한 것만으로 재접속한 셈이 된다」로 떨어집니다.

3.7 로그와 조사성

나중에 가장 곤란한 것은, 거의 여기입니다. 최소한, open / close / reopen 시각, 사용한 포트 설정, 송수신 프레임의 hex dump, checksum / CRC 오류, frame timeout / response timeout, 재접속 이유는 남기고 싶습니다.

4. 흔한 함정

4.1 Read 한 번 = 메시지 하나라고 생각한다

가장 많은 것은 이것입니다. 예를 들어 상대가 헤더, 길이, payload, CRC로 된 프레임을 돌려준다고 합시다. 이때 Read(buffer, 0, expectedLength)를 한 번 호출하고, 그 반환값을 그대로 프레임 하나라고 믿으면, 중간 수신에서 쉽게 깨집니다.

흔한 깨짐은 다음 세 가지입니다.

  • 길이만 읽고 payload는 아직 오지 않았다
  • 프레임 하나 반만 도착하고, 후반이 다음 Read로 넘어간다
  • 프레임 두 개가 한 번에 도착해서, 처음 하나만 처리하고 나머지를 버린다

그림으로 그리면, 장치가 보낸 순서와 Read가 돌려주는 순서가 일치하지 않는다는 이야기일 뿐입니다.

장치가 보낸 것
    [--- 프레임1 ---][--- 프레임2 ---]

패턴1: 중간까지밖에 도착하지 않는다
    1번째 Read -> [ STX ][ LEN ]                     <- payload가 아직 오지 않았다
    2번째 Read -> [ payload ][ CRC ][--- 프레임2 ---]

패턴2: 프레임 하나 반만 도착한다
    1번째 Read -> [--- 프레임1 ---][ 프레임2의 전반 ]
    2번째 Read -> [ 프레임2의 후반 ]

패턴3: 프레임 두 개가 한 번에 도착한다
    1번째 Read -> [--- 프레임1 ---][--- 프레임2 ---]   <- 하나만 처리하고 나머지를 버리기 쉽다

세 패턴 모두, 「깨진 것」이 아니라 「구분 위치가 Read 횟수와 일치하지 않을 뿐」입니다. 여기를 혼동해서, 도착한 byte 수가 예상과 다르다고 오류로 치는 구현을 하면, 정상적인 통신을 오류로 세기 시작합니다.

대책은 단순합니다. 수신은 먼저 쌓고, 그다음 parser가 프레임을 잘라 내는 쪽으로 역할을 나누는 것입니다. 골격 코드는 5.1에 두었습니다.

쌓은 뒤 잘라 낸다Read의 반환 단위를 그대로 프레임 하나라고 믿으면 중간 수신에서 깨지므로, 수신은 먼저 버퍼에 쌓고, 그다음 parser가 프레임을 잘라 내는 쪽으로 나눔을 나타내는 그림.대신에Read의 반환=프레임 하나라고 생각한다중간 수신에서 쉽게 깨진다수신은 먼저 버퍼에 쌓는다parser가 프레임을 잘라 낸다

그림7: Read의 반환 단위와 프레임을 분리하고, 쌓은 뒤 잘라 냅니다.

4.2 DataReceived를 그대로 업무 이벤트로 삼는다

.NETSerialPort.DataReceived는 편리해 보이지만, 이를 「메시지 하나가 도착했다」는 알림으로 보면 위험합니다. 실무상으로는, DataReceived를 「무언가 온 것 같다」는 알림으로 보고 핸들러 안에서는 무거운 처리를 하지 않으며, UI 갱신은 반드시 UI 스레드로 되돌립니다.

4.3 어디서든 Write해도 된다고 생각한다

UI의 버튼, 감시 타이머, 재접속 처리, keepalive가 각각 직접 Write하는 구성은 무너지기 쉽습니다. 시리얼은 byte stream이므로, 설계에 따라서는 명령이 끼어들거나 응답 대기 중 연타 송신이 일어납니다. 특히 request-response형이나 RS-485 계열에서는, single writer로 모으는 편이 꽤 안정됩니다.

송신은 single writer로 모은다UI의 버튼, 감시 타이머, keepalive나 재접속 처리가 각각 직접 Write하는 구성은, 명령 끼어들기나 응답 대기 중 연타 송신이 일어나 무너지기 쉽고, single writer로 모으면 안정됨을 나타내는 그림.대신에UI 버튼각각이 직접 Write감시 타이머keepalive·재접속끼어들기·연타 송신이 일어난다single writer로 모은다

그림8: 직접 Write하는 곳을 늘리지 말고, 송신은 워커 하나로 모읍니다.

4.4 ReadLine() / WriteLine()으로 전부 처리한다

행 기반 텍스트 프로토콜이라면 ReadLine() / WriteLine()은 편리합니다. 다만 편리한 것은, 정말로 행 프로토콜일 때뿐입니다. NewLine 불일치, payload 안의 개행, 문자 코드 차이, 바이너리 혼재 등이 있으면, 경계가 바로 무너집니다.

4.5 타임아웃을 설계하지 않고, 기본값 그대로 둔다

동기 read를 쉽게 두면, 그대로 무한 대기가 됩니다. 더 성가신 것은, 설정한 timeout이 모든 읽기 방식에 먹힌다고 할 수 없다는 점입니다. UI 스레드에서 동기 read한다, 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, 장치 초기화 재실행까지 묶어 다루는 편이 안전합니다.

재접속은 Open을 다시 하는 것이 아니다USB-시리얼에서는 포트가 사라지거나 이전 핸들이 무효가 되므로, 재접속은 session 무효화, pending request의 fail, reader와 writer 정지, backoff 후 reopen, 장치 초기화 재실행까지 묶어 다룸을 나타내는 그림.session 무효화pending request를 failreader / writer를 정지backoff 후 reopen장치 초기화를 재실행Open을 다시 하는 것만으로는 부족하다

그림9: 재접속은 세션을 다시 만드는 일로, 이 일련을 묶어 수행합니다.

4.8 COM 포트 열거를 진실이라고 생각한다

GetPortNames()는 편리하지만, 목록에 나온 것과 opening할 수 있는 것은 같지 않습니다. 이전 COM7을 맹신한다, 열거 결과의 선두를 자동 선택한다, 목록에 나온 시점에 유효하다고 본다, 같은 구현은 운영에서 곤란해지기 쉽습니다.

4.9 송수신 로그가 얇다

TimeoutException, IOException, Port closed만으로는, 거의 아무것도 알 수 없습니다. 송수신 시각, port profile, 송수신 hex dump, parser error, 어느 request에 대한 response인지, reconnect의 계기를 알 수 있게 해 두면, 원인 분리는 꽤 진행됩니다.

형식을 먼저 정해 두면, 나중에 grep도 차분 비교도 할 수 있습니다. 예를 들어, 이런 한 줄 포맷으로 둡니다.

2026-03-19T10:23:41.512+09:00  COM3  TX  req=00A7  len=5   02 01 10 3F 9C
2026-03-19T10:23:41.518+09:00  COM3  RX  req=00A7  len=3   02 01
2026-03-19T10:23:41.531+09:00  COM3  RX  req=00A7  len=6   10 00 4B 02 01 11
2026-03-19T10:23:41.532+09:00  COM3  PARSE req=00A7  frame=02 01 10 00 4B  result=OK
2026-03-19T10:23:41.532+09:00  COM3  PARSE req=-     frame=02 01 11        result=INCOMPLETE  need=2
2026-03-19T10:23:43.540+09:00  COM3  ERR req=00A8  reason=response-timeout  elapsed=2008ms
2026-03-19T10:23:43.541+09:00  COM3  STATE Ready -> Fault  reason=response-timeout

여기에서의 목표는 세 가지입니다.

  • RX 행과 PARSE 행을 나눈다. RX는 「몇 byte가 도착했는지」, PARSE는 「프레임을 몇 개 잘라 냈는지」입니다. 위 예에서는 2번째와 3번째 RX에 걸쳐 프레임 하나가 도착하고, 나머지가 다음 프레임의 선두가 되어 있습니다. 이 두 종류를 섞어 기록하면, 4.1의 분할 어긋남이 일어나고 있는지 나중에 판정할 수 없습니다
  • req=로 송수신을 맞춰 볼 수 있게 한다. 어느 응답이 어느 명령에 대응하는지는, 나중에 로그만으로는 복원할 수 없습니다
  • 상태 전이를 한 줄로 남긴다. Ready -> Fault 같은 전이와 이유가 남아 있으면, 재접속 계기를 그대로 추적할 수 있습니다

hex dump는 용량을 먹으므로, raw 로그는 링 버퍼로 일정량만, 요약 로그는 장기 보존이라는 이중 구조로 두는 것이 현실적입니다.

송수신 로그의 세 가지 목표RX 행과 PARSE 행을 나누고, req로 송수신을 맞춰 보고, 상태 전이를 한 줄로 남긴다는 세 가지 목표와, raw 로그는 링 버퍼로 일정량, 요약 로그는 장기 보존이라는 이중 구조를 나타내는 그림.RX와 PARSE 행을 나눈다나중에 원인을 가를 수 있는 로그req로 송수신을 맞춰 본다상태 전이를 한 줄로 남긴다raw 로그는 링 버퍼, 요약 로그는 장기 보존

그림10: 도착한 byte 수와 잘라 낸 프레임을 나누어 남기면, 어긋남을 나중에 추적할 수 있습니다.

5. 베스트 프랙티스

가장 효과적인 것은, 책임을 나누는 것입니다.

  • reader: port에서 byte 열을 읽기만 한다
  • writer: outbound queue에서 순서대로 쓰기만 한다
  • parser: byte 열에서 frame을 잘라 내기만 한다
  • protocol: request와 response의 대응이나 checksum을 다룬다
  • app state: 업무 상태를 갱신하기만 한다

수신 처리는, Read의 반환 단위를 그대로 업무 단위로 두지 말고, 일단 버퍼에 쌓은 뒤 parser가 frame을 잘라 내는 구성이 안정됩니다. 송신은 워커 하나로 모으고, 실제 Write를 single writer로 두는 편이 순서 어긋남을 줄입니다.

타임아웃도, 숫자 하나로 끝내기보다, open, inter-byte, response, reconnect의 의미마다 나누는 편이 원인 분리가 쉬워집니다. port 설정은 그 자리의 코드 값보다 profile로 두고, startup 때 로그에 남겨 두면 현장 조사가 꽤 편해집니다.

재접속은, 단순한 reopen이 아니라 session 재생성으로 생각하는 편이 안정됩니다. 수신 버퍼, parser 상태, pending request, 초기화 시퀀스, readiness 판정까지 포함해 다시 만들면, 「가끔만 깨지는」 재접속 버그를 줄이기 쉬워집니다.

마지막으로, raw 로그와 요약 로그를 둘 다 두는 것을 권합니다. raw hex dump나 open / close 이력은 조사에 강하고, request id나 retry 횟수의 요약은 운영에 강합니다.

책임별 파이프라인reader가 포트에서 byte 열을 읽고, 쌓아 둔 버퍼에서 parser가 프레임을 잘라 내고, protocol이 대응과 checksum을 다루며, app state가 업무 상태를 갱신하고, 송신은 각 곳에서 큐에 쌓고 writer만 쓴다는 책임 분리를 나타내는 그림.포트reader: 읽기만parser: 프레임 잘라 내기protocol: 대응과 checksumapp state: 업무 상태 갱신각 곳에서는 큐에 쌓기만writer: 순서대로 쓰기만

그림11: 수신과 송신의 파이프라인. 각각의 역할은 일만 하나씩 합니다.

여기서부터, 가장 효과적인 두 곳만 골격 코드를 둡니다. .NET 8 / C# 12에서, System.IO.Ports 패키지를 참조하고 있다는 전제입니다.

5.1 수신: 쌓은 뒤 잘라 낸다

예로, STX(0x02), LEN(1 byte), payload(LEN byte), CRC16(2 byte, 리틀 엔디언)이라는 프레임을 가정합니다. 형태는 무엇이든 좋지만, Read의 반환 단위가 아니라, 이 정의로 프레임을 자른다는 점이 본론입니다.

using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Diagnostics;

public static class Crc16Modbus
{
    // CRC-16/MODBUS: 초기값 0xFFFF, 다항식 0xA001의 오른쪽 시프트
    public static ushort Compute(ReadOnlySpan<byte> data)
    {
        ushort crc = 0xFFFF;
        foreach (var b in data)
        {
            crc ^= b;
            for (var i = 0; i < 8; i++)
            {
                crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
            }
        }

        return crc;
    }
}

public static class Frame
{
    public const byte Stx = 0x02;
    public const int HeaderLength = 2;   // STX + LEN
    public const int CrcLength = 2;

    public static byte[] Build(ReadOnlySpan<byte> payload)
    {
        // LEN은 1 byte이므로, 256 byte 이상은 캐스트하면 값이 순환한다.
        // 그래도 payload는 통째로 복사되므로, 받는 쪽은 잘린
        // 길이로 프레임을 자르고, payload 중간을 CRC로 읽는다. 이후
        // 프레임 경계도 한꺼번에 무너진다. 분할할지 LEN을 2 byte로 할지는
        // 프로토콜 결정이므로, 여기서는 거절만 한다
        if (payload.Length > byte.MaxValue)
        {
            throw new ArgumentOutOfRangeException(
                nameof(payload),
                $"프레임 하나의 payload는 {byte.MaxValue} byte까지입니다(LEN이 1 byte이므로).");
        }

        var frame = new byte[HeaderLength + payload.Length + CrcLength];
        frame[0] = Stx;
        frame[1] = (byte)payload.Length;
        payload.CopyTo(frame.AsSpan(HeaderLength));

        var body = frame.AsSpan(0, frame.Length - CrcLength);
        BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(frame.Length - CrcLength), Crc16Modbus.Compute(body));
        return frame;
    }
}

public sealed class FrameParser
{
    private readonly List<byte> _buffer = new();

    /// <summary>3.3의 inter-byte timeout. 조립 중인 프레임을 포기할 때까지의 시간.</summary>
    private static readonly TimeSpan AssemblyTimeout = TimeSpan.FromMilliseconds(200);

    /// <summary>지금 조립 중인 후보가, 언제부터 대기 상태가 되었는지(단조 증가 값).</summary>
    private long _pendingSince;

    /// <summary>CRC가 맞지 않아 버린 프레임을 알린다. 로그에 남기려면 반드시 구독한다.</summary>
    public event Action<byte[]>? FrameDiscarded;

    /// <summary>조립을 포기하고 재동기화했음을 알린다. 여기가 계속 늘면 결선이나 설정을 의심한다.</summary>
    public event Action<int>? Resynchronized;

    /// <summary>수신한 byte 열을 쌓아 두고, 잘라 낸 프레임만 반환한다.</summary>
    public IReadOnlyList<byte[]> Append(ReadOnlySpan<byte> received)
    {
        foreach (var b in received)
        {
            _buffer.Add(b);
        }

        var frames = new List<byte[]>();

        while (true)
        {
            // 1. 선두가 STX가 될 때까지 버린다. 노이즈나 이전 프레임의 나머지를 여기서 흡수한다
            var stxIndex = _buffer.IndexOf(Frame.Stx);
            if (stxIndex < 0)
            {
                _buffer.Clear();
                _pendingSince = 0;   // 후보가 없어졌으므로 대기 시간 측정도 그만둔다
                break;
            }

            if (stxIndex > 0)
            {
                // 후보의 선두가 바뀌었다 = 다른 프레임 조립을 시작한다
                _buffer.RemoveRange(0, stxIndex);
                _pendingSince = 0;
            }

            // 2. 길이를 읽을 수 있는 곳까지 도착했는지
            if (_buffer.Count < Frame.HeaderLength)
            {
                if (GiveUpOnStaleCandidate()) { continue; }
                break;   // 「깨진 것」이 아니라 「아직 부족하다」
            }

            int payloadLength = _buffer[1];
            int frameLength = Frame.HeaderLength + payloadLength + Frame.CrcLength;

            // 3. 프레임 하나분이 갖춰졌는지
            if (_buffer.Count < frameLength)
            {
                // 「아직 부족하다」와 「LEN이 노이즈로 깨졌다」는, 이 시점에서는
                // 구분이 되지 않는다. 노이즈나 가짜 STX로 LEN이 255가 되면,
                // 그 뒤에 도착하는 올바른 프레임까지 payload로 계속 빨아들이고,
                // 259 byte가 모여 CRC에서 떨어질 때까지 아무것도 올라오지 않는다.
                // 통신량이 적은 기기에서는, 이것이 몇 분의 무반응으로 보인다.
                // 대기에 상한을 두고, 넘으면 후보를 버리고 STX를 다시 찾는다
                if (GiveUpOnStaleCandidate()) { continue; }
                break;   // 여기서 빠져, 다음 수신을 기다린다
            }

            var frame = _buffer.GetRange(0, frameLength).ToArray();
            _buffer.RemoveRange(0, frameLength);
            _pendingSince = 0;

            // 4. CRC가 맞지 않는 것은 버린다. 버린 사실은 반드시 밖으로 낸다
            var expected = BinaryPrimitives.ReadUInt16LittleEndian(frame.AsSpan(frame.Length - Frame.CrcLength));
            if (expected == Crc16Modbus.Compute(frame.AsSpan(0, frame.Length - Frame.CrcLength)))
            {
                frames.Add(frame);
            }
            else
            {
                // 여기서 프레임 하나분을 한꺼번에 버릴지, STX 1 byte만 버리고 다시 읽을지는 설계 판단.
                // 전자는 단순하고, 후자는 LEN 자체가 노이즈였을 때 강하다. 어느 쪽으로 할지 정해 명문화한다.
                FrameDiscarded?.Invoke(frame);
            }
        }

        return frames;
    }

    /// <summary>
    /// 조립 중인 후보가 AssemblyTimeout을 넘었으면, 선두 STX를 1 byte만 버린다.
    /// 버리면 true를 반환하고, 호출 쪽은 다음 STX부터 다시 읽는다.
    /// 프레임 단위로 버리지 않는 이유는, 진짜 STX가 이 후보 안에 있을 수 있기 때문이다.
    /// </summary>
    private bool GiveUpOnStaleCandidate()
    {
        if (_pendingSince == 0)
        {
            // 대기를 시작한 순간. 벽시계는 NTP 동기로 튀므로, 단조 증가 값으로 잰다
            _pendingSince = Stopwatch.GetTimestamp();
            return false;
        }

        if (Stopwatch.GetElapsedTime(_pendingSince) < AssemblyTimeout)
        {
            return false;
        }

        _buffer.RemoveAt(0);
        _pendingSince = 0;
        Resynchronized?.Invoke(_buffer.Count);
        return true;
    }
}

GiveUpOnStaleCandidate가 3.3에서 든 inter-byte timeout의 실체입니다. 이것이 없으면, 노이즈나 가짜 STXLEN이 큰 값(예를 들어 255)으로 바뀌었을 때, parser는 그것을 「아직 부족하다」고 계속 다룹니다. 그 뒤에 도착하는 올바른 프레임까지, 바뀐 payload의 일부로 빨아들이고, 259 byte가 모여 CRC에서 떨어질 때까지 아무것도 올라오지 않습니다. 통신량이 적은 기기라면, 이것이 몇 분의 무반응으로 보입니다. 버리는 것을 STX 1 byte만으로 두는 이유는, 진짜 STX가 후보 안에 묻혀 있을 수 있기 때문입니다.

전제를 두 가지 적어 둡니다. 이 시간 초과는 Append가 호출되었을 때만 판정됩니다. 회선이 완전히 무음이 된 경우에는 parser 쪽에서는 아무 일도 없으므로, 그곳은 호출 쪽의 response timeout(5.2)에서 잡습니다. 또 하나, AssemblyTimeout 값은 보레이트와 프레임 길이에서 정하세요. 1 byte 전송 시간 × 가정 최대 프레임 길이에 여유를 더한 값이 하한이고, 이보다 짧으면 정상적인 프레임을 중간에 버립니다. Resynchronized 발생 횟수는 로그에 남겨 두면, 결선이나 보레이트 설정을 의심하는 재료가 됩니다.

바뀐 LEN이 올바른 프레임을 빨아들인다노이즈나 가짜 STX로 LEN이 큰 값으로 바뀌면, parser는 아직 부족하다고 계속 다루고, 그 뒤에 도착하는 올바른 프레임까지 바뀐 payload로 빨아들여, CRC에서 떨어질 때까지 무반응으로 보이므로, 시간 초과로 STX를 1바이트만 버리고 재동기화함을 나타내는 그림.대책노이즈나 가짜 STX로 LEN이 바뀐다parser는 「아직 부족하다」고 계속 기다린다올바른 프레임까지 빨아들인다CRC에서 떨어질 때까지 무반응으로 보인다시간 초과로 STX를 1바이트 버리고 재동기화

그림12: inter-byte timeout이 없으면, 바뀐 LEN이 이후 프레임을 계속 빨아들입니다.

읽는 쪽은, 포트에서 읽어 parser에 넘기기만 합니다. 여기서 업무 처리를 쓰기 시작하면, Read의 반환 단위가 업무 단위로 바뀝니다.

using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;

public sealed class SerialReader
{
    private readonly SerialPort _port;
    private readonly FrameParser _parser;
    private readonly byte[] _readBuffer = new byte[4096];

    public SerialReader(SerialPort port, FrameParser parser)
    {
        _port = port;
        _parser = parser;
    }

    public event Action<byte[]>? FrameReceived;

    public async Task RunAsync(CancellationToken token)
    {
        while (!token.IsCancellationRequested)
        {
            int count;
            try
            {
                count = await _port.BaseStream.ReadAsync(_readBuffer.AsMemory(), token);
            }
            catch (OperationCanceledException)
            {
                break;
            }

            if (count <= 0)
            {
                continue;
            }

            foreach (var frame in _parser.Append(_readBuffer.AsSpan(0, count)))
            {
                FrameReceived?.Invoke(frame);
            }
        }
    }
}

DataReceived를 쓰지 않은 것은 의도적입니다. 4.2대로 「무언가 온 것 같다」 이상의 의미가 없으므로, 읽기 루프를 직접 갖는 편이 상태와 timeout을 관리하기 쉬워집니다.

5.2 송신: single writer로 모은다

송신 쪽은, 어디서든 Write할 수 있는 상태를 만들지 않는 것이 핵심입니다. 큐에 쌓는 곳은 누구든 불러도 되고, 실제로 Write하는 것은 워커 하나뿐, 이라는 형태로 둡니다.

using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;

public sealed class SingleWriter
{
    private sealed record Outbound(byte[] FrameBytes, TaskCompletionSource<byte[]> Completion);

    /// <summary>송신 큐의 상한. 장치 1왕복 시간 × 허용하는 대기열로 정한다.</summary>
    private const int QueueCapacity = 64;

    private readonly SerialPort _port;
    private readonly TimeSpan _responseTimeout;

    // 상한 없이 두지 않는다. UI·타이머·워커가 장치 왕복보다 빨리 쌓으면,
    // 프레임과 TaskCompletionSource가 한없이 쌓이고, 장치는 응답하는데
    // 메모리만 계속 늘어난다. 상한을 정하고, 넘치면 보내는 쪽에 돌려준다
    private readonly Channel<Outbound> _queue = Channel.CreateBounded<Outbound>(
        new BoundedChannelOptions(QueueCapacity)
        {
            // 가득 차면 TryWrite가 false를 반환한다. 호출 쪽은 「지금 막혀 있다」
            // 것을 즉시 알 수 있다. DropOldest는 쓰지 않는다 ── 쌓은 쪽은
            // Task를 기다리고 있으므로, 조용히 버리면 영원히 돌아오지 않는다
            FullMode = BoundedChannelFullMode.Wait,
            SingleReader = true,
        });

    private Outbound? _inFlight;

    public SingleWriter(SerialPort port, TimeSpan responseTimeout)
    {
        _port = port;
        _responseTimeout = responseTimeout;
    }

    /// <summary>UI에서도 타이머에서도 불러도 된다. 실제 Write는 워커 하나만 수행한다.</summary>
    public Task<byte[]> SendAsync(ReadOnlySpan<byte> payload)
    {
        var item = new Outbound(
            Frame.Build(payload),
            new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously));

        if (!_queue.Writer.TryWrite(item))
        {
            // 가득 찼거나, 워커가 이미 멈췄다. 어느 쪽이든 「쌓지 못했다」는 것을
            // 호출 쪽에 돌려준다. 조용히 버리면, 기다리는 Task가 영원히 돌아오지 않는다
            item.Completion.TrySetException(new InvalidOperationException(
                $"송신 큐에 쌓지 못했습니다(상한 {QueueCapacity}건, 또는 워커 정지됨)."));
        }

        return item.Completion.Task;
    }

    /// <summary>parser가 프레임을 잘라 내면 호출한다. 응답 대기 1건에 묶는다.</summary>
    public void OnFrameReceived(byte[] frame)
    {
        var pending = Interlocked.Exchange(ref _inFlight, null);
        pending?.Completion.TrySetResult(frame);
    }

    public async Task RunAsync(CancellationToken token)
    {
        try
        {
            await foreach (var item in _queue.Reader.ReadAllAsync(token))
            {
                Interlocked.Exchange(ref _inFlight, item);
                try
                {
                    await _port.BaseStream.WriteAsync(item.FrameBytes.AsMemory(), token);
                }
                catch (Exception ex)
                {
                    // 단선·포트 닫힘·취소는 여기서 올라온다.
                    // 이 1건을 완료하지 않고 빠져나가면, SendAsync를 await하는
                    // 호출 쪽이 영원히 기다린다
                    Interlocked.Exchange(ref _inFlight, null);
                    item.Completion.TrySetException(ex);
                    throw;
                }

                // 여기서 응답까지 기다리므로, 다음 명령이 끼어들지 않는다
                var timeout = Task.Delay(_responseTimeout, token);
                var finished = await Task.WhenAny(item.Completion.Task, timeout);

                if (finished != item.Completion.Task)
                {
                    // 정지를 요청했을 때도 Task.Delay는 취소되어 먼저 끝난다.
                    // 여기를 보지 않으면, 정상적인 종료 처리가 타임아웃으로 취급되고,
                    // 호출 쪽에는 TimeoutException이 돌아간다
                    token.ThrowIfCancellationRequested();

                    // WhenAny가 timeout을 고른 뒤에 응답이 도착하는 경우가 있다.
                    // 그때 OnFrameReceived는 이미 _inFlight를 집어, 이 1건을
                    // 성공으로 완료하고 있다. 집어 오지 못했으면 응답이 이긴 것이고, 여기서
                    // TrySetException을 쏴도 효과가 없다. 그것을 모르고
                    // throw까지 가면, 호출 쪽은 결과를 받았는데
                    // 워커만 떨어진다. 다툼은 CompareExchange로 정한다
                    if (Interlocked.CompareExchange(ref _inFlight, null, item) != item)
                    {
                        // 응답이 이겼다. 완료는 OnFrameReceived가 넣는다(넣은 직후)
                        await item.Completion.Task;
                        continue;
                    }

                    item.Completion.TrySetException(new TimeoutException("응답이 없었습니다."));

                    // 타임아웃하면, 이 연결은 더 이상 믿을 수 없다.
                    // 「포기하고 다음을 보낸다」로 끝내면 안 되는 이유는 아래에 적은 대로,
                    // 이 프로토콜에는 요청 ID가 없으므로, 늦게 도착한 A의 응답이
                    // 다음 B의 응답으로 묶여 버린다. 3.6의 상태 전이 그림대로
                    // Fault로 떨어뜨리고, 세션을 다시 만든다
                    throw new TimeoutException("응답이 없으므로, 세션을 다시 만듭니다.");
                }
            }
        }
        finally
        {
            // 워커가 멈추는 이유가 무엇이든, 기다리게 한 건은 반드시 끝낸다.
            // 응답 대기 1건과, 큐에 쌓인 채 보내지 못한 분 둘 다
            var stopped = new OperationCanceledException("송신 워커가 정지했습니다.");
            Interlocked.Exchange(ref _inFlight, null)?.Completion.TrySetException(stopped);
            _queue.Writer.TryComplete();
            while (_queue.Reader.TryRead(out var pending))
            {
                pending.Completion.TrySetException(stopped);
            }
        }
    }
}

타임아웃으로 워커까지 멈추는 것은, 거칠어 보여도 필요한 조치입니다. 이 프레임에는 요청 ID가 들어 있지 않습니다. 그래서 받는 쪽은 「지금 온 프레임이 어느 명령에 대한 응답인지」를 가릴 수 없고, OnFrameReceived는 응답 대기 1건에 기계적으로 묶을 수밖에 없습니다.

여기서 타임아웃 뒤에도 그대로 다음을 보내면, 이렇게 됩니다.

  1. 명령 A를 보낸다. 정해 둔 시간 안에 응답이 오지 않아 타임아웃으로 친다
  2. 다음 명령 B를 보낸다
  3. 늦게 도착한 A의 응답이, B의 응답으로 호출 쪽에 넘어간다

호출 쪽에서 보면, B를 보냈는데 A의 값이 돌아옵니다. 값의 형식은 맞으므로 검사도 통과해 버리고, 가장 찾기 어려운 깨짐입니다. 3.6의 상태 전이 그림에서 Fault에서 Ready로 바로 돌아가는 선을 긋지 않은 것은, 이 경로가 있기 때문입니다. 타임아웃은 「1건의 실패」가 아니라 「이 연결은 더 이상 믿을 수 없다」는 판단이고, 수신 버퍼에 무엇이 남았는지 모르는 상태에서의 복귀는, 포트를 닫고 다시 여는 세션 재생성으로만 보장할 수 있습니다.

프로토콜 쪽에 손을 넣을 수 있다면, 프레임에 요청 ID를 두고 응답과 맞춰 보는 것이 본질적인 해결입니다. 그러면 늦게 도착한 응답은 「모르는 ID이므로 버린다」로 끝나고, 타임아웃 1건마다 연결을 다시 만들 필요가 없어집니다.

늦은 응답의 엇갈림요청 ID가 없는 프로토콜에서는, 명령 A가 타임아웃한 뒤에 명령 B를 보내면, 늦게 도착한 A의 응답이 B의 응답으로 호출 쪽에 넘어가므로, 타임아웃 뒤에는 Fault로 떨어뜨리고 세션을 다시 만듦을 나타내는 그림.장치워커호출 쪽장치워커호출 쪽시간 안에 응답이 오지 않아 타임아웃그래서 타임아웃 뒤에는 Fault로 떨어뜨린다명령 A를 보낸다A를 송신명령 B를 보낸다B를 송신늦게 도착한 A의 응답B의 응답으로 넘어가 버린다

그림13: 요청 ID가 없으면, 늦게 도착한 응답이 다음 명령에 묶입니다.

마지막으로 조립합니다. 포트 설정은 profile로 한곳에 두고, 기동 때 로그에 낸다는 것까지가 3.4의 이야기입니다.

using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;

// 3.4에서 정한 설정을, 그 자리의 값이 아니라 모아 둔다
using var port = new SerialPort("COM3", 115200, Parity.None, 8, StopBits.One)
{
    Handshake = Handshake.None,
    DtrEnable = true,
    RtsEnable = true,
    ReadTimeout = 500,
    WriteTimeout = 500,
};

Console.WriteLine($"open {port.PortName} baud={port.BaudRate} data={port.DataBits} parity={port.Parity} " +
                  $"stop={port.StopBits} handshake={port.Handshake} dtr={port.DtrEnable} rts={port.RtsEnable}");
port.Open();

var parser = new FrameParser();
var writer = new SingleWriter(port, TimeSpan.FromSeconds(2));
var reader = new SerialReader(port, parser);

// 정의한 이벤트는 반드시 구독한다. 여기를 잊으면, 버린 프레임도 응답도 표면에 나오지 않는다
parser.FrameDiscarded += frame => Console.Error.WriteLine($"crc error: {Convert.ToHexString(frame)}");
reader.FrameReceived += writer.OnFrameReceived;

using var cts = new CancellationTokenSource();
var readerTask = reader.RunAsync(cts.Token);
var writerTask = writer.RunAsync(cts.Token);

var request = new byte[] { 0x10, 0x00 };
try
{
    var response = await writer.SendAsync(request);
    Console.WriteLine($"response: {Convert.ToHexString(response)}");
}
finally
{
    // 송신이 실패해도, 워커는 반드시 멈춘 뒤에 빠져나간다. 여기를 건너뛰면,
    // using 폐기로 SerialPort가 닫힌 뒤에도 reader/writer가
    // 그 스트림을 계속 만지고, 게다가 그 예외는 아무도 관측하지 않는다
    cts.Cancel();
    try
    {
        await Task.WhenAll(readerTask, writerTask);
    }
    catch (OperationCanceledException)
    {
        // 정지 요청에 의한 종료. 여기는 정상 경로로 다룬다
    }
    catch (Exception ex)
    {
        // 워커 쪽 실패. 여기서 다시 던지면, 본래 실패 이유
        // (SendAsync의 예외)를 덮으므로, 기록에만 남긴다
        Console.Error.WriteLine($"worker stopped with error: {ex.Message}");
    }
}

cts.Cancel()Task.WhenAllfinally에 들어 있는 것은, 쓰는 취향이 아닙니다. 시리얼 통신에서 SendAsync가 실패하는 것은 예외가 아니라 일상입니다 ── 기기가 응답하지 않는다, 케이블이 빠졌다, 쓰기가 타임아웃했다. 그때 그대로 위로 빠져나가면, 워커를 멈추지 않은 채 using 폐기에 도달합니다. SerialPort가 닫힌 뒤에도 reader / writer는 그 스트림을 만지러 가고, 거기서 난 예외는 아무도 관측하지 않습니다. 상주 앱이라면, 실패한 조작의 워커만 살아남는 형태로 조금씩 쌓입니다. finally 쪽 예외를 다시 던지지 않는 것은, 본래 실패 이유(SendAsync의 예외)를 덮지 않기 위해서입니다.

실패해도 워커는 반드시 멈춘다SendAsync 실패는 시리얼 통신에서는 일상이며, 그대로 위로 빠져나가면 워커를 멈추지 않은 채 SerialPort가 폐기되어, 닫힌 스트림을 reader와 writer가 계속 만지고 예외를 아무도 관측하지 않으므로, finally에서 취소와 대기를 수행함을 나타내는 그림.SendAsync가 실패한다(일상)finally에서 cancel하고 기다린다워커를 멈춘 뒤에 폐기한다건너뛰면 닫힌 스트림을 계속 만진다상주 앱에서는 실패할 때마다 워커가 남는다

그림14: 송신이 실패한 경우일수록, finally에서 워커를 멈춘 뒤에 빠져나갑니다.

이 형태로 두면, 나중에 더하고 싶어지는 것, 예를 들어 retry, keepalive, reconnect가, 모두 큐에 쌓는 쪽이거나 워커 쪽 중 하나에 들어갑니다. Write를 직접 부르는 곳이 늘지 않으므로, 순서 어긋남의 원인이 늘지 않습니다.

6. 먼저 볼 체크리스트

  • 메시지 경계는 명문화되어 있는가
  • 수신은 byte 축적 → frame 잘라 내기로 되어 있는가
  • DataReceived를 메시지 도착으로 다루지 않는가
  • UI 스레드에서 동기 I/O를 하지 않는가
  • 송신은 single writer로 되어 있는가
  • timeout이 하나가 아니라 의미마다 나뉘어 있는가
  • Handshake / DTR / RTS가 명시되어 있는가
  • reconnect에서 session을 다시 만들고 있는가
  • raw hex dump를 남기고 있는가
  • 실제 기기 뽑았다 꽂기나 중간 절단을 시험하고 있는가

이 안에서 수상한 항목이 여러 개라면, 실전 투입 전에 한 번 멈춰 볼 가치가 있습니다.

7. 정리

마지막으로, 요점만 다시 늘어놓습니다.

  • 시리얼 통신은 메시지가 아니라 byte stream
  • Read 단위와 메시지 단위는 일치하지 않는다
  • 경계는 프로토콜로서 정의할 필요가 있다
  • DataReceived를 그대로 업무 이벤트로 두면 무너지기 쉽다
  • 송수신은 책임을 분리하고, 송신은 single writer로 모은다
  • timeout은 의미마다 분할하고, 재접속은 session 단위로 설계한다
  • raw hex dump를 포함한 로그가, 이후 조사를 꽤 편하게 한다

즉, 시리얼 통신 앱에서는 포트를 여는 것보다, byte 열을 어떻게 해석하고, 시간과 상태를 어떻게 제어하는가가 훨씬 중요합니다. 여기를 처음에 나누어 설계하는 것만으로, 「가끔만 깨지는」 유형의 통신 장애는 꽤 줄어듭니다.

여는 것보다 해석과 제어시리얼 통신 앱에서는 포트를 여는 것보다, byte 열을 어떻게 해석하고, 시간과 상태를 어떻게 제어하는가가 더 중요하며, 처음에 나누어 설계하면 가끔만 깨지는 통신 장애가 줄어듦을 나타내는 그림.포트를 연다여기는 난관이 아니다byte 열의 해석처음에 나누어 설계한다시간과 상태의 제어「가끔만 깨지는」 장애가 줄어든다

그림15: 중요한 것은 포트를 여는 것이 아니라, 해석과 제어의 설계가 됩니다.

8. 참고 자료

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

시리얼 통신에서 Read(16)을 호출하면 16바이트가 딱 맞게 수신되나요?
그렇다고 단정할 수는 없습니다. 시리얼 통신은 순서가 있는 byte stream일 뿐이고, 메시지 경계는 저절로 붙지 않기 때문입니다. 한 번 Write한 내용도 상대 쪽에서는 두 번에 나뉘어 도착하거나, 다른 데이터와 붙어 도착할 수 있습니다. 길이만 읽고 payload는 아직 없는 경우, 프레임 하나 반만 도착하는 경우, 프레임 두 개가 한 번에 도착하는 경우가 전형적인 깨짐입니다. 대책은 수신을 먼저 버퍼에 쌓고, 그다음 parser가 프레임을 잘라 내는 쪽으로 역할을 나누는 것입니다.
.NET의 SerialPort.DataReceived 이벤트를 쓸 때 주의점은 무엇인가요?
DataReceived는 수신 byte마다 발생한다고 할 수 없고, UI 스레드도 아닙니다. 이를 「메시지 하나가 도착했다」는 알림으로 보면 위험합니다. 실무에서는 「무언가 온 것 같다」 정도의 알림으로 보고, 핸들러 안에서는 무거운 처리를 하지 않으며, UI 갱신은 반드시 UI 스레드로 되돌립니다. 수신 byte 열은 일단 쌓아 둔 뒤 parser로 프레임을 잘라 내는 구성이 안정적입니다.
시리얼 통신의 타임아웃은 어떻게 설계하면 되나요?
타임아웃은 하나로 부족하고, 의미마다 나누는 편이 안정적입니다. 포트를 열 때까지의 open timeout, 프레임 도중 byte가 오지 않는 시간의 inter-byte timeout, 명령을 낸 뒤 응답이 끝날 때까지의 response timeout, 재접속 대기 간격인 reconnect backoff입니다. 타임아웃은 느릴 때의 보험이 아니라, 상태 전이를 진행하는 규칙으로 두면 안정됩니다. 동기 read를 기본값 그대로 쉽게 두면, 그대로 무한 대기가 되는 점에도 주의가 필요합니다.
USB-시리얼 변환에서 케이블을 뽑았다가 꽂은 뒤 복귀하지 않는 이유는 무엇인가요?
USB-시리얼에서는 포트가 잠시 사라지고, 이전 핸들이 무효가 되며, COM 번호가 바뀌고, 이전 pending request가 의미를 잃는 일이 흔히 일어나기 때문입니다. Open()을 다시 하는 것만으로는 재접속으로 부족합니다. session 무효화, pending request의 fail, reader와 writer 정지, backoff 후 reopen, 장치 초기화 시퀀스 재실행까지 묶어 다루는 「session 재생성」으로 설계하면, 가끔만 깨지는 재접속 버그를 줄일 수 있습니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기