feconf25에 참석해 세션을 들은 후 작성하는 후기 글입니다.

한때 우리 회사도 hot updater를 썼었는데
이 라이브러리를 만든 분이 직접 강연을 한다니 궁금해져서 후딱 들으러 감
2025.08.23. (토) FECONF25
세션2(12:50~13:20): 플러그인 시스템 기반의 유연한 React Native CodePush 대안
연사자: Toss RN Framework팀 강선규 개발자, 관련 링크드인

Intro
OTA(Over-theAir) 업데이터란?

정의
사용자가 앱스토어 심사를 하지 않고도 실시간으로 애플리케이션의 코드와 자원을 원격으로 업데이트하는 기술
주요 장점
- 업데이트를 위한 다운로드/설치 과정이 없어 사용자 경험을 개선할 수 있음
- 버그 수정과 기능 출시 주기를 단축해 빠르게 배포할 수 있음
- 앱스토러 심사를 생략할 수 있음
기존 솔루션: React Native CodePush
- Microsoft에서 개발한 ReactNative/Cordova 앱을 위한 라이브러리
- JavaScript 코드와 자원을 앱스토어 업데이트 없이 사용자에게 직접 배포함 (앱스토어 심사 X)
- Microsoft App Center 기반 솔루션
- 롤백 및 점진적 배포 가능
App Center의 구조적 문제점
CLI 종속적 워크 플로우
- Metro 번들러에 특화된 구조
- Expo, Re.Pack 등 다양한 RN 프레임워크 환경의 유연한 적용이 어려움
App Center 종속
인프라가 Microsoft App Center에 완전히 종속됨
자체 인프라로 마이그레이션 하거나 확장하기 어려움
CDN 부재
글로벌 CDN이 없음
특정 국가나 지역에서 업데이트 다운로드 속도가 현저히 느려지는 성능 저하 발생
-> Microsoft App Center, 2025년 3월 31일부터 모든 App Center 서비스 완전 종료 발표
발표자는 이미 종료 발표 전부터 이 구조적 문제점을 타파하고자 코드푸시 대안을 개발함
레딧에서 수요조사하고 계획만 하던 중에 종료 발표가 동기부여가 되어 열심히 개발을 시작하게 됨
CodePush가 종료된 이후 10개가 넘는 SaaS가 생기고 여전히 경쟁 중이라고 함
Hot Updater

개발 시작 전 요구사항 파악 및 기존 CodePush 분석
개발을 위한 요구사항
1. 다양한 인프라 활용
- AWS, Firebase, Supabase, Cloudflare 등 다양한 환경 지원 필요
- 각 기업/팀이 선호하는 인프라를 자유롭게 제약 없이 사용할 수 있도록 함
2. 다양한 프레임워크
- 기존 CodePush는 Metro에 특화되었음. 따라서 Re.Pack 등 다양한 번들러에 유연하게 대응하고자 함.
- Bare, Expo, Rock 등 RN 프레임워크를 모두 지원하고자 함
3. 플러그인 시스템
- 모듈화 구조: 필요한 기능만 선택적 활용 가능 (리소스 최적화)
- 확장성: 새로운 프레임워크나 인프라가 등장해서 플러그인만 추가하면 지원 가능
- 유지보수 용이성: 코어 기능/확장 기능을 분리해 유연한 아키텍처 구성, 개발 및 업데이트 효율화
기존 CodePush 분석
1. CodePush가 어디에서 JS 번들을 읽는가
- iOS(AppDelegate.mm) / Android(MainApplication.kt)에서 앱이 실행될 때 JS 번들을 어디서 가져올지 정의
- CodePush.bundleURL()을 호출해서 번들을 가져옴
- 빌드된 앱은 CodePush에서 내려받은 JS 번들을 실행함
2. 번들 경로 확인(PoC)
- iOS 디버깅 로그에 CodePush bundleURL을 찍어보면, 실제 기기 내부 파일 경로가 표시됨
/var/containers/.../main.jsbundle
- CodePush가 앱 안에 새로운 번들을 내려받아 저장하고 실행함을 확인함
3. CodePush 동작 원리 (추론)
1) 앱 실행 시 CodePush 업데이트 서버에 연결 - 새로운 업데이트(번들)가 있는지 확인
2) 새로운 번들이 있다면 다운로드
3) 기기 파일 시스템에 저장
4) CodePush.bundleURL이 저장된 번들의 파일 경로를 반환
5) RN 런타임이 해당 JS 번들을 실행
-> 이를 바탕으로 앱을 다시 빌드/배포하지 않고도 JS 번들을 교체해 앱을 업데이트함
플러그인 시스템 기반 번들 관리
OTA 구현을 위한 플러그인 구조
1. Build Plugin
- 다양한 RN 환경 (Bare Rn, Expo, RNFF 등)과 번들러(Metro, Re.Pack 등)에 맞춰 JS 번들 생성
- 어떤 프레임워크/번들러를 쓰든 플러그인을 통해 빌드 추상화
2. Storage Plugin
- 빌드된 번들을 저장하고 제공하는 저장소
- Supabase, Firebase Storage, Cloudflare R2, AWS S3
3. Database Plugin
- 번들의 메타데이터(버전, 배포 상태, 배포 기록 등)를 관리
- Supabase, Firbase, Cloudflare D1
- 지금 어떤 번들이 최신인지 기록하는 곳
Config 시스템
플러그인 구조를 실제로 적용하려면, 각각의 빌드/스토리지/DB 플러그인을 조합해 실행하는 Config 시스템이 필요함
// hot-updater.config.ts
export default defineConfig({
build: framework(),
storage: storage(),
database: database(),
});
CLI에서 hot-update deploy 실행하면, 이 Config를 기준으로 아래와 같은 흐름이 자동 실행 됨
빌드 - 저장소 업로드 - DB 메타데이터 갱신
플러그인 구조로 번들 관리 파이프라인을 모듈화해 원하는 인프라 조합을 쉽게 선택할 수 있음
핵심 구성 요소
1. 빌드 플러그인
- 번들을 어떤 RN 프레임워크/환경에서 빌드할지 선택

