Ink On은 이북 리더기를 위한 스크린세이버 앱이다. 이 글에서는 앱을 만들게 된 계기와 Expo(React Native)를 활용하여 이북 리더기라는 특수한 기기에서 마주한 제약들과, 어떠한 Android 네이티브 모듈을 사용했는지 간단한게 설명하려 한다.
앱을 만들게 된 이유
1. 이북리더기를 더 자주 확인하기 위해서
나는 얇은 책을 좋아한다. 나에게 두꺼운 책 읽기란 하늘의 별 따기였지만, 이북 리더기를 활용하면 자기 전에 800쪽이 넘는 벽돌책을 읽을 수 있다는 것을 깨달았다. 독서 습관을 기르기 위해서는 책을 자주 가지고 다니거나 눈에 잘 띄는 곳에 두는 것이 중요하다. 따라서 이북리더기를 Always On Display(AOD)로 활용하기 위해 스크린 세이버 앱을 구상하게 되었다.
2. 구동이 너무 느린 환경
이북리더기는 2-40만원대의 기기임에도 전자 잉크라는 물리적 특성으로 타 전자기기에 비해 구동이 매우 느리다. 나는 이를 참지 못하고 스크린세이버를 지난 3년간 기본 이미지를 활용하였는데, 아주 단순하게 내가 원하는 이미지 템플릿들을 다운받아야겠다는 생각을 하게 되었다.
주요 제약 사항
어떻게 앱을 만들었는지 설명하기에 앞서 주요한 챌린지와 제약 사항들을 짚고 넘어가려 한다.
1. 낮은 안드로이드 버전
이북 리더기의 가장 큰 특징은 안드로이드 지원 버전이 낮다는 것이다. 최신 Onyx
Boox 기기도 Android 12-13 정도이고, 조금 오래된 모델은 이미 Google Play
스토어의 최저 Android 버전인 6.0 아래인 경우도 있다. 그래서 최신 API에
의존하는 라이브러리는 주의가 필요하고, Expo의
minSdkVersion을 적절히 낮추는 설정이 필요했다.
2. Google Play가 아닌 세상
1번과 같은 이유로 이북 리더기 사용자들은 Google Play가 아닌 apk 파일을 직접 다운로드 받거나 Aurora Store 같은 서드파티 스토어를 통해 앱을 설치하는 경험이 익숙하다. 이는 단순히 배포 채널의 문제가 아니라, 인앱 결제(RevenueCat 등)가 GMS에 의존하기 때문에 앱 런타임에도 직접적인 영향을 준다.
3. 디버깅의 어려움
일반 안드로이드 폰과 달리 이북리더기는 Android Studio의 에뮬레이터로 확인할 수 없다. 따라서 개발 중에는 Send Anywhere 앱으로 빌드한 APK를 이북 리더기로 전송하고, 설치하고, 실행하고, 문제가 있으면 다시 빌드하는 사이클을 반복해야 했다. 생각보다 이 과정이 번거로웠다.
구현한 것들
Expo(React Native)는 여러 플랫폼을 지원하므로 이북 리더기는 어느정도로 지원 가능한지가 궁금했다. 결론적으로 Ink On은 안드로이드 기반 이북리더기(e.g. Onyx Boox, Meebook, Bigme 등)에서 슬립화면을 자동 업데이트하거나, 메트로폴리탄과 NASA의 퍼블릭 이미지에 달력, 날짜, 텍스트 위젯을 얹어 나만의 잠금 화면을 만들 수 있다.
1. 백그라운드 작업을 위한 커스텀 Expo 네이티브 모듈
앱의 핵심은 백그라운드에서 스크린세이버 이미지를 생성하는 것이다. React Native의 View를 캡처하는 방식으로는 앱이 종료된 백그라운드 상태에서 실행할 수 없고 성능도 부족했다. 이를 해결하기 위해 Expo Modules API를 사용해 Kotlin으로 직접 네이티브 모듈을 구축했다.
JS 런타임의 개입 없이 동작해야 하므로, Android Canvas API를 활용한 물리적인 렌더링 엔진을 구현했다. React Native 측에서 JSON 형태의 템플릿 제원만 넘겨주면, 복잡한 그리기 처리라도 네이티브 로직이 이를 자체적으로 파싱해 빈틈없이 백그라운드 비트맵으로 렌더링해낸다.
2. 빌드타임 네이티브 에셋 동기화 패턴
스크린세이버에 쓰이는 고용량 이미지 파일들은
public-domain-wallpapers라는 별도 npm 패키지로 분리했다. 흥미로운 점은 이 이미지들을 단순히 JS에서
로드하는 것이 아니라, 오프라인 접근성과 용량 관리를 위해
postinstall 훅을 사용해 Android 네이티브
에셋(modules/.../src/main/assets) 경로로 동기화하는 구조를
취했다는 것이다.
// scripts/sync-wallpaper-assets.js
const SOURCE = "node_modules/public-domain-wallpapers/images-eink";
const TARGET = "modules/screensaver-generator/android/src/main/assets/images-eink";
// yarn install 시 자동으로 이미지가 네이티브 에셋으로 복사된다
이를 통해 무거운 바이너리 덩어리의 리소스를 앱 React Native 번들과 분리하면서도, 네이티브 단에서 즉각적이고 지연 없이 접근할 수 있는 효율적인 파이프라인을 다질 수 있었다.
3. 런타임 한계를 극복하는 이중 저장소 패턴
주기적인 스크린세이버 갱신을 위해 Android WorkManager를 로컬 Expo 모듈에 연동했다. 여기서 마주한 가장 큰 한계는 WorkManager가 파생시킨 백그라운드 프로세스에서는 React Native의 JavaScript 런타임(Jotai나 AsyncStorage 등)에 일절 접근할 수 없다는 점이었다.
JavaScript 코드가 동작하지 않는 상황에서도 유저의 설정값을 가져오기 위해
이중 저장소(Dual Storage) 패턴을 적용했다. JS 상태가 변경될
때마다 React Native UI가 구동되는 AsyncStorage뿐만 아니라,
백그라운드의 네이티브 코드가 단일로 접근 가능한 안드로이드
SharedPreferences 양측에 데이터를 동시 갱신해 두는 것이다.
결과적으로 WorkManager의 Worker는 JS 단기 메모리 여부와 완벽히 떼어놓은
상태에서도
SharedPreferences에 담긴 JSON 설정값을 자유롭게 꺼내 독립적인
백그라운드 이미지 렌더링 스텝을 훌륭히 수행할 수 있었고,
BootReceiver와 결합해 재부팅 시의 자율성도 만족시켰다.
알게 된 것들
Expo 네이티브 모듈은 생각보다 강력하다
개발 전에는 "Expo는 네이티브 코드를 건드리기 어렵다"는 선입견이 있었다. 하지만
Expo Modules API를 사용하면 modules/ 디렉토리에 Kotlin/Swift
코드를 넣고, expo-module.config.json 하나로 등록하는 것만으로
네이티브 모듈이 동작한다. 이번 프로젝트에서는 Android Canvas API, WorkManager,
SharedPreferences, BootReceiver, SAF(Storage Access Framework)까지 건드렸는데,
Expo 밖으로 eject하지 않고도 전부 가능했다. 웬만한 안드로이드 네이티브 기능은
Expo 로컬 모듈로 충분히 구현할 수 있다.
백그라운드 작업은 별도의 세계다
React Native에서 UI를 만드는 것과 백그라운드 작업을 구현하는 것은 완전히 다른 영역이다. WorkManager는 React Native의 JavaScript 런타임과 무관하게 동작하기 때문에, 상태 공유, 메모리 관리, 중복 실행 방지 등을 모두 네이티브 레벨에서 직접 처리해야 한다. "앱이 꺼져 있어도 동작해야 한다"는 요구사항 하나가 아키텍처 전체를 바꿔놓았다.
마무리
이북 리더기라는 니치한 타겟에 맞춰 앱을 만들면서, 평소에 신경 쓰지 않던 영역을 많이 다루게 됐다. GMS가 없는 환경에서의 결제 처리, 백그라운드에서의 이미지 생성, 저사양 기기에서의 메모리 최적화 같은 것들이다. Expo가 제공하는 편의성을 누리면서도, 필요한 부분에서는 네이티브까지 내려가서 직접 구현할 수 있다는 점이 이번 프로젝트에서 가장 만족스러웠다.
