react native codepush rollback 되는 이슈

박은정·2023년 4월 13일
2

리액트네이티브

목록 보기
16/24

이슈 현상

앱을 시작했을때, 코드푸시 시스템팝업이 나와서 업데이트 한다는 선택을 하고 강제종료를 하게 되면 롤백처리되서 코드푸시가 완료된걸로 처리되고 앱에는 변경사항이 반영이 안되는 현상이 있었습니다.

react-native-code-push 깃허브에서 아래와 같은 이슈를 발견했습니다.

출처: https://github.com/microsoft/react-native-code-push/issues/1924

코드푸시 업데이트가 기기에 정상적으로 배포되더라도 업데이트에 성공한 후 애플리케이션을 강제로 닫으면 코드푸시가 설치 실패로 판단하고 롤백하는 현상이 발견되었습니다.

  1. 새 업데이트를 사용가능한 경우: codePush.sync() 사용해서 설치
[Sat Aug 08 2020 10:17:15.671]  LOG      I received the remote package:  {"appVersion": "3.2.1", "deploymentKey": "ZZZZZZZZZZZZZ", "description": undefined, "download": [Function download], "downloadUrl": "https://codepushupdates.azureedge.net/storagev2/YYYYYYY", "failedInstall": false, "isMandatory": true, "isPending": false, "label": "v6", "packageHash": "XXXXXXXXXXX", "packageSize": 615194}
[Sat Aug 08 2020 10:17:15.688]  LOG      SyncStatus =  5
[Sat Aug 08 2020 10:17:15.838]  LOG      SyncStatus =  7
[Sat Aug 08 2020 10:17:16.581]  LOG      SyncStatus =  8
[Sat Aug 08 2020 10:17:16.583]  LOG      SyncStatus =  1
[Sat Aug 08 2020 10:17:16.583]  LOG      Update installed successfully!
  1. 잠시 후 정상적인 사용 후 앱 강제 종료
  2. 앱을 다시 열고 코드푸시 확인
I received the remote package:  {"appVersion": "3.2.1", "deploymentKey": "ZZZZZZZZZZZ", "description": undefined, "download": [Function download], "downloadUrl": "https://codepushupdates.azureedge.net/storagev2/YYYYYYYYY", "failedInstall": true, "isMandatory": true, "isPending": false, "label": "v6", "packageHash": "XXXXXXXX", "packageSize": 615194}

업데이트가 성공적으로 설치되었지만 다음 실행 시 실패한 설치로 플래그가 지정되고 앱센터의 코드푸시 배포 섹션을 확인하면 앱이 v1xxxx 버전으로 완전히 롤백된 것으로 표시됩니다.

import codePush, { LocalPackage } from 'react-native-code-push'
import RNBootSplash from 'react-native-bootsplash'
import { timeout } from '../utils/timeout'

export const checkForUpdate = async (): Promise<boolean> => {
  try {
    const remotePackage = await codePush.checkForUpdate()
    console.log('I received the remote package: ', remotePackage)
    if (remotePackage && !remotePackage?.failedInstall) {
      return true
    }
  } catch (e) {
    // TODO - log error
  }
  return false
}

export const restartAppAndInstall = async () => {
  RNBootSplash.show({ duration: 250 })
  await timeout(300)
  codePush.restartApp()
}

export const installUpdateIfAvailable = () => {
  const TimeoutMS = 10000
  const checkAndUpdatePromise = new Promise(async (resolve: Function) => {
    const updateAvailable = await checkForUpdate()
    if (updateAvailable) {
      RNBootSplash.show({ duration: 250 })
      const syncStatus = (status: codePush.SyncStatus) => {
        console.log('SyncStatus = ', status)
        switch (status) {
          case codePush.SyncStatus.UP_TO_DATE:
          case codePush.SyncStatus.UPDATE_IGNORED:
            console.log('App is up to date...')
            resolve()
            break
          case codePush.SyncStatus.UPDATE_INSTALLED:
            console.log('Update installed successfully!')
            codePush.notifyAppReady()
            resolve()
            break
          case codePush.SyncStatus.UPDATE_INSTALLED:
            resolve()
            break
          default:
            break
        }
      }

      // Install the update
      codePush.sync(
        {
          installMode: codePush.InstallMode.IMMEDIATE,
          mandatoryInstallMode: codePush.InstallMode.IMMEDIATE,
        },
        syncStatus,
      )
    } else {
      resolve()
    }
  })
  return Promise.race([checkAndUpdatePromise, timeout(TimeoutMS)])
}