import { bare } from "@hot-updater/bare";
import { defineConfig } from "hot-updater";
export default defineConfig({
build: bare(),
});
Bare React Native를 쓴다면 build: bare()
Expo를 쓴다면 build: expo()
Rock을 쓴다면 build: rock()
= 어떤 환경으로 build할지 코드 한 줄만 바꿔주면 됨
2. 스토리지 플러그인
- 빌드된 번들을 업로드하고 제공할 저장소 선택

- 스토리지는 빌드 결과물(JS 번들, 소스맵, 자산 등)을 올리고 내려주기만 담당함
- 어디에 파일을 둘 것인가(원본 저장소)+앱이 어떻게 내려받게 할 것인가(download url)을 규격화함
예시에서는 supabase storage를 쓰거나 cloudflare r2를 쓰는 경우를 보여주고 있음
상위 로직은 그대로이고 storage 필드 내부 내용만 교체하면 된다는 것을 알 수 있음

export interface SupabaseStorageConfig {
supabaseUrl: string;
supabaseAnonKey: string;
bucketName: string;
}
supabase에 연결하기 위한 기본 정보
- supabaseUrl: Supabase 프로젝트 url
- supabaseAnonKey: 인증용 키
- bucketName: 번들을 올릴 버킷 이름
export const supabaseStorage =
(config: SupabaseStorageConfig, hooks?: StoragePluginHooks)
=> (_: BasePluginArgs): StoragePlugin => {
supabaseStorage라는 함수를 호출하면, supabase에 연결된 StoragePlugin 객체를 리턴
- defineConfig에서 storage: supabaseStorage({...}) 식으로 쓸 수 있게 만들어 둠
const bucket = supabase.storage.from(config.bucketName);
supabase SDK를 사용해 특정 버킷을 가리킴
이후 이 bucket 객체로 파일 업로드/다운로드/삭제 등을 수행함
return {
name: "supabaseStorage",
async upload(key: string, filePath: string) {
const upload = await bucket.upload(Key, Body);
// ...
},
};
실제 플러그인 객체
- name: 플러그인 이름
- upload: 파일 업로드 메서드 구현
- key: 스토리지 내 경로(예: ios/1.0.0/main.jsbundle)
- filePath: 실제 로컬 파일 경로
- 내부적으로 bucket.upload()를 호출해 Supabase Storage에 파일을 저장함
Supabase Storage는 클라우드 하드디스크
Supabase에 연결해서 하드디스크에 파일을 넣고 뺄 수있는 어댑터 역할
-> 스토리지에 파일을 업로드/삭제/다운로드를 추상화된 인터페이스로 호출할 수 있음
-> Supabase 말고 Cloudflare R2나 AWS S3로 갈아끼울 수 있고, 수정할 코드도 많지 않음
3. 데이터베이스 플러그인
- 스토리지에는 실제 js 번들 파일이 들어감
- 데이터베이스에는 번들의 정보(어떤 버전, 어떤 플랫폼용, 언제 배포됐는지, 어떤 파일 경로)가 저장됨
- 앱이 서버에 최신 번들을 요청하면, Db를 조회해 올바른 번들의 download Url을 알려줌

export interface DatabasePlugin {
getBundleById: (bundleId: string) => Promise<Bundle | null>;
updateBundle: (targetBundleId: string, newBundle: Partial<Bundle>) => Promise<void>;
appendBundle: (insertBundle: Bundle) => Promise<void>;
commitBundle: () => Promise<void>;
}
getBundleById: 특정 번들 id로 db에서 정보 조회
updateBundle: 기존 번들의 메타데이터 업데이트
appendBundle: 새로운 번들 정보 추가
commitBundle: 최종적으로 배포 확정 처리
파일이 업로드 된 경로와 번들의 활성 상태 여부를 db에서 관리
export const supabaseDatabase = (config) => (_): DatabasePlugin => {
return {
name: "supabaseDatabase",
async getBundleById(bundleId) {
return supabase.from("bundles").select("*").eq("id", bundleId).single();
},
};
};
supabase 구현 예시
- supabase의 bundles 테이블에서 id가 같은 행을 찾아 반환하는 코드
데이터베이스는 번들의 정체성을 추적함: 어떤 번들이 최신인지, 특정 클라이언트가 어떤 번들을 받아야 하는지
스토리지와 분리되어 있어서 저장소(s3, supabase, r2)를 바꿔도 db 구조만 유지하면 문제 없음
인터페이스가 표준화되어 있어 supabase -> cloudflare d1 같은 데이터베이스 교체도 쉽게 가능함
전반적으로 빌드 플러그인, 스토리지, 데이터베이스 모두 인터페이스가 표준화되어 있어
원하는 메소드를 적은 코드 변경으로 쉽게 바꿔 쓸 수 있다는 점이 특징인 듯 하다
hot-updater deploy 실행 과정

실행 흐름
1. hot-updater deploay 실행
-> cli 명령어를 입력해서 배포 프로 프로세스를 시작함
2. build plugin
-> 현재 RN 환경(Bare, Expo 등)에 맞춰 js 번들을 빌드함
3. storage plugin
-> 빌드된 번들을 supabase, cloudflare r2 같은 스토리지에 업로드함
4. database plugin
-> 업로드된 번들의 메타데이터(버전, 경로, 해시 등)를 db에 기록함
코드 흐름
const config = await loadConfig();
const [buildPlugin, storagePlugin, databasePlugin] = await Promise.all([
config.build({ cwd }),
config.storage({ cwd }),
config.database({ cwd }),
]);
// 1. 빌드 실행
buildPlugin?.build(...);
// 2. 스토리지 업로드
storagePlugin?.upload(...);
// 3. DB에 번들 정보 추가
databasePlugin?.appendBundle(...);
// 4. 최종 커밋 (배포 확정)
databasePlugin?.commitBundle();
loadConfig() -> hot-updater.config.ts 파일을 읽어서 어떤 플러그인을 쓸지 로드함
build -> upload -> appendBundle -> commitBundle 순서로 실행됨
콘솔 시스템

pnpm hot-updater console을 입력해서 콘솔 대시보드를 실행시킬 수 있음
현재 배포된 번들의 목록을 볼 수 있음
- ID
- 채널: production/staging
- 플랫폼: ios/android
- target: 앱버전/해시
- enable: 활성 여부
- force update: 강제 업데이트 여부
- message: 커밋메시지/설명
- created at: 배포시간
각 번들을 클릭하면 상세 편집 화면을 확인할 수 있음
- message: 업데이트 설명 수정 가능
- enable: 켜거나 끌 수 있음 - 꺼두면 유저는 이 번들 못받음
- force update: 켜면 사용자는 무조건 이 번들로 업데이트해야 앱을 쓸 수 있음
- 메타데이터 확인: 플랫폼, 앱 버전, 채널, 커밋 해시 등
- promote channel: 특정 번들을 다른 채널로 승격(staging->prod)
- delete bundle: 번들 삭제

콘솔 서버는 내부적으로 database plugin을 사용함
.get("/bundles", async () => {
const { databasePlugin } = await loadConfig();
return databasePlugin.getBundles();
})
.patch("/bundles/:bundleId", async () => {
const { databasePlugin } = await loadConfig();
await databasePlugin.updateBundle(...);
await databasePlugin.commitBundle();
return { success: true };
})
콘솔 Ui에서 수행하는 동작(번들 수정, 삭제, 상태 변경)은 rest api 요청으로 제어됨
이 api는 플러그인을 호출해서 db를 갱신함
[핵심!!] 업데이트 여부를 판단하는 방법
UUIDv7 기반 업데이트 체크

- UUIDv7은 시간 정보가 포함된 고유 ID임
- 단순 비교만 해도 어느 것이 더 최근에 만들어진 번들인지 알 수 있음
- 업데이트 서버가 별도의 timestamp 필드를 관리하지 않아도, uuid 크기 비교만으로 최신 버전 판별 가능
-> UUIDv7 자체가 버전 비교와 최신성 보장의 기준이 됨
번들에 삽입되는 고유한 ID

- 빌드 전에 번들에 placeholder를 넣어두고, 빌드 시 Babel 플러그인이 UUIDv7 값을 자동으로 생성해 삽입함
- 결과적으로 모든 번들은 고유한 HOT_UPDATE_BUNDLE_ID를 가지게 됨
- 이 값으로 번들의 고유성과 생성 시점을 정확히 추적할 수 있음
-> 모든 번들은 자동으로 UUIDv7 ID를 가지고 빌드됨: 추적과 관리가 쉬워짐
UUIDv7 기반 업데이트 시나리오

위에서 말했듯, UUIDv7은 시간 정보가 포함된 고유 ID라서 단순 비교만으로도 최신 여부를 판단할 수 있음
롤백 시나리오

- 만약 배포된 번들이 문제가 있어서 enable: false가 됨
- 클라이언트 앱이 서버에 '내 번들 ID'를 보내도 '사용불가' 응답을 받음
- 이 경우 서버는 '이전 버전 중 가장 최신의 안정적인 번들(UUIDv7 기준)을 찾아서 내려줌
-> 문제가 생기면 자동으로 안정된 최신 버전으로 롤백되는 구조
클라이언트 API

- 앱에서는 HotUpdater.checkForUpdate()로 서버에 업데이트 확인 요청을 함
- HTTP 헤더에 현재 번들 ID(UUIDv7)을 같이 보냄
- 서버가 새 버전이 있다고 응답하면, 번들 파일을 다운로드 받아 저장함(updateBundle)
- 서버에서 강제 업데이트 플래그를 같이 내려주면 앱을 reload()해서 즉시 반영함
-> 자동으로 최신 번들을 확인 -> 다운로드 -> 적용하는 흐름을 따름
업데이트 서버

- Hot Updater 서버는 서버리스(Serverless) 환경 위에서 동작하도록 설계됨
- 어디든 서버리스를 쓸 수 있는 환경이면 pnpm hot-updater init으로 초기화 가능
동적 인프라 스캐폴딩 로직

- 사용자가 어떤 클라우드 프로바이더(Supabase, Cloudflare, AWS, Firebase)를 선택하냐에 따라,
- 해당 프로바이더용 모듈을 동적으로 import해서 초기화함
switch(provider) {
case "supabase":
await supabase.runInit();
case "cloudflare":
await cloudflare.runInit();
...
}
- 사용자 프로바이더 선택
- 해당 모듈 동적 임포트
- runInit() 실행 (초기 리소스 구성)
- 백엔드 리소스 자동 세팅 완료
-> 코드 한 벌로 여러 클라우드 프로바이더 지원: 멀티 클라우드 유연성 확보
멀티 프로바이더 지원

- 특정 벤더에 종속되지 않고, 원하는 프로바이더에 맞춰 구성 가능
멀티 프로바이더를 유지보수 하는 방법
프로바이더별 구현 코드 예시

- Supabase(관계형 테이블)와 Firebase(문서 기반 NoSQL)는 DB 모델이 다름
- 그러나 결국 동일한 로직(업데이트 가능한 번들 가져오기)을 구현할 수 있음
- 데이터 접근 방식은 다르지만 결과는 같음 -> 멀티 프로바이더 유지 보수 가능
Supabse
- 테이블 기반 관계형 쿼리 사용
- from('bundles') -> bundles 테이블에서 조회
- 조건: 특정 플랫폼(platform)에서 현재 번들 ID보다 큰 것(gt) 중 활성화된 것(enable=true)만 선택
- 전통적인 sql 스타일로, 조건을 체인처럼 쌓아서 쿼리
Firebase
- 문서 기반 컬렉션 쿼리 사용
- collection('bundles') -> 문서 집합 조회
- 조건: 특정 플랫폼(platform)에서 현재 번들 ID보다 큰 것(id>currentBundleId) 중 활성화 된 것(enable=true)만 선택
공통 테스트케이스 예시

- 프로바이더마다 다르게 동작하지 않도록, 공통 테스트 케이스를 만들어서 실행
- 예: 앱 버전 전략, 강제 업데이트 여부 등을 공통 로직으로 검증
- 서버 배포 환경이 달라도 로직 일관성 보장함
프로바이더별 얇은 래퍼 예시

- 프로바이더마다 얇은 래퍼 코드를 제공 -> 테스트 동일하게 적용 가능
- 프로바이더별 DB 차이를 감싸줘서 상위 로직은 변하지 않음
- 유지보수 시 프로바이더 내부 구현만 조금 수정하면 됨
테스트 통과

- 모든 프로바이더에 대해 테스트를 돌려서 동일한 결과가 나오도록 보장함
- 29개의 파일, 647개 테스트를 통과한 예시를 보여줌

OTA 시스템 구축 과정
- hot-updater init 실행: OTA 인프라 자동 생성 (필요한 기본 구조 준비0
- 네이티브 설정: 앱 쪽에 기본 설정 추가(예; 번들 ID, 업데이트 체크 로직)
- hot-updater deploy 실행: 즉시 OTA 배포 가능 (앱 업데이트를 스토어 배포 없이 바로 전달)
추후 계획
미래버전: TypeScript 백엔드 통합

- 서버리스 한계를 넘어, Node.js 기반 백엔드에 직접 OTA를 붙이는 비전
- ORM 친화적 설계 => Prisma, Drizzle 같은 ORM과 자연스럽게 연결
- 마이그레이션 자동화 => DB 스키마 변화도 쉽게 반영
- 별도 서버 없이 백엔드에 바로 통합해 비용 절감 가능
- TypeScript 환경 그대로 사용해 개발자가 익숙한 경험을 유지할 수 있음
미래 비전: 네이티브 빌드 연동

- 원클릭 자동화: 명령어 한 번으로 빌드 + OTA 연결이 자동 관리
- 호환성 관리: 어떤 OTA 번들이 어떤 빌드와 호환되는지 콘솔에서 확인
- 데이터 자동 관리: 채널 정보, fingerprintHash 같은 메타데이터 자동 연결

- 콘솔에서 Native Builds 탭을 통해 빌드와 OTA 번들을 함께 관리
- 각 빌드별로 연결된 OTA 업데이트 목록 확인 가능
- 빌드별 상태(Enabled, Force Update)와 생성 시간도 추적 가능

오픈소스로 개발했더니 이런 협업 기회도 생겼다는 점을 소개해주심

와 드디어 정리 끝!!!!!!!!
이걸 8월 말에 들었는데 10월 초에 정리가 끝나다니...
RN을 잘 몰라서 공부하면서 정리했더니 오래 걸렸네요
여담이지만 hot updater는 서버에 번들을 업로드할 때 바뀐 부분만 체크해서 올리는게 아니라 그냥 바뀔때마다 번들을 통째로 올려버려서
s3 버킷 비용이 너무 많이 나와서 저희 회사에서는 사용을 중단했다네요...
까비~
2025/10/3 오전 6시 48분 작성 완료
'개발 관련 컨퍼런스 참여' 카테고리의 다른 글
| [토스 컨퍼런스] TOSS MAKERS CONFERENCE 25 후기 (1) | 2025.10.09 |
|---|---|
| [FECONF25] 개발자를 위한 모션 그래픽 솔루션: Lottie의 기술 진화와 활용전략 (1) | 2025.08.31 |
| [FECONF25] 모노레포 절망편, 14개 레포로 부활하기까지 걸린 1년 (4) | 2025.08.30 |
| 토스 개발자 컨퍼런스 SLASH24 (7) | 2024.09.02 |