본문으로 건너뛰기Hwanseok Sim

← 홈으로

  • 1.x 운영 중
  • 2.0 오픈 준비

Visit Call — 아파트 입주민 주차 관리 서비스

입주민이 앱으로 방문 차량을 미리 등록하면 단지 주차관제에 반영되고, 입차·출차를 푸시로 알려 주는 서비스. 관리자는 웹에서 세대·방문 통계·월 허용 주차시간을 관리한다.

기간
2025.05 – 운영 중
역할
1인 E2E 개발·운영
  • Flutter
  • TypeScript
  • Fastify
  • PostgreSQL
  • SQL Server Change Tracking
  • Next.js
  • Express · MySQL (1.x)
  • Docker Compose · Caddy
  • Firebase Messaging

Visit Call: 방문 차량, 미리 등록하고 편하게

입주민이 손님 차량번호를 앱에 미리 등록하면 단지 차단기가 번호판을 읽어 바로 통과시키고, 차량이 들어오면 세대 휴대폰으로 알림이 온다. 관리사무소는 웹에서 세대별 방문 주차시간과 통계를 관리한다.

입주민 앱, API 서버, 단지 주차관제 DB 연동, 관리자 웹, 서버 인프라, 스토어 배포, 현장 운영까지 혼자 처음부터 끝까지(E2E) 만들고 운영했다. 2025년에 1.x를 만들어 한 단지에서 운영했고, 2026년에는 운영하며 드러난 문제를 바탕으로 여러 단지를 받는 2.0을 다시 설계해 새 단지에 설치했다.

참고

마스킹 안내. 실제 적용 단지는 이름을 가리고 지역명만 써서 노원 단지(1.x 운영 중)와 부천 단지(2.0 첫 적용)로 표기했다. 앱 화면은 테스트 세대(999동 9999호, 가짜 값)로 찍었고 아이디는 회색 막대로, 연락처는 *로 가렸다. 관리자 웹 화면은 개발 DB의 데모 데이터(데모단지, 가짜 이름, 서버가 가린 연락처)다. 서버 주소·계정·앱 식별자는 싣지 않았고, 업체 주차관제 DB의 테이블 이름은 "업체 등록 테이블"처럼 일반 용어로 바꿨다.

항목내용
서비스아파트 입주민용 방문 차량 사전등록 · 입출차 알림 · 방문 주차시간 관리
사용자입주민(세대주·세대원), 관리사무소, 운영자
역할1인 E2E 개발·운영: 기획, 앱, API 서버, 주차관제 연동, 관리자 웹, 인프라, 스토어 배포, 현장 대응
기간2025-05 개발 시작 → 1.x 출시, 노원 단지 운영 → 2026-10 2.0 재설계 → 2026-10-23 부천 단지 서버 가동
배포App Store · Google Play(앱 1개, 1.0.0 → 2.0.0), 노원 단지 1.x 서버, 부천 단지 2.0 단지 서버
운영 규모노원 단지 (2026-10 DB 기준): 등록 세대 237, 앱 계정 439, 누적 방문 예약 약 9,400건, 앱으로 등록한 방문 차량 입차 월 600~750건
코드1.x: 저장소 4개(앱, 메인 서버, 폴링 서버, 관리자 웹) / 2.0: Flutter 앱 약 1.1만 줄 + TypeScript 모노레포 약 3.2만 줄
테스트2.0 앱 390개, 2.0 서버 약 370개(실제 PostgreSQL·MSSQL·MySQL 컨테이너), 앱 API E2E

목차: 1. 개요 · 2. 만든 것 · 3. 서비스 화면 · 4. 사용 예시 · 5. 시스템 구성 · 6. 워크플로우 · 7. ERD · 8. 기술적 도전 · 9. 연혁 · 10. 배포·운영 · 11. 품질 · 12. 기술 스택 · 13. 회고


1. 서비스 개요

1.1 해결한 문제

누가전에는Visit Call로
입주민손님이 오면 경비실에 알리거나 차단기 앞에서 호출해야 했다앱에 차량번호를 미리 등록하면 차단기가 방문 차량으로 인식한다. 입차하면 알림을 받는다
관리사무소세대별 방문 주차시간과 방문 기록을 따로 정리해야 했다웹에서 세대별 월 허용시간, 사용률, 방문 현황, 통계를 보고 엑셀로 내려받는다
단지 주차관제 시스템업체 소유 DB라 구조를 바꾸기 어렵다업체 DB에 트리거나 테이블을 추가하지 않는다. 입출차는 Change Tracking으로 읽고, 방문 차량은 업체 등록 테이블에 쓴다

1.2 사용자별 기능

사용자기능
세대주관리사무소 명부의 이름·휴대폰으로 세대 인증 후 가입, 방문 차량 등록·삭제, 입차 알림, 월 주차 사용량, 세대원 승인·거부·삭제, 세대주 정보 변경 요청
세대원가입 → 승인 대기 → 세대주가 승인하면 같은 세대의 예약·알림·사용량을 함께 쓴다
관리사무소통계, 방문 등록 현황, 세대 관리(CSV 가져오기), 월 허용 주차시간, 정보 변경 승인, 방문 기록 비활성화 기간, 지원센터 방문 차량 조회, 엑셀 내보내기
운영자단지 추가(사이트 설정, 앱 단지 목록), 관리자 계정·권한, 단지 서버 배포

2. 만든 것

서비스를 이루는 모든 구성요소를 직접 만들었다. 1.x에서 빠르게 출시해 현장에서 검증하고, 2.0에서 운영 중 드러난 문제를 구조로 해결했다.

구성요소1.x (2025 ~, 노원 단지)2.0 (2026-10 ~, 멀티 단지)
입주민 앱Flutter. 가입, 방문 차량 예약, 예약 확인, 우리집 사용기록, 세대원 관리, 푸시같은 앱을 이어서 개발. 단지 선택, 공통 네트워크·세션 계층, 무채색 디자인 시스템, 테스트 390개. 1.x·2.0 서버 모두에서 동작
API 서버Express + Sequelize + MySQL 메인 서버. 엔드포인트 63개(앱이 쓰는 것 25개), 예약·입출차 매칭, FCM, 통계 배치Fastify + PostgreSQL 통합 서버. 앱 호환 API 25개 + 관리자 API 45개, 사이트별 배치, FCM
주차관제 연동별도 폴링 서버(Express + mssql). Change Tracking 1초 폴링, WebSocket·HTTP 콜백으로 메인 서버에 전달통합 서버 안의 커넥터·폴러. 커서 DB 영속, 멱등 이벤트 아웃박스, 사이트별 단일 워커
관리자 웹Next.js 14 + shadcn/ui. 메뉴 9개, 엑셀, 월 이월 스케줄러. 두 DB에 직접 접속Next.js 16 BFF. 1.x 화면 9개 이식 + 사이트·관리자 관리, 사이트 범위 권한, 감사 로그. DB 접속 정보 없음
데이터MySQL 테이블 20개PostgreSQL 테이블 15개, 1.x 데이터 이관 도구
인프라Windows PC + PM2, DDNS 평문 HTTP단지 로컬 PC에 Docker Compose(proxy·server·admin·db), Caddy 자동 TLS
앱 배포Xcode Cloud, Android 릴리스 서명. 1.0.0 → 1.0.4App Store Connect · Google Play 수동 릴리스. 2.0.0

3. 서비스 화면

3.1 입주민 앱 (Flutter)

iPhone 시뮬레이터에서 부천 단지 테스트 세대로 찍은 현재 버전(2.0) 화면이다. 번호는 4장 사용 예시에서 가리키는 화면 번호다.

  • 상태는 아이콘 + 글자로. 예정·입차·출차·승인 대기·한도 임박을 색이 아니라 아이콘과 라벨로 구분한다.
  • 되돌릴 수 없는 작업은 한 번 더 확인. 회원 탈퇴는 "위험 영역"에 따로 두고, 입차한 예약은 삭제 버튼 대신 잠금 안내를 보여 준다(서버도 삭제를 막는다).
  • 오류는 원인별로. 차량번호 형식 오류와 통신 오류를 나눠 보여 주고, 입력 오류는 입력란 바로 아래에 표시한다.