export const getMetadata = async (): Promise<LocalPackage | null> => {
  try {
    const metadata = await codePush.getUpdateMetadata()
    return metadata
  } catch (e) {
    // TODO - log error
  }
  return null
}

export { codePush }
// AppDelegate.m
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {
  #if DEBUG
    NSLog(@"Using local js bundle");
    return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil];
  #else
    NSLog(@"Using codepush bundleURL");
    return [CodePush bundleURL];
  #endif
}

출처: https://github.com/microsoft/react-native-code-push/issues/1904

iOS 플랫폼에서 코드푸시로 업데이트를 푸시할 때 이상한 동작이 발생하고 있습니다.

내 바이너리 버전은 2.0 입니다. 2.0버전을 대상으로 코드푸시 업데이트를 만들면 업데이트가 기기에 성공적으로 설치됩니다.
하지만 앱을 다시 시작하면 (앱을 닫았다가 다시 열면) 원래 바이너리 번들로 돌아갑니다.

이 문제는 iOS에서만 발생하며 Android에서는 정상적으로 작동합니다.

예상동작

앱을 재시작한 후 설치된 코드푸시 버전을 유지합니다.

시나리오

릴리스 모드를 사용해서 로컬 패키지 서버 대신 코드푸시 서버로 업데이트를 받습니다.
문서에서 지적한 대로 AppDelegate.m 에서 해당 줄을 이미 교체했습니다.

jsCodeLocation = [CodePush bundleURL];

아래와 같이 JS 래퍼를 사용하고 있습니다.

let codePushOptions = {
    checkFrequency: codePush.CheckFrequency.ON_APP_START
};

export default UpdatesHandler = codePush(codePushOptions)(UpdatesHandler);

그리고 componentDidMount() 에서 적절한 메서드를 호출하는 코드푸시 서버와 동기화합니다.

componentDidMount() {
  codePush.sync({
    updateDialog: false,
    installMode: codePush.InstallMode.IMMEDIATE
  });
}

codePush.sync() 동기화 메서드를 호출하기 전에 codePush.notifyAppReady() 를 호출해봤지만 소용이 없었습니다.

이미 mscenter에서 확인했는데 롤백이 등록되어 있지 않았고, 모든 릴리스에서 2.0 버전을 타겟팅을 하고 있습니다.

환경

react-native-code-push version: 6.2.1
react-native version: 0.60.5
iOS/Android/Windows version: iOS 13.3.1
실제 디바이스에서 릴리스 빌드에서 재현

해결방법1: 앱이 마운트될때마다 업데이트 사항있는지 체크

한 가지 흥미로운 점은 전체 애플리케이션을 코드푸시로 감싸기만 하면 롤백이 되지 않는다는 점입니다.

let codePushOptions = {
  checkFrequency: codePush.CheckFrequency.ON_APP_RESUME,
  installMode: codePush.InstallMode.IMMEDIATE,
  mandatoryInstallMode: codePush.InstallMode.IMMEDIATE,
}
export default codePush(codePushOptions)(App)

왜 이렇게 작동하는지 모르겠지만 업데이트가 있을때 sync() 함수를 호출하면 롤백되지만
이처럼 코드푸시로 감싸기만 하면 App을 차단하지 않습니다.

업데이트 및 해결

애플리케이션의 루트에 다음과 같은 코드를 추가하면 롤백없이 사용자 지정 동기화 흐름이 작동하도록 할 수 있습니다.

