화면 한 줄 고칠 때마다 APK를 다시 깔지 않으려고 — 하이브리드 앱에 웹 화면 OTA 붙이기
개인 프로젝트로 작은 앱을 하나 굴리고 있다. 쓰는 사람은 몇 명 안 된다. 웹사이트가 본체고, 안드로이드 앱은 그 웹 화면을 그대로 싣는 껍데기다. Capacitor로 만들었다.
구조 자체는 마음에 든다. 빌드 스크립트가 굽는 dist/ 폴더를 웹은 Cloudflare Worker로 내보내고, 앱은 APK 안에 넣어서 WebView로 띄운다. 웹과 앱이 받는 파일이 바이트까지 같으니 두 화면이 갈라질 수가 없다. 규칙으로 지키는 게 아니라 구조가 막는다.
문제는 그 "APK 안에 넣는다"였다.
화면 한 줄인데 APK를 다시 받아야 한다
웹은 커밋하면 훅이 알아서 배포한다. 몇 분이면 새 화면이다.
앱은 다르다. APK 안의 dist/는 빌드한 순간의 스냅샷이다. 버튼 여백 하나, 오타 하나를 고쳐도 앱에 반영하려면 이걸 다 해야 했다.
- 맥에서 릴리스 APK를 빌드한다 (서명 키가 맥에만 있다)
- 다운로드 서버에 올린다
- 쓰는 사람들이 앱의 "새 버전" 알림을 보고, 크롬으로 받아서, 설치를 누른다
스토어를 안 거치니 심사는 없다. 그래도 귀찮다. 특히 3번은 내가 어떻게 할 수 없는 부분이다. 받는 사람이 안 누르면 그 폰은 계속 옛 화면이다.
솔직히 제일 무서운 건 귀찮음보다 "모른다"였다. 웹을 배포하고 APK를 깜빡하면 앱만 조용히 뒤처진다. 한번은 이걸 빠뜨릴 뻔해서, 배포 스크립트에 "앱에 실리는 파일이 바뀌었으니 APK도 다시 만들어야 한다"고 소리치는 단계를 따로 넣었을 정도다. 근데 그건 알림일 뿐이고, 결국 누군가 APK를 굽고 누군가 깔아야 한다.
혹시 하이브리드 앱 만들어 본 사람이라면 이 기분 알 거다. 화면은 웹인데 배포는 네이티브의 속도로 움직인다.
OTA가 뭔가 — 껍데기는 두고 내용물만 바꾸기
OTA는 Over-The-Air, 그냥 "망으로 받아서 갱신한다"는 뜻이다. 여기서 말하는 건 앱 전체가 아니라 웹 화면만이다.
하이브리드 앱은 두 층으로 나눠 볼 수 있다.
- 껍데기: 자바/코틀린 코드, 플러그인, 권한, 아이콘. 이게 바뀌면 APK를 다시 깔아야 한다.
- 내용물: HTML, JS, CSS, 데이터 파일. WebView가 읽어서 그리는 것.
내용물만 바뀌었으면 굳이 껍데기까지 다시 깔 이유가 없다. 앱이 "새 화면 파일 있나?" 하고 물어서, 있으면 받아 기기 저장소에 두고, 다음에 켤 때 APK 안의 파일 대신 그걸 읽게 하면 된다. 개념은 이게 전부다.
Capacitor에는 이걸 받쳐 주는 자리가 이미 있다. serverBasePath라는 설정인데, WebView가 파일을 어느 폴더에서 읽을지를 바꿔 준다. 좋은 점은 주소가 그대로 https://localhost라는 거다. 출처(origin)가 안 바뀌니 localStorage, IndexedDB에 쌓아 둔 것들이 그대로 남는다. 이게 바뀌었으면 로그인부터 다 날아갔을 거다.
(스토어로 배포하는 앱이면 이런 방식에 대한 정책도 따져 봐야 할 텐데, 이 앱은 스토어 밖에서 직접 배포하는 거라 거기까지는 안 봤다.)
이번에 만든 구조
전체 흐름은 이렇다.
[맥] 빌드된 dist/
│ ota 스크립트: 파일마다 sha256 → 목록 만들고 → 개인키로 서명
├─→ 공개 버킷: ota/<sha256> (파일 실물, 이름이 곧 내용)
└─→ 포인터 JSON: 목록 + 서명 (Worker가 /app/ota.json 으로 낸다)
[앱] 켤 때 / 돌아올 때
ota.js(화면 쪽): 포인터 읽고 "받을 만한가?" 대충 본다
Ota.java(네이티브): 서명·지문·버전·해시 검사 → 받아서 폴더에 둔다
다음에 새로 열 때: 그 폴더를 serverBasePath 로올리는 쪽: 파일 이름을 내용으로
dist/를 훑어서 파일마다 sha256을 구하고, 그 해시를 파일 이름으로 해서 공개 버킷에 올린다. 이름이 내용이니까 한 번 올린 파일은 영원히 안 바뀐다. 그래서 immutable 캐시를 걸 수 있고, 안 바뀐 파일은 다시 올라가지도 않는다.
목록은 이런 모양이다.
myapp-ota 1
version 2026-10-05-1432
at 2026-10-05T05:32:11.123Z
rev 136d0df
native <sha256>
content <sha256>
file <sha256> <바이트> index.html
file <sha256> <바이트> assets/app.js
...처음엔 당연히 JSON으로 하려고 했다. 그러다 서명을 생각하고 바꿨다. 서명은 "이 바이트 그대로"에 대한 것이라, 같은 뜻인데 표기가 다를 수 있는 형식이면 곤란하다. 키 순서, 공백, 이스케이프… JSON은 같은 내용을 여러 글자로 쓸 수 있다. 줄 단위로 순서를 박아 두면 그럴 여지가 없다. 자바에서 파싱하기도 쉽다.
content 줄은 좀 사소한데 은근히 중요했다. 빌드할 때마다 바뀌는 도장(빌드 시각 같은 것)을 빼고 계산한 내용 지문이다. 이게 없으면 서버 코드만 고친 커밋에도 "새 화면"이 올라가서, 앱이 똑같은 화면을 또 받는다. 지금은 이 값이 같으면 "변경 없음"으로 끝난다.
올리는 순서도 정해 뒀다. 파일 → 포인터 → 이력 → 지난 파일 정리. 포인터가 아직 안 올라간 파일을 가리키는 순간이 없어야 해서다. 보관은 최근 3개 묶음만 하고, 거기 안 쓰이는 파일만 지운다. 다 올린 뒤에는 앱이 받을 그 길로 포인터와 index.html을 한 번 되받아서 서명과 해시가 맞는지 본다.
받는 쪽: 화면은 묻고, 네이티브가 정한다
받는 쪽은 둘로 나눴다.
화면 쪽 ota.js는 앱을 새로 열 때마다, 그리고 다시 돌아올 때는 10분에 한 번 포인터를 읽는다. 목록을 느슨하게 파싱해서 "이거 받을 만한가"만 대충 보고, 그럴듯하면 네이티브 플러그인에 넘긴다. 웹에서는 아무것도 안 한다.
진짜 판단은 네이티브 Ota.java가 한다. 이 분리는 일부러 한 거다. 화면 코드가 "이거 받아도 돼"라고 말하면 그걸 믿는 구조였다면, 화면 코드를 바꿀 수 있는 사람이 다음 화면 코드도 정하게 된다. 이 앱의 화면 코드는 비밀번호를 받고 복호화된 데이터를 만지는 코드다. 그런 걸 아무 데서나 온 파일로 바꿔 끼우게 둘 순 없었다.
네이티브는 파일을 받을 때 기기에 이미 있는 같은 파일부터 찾는다. 이전에 받아 둔 폴더, 지금 화면이 뜬 폴더, APK 안의 assets 순서로. 어디서 찾았든 해시가 목록과 같아야만 남긴다. 그래서 화면 한 줄 고친 묶음이면 실제로 망에서 받는 건 그 파일 하나다. 큰 데이터 파일은 내용이 바뀔 때만 다시 온다.
받는 건 .tmp 폴더에 하고, 다 맞으면 이름을 바꿔 확정한다. 중간에 하나라도 틀리면 폴더째 버린다.
언제 바꿀까 — 받자마자는 안 된다
받자마자 페이지를 새로 읽으면 쓰고 있던 글이 날아간다. 보던 스크롤 위치도. 그래서 받아만 두고, 다음에 앱을 새로 열 때 바뀌게 했다. MainActivity.onCreate에서 브리지가 설정을 읽기 전에 Ota.boot()이 "이번엔 받아 둔 폴더를 낼까 말까"를 먼저 정한다.
기다리기 싫을 때를 위해 설정 화면에 "지금 바꾸기" 버튼을 하나 뒀다. 사람이 누를 때만 바뀐다.
여담인데, 이 앱은 업데이트하고 처음 열 때 3초 가까이 짧은 그림이 나온다. 별것 아닌 장식인데 OTA를 붙이고 보니 문제가 됐다. 화면 묶음은 커밋마다 올 수 있는데, 그때마다 그림을 세우면 켤 때마다 기다리게 만드는 스플래시가 된다. 그래서 OTA로 바뀐 경우는 조용히 넘어가게 했다. 그림은 APK를 새로 깔았을 때만.
안전하게 하려고 넣은 것들
여기가 이번 작업에서 제일 시간을 많이 쓴 부분이다. 화면 파일을 바꿔 끼운다는 건, 결국 남이 준 코드를 내 앱 안에서 돌린다는 얘기니까.
서명 — 서버가 털려도 앱 코드는 안 바뀌게
목록은 ECDSA P-256 키로 서명한다. 개인키는 맥에만 있고 저장소 밖에 둔다. 공개키는 자바 코드에 상수로 박혀서 APK 안에 굳는다.
이렇게 하면 포인터를 내보내는 Worker는 아무것도 검증하지 않아도 된다. 버킷이나 Worker가 털려서 포인터가 바뀌어도, 개인키 없이 만든 목록은 어느 폰도 받지 않는다. 내 경험상 "믿을 필요가 없는 자리"를 하나 만들어 두면 생각할 게 확 줄어든다.
올리는 스크립트도 확인을 하나 한다. 지금 가진 개인키의 공개 반쪽이 자바 코드에 박힌 공개키와 다르면 올리지 않는다. 다른 키로 서명한 묶음은 어차피 아무도 못 받으니까, 올려 봐야 조용히 실패할 뿐이다.
앱 서명 키(APK 서명)와 따로 둔 이유도 있다. 앱 서명 키를 잃으면 사람들이 앱을 지우고 다시 깔아야 하고, 기기에만 있던 데이터가 날아간다. OTA 키는 잃어도 공개키를 바꾼 APK를 한 번 다시 깔면 끝이다. 무게가 다르다.
파일마다 해시, 그리고 경로 검사
목록이 맞아도 파일이 바뀌면 소용없다. 받은 파일은 길이와 sha256이 목록과 같을 때만 남긴다. 길이를 넘으면 거기서 끊는다.
경로도 본다. .. 같은 폴더를 벗어나는 경로, 대소문자만 다른 중복, 너무 긴 이름은 목록째 거부한다. 파일 수와 크기에도 상한을 뒀다. 쓰고 나서도 실제 경로(canonical path)가 받는 폴더 안인지 한 번 더 본다. 서명이 있으니 과한가 싶기도 한데, 이건 비용이 거의 없어서 그냥 넣었다.
네이티브 지문 — 껍데기가 다르면 안 받는다
OTA를 생각하다 보면 금방 이 질문에 부딪힌다. "새 화면이 새 플러그인을 부르면?"
화면 코드가 자바 쪽에 새로 만든 플러그인을 부르는데, 폰에 깔린 APK에는 그 플러그인이 없다. 그러면 화면은 뜨는데 기능이 깨진다. 최악이면 앱이 죽는다. 실제로 예전에 플러그인 예외 하나 때문에 열자마자 죽는 APK를 낸 적이 있어서 이건 남 일 같지 않았다.
그래서 껍데기의 지문을 만들었다. 자바 소스, 리소스, Capacitor 설정, npm 잠금 파일, 매니페스트를 고치는 도구 스크립트를 전부 해시한 값 하나다. 화면 파일은 안 들어간다. 그건 OTA가 나르는 쪽이니까.
- APK를 구울 때 이 지문을 APK 안의
native.json에 적는다 - 화면 묶음 목록에도 "이 지문의 껍데기용"이라고 적는다 (서명 안에)
- 앱은 자기 지문과 같은 묶음만 받는다
native.json 자체는 묶음에 들어가면 안 된다. 그게 바뀌면 껍데기가 자기가 누군지 착각하니까. 목록에 그 이름이 있으면 통째로 거부한다.
이 지문 덕분에 "새 APK 받으세요" 알림 규칙도 바꿀 수 있었다. 전에는 APK 버전이 더 새것이면 무조건 알렸는데, 이제는 지문이 같은 새 APK는 알리지 않는다. 그 안의 새 화면은 OTA가 이미 가져왔거나 곧 가져올 거라서. 받아 깔 이유가 있는 건 껍데기가 바뀐 APK뿐이다.
버전 비교 — 되감기를 막는다
서명만 있으면 한 가지가 안 막힌다. 예전에 정상적으로 서명된 옛 묶음을 다시 내밀면? 서명은 맞으니까 받아 버린다. 옛 화면으로 되감기는 거다.
그래서 순서를 센다. 목록의 at(UTC ISO, 밀리초까지)이 기준이다. 폭이 같은 문자열이라 글자 순서가 곧 시간 순서다. 앱은 이 셋을 다 넘어야 받는다.
- APK 안의 화면보다 새것인가
- 이미 받아 둔 묶음보다 새것인가
- 한 번 되돌렸던 묶음보다 새것인가
version(사람이 보는 분 단위 이름)은 쓰지 않는다. 같은 분에 두 번 빌드하면 갈리지 않아서다.
올리는 쪽에도 같은 문이 있다. 올라가 있는 묶음보다 옛 dist면 거부하고, 올라가 있는 묶음을 만든 커밋이 지금 HEAD의 조상이 아니면 거부한다. 이건 웹 배포에서 한 번 데인 기억 때문이다. 다른 환경에서 "최신이라고 생각한" 브랜치를 배포했는데, 실제로는 맥에만 있던 커밋들보다 뒤처진 거였고 사이트가 하루치 뒤로 갔다. 같은 일이 앱 화면에서 생기면 더 늦게 알아챈다.
되돌리기 — 안 뜨는 묶음은 한 번만 참는다
제일 무서운 시나리오는 이거다. 서명도 맞고 해시도 맞는데, 내가 실수로 app.js를 깨뜨린 묶음을 올렸다. 앱을 켜면 하얀 화면. 웹이면 고쳐서 다시 올리면 되는데, 앱은 화면이 떠야 새 묶음을 받으러 갈 수 있다. 벽돌이다.
그래서 "떴다" 신호를 만들었다.
- 새 묶음으로 처음 열 때
boots카운터를 1로 둔다 - 화면의
app.js는 부트 맨 앞에서 네이티브에confirm(at)을 보낸다 - 다음에 열 때 확인이 없었으면 → APK의 화면으로 되돌리고, 그 묶음을
bad로 기억한다 bad보다 새 묶음이 올라오면 그때 다시 받는다
confirm에 at을 같이 넘기는 데는 이유가 있다. 이 세션에서 다음 묶음을 받아 둔 뒤에 늦게 도착한 "떴다"가 아직 열어 보지도 않은 그 묶음을 확인해 버리면 안 되니까. 이건 처음엔 생각 못 했다가 순서를 그려 보다 발견했다.
하나 더. 새 APK를 깔면 받아 둔 화면은 버려야 한다. 새 APK가 더 새 화면을 들고 왔을 수 있으니까. Capacitor도 비슷한 걸 하는데 versionCode로 가른다. 근데 이 프로젝트의 versionCode는 늘 1이다. android/ 폴더가 생성물이라 다시 만들면 1.0 / 1로 돌아간다. 그래서 boot()에서 APK 안의 빌드 도장, 네이티브 지문과 직접 견준다. 지문이 다르거나 APK 화면이 더 새것이면 APK가 이긴다.
비교: 아예 OTA를 안 하는 방식
같은 시기에 만들고 있는 다른 프로젝트, 맥릴레이도 구조가 같다. 웹과 앱이 같은 바이트고 앱은 Capacitor 껍데기다. 근데 거기는 OTA가 없다.
대신 빌드할 때 build.json에 app이라는 지문을 적는다. APK에 실리는 것들의 지문이다. 배포 스크립트가 이걸 지금 올라가 있는 APK의 지문(latest.json의 appHash)과 비교해서, 다르면 APK를 새로 빌드해 올리고 같으면 웹만 배포한다. 결정을 사람 대신 해 주는 거지, 사람이 받아 까는 단계는 그대로 남는다.
| 맥릴레이 | 이번 프로젝트 | |
|---|---|---|
| 지문이 덮는 범위 | APK에 실리는 것 전부 (화면 포함) | 껍데기만 (화면 제외) |
| 화면이 바뀌면 | APK 재빌드·재배포 | OTA로 받음 |
| 껍데기가 바뀌면 | APK 재빌드·재배포 | APK 재빌드·재배포 |
| 추가로 지켜야 할 것 | 거의 없음 | 서명 키, 버전 순서, 되돌리기 |
둘 다 "지문을 견줘서 APK가 필요한지 정한다"는 같은 생각인데, 지문의 경계를 어디에 긋느냐가 다르다. 맥릴레이는 화면까지 지문에 넣어서 단순하게 갔고, 이번 건 화면을 지문 밖으로 빼는 대신 그만큼의 안전장치를 떠안았다.
어느 쪽이 맞냐면, 이건 좀 의견이 갈릴 수 있는데 나는 "화면을 얼마나 자주 고치느냐"와 "받는 사람이 누구냐"로 정하는 게 맞다고 본다. 화면 수정이 가끔이고 받는 사람이 나뿐이면 APK 한 번 더 까는 게 서명 키 하나 더 관리하는 것보다 싸다. 이번 프로젝트는 반대 쪽에 더 가까웠다. 받는 사람이 따로 있고, 실기기에서 나오는 자잘한 화면 버그를 고쳐도 그 폰에 한참 안 닿는 게 제일 답답했다.
한계와 남은 일
아직 확인 전
이 글을 쓰는 시점(2026-10-05)에 OTA 코드는 작업 트리에만 있다. 커밋도 배포도 아직 안 했고, 서버의 포인터 주소는 아직 404다. 아래에 쓴 동작은 "그렇게 만들었다"이지 "실제 폰에서 그렇게 됐다"가 아니다.
확인한 것부터.
- 코드는 다 읽었고 구조는 위에 쓴 그대로다. 올리는 스크립트, 껍데기 지문, 서명 검증과 목록 파싱을 하는 자바 코드, 화면 쪽
ota.js, Worker의 포인터 라우트까지 - 서명 키는 만들어져 있고, 그 공개키가 자바 코드에 박혀 있다
- 기존 APK 검증 스크립트는 통과한다. 새 APK 알림 규칙(지문이 같으면 안 알림)과 Worker가 포인터 둘만 넘기는지를 표로 확인하는 것들이다
아직 안 된 것, 확인 못 한 것.
- 실기기 확인 전이다. 받아지는지, 다음에 열 때 정말 새 화면이 뜨는지,
serverBasePath를 바꿔도 로그인과 저장소가 남는지, 깨진 묶음에서 진짜 되돌아가는지. 전부 폰에서 봐야 안다. - 지금 깔려 있는 APK는 OTA를 모른다. 껍데기 지문이 없는 옛 APK라서, OTA를 아는 새 APK를 한 번은 다시 깔아야 한다. OTA를 붙이려고 APK를 다시 깔아야 한다는 게 좀 웃기긴 한데, 이건 어쩔 수 없다.
- 배포와 아직 안 이어졌다. 올리는 스크립트의 주석은 "웹 배포가 끝에 이걸 부른다"고 적혀 있는데, 정작 웹 배포 스크립트에는 아직 그 줄이 없다. 그러니까 "커밋하면 앱 화면까지 간다"는 아직 사실이 아니다.
- 자바 쪽 판단 규칙(
OtaCore)은 안드로이드 API 없이 순수 자바로만 짜서 맥에서 컴파일해 진짜 목록과 변조된 목록을 넣어 볼 수 있게 해 뒀다. 근데 그걸 돌리는 검증 스크립트는 주석에 이름만 있고 파일은 아직 없다. 서명 검증 경로는 아직 한 번도 실제로 안 돌려 봤다는 뜻이다. - 서명 키를 어디에 둘지는 덜 정해졌다. 코드는 "맥에는 파일, 클라우드 세션에는 환경변수"를 둘 다 받게 되어 있는데, 클라우드에도 둘지, 백업은 어디에 할지는 아직 결정 전이다. 키가 맥에만 있으면 클라우드에서 한 배포는 화면 묶음을 못 올린다.
그리고 설계상 남는 한계도 있다.
"떴다" 신호는 app.js의 부트 맨 앞에서 나간다. 그러니까 잡을 수 있는 건 "아예 안 뜨는" 묶음뿐이다. 떴는데 어떤 탭 하나가 깨진 묶음은 그대로 확인되고 남는다. 그건 결국 다음 묶음으로 고쳐야 한다. 확인 시점을 더 뒤로, 예를 들면 첫 화면이 다 그려진 뒤로 미룰까 고민 중인데, 그러면 잠금 화면에서 멈춘 채 앱을 닫는 평범한 경우까지 "실패"로 셀 수 있어서 아직 못 정했다.
한 번 되돌린 뒤에는 그보다 새 묶음이 올 때까지 기다린다. 깨진 걸 고쳐서 다시 올리면 그때 받는다. 이건 의도한 동작이지만, 되돌아갔다는 걸 사람이 알 방법이 지금은 없다. 조용히 옛 화면에 남는 거다. 처음에 피하고 싶었던 바로 그 "모른다"가 작은 모양으로 남아 있는 셈이다.
다음 할 일은 순서가 분명하다. 검증 스크립트 만들기 → 커밋 → OTA를 아는 APK 한 번 배포 → 폰에서 화면 한 줄 바꿔서 받아지는지 보기 → 일부러 깨진 묶음 올려서 되돌아가는지 보기. 그게 되면 그때 이 글 뒤에 "실제로 해 보니" 편을 붙이려고 한다.
