트랜잭션과 계량값의 함정 — 멱등 처리, 늦게 온 Stop, 충전기 편차 정규화
트랜잭션과 계량값의 함정 — 멱등 처리, 늦게 온 Stop, 충전기 편차 정규화
요약: 충전 이력과 요금에 직결되는 트랜잭션·계량값 처리에서 겪은 문제를 정리한다. 1.6의 transactionId 발급, 중복·지연 StopTransaction, meterStop=0, 2.0.1 TransactionEvent의 seqNo와 경합, 계량값 해석 검증, 충전기별 입력 편차를 정규화 계층으로 흡수하는 방법까지.
들어가며
OCPP 메시지 대부분은 틀려도 다시 보내면 그만이다. 트랜잭션과 계량값은 다르다. 충전량과 요금, 정산이 여기서 나온다. 한 번 잘못 저장되면 고객 민원과 수작업 정정으로 이어진다.
이 글의 교훈을 한 줄로 줄이면 이렇다. 충전기는 같은 메시지를 여러 번, 늦게, 순서를 바꿔, 빈 값으로 보낸다. 서버는 그걸 전제로 설계해야 한다.
1. 1.6 transactionId: 서버가 발급하는 번호
1.6에서는 StartTransaction 응답으로 서버가 정수 transactionId를 준다. 충전기는 이후 MeterValues와 StopTransaction에 이 번호를 싣는다.
1.1 충전기별 카운터는 경합한다
레거시는 충전기별로 번호를 매기는 저장 프로시저를 썼고, 동시에 여러 StartTransaction이 오면 테이블 락 때문에 대기가 생기고 가끔 중복 번호가 나왔다. DB 전역 SEQUENCE로 바꿔 해결했다. 번호가 충전기별로 이어지지 않아도 문제없다. 규격은 유일성만 요구한다.
1.2 실패 시 상수로 대체하지 마라
코드 리뷰 중 발견한 것: 시퀀스 조회가 실패하면 고정값을 돌려주는 폴백이 있었다. 이러면 실패한 모든 트랜잭션이 같은 번호를 갖는다. 번호를 못 만들면 거절하는 편이 낫다. 1.6에서 거절은 idTagInfo.status로 표현하고, 이때 transactionId는 규격상 0 등 의미 없는 값을 넣어 보낸다.
2. StopTransaction: 멱등하게, 그리고 번호를 바꿔치기하지 마라
2.1 중복은 Accepted로 응답한다
4편에서 봤듯 충전기는 응답을 못 받으면 StopTransaction을 다시 보낸다. 처음에는 중복 insert 오류를 "처리 실패"로 보고 오류 응답을 줬다. 그러자 충전기는 영원히 재전송했다.
지금 규칙은 이렇다.
- 같은 트랜잭션의 이력이 이미 있거나(트랜잭션 ID로, 또는 카드+시작 시각으로), 중복 키 예외가 나면 Accepted로 응답한다.
- 순수한 중복은 실패 이력 테이블에도 쓰지 않는다. 실패 테이블은 사람이 봐야 할 것만 담는다.
2.2 늦게 온 Stop이 새 충전을 끝내는 사고
더 무서운 버그가 있었다. 레거시 로직은 StopTransaction의 transactionId가 커넥터의 현재 진행 트랜잭션과 다르면, 현재 진행 중인 번호로 바꿔서 종료 처리를 했다. "충전기가 번호를 잘못 보냈겠지"라는 선의의 보정이었다.
문제는 이런 순서에서 터진다.
- 충전 A가 끝나고 StopTransaction(A)을 보냈지만 네트워크가 끊겨 응답을 못 받았다.
- 다음 고객이 충전 B를 시작했다.
- 재접속 후 충전기가 StopTransaction(A)을 재전송했다.
- 서버는 A가 현재 트랜잭션(B)과 다르니 B로 바꿔서… 진행 중인 B를 종료 처리했다.
지금은 번호가 다르면 그 Stop을 무시(기록만)하고 Accepted로 응답한다. 입력 보정은 "무엇을 바꿀 수 있는가"를 좁게 정해야 한다. 식별자는 절대 바꾸지 않는다.
2.3 meterStop = 0
일부 충전기는 StopTransaction의 meterStop을 0으로 보낸다(계량기 통신 오류, 펌웨어 버그). 그대로 쓰면 충전량이 음수가 된다. 보정 순서를 정했다.
- 마지막으로 받은 MeterValues의 누적 전력량(
Energy.Active.Import.Register) - 없으면
meterStart(충전량 0으로 기록) - 그래도 없으면 그 충전기의 직전 이력 값
그리고 계산된 충전량이 음수이거나 비정상적으로 크면 이력은 남기되 실패 테이블에도 기록해 사람이 확인하게 한다.
3. MeterValues: 어느 트랜잭션의 값인가
3.1 transactionId 없는 계량값
MeterValues에 transactionId가 빠져 오면 커넥터의 현재 트랜잭션으로 추정한다. 그런데 충전기가 오프라인 동안 쌓아 둔 예전 계량값을 재접속 후 한꺼번에 보내면, 그 값이 새로 시작한 트랜잭션에 붙어 버린다.
그래서 샘플의 타임스탬프가 현재 트랜잭션 시작 시각보다 이르면(오차 5초 허용) 그 묶음은 건너뛴다.
3.2 계량값을 어디에 두는가
- 실시간 표시용 스냅샷은 Redis(트랜잭션 키 아래, TTL 30일)
- 스냅샷 테이블 갱신(DB 트리거가 이력을 쌓는다)
- 시간대별 에너지 누적(요금 구간 계산용)
시간대별 누적을 인메모리 맵에 두면 서버를 여러 대로 늘릴 때 깨진다. 처음부터 Redis나 DB에 두자.
3.3 해석이 맞는지 테스트하라
3편에서도 언급했지만 가장 중요한 교훈이라 다시 적는다. 계량값 한 샘플에는 value 말고도 measurand(Energy, Power, SoC, Voltage…), unit(Wh/kWh), phase, context, location이 있다. 디코더가 value만 옮기면:
- 에너지 샘플을 못 찾아 meterStart/meterStop이 0이 되고,
- 모든 샘플이 기본 measurand(에너지)로 취급돼 SoC·전압 값이 에너지를 덮어쓰고,
- kWh로 온 값이 Wh로 저장된다.
그래도 서버는 정상 응답하고, 인증 시험도 통과한다. 시험은 응답을 볼 뿐이다. 실제 충전기 로그에서 뽑은 페이로드로 "입력 → 저장된 값" 테스트를 만들고, measurand 이름 표기(Energy.Active.Import.Register vs enum 상수 이름)가 코드 전체에서 한 가지로 통일돼 있는지도 확인하자.
4. 2.0.1 TransactionEvent: 모델이 바뀌면 함정도 바뀐다
4.1 seqNo로 중복을 거른다
TransactionEvent에는 트랜잭션 안에서 증가하는 seqNo가 있다. (충전기, transactionId)별 마지막 seqNo를 기억하고, 그보다 크지 않은 이벤트는 버린다. 단 트랜잭션 시작의 seqNo=0은 정상이다(시험 항목). "0이면 무효" 같은 조건을 넣지 말자.
4.2 Started보다 Ended가 먼저 처리되는 경합
충전이 아주 짧거나 네트워크가 몰리면, Ended가 처리될 때 Started가 아직 내부 매핑(충전기가 만든 UUID ↔ 내부 정수 ID)을 등록하지 못한 경우가 생긴다. 짧은 재시도(300ms × 3회)와, 그래도 없으면 커넥터 기준 스냅샷에서 찾는 폴백을 두었다.
4.3 트랜잭션 시작 ≠ 인증
케이블을 먼저 꽂으면 idToken 없이 Started가 온다. 응답은 {}면 된다. 인증은 이후 Updated(triggerReason=Authorized)에 idToken과 함께 온다. 1.6 사고방식으로 "Started = 인증된 충전 시작"이라고 저장하면 인증 없는 레코드가 쌓인다.
4.4 원격 시작과 매칭하기
서버가 RequestStartTransaction을 보낼 때 remoteStartId를 넣으면, 충전기는 TransactionEvent에 그 값을 실어 보낸다. 이걸로 "어느 원격 요청이 어느 트랜잭션이 됐는지"를 잇는다. 1.6에서는 이 연결 고리가 없어서 커넥터와 시각으로 추정해야 했다.
4.5 필드를 매번 보내지 않는 충전기
reservationId, remoteStartId, stoppedReason 같은 값을 Started에만 싣고 이후 이벤트에는 빼는 충전기가 있다. 트랜잭션별 메타 저장소에 한 번 받은 값을 보관해 두고 Ended 처리 때 채운다.
4.6 Ended 응답의 totalCost
비용 표시 기능을 지원한다고 선언하면 Ended 응답에 totalCost가 있어야 한다(시험 항목). 요금 계산이 아직 없더라도 필드는 채워 보낸다.
5. 결제 결과와 트랜잭션 시작의 순서
사업 로직이 DataTransfer로 오가는 구조라면, 표준 메시지와 DataTransfer 사이의 순서는 보장되지 않는다. 예를 들어 "결제 완료" DataTransfer와 StartTransaction이 어느 쪽이 먼저 올지 모른다.
- StartTransaction에서 발급한 transactionId를 (충전기, 커넥터) 키로 Redis에 3분간 캐시해, 늦게 온 결제 결과가 찾아 붙을 수 있게 했다.
- 취소 요청이 결제 결과보다 먼저 오면 짧게 몇 번 재시도한다.
일반화하면: 같은 충전 세션에 속하는 메시지가 여러 경로로 온다면, 먼저 온 쪽이 흔적을 남기고 나중 온 쪽이 찾아 붙는 구조로 만든다.
6. 충전기 편차를 정규화 계층으로 모으기
현장 충전기의 규격 해석 차이는 끝이 없다. 처음에는 필요한 곳마다 if로 처리했는데, 곧 어디서 무엇을 보정하는지 아무도 모르게 됐다. 그래서 **입력 정규화기(Normalizer)**를 별도 계층으로 모았다.
| 정규화기 | 처리하는 편차 |
|---|---|
| 채널 번호 정규화 | 커넥터가 하나뿐인 특정 모델 계열이 connectorId=2를 보냄 → 1로 강제 |
| idTag 정규화 | 특정 펌웨어가 카드 번호 앞에 접두 문자를 붙임 → 제거 (길이 조건 포함) |
| 부팅 필드 보정 | 제조사·모델명이 빈 문자열 → "-"로 채워 부팅 허용 |
| StartTransaction 관대화 | 필수인 idTag가 빠짐 → 선택으로 받고 경고 |
| PEM/CSR 정리 | 줄바꿈이 CRLF로 섞이거나, BEGIN CERTIFICATE REQUEST 헤더의 공백이 빠짐 → 정규식으로 정리 |
| EVSE/커넥터 평탄화 | 2.0.1 EVSE+커넥터 → 내부 단일 채널 번호 (규칙 문서화 필수) |
정규화 계층을 만들 때 지킨 원칙:
- 디코더 바로 뒤, 업무 로직 앞에 둔다. 업무 로직은 항상 정규화된 값만 본다.
- 보정할 때마다 로그를 남긴다. 조용한 보정은 나중에 원인 추적을 막는다.
- 식별자와 금액은 보정하지 않는다. 2.2의 사고가 그 이유다. 형식(대소문자, 공백, 접두사)만 다듬는다.
- 조건을 좁게. 접두사 제거 같은 규칙은 특정 길이·형식일 때만 적용한다. 해외 계약 ID처럼 우연히 같은 문자로 시작하는 정상 값을 망가뜨릴 수 있다.
- 정규화기마다 단위 테스트. 현장 페이로드를 그대로 테스트 케이스로 쓴다.
정리
- transactionId는 전역 시퀀스로, 실패 시 상수 폴백 금지.
- StopTransaction은 멱등하게. 중복은 Accepted, 번호가 다르면 무시. 식별자는 바꿔치기하지 않는다.
- meterStop=0은 마지막 계량값 → meterStart → 직전 이력 순으로 보정하고 이상치는 사람에게 알린다.
- 오프라인 동안 쌓인 옛 계량값이 새 트랜잭션에 붙지 않게 시각으로 거른다.
- 2.0.1은 seqNo로 중복을 거르고, Started/Ended 경합과 "시작 ≠ 인증"을 기억한다.
- 계량값은 해석 결과를 테스트한다. 인증 시험 통과가 데이터 정확성을 보장하지 않는다.
- 충전기 편차는 정규화 계층에 모으고, 형식만 다듬고, 항상 로그를 남긴다.
다음 글에서는 규격 해석이 맞는지 가장 확실하게 검증해 주는 OCTT 인증 시험, 1.6과 2.0.1을 통과하며 배운 것을 정리한다.
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아직 댓글이 없습니다. 첫 댓글을 남겨보세요.