let codePushOptions = {
  checkFrequency: codePush.CheckFrequency.MANUAL,
}
export default codePush(codePushOptions)(App)

이 문제가 있는 다른 분들은 먼저, AppDelegate.m 에서 실제 CodePush bundleURL] 을 사용하는지 확인한 다음, 위에 게시된 것처럼 루트 앱이 래핑되어있는지 확인하세요.

아래 코드는 RN 0.63.0에서 매우 깔끔한 업데이트 환경을 위해 사용하는 코드입니다.

export const checkForUpdate = async (): Promise<boolean> => {
  try {
    const remotePackage = await codePush.checkForUpdate()
    console.log('I received the remote package: ', remotePackage)
    if (remotePackage && !remotePackage?.failedInstall) {
      return true
    }
  } catch (e) {
    // TODO - log error
  }
  return false
}

export const restartAppAndInstall = async () => {
  RNBootSplash.show({ duration: 250 })
  await timeout(300)
  codePush.restartApp()
}

export const installUpdateIfAvailable = () => {
  const TimeoutMS = 10000
  const checkAndUpdatePromise = new Promise(async (resolve: Function) => {
    const updateAvailable = await checkForUpdate()
    if (updateAvailable) {
      const syncStatus = (status: codePush.SyncStatus) => {
        console.log('SyncStatus = ', status)
        switch (status) {
          case codePush.SyncStatus.UP_TO_DATE:
          case codePush.SyncStatus.UPDATE_IGNORED:
            console.log('App is up to date...')
            resolve()
            break
          case codePush.SyncStatus.UPDATE_INSTALLED:
            console.log('Update installed successfully!')
            // DO NOT RESOLVE AS THE APP WILL REBOOT ITSELF HERE
            break
          case codePush.SyncStatus.UNKNOWN_ERROR:
            console.log('Update received an unknown error...')
            resolve()
            break
          default:
            break
        }
      }

      // Install the update
      codePush.sync(
        {
          installMode: codePush.InstallMode.IMMEDIATE,
          mandatoryInstallMode: codePush.InstallMode.IMMEDIATE,
        },
        syncStatus,
      )
    } else {
      resolve()
    }
  })
  return Promise.race([checkAndUpdatePromise, timeout(TimeoutMS)])
}

애플리메이션을 처음 실행할 때 두 가지 방법으로 호출할 수 있습니다.

 useEffect(() => {
    console.log('Launching app and checking for codepush update....')
    installUpdateIfAvailable().finally(() => {
      timeout(300).finally(() => {
        RNBootSplash.hide({ duration: 350 })
      })
    })
  }, [])

또는 애플리케이션이 비활성 상태로 돌아올 때 (지금은 항상 강제로 확인하지만 주기적인 확인으로 전환할 예정입니다.) 다시 활성 상태로 돌아오면, checkForUpdate() 메서드를 호출하고,
true로 반환되면 앱에 업데이트할 때가 되었다는 화면을 표시합니다.

사용자가 해당 버튼을 클릭하면 restartAppAndInstall() 메서드를 호출해서 실제로 애플리케이션을 다시 시작하고, 업데이트를 쿼리하고 사용 가능한 업데이트가 있는지 표시하는 과정을 이어받습니다.
이 과정에서 react-native-bootspash 라이브러리를 사용해서 깜박임 없이 깔끔하고 매끄러운 경험을 제공합니다.

업데이트를 쿼리하다

쿼리 : 데이터베이스에서 특정한 데이터를 보여달라는 클라이언트(사용자)의 요청
구글 검색창에 "파이썬 기초 강의" 라는 검색어를 입력하면, 파이썬에 대한 정보가 나오는데
이러한 정보들은 모두 서버에 저장되어 있던 데이터베이스에서 온 것들이다.
내가 "파이썬 기초 강의"에 대한 데이터를 달라는 쿼리를 주었고, 서버가 이에 응답해서 데이터베이스에서 데이터를 보여준 것이다.

쿼리문을 작성하다 : 데이터베이스에서 원하는 정보를 코드로 작성한다.

