Tech

숨고 React Native 앱에서 CodePush 업데이트 전송 시간 줄인 경험

문제를 발견하고 치밀하게 분석한 경험과 해결 방법을 공유합니다

2023-12-05 Floyd Kim

들어가는 말

안녕하세요.

숨고 Tech Team의 Mobile Chapter에서 Mobile App Engineer로 일하고 있는 Floyd입니다. 숨고 앱 사용자에게 좋은 경험을 주고 서비스를 원활히 지원하기 위해 노력하고 있습니다.

얼마 전 숨고 앱의 CodePush 업데이트 용량이 약 14MB에서 8MB로 약 43% 감소했습니다. 덕분에 평균 업데이트 다운로드 소요시간도 7초에서 4초로 43% 줄어 다운로드 중단으로 인한 배포 실패 확률도 낮아졌습니다.

이런 큰 폭의 개선은 그동안 인지하지 못했던 문제를 발견하고 원인을 파악해 해결하면서 이루어진 개선인데요. 이 과정에서 알게 된 내용과 경험을 공유하려 합니다.

TL;DR

  • CodePush로 Source Map까지 함께 배포하고 있는지 확인합니다. 만약 Source Map을 함께 배포하고 있다면 이에 대한 명령어를 수정합니다.

  • Hermes를 활성화한 프로젝트라면 배포한 번들이 Hermes로 컴파일 되어있는지 확인합니다. Hermes로 컴파일되어 있지 않다면 배포 명령어를 수정합니다.

서론

앱 설치 용량 또는 웹페이지 로딩 소요 시간처럼 유저에게 제품을 전달하려면 어쩔 수 없이 소모하는 자원들이 있습니다. 이러한 저장 공간과 시간을 줄이기 위해 많은 개발자분들이 고민하고 계실텐데요. 숨고의 엔지니어들도 이러한 리소스를 줄이기 위해 항상 의식하며 노력하고 있습니다.

이러한 노력의 일환으로 번들 용량을 줄이는 작업을 하다가 이상한 점을 발견했습니다. 작업의 효과를 확인하기 위해 생성한 번들 용량이 기존에 알고 있던 프로덕션 배포 용량보다 수 MB 작았습니다. 작업 전 상태로 돌아가서 생성한 번들도 마찬가지였습니다. 대략 40%의 큰 차이였습니다.

이전에도 비슷한 작업을 하며 이런 차이를 목격한 적이 있었지만 이번에는 이 차이를 가볍게 넘기지 않았습니다. 제가 무엇을 잘못한 것일까요? 혹시 무언가 놓치지는 않았을까요? 이번 기회에 왜 이런 차이가 생겼는지 제대로 파악해보기로 했습니다.

배경지식

우선 글의 이해를 돕기 위해 앞으로 사용할 표현을 정의하겠습니다.

  • JS 번들 : 번들러(Bundler)를 사용해 생성한 Javascript 코드 번들 파일을 지칭합니다.
  • CodePush 번들 : JS 번들을 포함하며, 추가적으로 이미지 파일 등이 함께 묶여있는 파일을 지칭합니다. CodePush 업데이트시 유저에게 이 파일이 전송됩니다.

CodePush(코드푸시)란?

CodePush는 React Native 개발자가 모바일 앱 업데이트를 사용자의 디바이스에 직접 배포할 수 있도록 하는 App Center 클라우드 서비스입니다.

CodePush를 사용하면 각 플랫폼의 앱스토어 리뷰 과정 없이 React Native 앱의 내용을 변경할 수 있습니다. 유저에게 빠르게 가치를 전달하기 위해 유용하게 사용할 수 있습니다.

하지만 약간의 제약은 있습니다. 앱 내 Javascript 엔진으로 코드를 실행하는 React Native의 기술적 특성을 활용한 도구이므로 Javascript와 관련된 변경만 배포할 수 있습니다.

또한 배포 전략과 구현 방식에 따라 오히려 유저를 불편하게 만들 수도 있기 때문에 서비스 운영 상 ‘얻는 가치’와 ‘잃는 가치’ 사이의 트레이드오프를 고려해야 합니다. 예를 들어, CodePush 배포 방식을 백그라운드 업데이트로 설정하면 유저의 불편을 최소화할 수 있지만 배포 성공률이 비교적 낮아 점유율 상승이 더뎌질 수 있습니다.

