- 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
입주민이 손님 차량번호를 앱에 미리 등록하면 단지 차단기가 번호판을 읽어 바로 통과시키고, 차량이 들어오면 세대 휴대폰으로 알림이 온다. 관리사무소는 웹에서 세대별 방문 주차시간과 통계를 관리한다.
입주민 앱, 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.4 | App Store Connect · Google Play 수동 릴리스. 2.0.0 |
3. 서비스 화면
3.1 입주민 앱 (Flutter)
iPhone 시뮬레이터에서 부천 단지 테스트 세대로 찍은 현재 버전(2.0) 화면이다. 번호는 4장 사용 예시에서 가리키는 화면 번호다.
![]() ① 단지 선택 단지 목록은 원격 설정에서 받아 앱 재배포 없이 추가 |
![]() ② 로그인 선택한 단지 표시, 단지 변경, 자동 로그인 |
![]() ③ 세대주 회원가입 세대 인증 → 약관 동의 → 가입 3단계 |
![]() ④ 방문 차량 예약 차량번호 형식 검사 후 당일·기간 방문 등록 |
![]() ⑤ 예약 확인 예정·입차·출차 상태와 입출차 시각 |
![]() ⑥ 우리집 사용기록 실시간 현황, 월별 사용량, 한도 상태 |
![]() ⑦ 내 정보 세대 정보, 구성원·비밀번호·설정으로 이동 |
![]() ⑧ 우리집 구성원 세대원 승인·거부·삭제 |
![]() ⑨ 환경설정 단지 변경, 로그아웃, 약관, 회원 탈퇴 |
- 상태는 아이콘 + 글자로. 예정·입차·출차·승인 대기·한도 임박을 색이 아니라 아이콘과 라벨로 구분한다.
- 되돌릴 수 없는 작업은 한 번 더 확인. 회원 탈퇴는 "위험 영역"에 따로 두고, 입차한 예약은 삭제 버튼 대신 잠금 안내를 보여 준다(서버도 삭제를 막는다).
- 오류는 원인별로. 차량번호 형식 오류와 통신 오류를 나눠 보여 주고, 입력 오류는 입력란 바로 아래에 표시한다.
3.2 관리자 웹 (Next.js)
2.0 관리자 웹이다. 개발 DB에 데모 데이터(데모단지 세대 96, 예약 약 2,200건, 입출차 세션 약 2,100건)를 넣고 찍었다. 연락처는 서버가 가려서 내려 준다.
통계 현황: 지금 주차 중, 오늘 방문, 평균 주차시간, 피크 시간대와 월별 추이. 날짜 기준은 단지 시간대다.
방문차량 등록 현황: 예약과 실제 입출차 세션을 함께 본다. 행을 펼치면 재입차를 포함한 세션 상세가 나오고, 엑셀로 내보낼 수 있다.
사이트 설정: 단지마다 다른 값(업체 단지 코드, 방문 코드, 중복 출차 무시 시간, LPR 시각 오차, 입차 게이트, 동·호 형식, 방문목적 코드표, 허용시간 기본값)과 커넥터 설정. 접속 비밀값은 저장하지 않고 이름(secretRef)으로만 참조한다. 오른쪽은 변경 추적 커서와 이벤트·등록 대기 현황이다.
| 고급 통계 | 세대 관리 |
|---|---|
![]() | ![]() |
| 최근 30일 시간대별 입출차, 세대별·차량별 방문 순위 | 동·호·세대주·앱 계정 수. CSV 가져오기(UTF-8·CP949 자동 판별) |
| 허용 주차시간 설정 | 허용시간 사용 현황 |
|---|---|
![]() | ![]() |
| 세대별 월·일 허용시간. 설정 없는 달은 이전 달 값을 이어 쓴다 | 사용률(정상 · 80% 이상 · 초과 · 무제한) |
| 방문 현황 및 세대원 | 차량별 방문 현황 |
|---|---|
![]() | ![]() |
| 세대별 방문 수, 사용·허용 시간, 초과 표시 | 차량별 방문 횟수, 최근 세대, 평균 주차시간 |
| 세대주 정보 변경 요청 | 지원센터 방문 차량 | 방문 기록 비활성화 |
|---|---|---|
![]() | ![]() | ![]() |
| 앱에서 들어온 이름·전화 변경을 승인·거절 | 택배·점검 등 업체에 직접 등록된 차량을 방문목적 코드로 분류 | 공사·점검 기간에는 입출차 기록을 남기지 않음 |
| 로그인 | 사이트 관리 | 관리자 관리 |
|---|---|---|
![]() | ![]() | ![]() |
| 관리자 토큰은 BFF가 httpOnly 쿠키로 보관 | 전체 관리자는 여러 단지를 등록·전환 | 전체(super)·단지(site) 범위 관리자 |
4. 사용 예시
4.1 입주민: 처음 설치하고 손님 차량 등록하기
| 단계 | 사용자 | 시스템 |
|---|---|---|
| 1 | 앱을 열고 ①에서 부천 단지를 고른다 | 이후 모든 API를 부천 단지 서버로 보낸다 |
| 2 | ③에서 아이디·비밀번호를 입력하고, 관리사무소에 등록된 이름·휴대폰으로 세대를 확인한다 | 서버가 세대 명부와 대조하고 10분짜리 가입 티켓을 발급한다 |
| 3 | 약관에 동의하고 가입한 뒤 로그인한다 | 약관 동의를 증빙으로 저장한다. 로그인 직후 알림 권한을 묻고 기기 토큰을 등록한다 |
| 4 | ④에 손님 차량번호를 입력하고 방문일(당일 또는 기간)을 고른다 | 형식을 검사하고, 확인 시트로 한 번 더 보여 준 뒤 등록한다 |
| 5 | (손님 도착) | 방문일이 오늘이면 바로 업체 등록 테이블에 반영돼 차단기가 방문 차량으로 인식한다. 입차하면 "입차" 푸시가 오고 ⑤가 입차로 바뀐다 |
| 6 | (손님 출발) | 출차를 매칭해 세션을 닫는다. ⑥의 이번 달 사용 시간에 더해진다 |
등록이 거절되는 경우:
| 상황 | 서버 응답 | 앱 안내 |
|---|---|---|
| 같은 날 같은 차량을 이미 등록함 | 409 VISITOR_DUPLICATE | "이미 등록된 방문 차량"과 상세 사유, "출차 후 다시 등록해주세요" |
| 입주민 차량을 방문 차량으로 등록함 | 409 RESIDENT_CAR | "입주민 차량은 방문 등록이 불가능합니다" |
| 형식이 맞지 않는 번호 | isValid: false | 입력란 아래에 "올바르지 않은 차량 번호 형식입니다" |
| 네트워크 오류·시간 초과(15초) | — | 형식 오류와 구분해 통신 오류로 알린다 |
4.2 입주민: 가족과 함께 쓰기
- 가족이 ②에서 세대원 가입을 하면 승인 대기 계정이 되고, 세대주에게 "세대원 가입 요청" 푸시가 간다.
- 세대주가 ⑧에서 승인한다(거부·삭제도 여기서 한다).
- 가족은 "승인 완료" 푸시를 받고 다시 로그인하면 같은 세대의 예약·알림·사용량을 함께 본다.
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.x | 2.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, 평문 HTTP | Docker 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, enterTime2.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, 앱 루트가 로그인 화면으로 보낸다
end6.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.x | 2.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.x | 2.0 | 이유 |
|---|---|---|
visitors 한 행 = 예약 + 입출차, 재입차하면 행 복제 | reservations 1 : parking_sessions N | 같은 손님의 하루 여러 번 출입을 한 예약 아래 쌓는다 |
| 입출차를 메모리 이벤트로 바로 처리 | parking_events 아웃박스 + ct_cursors | 폴러는 저장과 커서 전진을 한 트랜잭션으로 하고, 처리는 소비자가 따로 한다. 처리 실패가 커서를 막지 않는다 |
| 업체 등록 실패는 로그만(이후 재시도 테이블 추가) | 예약의 반영 상태(projection_status) | 예약 저장과 업체 반영을 분리해 실패해도 예약은 남고 배치가 다시 시도한다 |
usage_parkingtime + 월말 백업 + 이월 cron | parking_time_allocations(host, month) 상속 | 배치와 백업 테이블이 필요 없다 |
| 세대주 정보 변경 요청을 JSON 파일에 저장 | pending_host_updates 테이블 | 관리자 승인 이력과 감사 로그를 남긴다 |
| 기록 비활성화 기간이 전역 | history_disable_periods(site_id, …) | 단지별로 다르다 |
한국 시간 벽시계 DATETIME | timestamptz(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가 강제됐다 | 로그인·가입·비밀번호·알림·약관 화면과 가입 시트에 SafeArea | 48px 가상 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-17 | 2.0 서버 | 모노레포: 골격, 앱 호환 API, 주차장 커넥터, 관리자 API·웹, 이관 도구 |
| 2026-10-19 | 2.0 앱 | 공통 네트워크·세션·단지 선택(R1), 디자인 시스템(R2) |
| 2026-10-23 | 2.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 --> R110.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.x | 2.0 |
|---|---|---|
| 자동 테스트 | 5개 | 390개 (38개 파일) |
| CI | iOS 수동 워크플로뿐 | 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.x | 2.0 |
|---|---|---|
| 앱 | Flutter 3.29, setState, http_interceptor, firebase_messaging, Xcode Cloud | Flutter, get_it + ChangeNotifier, 공통 ApiClient, Firebase Messaging · Remote Config, 무채색 디자인 시스템 |
| API 서버 | Node.js, Express 4, Sequelize 6, MySQL 8, JWT, bcrypt, firebase-admin, node-cron | Node.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 평문 HTTP | Docker 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-54 | 2.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을 타입 결과로 바꾼다 |
| 운영 지표 | 단지별 변경 추적 지연, 마지막 성공 시각, 실패 건수를 메트릭과 알림으로 |




