3.2 관리자 웹 (Next.js)

2.0 관리자 웹이다. 개발 DB에 데모 데이터(데모단지 세대 96, 예약 약 2,200건, 입출차 세션 약 2,100건)를 넣고 찍었다. 연락처는 서버가 가려서 내려 준다.

통계 현황: 지금 주차 중, 오늘 방문, 평균 주차시간, 피크 시간대와 월별 추이. 날짜 기준은 단지 시간대다.

통계 현황

방문차량 등록 현황: 예약과 실제 입출차 세션을 함께 본다. 행을 펼치면 재입차를 포함한 세션 상세가 나오고, 엑셀로 내보낼 수 있다.

방문차량 등록 현황

사이트 설정: 단지마다 다른 값(업체 단지 코드, 방문 코드, 중복 출차 무시 시간, LPR 시각 오차, 입차 게이트, 동·호 형식, 방문목적 코드표, 허용시간 기본값)과 커넥터 설정. 접속 비밀값은 저장하지 않고 이름(secretRef)으로만 참조한다. 오른쪽은 변경 추적 커서와 이벤트·등록 대기 현황이다.

사이트 설정

4. 사용 예시

4.1 입주민: 처음 설치하고 손님 차량 등록하기

단계사용자시스템
1앱을 열고 ①에서 부천 단지를 고른다이후 모든 API를 부천 단지 서버로 보낸다
2③에서 아이디·비밀번호를 입력하고, 관리사무소에 등록된 이름·휴대폰으로 세대를 확인한다서버가 세대 명부와 대조하고 10분짜리 가입 티켓을 발급한다
3약관에 동의하고 가입한 뒤 로그인한다약관 동의를 증빙으로 저장한다. 로그인 직후 알림 권한을 묻고 기기 토큰을 등록한다
4④에 손님 차량번호를 입력하고 방문일(당일 또는 기간)을 고른다형식을 검사하고, 확인 시트로 한 번 더 보여 준 뒤 등록한다
5(손님 도착)방문일이 오늘이면 바로 업체 등록 테이블에 반영돼 차단기가 방문 차량으로 인식한다. 입차하면 "입차" 푸시가 오고 ⑤가 입차로 바뀐다
6(손님 출발)출차를 매칭해 세션을 닫는다. ⑥의 이번 달 사용 시간에 더해진다

등록이 거절되는 경우:

상황서버 응답앱 안내
같은 날 같은 차량을 이미 등록함409 VISITOR_DUPLICATE"이미 등록된 방문 차량"과 상세 사유, "출차 후 다시 등록해주세요"
입주민 차량을 방문 차량으로 등록함409 RESIDENT_CAR"입주민 차량은 방문 등록이 불가능합니다"
형식이 맞지 않는 번호isValid: false입력란 아래에 "올바르지 않은 차량 번호 형식입니다"
네트워크 오류·시간 초과(15초)—형식 오류와 구분해 통신 오류로 알린다

4.2 입주민: 가족과 함께 쓰기

  1. 가족이 ②에서 세대원 가입을 하면 승인 대기 계정이 되고, 세대주에게 "세대원 가입 요청" 푸시가 간다.
  2. 세대주가 ⑧에서 승인한다(거부·삭제도 여기서 한다).
  3. 가족은 "승인 완료" 푸시를 받고 다시 로그인하면 같은 세대의 예약·알림·사용량을 함께 본다.

4.3 관리사무소: 단지 운영

할 일화면내용
새 단지 세대 명부 넣기세대 관리관리사무소 엑셀을 CSV로 가져온다. 문자 인코딩(UTF-8·CP949)을 자동으로 판별하고, 잘못된 행은 행 번호와 이유를 돌려준다
이번 달 방문 주차시간 정하기허용 주차시간 설정세대 개별·일괄·전체로 월 허용시간을 지정한다. 설정하지 않은 달은 이전 달 값을 이어 쓴다
많이 쓴 세대 확인허용시간 사용 현황사용률 80% 이상과 초과 세대를 표시한다
방문 기록 제출방문차량 등록 현황 · 방문 현황 및 세대원기간·세대·차량으로 걸러 엑셀로 내보낸다
공사 기간 기록 중지방문 기록 비활성화지정한 기간에는 입출차 기록과 통계를 남기지 않는다
세대주 정보 변경세대주 정보 변경 요청앱에서 들어온 이름·전화 변경을 승인하면 명부에 반영한다

4.4 API 예시