Source map(소스맵)이란?

JS 번들을 만들 때는 용량 감소, 보안 향상 등의 목적으로 경량화와 난독화를 수행하는 것이 일반적입니다. 난독화 된 문자열은 사람이 그대로 읽어 해석하는 것이 사실상 불가능해집니다.

Source map은 이렇게 난독화된 코드와 개발자가 작성한 코드(Source)의 관계를 연결(Map)하기 위한 정보입니다. 다시 말해, ‘난독화된 코드’의 특정 위치가 ‘내가 작성한 소스코드’의 어느 위치에 해당하는지 알기 위해 만드는 정보입니다.

서비스 운영 중 발생한 오류를 파악하기 위해 버그 트래킹 도구들을 활용할 때, Source map을 활용한다면 에러가 코드의 어떤 부분에서 발생했는지 알아낼 수 있습니다.

CodePush 번들의 불청객

다시 돌아와서, 왜 로컬에서 만든 CodePush 번들의 용량이 프로덕션 배포 용량과 크게 달랐을까요?

숨고 앱의 CodePush 배포는 항상 CI/CD 파이프라인을 통해 클라우드 환경에서 스크립트 실행을 통해 수행됩니다. 이번 작업에서는 간단히 용량 차이를 보기 위해 로컬 머신에서 직접 명령어를 실행해 CodePush 번들을 생성했습니다. 그러므로 프로덕션 배포 시 실행하는 명령어와 로컬에서 실행한 명령어가 다를 수 있음을 우선 의심했습니다.

문제

확인한 결과 명령어상의 차이점은 Source map 생성 여부 뿐이었습니다. 배포하지 않을 번들을 만들 때 Source map 생성은 불필요하므로 고려하지 않았기 때문입니다. Source map을 생성하지 않아서 CodePush 번들 크기가 작아졌다는 사실을 수긍할 수 없었습니다. 그동안에는 CodePush 번들 크기는 JS 번들의 크기와 애셋의 크기로 결정된다고 알고 있었기 때문에 자연스럽게 ‘Source map을 만들었더니 JS 번들 크기가 증가했다’는 결론으로 이어졌는데 이것은 이상하다고 생각했기 때문입니다.

그렇다면 유저에게 배포된 CodePush 번들의 내용을 확인할 수 있다면 의문이 해소될까요? 배포된 CodePush 번들은 AppCenter 웹사이트에서 간편하게 다운로드 받을 수 있고 다운로드 받은 파일에 .zip확장자를 붙여주면 압축을 풀어서 내용을 볼 수 있습니다.

// appcenter-cli 코드를 보면 zip 압축을 하고 있음을 알 수 있습니다.

class CodePushReleaseCommandBase extends AppCommand {
  protected async release(client: AppCenterClient): Promise<CommandResult> {
    // ...
    const updateContentsZipPath = await zip(this.updateContentsPath);

    const releaseUpload = this.upload(client, app, this.deploymentName, updateContentsZipPath);
    // ...
  }
}

이렇게 열어본 프로덕션 CodePush 번들에는 40MB 크기의 거대한 Source map 파일이 자리잡고 있었습니다. 예상치 못한 불청객입니다. AppCenter CLI는 배포할 파일들이 특정 디렉토리에 모여있음을 가정하고 zip 압축을 수행하며, zip 압축은 일반적으로 특정 디렉토리에 속한 모든 파일들을 하나의 파일로 묶습니다. Source map이 CodePush 디렉토리 안에 생성된다면 여지없이 모두 묶어 CodePush 번들을 만듭니다.

Map(main.jsbundle.map)이 CodePush 번들에 포함된 모습
Map(main.jsbundle.map)이 CodePush 번들에 포함된 모습

결국 프로덕션 빌드 명령어를 확인했고 CodePush 디렉토리에 Source map 파일을 생성하고 있음을 확인했습니다. 이는 저희가 사용하는 버그 리포팅 도구의 설치 가이드에서 기인한 것이라 황당하기도 했습니다. 수 년 전의 일이지만요. appcenter-cli Github의 Issue들을 살펴보면 Source Map 생성 관련 옵션의 쓰임새가 달라지면서 이러한 문제가 생겨난 것으로 보입니다.

