현재 회사에서는 codePush 라는 Over-The-Air(OTA) 서비스에 의존하고 있다. ReactNative 를 사용함으로써 이득볼수 있는 가장 큰 요소중 하나가 codePush를 사용 할수 있다는 점이다.
간단하게 한줄 정리하면 기나긴 스토어의 심사를 거치지 않고 javascript 로 작성된 코드를 수정하고 배포할수 있다는 점이다. 자세한 기술적 설명은 생략하고 링크로 대체한다. 해당 링크가 codePush 대해서 가장 잘 설명 하고 있다고 생각한다.
무려 마이크로소프트 에서 제공하는 굉장히 좋은 서비스인건 맞지만 가끔 알수 없는 이유로 codePush 패키지의 다운로드 속도가 굉장히 저하된다. (가장 느릴때 체감 10분...) 관련된 이슈로 찾아본 결과로는 단순히 마이크로소프트 codePush 서버가 알수 없는 이유로 문제를 일으킨다 라는 내용 이외에 맘에드는 내용을 찾을수 없었다.
결과적으로 이것은 막을수 없는 천재지변 이구나.. 라고 단념하게 됐다.
그렇다면 codePush 패키지의 다운로드 속도가 저하 됨으로써 현재 서비스에서 어떤 문제점이 생기는걸까?
codePush 정책은 서비스 마다 다르기 때문에 해당 문제점들은 현재 서비스에 국한되는 문제점들 일수도 있다.
현재 codePush 는 조금 번거로운 점이 하나 있는데 앱 실행시에 codePush update가 존재하면 update 패키지를 다운로드 한다. 그리고 다운로드 받은 패키지의 내용을 앱에 적용 시키기 위해서는 앱을 재실행 해야 한다는 점이다.
앱을 재실행을 시키는 방법은 3가지가 있는데 가장 자주쓰이는 방법 2가지만 다루려고 한다.
첫번째는 패키지 다운 후에 즉시 강제로 앱을 재실행 하는것.
두번째는 다운 받은 패키지를 local 에 가지고 있다가 유저가 다음에 앱을 재실행 한다면 그 때 자연스럽게 적용 시키는 방법이 있다.
...
// codepush 코드중 일부
const update = await CodePush.checkForUpdate(); // 최신 codepush 존재 여부를 체크한다.
if (update) {
CodePush.sync(
{
installMode: CodePush.InstallMode.IMMEDIATE,
mandatoryInstallMode: CodePush.InstallMode.IMMEDIATE,
rollbackRetryOptions: {
delayInHours: 0,
maxRetryAttempts: 3
}
}
)
...
모든 codePush update를 필수로 업데이트 하고 즉시 재실행 하는 정책은 유저에 좋지 못한 경험을 주게 된다. 위의 코드에서 mandatoryInstallMode:CodePush.InstallMode.IMMEDIATE
라는 옵션은 필수 업데이트 적용을 위해서 유저의 의사와 상관없이 앱을 즉시 재실행 한다 라는 의미이다. 이렇게 되면 단순 텍스트 수정 임에도 불구하고 모든 유저들은 앱이 강제로 재실행 되는 경험을 제공 하게된다.
여기서 가장 큰 문제는 codepush 서버의 상태이다. 만약 서버의 상태가 좋지 못하여 패키지의 다운로드에 걸리는 시간이 2-3분 정도 소요된다고 가정해보자. 유저가 앱을 한참 이용중 일때 codepush 패키지 다운이 완료되어 갑자기 앱이 재실행되는 경험을 주게된다. 만약 유저가 기나긴 입력 폼을 적고있는 도중에 패키지 다운이 완료됐고 유저의 의사와 상관없이 앱이 갑작스럽게 재실행 된다면 이는 유저 입장에서는 아주 끔찍한 경험이 될것이다.
때문에 이런 경험을 막고자 IntroScreen 을 만들어 codepush 업데이트 패키지 다운로드가 완료 되기전까지는 MainScreen 을 렌더하지 않는 정책을 유지해 왔다. 하지만 만약 codepush 서버의 상태가 좋지않아 최악의 경우 10분이 걸리게 된다면 우리 앱의 유저는 10분 동안 앱을 이용하지 못하고 IntroScreen만 보게되는 경험을 주게된다. 결국 단순 텍스트 수정 때문에 서비스 전체를 이용하지 못하게 되버리는 최악의 상황이 일어나게 된다. 아래는 현재 사용하고 있는 IntroScreen 이다.
위의 옵션중에 maxRetryAttempts: 3
이라는 옵션은 codePush를 시도하는 최대 횟수다. 어떤 의미냐면 codePush 업데이트가 존재하여 이를 다운로드 받는 중에 알수없는 이유로 실패했거나 다운로드 중에 앱이 종료 되는등 여러 문제로 제대로 적용 시키지 못했을때 기본 1번 + 최대 3번까지 (총 4번) 시도를 하고 그럼에도 최신 버전의 codePush를 적용하지 못했다면 codePush 서버는 이를 업데이트를 받을 의사가 없는 유저 또는 치명적인 문제가 있는것으로 인식하고 이를 rollback 유저로 인식하게 된다. 이후에 rollback 유저는 최신 codePush를 적용받지 못하게 된다.
그럼 rollback 유저는 왜 생기는걸까? 왜 실패를 하게되는 걸까? 가장 의심되는 부분은 codepush 서버의 상태에 의한 sideEffect 이다. 서버의 상태가 좋지않으면 위 사진의 IntroScreen 에서 기다리다가 지친 유저들은 앱을 종료하고 재실행 하게 될텐데 이 행위가 maxRetryAttempts count 를 증가 시킨다. 그렇게 앱을 종료후 4번째 실행 시키게 되는 시점에 rollback 유저로 분류되고 codePush update를 포기하게 된다. 이렇게 되면 유저는 최신버전의 codepush를 적용 하지 못한채로 IntroScreen 을 넘어가게 되고 codepush가 적용되지 않은 상태로 앱을 이용하게 된다. 만약 버그가 발생하여 수정된 코드를 codepush로 적용 했다면 rollback 유저는 수정된 코드를 적용받지 못하게 된다. 결국 유저는 똑같은 버그를 경험하게 된다.
rollback 유저로 분류된 유저가 최신 codePush를 받는 방법은 2가지 밖에 없다. 첫번째는 앱을 삭제후 재설치한다. 두번째는 rollback 된 버전 다음의 최신 codePush 배포가 이루지기 전까지 기다린다. 이 말은 현재 우리 서비스에서 rollback 유저에 대해서 전혀 사후처리를 하지 않고 있다는 의미로 받아 들여진다.
codePush 는 mandatory 라는 boolean 타입의 flag로 해당 codePush update 가 필수인지 필수가 아닌지 설정이 가능한 옵션이 있다. 커맨드의 -m 이 포함되면 필수 업데이트 상태가 된다.
기존에는 모든 사항을 필수 커맨드로 업데이트 하였지만 이를 필수, 선택으로 분리하였고 수정된 코드의 내용이나 상황에 따라서 유연한 배포를 할수 있도록 개선하였다.
분리한 이유는 작은 수정사항 때문에 모든 유저가 필수적인 업데이트를 받는 것은 불합리해 보이기 때문이다. 또한 만약에 codePush 서버의 성능 저하 상태까지 겹쳐버리면 작은 수정사항 때문에 모든 유저가 IntroScreen 을 벗어나지 못하고 앱을 이용하지 못하는 상황이 된다. 간단하게 정리해보면
필수 업데이트
appcenter codepush release-react -a Service/mobile-android -k $KEY_PATH -m
선택 업데이트
appcenter codepush release-react -a Service/mobile-android -k $KEY_PATH
const checkInitApp = async () => {
// 현재 유저의 바이너리 앱 버전 체크
const responseCallback = await props.store.configStore!.checkVersion();
const expiredAppVersion = responseCallback.isShowUpdateAlert();
if (expiredAppVersion) {
// 스토어 업데이트 필요한 경우, UpdateAlert 를 띄우고 스토어로 연결 시켜준다.
setNeedAppVersionUpdate(true);
} else if (responseCallback.isSuccess()) {
// codepush 로직을 수행한다.
setNeedAppVersionUpdate(false);
CodePush.notifyAppReady();
checkCodePush();
} else {
// 에러 및 예외처리
};
};
...
const getCodepushState = (update: RemotePackage | null): CodePushState => {
if (!update) return 'UPTODATE';
if (update.isMandatory) return 'FORCED';
return 'OPTIONAL';
};
const checkCodePush = async () => {
try {
const update = await CodePush.checkForUpdate();
const codePushState = await getCodepushState(update);
switch (codePushState) {
case 'UPTODATE':
// 최신버전 일때 로직
break;
case 'FORCED':
forcedCodepushUpdate(update!);
break;
case 'OPTIONAL':
optionalCodepushUpdate(update!);
break;
default:
break;
}
const updateMetadata = await CodePush.getUpdateMetadata(); // 현재 적용된 codepush 정보
if (updateMetadata) {
// ...관련로직 처리
}
} catch (error) {
// 에러처리
}
};
...
codepush 가 존재한다면 await CodePush.checkForUpdate()
의 결과값 으로 RemotePackage 타입의 object가 리턴된다. update 가 존재 하지 않는다면 현재 최신의 codepush 가 적용되어 있는 상태로 본다. 만약 update가 존재한다면 isMandatory 를 체크하여 필수 업데이트 인지를 체크한다. getCodepushState 함수로 조건에 맞는 string 을 리턴 하고 리턴된 값에 따라 switch case 문으로 어떤 로직을 실행할지 분류 한다.
필수 업데이트
...
const forcedCodepushUpdate = async (update: RemotePackage) => {
// ...관련 로직처리
try {
update
.download((progress: DownloadProgress) => {
// 패키지 다운로드 중
// IntroScreen 에서 보여줄 progressBar state 와 stateMessage 셋팅
setSyncDownloadProgress(progress); // { totalBytes: number, receivedBytes: number }
setStateSyncMessage("패키지 다운로드 중입니다.");
})
.then((newPackage: any) => {
// 다운로드 된 패키지 적용 중
setStateSyncMessage("패키지 적용중 입니다.);
newPackage.install().done(() => {
// 패키지 적용 완료됐을때
setStateSyncMessage("적용 완료! 곧 앱이 재실행 됩니다.");
CodePush.restartApp(); // 앱 재시작
});
});
} catch (error) {
// 에러처리
}
};
...
...
const optionalCodepushUpdate = async (update: RemotePackage) => {
// ...관련 로직처리
try {
update.download().then((newPackage: any) => {
newPackage.install().done(() => {
CodePush.allowRestart();
// ...관련 로직처리
});
});
} catch (error) {
// 에러처리
}
};
...
// useCodePush.tsx
...
const [syncStateMessage, setStateSyncMessage] = useState<string>("업데이트 확인중");
const [isCodePushUpdating, setIsCodePushUpdating] = useState<boolean>(false);
const [needAppVersionUpdate, setNeedAppVersionUpdate] = useState<boolean>(true);
const [syncDownloadProgress, setSyncDownloadProgress] = useState<DownloadProgress>({
totalBytes: 0,
receivedBytes: 0
});
...
useEffect(() => {
...
checkInitApp();
...
}, []);
...
return { isCodePushUpdating, syncDownloadProgress, syncStateMessage, needAppVersionUpdate };
};
export default useCodePush;
...
// IntroScreen.tsx
function IntroScreen(props: Props) {
...
const { isCodePushUpdating, syncDownloadProgress, syncStateMessage, needAppVersionUpdate } = props;
...
}
export default useCodePush(IntroScreen)
import * as Progress from 'react-native-progress';
...
const getProgress = (): number => {
const { syncDownloadProgress } = props;
if (syncDownloadProgress.receivedBytes <= 0) return 0;
return Number((syncDownloadProgress.receivedBytes / syncDownloadProgress.totalBytes).toFixed(2));
};
...
const progress = this.getProgress();
return (
<Progress.Bar
style={{ margin: srf(15), backgroundColor: white }}
progress={progress} // 0.0 ~ 1.0
borderWidth={0}
height={srf(6)}
width={srf(295)}
color={typography_mint}
borderRadius={srf(6)}
useNativeDriver
/>
)
...
progressBar UI 추가. 참고로 codepush 서버 상태가 좋으면 progressBar가 보일틈도 없이 패키지 다운이 완료되고 곧바로 앱이 재시작 된다.
선택적 codepush 업데이트를 하고나서 유저의 조작으로도 앱을 재실행 할수 있게하는 UI 를 추가했다. 왜냐하면 최신 패치를 적용받기 원하는 유저는 최초 앱을 실행할때 패키지를 다운로드만 받는다. 이 패키지가 적용되는 정확한 시점은 앱을 다시 실행 했을때이다. 이렇게 되면 패치적용을 위해 앱을 다시 종료하고 또 다시 켜야하는데 유저 액션이 어색하다는 생각이들어 해당 UI를 추가하였다.
또 하나 codepush 서버가 느려지고 동시에 선택적 업데이트를 적용받는 유저의 경우, 패키지를 다운받는 도중에 앱을 종료하게 될 수도 있다. 이렇게 되면 결국 다운로드 도중에 취소 즉 rollback 되기 때문에 다음 앱 실행시 또 다시 패키지를 다운로드 받는 단계로 되돌아가 버린다.
그래서 선택적 업데이트를 받는 중에 codepush 서버 성능 저하 되는 상황을 고려하여 해당 UI 에 패키지를 다운로드 중 UI를 추가 작업하였다. 다운로드 중 일때는 아래의 상태로 유지되고 패키지 다운로드가 완료되면 위의 사진 "앱재실행" 상태로 UI가 변경된다. 이렇게 하면 유저의 액션으로 확실하게 최신 패치를 다운로드 받고 적용시킬수 있다.
공유 감사합니다!