RSS

Metro 포트 충돌로 배운 것: RN이 OTA 업데이트를 할 수 있는 이유

새로 만든 앱을 아이폰에 설치했습니다. 빌드는 성공했고 설치도 됐는데, 앱을 열자 빨간 에러 화면이 떴습니다. 그런데 에러가 가리키는 파일이 이상했습니다.

지우지도 않은 파일에서 에러가 났습니다

Uncaught Error
Cannot find native module 'ExpoSecureStore'

  auth.ts:1
  auth-context.tsx:9
  _layout.tsx:4

auth.tsauth-context.tsx 는 이 프로젝트에 없는 파일입니다. 이 앱은 서버도 로그인도 없는 지도 앱이라, 만들면서 그런 인증 관련 코드는 전부 걷어냈습니다. _layout.tsx 도 열어보면 화면 하나만 띄우는 여덟 줄짜리 파일입니다.

없는 파일에서 에러가 날 수는 없습니다. 그렇다면 지금 폰에서 돌고 있는 건 제가 만든 코드가 아니라는 뜻이었습니다.

8081 포트에 다른 프로젝트가 있었습니다

빌드 로그 마지막 줄이 힌트였습니다.

Waiting on http://localhost:8081

포트를 누가 쓰고 있는지 확인했습니다.

lsof -nP -iTCP:8081 -sTCP:LISTEN

다른 앱 프로젝트에서 띄워둔 개발 서버가 그 포트를 점유하고 있었습니다. 며칠 전 안드로이드 작업을 하다 켜둔 채 잊은 것이었습니다.

새로 설치한 앱은 8081에 접속했고, 거기 있던 다른 앱의 자바스크립트를 받아와서 실행했습니다.

왜 이런 일이 가능한가

여기서 React Native의 구조가 드러납니다. 네이티브 앱과 자바스크립트 번들은 완전히 분리돼 있습니다.

개발 빌드에서 네이티브 쪽이 하는 일은 단순합니다. "이 주소에서 번들을 받아와서 실행해라"가 전부입니다. 받아온 번들이 자기 앱의 것인지는 확인하지 않습니다. 주소가 맞으면 무엇이든 실행합니다.

에러 메시지가 "번들이 다릅니다"가 아니라 "네이티브 모듈을 찾을 수 없습니다"였던 이유가 이것입니다. 실행된 자바스크립트는 다른 앱의 것이라 보안 저장소 모듈을 불러오려 했는데, 껍데기인 네이티브 바이너리는 제 앱의 것이라 그 모듈이 애초에 들어있지 않았습니다. 불일치가 그 지점에서야 드러난 겁니다.

정리하면 이렇게 어긋나 있었습니다.

어느 앱 것인가 무엇을 기대하나
네이티브 바이너리 지도 앱 지도 모듈만 들어있음
자바스크립트 번들 다른 앱 보안 저장소 모듈을 요구

Metro가 하는 일

Metro는 React Native의 번들러입니다. 개발 중에는 서버로 떠서, 프로젝트의 자바스크립트를 하나로 묶어 HTTP로 내려줍니다. 파일을 고치면 다시 묶어서 다시 내려줍니다. 저장하자마자 화면이 갱신되는 게 이것 덕분입니다.

기본 포트가 8081이고, 프로젝트마다 이 값이 같습니다. 그래서 여러 프로젝트를 동시에 열어두면 먼저 뜬 쪽이 포트를 차지하고, 나중에 붙는 앱은 남의 번들을 받게 됩니다.

해결은 포트를 나누는 것입니다.

npx expo start --port 8082

다만 이미 설치된 앱은 빌드 시점에 정해진 포트를 봅니다. 그래서 앱도 그 포트를 보도록 다시 빌드해야 합니다.

RCT_METRO_PORT=8082 npx expo run:ios --device <UDID> --no-bundler

이 구조가 곧 OTA 업데이트입니다

여기서 흥미로운 지점이 나옵니다. 개발 중에 Metro가 하던 일을, 배포된 앱에서는 업데이트 서버가 합니다.

구조가 똑같습니다. 네이티브 바이너리는 그대로 두고 자바스크립트 번들만 갈아끼웁니다. 앱을 켤 때 새 번들이 있는지 물어보고, 있으면 받아서 다음 실행부터 그것으로 돕니다.

그래서 스토어 심사를 다시 받지 않고도 반영할 수 있습니다. 스토어가 검사하고 배포하는 건 네이티브 바이너리인데, 그건 바뀌지 않았으니까요.

제가 사고로 겪은 것이 바로 이 메커니즘입니다. 다른 번들을 받아서 실행했고, 앱은 아무 저항 없이 그걸 실행했습니다. 이 유연함이 OTA 업데이트를 가능하게 하는 바로 그 성질입니다.

그래서 못 바꾸는 것도 정해집니다

같은 구조가 한계도 정합니다. OTA로 바꿀 수 있는 건 자바스크립트와 이미지 같은 에셋뿐입니다.

새 네이티브 모듈을 추가하거나, 권한 설정을 바꾸거나, SDK를 올리는 일은 네이티브 바이너리를 건드리므로 새 빌드가 필요합니다.

이것도 제 사고가 그대로 보여줬습니다. 자바스크립트만 다른 것으로 바뀌었더니, 네이티브에 없는 모듈을 찾다가 죽었습니다. 자바스크립트가 네이티브에 없는 기능을 요구하면 그 자리에서 멈춥니다. OTA로 네이티브를 바꿀 수 없다는 말의 실제 모습이 이렇습니다.

runtimeVersion은 이 사고를 막는 장치입니다

설정 파일에 runtimeVersion 이라는 값이 있습니다. 처음 봤을 때는 그냥 버전 표기인 줄 알았는데, 이번 일을 겪고 나서 무엇을 위한 것인지 이해했습니다.

이 값은 번들과 네이티브 바이너리가 짝이 맞는지 확인하는 표식입니다. 업데이트를 받을 때 앱은 자기 runtimeVersion 과 번들의 값을 비교하고, 다르면 그 업데이트를 받지 않습니다.

제가 겪은 사고는 그 가드가 없는 개발 모드에서 같은 상황을 수동으로 재현한 셈입니다. 운영에서는 이런 조합이 애초에 성립하지 않도록 막혀 있습니다.

남은 주의사항

애플은 OTA로 앱의 핵심 동작을 바꿔 심사를 우회하는 것을 금지합니다. 버그 수정, 문구 변경, 콘텐츠 갱신 정도로 쓰는 것이 안전합니다. 기술적으로 가능한 것과 규정상 허용되는 것이 다릅니다.

그리고 개발할 때는 프로젝트를 여러 개 열어두지 않거나, 열어야 한다면 포트를 나누는 것이 좋겠습니다. 저는 켜둔 개발 서버 하나 때문에 빌드 문제인 줄 알고 한참 엉뚱한 곳을 봤습니다.