본문으로 건너뛰기Hwanseok Sim

작성일

VisitCall 개발기 7 — 2.0 재설계: 멀티 단지 통합 서버

1.x 저장소 네 개를 분석해 결함을 정리하고, 메인 서버와 폴링 서버를 Fastify + PostgreSQL 통합 서버 하나로 합친 과정. 커서 영속, 멱등 아웃박스, 사이트별 단일 워커를 넣었다(2026년 10월).

VisitCall 개발기 시리즈 7/8편. 전체 목록은 visitcall 태그에서 볼 수 있다.

6편의 출차 매칭과 시각 처리는 메인 서버, 폴링 서버, 관리자 웹이 같은 일을 서로 다르게 하는 데서 나온 문제였다. 저장소 하나씩 고쳐서는 끝나지 않아서 2026년 10월에 서버를 새로 설계했다. 새 저장소에는 10월 17일에 모노레포 골격부터 앱 호환 API, 주차장 커넥터, 관리자 API와 웹, 1.x 데이터 이관 도구까지 올렸고, 19일에는 운영 DB 실스키마와 단지 배포 구성을 반영했다.

먼저 1.x를 분석했다

코드를 쓰기 전에 1.x의 네 저장소(앱, 메인 서버, 폴링 서버, 관리자 웹)를 읽고 같은 목차(10개 절)로 보고서를 썼다. 결함에 번호를 매기고, 저장소끼리 어긋나는 부분을 따로 모았다.

발견내용
재로그인서버가 토큰을 검증하지 않고 ID만으로 새 토큰을 발급. 계정을 가로챌 수 있다
가입 응답서버는 201을 주는데 앱은 200만 성공으로 처리. 계정은 만들어지는데 앱은 실패로 표시한다
방문 등록앱이 인증 헤더를 보내지 않는 요청이 있어서 서버가 인증을 강제하면 앱이 깨진다
출차 매칭메인 서버, 폴링 서버, 관리자 화면이 각자 다른 기준으로 매칭한다
시간대세 서버 모두 서버 로컬 시간이 한국 시간이라고 가정한다
탈퇴세대원 한 명이 탈퇴하면 세대 전체 방문 기록이 삭제된다
비밀값키와 DB 비밀번호, 세대 명부가 git에 커밋되어 있다

버그와 현장 규칙은 나눠서 다뤘다. 이중 게이트 중복 출차 무시나 LPR 시각 오차 허용은 현장에서 필요해서 생긴 규칙이므로 고치지 않고 단지 설정으로 남겼다. 세대 전체 삭제나 통계가 하루 밀리는 문제는 결함이므로 고쳤다.

결정을 번호로 남겼다

설계 결정은 문서에 번호(D-01부터 D-56)를 붙여 기록했다. 확정, 제안, 미결 중 상태를 적고, 바뀌면 지우지 않고 상태와 날짜를 갱신한다. 다음은 구조를 정한 결정들이다.

ID결정
D-02메인 서버와 폴링 서버를 하나로 합친다
D-03운영은 Docker Compose. PM2는 쓰지 않는다
D-06 · 07Node.js와 TypeScript, Fastify, zod, PostgreSQL
D-12관리자 웹은 Next.js를 유지하되 DB에 붙지 않고 서버의 관리자 API만 호출한다
D-13업체 DB에는 방문 차량 등록 행만 쓴다
D-22시간대 변환은 한 곳에서만, 테스트는 TZ=UTC로 실행

D-05는 중간에 바뀐 대표 사례다. 처음에는 "Flutter 앱은 수정하지 않는다"였지만 10월 19일에 "앱을 수정하되, 설치된 구버전 앱을 위해 서버의 레거시 계약은 유지한다"(D-47)로 변경됐다. 새 단지와 노원 단지가 서로 다른 서버를 쓰게 되어 앱이 접속 대상을 고를 수 있어야 했기 때문이다. 앱 쪽 이야기는 8편에서 다룬다.

설치된 앱의 API 25개는 바꾸지 않았다

이미 설치된 앱은 업데이트하지 않는 사용자가 남는다. 그래서 앱이 부르는 엔드포인트 25개를 정본 문서로 고정했다. 경로, 요청 키, 응답 봉투, 성공 상태 코드, 시각 형식까지 1.x와 같게 맞추고 필드 추가만 허용한다. 서버 안에서는 이 계층을 legacy/ 어댑터로 두어 내부 도메인 모델과 분리했다. 응답 봉투가 평면, {success, data}, 맨 배열로 섞여 있는 것도 앱이 이 형태로 파싱하므로 일부러 정리하지 않았다.

