작성일
VisitCall 개발기 6 — 운영 중 장애와 교훈
노원 단지에서 1.x를 운영하며 겪은 입차 매칭 실패, 시각 형식, 자정 배치, 알림 오발송, 이중 게이트, 그리고 Change Tracking 버전을 문자열로 비교해 감지가 멈춘 사건까지(2025년 11월~2026년 8월).
VisitCall 개발기 시리즈 6/8편. 전체 목록은 visitcall 태그에서 볼 수 있다.
출시 뒤 노원 단지의 1.x 운영은 거의 현장 로그를 읽고 핫픽스를 내는 일이었다. 2025년 11월부터 2026년 3월까지 서버 커밋은 대부분 한두 가지 증상을 고치는 변경이고, 2026년 8월에는 입출차 감지가 멈추는 일이 한 번 더 있었다. 그중 일곱 가지를 시간순으로 적는다. 차량번호와 값은 모두 예시용 가짜다.
11월 첫 주: 종료일이 비면 매칭이 안 되고, 시각이 9시간 어긋났다
종료일이 없는 예약 (2025-11-04).
입차 신호가 오면 서버는 차량번호와 입차 시각으로 예약을 찾는다. 조건은 방문 시작일이 입차 시각 이전이고 방문 종료일이 입차 시각 이후인 예약이었다. 하루만 방문하는 예약은 종료일이 비어 있다(NULL). NULL과 비교하면 결과가 참이 되지 않으므로, 종료일이 없는 예약은 입차해도 찾아지지 않았다. 당시 커밋에 이 사례를 적어 두었다.
예약: 차량 12가3456, 방문 시작 2025-11-04 00:00, 방문 종료 NULL
입차: 2025-11-04 16:22:01
결과: 레코드를 찾을 수 없음 (NULL >= 16:22:01 은 참이 아님)
고친 방법은 조건에 종료일이 NULL이면 시작일 당일만 유효를 추가하는 것이었다. 10월에 방문 기간 입력이 들어오면서 종료일이 있는 예약과 없는 예약이 함께 존재하게 된 상태에서 드러난 문제다.
시각 응답 (2025-11-07). 예약 조회 API가 날짜를 DB에서 꺼낸 그대로 JSON으로 직렬화해서 UTC ISO 문자열이 내려갔다. 한국 시간 11월 6일 0시가 "11월 5일 15시(UTC)"로 표현되는 식이라 9시간이 어긋난다. 응답 시각을 한국 시간 문자열로 통일했다.
// 변경 전
{ "visit_date_from": "2025-11-05T15:00:00.000Z", "visit_date_to": null }
// 변경 후
{ "visit_date_from": "2025-11-06 00:00:00", "visit_date_to": null }
같은 달에 "이전 커밋 KST 시간대 적용" 커밋이 나올 만큼 비슷한 손질이 이어졌다. 서버의 로컬 시간이 한국 시간이라는 가정 위에서 필요한 곳마다 변환을 끼워 넣고 있었다.
엿새 동안 중복 등록과 출차 매칭을 열 번 넘게 고쳤다 (2025-11-12~17)
폴링 서버에서는 11월 7일에 출차 처리 순서를 바꿨다. 출차 변경을 감지하면 WebSocket 알림과 메인 서버 API 전송을 항상 하고, 이력 행 갱신은 마지막에 하되 실패해도 무시한다. 이력 갱신이 실패하면 알림까지 막히던 것을 풀었다. 11월 12일에는 입차 게이트 필터도 넣었다. 지정한 게이트의 입차만 알림 대상으로 삼는다.
11월 12일부터 17일까지 메인 서버의 방문 등록과 출차 쪽 커밋은 열 개를 넘었다. 정리된 규칙은 다음과 같다.
| 항목 | 정리된 규칙 |
|---|---|
| 중복 등록 | 같은 날짜, 같은 차량번호만 중복으로 본다. 입차·출차 여부는 따지지 않는다 |
| 업체 DB 409 | 업체 쪽이 이미 등록된 번호로 거절하면 입주민 차량으로 보고 예약을 지운다. 응답 원인을 구분해 앱에 알린다 |
| 등록 구분 | 앱에서 신규든 수정이든 시스템이든 업체에는 모두 같은 앱 등록 코드로 보낸다 |
| 출차 매칭 | 같은 차량이고 입차했고 아직 출차 안 한 행. 방문 시작일이 출차일 이전. 가장 최근 방문일 우선. 미래 예약은 출차 금지 |
| 당일/미래 | 당일 예약은 바로 업체에 보내고, 미래 예약은 배치로 보낸다 |
특히 11월 13일에는 "미래 예약 건에 출차 시간이 기록되는 현상"을 고쳤다. 아직 오지 않은 방문 예약에 출차 시각이 붙는 오염이었고, 위 표의 "미래 예약은 출차 금지" 규칙이 그 방어선이다. 출차 신호를 예약과 맞추는 규칙은 이때 정리했고, 2.0에서는 업체의 입차 ID를 첫 번째 키로 쓴다.
자정 배치는 한 번 실패하면 하루가 빈다 (2025-12-20, 2026-01-13)
미래 날짜로 등록된 예약은 자정에 업체 DB로 보낸다. 하루 한 번 도는 배치라 실패하면 미리 등록해 둔 그날의 방문 차량이 차단기에서 인식되지 않는다. 12월 20일에 0시 5분에 같은 작업을 한 번 더 도는 백업 스케줄을 넣었다. 마지막 실행 날짜를 메모리 변수에 두어 이미 성공했다면 건너뛴다.
1월 13일에는 이 배치를 손봤다. 고친 내용은 이렇다.
| 항목 | 이전 | 이후 |
|---|---|---|
| HTTP 요청 | 타임아웃 없음, 무한 대기 가능 | 10초 타임아웃 |
| 업체 전송 | 예약 N건을 순차 처리 | 병렬 처리(Promise.allSettled) |
| 세대 조회 | 예약마다 1번(N+1) | 일괄 1번 |
| 시각 | 0시 00분 메인, 05분 백업 | 0시 03분 메인, 08분 백업 (다른 자정 작업과 충돌 방지) |
관리자 웹에도 자정 작업이 있었다. 허용 주차시간은 매월 1일 0시 5분(한국 시간)에 전월 값을 백업하고 새 달로 이월한다. 12월 20일 Node 스케줄러로 만들었는데, 같은 날 PM2가 Windows에서 npx를 바로 실행하지 못해 설정을 한 번 더 고쳤다. 1월 5일에는 12월 데이터가 두 번 보이는 버그가 있었다. 원인은 월별 허용시간 테이블에 같은 세대의 행이 여러 개일 때 GROUP BY 없이 조인한 것이었고, 목록 항목의 React key가 겹쳐서 정렬할 때마다 행이 늘어나 보이는 증상도 같이 있었다.
알림이 다른 세대로 갔다 (2025-12-23)
2편에서 짚은 약점이 실제로 터졌다. 같은 차량번호를 여러 세대가 예약해 둔 경우, 알림 서비스가 차량번호만으로 가장 최근 방문 기록을 다시 조회하다가 입차 처리가 맞춘 세대와 다른 세대에 알림을 보냈다. 수정 전후를 커밋에 이렇게 적었다.
Before: 입차 처리와 알림 발송이 분리되어, 알림 서비스에서 차량번호만으로 별도 DB 조회
→ 동일 차번 다중 세대 예약 시 잘못된 세대에게 알림 발송 가능
After : 입차 처리 결과의 host_id를 알림 서비스에 직접 전달
→ 실제로 입차 처리된 레코드의 세대에게만 알림 발송
→ DB 업데이트 실패 시 알림도 발송하지 않음 (일관성)
2.0의 이벤트 소비자도 매칭된 예약의 세대에게만 알림을 보낸다.
이중 게이트와 LPR 시간 오차 (2026-01-15, 2026-03-09)
출구 게이트가 두 곳이어서 같은 차량이 두 번 출차로 기록되는 일이 있었다. 1월 15일에 검사를 단순하게 바꿨다. 세대와 방문일이 같은지 따지던 조건을 없애고, 차량번호가 같고 최근 5분 안에 출차 기록이 있으면 건너뛴다. 기존에는 10분이었다. 3월 9일에는 차단기 번호판 인식(LPR)이 기록한 시각과 서버 시각의 차이를 허용하는 패치가 들어갔다. 두 시계는 현장에서 실제로 몇 초씩 어긋났다. 이 두 가지는 버그가 아니라 현장 규칙이라고 판단해 2.0에서도 단지별 설정으로 남겼다.
폴링은 도는데 아무것도 읽지 않았다 (2026-08-11)
폴링 서버가 살아 있고 로그에도 에러가 없는데 입출차가 감지되지 않았다. 원인은 이랬다. mssql 드라이버는 SQL Server의 BIGINT를 문자열로 돌려준다. 서버는 현재 버전과 마지막 버전을 JavaScript의 비교 연산자로 비교하고 있었다. 버전이 6자리에서 7자리로 넘어가는 순간 사전순 비교가 뒤집힌다.
"1000000" > "999999" // false: 사전순으로는 "1"이 "9"보다 앞선다
폴링은 계속 돌았지만 "새 버전이 없다"고 판단해 변경 조회를 한 번도 하지 않았다. 고친 코드는 한 줄이다.
isNewerVersion(currentVersion, previousVersion) {
return BigInt(currentVersion) > BigInt(previousVersion);
}
원래 값은 문자열 그대로 SQL 파라미터와 로그에 쓰고, 비교할 때만 BigInt로 바꾼다. 이 저장소의 첫 단위 테스트가 이때 생겼다.
assert.equal(service.isNewerVersion('1000000', '999999'), true);
assert.equal(service.isNewerVersion('999999', '1000000'), false);
assert.equal(service.isNewerVersion('1000000', '1000000'), false);
복구 방침은 설계 문서에 적었다. 멈춰 있던 동안 놓친 이벤트는 재생하지 않고, 재시작하면 현재 버전부터 새 이벤트만 처리한다. 폴링 서버 로그를 보면 변경 버전은 2025-11-07 기동 시점에 약 20만이었고, 2026-10-18 하루에는 약 3,000 늘었다. 계속 커지는 값이니 언젠가 6자리에서 7자리로 넘어갈 수밖에 없었는데, 그 경계를 시험하는 테스트가 처음부터 없었다.