ReactNative codePush 개선작업 기록하기

nudge411·2022년 3월 14일
17
post-thumbnail

1. codePush 란 무엇일까

현재 회사에서는 codePush 라는 Over-The-Air(OTA) 서비스에 의존하고 있다. ReactNative 를 사용함으로써 이득볼수 있는 가장 큰 요소중 하나가 codePush를 사용 할수 있다는 점이다.
간단하게 한줄 정리하면 기나긴 스토어의 심사를 거치지 않고 javascript 로 작성된 코드를 수정하고 배포할수 있다는 점이다. 자세한 기술적 설명은 생략하고 링크로 대체한다. 해당 링크가 codePush 대해서 가장 잘 설명 하고 있다고 생각한다.

2. codePush 서비스 개선이유

무려 마이크로소프트 에서 제공하는 굉장히 좋은 서비스인건 맞지만 가끔 알수 없는 이유로 codePush 패키지의 다운로드 속도가 굉장히 저하된다. (가장 느릴때 체감 10분...) 관련된 이슈로 찾아본 결과로는 단순히 마이크로소프트 codePush 서버가 알수 없는 이유로 문제를 일으킨다 라는 내용 이외에 맘에드는 내용을 찾을수 없었다.
결과적으로 이것은 막을수 없는 천재지변 이구나.. 라고 단념하게 됐다.
그렇다면 codePush 패키지의 다운로드 속도가 저하 됨으로써 현재 서비스에서 어떤 문제점이 생기는걸까?

3. 개선 이전의 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
      }
    }
  )
...

문제 1. 모든 codePush 업데이트를 필수로 배포하고 앱을 즉시 재실행 시켰다.

  • 모든 codePush update를 필수로 업데이트 하고 즉시 재실행 하는 정책은 유저에 좋지 못한 경험을 주게 된다. 위의 코드에서 mandatoryInstallMode:CodePush.InstallMode.IMMEDIATE 라는 옵션은 필수 업데이트 적용을 위해서 유저의 의사와 상관없이 앱을 즉시 재실행 한다 라는 의미이다. 이렇게 되면 단순 텍스트 수정 임에도 불구하고 모든 유저들은 앱이 강제로 재실행 되는 경험을 제공 하게된다.

  • 여기서 가장 큰 문제는 codepush 서버의 상태이다. 만약 서버의 상태가 좋지 못하여 패키지의 다운로드에 걸리는 시간이 2-3분 정도 소요된다고 가정해보자. 유저가 앱을 한참 이용중 일때 codepush 패키지 다운이 완료되어 갑자기 앱이 재실행되는 경험을 주게된다. 만약 유저가 기나긴 입력 폼을 적고있는 도중에 패키지 다운이 완료됐고 유저의 의사와 상관없이 앱이 갑작스럽게 재실행 된다면 이는 유저 입장에서는 아주 끔찍한 경험이 될것이다.

  • 때문에 이런 경험을 막고자 IntroScreen 을 만들어 codepush 업데이트 패키지 다운로드가 완료 되기전까지는 MainScreen 을 렌더하지 않는 정책을 유지해 왔다. 하지만 만약 codepush 서버의 상태가 좋지않아 최악의 경우 10분이 걸리게 된다면 우리 앱의 유저는 10분 동안 앱을 이용하지 못하고 IntroScreen만 보게되는 경험을 주게된다. 결국 단순 텍스트 수정 때문에 서비스 전체를 이용하지 못하게 되버리는 최악의 상황이 일어나게 된다. 아래는 현재 사용하고 있는 IntroScreen 이다.

현재 앱의 로딩중인 화면...

문제 2. maxRetryAttempts 횟수를 3번 으로 제한하고 rollback 유저에 대한 사후처리가 전혀 없었다.

  • 위의 옵션중에 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 유저에 대해서 전혀 사후처리를 하지 않고 있다는 의미로 받아 들여진다.

문제 3. codepush 패키지 다운로드 중일때 불친절한 UI로 유저에게 좋지않은 경험을 주고있었다.

  • codepush 서버의 성능 저하라는 천재지변은 일어날수 밖에 없다. codePush 서버 상태가 좋지 안다면 IntroScreen 화면이 지속 돼버린다. 현재의 IntroScreen 은 전혀 다운로드가 진행되고 있는것 처럼 보여지지 않는다. "데이터 로딩중..." 이라는 불친절한 메세지 달랑 하나만 던져 주기 때문에 유저는 전혀 무엇인가 진행되고 있는지 알수없게 되고 "앱이 실행 되지 않는다!" 라고 인식하게 된다. 이는 cx 팀을 매우 곤란하게 만든다.

