충전기 HMI를 WPF로 만들기 — 계층화, 정규화, 키오스크 UI, 무중단 업데이트
충전기 HMI를 WPF로 만들기 — 계층화, 정규화, 키오스크 UI, 무중단 업데이트
요약: 충전기 안에서 도는 Windows HMI를 WPF(.NET 8)로 만들며 쌓은 노하우를 정리한다. 커넥터별 독립 화면, 충전기 쪽 OCPP 클라이언트와 오프라인 저장 후 전송, 벤더·하드웨어 편차를 계층과 정규화로 흡수한 방법, 터치·키오스크 UI 팁, 워치독과 무중단 업데이트까지.
들어가며
충전기 앞에 선 사용자가 만지는 화면, 카드를 대고 결제하고 충전 진행률을 보는 그 화면이 HMI다. 우리 충전기의 HMI는 산업용 Windows PC에서 도는 WPF 앱이고, 동시에 충전기 쪽 OCPP 클라이언트이며, 제어 보드·계량기·RFID·결제 단말을 묶는 디바이스 허브다.
서버(CSMS) 개발과 병행하면서 HMI를 만들어 보니, OCPP를 반대편에서 보는 경험이 서버 설계에도 큰 도움이 됐다. 이 글은 구조, OCPP 클라이언트, 계층화·정규화, UI 노하우, 배포·업데이트 순으로 정리한다. WPF 기본 문법은 WPF 시리즈를 참고하자.
1. 전체 구조
솔루션
├─ hmi (WPF 앱) Views / ViewModels / Services / Core / Domain / AppLayer / Styles
├─ hmi.Core 공용 라이브러리
├─ hmi_Watchdog 프로세스 감시 (별도 exe)
├─ hmi_UpdateLauncher 설치 교체 담당 (별도 exe)
├─ hmi_WiX MSI 설치 패키지
└─ hmi_MSIX MSIX 패키지 (보조)
- .NET 8 + WPF, x64, CommunityToolkit.Mvvm(
[ObservableProperty],[RelayCommand]), MaterialDesign/MahApps 테마, NLog. - .NET Generic Host와 Microsoft DI로 서비스를 등록한다.
- COM 참조가 있어
dotnet build가 아니라 MSBuild로만 빌드된다. 이런 제약은 README 첫 줄에 적어 두자. 신규 인원이 첫날 반나절을 날린다. - 배포는 self-contained 단일 파일.
2. 커넥터마다 독립된 화면 흐름
충전기 한 대에 커넥터가 2~4개 있고, 각 커넥터에서 서로 다른 고객이 동시에 충전한다. 그래서 커넥터마다 Frame과 NavigationService를 따로 둔다.
services.AddSingleton<INavigationServiceA, NavigationServiceA>();
services.AddSingleton<INavigationServiceB, NavigationServiceB>();
if (settings.ConnectorCount >= 3) services.AddSingleton<INavigationServiceC, NavigationServiceC>();
if (settings.ConnectorCount >= 4) services.AddSingleton<INavigationServiceD, NavigationServiceD>();
ShellWindow는 커넥터 수만큼 Frame을 나눠 배치하고, 각 Frame은 자기 커넥터의 충전 흐름을 독립적으로 진행한다.
충전 흐름(화면 순서)
Splash → Home → (커넥터 선택) → 사용자 유형 선택 → 인증(RFID / QR / PnC)
→ 충전 금액 → 카드 결제 / 결제 승인 → 충전 요청 → 충전 대기 → 충전 중 → 충전 완료
분기: 충전 오류 / 사용 불가 / 비상 정지
충전 중 화면은 단순형, 상세형, 진행률형, 통계형 네 가지 레이아웃을 설정으로 고른다. 어떤 화면에서 어떤 StatusNotification이 나가는지(Available → Preparing → Charging → Finishing…)를 표로 만들어 두니 서버 쪽과 대화가 쉬워졌다.
3. 충전기 쪽 OCPP 클라이언트
3.1 연결
- WebSocket 클라이언트 라이브러리에 서브프로토콜
ocpp1.6을 지정한다. 2.0.1 클라이언트는 메시지 모델 일부만 준비된 상태다. - 보안 프로파일 1 이상은 Basic 헤더, 2 이상은 서버 인증서 검증 콜백(체인 검증 결과를 자세히 로그로), 3은 ECDSA 클라이언트 인증서.
- 사설 IP 서버라면 접속 전에 루트 인증서를 받아 OS 저장소에 설치한다(7편).
3.2 부팅 순서
1. WebSocket 연결
2. (자체 정책) 라이선스 확인 — 실패 시 연결 종료
3. BootNotification
4. Accepted:
- Heartbeat 주기 저장, 서버 시각으로 시간 동기화, Heartbeat 시작
- 펌웨어 업데이트가 끝난 직후라면 FirmwareStatusNotification
- StatusNotification(connector 0, 1..N)
- 오프라인 동안 쌓인 PendingData 재전송
5. 재접속 시: BootNotification 생략, Heartbeat·StatusNotification만 다시
2번처럼 BootNotification 전에 연결을 끊는 자체 정책을 넣으면, 서버 운영자 입장에서는 "접속하자마자 끊기는 충전기"로만 보인다. 이런 정책은 반드시 서버 팀과 공유하고, 끊을 때 로그와 화면에 이유를 남기자.
3.3 오프라인 저장 후 전송
충전기는 네트워크가 끊겨도 충전을 멈추면 안 된다.
- 모든 트랜잭션 메시지는 보내기 전에 로컬 SQLite(PendingData)에 먼저 기록한다.
- 오프라인이면 로컬 transactionId를 발급해 충전을 진행한다.
- 10초 타이머가 PendingData를 확인해 연결되면 순서대로 재전송하고, 응답을 받은 것만 지운다.
서버 쪽에서 "StopTransaction이 중복으로 온다", "옛 계량값이 새 트랜잭션에 붙는다"(5편)는 문제는 바로 이 구조에서 나온다. 양쪽을 다 만들어 보면 서버의 멱등 처리가 왜 필요한지 몸으로 안다.
3.4 서버 방언이 둘일 때
같은 1.6이라도 연동하는 CSMS에 따라 DataTransfer 메시지, 오프라인 이력 전송 방식(MeterValues냐 DataTransfer냐), 알람 보고 방식이 달랐다. IOcppService 인터페이스 아래에 서버 방언별 구현을 두고 DI에서 설정으로 고른다. 인터페이스를 보면 방언이 어디서 갈라지는지가 드러난다. 한쪽에만 있는 기능은 다른 쪽에서 명시적으로 스텁 처리한다.
4. 계층화: 뷰모델에서 정책을 꺼내라
초기 HMI는 뷰모델이 모든 걸 했다. 화면 전환, 결제 정책, 재시도, LED 색, 비상 정지 처리까지. 뷰모델 20여 개가 정적 서비스 로케이터(App.GetOcppService())에 붙어 있었다. 한 번에 갈아엎기엔 테스트가 없고 하드웨어 의존성이 컸다. 그래서 점진적으로 네 계층으로 나누고 있다.
| 계층 | 내용 |
|---|---|
| Presentation | View, ViewModel. 화면 상태와 바인딩만 |
| Application | 업무 정책: 비회원 결제 흐름, 자동 충전 인가 정책, 인증 후 화면 이동 정책, 충전 재시도 정책, LED 상태 표시 정책, 비상 정지 화면 정책 |
| Domain | 추상화: 충전기 프로파일(하드웨어 능력), CSMS 벤더 |
| Infrastructure | OCPP 클라이언트, 직렬 통신, 결제 단말, 계량기, RFID |
정책을 클래스 하나로 꺼내면 "비상 정지 중에는 어떤 화면으로 가는가" 같은 규칙이 뷰모델 여러 곳에 흩어지지 않고 한 곳에 모인다. 이 작업을 하며 지킨 원칙:
- 한 번에 하나씩, 동작을 바꾸지 않고 옮긴다.
- 비슷해 보여도 합치지 않는다. 커넥터 A용과 B용 제어 보드 서비스가 거의 같아 보였지만, A에만 비상 정지 감지와 디바운스 로직이 있었다. 하드웨어에서 빌드·시험하기 전에는 합치지 않기로 했다. 안전 관련 코드는 "중복 제거"보다 "확실한 동작"이 우선이다.
- 읽기만으로 검증한 리팩터링은 "미검증"으로 표시한다. 코드 리뷰로 옮긴 부분은 실제 빌드와 장비 시험이 끝날 때까지 체크리스트에 남겨 둔다.
5. 정규화: 문자열 비교를 없애고 선언형으로
5.1 벤더 분기
코드 곳곳에 if (Vendor == "xxx") 같은 문자열 비교가 30군데쯤 있었다. 오타 하나로 분기가 조용히 틀어진다. 레지스트리에서 한 번 파싱해 enum으로 바꿨다.
public enum CsmsVendor { Unknown, InHouse, ThirdParty, Octt }
var vendor = CsmsVendorRegistry.Parse(settings.CsmsVendor); // 한 곳에서만 문자열을 본다
5.2 하드웨어 변형은 프로파일 JSON으로
제어 보드 종류, 통신 방식, 기능(PLC 2채널, EVCC MAC 읽기, LED 상태), 오류 코드 매핑이 기종마다 다르다. 이걸 코드 분기 대신 능력 프로파일로 기술한다.
{
"id": "ctrlboard-dualplc",
"transport": "rs232",
"frameVersion": 2,
"slaveCount": 2,
"features": ["dualPlc", "evccMac", "ledStatus"],
"faultCodeMap": { "0x0001": "비상 정지 버튼 눌림", "0x0002": "누설 전류 감지" }
}
핵심은 현장에서 바꾸는 운영 설정(서버 주소, 충전 모드, 단가 표시)과 앱과 함께 배포되는 능력 명세(보드가 무엇을 할 수 있나)를 분리하는 것이다. 설정 화면에서 능력 명세를 건드릴 수 없게 한다.
5.3 디바이스 통신 계층
직렬 통신은 FrameBuilder/FrameParser/IFrameDecoder, CRC 계산기, 재연결 관리자로 나눴다. 제어 보드, RFID 리더, 기타 주변기기가 같은 틀 위에 각자 디코더만 가진다. 계량기는 별도 수집 프로세스가 공유 메모리(MemoryMappedFile)에 값을 쓰고 HMI가 읽는 구조다. 수집 프로세스가 죽어도 UI가 멈추지 않는다.
충전기 여러 대가 한 전력 용량을 나눠 쓰는 현장에서는 UDP 브로드캐스트로 충전기끼리 상태를 주고받아 순차/동시 충전 모드를 조율한다. 방화벽 규칙은 설치 시 HMI가 직접 추가한다.
6. 터치·키오스크 UI 노하우
6.1 키오스크 잠금 (릴리스 빌드, 시험 모드 제외)
- 창 테두리 없음(
WindowStyle="None"), 항상 위(Topmost) - Windows 키 비활성화, 작업 관리자 비활성화
- 셸 교체는 하지 않는다. OS 문제로 복구가 어려워진다는 경험 끝에 주석으로 남겨 두었다.
- 중복 실행은 Mutex로 막고, 이미 떠 있으면 최상위 경고창을 띄운다.
- 설정 화면은 보안 코드를 넣어야 열리고, 톱니 버튼 하나로만 진입한다. 숨겨진 제스처 진입로는 없앴다(현장에서 우연히 열린다).
6.2 터치 입력
- 80ms 디바운스 + 영역별 터치 잠금. 산업용 터치 패널은 유령 터치와 이중 탭이 잦다. 결제 버튼이 두 번 눌리면 사고다.
- UI 멈춤 감지기: 500ms 타이머로 UI·렌더·입력 지연을 재서 임계값(1초, 1초, 2초)을 넘으면 쿨다운을 두고 로그로 남긴다. "화면이 가끔 멈춘다"는 현장 제보를 재현하지 않고도 원인을 좁힐 수 있다.
- 애니메이션 관리자: 화면 전환이나 레이아웃 변경 후 Storyboard가 멈추는 WPF 특성 때문에, 커넥터별 애니메이션 레지스트리를 두고 필요할 때 전부 다시 시작한다.
6.3 소리와 밝기
- 음성 안내(충전 시작·완료, 카드 태깅, 연결 중, 케이블 거치, 비상 정지, 결제 성공·실패)를 enum으로 매핑한다. 야외 소음을 고려해 시스템 볼륨을 앱이 관리한다(시험 서버 모드에서는 제외).
- 밝기는 자동/수동/시간대 스케줄. 야간에 눈부신 화면은 민원이 된다.
6.4 스타일 규칙
설정 화면 20여 페이지를 만들며 정한 규칙이다.
- 폰트 패밀리 하나, 크기 다섯 단계(10/12/14/16/20)
- 여백은 4px 그리드
- 색은
Hmi*토큰만, 색 리터럴 금지 - 테마에 따라 바뀌는 리소스만
DynamicResource, 나머지는StaticResource(성능) - 디자인 시스템 리소스 딕셔너리(
DesignSystem.xaml, 폰트·여백 딕셔너리)를 분리하고 테마 서비스로 전환
부팅마다 앱이 새로 뜨는 장비라 콜드 스타트가 중요하다. ReadyToRun 게시, 워크스테이션 GC를 검토 중이다.
6.5 다국어는 처음부터
UI 문자열과 음성 안내가 한국어로만 되어 있다. 해외 사업이나 외국인 사용자를 생각하면 리소스 파일(resx) 분리를 처음부터 하는 게 싸다. 나중에 하면 화면 전체를 다시 훑어야 한다.
7. 워치독과 무중단 업데이트
7.1 워치독
별도의 작은 WPF 앱이 1초마다 HMI 프로세스를 확인한다. 없으면 실행하고, 두 개 이상이면 새로 뜬 쪽을 종료하고, 실행 이력을 남긴다. 무인 장비에서 "앱이 죽으면 다시 살아난다"는 보장은 필수다.
7.2 업데이트 흐름
앱이 자기 자신을 교체할 수 없으므로 별도 런처가 설치를 맡는다.
1. 자동 업데이트 서비스가 매니페스트(XML) 확인 → 새 버전 다운로드
2. SHA-256 검증 (너무 작은 파일은 거절)
3. "펌웨어 업데이트" 예약 작업 등록
4. 모든 커넥터가 충전 중이 아닐 때까지 2초마다 대기
5. FirmwareStatusNotification(Installing) → 런처 복사·실행 → HMI 종료
6. 런처: msiexec /i … /passive /norestart (관리자 권한) → 완료 플래그 기록 → HMI 재실행
7. HMI: 재시작 후 FirmwareStatusNotification으로 결과 보고
설계 포인트:
- 충전 중에는 절대 설치하지 않는다. 설치 시점만이 아니라 다운로드 확인 시점도 유휴일 때로 제한하는 개선을 진행 중이다.
- OCPP 상태 알림을 앱 업데이트에도 쓴다. 서버 관제에서 펌웨어 업데이트와 같은 방식으로 진행 상황이 보인다.
- MSI(WiX)는 per-machine 설치,
MajorUpgrade, 설정·DB 폴더 생성, 자동 시작 등록. 업그레이드마다 버전 번호를 올려야 MajorUpgrade가 동작한다(날짜.빌드 규칙). - 매니페스트 호스팅은 현장 장비에 비밀 토큰을 두지 않아도 되는 정적 호스팅(CI가 빌드)을 택했다. 현장 충전기는 보통 CSMS로만 나가는 망이라, 프록시가 필요할 수 있다는 점이 남은 과제다.
- 실행 파일 이름을 한 곳에서 관리하자. 설치 패키지, 런처, 워치독이 서로 다른 exe 이름을 가정하고 있던 것을 뒤늦게 발견했다.
- 다운로드한 설치 파일 정리, 필수 업데이트 건너뛰기 불가 같은 정책도 미리 정해 두자.
8. 시뮬레이터 모드와 시험
하드웨어 없이 개발하려고 시험 모드를 만들었다.
- 플래그를 켜면 설정·장치 상태를 JSON으로 덮어쓰고, 제어 보드·계량기·RFID 서비스를 시뮬레이터 구현으로 DI 교체한다.
- 계량기 수집 프로세스, 결제 단말, 60초 장치 점검은 건너뛴다.
- 서버 연결과 라이선스 호출은 실제로 한다. 서버와의 통합을 시험하는 게 목적이기 때문이다.
- 화면 상단에 빨간 TEST 바로 OCPP·라이선스 상태를 표시한다. 시험 모드로 현장에 나가는 사고를 막는다.
- "시험 서버" 플래그는 별개로 둬서 개발 CSMS 접속, 키보드 잠금 해제, 볼륨 강제 해제를 따로 켠다. 두 플래그 조합표를 문서로 둔다.
- 시뮬레이터 코드는 운영 코드와 폴더·클래스로 분리한다.
- 개발 루프는 파일 감시 스크립트로: XAML/CS 변경 → 1.8초 대기 → MSBuild → 성공하면 exe 재시작, 실패하면 이전 것 유지.
부끄러운 고백을 하나 하자면, 단위 테스트 프로젝트가 아직 없다. 대신 부팅 순서, 모든 OCPP 메시지와 그걸 보내는 뷰모델, 체크리스트, 문제 해결 표를 담은 시험 절차 문서를 만들어 수동 시험을 체계화했다. 계층화로 정책 클래스가 분리되면 그것부터 테스트를 붙일 계획이다.
9. 보안 체크리스트 (자기반성 포함)
- 인증 정보(Basic 헤더 디코딩 값)를 로그에 남기지 않는다.
- 클라이언트 인증서 PFX 비밀번호를 소스에 하드코딩하지 않는다. OS 인증서 저장소나 보호된 설정을 쓴다.
- API 키가 들어간 문서를 저장소에 두지 않는다.
- 시험용 카드 번호, 시험 서버 주소를 문서와 코드에서 분리한다.
정리
- HMI는 UI + 충전기 쪽 OCPP 클라이언트 + 디바이스 허브다. 커넥터마다 독립된 화면 흐름을 둔다.
- 트랜잭션은 먼저 로컬에 쓰고 나중에 보낸다. 서버의 멱등 처리와 짝을 이룬다.
- 뷰모델에서 정책을 Application 계층으로 꺼내고, 벤더 분기는 enum 레지스트리로, 하드웨어 변형은 능력 프로파일 JSON으로 정규화한다. 안전 코드는 서둘러 합치지 않는다.
- 키오스크 UI는 디바운스, 멈춤 감지, 애니메이션 재시작, 음성 안내, 스타일 토큰이 체감 품질을 만든다.
- 업데이트는 별도 런처 + 유휴 시점 설치 + OCPP 상태 보고로 무중단에 가깝게.
- 시뮬레이터 모드로 하드웨어 없이 서버와 통합 시험을 하고, 시험 모드가 현장에 나가지 않게 눈에 띄게 표시한다.
이것으로 OCPP 시리즈를 마친다. 규격 비교에서 시작해 서버 아키텍처, 메시지 파이프라인, 세션 운영, 트랜잭션, 인증 시험, 보안, PnC, 관리 화면, 충전기 HMI까지, 서버와 충전기 양쪽을 오가며 겪은 것을 최대한 함축해 담았다. 같은 길을 가는 분들이 한 번 덜 넘어지길 바란다.
OCPP 시리즈
- OCPP 한눈에 보기 — 1.6J, 2.0.1, 2.1은 무엇이 다른가
- CSMS 아키텍처 — 버전별 OCPP 서버, API 허브, 레거시 공존
- OCPP-J 메시지 파이프라인 구현 — 핸드셰이크부터 응답 매칭까지
- 충전기 수천 대의 WebSocket 세션 운영기 — 중복 접속, 배포 끊김, NAT, 좀비 세션
- 트랜잭션과 계량값의 함정 — 멱등 처리, 늦게 온 Stop, 충전기 편차 정규화
- OCTT 인증 통과기 — OCPP 1.6과 2.0.1 CSMS 적합성 시험에서 배운 것
- OCPP 보안 프로파일과 인증서 운영 — Basic 인증부터 mTLS, SignCertificate까지
- Plug & Charge 1차 필드 테스트 — ISO 15118 인증서 체계와 CSMS가 해야 할 일
- OCPP 버전별 세션 관리 화면이 필요한 이유 — 관제·원격 명령·시험을 한 콘솔에
- 충전기 HMI를 WPF로 만들기 — 계층화, 정규화, 키오스크 UI, 무중단 업데이트 (현재 글)
댓글
0아직 댓글이 없습니다. 첫 댓글을 남겨보세요.