업데이트를 쿼리하다 : AppCenter 클라우드에서 업데이트 정보(데이터)를 달라는 요청을 한다... 정도로 이해하면 될 것 같습니다.

해결방법2: AppDelegate.m 수정

제 경우는 코드 구조 자체가 아니라 다른 라이브러리와 비호환성으로 인한 잘못된 구성이었습니다.

Wix의 React Native Navigation 라이브러리를 사용 중인 분들을 위한 팁입니다.
AppDelegate.mdidFinishLaunchingWithOptions 메서드 내에서 올바른 번들URL을 사용하는지 확인합니다.

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  
  //NSURL *jsCodeLocation = [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil];  
//This was the original setup. So it was grabbing the original bundle instead of the update of the codepush server
  
  NSURL *jsCodeLocation = [CodePush bundleURL]; // Use this when you make the release build
  
  [ReactNativeNavigation bootstrap:jsCodeLocation launchOptions:launchOptions];
  
  [RNSplashScreen show];
  return YES;
}

이 변경 후 코드푸시 서버에서 마지막 업데이트를 성공적으로 다운로드할 수 있습니다.

코드해석

application didFinishLaunchingWithOptions:
애플리케이션이 실행을 완료할 때 호출되는 수명주기 메서드입니다.
이 코드에서는 애플리케이션이 계속 실행되어야 함을 나타내는 boolean값 YES를 반환합니다.

NSURL *jsCodeLocation = [CodePush bundleURL];
리액트 네이티브 애플리케이션의 자바스크립트 번들 파일 위치를 나타내는 jsCodeLocation 이라는 NSURL 객체를 생성합니다.
코드푸시 라이브러리는 앱의 자바스크립트 코드를 원격으로 업데이트하는 데 사용되는 자바스크립트 번들의 URL을 가져오는데 사용됩니다.

[ReactNativeNavigation bootstrap:jsCodeLocation launchOptions:launchOptions];
ReactNativeNavigation 라이브러리의 bootstrap:launchOptions: 메서드를 호출해서 + 매개변수로 jsCodeLocationlaunchOptions 를 전달합니다.
이 메서드는 지정된 자바스크립트 번들 및 실행 옵션으로 리액트 네이티브 애플리케이션을 부트스트랩하는 역할을 담당합니다.

부트스트랩하다?

소프르퉤어 개발의 맥락에서 "부트스트랩"은 일반적으로 시스템이나 애플리케이션을 사용할 수 있도록 초기화하거나 설정하는 프로세스를 의미합니다.
여기에는 필요한 설정 작업을 수행하고, 필요한 종속성 또는 리소스를 로드하고, 시스템 또는 애플리케이션을 실행할 수 있도록 준비하는 작업이 퐇함됩니다.

해당 코드의 경우, ReactNativeNavigation 라이브러리의 bootstrap:launchOptions: 메서드는 매개변수로 제공된 jsCodeLocation (자바스크립트 번들파일)과 launchOptions (실행 옵션)을 사용해서 리액트 네이티브 애플리케이션을 초기화하거나 설정하는 작업을 담당합니다.
이 프로세스는 번들 파일의 자바스크립트 코드를 기반으로 사용자 인터페이스를 렌더링하고 실행을 시작할 수 있도록 리액트 네이티브 애플리케이션을 준비합니다.
여기에는 네이티브 모듈을 설정하고, 탐색을 구성하고, 기타 필요한 설정 작업을 수행해서 리액트 네이티브 애플리케이션을 사용할 준비가 되도록 하는 작업이 포함될 수 있습니다.

[RNSplashScreen show];
애플리케이션의 스플래시 화면을 표시하는 RNSpashScreen 라이브러리의 show 메서드를 호출합니다.
이 메서드는 일반적으로 리액트 네이티브 애플리케이션이 초기화되는 동안 로딩 화면을 표시하는 데 사용됩니다.

return YES;
애플리케이션이 계속 실행되어야 함을 나타내는 YES를 반환합니다.

profile
새로운 것을 도전하고 노력한다

0개의 댓글