본문으로 건너뛰기Hwanseok Sim

작성일

VisitCall 개발기 8 — 2.0 클라이언트 호환과 부천 단지 배포

앱 하나가 1.x와 2.0 서버에서 모두 동작하도록 단지 선택과 공통 네트워크 계층을 넣고, 부천 단지 로컬 PC에 Docker Compose 스택을 올려 스토어 배포까지 마친 과정(2026년 10~11월).

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

2.0 서버가 있어도 입주민 휴대폰에 설치된 앱은 노원 단지의 1.x 서버만 알고 있었다. 새 단지(부천 단지)를 2.0으로 먼저 열기로 했기 때문에, 하나의 앱이 두 종류의 서버에 모두 붙어야 했다.

앱은 새 저장소에서 이어 갔다

앱 코드는 1.x 앱 저장소의 이력을 통째로 가져온 새 저장소에서 고쳤다. 10월 19일의 커밋이 1.x 클라이언트 이력을 2.0 클라이언트의 기반으로 가져온 것이다. 스토어 항목은 그대로 두어 기존 사용자가 같은 앱을 업데이트로 받는다. 서버 쪽 호환 계약과 같은 원칙으로, 2.0 전용 기능은 응답에 기능 감지 필드가 있을 때만 켜고 없으면 1.x와 똑같이 동작한다.

사용자가 보는 기능은 거의 바뀌지 않고, 바뀌는 것은 "어느 서버에 붙는가"와 그 밑의 공통 계층이다.

앱 재배포 없이 단지를 추가한다

앱은 시작할 때 단지 목록을 구하고, 사용자가 고른 단지의 서버로만 모든 요청을 보낸다. 목록은 Firebase Remote Config의 sites 키에서 받는다. 단지를 추가하거나 주소를 바꿀 때 앱을 다시 심사받을 필요가 없다. 목록을 구하는 순서와 검증 규칙은 다음과 같다.

lib/src/core/site/site_directory.dart
/// 사이트 목록: 원격(Remote Config) → 마지막 성공 목록(로컬 캐시) → 앱 번들 기본 목록 순.
///
/// 항목 규칙: id·name·baseUrl이 비지 않은 문자열, isAllowedBaseUrl 통과,
/// id 중복이면 앞의 것만. baseUrl 끝의 '/'는 제거한다.

isAllowedBaseUrl은 보안을 위한 검사다. 원격 설정이 바뀌더라도 앱이 아무 주소나 받아들이면 안 된다. 앱은 https 주소와, 기존 단지의 레거시 주소 하나만 허용한다. 1.x 서버는 평문 HTTP였고 앱이 다른 http 호스트는 거부하도록 만들어져 있었기 때문에 레거시 주소 하나만 예외로 둔 것이다.

단지를 바꾸면 먼저 로그아웃하고 기기 토큰을 지운다. 두 서버의 세션과 FCM 토큰이 섞이면 알림이 엉뚱한 곳으로 가기 때문이다.

토큰 갱신은 한 번만 한다

1.x 앱은 서비스마다 HTTP 클라이언트와 재시도 로직을 가졌고, 토큰이 만료되면 탭마다 따로 재로그인했다. 자동 재로그인이 걸린 API는 세 개뿐이었다. 2.0에서는 모든 호출이 하나의 ApiClient를 지나간다. 인증이 필요한 요청이 401이나 403을 받으면 토큰을 갱신하고 한 번만 다시 시도한다. 동시에 여러 요청이 만료를 만나면 갱신이 여러 번 일어날 수 있어서 이 부분을 따로 막았다.

lib/src/core/network/api_client.dart
/// 진행 중인 토큰 갱신. 동시에 401/403을 받은 요청들이 함께 기다린다.
Future<String?>? _refreshing;

Future<String?> _refreshToken(AuthTokenProvider auth) async {
  final refreshing =
      _refreshing ??= Future.sync(auth.refreshToken)
          .whenComplete(() => _refreshing = null);
  try {
    return await refreshing;
  } catch (error) {
    return null;
  }
}

