CSMS 아키텍처 — 버전별 OCPP 서버, API 허브, 레거시 공존
CSMS 아키텍처 — 버전별 OCPP 서버, API 허브, 레거시 공존
요약: OCPP 1.6과 2.0.1 서버를 따로 두고, 관리 화면·충전기 HMI·PKI·배치를 API 허브 하나로 묶은 구조를 정리한다. 레거시 시스템을 한 번에 바꾸지 않고 옆에 세워 옮기면서 생긴 경합과 교훈도 함께 남긴다.
들어가며
내 계획은 "오래된 OCPP 1.6 서버를 새 스택으로 옮기고, 2.0.1과 PnC까지 되게 하라"였다. 기존 서버는 스크립트 언어로 만든 단일 프로세스였고, 2.0.1 핸들러는 대부분 빈 함수(pass)였다. 충전기 수천 대가 붙어 있는 상태라 멈추고 갈아엎을 수는 없었다.
이 글은 그 결과로 만들어진 구조와, 구조를 그렇게 잡은 이유를 정리한다. 특정 사업 로직(요금, 결제)은 일반화해서 적는다.
1. 전체 그림
[관리/테스트 화면 (Next.js 정적 배포)] [충전기 HMI (Windows, WPF)]
│ JWT │ OCPP-J (ws/wss)
▼ ▼
┌──────────── API 허브 ─────────────┐ ┌── OCPP 1.6 CSMS (보안 프로파일 2/3)
│ REST 게이트웨이 /gateway/ocpp16|21 │──────▶ ├── OCPP 1.6 CSMS (프로파일 1, TLS 없음)
│ WS 게이트웨이 /wsgateway/… │ └── OCPP 2.0.1 CSMS
│ PnC 게이트웨이 /gateway/pnc │──────▶ [PKI (운영기관 테스트 서버 / 자체 목 서버)]
│ 웹훅 수신, 루트 CA 배포, 펌웨어 배포 │◀────── 웹훅
│ 배치: 원격명령 분배, 인증서 만료, │
│ 이력 복구, 집계 │
└───────────────────────────────────┘
│ ▲
▼ │ (세션 없는 충전기 명령은 큐로)
[SQL Server] [Redis] ◀──── 공유 ────▶ [레거시 1.6 서버(점진 퇴역)]
서비스를 정리하면 다섯 종류다.
- 버전별 OCPP 서버: 1.6(프로파일 2/3), 1.6(프로파일 1), 2.0.1. 충전기의 WebSocket을 직접 받는다.
- API 허브: 모든 외부 요청의 단일 창구. 화면, HMI, PKI, 배치가 여기로 온다.
- PKI: PnC용 인증서 발급·검증. 운영기관 테스트 서버에 붙기 전에는 자체 목 서버를 썼다(8편).
- 관리 화면: 버전별 세션 모니터, 원격 명령, 시험 도구(9편).
- 충전기 HMI: 충전기 안에서 도는 Windows 앱. 충전기 쪽 OCPP 클라이언트를 품고 있다(10편).
2. OCPP 서버를 버전별로 나눈 이유
한 프로세스에서 서브프로토콜을 보고 분기하는 설계도 가능하다. 우리는 버전마다 배포 단위를 분리했다. 이유는 세 가지였다.
- 데이터 모델이 다르다. 1편에서 봤듯 2.0.1은 트랜잭션·EVSE·Device Model이 다르다. 한 코드베이스에 두면 거의 모든 핸들러에 버전 분기가 생긴다.
- 보안 프로파일이 포트·TLS 설정에 묶인다. 톰캣 포트 하나는 한 가지 TLS 설정(클라이언트 인증서 요구 여부, RSA/ECDSA 키)만 가진다. 프로파일 1(TLS 없음, Basic 인증) 충전기와 프로파일 3(mTLS) 충전기를 같이 받으려면 어차피 리스너가 나뉜다. 프로파일 1용 1.6 서버를 별도 jar로 빌드해 따로 서비스로 띄운 것도 이 때문이다.
- 배포 리스크를 나눈다. 2.0.1 쪽을 자주 고쳐도 매출이 나오는 1.6 쪽은 건드리지 않는다.
대가도 있었다. 공통 버그 수정을 두 번 해야 한다. 세션 관리, 핸드셰이크 인터셉터, 충전기 입력 정규화 같은 코드는 사실상 같은데 저장소가 달라서 "1.6에서 가져옴" 주석을 달고 손으로 옮겼다. 지금 다시 한다면 프로토콜과 무관한 부분(세션 레지스트리, 인증, 정규화, Redis 키 규칙)은 공유 라이브러리로 먼저 뽑겠다.
서버 내부 계층
두 버전 서버는 같은 뼈대를 쓴다.
socket/
├─ support/ 핸드셰이크 인터셉터, Basic 인증, 입력 정규화, Redis 키
├─ session/ 세션 레지스트리, WebSocket ping 스케줄러
├─ service/protocol/ 프레임 파서, 액션별 디코더, 응답 팩토리
├─ service/handler/ 액션별 핸들러 (수신 요청 1개 = 클래스 1개)
├─ service/process/ 업무 로직 (핸들러와 분리)
├─ outbound/ 서버→충전기 요청과 응답 매칭
└─ controller/ 운영 REST, 인증시험용 REST
핸들러와 업무 로직(process)을 분리한 것이 가장 잘한 결정이었다. 핸들러는 "이 액션을 받으면 디코드하고 process를 부르고 응답을 만든다"만 한다. 같은 업무 로직을 네이티브 메시지와 DataTransfer 양쪽에서 부를 수 있어서, 1.6의 DataTransfer PnC와 2.0.1의 네이티브 PnC가 같은 검증 코드를 쓴다.
3. API 허브: 단일 창구의 역할
3.1 게이트웨이
관리 화면은 정적 사이트라 브라우저에서 곧바로 API를 부른다. 브라우저가 OCPP 서버마다 주소와 인증을 따로 알면 관리가 안 된다. 그래서 API 허브가 경로 접두사로 라우팅한다.
/gateway/ocpp16/**,/gateway/ocpp21/**,/gateway/ocpp16-profile1/**→ 각 서버 REST로 그대로 전달/wsgateway/ocpp16/{id},/wsgateway/ocpp21/{id}→ 브라우저 WebSocket을 받아 서버로 wss 업스트림 세션을 열고 양방향 중계
WebSocket 중계가 필요한 이유는 브라우저가 WebSocket 핸드셰이크에 Authorization 헤더를 넣을 수 없기 때문이다. 브라우저는 쿼리 파라미터로 인증 정보를 넘기고, 허브가 그걸 Basic 헤더로 바꿔 서버에 붙는다. 사설 인증서를 쓰는 서버라면 허브가 전용 truststore를 들고 있으면 된다. 덕분에 브라우저가 가상 충전기가 되어 실제 서버에 붙는 시험 환경을 만들 수 있었다(9편).
3.2 원격 명령 라우팅
"이 충전기 충전 중지"라는 요청이 오면, 허브는 각 OCPP 서버에 세션 목록(GET /api/ocpp/sessions, 모든 서버가 같은 형식을 돌려주게 맞췄다)을 묻고, 실제로 그 충전기의 소켓을 가진 서버로 명령을 보낸다. 충전기가 어느 버전으로, 어느 서버에 붙어 있는지 호출하는 쪽은 몰라도 된다.
주의할 점이 두 가지 있었다.
- 첫 번째 서버에서 찾았다고 멈추지 않는다. 같은 ID가 두 서버에 동시에 잡히는 경우가 실제로 있었다(충전기가 설정 변경 후 재접속하는 몇 초 사이). 모든 서버를 조회하고 중복이면 경고 로그를 남긴다.
- 세션이 없으면 어떻게 할지 명확히 정한다. 우리는 레거시 서버가 폴링하는 명령 테이블에 적어 두는 방식으로 되돌렸다. 단, 이 폴백은 1.6 충전기에만 의미가 있다.
3.3 공유 상태
메시지 브로커는 두지 않았다. 서비스 간 공유는 SQL Server + Redis + REST로 충분했다.
- SQL Server: 충전 이력, 충전기 상태, 인증서 이력 같은 영속 데이터. 레거시와 같은 테이블을 쓰기 때문에 스키마를 함부로 못 바꾼다는 제약이 컸다.
- Redis: 진행 중 트랜잭션, 계량 스냅샷(
TRANSACTION:{id}:METER_VALUES:{ts}형태), 인증 세션, 토큰 캐시, 부팅 상태 캐시.
Redis를 쓰면서 배운 것 하나: "조회 실패"와 "값 없음"을 구분해야 한다. 일시적인 Redis 오류를 "세션 값 없음"으로 처리하는 바람에, 충전기별 설정을 못 읽고 기본값으로 조용히 넘어간 적이 있다. 지금은 마지막으로 성공한 값을 TTL 범위 안에서 로컬에 들고 있다가 오류 때 쓰고, 그 사실을 경고 로그로 남긴다(단일 인스턴스 기준의 임시방편이다).
3.4 배치는 허브에 모은다
- 원격 명령 큐 분배
- PnC: 신규 차량 계약 사전 등록, 충전기 인증서 만료 임박 시 재발급 트리거
- Redis에 남은 고아 트랜잭션을 이력으로 복구, DB 락으로 실패한 이력 재처리
- 일·월 집계(MERGE로 재실행 가능하게)
배치를 만들 때 지킨 원칙은 세 가지다.
- 모든 스케줄 작업 옆에 수동 실행 엔드포인트를 둔다. 장애 날 새벽 3시에 재실행할 방법이 있어야 한다.
- 프로토콜별 전송 플래그를 기본 꺼짐으로 두고 하나씩 켠다. "세션이 있다"와 "보내도 된다"를 분리한다. 자정 일괄 설정 배치는 1.6 → 2.0.1 순서로 한 버전씩 켰다.
- 부수 효과는 best-effort로. 웹훅 팬아웃, 알림 같은 부가 작업이 실패해도 본 흐름은 성공으로 끝난다. 대신 실패는 반드시 로그로 남기고, 결과를 로그에만 남기는 작업에는 알림을 붙인다.
4. 레거시를 옆에 두고 옮기기
한 번에 갈아끼우지 않고 새 서버를 옆에 세워 충전기를 조금씩 옮기는 방식(스트랭글러 패턴)을 택했다. 안전하지만 두 시스템이 같은 데이터를 만지는 구간에서 경합이 생긴다. 실제로 겪은 것들이다.
4.1 같은 큐를 두 소비자가 읽는다
레거시 서버는 명령 테이블을 5초마다 폴링하고, 새 분배 배치는 더 긴 주기로 같은 테이블을 읽었다. 둘이 같은 원격 중지 명령을 동시에 집어 가면 충전기는 StopTransaction을 두 번 보낸다. 이력 테이블의 유니크 인덱스가 두 번째 insert를 막아 데이터는 지켜졌지만, 로그에는 중복 키 오류가 쌓였다.
해결 방향은 "누가 처리할지"를 행 단위로 확정하는 것이다. 새 배치는 매 주기 전체 세션 스냅샷을 한 번 뜨고, 세션이 새 서버에 있는 충전기의 행만 워터마크 이후로 가져가 REST로 보낸다. 세션이 없으면 행을 건드리지 않고 레거시에 남겨 둔다.
4.2 폴링이 사라진 곳의 조용한 유실
새 서버는 명령을 REST로 받는다. 그런데 외부의 어떤 시스템이 아직 옛날 방식대로 명령 테이블에 행을 넣고 있다면, 그 명령은 오류도 로그도 없이 사라진다. 코드 버그가 아니라 전환 리스크다. 전환 전에 "그 테이블에 쓰는 주체"를 전부 찾아 목록으로 만들어 두자.
4.3 레거시 로직은 똑같이 옮기는 것부터
요금 계산을 옮기면서 새 코드가 옛 시스템보다 큰 금액을 낸 적이 있다. 원인은 셋이었다. 알고리즘을 "개선"해서 옮겼고, 조회 키가 달랐고, 구간 합 대신 끝값에서 시작값을 빼는 방식이어야 했다. 먼저 똑같이 옮겨 결과를 대조하고, 개선은 그다음에 한다. 추정 로직("idTag가 숫자로만 되어 있으면 법인 카드")보다 저장된 인증 유형을 우선해야 한다는 것도 이때 배웠다.
5. 배포와 운영 환경
- Spring Boot 3.x + Java 21, 톰캣 WebSocket, MyBatis, Redis(Lettuce), BouncyCastle.
- Jenkins 파이프라인으로 빌드 후 Windows 서버에 원격 배포, jar를 Windows 서비스로 실행. 프로파일(local/prod/prod-profile1)은 서비스 실행 인자로 고른다.
- TLS 종단: 운영에서는 앞단 리버스 프록시가 wss를 끝내고 서버는 평문으로 받는 구성과, 서버가 직접 mTLS를 받는 구성(프로파일 3)이 공존한다. 프로파일 3은 클라이언트 인증서를 서버가 직접 봐야 하므로 프록시에서 끝내면 안 된다.
- 톰캣 튜닝: WebSocket 텍스트 버퍼 128KB(인증서 체인이 담긴 메시지는 생각보다 크다), 비동기 전송 타임아웃, 최대 연결 수,
server.shutdown: graceful. 다만 graceful 설정만으로는 유휴 WebSocket이 정리되지 않는다(4편).
정리
- OCPP 서버는 버전·보안 프로파일 단위로 배포를 분리했다. 모델 차이와 TLS 리스너 제약 때문이다. 대신 공통 코드는 라이브러리로 뽑아 두지 않으면 수정을 두 번 하게 된다.
- API 허브가 게이트웨이(REST/WS), 원격 명령 라우팅, PnC 게이트웨이, 배치를 맡는다. 호출하는 쪽은 충전기가 어느 버전 서버에 붙었는지 몰라도 된다.
- 공유 상태는 DB + Redis + REST로 충분했다. Redis는 "실패"와 "없음"을 구분하자.
- 레거시와 공존하는 동안 큐 소유권을 행 단위로 확정하고, 옛 경로로 들어오는 입력이 없는지 전수 조사하고, 로직은 먼저 똑같이 옮긴 뒤 개선한다.
다음 글에서는 OCPP 서버 한 대의 내부, 즉 WebSocket 핸드셰이크부터 메시지 디코딩, 응답, 서버발 요청의 매칭까지를 다룬다.
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아직 댓글이 없습니다. 첫 댓글을 남겨보세요.