4. 개선하기

문제 1. 모든 codePush 업데이트를 필수로 배포하고 앱을 즉시 재실행 시켰다.

--> 해결: codePush 를 필수 업데이트와 선택적 업데이트 2가지로 분리하고, 필수 업데이트 일때만 강제로 재시작 하여 패치를 적용하고, 선택적 업데이트 일때는 다음에 앱 실행시 패치를 적용한다.

  • codePush 는 mandatory 라는 boolean 타입의 flag로 해당 codePush update 가 필수인지 필수가 아닌지 설정이 가능한 옵션이 있다. 커맨드의 -m 이 포함되면 필수 업데이트 상태가 된다.
    기존에는 모든 사항을 필수 커맨드로 업데이트 하였지만 이를 필수, 선택으로 분리하였고 수정된 코드의 내용이나 상황에 따라서 유연한 배포를 할수 있도록 개선하였다.

  • 분리한 이유는 작은 수정사항 때문에 모든 유저가 필수적인 업데이트를 받는 것은 불합리해 보이기 때문이다. 또한 만약에 codePush 서버의 성능 저하 상태까지 겹쳐버리면 작은 수정사항 때문에 모든 유저가 IntroScreen 을 벗어나지 못하고 앱을 이용하지 못하는 상황이 된다. 간단하게 정리해보면

  • 필수 업데이트

    • appcenter codepush release-react -a Service/mobile-android -k $KEY_PATH -m
    • 서비스 이용에 지대한 영향을 끼치는 치명적인 버그를 수정하여 배포가 필요한 경우
    • codepush versionLabel 이 숫자 0 으로 끝나는 경우 ex) v110, v120, v130...
    • 패키지 다운로드가 완료 되기 전까지 IntroScreen을 렌더한다. (서비스 이용 불가)
    • 패키지 다운로드가 완료 되면 앱을 강제로 재실행 한다. (패치 적용을 위함)
  • 선택 업데이트

    • appcenter codepush release-react -a Service/mobile-android -k $KEY_PATH
    • 필수 업데이트를 제외한 모든 codepush update 배포
    • 패키지 다운로드 여부와 상관없이 IntroScreen 을 넘기고 MainScreen 렌더 (서비스 이용 가능)
    • 패키지 다운로드가 완료 되면 다음번에 앱 재실행시 자연스럽게 최신 패키지 적용
    • 또는 유저의 직접적인 액션으로 패키지 적용을 원하는 경우 버튼 UI 이용하여 패키지 적용

문제 2. maxRetryAttempts 횟수를 3번 으로 제한하고 rollback 유저에 대한 사후처리가 전혀 없었다.

--> 해결: maxRetryAttempts 횟수를 무한대로 한다.

  • maxRetryAttempts 를 따로 설정하지 않았다. 시도 횟수와 상관없이 계속해서 업데이트를 시도하게 되는것 같다. 이렇게 되면 rollback 유저가 발생하지 않는다. 이 방식은 업데이트 방식을 필수/선택 으로 나누었기 때문에 적용할수 있었다. 다만 codepush 서버 이슈와 필수 업데이트가 겹쳤을때는 오랜시간 동안 패키지 다운로드를 기다려야 한다.

문제 3. codepush 패키지 다운로드 중일때 불친절한 UI로 유저에게 좋지않은 경험을 주고있었다.

--> 해결: IntroScreen 에 ProgressBar와 상태 메세지를 추가한다.

  • 방금 위에서의 조건이 겹치게 되면 유저는 패키지 다운로드를 기다려야 하는 상황이 발생하게 된다. 기존에 '데이터 로딩중..' 이라는 불친절한 UI 는 앱이 실행되지 않는, 앱이 중지된것 같은 경험을 제공했었다. 하여 UI 개선을 통해 패키지 다운로드가 진행중 이라는 인식을 유저에게 전달하기 위해서 상태 메세지와 ProgressBar 를 추가하였다.

