종량제 200GB/월 네트워크, USB 수동 배포, 전체 빌드 재배포. 이 세 가지 제약을 해결하기 위해 에셋/코드 분리 배포 전략과 온프레미스 Supabase 기반 배포 파이프라인을 설계하고 구축한 기록.
제약 조건과 기존 방식의 한계
네트워크 제약
현장 키오스크는 종량제 인터넷을 사용한다. 약 1000대의 전체 기기 합산 월간 업로드/다운로드 200GB가 한도이며, 이 안에서 앱 업데이트, 통계 동기화, 고객 사진 전송, 오류 리포트가 모두 처리되어야 한다.
USB 수동 배포의 한계
이전에는 빌드 결과물을 USB에 담아 현장 직원이 기기마다 직접 복사했다. 이 방식에서 발생한 문제:
- 배포 한 건에 현장 방문이 필요
- 긴급 버그픽스에도 물리적 이동 시간이 소요
- 롤백 시 이전 버전 USB를 다시 들고 가야 함
- 여러 기기에 서로 다른 버전이 설치되는 상황 발생
결과적으로 배포, 오류 대응, 롤백, 업데이트 모두 현장 직원의 물리적 이동에 의존하는 구조였고, 기민한 대응이 불가능했다.
배포 빈도 분석과 분리 전략
분석
실제 배포 이력을 분석한 결과, 에셋(UI 텍스처, 오디오, 씬)이 변경되는 경우는 드물었다. 대부분의 배포는 코드 버그픽스와 로직 수정이었다. Unity의 기본 빌드는 에셋과 코드가 하나의 바이너리로 묶이기 때문에, 코드 한 줄을 고쳐도 전체 빌드를 다시 만들어야 했다. 여기에 네이티브 DLL(프린터 SDK, 카드리더 SDK)과 그 종속 라이브러리가 빌드 바이너리의 상당 부분을 차지했다.
분리 전략
이 분석을 바탕으로 세 가지를 분리했다.
에셋과 코드의 분리. Unity Addressables로 에셋을 독립 번들로 빌드하고, 코드만 포함된 Player Build를 별도 배포. 에셋만 변경됐으면 번들만 서버에 올리고, 코드만 변경됐으면 앱 바이너리만 배포한다.
네이티브 DLL의 분리. 동적 DLL 로더(SDK 클래스, Reflection.Emit 기반)를 구현하여 네이티브 SDK를 빌드에 포함하지 않고 런타임에 외부 경로(D:\DATA_IFOTO\SDK)에서 로드. 프린터 SDK 4종(ChcusbProxy, CxStat64, Cx2Stat64, CPUSBM1)과 종속 라이브러리가 빌드 바이너리에서 제외되어 배포 크기가 줄어든다. (동적 DLL 로더의 기술적 구현은 네이티브 SDK 통합 참조)
앱/에셋/DLL 3중 분리의 효과. 코드 수정만 있는 배포에서는 Player Build ZIP만 전송. 에셋 번들은 이전 캐시를 사용하고, 네이티브 DLL은 현장에 이미 설치되어 있으므로 배포 대상에서 제외.
온프레미스 Supabase 서버
선택 이유
자체 서버에서 Supabase를 Docker로 운영한다. 클라우드 Supabase가 아닌 온프레미스를 선택한 이유는 운영 자유도가 선택 기준이었기 때문이다. 장애 발생 시 SSH로 접속하여 Docker 컨테이너 로그 확인, PostgreSQL 직접 쿼리, 설정 변경 후 즉시 재배포가 가능해야 했다.
Supabase의 세 가지 역할
단순한 데이터베이스가 아니라, 배포 파이프라인의 백엔드 전체를 담당한다.
| 역할 | Supabase 기능 | 용도 |
|---|
| 버전 관리 DB | PostgreSQL + REST API | app_versions 테이블로 채널별 버전 관리, RLS 정책으로 접근 제어 |
| 바이너리 CDN | Storage Public 버킷 | 앱 빌드 ZIP, Addressables 번들을 인증 없이 HTTP GET으로 배포 |
| 사진 전달 CDN | Storage Public 버킷 | 고객 촬영 사진을 업로드하고 Public URL을 QR 코드로 인코딩하여 전달 |
세 버킷 모두 Public 읽기가 가능하다. 키오스크가 인증 없이 에셋과 빌드를 다운로드하고, 고객이 QR 코드로 사진에 접근할 수 있어야 하기 때문이다. 업로드는 Service Role Key로만 가능하며, RLS 정책으로 is_active = true인 버전만 공개 조회를 허용한다.
실시간 오류 보고
기기별 장애 발생 시 error_report 테이블에 실시간 전송한다. device_id, device_type, message, timestamp를 기록하여 어떤 기기에서 어떤 장비가 언제 어떤 오류를 냈는지 원격으로 즉시 추적할 수 있다.
// 각 디바이스 시스템에서 오류 감지 시
Systems.Network.Alarm(DeviceType.Printer, "Paper empty").Forget();
Systems.Network.Alarm(DeviceType.Camera, "Disconnected").Forget();
device_status 테이블은 프린터, 카메라, IOBoard, 카드리더, 지폐기의 현재 상태를 기기별로 기록한다. 이 두 테이블의 조합으로 기존에 3~7일 걸리던 장애 원인 파악이 즉시 추적으로 전환됐다.
빌드 배포 파이프라인
전체 흐름
flowchart LR
subgraph 개발PC["개발 PC"]
A1["Unity Editor\nAddressables Build"]
A2["Unity Editor\nPlayer Build"]
A3["BuildUploader\n(Qt 6, C++17)"]
end
subgraph 서버["온프레미스 Supabase"]
B1["Storage\naddressables/"]
B2["Storage\napp-builds/"]
B3["DB\napp_versions"]
end
subgraph 현장["키오스크"]
C1["UpdateController\n버전 체크 + 카탈로그 비교"]
end
A1 -->|".bundle, .hash"| A3
A2 -->|"ZIP"| A3
A3 -->|"델타 업로드"| B1
A3 -->|"ZIP + 메타데이터"| B2
A3 -->|"버전 등록"| B3
B1 -->|"번들 다운로드"| C1
B2 -->|"앱 다운로드"| C1
B3 -->|"버전 조회"| C1
BuildUploader: Qt 6 배포 도구
Tools/BuildUploader/에 위치한 Qt 6 기반 데스크톱 GUI 도구. 두 가지 업로드 모드를 제공한다.
앱 빌드 업로드. ZIP 파일 선택 시 파일명에서 버전을 자동 추출. Supabase Storage에 업로드 후 app_versions 테이블에 버전 정보(채널, checksum, 릴리즈 노트)를 등록한다. 채널은 stable, beta, dev 중 선택하며, 키오스크는 자신의 채널에 해당하는 최신 active 버전만 조회한다.
Addressables 델타 업로드. SHA256 매니페스트(.upload-manifest.json)와 비교하여 변경분만 업로드한다. 파일 크기 비교를 SHA256 해시 연산보다 먼저 수행하여, 변경되지 않은 파일에 대한 해시 연산을 회피한다.
이전 매니페스트 로드
|
파일별 비교:
매니페스트에 없음 -> [NEW] -> 업로드 대상
파일 크기 불일치 -> [CHANGED] -> 업로드 대상
크기 동일 + SHA256 불일치 -> [CHANGED] -> 업로드 대상
크기 동일 + SHA256 일치 -> [OK] -> 스킵
|
매니페스트에만 존재 (폴더에 없음) -> 서버에서 삭제
클라이언트 자동 업데이트
UpdateController가 앱 시작 시 실행한다. 순서가 중요하다. 앱이 먼저 업데이트되어야 새 에셋 스키마와 호환되므로 순서를 강제했다.
- 앱 버전 체크: Supabase REST API로 채널별 최신 active 버전 조회,
System.Version 파싱으로 비교
- 앱 다운로드: ZIP 다운로드 + SHA256 체크섬 검증. 동일 파일이 이미 존재하고 체크섬이 맞으면 다운로드 스킵
- Addressables 카탈로그 비교:
CheckForCatalogUpdates()로 해시 비교, 변경 시 새 카탈로그 수신
- 번들 다운로드:
GetDownloadSizeAsync()가 0보다 크면 변경된 번들만 다운로드
배포 인프라의 가치
Supabase를 단순한 데이터베이스로 활용한 것이 아니라, Storage를 CDN으로, REST API를 버전 관리 엔드포인트로, RLS를 접근 제어로 활용하여 배포 파이프라인의 백엔드 전체를 구성했다. 200GB/월 종량제 제약 하에서 에셋/코드/DLL 3중 분리와 델타 업로드로 전송량을 최소화하면서, USB 수동 배포를 원격 자동 배포로 전환한 구조.
오프라인 내결함성
네트워크 연결이 없어도 키오스크의 핵심 기능(촬영, 인쇄, 결제)은 동작해야 한다.
초기화 타임아웃 (10초). Supabase 클라이언트 초기화에 10초 타임아웃을 적용한다. 실패 시에도 IsInitialized = true를 설정하여 이후 코드에서 무한 재시도에 빠지지 않도록 한다.
자동 재연결 (5초). Send() 호출 시 연결이 끊긴 상태면 5초 타임아웃으로 재연결을 시도한다. HttpRequestException 발생 시 IsConnected = false로 상태를 갱신하고 조용히 실패한다.
통계 동기화 복구. StatisticsSystem의 RecordLoop는 로컬 파일을 우선 저장한다. 네트워크 복구 시 Math.Max 병합으로 로컬과 서버 중 큰 값을 취하여 데이터 손실 없이 동기화한다. 통계 카운터는 단조증가하므로 큰 값이 항상 최신이다.
결과
- USB 수동 배포 완전 제거. 원격 배포로 전환
- 에셋만 변경 시 앱 재빌드 없이 번들만 업로드하여 배포 완료
- 델타 업로드로 전송량 90% 이상 절감
error_report 실시간 전송으로 장애 원인 파악 3~7일에서 즉시 추적으로 전환
- 네트워크 장애 시에도 핵심 기능 정상 동작