진행 중인 갱신 Future가 있으면 새로 만들지 않고 같은 것을 기다린다. 예약 확인 탭과 사용기록 탭이 동시에 요청을 보내 둘 다 401을 받아도 재로그인 호출은 한 번이다. 이 부분은 동시 요청 단위 테스트로 고정했다.

세션 복원 결과도 구분했다. 자동 로그인에서 실패한 이유가 서버의 거부인지 일시적인 네트워크 오류인지에 따라 앱이 할 일이 다르다.

lib/src/core/session/relogin_result.dart
/// 서버가 401로 세션을 거부했다. 로그인 정보를 지워야 한다.
final class ReloginUnauthorized extends ReloginResult {}

/// 네트워크 오류·타임아웃·5xx 등 일시 실패. 로그인 정보를 유지한다.
final class ReloginFailed extends ReloginResult {}

1.x는 네트워크가 끊겨도 로그인 정보를 지웠고, 로그아웃은 저장소 전체를 비웠다. 이제는 "거부"와 "실패"를 나눠서 일시적인 오류로는 로그인을 풀지 않는다.

1.x 서버와 맞추기

앱 하나가 두 서버에 붙으면 서버끼리 다른 점을 앱이 흡수해야 한다. 가장 먼저 나온 것은 세대주 가입 응답 코드다. 1.x 서버는 가입 성공에 201을 돌려주고, 앱은 200만 성공으로 처리하고 있었다. 계정은 만들어지는데 화면에는 실패로 나왔다. 2.0 서버는 200을 주기로 했고, 앱은 200과 201을 모두 성공으로 보도록 바꿨다(10월 23일, 2.0.0 빌드 12번).

이 수정은 노원 단지 1.x 운영 서버를 상대로 한 노원 호환 테스트 계획을 쓰는 과정에서 나왔다. 운영 서버에서 관리사무소에 요청한 테스트 세대로 진행한다. 레거시 복제 서버는 만들지 않는다. 이번 회차는 iOS TestFlight만 본다. 문서와 결과 기록에 운영 호스트와 실제 이름·전화번호·차량번호를 쓰지 않는다는 규칙도 문서 첫머리에 적었다.

iOS 27 SDK와 Android 15에서 생긴 문제

같은 시기에 플랫폼 쪽 문제가 나왔다.

증상원인해결
iOS 27 SDK로 빌드한 앱이 시작하자마자 종료UIScene 생명주기가 없는 앱을 새 SDK가 거부Scene 매니페스트와 SceneDelegate 도입 (10월 23일)
Android 15 이상에서 하단 버튼이 내비게이션 바에 가려 눌리지 않음targetSdk 36에서 edge-to-edge가 강제됨로그인·가입·비밀번호·알림·약관 화면과 가입 시트에 SafeArea (11월 1일)

확인은 실기기와 에뮬레이터에서 했다. Android는 48픽셀 가상 inset을 넣는 위젯 테스트도 추가했다. 11월 1일에는 세대 정보 확인 시트의 입력 오류와 로딩 표시가 시트 뒤에 가려지는 문제도 고쳤다. 바텀시트는 부모의 setState로 다시 그려지지 않기 때문에 StatefulBuilder로 시트 안에서 갱신한다. 앱 버전은 2.0.0 빌드 10번에서 시작해 16번까지 올라갔다. 프로젝트 소개 문서 기준 2.0 앱의 자동 테스트는 390개다.

부천 단지에 서버 올리기

서버 쪽은 docs/deployment.md에 신규 단지 로컬 서버 배포 절차를 먼저 썼다. 사전 준비, 설치, 네트워크, 환경변수와 비밀값, 기동, 사이트 설정, 검증, 오픈, 운영(업데이트·롤백·백업·장애 대응) 순서다. 배포 단위는 "단지 로컬 스택"이다. 업체 DB를 외부에 열지 않고 단지 내부망에서만 접속하기 위해서다.

