배경
2024 관광데이터 활용 공모전에 프론트엔드 개발자로 참여하여 프로젝트를 진행중이다.
앱을 1차적으로 릴리즈하고, 다음 업데이트를 한번 해보니, 스토어 심사를 진행하는 것이 생각보다 시간이 걸리고 불편한 일이라는 것을 느낄 수 있었다.
이런 부분들은 이미 알고는 있었으나, 실제로 경험해보니 더욱 체감이 되었다.
웹뷰를 사용하면 해결할 수 있었겠지만, 초기에 웹뷰를 사용하지 않고, RN으로만 진행하기로 하였기때문에, 다른 방법을 탐색해야했다.
그러다 찾은 것이 Code push다.
어떻게 가능한가?
이것을 이해하기 위해서는 RN의 원리에 대해 간단하게 나마 알아보아야한다.
React native의 스레드
React native는 메인스레드, JS스레드, 백그라운드 스레드(Shadow 스레드)로 구성된다.
1. 메인스레드
메인스레드는 UI그리기, 사용자입력처리, 네이티브 이벤트 처리 등 네이티브에서 일어나는 작업들을 수행하는 스레드이다.
애니메이션 전환 역시 메인스레드에서 처리된다.
2. Shadow 스레드
shadow 스레드는 레이아웃 계산을 맡아 컴포넌트 레이아웃을 계산한다. 그리고, 이걸 메인스레드에 전달한다.
Yoga라는 오픈소스 레이아웃 엔진을 사용한다고 한다.
3. JS스레드
이 스레드가 JS번들을 실행한다. 즉, RN으로 작성한 코드가 직접적으로 실행되는 스레드이다.
다만, 이 스레드의 특이한 점은 Hermes라는 JS엔진을 사용한다는 것이다.
Hermes는 다른 엔진과 다르게 JS 코드를 미리 바이트코드로 컴파일한다. 그래서 인터프리터가 컴파일을 하지 않는다. 따라서 이게 성능을 개선하는데 도움을 줄 수 있다.
그리고 스레드들은 Bridge라는 개념을 통해 필요한 정보를 직렬화하여 비동기 통신한다.(non block)
그림으로 그리면 이런 형태로 작동하게되는 것이다.
React native 앱이 실행되는 과정
1. 앱이 시작하며 메인 스레드가 실행된다. 이때 JS번들을 로드한다.
2. JS번들의 로드가 완료되면 메인스레드는 JS번들을 JS스레드로 보낸다.
3. React렌더링이 시작된다.
4. Shadow 스레드가 레이아웃 계산을 마치면 레이아웃 결과물을 메인스레드로 보낸다.
5. 메인 스레드는 Shadow 스레드가 보낸 레이아웃을 렌더링한다.
그렇다면 결국, JS번들과 앱의 메인 스레드 코드는 분리되어있다는 것을 알 수 있다.
그리고, 보통 RN개발자가 작성하는 코드는 JS번들로 결과물이 나오게된다.
그렇다면, JS번들파일을 네트워크를 통해 전달받을 수 있다면, 실시간으로 업데이트 할 수 있게 된다.
환경 구성 및 기본 설정
설정은 공식 문서와 github 레포를 확인하는게 가장 좋다.
특히 공식문서가 매우몹시 친절하게 되어있으므로 따라하기만 하면 적용할 수 있을 것이다.
배포설정
원래는 appcenter에서 커맨드를 지원해서 이렇게 설정해주면된다.
appcenter codepush release-react -a {user name}/{앱 이름} -d {type(Production or Staging)}
그러나 현재 우리 프로젝트는 yarn workspace를 사용한 모노레포의 형태를 취하고 있기에, 이렇게 사용할 수 없었다.
appcenter 레포와 이슈들을 확인했으나, 번들 entry를 설정해주는 args가 없었다.
그래서 따로 쉘파일을 만들어 주었다.
mkdir CodePush
# 번들 생성
npx react-native bundle \
--entry-file=./index.js \
--bundle-output=./CodePush/index.android.bundle \
--assets-dest=./CodePush/ \
--dev=false \
--platform=android
# codepush 업로드
# t에 타겟 버전(android build)
appcenter codepush release \
-a 유저네임/프로젝트 \
-c ./CodePush \
-d 프로덕션 \
-t 타겟버전
``
rm -rf CodePush
번들을 직접 만들고, codepush로 해당 번들 폴더를 업로드하고, 삭제하는 것이다.
이걸 script에 등록해서 yarn run codepush:android형태로 쓸수 있게 했다.
기본설정
기본설정은 매우 간단하다
App.tsx에서 전체 앱을 CodePush로 감싸주면 된다.
...
import CodePush from 'react-native-code-push';
function App() {
return (
...
);
}
export default CodePush(App);
이렇게 설정해주면 Codepush는
- 앱이 실행될때, 번들 업데이트가 있는지 확인하며
- 있다면 다운받고
- 다음번에 앱이 재실행 됐을때 업데이트되게 된다.
하지만, 나는 앱이 즉시 업데이트되기를 원했다. 계속해서 개선할 점이 보이고, 그런 부분이 즉시 개선되었으면 했기때문이다.
설정 옵션
Codepush(options)(App)의 형태로 코드 푸쉬가 어떻게 동작할 것인지 옵션을 정해줄 수 있다.
deploymentKey, installMode, mandatoryInstallMode, minimumBackgroundDuration, updateDialog, rollbackRetryOptions, checkFrequency의 옵션이 존재한다.
Codepush 의 d.ts를 읽어보면 내용을쉽게 알 수 있다.
그중 installMode를 변경해주고 싶었는데, 왜냐하면 default가 ON_NEXT_RESTART, 즉 다음번에 앱이 실행될때 업데이트 하는 방식이었기때문이다.
그래서 이런식으로 옵션을 주고
const codePushOptions: CodePushOptions = {
checkFrequency: CodePush.CheckFrequency.ON_APP_START,
installMode: CodePush.InstallMode.IMMEDIATE,
};
이렇게 적용해주었다.
export default CodePush(codePushOptions)(App);
이렇게 처리하면, 이제 문제없이 잘 작동할 것이라 생각했다.
문제 발생
그러나 문제가 발생했다.
그것은 바로... 회원가입을 할때 앱 업데이트로 강제 재시작이 되어버리는 것...
이걸 어떻게 풀어낼지 고민했다. 회원가입을 한다 하더라도, 최신 업데이트 버전의 앱은 보여주고 싶었기 때문이다.
CodePush에서 제공하는 함수들
다행히 CodePush는 컴포넌트를 Wrapping하는 방식 뿐만 아니라, 직접 세부 동작을 지정할 수도 있게 만들어져 있었다.
업데이트가 필요한지 확인하는 함수, 번들을 받아오는 함수, 번들을 기기에설치하는 함수 모두 제공한다.
그렇다면 로직은
- 앱시작시 바로 업데이트가 있는지 확인
- 있는지 없는지에 따라 최신버전인지 분기
- 최신버전이 아니라면 번들 다운로드 + 최신 번들 기기에 설치
- 앱 재시작
이 될 것이다.
그리고 그 과정에서 로딩화면을 보여주면 된다!
아래와 같이 작성할 수 있다.
import { useEffect, useState } from 'react';
import CodePush from 'react-native-code-push';
export default function useCodePush() {
const [isRecent, setRecent] = useState<boolean>();
const checkUpdate = async () => {
const update = await CodePush.checkForUpdate(); //업데이트가 있는지 확인
setRecent(!update); //최신 상태 변경
if (update) {
const newPackage = await update.download(); //번들 다운로드
await newPackage.install(CodePush.InstallMode.IMMEDIATE); //즉시 번들 설치
CodePush.notifyAppReady(); //CodePush에 해당 기기가 최신 버전으로 설치했다고 알림
CodePush.restartApp(); // 앱 재시작
}
};
useEffect(() => {
checkUpdate();
}, []);
return {
isRecent,
};
}
그런데 download메서드가 굉장한걸 지원한다.
바로 번들의 totalBytes와 receivedBytes를 보여주는 것이다.
이걸 활용하면 로딩 프로그래스바를 만들어줄 수 있다.
최종코드
import { useEffect, useState } from 'react';
import CodePush from 'react-native-code-push';
export default function useCodePush() {
const [isRecent, setRecent] = useState<boolean>();
const [downloadProgress, setDownloadProgress] = useState(0);
const checkUpdate = async () => {
const update = await CodePush.checkForUpdate();
setRecent(!update);
if (update) {
const newPackage = await update.download(
({ receivedBytes, totalBytes }) => {
setDownloadProgress(receivedBytes / totalBytes);
},
);
await newPackage.install(CodePush.InstallMode.IMMEDIATE);
CodePush.notifyAppReady();
CodePush.restartApp();
}
};
useEffect(() => {
checkUpdate();
}, []);
return {
downloadProgress,
isRecent,
};
}
이걸 이제 App에 적용해주자.
function App() {
const { isRecent, downloadProgress } = useCodePush();
if (!isRecent)
return (
<UpdateLoading isRecent={isRecent} downloadProgress={downloadProgress} />
);
return (
...
);
}
export default Sentry.wrap(App);
UpdateLoading컴포넌트는 SPOT 레포 github를 참고하면 좋을 것 같다.