앱은 1.x와 2.0 서버에 공통인 레거시 계약(/api/* 25개)을 쓴다. 주소·계정·번호는 모두 예시 값이다.

BASE=https://<부천 단지 서버>/api

# 로그인 → 평면 응답 {token, user}
curl -s -X POST $BASE/auth/login -H 'Content-Type: application/json' \
  -d '{"id":"demo-head","password":"****"}'
# {"token":"eyJ…","user":{"id":"demo-head","hostId":17,"accountKey":42,"role":"head"}}

# 방문 차량 등록 ①: 인증된 검사 호출이 30분짜리 등록 티켓을 만든다
curl -s -X POST $BASE/car/validate -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"carNumber":"00가0001"}'
# {"success":true,"data":{"isValid":true}}

# 방문 차량 등록 ②: 앱은 이 요청에 토큰을 보내지 않는다 → 위 티켓으로 검증한다
curl -s -X POST $BASE/visitor/register-vehicle -H 'Content-Type: application/json' \
  -d '{"carNumber":"00가0001","hostId":17,"visitorPhone":"","visitDateFrom":"2026-11-06","visitDateTo":null}'
# {"success":true,"data":{"visitor_id":1024,"host_id":17,"car_number":"00가0001", …}}

# 같은 차량·같은 날 다시 등록 → 409
# {"success":false,"errorType":"VISITOR_DUPLICATE","detail":"차량번호 00가0001는 2026-11-06에 이미 예약되어 있습니다."}

# 이번 달 주차시간
curl -s "$BASE/parking-time/usage/17?year=2026&month=11" -H "Authorization: Bearer $TOKEN"
# {"success":true,"data":{"hostId":17,"year":2026,"month":11,"totalHours":12.5,
#   "totalSessions":9,"avgSessionHours":1.39,"limit":{…},"sessions":[…]}}

응답 봉투(평면 / {success,data} / 맨 배열)와 snake·camel이 섞인 필드명은 배포된 앱이 그대로 파싱하므로 일부러 정리하지 않았다.

입차 푸시(FCM data): 앱은 plteNo와 enterTime이 모두 있을 때만 목록을 갱신하고, screen은 허용 목록에 있는 화면일 때만 연다.

{ "plteNo": "00가0001", "enterTime": "20261106140211", "screen": "/notification_list" }

앱 단지 목록(Firebase Remote Config sites): 이 값만 바꾸면 앱을 다시 배포하지 않고 단지를 추가한다. 앱은 https 주소와 기존 단지의 레거시 주소 하나만 받아들인다.

[
  { "id": "a-apartment", "name": "노원 단지", "baseUrl": "http://<1.x 서버>/api" },
  { "id": "b-apartment", "name": "부천 단지", "baseUrl": "https://<2.0 서버>/api" }
]

관리자 API: 프록시 밖으로 열지 않고, 관리자 웹(BFF)이 Compose 내부망에서 부른다.

POST /v1/admin/auth/login                              # → {token, expiresAt, admin}
GET  /v1/admin/sites/:siteId/stats/summary             # 지금 주차 중, 오늘 방문, 평균 주차시간, 피크 시간대
GET  /v1/admin/sites/:siteId/stats/monthly?year=2026
GET  /v1/admin/sites/:siteId/households/visits/export  # 엑셀
PUT  /v1/admin/sites/:siteId/allocations/bulk          # 월 허용시간 일괄 지정
POST /v1/admin/sites/:siteId/hosts/import              # 세대 명부 CSV, 행별 오류 반환
GET  /v1/admin/sites/:siteId/connector-status          # 업체 DB 연결·변경 추적 지연

운영 명령

# 단지 서버 기동 (구버전 앱용 평문 포트를 닫는 설정 포함)
docker compose -f infra/compose.yaml -f infra/compose.no-legacy.yaml --env-file .env.production up -d --build

# 첫 전체 관리자 생성 (DB는 Compose 내부에만 열려 있어 server 컨테이너 안에서 실행)
docker compose -f infra/compose.yaml --env-file .env.production exec -e ADMIN_PASSWORD='****' server \
  node src/cli/create-admin.ts --login admin --name 관리자 --scope super

# Windows 운영 PC용 래퍼: Compose 옵션을 고정하고 업체 DB 연결을 점검한다
scripts\vc.cmd ps
scripts\vc.cmd vendor-check

# 1.x → 2.0 데이터 이관 (dry-run 리포트 먼저)
node src/cli/import-legacy.ts --dry-run …

5. 시스템 구성

5.1 1.x: 빠르게 출시한 단일 단지 구성

flowchart LR
    App["입주민 앱 1.x<br/>Flutter"]
    Office["관리사무소 브라우저"]
    subgraph Backend["1.x 백엔드 · Windows PC, PM2"]
        Main["메인 서버<br/>Express · Sequelize"]
        DB[("MySQL<br/>테이블 20개")]
        Poll["폴링 서버<br/>Express · mssql"]
        Admin["관리자 웹<br/>Next.js 14 + 월 이월 스케줄러"]
    end
    Vendor[("업체 주차관제 DB<br/>MSSQL")]
    LPR["차단기 LPR"]
    FCM["Firebase<br/>Cloud Messaging"]

    App -- "HTTP /api · JWT" --> Main
    Main --> DB
    Main -- "HTTP 등록 · 삭제" --> Poll
    Poll -- "WebSocket 입출차 이벤트<br/>+ HTTP 콜백 재시도" --> Main
    Poll -- "Change Tracking 1초 폴링<br/>등록 테이블 쓰기" --> Vendor
    LPR --> Vendor
    Main -- "입차 · 세대원 알림" --> FCM --> App
    Office --> Admin
    Admin -- "SQL 직접 읽기·쓰기" --> DB
    Admin -- "SQL 직접 읽기" --> Vendor
구성요소역할규모
메인 서버인증·세대·방문·주차시간·푸시 API, 입출차와 예약 매칭, 매일 통계 집계엔드포인트 63개, JS 약 8,600줄
폴링 서버업체 DB 변경을 1초마다 읽어 입출차 이벤트로 바꾸고, 방문 차량을 업체 등록 테이블에 쓴다엔드포인트 26개 → 16개, JS 약 2,500줄
관리자 웹통계, 방문 현황, 허용시간, 세대 관리, 기록 비활성화. 매월 1일 허용시간 이월메뉴 9개
앱입주민 기능 전체Dart 약 11,400줄, 1.0.0 → 1.0.4

5.2 2.0: 두 단지, 두 서버, 앱 하나

flowchart LR
    subgraph Phone["입주민 휴대폰"]
        App["Visit Call 앱 2.0<br/>Flutter · iOS / Android"]
    end

    subgraph Firebase["Firebase"]
        RC["Remote Config<br/>단지 목록"]
        FCM["Cloud Messaging<br/>푸시"]
    end

    subgraph SiteA["노원 단지 · 1.x 유지"]
        L1["1.x 메인 서버 + 폴링 서버"]
        VA[("업체 주차관제 DB")]
    end

    subgraph SiteB["부천 단지 · 2.0 단지 서버 (Docker Compose)"]
        PX["proxy · Caddy<br/>자동 TLS"]
        SV["통합 서버 · Fastify"]
        DB[("PostgreSQL")]
        AD["관리자 웹 · Next.js BFF"]
        VB[("업체 주차관제 DB<br/>단지 내부망")]
    end

    Office["관리사무소 브라우저"]

    App -- "단지 목록" --> RC
    App -- "레거시 /api" --> L1
    App -- "HTTPS /api" --> PX
    PX --> SV
    Office -- "HTTPS" --> PX --> AD
    AD -- "/v1/admin · 관리자 토큰" --> SV
    SV --> DB
    SV -- "변경 추적 · 방문 등록" --> VB
    L1 <--> VA
    SV -- "입차 · 세대원 알림" --> FCM
    L1 --> FCM
    FCM -- "푸시" --> App
  • 노원 단지는 1.x를 그대로 두고, 새 단지인 부천 단지에 2.0을 먼저 열었다. 노원 단지는 부천 단지가 안정되면 이관한다(이관 도구는 완성).
  • 배포 단위는 단지 로컬 스택이다. 업체 DB를 외부에 열지 않고 단지 내부망에서만 접속한다. 코드는 멀티 단지를 지원하므로 나중에 한 스택으로 합칠 수 있다.

5.3 2.0 통합 서버 내부

flowchart LR
    subgraph Server["server · Fastify 5 (단일 프로세스)"]
        Legacy["legacy<br/>앱 호환 /api/*"]
        AdminApi["admin API<br/>/v1/admin/*"]
        Domain["도메인<br/>예약 · 세대 · 세션 · 주차시간"]
        Connector["parking-connector<br/>방문 등록 반영"]
        Sync["parking-sync<br/>변경 추적 폴러 · 이벤트 소비자"]
        Scheduler["scheduler<br/>단지 시간대 배치"]
        Notifier["notifier<br/>FCM"]
    end
    DB[("PostgreSQL 18")]
    Vendor[("업체 주차관제 DB")]
    Legacy --> Domain
    AdminApi --> Domain
    Domain --> DB
    Domain --> Connector
    Scheduler --> Connector
    Connector -- "등록 테이블 쓰기" --> Vendor
    Sync -- "Change Tracking 읽기" --> Vendor
    Sync --> DB
    Sync --> Notifier
모듈책임
legacy/배포된 앱이 부르는 25개 엔드포인트. 응답 봉투·필드 이름·상태코드·시각 문자열을 1.x와 똑같이 맞추는 어댑터. 내부 도메인 모델과 분리했다
modules/가입·세션·탈퇴, 세대·세대원, 예약·등록 티켓, 주차시간 계산, 관리자 API(사이트 범위 검사, 감사 로그)
parking-connector/단지별 업체 DB 게이트웨이. 한 차량의 유효한 방문 예약을 업체 등록 테이블 한 행으로 합쳐 반영한다
parking-sync/단지별 변경 추적 폴러(커서 영속, 멱등 이벤트), 이벤트 소비자(예약 매칭, 세션, 입차 알림), 단지 잠금
scheduler/단지 시간대 기준 배치: 자정 반영, 반영 재시도, 만료 정리, 티켓 정리, 이벤트 재시도
packages/contracts관리자 API 요청·응답 zod 스키마. 서버는 직렬화에, 관리자 웹은 응답 검증에 같은 스키마를 쓴다

모듈 사이는 프로세스 안의 호출과 DB(이벤트 아웃박스, 예약의 반영 상태)로 잇는다. 커넥터 하나가 죽어도 앱 API는 살아 있다.

5.4 1.x → 2.0에서 바꾼 것

영역1.x2.0
프로세스메인 서버 + 폴링 서버 + 관리자 웹이 HTTP 콜백·WebSocket·DB 직결로 얽힘통합 서버 하나. 업체 연동도 같은 프로세스에서 DB 아웃박스로 연결
관리자 웹인증 없음, 두 DB에 직접 접속, 접속 정보를 빌드에 포함BFF + 관리자 토큰 + 사이트 범위 권한 + 감사 로그. DB 접속 정보 없음
인증재로그인이 토큰 검증 없이 ID만으로 새 토큰 발급, 폴링 서버·관리자 API 무인증모든 경로 인증 기본. 재로그인은 서명과 DB 세션 일치를 검증. 무인증으로 남겨야 하는 앱 경로는 티켓과 rate limit으로 방어
비밀값·개인정보키·DB 비밀번호·세대 명부가 git에 커밋런타임 주입(env, Compose secrets)만. 로그 개인정보 마스킹
SQL문자열 보간(인젝션 가능)전부 파라미터 바인딩
입출차 감지커서를 메모리에 둬 재시작하면 유실, 재진입 방지 없음커서 DB 영속, 멱등 이벤트, 단지별 단일 워커
시간서버 로컬 시간 = 한국 시간을 가정단지 시간대를 명시하는 공용 유틸, 테스트를 TZ=UTC로 실행
단지한 곳 고정, 관리자 웹 메뉴에 단지명이 박혀 있음모든 데이터에 site_id, 단지별 설정·폴러, 앱은 단지 선택
데이터 삭제세대원 1명이 탈퇴하면 세대 전체 방문 기록 삭제본인 계정만 삭제, 예약·세션·약관 동의 기록 보존
운영Windows + PM2, 평문 HTTPDocker Compose, Caddy 자동 TLS, 마이그레이션 일회성 서비스
테스트거의 없음(앱 5개, 폴링 서버 1개)앱 390개, 서버 약 370개, 계약·통합·E2E

6. 워크플로우

6.1 앱 시작과 자동 로그인

flowchart TD
    Boot["main.dart<br/>dotenv → Firebase → 백그라운드 푸시 핸들러<br/>→ DI → 단지 복원 → 푸시 초기화 → runApp"] --> Splash["스플래시 (최소 1초)"]
    Splash --> Q1{"선택한 단지가 있나?"}
    Q1 -- "없음" --> Site["① 단지 선택"] --> Login
    Q1 -- "있음" --> Restore["SessionController.restore()"]
    Restore -- "signedIn" --> Role{"역할"}
    Restore -- "noSession · expired" --> Login["② 로그인"]
    Restore -- "failed (네트워크 등)" --> Retry["다시 시도 / 로그인 화면으로<br/>로그인 정보는 지우지 않는다"]
    Login --> Role
    Role -- "reserve" --> Pending["승인 대기"]
    Role -- "head · member" --> Home["홈 · ④ 방문 차량 예약"]

1.x 앱을 쓰던 사용자는 업데이트 후 단지 복원 단계에서 노원 단지로 자동 설정되고, 1.x와 같은 저장 키를 쓰므로 다시 로그인하지 않는다.

6.2 세대주 가입과 세대원 승인

sequenceDiagram
    autonumber
    participant H as 세대주 앱
    participant M as 세대원 앱
    participant S as 서버
    participant DB as PostgreSQL
    participant F as FCM

    Note over H,DB: 세대주 가입
    H->>S: POST /api/auth/hosts/verify (이름 + 휴대폰)
    S->>DB: 세대 명부 조회, 정확히 1세대만 통과
    S->>DB: 가입 티켓 발급 (10분, 요청 IP)
    S-->>H: host_id, address
    H->>S: POST /api/auth/register (아이디, 비밀번호, hostId, 약관 동의)
    S->>DB: 티켓 소진 + 세대주 계정 + 약관 동의 기록
    S-->>H: 200 (1.x는 201이라 앱이 실패로 표시하던 문제를 앱·서버 양쪽에서 해결)

    Note over M,F: 세대원 가입과 승인
    M->>S: POST /api/auth/register-member
    S-->>M: 201, 역할 reserve
    S->>F: family_member_joined
    F-->>H: 세대원 가입 요청 알림
    H->>S: POST /api/hosts/{hostId}/members/{accountKey}/approve
    S->>F: member_approved (1.x는 버그로 발송되지 않음)
    F-->>M: 승인 알림, 다시 로그인하면 홈

1.x에서는 hostId만 알면 남의 세대주 자리를 차지할 수 있었다. 2.0은 세대 인증에 성공한 요청에만 가입 티켓을 주고, 가입은 같은 IP에서 그 티켓을 소진해야 받아들인다.

6.3 방문 차량 등록 → 업체 DB 반영

sequenceDiagram
    autonumber
    actor U as 입주민 앱
    participant S as 통합 서버
    participant DB as PostgreSQL
    participant V as 업체 주차관제 DB

    U->>S: POST /api/car/validate (Bearer)
    S->>DB: 등록 티켓 발급 (30분)
    S-->>U: isValid = true
    U->>S: POST /api/visitor/register-vehicle (토큰 없음)
    S->>DB: 티켓 소진 + reservations INSERT<br/>유일 (site_id, plate, visit_date_from)
    alt 방문일이 단지 시간대로 오늘
        S->>V: 그 차량의 유효 예약 전체를 등록 테이블 1행으로 합쳐 반영
        alt 입주민·정기 차량 행이 이미 있음
            S->>DB: 예약 삭제
            S-->>U: 409 RESIDENT_CAR
        else 반영 성공
            S->>DB: 반영 상태 registered
            S-->>U: success
        end
    else 미래 날짜
        S-->>U: success (반영 대기)
        Note over S,V: 단지 시간 00:03 배치가 반영하고, 실패분은 5분마다 재시도
    end
  • 업체 DB를 호출하는 동안 PostgreSQL 트랜잭션을 잡지 않는다. 읽기 → 반영 → 다시 읽어 비교하는 방식으로 최신 상태에 맞춰, 등록·삭제가 겹쳐도 업체 행이 어긋나지 않는다.
  • 업체 DB에는 방문 차량 등록 행만 쓰고, 입출차 이력 테이블은 읽기만 한다.

6.4 입차 감지 → 알림: 1.x와 2.0

1.x: 폴링 서버가 감지하고 WebSocket으로 메인 서버에 넘긴다.

sequenceDiagram
    autonumber
    participant L as 차단기 LPR
    participant V as 업체 DB
    participant P as 폴링 서버
    participant M as 메인 서버
    participant F as FCM
    participant App as 앱

    L->>V: 입차 이력 행 기록
    loop 1초마다
        P->>V: 변경 추적 버전 확인, 늘었으면 변경 행 조회
    end
    P->>V: 방문 차량이면 이력 행에 등록 구분 표시
    P-->>M: WebSocket 입차 이벤트 (HTTP 콜백은 최대 3회 재시도)
    M->>M: 1분 중복 제거, 차량번호와 예약 기간으로 매칭
    M->>F: 매칭된 세대의 계정에 입차 알림
    F-->>App: plteNo, enterTime

2.0: 같은 프로세스 안에서 아웃박스로 넘기고, 커서를 DB에 남긴다.

sequenceDiagram
    autonumber
    participant L as 차단기 LPR
    participant V as 업체 DB
    participant P as 폴러 (단지별 1워커)
    participant DB as PostgreSQL
    participant C as 이벤트 소비자
    participant F as FCM

    L->>V: 입차 이력 행 기록
    loop 단지마다 루프 하나
        P->>V: 마지막 버전 이후 변경 조회<br/>스냅샷 격리, 최소 유효 버전 검사, 자기 쓰기 제외
        P->>DB: parking_events INSERT (dedupe_key) + ct_cursors 전진<br/>(한 트랜잭션)
    end
    C->>DB: 새 이벤트 조회
    C->>DB: (site_id, 차량번호, 단지 날짜)로 예약 매칭<br/>입차 게이트·기록 비활성화 기간 확인
    C->>DB: parking_sessions INSERT
    C->>F: 세대 계정(세대주·세대원)에 입차 푸시
    L->>V: 출차 이력 행 기록
    C->>DB: 업체 입차 ID(+차량번호)로 열린 세션을 찾아 exit_at 기록<br/>이중 게이트 중복 출차·미래 시각은 무시
  • 서버가 꺼져 있던 동안의 변경도 커서부터 다시 읽어 잃지 않고, 같은 변경을 두 번 읽어도 멱등키 때문에 한 번만 저장된다.
  • 변경 추적 보존기간(실측 2일)이 지나 커서가 무효가 되면 마지막 업체 시각부터 다시 스캔해 복구한다.

6.5 앱: 토큰 만료와 재로그인

sequenceDiagram
    participant A as 예약 확인 탭
    participant B as 사용기록 탭
    participant C as ApiClient
    participant S as SessionController
    participant SV as 서버

    par 동시 요청
        A->>C: GET /api/visitor/history/{hostId}
    and
        B->>C: GET /api/parking-time/current/{hostId}
    end
    C->>SV: 두 요청 (만료된 토큰)
    SV-->>C: 401, 401
    C->>S: refreshToken() 진행 중이면 같은 Future를 기다린다
    S->>SV: POST /api/auth/relogin (1번만)
    SV-->>S: 새 토큰
    C->>SV: 두 요청을 각각 1번씩 재시도
    SV-->>C: 200, 200
    opt 갱신이 거절되면
        S-->>A: sessionExpired, 앱 루트가 로그인 화면으로 보낸다
    end

6.6 관리자 요청

sequenceDiagram
    actor A as 관리자 브라우저
    participant N as 관리자 웹 (Next BFF)
    participant S as 서버 /v1/admin/*
    participant DB as PostgreSQL
    A->>N: 화면 요청 (httpOnly 쿠키)
    N->>S: Bearer 관리자 토큰
    S->>S: 토큰 버전 확인 → 사이트 범위 검사
    S->>DB: 조회 · 변경 + 감사 로그 (변경 시)
    S-->>N: 응답 (공유 zod 스키마로 직렬화)
    N->>N: 같은 스키마로 응답 검증
    N-->>A: 렌더링

전체 관리자는 모든 단지, 단지 관리자는 자기 단지만 본다. 비밀번호를 바꾸거나 강제 로그아웃하면 토큰 버전이 올라 기존 토큰이 모두 무효가 된다.

6.7 배치

작업1.x2.0 (단지 시간대 기준, 단지별 잠금)
미래 예약을 업체 DB에 반영매일 00:03 (00:08 백업)매일 00:03, 실패분은 5분마다 재시도
지난 예약 정리매일 00:10 이력 테이블로 이동매일 00:10 업체 등록에서 정리
월 주차시간 통계매일 02:00 집계 테이블 갱신세션에서 바로 계산
월 허용시간 이월매월 1일 전 행 복사 후 갱신(관리자 웹 스케줄러)필요 없음: 행이 없는 달은 이전 달 값을 이어 씀
티켓·이벤트—만료 티켓 정리(04:20), 실패 이벤트 재시도(1분)

7. ERD

7.1 1.x: MySQL (테이블 20개 중 핵심 10개)

erDiagram
    apartments ||--o{ hosts : "세대"
    hosts ||..o{ account : "계정 (FK 없음)"
    hosts ||--o{ visitors : "방문 = 예약 + 입출차"
    visitors ||--o| visitor_parking_syncs : "업체 등록 재시도"
    visitorlinks |o--o{ visitors : "방문 링크"
    hosts ||..o{ visitors_history : "30일 지난 방문 (FK 없음)"
    hosts ||--o{ usage_parkingtime : "월 허용시간"
    hosts ||--o{ usage_parkingtimestats : "월 사용 통계"
    hosts ||--o{ user_agreements : "약관 동의"

    apartments {
        int apartment_id PK
        varchar apartment_name
    }
    hosts {
        int host_id PK
        int apartment_id FK
        varchar address2 "NNN동 NNN호"
        varchar resident_name
        varchar resident_phone
    }
    account {
        int account_KEY PK
        int host_id "FK 없음"
        varchar ID UK "로그인 ID"
        varchar PW "bcrypt"
        varchar role "head / member / reserve"
        varchar fcm_token "1계정 1기기"
        varchar session_token "단일 기기 세션"
    }
    visitors {
        int visitor_id PK
        int host_id FK
        varchar car_number
        date visit_date_from
        date visit_date_to
        datetime entry_time
        datetime exit_time
        varchar parking_entry_id UK "업체 입차 ID"
    }
    visitor_parking_syncs {
        int visitor_id PK, FK
        varchar status "pending / synced / failed"
        int attempt_count
        datetime next_attempt_at
    }
    visitorlinks {
        varchar unique_string UK
        datetime expiration_date
        boolean is_used
    }
    visitors_history {
        int history_id PK
        int host_id
        varchar car_number
        datetime deleted_at
    }
    usage_parkingtime {
        int host_id FK
        decimal monthly_limit_hours
        decimal daily_limit_hours
        varchar month_data
    }
    usage_parkingtimestats {
        int host_id FK
        varchar usage_month "YYYY-MM"
        decimal total_hours
        int total_sessions
    }
    user_agreements {
        int host_id FK
        varchar user_id
        varchar service_terms_version
        varchar privacy_policy_version
    }
  • 나머지는 월 백업(usage_parkingtime_old 등), 일별 이용 통계, 이관 기록, SMS 시절 잔재 테이블이다. 세대주 정보 변경 요청은 DB가 아니라 JSON 파일에 저장했다.
  • 한계: 예약과 입출차 세션이 한 행이라 재입차하면 행을 복제했다. 계정·이력은 FK가 없어 세대를 지운 뒤 고아 행이 남았다. 시각은 한국 시간 벽시계 DATETIME으로 저장했다.

7.2 2.0: PostgreSQL 핵심 도메인 · 주차장 연동

PostgreSQL 18, Drizzle ORM, 마이그레이션 8개, 테이블 15개. 시각은 timestamptz(UTC), 달력 날짜는 단지 시간대 기준 date. 단지에 속하는 데이터는 모두 site_id로 범위가 정해진다.

erDiagram
    sites ||--o{ hosts : "세대"
    hosts ||--o{ accounts : "세대주 · 세대원"
    sites ||--o{ reservations : "단지"
    hosts ||--o{ reservations : "방문 예약"
    accounts |o--o{ reservations : "예약한 계정"
    reservations |o--o{ parking_sessions : "예약 1 : 세션 N"
    hosts |o--o{ parking_sessions : "세대"
    sites ||--o{ parking_events : "아웃박스"
    sites ||--o{ ct_cursors : "테이블별 커서"
    parking_events }o..o| parking_sessions : "소비자가 매칭"
    hosts ||--o{ parking_time_allocations : "월 허용시간"

    sites {
        bigint id PK
        text code UK "URL 슬러그"
        text name
        text timezone "기본 Asia/Seoul"
        text status "active / inactive"
        jsonb settings "방문 코드, 게이트, 중복 제거, LPR 오차, 동호 형식 등"
        jsonb parking_connector "kind, secretRef만 (비밀값 금지)"
    }
    hosts {
        bigint id PK "앱의 hostId"
        bigint site_id FK
        text dong
        text ho
        text unit_label "NNN동 NNN호"
        text resident_name
        text resident_phone "숫자만"
        text status
    }
    accounts {
        bigint id PK "앱의 accountKey"
        bigint host_id FK
        text login_id UK "대소문자 무시 전역 유일"
        text password_hash "bcrypt"
        text role "head / member / reserve"
        text fcm_token UK "1계정 1기기"
        text session_token "단일 기기 세션"
        timestamptz approved_at
    }
    reservations {
        bigint id PK "앱의 visitor_id"
        bigint site_id FK
        bigint host_id FK
        bigint account_id FK "null 허용"
        text plate "정규화 차량번호"
        date visit_date_from "단지 시간대"
        date visit_date_to
        text projection_status "pending / registered / failed / skipped"
        timestamptz projected_at
    }
    parking_sessions {
        bigint id PK
        bigint site_id FK
        bigint reservation_id FK "예약 삭제 후에도 통계용 보존"
        bigint host_id FK
        text plate
        timestamptz entry_at
        timestamptz exit_at
        text vendor_entry_id "출차 매칭 1순위 키"
        text vendor_entry_time "업체 원문 YYYYMMDDHHMMSS"
    }
    parking_events {
        bigint id PK
        bigint site_id FK
        text kind "enter / exit"
        text plate
        timestamptz occurred_at
        text vendor_entry_id
        text gate_id
        jsonb raw "업체 원본 행"
        text dedupe_key UK "site:table:pk:version"
        text status "new / processed / ignored / failed"
        int attempts
    }
    ct_cursors {
        bigint site_id PK
        text table_name PK "enter / exit"
        bigint last_version "마지막 처리 버전"
        text last_vendor_time
    }
    parking_time_allocations {
        bigint id PK
        bigint site_id FK
        bigint host_id FK
        date month "해당 월 1일, 없는 달은 이전 값"
        numeric monthly_limit_hours
        numeric daily_limit_hours
    }

7.3 2.0: 가입 · 운영 · 감사

erDiagram
    hosts ||--o{ signup_tickets : "세대 인증 후 발급"
    hosts ||--o{ registration_tickets : "세대"
    accounts ||--o{ registration_tickets : "차량 검사 후 발급"
    hosts ||--o{ pending_host_updates : "정보 변경 요청"
    accounts |o--o{ user_agreements : "약관 동의 (증빙 보존)"
    sites ||--o{ history_disable_periods : "기록 비활성화"
    sites |o--o{ admin_users : "단지 범위 관리자"
    admin_users |o--o{ audit_logs : "변경 기록"
    admin_users |o--o{ pending_host_updates : "승인 · 거절"

    signup_tickets {
        bigint id PK
        bigint host_id FK
        text ip "같은 IP에서만 소진"
        timestamptz expires_at "10분"
        timestamptz used_at
    }
    registration_tickets {
        bigint id PK
        bigint host_id FK
        bigint account_id FK
        text plate
        timestamptz expires_at "30분"
        timestamptz used_at
    }
    pending_host_updates {
        bigint id PK
        bigint site_id FK
        bigint host_id FK
        text requested_by
        text resident_name
        text resident_phone
        text status "pending / approved / rejected"
        bigint decided_by FK
    }
    user_agreements {
        bigint id PK
        bigint site_id FK
        bigint account_id FK "탈퇴 시 null"
        text login_id "증빙용 보존"
        text service_terms_version
        text privacy_policy_version
    }
    history_disable_periods {
        bigint id PK
        bigint site_id FK
        date start_date
        date end_date
        boolean is_active
    }
    admin_users {
        bigint id PK
        text login_id UK
        text scope "super / site"
        bigint site_id FK "site 범위일 때만"
        int token_version "올리면 기존 토큰 폐기"
    }
    audit_logs {
        bigint id PK
        bigint admin_user_id FK
        text admin_login_id
        bigint site_id FK
        text action "host.create 등"
        jsonb summary "개인정보 제외"
    }

7.4 데이터 설계에서 바꾼 것과 이관

1.x2.0이유
visitors 한 행 = 예약 + 입출차, 재입차하면 행 복제reservations 1 : parking_sessions N같은 손님의 하루 여러 번 출입을 한 예약 아래 쌓는다
입출차를 메모리 이벤트로 바로 처리parking_events 아웃박스 + ct_cursors폴러는 저장과 커서 전진을 한 트랜잭션으로 하고, 처리는 소비자가 따로 한다. 처리 실패가 커서를 막지 않는다
업체 등록 실패는 로그만(이후 재시도 테이블 추가)예약의 반영 상태(projection_status)예약 저장과 업체 반영을 분리해 실패해도 예약은 남고 배치가 다시 시도한다
usage_parkingtime + 월말 백업 + 이월 cronparking_time_allocations(host, month) 상속배치와 백업 테이블이 필요 없다
세대주 정보 변경 요청을 JSON 파일에 저장pending_host_updates 테이블관리자 승인 이력과 감사 로그를 남긴다
기록 비활성화 기간이 전역history_disable_periods(site_id, …)단지별로 다르다
한국 시간 벽시계 DATETIMEtimestamptz(UTC) + 단지 시간대 date컨테이너 시간대와 무관하게 동작한다
계정·이력에 FK 없음모든 관계에 FK, 증빙 데이터는 SET NULL + 비정규화 보존고아 행을 막고, 약관 동의는 탈퇴 후에도 남긴다
  • 식별자 보존: 앱이 hostId·accountKey·visitor_id를 정수로 저장하므로 PK를 bigint identity로 두고, 이관할 때 1.x ID를 그대로 옮긴다.
  • 이관 리허설: 1.x 방문 9,069행을 예약 8,556건과 입출차 세션 7,120건으로 나눴다. 시각은 한국 시간 → UTC로 바꾸고, 30일 넘게 출차 기록이 없는 열린 세션 282건은 제외했다.

7.5 앱 로컬 저장소

키내용비고
user_data토큰, 아이디, hostId, accountKey, 역할1.x와 같은 키라 업데이트 후에도 자동 로그인이 이어진다
remember_login자동 로그인 여부토큰을 갱신해도 사용자가 정한 값을 유지한다
selected_site_id, selected_site선택한 단지레코드 전체를 저장해 목록을 못 받아도 접속할 수 있다
sites_cache마지막으로 받은 단지 목록원격 설정 → 캐시 → 앱에 넣은 목록 순으로 쓴다

8. 기술적 도전과 해결

8.1 손댈 수 없는 업체 주차관제 DB와 연동하기

단지 차단기는 업체 소유 MSSQL을 쓴다. 이 DB에 방문 차량을 넣어야 차단기가 열리고, 이 DB의 입출차 이력을 읽어야 알림을 보낼 수 있다.

문제1.x에서 한 일2.0에서 한 일
업체 DB를 바꾸지 않고 입출차 감지10초 전체 조회를 SQL Server Change Tracking 1초 폴링으로 바꿨다(2025-07). 트리거나 테이블을 추가하지 않는다같은 방식을 단지별 폴러로 옮기고 커서를 DB에 남겼다
내가 쓴 변경이 다시 감지됨폴러가 업체 행을 UPDATE하면 그것도 변경으로 잡혀 INSERT만 처리하도록 제한업체 이력 테이블은 쓰지 않고, 자기 쓰기 컨텍스트를 걸러낸다
재시작하면 그 사이 입출차가 사라짐커서가 메모리에 있어 재시작 시 현재 버전부터 읽었다커서 DB 영속 + 멱등 아웃박스. 보존기간(2일)을 넘기면 업체 시각부터 재스캔
버전이 100만을 넘자 감지가 멈춤드라이버가 BIGINT를 문자열로 줘서 "999999" < "1000000" 비교가 거짓이 됐다. BigInt 비교로 고치고 첫 단위 테스트를 붙였다(2026-08)처음부터 BigInt로 다룬다
출구 게이트 두 곳이 같은 차를 두 번 인식같은 차량의 5분 내 출차를 무시업체 입차 ID를 출차 매칭 1순위 키로 단일화, 중복 무시 시간은 단지 설정으로
입주민 차량을 방문 차량으로 등록업체가 409를 주면 입주민 차량으로 보고 예약을 지움같은 판단을 커넥터에서 하고 앱에는 RESIDENT_CAR로 알린다
문서·덤프·코드마다 다른 업체 스키마INSERT 전에 컬럼 존재를 확인하는 방어 코드운영 DB 실스키마를 대조해 업체 원래 컬럼만 쓰고, 기본값 없는 필수 컬럼은 단지별 INSERT 고정값 설정으로 맞춘다

8.2 배포된 앱을 깨지 않고 서버를 통째로 바꾸기

  • 업데이트하지 않는 사용자가 남으므로 앱이 부르는 25개 엔드포인트의 경로·요청 키·응답 봉투·상태코드·시각 형식을 계약으로 동결했다. 응답 필드 추가만 허용한다.
  • 서버의 레거시 호환 계층을 어댑터로 두고 내부 도메인 모델과 분리했다. 계약 테스트로 키 집합·타입·봉투를 고정하고, 떠 있는 서버에 실제 HTTP로 E2E를 돌린다.
  • 시각 문자열: 앱은 오프셋 없는 문자열을 그대로 쓰고 +09:00 오프셋은 9시간 어긋나게 해석한다. 그래서 응답 시각을 단지 시간대 벽시계 YYYY-MM-DD HH:mm:ss로 통일했다.
  • 앱 쪽에서는 두 서버의 차이를 흡수했다. 1.x는 가입 성공에 201, 2.0은 200을 주므로 앱은 둘 다 성공으로 본다. 2.0 전용 기능은 응답에 감지 필드가 있을 때만 켠다.

8.3 토큰을 보내지 않는 앱 요청 방어하기

  • 배포된 앱은 방문 차량 등록 요청에 인증 헤더를 넣지 않는다. 인증을 강제하면 구버전 앱이 깨진다.
  • 앱이 등록 직전에 항상 인증된 차량번호 검사를 같은 번호로 부른다는 점을 이용했다. 검사에 성공하면 (세대, 차량번호) 30분 티켓을 발급하고, 등록은 그 티켓을 소진해야 받아들인다. 세대주 가입도 같은 방식이다(세대 인증 → 10분 티켓, 같은 IP).
  • 무인증 경로에는 IP별 rate limit을 따로 둔다(예: 로그인 분당 10회). 프록시 1홉만 신뢰해 IP 위조를 막는다.

8.4 시간대: 한국 시간 가정에서 벗어나기

  • 1.x는 세 서버 모두 "서버 로컬 시간 = 한국 시간"을 가정했다. 업체 시각은 시간대 정보 없는 14자리 문자열이고, LPR과 서버 시계도 몇 초씩 어긋났다. 운영하면서 응답 시각 형식 통일, LPR 오차 5초 허용 같은 패치를 이어 붙였다.
  • 2.0은 날짜·시각 변환을 공용 유틸 한 곳(Luxon)으로 모으고, 업체 원문 시각을 단지 시간대로 명시적으로 해석한다. 배치도 단지 시간대 자정 기준이다.
  • 모든 테스트를 TZ=UTC로 실행해 로컬 시간대에 기대는 코드가 들어오면 바로 드러나게 했다.

8.5 한 단지에서 여러 단지로

  • 모든 조회·매칭·배치·통계에 site_id 조건을 넣고, 입출차 매칭은 (site_id, 차량번호) 기준이다.
  • 단지마다 다른 값(업체 접속, 업체 단지 코드, 방문 코드, 게이트, 중복 제거 시간, 세대당 계정 수, 동·호 형식)은 코드가 아니라 사이트 설정에 두고, 관리자 웹과 서버가 같은 zod 스키마로 검증한다.
  • 구버전 앱은 단지를 모르므로 로그인 ID를 전역 유일로 두고, 레거시 경로 요청은 계정이 속한 단지로 해석한다.
  • 앱은 Firebase Remote Config의 단지 목록에서 단지를 고른다. 원격 조회가 실패하면 마지막 성공 목록, 그것도 없으면 앱에 넣은 목록을 쓴다. 단지를 바꾸면 먼저 로그아웃하고 기기 토큰을 지워 두 서버의 토큰이 섞이지 않게 했다.

8.6 현장 규칙은 지키고 결함은 고치기

  • 1.x 동작을 옮기기 전에 버그인지 현장 규칙인지 먼저 가렸다. 이중 게이트 중복 출차 무시, LPR 시각 오차 허용, 재방문 처리 같은 현장 규칙은 단지 설정으로 남겼다. 세대 전체 기록 삭제, 통계가 하루 밀리는 문제 같은 결함은 고쳤다.
  • 출차 매칭은 세 곳(메인 서버, 폴링 서버, 관리자 화면)에서 제각각이었다. 업체 출차 행의 입차 ID를 1순위 키로 단일화했다.
  • 같은 차량을 여러 세대가 예약하면 다른 세대에 알림이 가던 문제를 1.x 운영 중에 찾아 고쳤고, 2.0은 매칭된 예약의 세대에만 보낸다.

8.7 모바일 앱: 1.x 구조를 정리하며 고친 것

문제원인해결확인
토큰이 만료되면 탭마다 따로 재로그인했고, 자동 재로그인은 API 3개에만 있었다화면·서비스가 각자 HTTP 클라이언트와 재시도 로직을 가졌다앱 전역 ApiClient가 401/403을 공통 처리하고 갱신을 single-flight로 묶었다(6.5절)동시 요청 단위 테스트
로그인 직후 세대 정보를 3번 부르고, 탭을 옮길 때마다 다시 불렀다. 입력 중인 차량번호도 사라졌다탭을 switch로 교체하고 화면마다 같은 API를 따로 불렀다서비스 메모리 캐시(로그아웃 때 비움) + IndexedStack으로 탭 상태 유지3회 → 1회
자동 로그인이 네트워크 오류에도 로그인 정보를 지웠고, 로그아웃은 저장소 전체를 지웠다실패 원인을 구분하지 않았고 SharedPreferences.clear()를 썼다세션 복원 결과를 signedIn / noSession / expired / failed로 나누고, 로그인 키만 지운다세션 테스트
세대원 가입 푸시를 눌러도 화면이 열리지 않았다푸시 경로와 등록된 라우트 이름이 달랐다라우트 상수화, 푸시 이동은 허용 목록으로 제한라우트 테스트
iOS 27 SDK로 빌드한 앱이 시작하자마자 종료됐다UIScene 생명주기 없는 앱을 새 SDK가 거부했다Scene 매니페스트 + SceneDelegate 도입실기기, TestFlight
Android 15 이상에서 하단 버튼이 내비게이션 바에 가려 누를 수 없었다targetSdk 36에서 edge-to-edge가 강제됐다로그인·가입·비밀번호·알림·약관 화면과 가입 시트에 SafeArea48px 가상 inset 위젯 테스트, 3버튼 내비게이션 에뮬레이터
세대 정보 확인 시트의 오류가 시트 뒤에 가려지고 로딩이 멈추지 않았다바텀시트는 부모 setState로 다시 그려지지 않는다StatefulBuilder로 시트를 갱신하고 오류를 시트 안에 표시시트 위젯 테스트
화면마다 색이 달랐다. 직접 쓴 색이 407곳, 지정한 글꼴은 번들에 없었다테마 없이 화면에 스타일을 직접 썼다무채색 토큰과 공통 컴포넌트 12종가드 테스트가 하드코딩 색을 막는다(현재 0곳)

9. 개발 연혁

날짜는 커밋과 운영 기록 기준이다.

시기구분내용
2025-05앱Flutter 앱 개발 시작(1.0.0)
2025-07연동주차관제 DB 폴링 서버 착수. 10초 전체 조회 → Change Tracking 1초 폴링
2025-07앱API 연동, FCM 푸시, 세대원 가입, 예약 수정·삭제, 토큰 자동 갱신, 중복 푸시 처리
2025-07관리자업체 DB 통계 대시보드 프로토타입
2025-08앱우리집 사용기록(주차시간), 약관 동의, 회원 탈퇴, Xcode Cloud 빌드, Android 릴리스 서명
2025-09서버·관리자메인 서버 단지 운영판 정리, 업체 DB 확장 테이블 제거, Next.js 관리자 웹
2025-10전체방문 기간 등록, 세대원 가입 알림, iOS 1.0.2, 메인 서버 PM2 운영
2025-11서버·앱응답 시각 한국 시간 통일, 같은 날 중복 예약 409, 입차 게이트 필터, 앱 1.0.3
2025-12서버·관리자재방문 매칭, 다른 세대 알림 오발송 수정, 월 허용시간 백업·이월 스케줄러
2026-01서버·관리자출구 이중 인식 5분 무시, 방문 기록 비활성화
2026-03서버LPR–서버 시각 오차 5초 허용
2026-08앱·연동Android 16(API 36) 대응·로그 마스킹·첫 테스트(1.0.4), 변경 추적 버전 비교 버그 수정
2026-09서버업체 등록 재시도 상태 기록
2026-10-172.0 서버모노레포: 골격, 앱 호환 API, 주차장 커넥터, 관리자 API·웹, 이관 도구
2026-10-192.0 앱공통 네트워크·세션·단지 선택(R1), 디자인 시스템(R2)
2026-10-232.0 서버부천 단지 PC에 스택 가동. 방문 등록 → 업체 DB 반영 → 입차 → 출차 매칭 → 주차시간 합산 확인
2026-10-24앱2.0.0 App Store 업로드, 부천 단지 세대 명부 가져오기
2026-11앱Android 15+ edge-to-edge 대응, 가입 시트 수정, 2.0.0(16) Google Play 제출, 스토어 소개 자료 통일

10. 개발·배포·운영

10.1 앱 릴리스

flowchart LR
    subgraph Dev["개발"]
        D1["작업 브랜치"] --> D2["구현 + 테스트 작성"]
        D2 --> D3["flutter analyze<br/>flutter test"]
    end
    subgraph CI["GitHub Actions"]
        C1["push · PR마다<br/>analyze + test"]
    end
    subgraph Release["릴리스"]
        R1["main 병합"] --> R2["빌드 번호 올리기"]
        R2 --> R3["에뮬레이터 · 실기기 확인"]
        R3 --> A1["flutter build appbundle"] --> A2["Google Play<br/>프로덕션 검토 → 출시"]
        R3 --> I1["flutter build ipa"] --> I2["App Store Connect<br/>TestFlight → 심사 → 출시"]
    end
    subgraph Ops["운영"]
        O1["새 단지 추가"] --> O2["Remote Config sites 수정"] --> O3["앱 재배포 없이 반영"]
    end
    D3 --> C1 --> R1

10.2 단지 서버 배포

flowchart LR
    Dev["개발 Mac<br/>개발 · 테스트"] -- "git push" --> Git[("GitHub private")]
    Git -- "git pull · 배포 브랜치" --> Site
    subgraph Site["단지 운영 PC · Docker Compose"]
        Migrate["migrate (일회성)"] --> Srv["server"]
        Prx["proxy · Caddy<br/>Let's Encrypt"] --> Srv
        Prx --> Adm["admin"]
        Adm --> Srv
        Srv --> PG[("db")]
    end
    Router["공유기<br/>80 · 443 포워딩, DDNS"] --> Prx
    Srv -- "단지 내부망" --> VDB[("업체 DB")]
  • 앱 API는 https://<DDNS>/api, 관리자 웹은 같은 호스트의 별도 포트로 연다. 부천 단지는 구버전 앱용 평문 포트를 열지 않는다.
  • 비밀값(JWT 시크릿, DB 비밀번호, FCM 서비스 계정, 업체 DB 접속 정보)은 .env.production과 Compose secrets로만 주입하고 이미지·번들에 굽지 않는다.
  • 부천 단지 운영 PC는 Windows + Docker Desktop(WSL2)이라 로그인 후 Docker 자동 시작, 시간 동기화, 절전 해제 같은 Windows 설정을 따로 정리했다. 업데이트·롤백·백업·장애 대응 절차는 배포 문서로 남겼다.

10.3 운영하며 배운 것

  • 1.x 운영 중 폴링 서버 결함으로 입출차를 받지 못한 기간이 두 번 있었고, 재시작하면 그 사이 변경이 사라졌다. 2.0의 커서 영속·재동기화 설계는 이 경험에서 나왔다.
  • 운영 로그(입차 처리, 업체 등록 응답, 변경 추적 버전 증가량)를 분석해 실제 하루 처리량과 실패 패턴을 확인하고 2.0 설계 근거로 삼았다.
  • 서버·앱 로그 모두 토큰·비밀번호·전화번호·차량번호를 가린다.
  • 결정은 번호(D-01 ~ D-56)를 붙여 기록하고, 앱 API 계약 문서를 정본으로 삼았다. 사람과 AI 코딩 에이전트가 같은 규칙 문서(보안, 하위호환, 멀티 단지, 시간대 규칙)를 보고 작업한다.
  • 스토어 소개 문구·스크린샷·홍보 배너를 App Store와 Google Play에 같은 내용으로 맞췄다.

11. 품질과 테스트

앱

지표1.x2.0
자동 테스트5개390개 (38개 파일)
CIiOS 수동 워크플로뿐push·PR마다 analyze + test
화면에 직접 쓴 색407곳0곳 (가드 테스트)
로그인 직후 세대 정보 조회3회1회 (캐시)
자동 재로그인API 3개, 요청마다 따로 갱신모든 인증 API, 동시 요청도 갱신 1번
요청 타임아웃일부 호출에 없음모든 요청 15초
알림 권한 요청앱 시작 즉시로그인 직후

서버

종류내용
정적 검사Biome(lint·format), TypeScript strict
단위·통합Vitest + Testcontainers로 PostgreSQL 18, MSSQL 2022, MySQL 8.4를 실제로 띄워 약 370개
계약앱 25개 엔드포인트의 응답 키·타입·봉투·상태코드 고정
시나리오변경 추적 폴링 재시작·중복·지연, 업체 반영 수렴, 출차 매칭, 1.x 이관(MySQL 덤프 → PostgreSQL dry-run)
E2E서버를 띄우고 실제 HTTP로 가입 → 방문 등록 → 업체 DB 입출차 → 세션·주차시간
시간대전체 테스트를 TZ=UTC로 실행

12. 기술 스택과 결정 기록

영역1.x2.0
앱Flutter 3.29, setState, http_interceptor, firebase_messaging, Xcode CloudFlutter, get_it + ChangeNotifier, 공통 ApiClient, Firebase Messaging · Remote Config, 무채색 디자인 시스템
API 서버Node.js, Express 4, Sequelize 6, MySQL 8, JWT, bcrypt, firebase-admin, node-cronNode.js 26, TypeScript 6, Fastify 5, zod 4, PostgreSQL 18, Drizzle ORM, Luxon
주차관제 연동Node.js, Express 5, mssql, ws(WebSocket)통합 서버 안의 커넥터·폴러, mssql, SQL Server Change Tracking
관리자 웹Next.js 14, React 18, Tailwind, shadcn/ui, recharts, exceljs·xlsx, mysql2·mssql 직결Next.js 16 BFF(httpOnly 쿠키), shadcn/ui, recharts, 공유 zod 계약
인프라Windows PC, PM2, DDNS 평문 HTTPDocker Compose, Caddy 2(자동 TLS), Compose secrets
도구—npm workspaces, Biome, Vitest, Testcontainers, GitHub Actions

주요 결정(전체 56건):

ID결정
D-02메인 서버와 폴링 서버를 한 프로세스로 통합
D-08재로그인은 만료와 무관하게 서명·DB 세션 일치를 검증해 재발급
D-09무인증 방문 등록은 직전 인증 호출이 발급한 30분 티켓으로 방어
D-13업체 DB에는 방문 등록 반영만 쓴다
D-14변경 추적 커서 DB 영속, 멱등키 아웃박스, 최소 유효 버전 검사, 자기 쓰기 제외
D-16단지별 폴링·배치는 DB 잠금으로 단일 워커
D-22단지 시간대 변환은 공용 유틸 한 곳, 테스트는 TZ=UTC
D-26탈퇴는 본인 계정만(1.x의 세대 전체 삭제는 버그)
D-32세대주 가입은 세대 인증이 발급한 10분 티켓(같은 IP)으로만
D-45 · 46새 단지를 2.0으로 먼저, 단지 로컬 스택으로 배포
D-47 · 50앱은 단지 선택만 추가, 단지 목록은 Remote Config로 원격 관리
D-542.0 전용 앱 기능은 응답의 기능 감지 필드가 있을 때만 켠다
D-56단지별 업체 등록 테이블 INSERT 고정값을 설정으로

13. 회고와 다음 단계

회고

  • 1.x는 현장에 빨리 내놓는 데 집중했다. 앱·메인 서버·폴링 서버·관리자 웹을 각각 만들고 HTTP·WebSocket·DB 직결로 이었다. 덕분에 한 단지에서 실제 입주민이 쓰는 서비스를 일찍 운영할 수 있었다.
  • 운영하면서 구조의 한계가 드러났다: 재시작 때 입출차 유실, 시간대 가정, 무인증 관리자 웹, 테스트 부재, 단지를 늘릴 수 없는 구조. 현장 이슈마다 핫픽스를 이어 붙이는 방식으로는 감당하기 어려워졌다.
  • 2.0은 1.x 4개 저장소를 먼저 분석해 결함을 ID로 정리한 뒤, 현장 규칙과 결함을 가려 설계했다. 이미 설치된 앱을 깨지 않도록 API 계약을 동결하고, 앱 하나가 두 세대의 서버에서 모두 동작하게 했다.

다음 단계

과제내용
부천 단지 실차 검증실제 차량 LPR 입출차와 FCM 실발송을 확인하고 정식 오픈
노원 단지 2.0 전환이관 도구로 1.x 데이터를 옮기고 병행 운영 후 1.x 서버 종료
모르는 차량 신고입차 알림·이용내역에서 우리 세대가 등록하지 않은 차량을 신고. 2.0 전용 확장이라 기능 감지로만 켠다(설계 완료)
앱 타입 결과서비스 반환형 Map을 타입 결과로 바꾼다
운영 지표단지별 변경 추적 지연, 마지막 성공 시각, 실패 건수를 메트릭과 알림으로