작성일
VisitCall 개발기 3 — 주차관제 DB 연동과 Change Tracking 폴링
구조를 바꿀 수 없는 업체 주차관제 DB에서 입출차를 읽고 방문 차량을 써 넣기 위해 SQL Server Change Tracking 1초 폴링 서버를 만든 과정과, 알림 큐·필터링 결정(2025년 7~9월).
VisitCall 개발기 시리즈 3/8편. 전체 목록은 visitcall 태그에서 볼 수 있다.
방문 차량을 차단기가 알아보게 하려면 업체 소유 SQL Server의 등록 테이블에 행을 넣어야 했다. 입차 알림을 보내려면 반대로 같은 DB의 입출차 이력을 읽어야 했다. 업체 시스템이라 테이블을 바꾸거나 트리거를 달 수 없었다. 2025년 7월 중순부터 9월 중순까지 이 제약 안에서 별도 폴링 서버를 만들었다. 업체 DB의 실제 테이블과 컬럼 이름은 공개하지 않으므로 아래 예시는 일반 용어로 바꿨다.
10초 전체 조회에서 Change Tracking으로
처음 시도는 10초마다 입차 이력을 통째로 조회해 새 행을 가려내는 방식이었다. 폴링 서버 저장소의 첫 커밋(2025-07-15)에는 이 구버전 서비스가 남아 있었지만, 같은 커밋에 이미 Change Tracking 서비스와 설정 스크립트가 들어 있었고 구버전은 이후 사용하지 않는 코드로 남았다. 전체 조회는 이력이 쌓일수록 비용이 늘고 10초라는 지연도 알림에는 길다.
SQL Server의 Change Tracking은 테이블의 변경을 DB가 버전 번호와 함께 기록해 주는 기능이다. 트리거나 별도 테이블 없이 DB 설정만 켜면 되고, 변경된 행의 키와 작업 종류(I, U, D)를 CHANGETABLE로 조회할 수 있다. 업체 DB를 건드리지 않는다는 조건에 가장 가까웠다. 켜는 스크립트는 다음과 같다. 테이블 이름은 일반 용어로 바꿨다.
-- DBA 권한 필요. 보존 기간 2일, 자동 정리 켬
ALTER DATABASE [parking_db] SET CHANGE_TRACKING = ON
(CHANGE_RETENTION = 2 DAYS, AUTO_CLEANUP = ON);
ALTER TABLE entry_history ENABLE CHANGE_TRACKING
WITH (TRACK_COLUMNS_UPDATED = ON);
폴링 서비스는 1초마다 현재 버전(CHANGE_TRACKING_CURRENT_VERSION())을 확인하고, 마지막으로 본 버전보다 커졌을 때만 변경 행을 조회한다. 아무 일도 없으면 버전 비교 쿼리 한 번으로 끝나서 부담이 작다.
const request = connection.request();
request.input('FromVersion', sql.BigInt, fromVersion);
request.input('ToVersion', sql.BigInt, toVersion);
// 변경된 행 + 원본 행을 조인해 게이트·시각 같은 상세를 함께 읽는다
const result = await request.query(`
SELECT CT.plate, CT.SYS_CHANGE_VERSION, CT.SYS_CHANGE_OPERATION,
CT.SYS_CHANGE_CONTEXT, EH.gate_id, EH.entered_at
FROM CHANGETABLE(CHANGES entry_history, @FromVersion) AS CT
LEFT JOIN entry_history EH ON CT.plate = EH.plate
WHERE CT.SYS_CHANGE_VERSION <= @ToVersion
ORDER BY CT.SYS_CHANGE_VERSION
`);
이 서비스는 시작할 때 마지막으로 처리한 버전을 읽어 오지 않고 "현재 버전"부터 시작하고, 그 버전을 메모리에만 둔다. 재시작하면 그 사이의 입출차가 사라진다. 빠르게 만들려고 이렇게 했는데, 이 선택은 6편과 7편에서 대가를 치른다.
UPDATE를 무시한 이유
변경 기록에는 INSERT만 있는 것이 아니다. 서버가 업체 이력 행을 수정하면 그 수정도 변경으로 기록된다. 폴링 서버는 입차를 처리하면서 이력 행에 방문 차량이라는 표시를 되써 넣었기 때문에, 처리한 결과가 다시 변경 이벤트로 돌아오는 구조였다. 내버려 두면 끝나지 않는 순환이 생겨서, 주석에 이유를 적고 UPDATE를 건너뛰게 했다.
// INSERT 작업만 처리 (입차 기록) - UPDATE는 무한루프 방지를 위해 제외
if (operation === 'I') {
// ... 입차 처리
} else if (operation === 'U') {
return; // 명시적으로 return하여 처리 중단
}
입차는 한 번 INSERT되는 사건이므로 INSERT만 보면 충분하다. 2.0은 업체 이력 테이블에 아예 쓰지 않고, 자기가 쓴 변경에는 변경 컨텍스트 표시를 붙여 걸러낸다.
처리 대상 좁히기
입차 이력에는 앱으로 등록한 방문 차량만 있는 것이 아니라 입주민·정기 차량도 모두 들어온다. 처리 대상은 다음과 같이 좁혔다.
| 조건 | 이유 |
|---|---|
| 등록 구분 코드가 앱 등록 값이거나 아직 비어 있는 차량 | 입주민 차량 입차까지 방문 알림으로 만들면 안 된다 |
| 지정한 입차 게이트의 INSERT만 | 이 필터는 11월 운영 중에 추가했다(6편) |
등록 구분이 앱 등록이 아닌 행은 로그에 "처리에서 제외됨"으로 남기고 지나갔다. 게이트 필터는 처음에 코드에 상수로 박았다가 2.0에서 단지별 설정으로 옮겼다.
WebSocket, HTTP 콜백, 재시도 큐
감지한 입출차는 메인 서버로 넘어가야 알림이 된다. 폴링 서버는 두 가지를 같이 썼다. 메인 서버가 연결해 둔 WebSocket으로 이벤트를 밀어 주고, 메인 서버의 HTTP 엔드포인트로도 콜백을 보낸다. 콜백은 메모리 큐에 넣어 최대 3번 재시도하며, 대기 시간은 5초부터 두 배씩 늘린다.
this.maxRetries = 3;
this.retryDelay = 5000; // 5초
// 실패하면 지수 백오프로 재시도 예약
const delay = this.retryDelay * Math.pow(2, item.retryCount - 1); // 5s, 10s, 20s
쓰기 방향과 확장 테이블 정리
반대 방향인 방문 차량 등록은 메인 서버가 폴링 서버의 등록 API를 호출하고, 폴링 서버가 업체 등록 테이블에 한 행을 넣거나 수정하는 방식이었다. 업체 쪽이 중복 등록을 거절(409)하면 이미 입주민 차량으로 등록된 번호로 보고 예약을 지웠다. 이 동작은 2.0에도 남겼다(7편).
초기에는 업체 DB 안에 우리가 만든 확장 이력 테이블도 있었다. 입출차 이력과 세대 정보를 이어 붙이려는 용도였다. 이 테이블을 읽고 쓰는 코드가 점점 늘어서, 9월 13일에 "앱 연동 수정, 입출차 알림로직 수정, 레거시코드 삭제" 커밋과 "완전 제거 및 코드베이스 정리" 커밋으로 걷어냈다. 이때부터 업체 DB에는 방문 차량 등록 행만 쓴다. 2.0 결정 기록에도 같은 원칙을 올렸다.
개발 중에 가장 쓸모 있었던 것은 시험용 CLI였다. 업체의 입출차 테이블에 직접 행을 넣는 간단한 스크립트(test-programs)를 만들어 두었다. 차단기 앞에 차가 없어도 입차와 출차 이벤트를 만들어 알림 전체를 돌려 볼 수 있었다.