스택은 Docker Compose의 네 서비스다. 리버스 프록시(Caddy), 통합 서버, 관리자 웹, PostgreSQL이다. 이 단지는 구버전 앱이 올 일이 없으므로 평문 HTTP 진입점을 호스트에 열지 않는 설정 파일을 덧붙여 기동한다.

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

프록시는 이렇게 설정했다.

  • 에러 본문은 항상 JSON이어야 한다. 프록시가 HTML 오류 페이지를 내면 앱이 "서버 연결 실패"로 처리한다.
  • 업스트림 대기를 연결 3초와 응답 헤더 10초로 제한했다. 합쳐서 13초이므로 앱의 타임아웃 15초보다 짧다.
  • 접근 로그를 켜지 않았다. 앱 경로에 차량번호나 전화번호가 들어갈 수 있어서 평문 로그로 남기면 안 된다. 필요하면 쿼리를 지우고 경로를 마스킹한 뒤에 켠다는 주석을 달았다.
  • 비밀값은 환경 파일과 Compose secrets로만 주입하고 이미지에 굽지 않는다.

부천 단지의 운영 PC는 Windows 11에 Docker Desktop(WSL2)이었다. 문서에서 권장한 Linux 서버가 아니라서 Windows 설정을 따로 정리했다. BIOS의 CPU 가상화 켜기, 시간 동기화(w32time) 확인, 전원 연결 시 절전·최대 절전 끄기, 로그인 후 Docker 자동 시작 같은 항목이다. 입출차 시각을 서로 비교하는 서비스라서 시계 동기화를 확인 항목에 넣었다. 이 PC에서 쓰는 명령은 scripts\vc.cmd 래퍼로 Compose 옵션을 고정하고, 업체 DB 연결 점검(vendor-check)도 한 줄로 부른다.

공개 HTTPS에서는 막히는 곳이 있었다. DDNS 도메인에 인증서를 자동 발급하려는데, 일부 DDNS 업체의 도메인은 CAA 레코드로 자체 인증서 계정만 허용한다. 그래서 우리 도메인에 CNAME을 두고 거기서 발급하는 방법을 문서에 남겼다(10월 23일).

업체 DB 쪽에서는 단지마다 등록 테이블의 컬럼 구성이 달랐다. 노원 단지는 21컬럼, 부천 단지는 37컬럼이었다. 기본값이 없는 필수 컬럼은 단지마다 알맞은 값으로 채워야 했다. 그래서 등록 행을 넣을 때 쓰는 고정값을 단지별 설정 항목으로 뺐다(결정 D-56, 10월 23일).

같은 날 부천 단지 PC에서 스택이 올라갔고, 방문 등록 → 업체 DB 반영 → 입차 → 출차 매칭 → 주차시간 합산 흐름을 확인했다. 이튿날 앱 2.0.0이 App Store에 업로드됐고 세대 명부를 가져왔다.

지금 상태와 남은 일

노원 단지는 1.x 서버를 계속 쓰고, 부천 단지는 2.0 서버로 운영을 시작했다. 앱은 하나이고 어느 단지를 고르는지에 따라 붙는 서버가 달라진다. 노원 단지의 1.x 데이터를 옮기는 이관 도구는 만들어 두었지만 아직 쓰지 않았다. 돌아보면 1.x 폴링 서버의 커서를 DB에 남기는 일을 2.0까지 미룬 것이 가장 비싸게 돌아왔다.

남은 일은 부천 단지의 실차 검증과 정식 오픈, 노원 단지의 2.0 전환, 모르는 차량 신고 기능, 단지별 변경 추적 지연을 지표로 보는 일이다. 실제 차량의 번호판 인식과 푸시 실발송 검증이 아직 남아 있다.

이전 글

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