본문으로 건너뛰기Hwanseok Sim

작성일

VisitCall 개발기 5 — 1.x 출시: Xcode Cloud와 스토어 배포

Flutter 앱을 App Store와 Google Play에 올리기까지의 빌드 실패, Xcode Cloud 스크립트 시행착오, Android 서명, 서버를 PM2로 상시 운영하게 된 과정(2025년 8~10월).

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

2025년 8월 중순부터 10월 말까지는 대부분 빌드를 통과시키고 한 단지에 내놓는 일이었다. 앱 버전은 1.0.0에서 1.0.1, 1.0.2로 올라갔다.

코드부터 한 번 훑었다

출시 전인 8월 11일에 코드 품질·보안·성능·아키텍처 분석 보고서를 한 번 써 두었다. 여기서 짚은 서비스별 HTTP 클라이언트와 부족한 테스트는 2.0 앱에서 다시 다룬다(8편).

Android와 iOS 빌드를 통과시키기

8월 14일에는 "빌드오류수정(안드로이드)" 커밋이 두 번 나갔고 iOS 권한 파일과 설정 파일도 같이 정리했다.

  • Firebase 설정 파일에서 분석과 광고 관련 항목을 껐다.
  • iOS 정보 파일에서 백그라운드 페치 권한을 뺐다. 이 앱은 푸시로 알림을 받으므로 쓰지 않는 권한을 선언해 둘 이유가 없었다.
  • 개발용 entitlements 파일을 만들었다.

8월 15일에는 버전을 1.0.0에서 1.0.1로 올렸고, 스플래시 화면이 최소 1초는 보이게 하면서 FCM 서비스를 추가했다. 2.0 앱도 같은 규칙을 쓴다.

그다음 주에 약관 동의 UI(8월 22일)와 환경설정 및 회원 탈퇴(8월 24일)가 들어갔다. 스토어에 올리는 앱이라면 약관 동의와 계정 삭제는 갖춰야 하는 항목이라 이 시기에 같이 넣었다.

Xcode Cloud 스크립트 시행착오

iOS 빌드는 Xcode Cloud로 하기로 했다. 문제는 Flutter 앱을 빌드하려면 CI 머신에 Flutter SDK가 있어야 하는데 Xcode Cloud에는 없다는 점이었다. 8월 23일부터 31일까지 아래처럼 고쳐 나갔다.

날짜메시지 요지
08-23Xcode Cloud 빌드 설정 추가, 앱 아이콘 자산 포함 설정
08-24빌드 스크립트 개선, 127 오류 해결
08-24빌드 오류 해결을 위한 설정 추가, pubspec.lock 의존성 갱신
08-31Flutter 설치 및 확인 로직 추가, 오류 메모 문서 추가

셸에서 종료 코드 127은 "명령을 찾을 수 없음"이다. flutter나 pod가 PATH에 없다는 뜻이었다. ci_scripts 디렉터리에는 같은 목적의 스크립트가 네 가지 이름으로 남아 있다. 최종판은 Flutter가 있을 만한 경로를 차례로 확인하고, 없으면 설치한다.

ios/ci_scripts/ci_pre_xcodebuild.sh
set -e
export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:$HOME/.pub-cache/bin"

# Flutter 바이너리 찾기
FLUTTER_ROOT=""
for path in "/usr/local/flutter" "$HOME/flutter" "/opt/flutter"; do
    if [ -d "$path" ]; then
        FLUTTER_ROOT="$path"
        break
    fi
done

# 없으면 stable 채널을 shallow clone으로 설치
if [ -z "$FLUTTER_ROOT" ]; then
    git clone "$FLUTTER_GIT_URL" -b stable --depth 1 "$HOME/flutter"
fi

처음부터 이 형태였던 것은 아니다. PATH를 출력하고 which dart, which flutter, which pod를 찍어 보는 디버그 출력이 길게 붙은 채로 몇 번을 고쳤다. CI는 원격이라 한 번 실행에 시간이 걸리므로 로그를 충분히 남겨야 원인을 좁힐 수 있었다. 그 뒤로 CI 스크립트는 첫머리에 PATH와 which 결과부터 찍게 했다.

Android: 서명과 패키지 이름

8월 26일 Android 앱 서명을 추가하고 패키지 이름을 바꿨다. 스토어에 한 번 올린 패키지 이름은 바꿀 수 없어서 올리기 전에 정해야 했다. 현재 빌드 설정은 릴리스 서명 정보를 별도 속성 파일(key.properties)에서 읽고, 파일이 없거나 값이 비어 있으면 릴리스 서명을 쓰지 않도록 되어 있다. 서명 정보는 저장소 밖에 둔다. 9월 12일에는 모든 서비스가 같은 서버 포트를 보도록 통일하는 커밋에서 패키지 이름이 한 번 더 바뀌었다. 첫 iOS 업로드는 10월 23일이었다.

개발 환경 설정을 앱 밖으로

9월 11일 dotenv를 도입해 서버 주소를 코드 상수에서 .env로 옮겼다. 같은 커밋에 HTTP 로깅 인터셉터도 들어갔다. 이틀 뒤인 9월 13일 커밋은 "JSON 파싱 에러 수정 및 안전한 타입 변환"이다. 주차 사용량 모델의 파싱 문제를 고치면서 모델 전반에서 타입 변환을 안전하게 바꿨다. 서버 응답의 타입이 기대와 다르면 앱이 죽는 일이 없게 하려는 수정이다.

서버: 단지용 판과 PM2

앱이 스토어로 가는 동안 서버도 "개발 서버"에서 "단지 운영 서버"로 바뀌었다. 9월 11일 메인 서버를 단지 운영판 저장소로 갈라 내면서 시작 시 초기화 과정을 줄이고 데이터 보정 로직을 껐다. 10월 23일에는 PM2로 상시 실행하도록 설정을 추가했다.

ecosystem.config.js
module.exports = {
  apps: [{
    name: 'visitcall-server',
    script: './src/index.js',
    instances: 1,
    autorestart: true,
    max_memory_restart: '1G',
    max_restarts: 10,
    restart_delay: 4000,
    log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
  }],
};

운영 PC는 Windows였고 PM2로 프로세스를 지키는 구성이었다. 앱이 연결하는 주소는 DDNS를 쓴 평문 HTTP였다. 나중에 보니 앱이 다른 http 호스트는 거부하도록 만들어져 있어서, 이 주소는 서버를 옮기기 어렵게 하는 제약이 됐다(8편). PM2는 프로세스가 죽으면 다시 띄워 주지만 폴링 서버가 재시작 때 상태를 잃는 문제는 막지 못했고, 그 결과는 6편에 있다.

10월 22일에는 며칠씩 머무는 방문 차량을 위한 방문 기간 설정을 넣었다. 이 기능이 6편의 첫 사건을 만든다.

이전·다음 글