수정 이력(10건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 「접속 중」이 떠안는 질문의 분해, 3층 패널, 신선도 판정의 함정, flapping을 평활화하는 방법 등을 그림으로도 따라갈 수 있도록 Mermaid 그림을 15개 추가했습니다(본문 500~750자당 1그림 규약에 맞춘 것입니다). 기존 두 그림의 캡션 번호도 일련 번호로 다시 매겼습니다. 본문 문장은 바꾸지 않았습니다.
- 글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 모은 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 상태를 누가 답할 수 있는 질문인지로 나눈 그림과, 연결과 세션의 수명을 보이는 상태 전이도를 추가했습니다. 신선도는 기능 준비와 독립된 축이므로 상태 전이도에는 넣지 않고, 4.5절의 판정과 조합하는 형태로 했습니다. 본문 설명은 바꾸지 않았습니다.
- 외부 리뷰(1283건) 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조하십시오.
- 데이터 신선도 판정에, 수신과 독립된 주기의 입구를 추가했습니다. `Observe`가 호출되는 것은 수신했을 때뿐이고, 그때 넘겨지는 `Reading`은 방금 만든 것이므로 수신 시각과 현재 시각의 차는 거의 제로입니다. 이 경로에서 `Stale`이 되는 것은 「수신은 이어지지만 연번이 진행하지 않는」 경우뿐이며, callback이 완전히 멈췄을 때(가장 알고 싶은 깨짐)는 `Observe`가 호출되지 않아 화면이 마지막으로 계산한 `Fresh` 채로 굳습니다. 마지막으로 받은 값의 오래됨을 다시 재는 `Reevaluate`를 추가하고, budget보다 짧은 주기의 타이머에서 호출하는 형태로 했습니다. 수신 스레드와 타이머 스레드가 동시에 오므로 내부 상태는 `lock`으로 지킵니다.
- 표시 쪽 presenter가 화면을 닫는 중의 갱신으로 감시 워커를 연쇄로 끌어내리던 것을 고쳤습니다. `Changed?.Invoke`는 감시 스레드에서 동기적으로 핸들러를 호출하므로, 컨트롤 핸들이 Dispose된 뒤의 `BeginInvoke`가 던지는 예외가 그대로 `Publish`를 타고 워커 쪽으로 빠져나갑니다. `Dispose`에서 구독만 빼서는, 빼는 도중에 시작된 호출을 막을 수 없습니다. 구독을 `Control.Disposed`로 화면 수명에 묶고, Dispose 중이거나 이미 Dispose된 컨트롤에는 던지지 않으며, 판정과 `BeginInvoke` 사이에 남는 틈은 `ObjectDisposedException`/`InvalidOperationException`을 잡아 표시만 포기하는 형태로 했습니다.
- 신선도 판정을 벽시계(`DateTimeOffset`) 뺄셈으로 하던 것을 단조 증가 타임스탬프로 바꿨습니다. NTP 동기나 수동 설정으로 시각이 되돌아가면 경과 시간이 음수가 되어, 기기가 끊겼는데도 Fresh로 남습니다. 반대로 앞서면 방금 도착한 값이 즉시 Stale이 됩니다. `TimeProvider.GetTimestamp`와 `GetElapsedTime`으로 재고, 벽시계 수신 시각은 표시 전용으로 나눴습니다.
- freshness 판정에서 기기 쪽 시계가 붙인 `ValueTimestamp`를 이쪽 시계의 `now`에서 빼던 것을 고쳤습니다. 시계가 맞지 않으면, 늦은 기기의 값이 도착한 직후에 stale이 되고, 앞서 있으면 멈춘 값이 언제까지나 fresh로 남습니다. 연번이 마지막으로 진행한 때의 수신 시각을 가지고, budget은 자기 시계로 잰 경과 시간에 적용하는 형태로 했습니다.
- 요약·이유·상세의 3층 패널과, 여러 대를 모아 내는 화면 와이어프레임을 텍스트로 추가했습니다. 상태를 한곳에 모아 구독하는 C# 골격과, 도착했는지와 내용이 진행했는지를 나누어 판정하는 예를 새로 두고, 용어표와 NG 문구 고쳐 쓰기 표를 더했습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635204)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「외부 기기의 상태 확인과 표시의 베스트 프랙티스 - 『접속 중』만으로 끝내지 않는 설계」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635204 https://comcomponent.com/ko/blog/external-device-state-check-display-best-practices/
- DOI(최신 버전)
- 10.5281/zenodo.21635204
- DOI(이 버전)
- 10.5281/zenodo.21635205
산업용 카메라, 바코드 리더, PLC, 계측기, 프린터, 시리얼 기기, USB 기기. 외부 기기와 연결되는 Windows 앱에서는, 실제 장애 그 자체보다 먼저 화면의 상태 표시가 현실과 어긋나는 것으로 사고가 나는 경우가 꽤 많습니다.
예를 들어 이런 상태입니다.
- OS에서는 보이는데, 다른 프로세스가 잡고 있어 쓸 수 없다
open은 됐는데, 원점 복귀나 워밍업, 인증이 끝나지 않았다- 기기는 매달려 있지만, 응답은 이미 멈춰 있다
- 취득 스레드가 죽어 있는데, 마지막 값만 화면에 남아 있다
- 대상이 아닌 개체나 firmware인데, 단순히 「접속 중」이라고 표시해 버린다
여기서 정말로 알고 싶은 것은 연결되어 있는지 여부만이 아닙니다. 지금 무엇을 안전하게 해도 되는가입니다.
flowchart TB
accTitle: 알고 싶은 것은 조작 가능 여부
accDescr: 외부 기기 연계 앱에서 정말로 알고 싶은 것은 연결되어 있는지 여부만이 아니라 지금 무엇을 안전하게 해도 되는가이며, 실제 장애보다 먼저 화면의 상태 표시가 현실과 어긋나 사고가 나기 쉽다는 것을 보이는 그림.
a1["「연결되어 있는가」"] -.-> a2["알고 싶은 것의 일부에 불과하다"]
a3["「지금 무엇을 안전하게 해도 되는가」"] --> a4["정말로 알고 싶은 것"]
a4 -.-> a5["표시가 현실과 어긋나면 먼저 사고가 난다"]
그림1: 상태 표시의 목적은 접속 여부가 아니라, 지금 안전하게 할 수 있는 조작을 전하는 것입니다.
이 글의 대상 독자와 전제
| 항목 | 내용 |
|---|---|
| 대상 독자 | 외부 기기와 연결되는 Windows 앱을 설계·구현하는 분. 기존 앱에서 「화면은 접속 중인데 움직이지 않는다」는 문의를 줄이고 싶은 분을 대상으로 합니다 |
| 전제로 하는 지식 | 어떤 언어로든 앱을 작성할 수 있을 것. 특정 SDK나 디바이스 드라이버 지식은 전제로 하지 않습니다 |
| 전제로 하는 환경 | Windows 데스크톱 앱을 전제로 합니다. 다만 상태를 나누는 방법과 표시의 사고방식 자체는 OS에 의존하지 않습니다 |
| 다루지 않는 것 | 개별 벤더 SDK의 API 사용법, 드라이버 쪽 구현 |
이 글에서 쓰는 용어
영어로 그대로 나오는 말을, 먼저 한 줄씩 정리해 둡니다.
| 용어 | 한 줄 의미 |
|---|---|
| PLC | Programmable Logic Controller. 생산 설비 제어에 쓰는 산업용 컨트롤러 |
| firmware | 기기에 내장된 소프트웨어. 같은 모델이라도 개체마다 버전이 다를 수 있습니다 |
| heartbeat | 생존 확인을 위해 주기적으로 주고받는, 가벼운 문의나 알림 |
| poll / event | poll은 이쪽에서 주기적으로 문의하는 방식, event는 상대의 알림을 기다리는 방식 |
| stale | 값이 오래된 상태. 취득은 되고 있었지만, 지금 화면에 나온 값이 새롭다고는 말할 수 없는 상태 |
| freshness budget | 「이 시간을 넘으면 값을 새롭다고 보지 않는다」고 정해 두는 상한 |
| flapping | 상태가 짧은 시간에 오가는 것. 접촉 불량이나 순간 단절로 일어납니다 |
| reconcile | 맞춰 보고 내부 상태를 실태에 다시 맞추는 것 |
| interlock | 안전을 위해 동작을 멈추는 장치. 열려 있는 동안은 설비가 움직이지 않습니다 |
| PnP | Plug and Play. OS가 기기의 연결과 절단을 검출해 구성하는 메커니즘 |
| RTT | round-trip time. 문의를 낸 뒤 응답이 돌아올 때까지의 시간 |
1. 먼저 결론
외부 기기의 상태 확인과 표시에서 가장 효과가 큰 것은 상태를 하나의 boolean으로 뭉개지 않는 것입니다.
적어도 이 정도는 나누어 가지고 싶습니다.
- 존재: OS에서 보이는가
- 세션 확립: 이 앱이 open / login / initialize 완료인가
- 응답성: heartbeat나 status query에 응답하는가
- 기능 준비: 실제 조작을 지금 받을 수 있는가
- 데이터 신선도: 화면의 값은 새로운가
- 구성 일치: 대상으로 한 개체, 모델명, firmware인가
- 감시 건전성: 애초에 감시 처리가 살아 있는가
꽤 거칠게 말하면 이렇습니다.
존재 확인은 OS 쪽, 사용 가능 여부는 앱 쪽, 신선도 판정은 화면 쪽에서 가진다.
이 셋을 섞지 않는 것만으로도 상태 표시는 꽤 안정됩니다.
flowchart TB
subgraph OS["OS가 답할 수 있는 것"]
E["존재<br/>대상 interface가 보이는가"]
end
subgraph APP["앱만이 답할 수 있는 것"]
S["세션<br/>open / login / initialize 완료인가"]
R["응답성<br/>가벼운 문의에 기한 내에 돌아오는가"]
F["기능 준비<br/>지금 조작을 받을 수 있는가"]
C["구성 일치<br/>대상으로 한 개체·모델명·firmware인가"]
end
subgraph UIL["화면 쪽이 답하는 것"]
D["데이터 신선도<br/>표시 중인 값은 새로운가"]
W["감시 건전성<br/>감시 처리 자체가 살아 있는가"]
end
E --> S --> R --> F --> D
C -.->|"여기가 어긋나면<br/>나머지가 전부 성립해도 쓸 수 없다"| F
W -.->|"멈추면<br/>모든 판정이 오래된다"| D
그림2: 상태를 하나의 boolean으로 뭉개지 않고, 누가 답할 수 있는 질문인지로 나누어 가진다. 위 단이 성립해도 아래 단이 성립한다고는 할 수 없다
이 글의 지식 맵
외부 기기와 연동하는 Windows 앱에서는 존재·세션 확립·응답성·기능 준비·데이터 신선도·구성 일치·감시 건전성이라는 7개의 상태 축을 나누어 내부에 두는 것이 핵심입니다. 단일 「연결 중」 표시로 압축하면 operator가 다음 조치를 판단할 수 없게 되므로, UI는 요약·이유·상세의 3계층과 상태 더하기 이유 더하기 다음 행동이라는 문구로 보여 줍니다. 데이터 신선도는 wall clock이 아니라 단조 증가 타임스탬프와 freshness budget으로 판정하고, 기기 측 ValueTimestamp와의 시계 어긋남으로 인한 오판정을 피합니다. 감시 워커와 UI를 state store를 통해 분리하고, 화면 폐기 중의 BeginInvoke 호출이 감시 워커를 함께 쓰러뜨리지 않도록 Control.Disposed에 구독을 묶으며, 재연결에는 backoff를 붙이고, flapping은 확정 표시 전에 고릅니다.
flowchart LR
accTitle: 외부 기기의 상태 확인과 표시의 지식 맵
accDescr: 존재·세션 확립·응답성·기능 준비·데이터 신선도·구성 일치·감시 건전성이라는 7개의 상태 축을 나누어 둠으로써, 단일 「연결 중」 표시가 operator의 오판을 부르는 문제를 어떻게 막는지를 보여주는 그림
multi_axis_device_state_model["상태를 다축으로 두는 설계"]
connected_label_oversimplification["「연결 중」으로의 상태 집약"]
operator_misjudgment["operator가 다음 조치를 판단하지 못하는 상태"]
device_existence_state["존재(상태 축)"]
device_session_state["세션 확립(상태 축)"]
device_responsiveness_state["응답성(상태 축)"]
device_readiness_state["기능 준비(상태 축)"]
data_freshness_state["데이터 신선도(상태 축)"]
device_identity_match["구성 일치(상태 축)"]
monitoring_health_state["모니터링 건전성(상태 축)"]
three_tier_status_panel["요약·이유·상세의 3계층 패널"]
status_message_three_elements["상태+이유+다음 행동이라는 문구 구성"]
startup_enumeration["시작 시 열거"]
arrival_removal_notification["도착/제거 알림"]
heartbeat_polling["heartbeat 폴링"]
freshness_budget["freshness budget"]
monotonic_timestamp["단조 증가 타임스탬프"]
wall_clock_drift["월클럭 점프"]
freshness_misjudgment["최신성 판정 오류"]
value_timestamp_clock_skew["기기 측 ValueTimestamp와의 시각 오차"]
stale_data_live_masking["stale data를 live 값으로 보이게 하기"]
device_key_instability["개체 식별 불안정성"]
device_misidentification["개체 혼동"]
reconnect_backoff["백오프를 적용한 재연결"]
reconnect_storm["최단 루프 재접속에 의한 부하 집중"]
flapping_debounce["flapping 안정화"]
monitoring_worker_ui_separation["감시 워커와 UI의 분리"]
ui_monitoring_coupling["UI 스레드에서 감시 처리 직접 실행"]
ui_disposal_race["화면 폐기 중 BeginInvoke 예외에 의한 레이스"]
monitoring_worker_crash["감시 워커의 중지(연쇄)"]
control_lifetime_bound_subscription["Control.Disposed에 연동한 구독 해제"]
stable_device_key["변동이 적은 개체 식별 키"]
connected_label_oversimplification -->|"원인이 될 수 있다"| operator_misjudgment
multi_axis_device_state_model -->|"전제로 한다"| device_existence_state
multi_axis_device_state_model -->|"전제로 한다"| device_session_state
multi_axis_device_state_model -->|"전제로 한다"| device_responsiveness_state
multi_axis_device_state_model -->|"전제로 한다"| device_readiness_state
multi_axis_device_state_model -->|"전제로 한다"| data_freshness_state
multi_axis_device_state_model -->|"전제로 한다"| device_identity_match
multi_axis_device_state_model -->|"전제로 한다"| monitoring_health_state
multi_axis_device_state_model -->|"방지한다"| connected_label_oversimplification
three_tier_status_panel -->|"완화한다"| operator_misjudgment
status_message_three_elements -->|"완화한다"| operator_misjudgment
startup_enumeration -->|"보다 먼저 해야 한다"| arrival_removal_notification
device_existence_state -->|"에서 확인할 수 있다"| arrival_removal_notification
device_responsiveness_state -->|"에서 확인할 수 있다"| heartbeat_polling
data_freshness_state -->|"에서 확인할 수 있다"| freshness_budget
freshness_budget -->|"전제로 한다"| monotonic_timestamp
wall_clock_drift -->|"원인이 될 수 있다"| freshness_misjudgment
value_timestamp_clock_skew -->|"원인이 될 수 있다"| freshness_misjudgment
monotonic_timestamp -->|"방지한다"| freshness_misjudgment
stale_data_live_masking -->|"원인이 될 수 있다"| operator_misjudgment
device_key_instability -->|"원인이 될 수 있다"| device_misidentification
reconnect_backoff -->|"방지한다"| reconnect_storm
reconnect_storm -.->|"원인이 될 수 있다"| operator_misjudgment
flapping_debounce -.->|"완화한다"| operator_misjudgment
monitoring_worker_ui_separation -->|"방지한다"| ui_monitoring_coupling
ui_disposal_race -->|"원인이 될 수 있다"| monitoring_worker_crash
control_lifetime_bound_subscription -->|"완화한다"| monitoring_worker_crash
device_identity_match -->|"전제로 한다"| stable_device_key
stable_device_key -->|"방지한다"| device_misidentification
monitoring_health_state -->|"에서 확인할 수 있다"| monitoring_worker_ui_separation
monitoring_worker_ui_separation -->|"전제로 한다"| control_lifetime_bound_subscription
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 31건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 왜 「접속 중」이 위험한가
「접속 중」이라는 문구는, 하나의 문구로 여러 의미를 제멋대로 짊어지게 됩니다.
실제로는 적어도 다음 질문들이 섞여 있습니다.
- OS에서 대상 기기의 interface가 보이는가
- 이 앱이 그 기기를 open / login / initialize할 수 있었는가
- 가벼운 문의에 기한 내에 돌아오는가
- 지금 요구한 조작을 안전하게 실행할 수 있는가
- 화면에 나온 값은 새로운가
- 대상으로 한 개체·모델명·firmware인가
이 여섯 가운데 어느 것을 충족하는지에 따라 「쓸 수 있다」의 의미가 바뀝니다.
예를 들어 다음 넷은 전부 다릅니다.
- 미접속 애초에 OS가 대상 interface를 찾지 못했다
- 접속 완료 / 확인 중 물리적으로는 보이지만, 초기화나 인증이 끝나지 않았다
- 접속 완료 / 사용 불가 응답은 있지만, warming up, busy, interlock, media 없음 등으로 동작할 수 없다
- 값이 오래됨 이전에는 취득할 수 있었지만, 화면의 값이 freshness budget을 넘었다
이를 전부 「접속 중」으로 뭉개면, operator는 무엇을 해야 할지 판단할 수 없습니다.
flowchart TB
accTitle: 「접속 중」이 짊어지는 질문
accDescr: 접속 중이라는 하나의 문구는 OS에서 보이는지, 응답이 오는지, 조작할 수 있는지, 값이 새로운지, 대상으로 한 개체인지라는 여러 질문을 제멋대로 짊어지고, 전부를 뭉개면 operator는 무엇을 해야 할지 판단할 수 없다는 것을 보이는 그림.
b1["OS에서 보이는가"] --> b4["전부 「접속 중」으로 뭉개진다"]
b2["응답이 오는가·조작할 수 있는가"] --> b4
b3["값이 새로운가·대상으로 한 개체인가"] --> b4
b4 --> b5["operator는 다음 수를 판단할 수 없다"]
그림3: 「접속 중」 한 단어는, 답이 다른 여러 질문을 제멋대로 짊어집니다.
3. 먼저 나누어야 할 상태
추천은 내부 상태는 다축으로 가지고, UI에서는 필요에 따라 요약하는 것입니다.
3.1 내부에서 나누고 싶은 상태 축
| 축 | 무엇을 의미하는가 | 전형적인 확인 방법 | UI에서 보여 주고 싶은 예 |
|---|---|---|---|
| 존재 | OS에서 대상 interface가 보이는가 | 시작 시 열거, arrival / removal 알림 | 미접속 / 접속 완료 |
| 세션 | 이 앱이 open / login / initialize 완료인가 | handle / SDK 초기화 결과 | 확인 중 / 초기화 중 |
| 응답성 | status query나 heartbeat에 응답하는가 | timeout 있는 경량 문의 | 응답 있음 / 응답 지연 / 응답 없음 |
| 기능 준비 | 실제 조작이 지금 가능한가 | device-specific status | 사용 가능 / busy / warming up |
| 데이터 신선도 | 표시 값이 새로운가 | timestamp / sequence | 최신 / 값이 오래됨 |
| 구성 일치 | 대상 기기와 일치하는가 | model / serial / firmware / profile | 대상 기기 / 대상 외 기기 |
| 감시 건전성 | 앱의 감시 경로가 살아 있는가 | worker heartbeat / loop lag | 감시 중 / 감시 정지 |
여기서 중요한 것은 기기가 나쁜 상태와 앱이 관측하지 못한 상태를 나누는 것입니다.
3.2 UI는 전부를 평면적으로 보여 주지 않아도 된다
내부에서 다축으로 가지면 화면이 시끄러워질 것처럼 보입니다. 그러나 UI는 전부를 같은 무게로 낼 필요는 없습니다.
추천은 3층입니다.
- 상단에 요약 상태
- 그 아래에 이유
- 필요하면 상세 패널
예를 들어,
- 요약:
접속 완료 / 사용 불가 - 이유:
워밍업 중약 18초 남음 - 상세:
modelserialfirmwarelast heartbeatlast frame time
처럼 나누면, 정보량을 늘려도 꽤 읽기 쉬워집니다.
화면의 골격으로는 이런 배치입니다.
+-- 전공정 카메라 -------------------------------------------+
|
| [요약] ! 접속 완료 / 사용 불가
| [이유] 워밍업 중 - 약 18초 남음
|
| [상세] v 펼치기 (기본은 접힘)
| model ACME-CAM-2000
| serial A1B2C3
| firmware 2.4.1
| last heartbeat 10:23:41.512 (0.5초 전)
| last frame 10:23:41.402 (0.6초 전)
|
+-----------------------------------------------------------+
핵심은 위에서 아래로 갈수록 읽는 사람이 줄어들어도 된다는 점입니다. 요약은 누구나 1초에 읽고, 이유는 「왜 멈춰 있는가」를 알고 싶은 사람이 읽고, 상세는 원인을 가리는 사람만 엽니다. 이 전제로 배치하면 상세를 두껍게 해도 화면이 시끄러워지지 않습니다.
반대로 model이나 serial을 요약과 같은 크기로 항상 표시하면, 가장 중요한 한 줄이 묻힙니다.
flowchart TB
accTitle: 요약·이유·상세의 3층
accDescr: UI는 상단에 요약 상태, 그 아래에 이유, 필요하면 상세 패널의 3층으로 나누고, 위에서 아래로 갈수록 읽는 사람이 줄어들어도 된다는 전제로 배치하면 정보량을 늘려도 읽기 쉬워진다는 것을 보이는 그림.
c1["요약: 누구나 1초에 읽는다"] --> c2["이유: 왜 멈춰 있는지 알고 싶은 사람"]
c2 --> c3["상세: 원인을 가리는 사람만 연다"]
c3 -.-> c4["상세를 두껍게 해도 화면이 시끄러워지지 않는다"]
그림4: 아래 층일수록 읽는 사람이 줄어들어도 된다는 전제로 3층으로 나눈다.
4. 상태 확인의 베스트 프랙티스
4.1 시작 시 열거와 도착 / 삭제 알림
Windows에서 외부 기기를 다룰 때의 토대는 시작 시 기존 기기를 열거하고, 이후에는 arrival / removal 알림을 받는 것입니다.
특히 잡아 두고 싶은 것은 이 세 점입니다.
- 알림만으로는 기존 기기를 잡을 수 없다
- runtime communication에서는 setup class보다 interface class가 자연스럽다
- remove 알림과 I/O error의 보이는 순서가 앞뒤로 어긋날 수 있다
실무 규칙은 단순합니다.
- 시작 시 열거한다
- 알림을 구독한다
- 알림을 받으면 다시 열거해 내부 상태를 reconcile한다
flowchart TB
accTitle: 열거와 알림의 실무 규칙
accDescr: 알림만으로는 기존 기기를 잡을 수 없으므로, 시작 시 열거하고, 이후에는 도착과 삭제 알림을 구독하며, 알림을 받으면 다시 열거해 내부 상태를 reconcile한다는 실무 규칙을 보이는 그림.
d1["1. 시작 시 열거한다"] --> d2["2. 알림을 구독한다"]
d2 --> d3["3. 알림을 받으면 다시 열거해 reconcile"]
d1 -.-> d4["알림만으로는 기존 기기를 잡을 수 없다"]
그림5: 존재 파악은 열거와 알림과 reconcile의 3점 세트로 한다.
4.2 「존재」「열 수 있다」「응답한다」「쓸 수 있다」를 나눈다
외부 기기 사고는 여기를 한데 묶었을 때 늘어납니다.
- 존재한다 OS에서 interface가 보인다
- 열 수 있다 다른 프로세스 경합이나 권한 문제 없이 handle / session을 가질 수 있다
- 응답한다 가벼운 문의에 timeout 안에 돌아온다
- 쓸 수 있다 실제 조작을 받을 수 있다
이 넷은 같지 않습니다.
flowchart TB
accTitle: 존재·열 수 있다·응답·쓸 수 있다는 다르다
accDescr: OS에서 interface가 보이는 존재, 경합이나 권한 문제 없이 세션을 가질 수 있는 열 수 있다, 가벼운 문의에 timeout 안에 돌아오는 응답한다, 실제 조작을 받을 수 있는 쓸 수 있다의 넷은 같지 않다는 것을 보이는 그림.
e1["존재한다"] --> e2["열 수 있다"]
e2 --> e3["응답한다"]
e3 --> e4["쓸 수 있다"]
e1 -.-> e5["넷은 같지 않다"]
그림6: 「존재한다」에서 「쓸 수 있다」까지는 확인 단계가 넷 있다.
4.3 event와 poll을 섞는다
event 기반만, poll 기반만, 어느 한쪽으로 몰기보다 검출은 event, 건전성 확인은 poll이 실무에서 다루기 쉽습니다.
- arrival / removal은 event
- heartbeat / status query는 poll
- freshness 판정은 timestamp / sequence
이렇게 나누면 접속 검출과 실제 사용 가능 여부를 떼어 내기 쉬워집니다.
flowchart TB
accTitle: 검출은 event, 건전성은 poll
accDescr: 한쪽으로만 몰기보다, 도착과 삭제 검출은 이벤트로 받고, heartbeat나 status query에 의한 건전성 확인은 주기적 폴링으로 하며, 신선도 판정은 timestamp나 sequence로 하는 분담이 다루기 쉽다는 것을 보이는 그림.
f1["도착·삭제 검출"] --> f2["event"]
f3["heartbeat·status query"] --> f4["poll"]
f5["신선도 판정"] --> f6["timestamp / sequence"]
그림7: event와 poll은 경쟁시키지 않고, 목적마다 가려 쓴다.
4.4 감시 처리와 UI를 분리한다
UI thread에서 직접 open / read / status query를 돌리면, 표시의 사정과 감시 처리의 사정이 쉽게 섞입니다.
추천은,
- 감시 워커가 state store를 갱신
- UI는 state store를 구독해 그린다
- UI 조작은 command로 감시 층에 넘긴다
는 형태입니다.
이렇게 하면 감시 정지와 기기 정지를 나누어 다루기 쉬워집니다.
flowchart TB
accTitle: 감시와 UI는 state store로 나눈다
accDescr: 감시 워커가 state store를 갱신하고, UI는 state store를 구독해 그리며, UI 조작은 command로 감시 층에 넘기는 일방통행 형태로 하면, 감시 정지와 기기 정지를 나누어 다루기 쉬워진다는 것을 보이는 그림.
g1["감시 워커"] -->|"갱신한다"| g2["state store"]
g2 -->|"구독해 그린다"| g3["UI"]
g3 -.->|"조작은 command로 넘긴다"| g1
g2 -.-> g4["쓰는 것은 워커만의 일방통행"]
그림8: 감시와 UI는 state store를 끼워 분리하고, 일방통행으로 만든다.
골격을 C#으로 쓰면 다음 정도입니다(.NET 8 / C# 12 기준). 쓰는 것은 감시 워커만, UI는 읽고 그리기만, 이라는 일방통행이 요령입니다.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
public enum DeviceAvailability
{
Unknown, // 아직 한 번도 관측하지 못했다
Absent, // OS에서 interface가 보이지 않는다
Initializing, // open / login / initialize 도중
Ready, // 조작을 받을 수 있다
Unavailable, // 응답은 있지만 busy / warming up 등으로 쓸 수 없다
NotResponding, // heartbeat가 돌아오지 않는다
Mismatched, // 대상 외 개체 / firmware
}
// UI에 넘기는 불변 스냅샷. record로 만들어 값 등가성으로 차이를 판정한다
public sealed record DeviceSnapshot(
string DeviceKey, // serial number 등 흔들리지 않는 키
string DisplayName,
DeviceAvailability Availability,
string Reason, // 「워밍업 중」 등의 이유
DateTimeOffset? LastSuccessAt, // 마지막으로 관측에 성공한 시각
long Sequence, // 기기 쪽이 붙이는 연번
string FirmwareVersion);
public sealed class DeviceStateStore
{
private readonly ConcurrentDictionary<string, DeviceSnapshot> _snapshots = new();
public event Action<DeviceSnapshot>? Changed;
// 호출하는 것은 감시 워커만
public void Publish(DeviceSnapshot snapshot)
{
_snapshots.TryGetValue(snapshot.DeviceKey, out var previous);
_snapshots[snapshot.DeviceKey] = snapshot;
// 값이 바뀌었을 때만 알린다. poll마다 알리면 UI가 쓸데없이 다시 그려진다
if (previous != snapshot)
{
Changed?.Invoke(snapshot);
}
}
public IReadOnlyList<DeviceSnapshot> Current() => _snapshots.Values.ToList();
}
UI 쪽은 구독해서 UI 스레드로 되돌리는 곳까지를 한곳에 가둡니다.
using System;
using System.Windows.Forms;
public sealed class DeviceStatusPresenter
{
private readonly DeviceStateStore _store;
private readonly Control _uiContext; // UI 스레드로 되돌리기 위한 발판
private readonly Label _summary;
private readonly Label _reason;
public DeviceStatusPresenter(DeviceStateStore store, Control uiContext, Label summary, Label reason)
{
_store = store;
_uiContext = uiContext;
_summary = summary;
_reason = reason;
// 화면 수명에 구독을 묶는다. Dispose 호출을 잊으면 그대로
// 「닫은 폼에 갱신을 계속 던진다」로 변하는 것을 막는다
_uiContext.Disposed += (_, _) => Dispose();
_store.Changed += OnChanged; // 구독을 잊으면 갱신해도 화면이 바뀌지 않는다
}
public void Dispose() => _store.Changed -= OnChanged;
private void OnChanged(DeviceSnapshot snapshot)
{
// 감시 워커는 화면을 닫는 중에도 움직인다.
// 핸들이 깨진 뒤의 BeginInvoke는 예외를 던지고, 그 예외는
// Changed?.Invoke를 타고 감시 워커 쪽으로 빠져나간다.
// 「화면을 닫으니 감시가 멈췄다」는 깨짐이 된다
if (_uiContext.IsDisposed || _uiContext.Disposing || !_uiContext.IsHandleCreated)
{
return;
}
try
{
if (_uiContext.InvokeRequired)
{
_uiContext.BeginInvoke(() => Render(snapshot));
return;
}
Render(snapshot);
}
catch (ObjectDisposedException)
{
// 위의 판정을 통과한 뒤에 닫혔다. 이 틈은 원리적으로 지울 수 없으므로,
// 표시를 포기하는 것으로 받는다. 감시는 멈추지 않는다
}
catch (InvalidOperationException)
{
// 핸들이 아직 없거나 이미 Dispose됨. 동일
}
}
private void Render(DeviceSnapshot snapshot)
{
_summary.Text = snapshot.Availability switch
{
DeviceAvailability.Absent => "미접속",
DeviceAvailability.Initializing => "접속 완료 / 확인 중",
DeviceAvailability.Ready => "사용 가능",
DeviceAvailability.Unavailable => "접속 완료 / 사용 불가",
DeviceAvailability.NotResponding => "응답 없음",
DeviceAvailability.Mismatched => "대상 외 기기",
_ => "확인 중",
};
_reason.Text = snapshot.Reason;
}
}
화면을 닫는 순간이, 이 형태에서 가장 깨지기 쉬운 곳입니다. Changed?.Invoke(snapshot)는 감시 워커 스레드에서 동기적으로 핸들러를 호출합니다. 폼이 닫혀 컨트롤 핸들이 Dispose된 뒤에 BeginInvoke를 호출하면 예외가 되고, 그 예외는 Publish를 타고 감시 워커로 빠져나갑니다. 화면 뒷정리가 감시 자체를 떨어뜨리는 셈입니다. 게다가 증상은 「종료 시 가끔 죽는다」라서 재현 조건을 잡기 어렵습니다.
Dispose에서 구독만 빼는 것으로는 부족합니다. 빼는 도중에 워커가 Invoke를 시작했다면, 그 호출은 이미 막을 수 없기 때문입니다. 잡아야 할 곳은 셋입니다.
- 화면 수명에 구독을 묶는다.
Control.Disposed로 자동으로 구독을 빼고,Dispose호출을 잊어도 「닫은 폼에 계속 던진다」로 변하지 않게 합니다 - Dispose 중이거나 이미 Dispose된 컨트롤에는 던지지 않는다.
IsDisposed/Disposing/IsHandleCreated를 보고 그 자리에서 버립니다 - 그래도 남는 틈은 예외로 받는다. 판정과
BeginInvoke사이에 닫힐 가능성은 원리적으로 지울 수 없습니다.ObjectDisposedException과InvalidOperationException을 여기서 잡고, 표시만 포기하는 형태로 합니다. 잡지 않으면 감시가 연쇄로 내려갑니다
「닫을 때의 표시 갱신은 버려도 된다」고 정해 두는 것이 요점입니다. 그 한 번의 그리기에는 가치가 없지만, 감시 워커가 살아 있는 것에는 가치가 있습니다.
flowchart TB
accTitle: 화면을 닫는 순간의 연쇄
accDescr: Changed는 감시 스레드에서 동기적으로 핸들러를 호출하므로, 핸들 Dispose 후 BeginInvoke 예외가 Publish를 타고 감시 워커로 빠져 감시를 떨어뜨린다. 구독을 화면 수명에 묶고, 남는 틈은 예외를 잡아 표시만 포기한다는 것을 보이는 그림.
h1["화면을 닫는다"] --> h2["Dispose 후 BeginInvoke가 예외를 던진다"]
h2 --> h3["예외가 감시 워커로 빠져나간다"]
h3 --> h4["「화면을 닫으니 감시가 멈췄다」"]
h4 -.->|"막으려면"| h5["구독을 화면 수명에 묶는다"]
h5 --> h6["남는 틈은 잡고 표시만 포기한다"]
그림9: 닫는 순간의 한 번 그리기는 버리고, 감시 워커의 생존을 지킨다.
4.5 freshness는 「도착했는지」와 「내용이 진행했는지」를 나누어 판정한다
데이터 신선도 판정은 수신 시각만 보고는 부족합니다. SDK의 callback은 계속 오는데, 값의 timestamp나 sequence가 멈춰 있는, 그런 깨짐이 있기 때문입니다.
그래서 수신의 새로움과 내용의 새로움을 나눕니다.
using System;
public sealed record Reading(
long Sequence, // 기기 쪽이 붙이는 연번
DateTimeOffset ValueTimestamp, // 기기 쪽이 값에 붙인 시각
DateTimeOffset ReceivedAt, // 앱이 받은 시각. 화면에 내는 용
long ReceivedTicks); // 같은 수신의 단조 증가 타임스탬프. 판정용
public enum Freshness
{
Fresh,
Stale,
Unknown,
}
public static class FreshnessPolicy
{
// freshness budget: 이것을 넘으면 live 얼굴로 보여 주지 않는다
public static readonly TimeSpan Budget = TimeSpan.FromSeconds(5);
/// <param name="lastAdvancedTicks">연번이 마지막으로 진행한 때의, 단조 증가 타임스탬프</param>
/// <param name="nowTicks">판정 시점의 단조 증가 타임스탬프</param>
public static Freshness Evaluate(
Reading? previous, Reading? current,
long lastAdvancedTicks, long nowTicks, TimeProvider clock)
{
if (current is null)
{
return Freshness.Unknown; // 한 번도 얻지 못했다
}
if (clock.GetElapsedTime(current.ReceivedTicks, nowTicks) > Budget)
{
return Freshness.Stale; // 애초에 도착하지 않았다
}
if (previous is null)
{
return Freshness.Fresh; // 처음에는 비교 대상이 없으므로 수신 시각만으로 판단한다
}
if (current.Sequence < previous.Sequence)
{
// 연번이 되돌아갔다. 기기 재시작, 다른 개체로의 교체, SDK 재초기화를 의심한다
return Freshness.Unknown;
}
if (current.Sequence == previous.Sequence &&
clock.GetElapsedTime(lastAdvancedTicks, nowTicks) > Budget)
{
// 수신은 이어지는데 내용이 갱신되지 않았다
return Freshness.Stale;
}
return Freshness.Fresh;
}
}
lastAdvancedTicks는 다음과 같이 이쪽 시계로 가집니다.
public sealed class FreshnessTracker(TimeProvider clock)
{
private readonly object _gate = new();
private Reading? _previous;
private long _lastAdvancedTicks;
// 수신할 때마다 호출한다. ReceivedAt과 ReceivedTicks는 같은 수신의 기록
public Reading Capture(long sequence, DateTimeOffset valueTimestamp) =>
new(sequence, valueTimestamp, clock.GetLocalNow(), clock.GetTimestamp());
public Freshness Observe(Reading current)
{
lock (_gate)
{
if (_previous is null || current.Sequence > _previous.Sequence)
{
_lastAdvancedTicks = current.ReceivedTicks;
}
var result = FreshnessPolicy.Evaluate(
_previous, current, _lastAdvancedTicks, clock.GetTimestamp(), clock);
_previous = current;
return result;
}
}
// 수신이 완전히 끊기면 Observe는 다시 호출되지 않는다.
// 타이머에서 주기적으로 이것을 호출해, 마지막으로 받은 값의 「오래됨」을 다시 잰다.
// 상태는 갱신하지 않으므로, 몇 번 호출해도 안전하다
public Freshness Reevaluate()
{
lock (_gate)
{
return FreshnessPolicy.Evaluate(
_previous, _previous, _lastAdvancedTicks, clock.GetTimestamp(), clock);
}
}
}
Observe만으로는 기기가 침묵한 것을 검출할 수 없습니다. Observe가 호출되는 것은 수신했을 때뿐이고, 게다가 그때 넘겨지는 Reading은 방금 만든 것입니다. ReceivedTicks와 현재 시각의 차는 거의 제로이므로, 이 경로에서 Stale이 되는 것은 「수신은 이어지지만 연번이 진행하지 않는」 경우뿐입니다. SDK의 callback이 완전히 멈췄을 때 ── 가장 알고 싶은 깨짐 ── 에는 Observe가 호출되지 않고, 화면은 마지막으로 계산한 Fresh를 낸 채로 굳습니다.
그래서 수신과 독립된 주기로 다시 판정하는 입구를 마련합니다. 위의 Reevaluate가 그것이며, 타이머에서 호출합니다. 주기는 budget보다 짧게 합니다(budget이 5초면 1초마다, 정도). 같은 길이로 하면, 최악으로 budget의 2배 가까이 알아채지 못합니다.
flowchart TB
accTitle: Observe만으로는 침묵을 검출할 수 없다
accDescr: Observe가 호출되는 것은 수신했을 때뿐이므로, SDK의 callback이 완전히 멈추면 Observe는 호출되지 않고, 화면은 마지막으로 계산한 Fresh 채로 굳는다. 수신과 독립된 주기로 Reevaluate를 호출해 마지막 값의 오래됨을 다시 잰다는 것을 보이는 그림.
i1["callback이 완전히 멈춘다"] --> i2["Observe가 호출되지 않게 된다"]
i2 --> i3["화면은 마지막 Fresh 채로 굳는다"]
i3 -.->|"대책"| i4["타이머에서 Reevaluate를 호출한다"]
i4 --> i5["주기는 budget보다 짧게 한다"]
그림10: 가장 알고 싶은 「완전한 침묵」은, 수신 구동 판정으로는 보이지 않는다.
// System.Threading.Timer. 수신이 없어도 판정은 진행한다.
// RenderFreshness는 4.4의 presenter와 같은 경로로 화면에 반영하는 자체 메서드
_freshnessTimer = new Timer(
_ => RenderFreshness(_tracker.Reevaluate()),
null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
Observe와 Reevaluate가 다른 스레드에서 동시에 오므로, FreshnessTracker 내부는 lock으로 지킵니다. 여기를 빼면 _previous의 교체와 읽기가 섞여, 가끔 한 세대 오래된 판정이 나온다는 추적하기 어려운 장애가 됩니다.
여기서 ValueTimestamp와의 차를 취하지 않는 것이 요점입니다. ValueTimestamp는 기기 쪽 시계로 붙은 값이며, 이쪽 시계와 맞다는 보장이 없습니다. 둘을 빼면, 기기 시계가 늦어 있는 것만으로 방금 도착한 값이 stale이 되고, 반대로 앞서 있으면 멈춘 값이 언제까지나 fresh로 남습니다. budget은 반드시 자기 시계로 잰 경과 시간에 적용하십시오. ValueTimestamp는 「기기가 언제의 값이라고 하는지」를 화면에 내거나, 연번과 맞춰 기기 쪽 정지를 의심하는 재료로 쓰는 용도에 그칩니다.
그리고 그 「자기 시계」도 DateTimeOffset의 뺄셈으로는 부족합니다. 이쪽은 벽시계이며, NTP 동기나 수동 설정, 서머타임 전환으로 점프합니다. 시각이 되돌아가면 경과 시간이 음수가 되어, 기기가 끊겼는데도 Fresh로 남습니다. 반대로 앞서면, 방금 도착한 값이 그 순간에 Stale이 됩니다. 24시간 움직이는 화면일수록 밟습니다.
그래서 budget 판정에는 단조 증가 타임스탬프를 씁니다. TimeProvider.GetTimestamp()는 Stopwatch 기반의 고정밀도 값을 반환하고, GetElapsedTime(시작, 종료)로 두 점 사이의 경과 시간을 얻습니다(10장의 참고 자료 참조. .NET 8 이후). 벽시계의 ReceivedAt은 화면에 「10:15:03 수신」이라고 내기 위해서만 남기고, 「몇 초가 지났는가」 판정에는 손대지 않게 하는 분담이 안전합니다. 테스트 시계로 갈아 끼울 수 있는 것도 TimeProvider를 끼우는 이점입니다.
flowchart TB
accTitle: 신선도 판정은 단조 증가 시계로
accDescr: 벽시계는 NTP 동기나 수동 설정으로 점프하므로, 그 뺄셈으로 신선도를 재면 끊겼는데 Fresh로 남거나 방금 도착한 값이 Stale이 된다. budget 판정은 단조 증가 타임스탬프로 하고, 벽시계 수신 시각은 표시 전용으로 나눈다는 것을 보이는 그림.
j1["벽시계는 NTP나 수동 설정으로 점프한다"] --> j2["경과 시간이 음수나 과대가 된다"]
j2 --> j3["신선도 판정이 틀린다"]
j3 -.->|"그래서"| j4["판정은 단조 증가 타임스탬프로"]
j4 --> j5["벽시계 수신 시각은 표시 전용으로 나눈다"]
그림11: 「몇 초가 지났는가」에 벽시계를 쓰지 않는 것이, 신선도 판정의 토대가 된다.
이 형태로 두면, UI 쪽은 Freshness.Stale을 받은 시점에 「값 옆에 age를 낸다」「조작 가능 판정에서 뺀다」는 5.3의 방침을 그대로 적용할 수 있습니다.
Unknown을 Stale과 따로 가지는 것은 아직 모른다와 오래됐다를 섞지 않기 위해서입니다. 전자는 기다리면 해결될 수 있지만, 후자는 기다려도 고쳐지지 않습니다.
4.6 개체 식별을 안정시킨다
friendly name이나 COM3 같은 겉보기 식별자만으로 상태를 쫓으면, 개체를 혼동하기 쉬워집니다.
가능하면,
- serial number
- logical device id
- stable device path
- 기기 쪽 개체 ID
같은 흔들리지 않는 키를 내부에 가지는 편이 안전합니다.
5. 표시의 베스트 프랙티스
5.1 한 장으로 보는 판단표
| 실제 상태 | UI 요약 | 보충 표시 |
|---|---|---|
| interface 없음 | 미접속 | 케이블, 전원, USB 접속을 확인 |
| interface 있음, 초기화 중 | 접속 완료 / 확인 중 | 초기화 중, 인증 중, 워밍업 중 |
| 응답 있음, 조작 조건 미달 | 접속 완료 / 사용 불가 | busy, media 없음, interlock open |
| 응답 있음, 값이 오래됨 | 접속 완료 / 값이 오래됨 | 최종 갱신 12초 전 |
| 응답 없음 | 응답 없음 | 재접속 중, 통신 timeout |
| 대상 외 개체 | 대상 외 기기 | model / serial / firmware 불일치 |
| 감시 처리 정지 | 감시 이상 | 감시 워커 정지, 재시작 필요 |
5.2 문구는 「상태 + 이유 + 다음 행동」
에러나 이상만으로는 화면으로서 약합니다.
메시지는 다음 세 요소에 맞추는 편이 operator가 헤매기 어렵습니다.
- 상태: 무엇이 일어나고 있는가
- 이유: 왜 그렇게 판단했는가
- 다음 행동: 무엇을 하면 되는가
예를 들어,
접속 완료 / 사용 불가 - 워밍업 중 - 약 18초 기다려 주세요응답 없음 - heartbeat timeout - 케이블과 전원을 확인해 주세요대상 외 기기 - Firmware 2.1.0이 필요합니다 - 대상 기기를 확인해 주세요
같은 형태입니다.
반대로, 현장에서 자주 보는 문구와 나란히 놓으면 무엇이 부족한지가 분명해집니다.
| 흔한 NG 문구 | 부족한 것 | 고쳐 쓴 예 |
|---|---|---|
에러 |
상태도 이유도 다음 행동도 없다 | 응답 없음 - heartbeat timeout - 케이블과 전원을 확인해 주세요 |
접속 중 |
상태가 애매하다. 결국 지금 쓸 수 있는지가 모른다 | 접속 완료 / 사용 불가 - 워밍업 중 - 약 18초 기다려 주세요 |
디바이스를 찾을 수 없습니다 |
이유와 다음 행동이 없다 | 미접속 - 대상 interface가 열거되지 않았습니다 - 케이블과 전원을 확인해 주세요 |
0x80070005가 발생했습니다 |
사람이 읽을 상태와 행동이 없다 | 사용 불가 - 포트를 open할 수 없습니다. 0x80070005 액세스가 거부되었습니다 - 다른 앱이 같은 포트를 쓰고 있지 않은지 확인해 주세요 |
재시도하고 있습니다... |
언제까지, 몇 번, 다음은 어떻게 되는지가 모른다 | 재접속 중 3회째 / 최대 10회 - 다음 시도까지 8초 - 수동으로 재접속할 수도 있습니다 |
정상 |
어느 시점의 정상인지가 모른다 | 사용 가능 - 최종 갱신 0.5초 전 |
고쳐 쓴 예에 공통된 것은 operator가 그 화면만 보고 다음 수를 정할 수 있는가입니다. 「지원에 연락해 주세요」밖에 쓸 수 없는 상태라면, 그것은 문구가 아니라 상태 설계가 부족한 것입니다.
flowchart TB
accTitle: 문구는 상태+이유+다음 행동
accDescr: 에러나 이상만으로는 약하고, 무엇이 일어나고 있는가의 상태, 왜 그렇게 판단했는가의 이유, 무엇을 하면 되는가의 다음 행동 세 요소에 맞추면, operator가 그 화면만 보고 다음 수를 정할 수 있다는 것을 보이는 그림.
k1["상태: 무엇이 일어나고 있는가"] --> k4["operator가 헤매지 않는 문구"]
k2["이유: 왜 그렇게 판단했는가"] --> k4
k3["다음 행동: 무엇을 하면 되는가"] --> k4
k4 -.-> k5["화면만으로 다음 수를 정할 수 있는가"]
그림12: 메시지의 좋고 나쁨은, 세 요소가 갖춰져 있는지로 판정할 수 있다.
5.3 stale data를 숨기지 않는다
last known value는 도움이 됩니다. 다만 live value의 얼굴로 보여 주지 않는 편이 안전합니다.
추천은,
- 값 옆에 timestamp
- 값의 age 표시
- stale이 되면 색이나 레이블을 바꾼다
- 일정 시간을 넘으면 조작 가능 판정에서 뺀다
입니다.
5.4 중요도에 따라 보여 주는 장소를 바꾼다
status bar는 편리하지만 놓치기 쉽습니다. critical한 이상을 status bar 구석에만 두는 것은 피하고 싶습니다.
- 경미한 상태 변화: status bar
- 작업 계속이 가능한 주의: inline notice
- 조작 정지가 필요한 이상: 주 표시 영역, 대화상자, 배너
라는 구분이 솔직합니다.
5.5 여러 대 표시에서는 요약과 상세를 나눈다
여러 대 기기를 다루는 화면에서는, 전건 상세를 항상 내면 보기 어려워집니다.
- 상부에 전체 요약
- 하부에 기기별 행
- 선택 시 상세 창
의 3단으로 하면, 전체 파악과 개별 원인 분리를 함께 하기 쉽습니다.
화면 골격은 이렇게 됩니다.
+-- 장치 목록 -----------------------------------------------+
|
| [전체 요약] 사용 가능 6 / 8 주의 1 이상 1
|
| [기기별 행]
| 상태 표시명 이유 최종 갱신
| -------- --------------- ---------------- --------
| 사용 가능 전공정 카메라 - 0.5초 전
| 사용 가능 라벨 프린터 - 1.2초 전
| > 사용 불가 검사 카메라 워밍업 중 0.6초 전 <- 선택 중
| 응답 없음 바코드 리더 heartbeat timeout 48초 전
|
| [상세 창] 검사 카메라
| serial A1B2C3 / firmware 2.4.1 / 약 18초 남음
| [ 재접속 ] [ 로그 열기 ]
|
+------------------------------------------------------------+
이 3단 구성이면, 전체 요약만 보는 사람(오늘은 라인을 돌려도 되는가), 행을 보는 사람(어느 기기가 멈춰 있는가), 상세 창을 보는 사람(무엇을 하면 고쳐지는가)을 같은 화면에서 동시에 충족할 수 있습니다.
행 정렬은 이상을 위로 올리는 편이 다루기 쉽습니다. 다만 정렬이 매초 바뀌면 잘못 누르기가 생기므로, 정렬은 flapping을 평활화한 뒤의 확정 상태로 합니다(6.2 참조).
flowchart TB
accTitle: 3단 구성이 세 종류의 독자를 충족한다
accDescr: 여러 대 화면은 상부에 전체 요약, 하부에 기기별 행, 선택 시 상세 창의 3단으로 하면, 라인을 돌려도 되는가를 보는 사람, 어느 기기가 멈춰 있는가를 보는 사람, 무엇을 하면 고쳐지는가를 보는 사람을 같은 화면에서 동시에 충족할 수 있다는 것을 보이는 그림.
m1["전체 요약"] -.-> m2["오늘은 라인을 돌려도 되는가"]
m1 --> m3["기기별 행"]
m3 -.-> m4["어느 기기가 멈춰 있는가"]
m3 --> m5["상세 창"]
m5 -.-> m6["무엇을 하면 고쳐지는가"]
그림13: 여러 대 화면은, 독자가 다른 세 단으로 나누어 구성한다.
6. 재접속과 운용의 베스트 프랙티스
6.1 재접속은 backoff를 붙인다
응답이 멈췄을 때의 재접속을, 최단 루프로 계속 두드리지 않는 편이 안전합니다.
- device / driver / SDK에 부하를 준다
- 로그가 홍수가 된다
- 일시적인 불안정이 더 나빠진다
- UI가 심하게 흔들린다
때문입니다.
현실적인 것은,
- 첫 회는 바로 retry
- 안 되면 단계적으로 간격을 늘린다
- 상한을 둔다
- 수동
재접속도 준비한다
입니다.
상태와 전이 조건을 그림으로 그리면, backoff와 재접속 상한이 어디에 효력을 내는지가 보입니다.
stateDiagram-v2
[*] --> Unknown
Unknown --> Absent: 열거해도 찾을 수 없다
Unknown --> Present: 시작 시 열거 / 도착 알림
Absent --> Present: 도착 알림
Present --> Absent: 삭제 알림(어느 상태에서든)
state Present {
[*] --> Detected
Detected --> Opening: open / login / initialize
Opening --> Ready: 초기화와 구성 확인에 성공
Opening --> Fault: 초기화 실패(재시도 여지가 있다)
Opening --> Mismatch: 대상 외 개체·모델명·firmware
Ready --> Busy: warming up / 실행 중 / interlock
Busy --> Ready: 조작을 받을 수 있다
Busy --> Fault: I/O 오류
Ready --> Fault: I/O 오류 / 응답 없음이 확정
Fault --> Reconnecting: backoff를 둔다
Reconnecting --> Opening: 대기 시간이 경과
Reconnecting --> RetryExhausted: 재접속 상한에 도달
Fault --> Opening: 수동으로 재접속
RetryExhausted --> Opening: 수동으로 재접속(자동으로는 돌아가지 않는다)
Mismatch --> Opening: 구성을 고친 뒤 수동으로 재접속
}
그림14: 이 그림이 다루는 것은 접속과 세션의 수명뿐이며, 데이터 신선도는 넣지 않았다. 신선도는 기능 준비와 독립된 축(그림1)이며, Ready여도 Busy여도 마찬가지로 오래질 수 있으므로, 상태로 섞으면 「실행 중에 값이 끊겼다가, 복귀하니 Ready가 되어 있었다」 같은 혼동이 생긴다. 화면에서는 이 그림의 상태와 4.5절의 신선도 판정을 따로 가지고 조합한다. 재시도 상한에 도달해도 Absent(미접속)로 떨어뜨리지 않고, 자동으로는 돌아가지 않는 RetryExhausted에 머문다 ── 기기는 보인 채로 깨져 있는 경우가 있고, 미접속으로 표시하면 복구 조작을 잘못 이끈다. 구성 불일치도 재시도로는 고쳐지지 않으므로, 자동 재접속 고리에서 빼 두었다
6.2 flapping을 평활화한다
USB 접촉 불량이나 네트워크 순간 단절 같은 장면에서는, 상태가 짧은 시간에 오갑니다. 여기서 생 이벤트를 그대로 UI에 내면 꽤 보기 어렵습니다.
그래서,
- 내부 로그는 생 이벤트 그대로 남긴다
- UI는 짧은 확인 기간을 둔 뒤 확정 표시한다
- 다만 critical 이상은 바로 보여 준다
는 구분이 다루기 쉽습니다.
flowchart TB
accTitle: flapping은 평활화한 뒤 확정 표시
accDescr: 접촉 불량이나 순간 단절로 상태가 짧은 시간에 오가는 flapping에서는, 내부 로그는 생 이벤트 그대로 남기고, UI는 짧은 확인 기간을 둔 뒤 확정 표시하며, 다만 critical한 이상은 바로 보여 준다는 구분을 보이는 그림.
n1["상태가 짧은 시간에 오간다"] --> n2["생 이벤트는 로그에 그대로 남긴다"]
n1 --> n3["UI는 확인 기간을 두고 확정 표시"]
n3 -.-> n4["critical 이상만은 바로 보여 준다"]
그림15: 생 이벤트는 로그에, UI에는 평활화한 확정 상태를 낸다.
6.3 최소한 남겨야 할 로그
상태 표시 개선은 로그 설계와 거의 세트입니다.
| 항목 | 예 |
|---|---|
| timestamp | 2026-03-20T10:23:41.512+09:00 |
| stable device key | camera:A1B2C3 |
| 표시명 | 전공정 카메라 |
| 이전 상태 -> 새 상태 | Ready -> Stale |
| 이유 | heartbeat timeout firmware mismatch |
| 오류 코드 | HRESULT Win32 SDK code |
| last success | 2026-03-20T10:23:36.011+09:00 |
| age / RTT | 5.5s 320ms |
| retry count | 3 |
| app / firmware version | App 1.8.2 / FW 2.4.1 |
특히 중요한 것은 상태 전이 로그입니다.
6.4 감시 정지와 기기 정지를 혼동하지 않는다
- poll loop가 예외로 죽었다
- SDK callback이 멈췄다
- acquisition worker가 deadlock했다
- state store 갱신만 멈췄다
이럴 때 기기는 살아 있어도 앱은 관측하지 못합니다.
이 상태를 미접속이나 응답 없음만으로 내면, 기기 쪽 문제로 보입니다.
그래서 감시 경로의 건전성은 별도 축으로 가지는 편이 좋습니다.
flowchart TB
accTitle: 감시 정지를 기기 정지로 보여 주지 않는다
accDescr: poll loop의 사망이나 callback 정지에서는 기기가 살아 있어도 앱은 관측하지 못하며, 이를 미접속이나 응답 없음으로 내면 기기 쪽 문제로 보이므로, 감시 경로의 건전성은 별도 축으로 가진다는 것을 보이는 그림.
p1["poll loop가 예외로 죽는다"] --> p3["기기는 살아 있는데 관측할 수 없다"]
p2["callback이나 worker가 멈춘다"] --> p3
p3 --> p4["「미접속」으로 내면 기기 문제로 보인다"]
p4 -.->|"그래서"| p5["감시 건전성은 별도 축으로 가진다"]
그림16: 「관측하지 못했다」를 「기기가 나쁘다」와 혼동시키지 않는다.
7. 기기 유형별로 놓치기 쉬운 점
7.1 USB / PnP 기기
- 알림만으로는 existing device를 잡을 수 없다
- runtime에서는 setup class보다 interface class가 자연스럽다
- composite device는 여러 interface를 내는 경우가 있다
- remove 알림과 I/O error의 보이는 순서가 앞뒤로 어긋날 수 있다
7.2 시리얼 기기
COMx가 보인다고 안심할 수 없습니다.
- 포트 자체는 있지만, 대상 기기가 매달려 있지 않다
- 다른 프로세스가 open하고 있다
- 응답은 이미 멈춰 있다
- read / write가 timeout으로 굳는다
시리얼에서는 존재와 응답과 사용 가능을 특히 나누는 편이 안전합니다.
7.3 네트워크 기기
ping이 통과하는 것과 앱이 쓸 수 있는 것을 동일시하지 않는 편이 좋습니다.
- 이름 해석이 되는가
- TCP 접속이 되는가
- 애플리케이션 계층 handshake가 되는가
- status가 ready인가
- 값이 fresh인가
라는 단계가 있습니다.
flowchart TB
accTitle: ping이 통과하는 것과 쓸 수 있는 것의 단계
accDescr: 네트워크 기기에서는 이름 해석이 되는가, TCP 접속이 되는가, 애플리케이션 계층 handshake가 되는가, status가 ready인가, 값이 fresh인가라는 단계가 있으며, ping이 통과하는 것과 쓸 수 있는 것을 동일시하지 않는다는 것을 보이는 그림.
q1["이름 해석이 된다"] --> q2["TCP 접속이 된다"]
q2 --> q3["애플리케이션 계층 handshake가 된다"]
q3 --> q4["status가 ready"]
q4 --> q5["값이 fresh"]
q1 -.-> q6["ping이 통과한다=쓸 수 있다, 가 아니다"]
그림17: 네트워크 기기의 「쓸 수 있다」까지는 다섯 단계가 있다.
7.4 SDK 의존 카메라 / 계측 기기
SDK callback이 오고 있다는 것만으로 live라고 단정하지 않는 편이 안전합니다.
- callback thread 자체가 멈춘다
- frame은 오지만 timestamp가 진행하지 않는다
- image stream은 오지만 control channel이 죽어 있다
- reconnect 후 설정 재적용이 끝나지 않았다
이런 일이 생기므로, SDK 바깥에서 본 건전성도 가지고 있으면 안심입니다.
8. 해서는 안 되는 것
- 상태를
접속 중 / 미접속 / 에러셋으로 뭉갠다 - 알림만으로 existing device도 잡을 수 있다고 생각한다
open성공을 그대로사용 가능으로 본다- last known value를 fresh한 얼굴로 보여 준다
- timestamp를 표시하지 않는다
- UI thread에서 open / read / status query를 돌린다
- retry를 최단 루프로 돌린다
- critical한 이상을 status bar에만 낸다
미접속과감시 정지를 혼동한다- friendly name이나
COM3만으로 개체를 식별한다
9. 정리
외부 기기 연계 앱에서 정말로 중요한 것은, 무엇을 확인하면, 어디까지 말해도 되는가를 정하는 것입니다.
특히 이 나누는 방법이 효과가 있습니다.
존재한다 이 앱이 열 수 있다 응답하고 있다 지금 그 조작을 할 수 있다 화면의 값이 새롭다
이 다섯을 나눈다.
그 위에서의 실무 지침은, 대략 이 정도입니다.
- 시작 시는 열거, 이후에는 알림
- 사용 가능 여부는 heartbeat와 device-specific status로 정한다
- 표시 값에는 timestamp와 age를 붙인다
- critical한 이상은 놓치기 어려운 곳으로 낸다
- 감시 계통의 이상을 기기 이상으로 보여 주지 않는다
「접속 중」이라고 낼 수 있는 것보다, 그 표시가 현실과 얼마나 어긋나기 어려운가가 실무에서는 훨씬 중요합니다.
10. 참고 자료
- Microsoft Learn, TimeProvider Class(
GetTimestamp가Stopwatch기반의 고정밀도 값을 반환한다는 것,GetElapsedTime(Int64, Int64)로 두 점 사이 경과 시간을 얻을 수 있다는 것) - Microsoft Learn, CM_Register_Notification
- Microsoft Learn, Registering for Notification of Device Interface Arrival and Device Removal
- Microsoft Learn, Registering for Device Notification
- Microsoft Learn, Comparison of setup classes and interface classes
- Microsoft Learn, Device Information Sets
- Microsoft Learn, SetupDiEnumDeviceInterfaces
- Microsoft Learn, Communications functions
- Microsoft Learn, ClearCommError
- Microsoft Learn, COMMTIMEOUTS structure
- Microsoft Learn, WaitCommEvent
- Microsoft Learn, Monitoring Communications Events
- Microsoft Learn, Status Bars (Design basics)
- Microsoft Learn, UX checklist for desktop applications
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Windows 가상화의 심층(제3회) ── 수초 만에 시작되는 가상 머신: WSL2·Windows Sandbox·컨테이너가 가벼운 이유
WSL2와 Windows Sandbox가 수초 만에 시작되어 가벼운 이유는 무엇인가. 동적 베이스 이미지, 다이렉트 맵, 메모리의 동적 배분, Hyper-V 격리 컨테이너까지 구조부터 설명합니다.
Windows 가상화의 심층(제2회) ── 커널에서도 보이지 않는 메모리: VBS·HVCI·Credential Guard의 구조
대응 하드웨어에 클린 설치하면 기본으로 켜지는 VBS는, 하이퍼바이저와 SLAT로 커널보다 강한 격리를 만듭니다. VTL, 보안 커널, HVCI, Credential Guard의 구조를 설명합니다.
Windows 가상화의 심층(제1회) ── 지금 쓰는 Windows는 어디에서 실행되는가: 하이퍼바이저와 파티션
Hyper-V를 켜면 호스트 Windows 자체가 루트 파티션으로서 하이퍼바이저 위에서 실행됩니다. VT-x, SLAT, VMBus의 역할까지 가상화의 기반을 설명합니다.
Win32 스레드 풀 API ── CreateThreadpoolWork로 「스레드를 만들지 않는」 병렬 처리
네이티브 코드에서 CreateThread를 마구 늘리고 있지는 않은가요. Vista에서 개편된 Win32 스레드 풀 API의 work·timer·wait·io 네 객체, 클린업 그룹, 콜백에서 해서는 안 되는 일까지 1차 정보를 바탕으로 설명합니다.
Named Pipe 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows의 프로세스 간 통신의 정석인 Named Pipe를 실무 관점에서 해설합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 처리하는 서버 설계, ACL과 impersonation 보안, .NET NamedPipeStream까지 1차 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
외부 기기 연계 앱에서는 통신 처리뿐 아니라 상태 관리와 UI 표시의 정합이 운용 품질에 직결되므로, 설계 단계에서 정리해 두면 사고가 줄어듭니다.
기술 상담 & 설계 리뷰
『접속 중』만으로는 부족한 상태 설계는 검출, 응답 확인, 가용성, 데이터 신선도, 재접속 축을 나누어 리뷰하면 판단하기 쉬워집니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 기기 상태를 「접속 중」이라고만 표시하면 왜 부족한가요?
- 「접속 중」이라는 문구는 OS에서 보이는지, 이 앱이 open되어 있는지, 응답이 오는지, 지금 조작할 수 있는지, 화면의 값이 새로운지, 대상으로 한 개체인지라는 여러 질문을 하나로 뭉개 버리기 때문입니다. 예를 들어 「미접속」「접속 완료/확인 중」「접속 완료/사용 불가」「값이 오래됨」은 전부 다른 상태인데, 이를 전부 「접속 중」으로 뭉개면 operator는 무엇을 해야 할지 판단할 수 없습니다.
- 외부 기기의 상태는 내부에서 어떻게 나누어 가져야 하나요?
- 존재(OS에서 보이는지), 세션 확립(open/login/initialize 완료인지), 응답성(heartbeat에 응답하는지), 기능 준비(지금 조작을 받을 수 있는지), 데이터 신선도(화면의 값이 새로운지), 구성 일치(대상으로 한 개체·firmware인지), 감시 건전성(감시 처리 자체가 살아 있는지)을 나누어 가집니다. 거칠게 말하면 존재 확인은 OS 쪽, 사용 가능 여부는 앱 쪽, 신선도 판정은 화면 쪽에서 가진다는 분담입니다. UI는 요약·이유·상세의 3층으로 나누어 보여 주면 읽기 쉬워집니다.
- 기기 검출과 건전성 확인은 event와 poll 중 어느 쪽으로 해야 하나요?
- 한쪽으로만 몰기보다, 검출은 event, 건전성 확인은 poll이라는 분담이 실무에서 다루기 쉽습니다. 구체적으로는 arrival/removal은 이벤트 알림으로 받고, heartbeat나 status query는 주기적 폴링으로 하며, 신선도 판정은 timestamp나 sequence로 합니다. 다만 알림만으로는 기존 기기를 잡을 수 없으므로, 시작 시 열거하고, 이후에는 알림을 구독하며, 알림을 받으면 다시 열거해 내부 상태를 reconcile하는 것이 실무 규칙입니다.
- 응답이 멈춘 기기에 대한 재접속은 어떻게 구현해야 하나요?
- 최단 루프로 계속 두드리지 말고 backoff를 붙입니다. 첫 회는 바로 retry하고, 안 되면 단계적으로 간격을 늘리며, 상한을 두고, 수동 「재접속」 버튼도 준비하는 형태가 현실적입니다. 최단 루프로 돌리면 디바이스나 SDK에 부하를 주고, 로그가 홍수가 되며, 일시적인 불안정이 더 나빠집니다. 또한 USB 접촉 불량 등으로 상태가 짧은 시간에 오가는 flapping에는, UI 쪽에서 짧은 확인 기간을 둔 뒤 확정 표시하는 방법이 유효합니다.