인증이 없는 요청은 이렇게 풀었다. 방문 차량 등록 요청은 앱이 토큰을 보내지 않는다. 대신 앱이 등록 직전에 항상 인증된 차량번호 검사 호출을 같은 번호로 부른다는 점을 이용했다. 검사가 성공하면 (세대, 차량번호)에 묶인 30분짜리 티켓을 서버가 만들고, 등록은 그 티켓을 소진해야 받아들인다. 세대주 가입도 같은 방식이다(세대 인증이 10분짜리 티켓을 만들고, 같은 IP에서만 소진할 수 있다). 앱을 고치지 않고 1.x의 "hostId만 알면 남의 세대주 자리를 차지할 수 있다"는 구멍을 막은 것이다.

입출차 변경을 잃지 않게 했다

1.x의 가장 큰 구조적 약점은 폴러의 커서(마지막 처리 버전)가 메모리에만 있다는 점이었다. 2.0의 폴러는 사이트마다 커서를 DB에 저장하고, 읽은 변경을 이벤트 테이블(아웃박스)에 쌓은 뒤 소비자가 처리한다. 이벤트를 저장하는 일과 커서를 전진시키는 일은 한 트랜잭션에서 한다.

services/parking-sync/poller.ts
const inserted = await db.db.transaction(async (tx) => {
  // 커서 행을 잠그고, 읽을 때와 같은 위치인지 확인한다
  const [locked] = await tx.select({ lastVersion: ctCursors.lastVersion })
    .from(ctCursors)
    .where(and(eq(ctCursors.siteId, site.id), eq(ctCursors.tableName, table)))
    .for('update');
  if (!locked || locked.lastVersion !== cursor.lastVersion) throw new CursorMovedError();

  // 같은 변경을 두 번 읽어도 dedupe_key 때문에 한 번만 저장된다
  await tx.insert(parkingEvents).values(events)
    .onConflictDoNothing({ target: parkingEvents.dedupeKey });

  await tx.update(ctCursors)
    .set({ lastVersion: nextVersion, lastVendorTime, updatedAt: now() })
    .where(/* 같은 사이트, 같은 테이블 */);
});

멱등 키는 사이트:테이블:행키:변경버전이다. 서버가 꺼져 있던 동안의 변경은 커서부터 다시 읽으므로 잃지 않고, 같은 변경이 두 번 들어와도 중복되지 않는다. 변경 추적의 보존 기간(실측 2일)을 넘어 커서가 무효가 되면, 마지막으로 처리한 업체 시각부터 다시 스캔해 복구한다. 버전은 처음부터 BigInt로 다룬다.

한 사이트는 한 워커만 폴링하도록 PostgreSQL의 어드바이저리 락을 쓴다. 서버를 여러 개 띄워도 같은 사이트를 두 군데서 읽지 않는다. 락은 커넥션 풀 밖의 전용 연결로 잡고 주기적으로 보유 여부를 확인한다.

모든 데이터에 사이트를 붙였다

1.x 코드에는 단지가 한 곳이라는 가정이 박혀 있었다. 2.0은 모든 테이블에 site_id를 두고, 조회·매칭·배치·통계가 전부 그 범위 안에서 돈다. 단지마다 다른 값(업체 접속, 단지 코드, 방문 코드, 게이트, 중복 출차 무시 시간, 동·호 형식)은 사이트 설정 JSON에 두며, 관리자 웹과 서버가 같은 zod 스키마로 검증한다. 접속 비밀값은 설정에 넣지 않고 secretRef라는 이름으로만 참조한다.

PostgreSQL 쪽 스키마는 마이그레이션 8개, 테이블 15개다. 시각은 timestamptz(UTC), 달력 날짜는 사이트 시간대 기준 date로 둔다. 1.x에서 한 행이던 예약과 입출차 세션은 둘로 나눠서 예약 1건에 세션 N건이 연결된다. 재입차할 때 행을 복제하던 문제가 이렇게 사라진다.

단계마다 리뷰를 받고 지적 사항을 따로 고쳤다

서버는 5단계(골격, 앱 호환 API, 주차장 커넥터, 관리자 API와 웹, 이관 도구)로 나눠 만들었다. 2단계와 3·4단계가 끝난 뒤에는 코드를 쓴 쪽과 따로 리뷰를 받고, 지적 사항을 고치는 커밋을 별도로 냈다. 지적의 예는 다음과 같다.

  • 업체 DB를 호출하는 동안 PostgreSQL 트랜잭션을 쥐고 있었다. 읽기, 반영, 다시 읽어 비교하는 방식으로 바꿔 트랜잭션을 풀었다.
  • 소비자와 스케줄러의 잠금이 일반 커넥션 풀에 묶여 있었다. 풀 밖의 전용 연결로 옮겼다.
  • 약관 동의 시각을 앱이 오프셋 없는 로컬 시각으로 보내는데 서버가 UTC로 읽고 있었다. 사이트 시간대로 해석하게 바꿨다.

테스트는 Vitest와 Testcontainers로 PostgreSQL, MSSQL, MySQL 컨테이너를 실제로 띄워 돌린다. 프로젝트 소개 문서 기준 서버 테스트는 약 370개다. 서버를 띄워 앱 API 25개를 실제 HTTP로 호출하는 E2E도 따로 있다.

이전·다음 글