5. 코드 변경사항

  • 여러가지 디테일한 커스텀을 위해서 Codepush.sync() 는 사용하지 않았다.
  • codePush 를 로직을 실행하기 전에 한가지 필수 api 요청이 있다. 바로 플랫폼의 스토어에 올라와 있는 바이너리 버전을 체크하는 요청이다. 최신 바이너리 앱버전은 스토어 심사를 거쳐야만 스토어에 업데이트 된다. 만약 현재 유저앱이 플랫폼의 최신 버전으로 업데이트 되어있지 않다면 codepush update 로직은 무시된다. 예를 들면 스토어 v1.0.8 버전에서 가장 최근의 codepush 가 v99 이고, 이후 스토어의 심사를 거쳐 최신 버전인 v1.0.9 으로 업데이트 되었다고 가정해보자. 그렇게 되면 v100 부터의 codepush는 스토어 버전 v1.0.9 부터 적용된다. 만약 유저가 v1.0.8 버전을 가지고 있다면 v99 이후의 codepush 내용을 적용받지 못한다. 최신의 codepush v100 을 적용 받기 위해서는 우선 필수적으로 버전 v1.0.9 의 업데이트가 요구 된다. 이 경우에도 IntroScreen 이 유지되어 서비스 이용이 불가하다.
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 {
      // 에러 및 예외처리
    };
};

  • "업데이트" 버튼을 누르면 플랫폼의 스토어로 연결된다.
  • checkCodePush
...
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) {
      // 에러처리
    }
  };
...
  • 위 처럼 로직을 분리하여 로직 처리부분에 필요한 state 값들을 조정해 주면 된다. 참고로 해당 코드들은 useCodepush.tsx 에 작성한다음 HOC 를 활용하여 IntroScreen을 감싸고 있다.
// 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)
  • progressBar
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 는 라이브러리를 사용했고 progress 부분에 소숫점 Number를 state 로 관리해주면 된다. codepush 에서 패키지의 전체크기와 현재 다운받은 크기를 내려주는데 이를 활용하여 소숫점으로 변환하는 함수를 작성하였고 state 를 관리하였다.

6. 결과

  • progressBar UI 추가. 참고로 codepush 서버 상태가 좋으면 progressBar가 보일틈도 없이 패키지 다운이 완료되고 곧바로 앱이 재시작 된다.

  • 선택적 codepush 업데이트를 하고나서 유저의 조작으로도 앱을 재실행 할수 있게하는 UI 를 추가했다. 왜냐하면 최신 패치를 적용받기 원하는 유저는 최초 앱을 실행할때 패키지를 다운로드만 받는다. 이 패키지가 적용되는 정확한 시점은 앱을 다시 실행 했을때이다. 이렇게 되면 패치적용을 위해 앱을 다시 종료하고 또 다시 켜야하는데 유저 액션이 어색하다는 생각이들어 해당 UI를 추가하였다.

  • 또 하나 codepush 서버가 느려지고 동시에 선택적 업데이트를 적용받는 유저의 경우, 패키지를 다운받는 도중에 앱을 종료하게 될 수도 있다. 이렇게 되면 결국 다운로드 도중에 취소 즉 rollback 되기 때문에 다음 앱 실행시 또 다시 패키지를 다운로드 받는 단계로 되돌아가 버린다.

  • 그래서 선택적 업데이트를 받는 중에 codepush 서버 성능 저하 되는 상황을 고려하여 해당 UI 에 패키지를 다운로드 중 UI를 추가 작업하였다. 다운로드 중 일때는 아래의 상태로 유지되고 패키지 다운로드가 완료되면 위의 사진 "앱재실행" 상태로 UI가 변경된다. 이렇게 하면 유저의 액션으로 확실하게 최신 패치를 다운로드 받고 적용시킬수 있다.

7. 후기

  • 후기를 디테일하고 전문적으로 작성해보고 싶었는데 점점 갈수록 힘이빠진다... 중간부터 조금 대충 작성해서 내려간것 같다.. 하다보면 늘지 않을까 싶다..

  • maxRetryAttempts 를 설정하지 않으면 무한대가 아니라, 최대 6회까지 재시도 하고 그 이후에는 롤백유저로 분리한다고 한다! 출처: chatGPT
profile
잊기 위한 기록을 합니다.

8개의 댓글

comment-user-thumbnail
2022년 8월 2일

공유 감사합니다!

1개의 답글
comment-user-thumbnail
2022년 9월 30일

정말 감사합니다

1개의 답글
comment-user-thumbnail
2023년 1월 12일

글 잘 봤습니다! 혹시 app version check 하실 때 react-native-version-check을 사용하셨나요??

1개의 답글
comment-user-thumbnail
2023년 2월 9일

description을 받아올 순 없겠죠? 업데이트 내용을 업데이트 도중 보여주려고하는데

1개의 답글