해결 방법

Source map이 배포 디렉토리 안에 생성되지 않도록 명령어상의 경로를 수정하여 이 문제를 바로잡았습니다.

CodePush 디렉토리 바깥에 생성

Source map을 업로드하는 명령에서도 경로를 수정해 버그 리포팅 내용에 문제가 없도록 합니다.

효과

프로덕션 CodePush 업데이트 전송 평균 소요시간이 약 7초에서 4초로 43% 가량 크게 감소했습니다. CodePush 번들 용량이 14MB에서 8MB로 작아졌기 때문입니다.

전송 소요 시간 변화
전송 소요 시간 변화

다운로드에 걸리는 시간이 줄어든 덕분에 배포에 실패하는 비율도 낮아졌습니다. AppCenter 웹사이트에서 확인할 수 있는 데이터 중 Rollback 발생 수치가 대폭 감소하여 그간 집계되던 Rollback의 대부분은 유저가 업데이트를 기다리다가 이탈하여 발생한 것이라는 인사이트도 얻을 수 있었습니다.

Mandatory 업데이트 중 Downloads 수가 비슷한 배포를 비교해봤습니다.

용량(MB) Rollback Downloads 발생율(%)
기존 16.0 3,397 75,642 4.5
개선 후 8.0 249 65,589 0.4

앱에 Firebase Performance 기능을 연동하셨다면 업데이트의 용량과 소요시간을 간편하게 모니터링할 수 있습니다. 수상한 변화가 기록됐다면 꼭 원인을 파악해보시기 바랍니다.

CodePush 번들 사이즈 변화
CodePush 번들 사이즈 변화

Hermes 컴파일과 CodePush

한편, 이번 문제를 해결하는 과정에서 appcenter-cli의 소스 코드를 확인한 덕분에 또 다른 문제를 알게 되었습니다. appcenter-cli는 CodePush 번들 생성과 업로드를 수행하는 CLI 프로그램입니다.

문제

약 한 달 간 Hermes로 컴파일하지 않은 JS 번들이 배포되고 있었음을 발견했습니다. 이로 인해 앱 구동 속도가 느려져 유저에게 나쁜 경험을 줄 수 있음을 알게 되었습니다.

appcenter-cli는 React Native 빌드 설정에서 Hermes 활성화 여부를 읽고, 읽어낸 결과에 따라 JS 번들을 Hermes 컴파일하도록 구현돼있습니다.

// appcenter-cli가 Hermes 엔진 활성 여부를 인식하는 방법

function getAndroidHermesEnabled(gradleFile: string): boolean {
  return parseBuildGradleFile(gradleFile).then((buildGradle: any) => {
    return Array.from(buildGradle['project.ext.react'] || [])
              .some((line: string) => /^enableHermes\s{0,}:\s{0,}true/.test(line))
  })
}

function getiOSHermesEnabled(podFile: string): boolean {
  // ..
  const podFileContents = fs.readFileSync(podPath).toString()
  return /([^#\n]*:?hermes_enabled(\s+|\n+)?(=>|:)(\s+|\n+)?true)/.test(podFileContents)
}

하지만 이는 React Native 0.71부터는 유효하지 않은 방법입니다. Hermes 엔진 활성화 설정 방법이 달라졌기 때문입니다. 그런데 appcenter-cli 구현에는 이 변화가 반영되지 않았습니다. AppCenter 팀은 이에 대한 이슈를 리포트 받은 바 있지만 아쉽게도 조치를 취하기 보다는 앞으로 자동 감지 기능을 유지보수 하지 않기로 결정했습니다.

RN 0.71부터 달라진 Hermes 엔진 활성 설정 방법 (android/gradle.properties)
RN 0.71부터 달라진 Hermes 엔진 활성 설정 방법 (android/gradle.properties)
RN 0.71부터 달라진 Hermes 엔진 활성 설정 방법 (ios/Podfile)
RN 0.71부터 달라진 Hermes 엔진 활성 설정 방법 (ios/Podfile)

명령어상으로는 컴파일*이 안됐을 것이라 추측할 수 있지만 더 직접적으로 확인할 수 있는 방법도 있습니다.

*이 포스팅에서의 ‘컴파일’은 엄밀한 정의와 달리 Javascript 코드를 Bytecode로 변환하는 것을 의미합니다.

JS 번들 파일을 열어보는 것이 가장 확실합니다. 파일을 IDE 등으로 열어보았을 때 Hermes 컴파일을 거쳤다면 JS 번들 내용이 bytecode라서 경고가 뜨지만 그렇지않아 minified JS 상태로 배포된 번들은 일반 텍스트라서 바로 내용을 확인할 수 있습니다.

용량을 비교해서도 확인할 수 있는데요. 올바르게 생성한 CodePush 번들 용량과 배포된 CodePush 번들의 용량이 같으면 문제가 없다고 볼 수 있습니다.

이는 Hermes 컴파일을 적용하면 그렇지 않은 경우보다 CodePush 번들 크기가 증가하기 때문입니다. 컴파일하여 생성된 Bytecode 파일은 일반적인 JS 번들보다 용량이 작지만 압축률이 낮기 때문에 zip 압축을 거쳐 만들어지는 CodePush 번들 용량은 오히려 커집니다.

해결 방법

React Native 0.71 이상이면서 Hermes 엔진을 활성화한 프로젝트를 배포하고 계시다면 appcenter-cli 명령에 명시적으로 --use-hermes 옵션을 사용하시길 추천드립니다.

이 옵션을 사용하면 CodePush 번들을 만들 때 appcenter-cli가 Hermes 활성화 여부를 체크하지 않고 항상 Hermes 컴파일을 수행합니다.

React Native 0.71 미만에서 업그레이드를 계획하고 계신 팀에서도 인지하고 적용하시기를 추천드립니다.

효과

이 문제를 해결하면 무엇이 달라질까요? 앱 구동속도에 영향을 줄 것이라는 가설을 가지고 비교 테스트했습니다. 그 결과 체감되는 차이를 발견하여 이를 구체적으로 측정해봤습니다.

Hermes 컴파일 여부에 따라 유저가 체감하는 앱 구동 소요시간이 어떻게 달라지는지 측정하기 위해 네이티브 구동 시작부터 JS 번들을 로드하여 실행을 시작하기까지 걸리는 시간을 측정했습니다. 이 구간의 측정은 react-native-firebase로 측정하지 못하여 react-native-performance를 사용했습니다.

측정은 사양이 다른 iOS 및 Android 기기 4개를 사용해 여러 차례 측정했습니다. 측정 결과 컴파일 하지 않은 JS 번들을 배포하면 구동 소요시간이 2배에서 최대 5배까지 늘어나는 것을 확인했습니다. 특히 안드로이드 저사양 기기의 경우 컴파일하지 않은 번들을 배포하면 약 5.6초 걸려 구동됐지만 컴파일한 번들을 배포하자 소요시간이 약 1.3초로 이전 대비 약 70% 이상 감소했습니다. 이는 CodePush 배포를 하지 않은 상태와 동일한 수준의 바람직한 결과이며 사용자 입장에서 크게 체감되는 변화라고 생각합니다.

글을 마치며

예상치 못한 것이었지만, 발견한 문제를 놓치지 않고 분석하여 적합한 해결책을 도출하는 과정에서 많은 것을 배울 수 있었습니다. 이러한 경험이 축적되어 훗날 탁월한 엔지니어로 성장할 수 있다고 생각합니다. 게다가 문제가 해결되면 어떤 효과가 있는지 파악하여 얻은 성취감도 컸습니다.

또한 문제를 발견하고 해결하기 위해 적절한 측정이 중요하다는 것을 배울 수 있는 경험이었습니다. 이렇게 측정된 결과를 잘 활용하는 것도 중요한 만큼, 이번에 알게 된 내용을 바탕으로 앞으로 여러 측면에서의 숨고 앱의 성능 개선을 시도해보고자 합니다.

숨고의 Mobile Chapter는 앞으로도 React Native를 더 잘 활용하면서 생태계에 기여하는 팀으로 성장하기 위해 노력하겠습니다. 이 글을 통해 독자분들께서 문제를 점검하고 해결하셔서 유저 경험 향상과 서비스 운영에 도움이 되시길 바랍니다.

감사합니다.

Floyd Kim Mobile App Engineer
Agile 선언문과 12 원칙을 실천하고 싶은 개발자입니다.