RN으로 앱 개발을 하며 느끼는 점은
단점이 생각보다 많지만,
"React 코드로 앱(iOS/Android) 개발이 가능하다." 하나로 밸런스가 얼추? 맞는다.
하지만 외주 개발로 만들어진 앱의 유지 보수를 맡게 되고,
이 시리즈를 작성하면서 느낀점은 문제들이 많고 생각보다 어려운 과정이구나 깨닫게 됐다.
1, 2년 전의 나에게는 당장 해결되는 솔루션이 필요했겠지만,
생각해보면 원인을 잘 파악하고 비슷한 에러들을 카테고라이징 하는게 더 중요한 것 같다.
RN 작동 원리 파악이 부족해서 헤매던 적도 있고,
네이티브 코드를 수정하는 게 무서워 문제 해결을 못한 적도 많았다.
그러다보니 같은 에러를 두고, 여러 해결 방법으로 처리해 중복된 코드도 많았다.
결국 내게 필요했던 것은 솔루션을 복사/붙여넣기 하는 것이 아닌,
에러의 원인 파악과 해결 방안을 정리하는 것이었다.
꿀팁이라 하기에는 부족할 수 있지만, 팁 정도는 되겄지.
$ npx react-native@latest init AwesomeProject
init 프로젝트인 AwesomeProject를 만들면 디렉토리는 다음처럼 구성돼있다.
AwesomeProject/
├── __tests__/ # 테스트 코드
├── android/ # 안드로이드 프로젝트
├── ios/ # iOS 프로젝트
├── node_modules/ # npm 패키지
├── .buckconfig # Buck 설정 (안드로이드)
├── .editorconfig # 편집기 설정
├── .eslintrc.js # ESLint 설정
├── .gitattributes # Git
├── .gitignore # Git
├── .prettierrc # Prettier 설정
├── .watchmanconfig # Watchman 설정
├── app.json # 앱 구성
├── babel.config.js # Babel 설정
├── index.js # 앱
├── metro.config.js # Metro Bundler
└── package.json # package.json
기본 React 프로젝트와 무슨 차이가 있을까?
단순히 index.html 대신 android/ ios/가 생겼다고 생각할 수 있다.
하지만 이러한 사실이
JS 지식만으로는 RN 프로젝트의 문제들을 해결하기 어렵다는 것을 알려준다.
결국 싫어도 언젠가는 Native 코드들을 만져야 하는 순간이 오게 된다.
iOS 플랫폼은 CocoaPods을 사용하여 외부 종속성을 관리한다.
CocoaPods는 iOS 프로젝트에서 써드파티 라이브러리를 쉽게 추가하고,
관리할 수 있도록 도와주는 도구다.
Pods 디렉토리에는 프로젝트에서 사용하는 외부 라이브러리들의 소스 코드와 설정 파일이 포함되어 있어서 Podfile을 사용해 종속성을 정의하고,
pod install 명령으로 CocoaPods를 통해 라이브러리를 설치한다.
Gradle Wrapper를 실행하는 스크립트다.
Gradle은 Android 프로젝트의 빌드와 의존성 관리를 담당한다.
gradlew 스크립트를 실행하면 Gradle을 자동으로 설치하고, 프로젝트를 빌드한다.
Gradle은 build.gradle 파일을 사용하여 프로젝트의 설정 및 의존성을 정의한다.
gradlew를 사용하면 Gradle 버전에 대한 의존성을 최소화하고, 프로젝트를 여러 환경에서 일관되게 빌드할 수 있다.
라고 chatGPT가 정리해줌
요즘은 검색하기 애매하고, 물어보자니 자잘한 지식들을 GPT가 잘 설명해 주니 좋다.
한창 RN과 사투가 멈추지 않던 지난날,
나는 이러한 내용을 하나도 모른 채 RN 버전을 업그레이드 하면 된다는 말을 듣고,
아무 생각없이 업그레이드를 시도했다.
결과는 빌드조차 되지 않고, 더 깊은 구렁텅이에 빠지게 됐다.
pod을 깔았다 지우고, gradlew 스크립트를 지우고, 다시 쓰기를 반복했다.
그래도 결과는 바뀌지 않았다.
가장 위험한 상태인 '내가 뭘 했는지 모름'과 '뭐가 문제인지 모름'이 협심해 날 괴롭혔다.
우선 에러가 왜 일어났는지, 그리고 왜 업그레이드로 해결 되는지 파악하지 못했다.
두 가지를 섭렵하기 위한 고마운 툴을 공유한다.
react-native 커뮤니티는 의외로 끈끈하다.
길 잃은 우리들에게 제법 여럿 따듯한 손길들이 내어진다.
React Native Upgrade Helper는 RN 프로젝트를 업그레이드 하는데 도움을 준다.
예를 들어 AwesomeProject를 0.68.1에서 0.71.0으로 업그레이드
프로젝트(App) 이름과 현재 버젼, 업그레이드할 버젼을 기입하고
"Show me how to upgrade!" 말그대로 해줘! 하면
Useful content라고 업그레이드 시 주의사항도 안내해 주며,
package.json부터 root에 위치한 각종 설정 파일,
각 OS 디렉토리에 포함된 설정 파일들까지,
자주 conflict을 해결하는 우리들에게 익숙한 뷰로 변경점을 상세히 보여준다.
또한 이를 활용해서 현재 프로젝트 버전의 네이티브 코드는 어떻게 작성됐는지 확인할 수 있다.
여러 버전과 비교하며 확인해 보면 네이티브 코드가 어떻게 작성되는지 확인할 수 있다.
보통 버전을 업그레이드 하라는 이유는
낮은 버전에 기입된 내용이 이후 버전에서 삭제되거나,
M1(Apple Silicon)에 맞춰 추가된 설정,
hermes처럼 JS 엔진이 변경되어 바뀐 내용,
각 OS 별 수정 사항을 임시로 처리했던 내용,
그 외에도 여러가지 업그레이드 버전에서는 불필요한 내용이나 충돌을 수정했기 때문이다.
하지만 부득이하게 앱의 RN 버전을 업그레이드 할 수 없다면?
하지만 특정 네이티브 코드나 라이브러리 코드의 수정이 필요하다면?
RN을 사용하다보면 서드 파티 라이브러리를 많이 사용하게 된다.
자주 사용하는 react-native-navigation조차 공식 라이브러리가 아니기 때문.
그러다보니 잘 사용하던 라이브러리도 오랫동안 업데이트가 없는 경우도 많고,
위 사진처럼 더 이상 운영하지 않는 리포지토리도 많다.
꼭 그런 라이브러리에 오류가 발생해 수정이 필요한 경우가 생각보다 많다.
patch-package를 사용하면 이를 쉽게 해결할 수 있다.
사용방법 또한 매우 간단하다.
그저 수정이 필요한 파일을 수정하고, patch-package 명령어로 patch 파일을 만든다.
수정사항을 커밋하면 끝.
참고로 yarn을 사용하면 postinstall-postinstall 패키지를 사용해야하는데
보다시피 yarn remove 후에 postinstall이 작동하지 않아 추가하는 패키지다.
yarn v2, pnpm 등은 patch 기능이 있어 따로 사용하지 않는다.
예시로 현재 사용하고 있는 react-native-sound 라이브러리의 경우
RNSound.m 파일의 불필요한 if문을 지우는 패치를 추가해줬다.
사실 이러한 패치 파일들이 늘어나는 것도 관리 포인트가 늘어나는 형태다보니,
무작정 많이 쓰기에 좋지는 않다.
급한 부분에만 사용하거나, 업데이트가 멈춘 라이브러리에만 사용하는게 좋다.
이테까지 빌드에만 초점을 맞춰 글을 진행했다.
이유는 일단 실행이 된다면 이후부터는 RN의 문제라기보다 JS 코드의 문제일 확률이 높고,
Android는 되는데 ios는...
ios는 되는데 Android는... 처럼
각 OS마다 다른 사항으로 인해 생기는 이슈일 확률이 높다.
무엇보다 가장 큰 문제는 이러한 여러가지 이슈들이 섞여서 출현하게 된다는 것.
-> 내가 react 코드를 잘못 썼나?
-> 어 근데 사용한 라이브러리가 문제인 것 같네?
-> 뭐지 라이브러리는 문제가 없는데 ios에서만 이러는건가?
-> 아 이 다음 ios 버전에서는 수정됐구나
-> 그럼 임시로 JS 코드 여기만 수정하자
-> 와 이러니까 android가 작동을 안하네
-> Platform api를 사용해서 분기처리
-> 어찌어찌 실행은 되지만, 뒤를 돌아보니 지저분한 코드가 가득
더 깊게 들어가면 시뮬레이터(에뮬레이터)에서는 되고,
실제기기에서는 작동을 안하거나 그 반대의 경우.
심하게는 네이티브 코드를 의도치 않게 잘못 작성해 빌드까지는 되지만,
의도한대로 실행이 되지 않는 기괴한 결과물이 나오기도 한다.
최근에 수정한 이슈로 아까도 언급했던 "react-native-sound"에서
생각보다 시간이 걸린 문제가 있었는데
react-native-sound의 공식문서에서는
각 네이티브 폴더에 mp3 파일을 넣어 사용하는 것을 기본 예시로 소개했다.
시뮬레이터로 테스트를 진행하고, 정상적으로 사운드가 나오는 것을 확인
ios는 실제기기에서도 문제 없었다.
문제는 Android 실제 기기에서 사운드가 재생이 안되고 있었다.
Android 에뮬레이터에서도 문제가 없었는데 왜 그랬을까?
이유를 찾아보니 android/app/build.gradle 파일 설정 중
shrinkResources를 true로 설정 할 경우
사용하지 않는 리소스들을 자동으로 삭제하는데
이유는 아직까지 모르겠으나 assets에 있는 파일 중 mp3 파일만이 살아남지 못했다.
안드로이드 개발자 문서에서 관련 부분을 찾아봤지만,
설명을 읽으면서도 원인을 정확히 파악하지 못했다.
일단 해결책으로 맞춤설정을 추가해서 빌드해봤지만, mp3 파일을 살려내지 못했다.
오히려 기존에 잘 불러오던 이미지 파일들을 전부 번들에서 찾지 못해
이미지가 전부 깨진 엉망진창인 앱이 됐다.
이런식으로 직접 import 해서 일시적으로 해결하긴 했다.
이렇게 mp3 파일을 직접 불러오면 shrinkResources를 true로 해도,
사운드가 제대로 재생된다.
shrinkResources 값에 따라 번들 크기가 어느정도 차이가 날지 비교해봤는데
놀랍도록 차이가 없었다...
대부분의 리소스가 모두 잘 사용되고 있다는 뜻이겠거니 받아들였다.
예전에 나였다면 의미 없는 일이었다며 shrinkResources를 false로 되돌리고,
다른 일에 집중 했을지도 모른다.
하지만 앱의 규모가 커지면서 사용하지 않는 assets을 바로 정리하는 것이
의외로 어렵다는 사실을 깨닫고 나서부터 이러한 설정들에 더 관심을 가지게 됐다.
결국 정리하자면 빌드를 성공하고 앱을 시뮬레이터나 실제 기기에서 실행에 성공했다면,
이다음부터는 정말 실전밖에 남지 않았다.
위의 사례처럼 네이티브 코드를 수정하면 해결되겠지 싶은 문제도,
결국은 JS 코드 수정으로 해결한 것처럼 방법이 너무 다양하다.
이제 반대로 JS 코드가 아닌 위에서 설명한 patch-package를 사용해 네이티브 코드를 수정한 케이스를 공유해 본다.
분명히 글을 6월에 올릴 계획을 하고, 계획을 순조로이 진행했다.
진행하던 유지 보수 작업도, 놀라울 정도로 이슈 하나 없었다.
평화에 취하는 것도 잠시 운영팀에서 급하게 나를 호출했다.
"바코드 스캔을 여러 번 하면 앱이 멈춰요!"
그리 당황하지는 않았다.
지금 작업하고 있는 앱은 TS를 사용하지만 @ts-ignore가 넘치고,
esLint를 걷어내고, IDE에 시뻘건 밑줄이 그어져도 커밋을 남긴 혼종 그 자체다.
으레 있는 JS 코드상에 이슈이리라 괘념치 않았다.
하지만 상황은 생각보다 심각했다.
바코드 스캔 기능과 관련된 부분들은 trt/catch로 감싸져 있었다.
react-native-navigation, mobX 등 생각해 봤던 오류 후보가 있었지만,
이들 모두 코드상 문제가 생긴다면 catch 문에 잡혀야 정상이다.
모종의 이유로 앱은 Freeze, deadlock처럼 말 그대로 작동이 무한히 지연되고 있었다.
처음에는 바코드 스캔과 관련한 불필요한 로직, navigation stack 생성, 렌더링 등
JS 코드 안에서 수정이 가능한 문제라 생각했다.
실제로 문제가 있는 코드들을 조금 정리하고 나니
Android 환경에서는 해당 오류가 발생하지 않았다.
xCode Instruments를 사용해 디테일한 Profiling을 시도했다.
문제는 iOS였다.
바코드 스캔과 관련한 모든 로직을 수정하고, 렌더링 최적화를 진행했음에도
여전히 앱은 바코드 스캔마다 간헐적으로 멈추고 있었다.
iOS 관련 서치를 계속하다 보니 해당 문제를 Severe Hang이라 부르는 걸 알게 되었다.
유저와 인터렉션 중 지연되는 시간이 대략 2000ms가 초과되면 표시되는 로그였다.
예상대로 iOS 메인이 아닌 그 외 쓰레드에서 deadlock이 발생하고,
unLock을 무한히 기다리는 상황이었다.
해서 대강 정리가 됐다.
우선 JS 코드만으로 이런 deadlock과 같은 쓰레드 사고는 어렵다고 판단했다.
알다시피 javascript는 싱글 스레드 언어기도 하고,
react-native는 메인 쓰레드에서 실행되는 JS 엔진에서 동작한다.
메인 외 쓰레드에서 발생하는 사고라면 적어도 내가 작성한 코드는 아니다.
(react-native는 NativeModule을 지원해서 네이티브 코드를 작성할 수 있다. 하지만 나는 쓴 적 없음)
결론은 사용하고 있는 바코드 스캔 라이브러리 문제라고 판단했다.
"1년에 4000불가량을 지불하는데 오류 지원은 확실하겠지."라는 내 바람은 처참히 박살 났다.
창구라고는 라이브러리 github 이슈 탭이 전부.
관련 코드와 Profiling 결과를 동봉해 이슈를 남겼다.
내가 쓴 코드는 아니지만, 나름 회사 작업물인데 이리 공개되니 좀 껄끄럽기도 했다.
어설픈 영작과 함께 내 답답함을 러브레터처럼 남겼다.
일주일 정도 길어지는 무응답에 독촉까지 하게 되니 섭할 수도 있지 않은가?
이윽고 답변이 도착했지만, 실망스러웠다.
요약하자면
"우리는 문제없어! useEffect()
사용 방법이 틀린 것 같아 이렇게 써봐!"
사실 답장을 기다리는 동안 어느 정도 원인을 찾았었다.
좀 더 정확한 원인 파악을 위해 기다린 것인데 비교적 성의 없는 답변에 아쉬운 마음은 어쩔 수 없었다.
Sentry를 적용해서 좀 더 많은 로그를 수집했다.
공통적으로 __psynch_mutexawait
과 함께 Hang이 발생했다.
또한 공통적으로 AVFoundation
관련 로그들도 확인할 수 있었다.
우선 AVCapturesession
는 iOS에서 카메라, 비디오 등 입력 장치를 제어하기 위해 제공되는 클래스다.
매번 이러한 로그와 함께 Hang이 발생한다면,
바코드 스캔을 위한 함수 호출이 아닌 단순히 카메라 화면을 여는 것만으로 문제가 생길 수 있다고 판단했다.
실제로 테스트해보니 라이브러리에서 제공하는 <DCVCameraView />
만을 렌더링 해도 Hang이 발생했다.
<DCVCameraView />
와 관련된 RN의 네이티브 모듈 코드와
DYSCameraView.m
등 네이티브 코드를 분석했다.
분석해 보니 DYSCameraView.m
는 매번 무의미하게 새로 init 하는 로직을 갖고 있었다.
특히 DynamsoftCameraEnhancer
는 굳이 새로 init 되지 않아도 되는 클래스였다.
앱 특성상 여러 바코드 스캔 화면을 새로 렌더링 할 때가 많은데,
그럴 때마다 DynamsoftCameraEnhancer
는 새로 선언되고 있었다.
해당 코드를 수정해 dce가 nil인 경우에만 새로 선언하게 수정했다.
이후 Hang은 더 이상 발생하지 않는다.
보이는 것처럼 height을 heigth로 작성하는 등
실수는 할 수 있지만, 고치지 않는 것처럼
확인할 수 없는 실제 코드는 더 엉망일 가능성이 크리라 생각된다.
분명 mutex
와 관련된 로직을 엉망으로 작성했을 거라 의심된다.
이런 라이브러리에 연간 4000불을 태우다니.
세상은 의외로 어설프게 돌아갈지도 모른다.
토스ㅣSLASH 23 - 달리는 토스 앱에 React Native 엔진 더하기에서도 나왔듯이 RN은 문제점이 참 많은 프레임워크다.
가끔은 "그래서 Flutter보다 RN이 더 좋은 이유가 뭐예요?"라는 질문에
"그야, React 개발자한테 앱 개발도 시킬 수 있어서죠."라는
빈정 섞인 대답이 목구멍 바로 밑까지 차오른다.
누워서 침을 너무 뱉어 숨이 막힐 지경이다.
그럼에도 개발은 정신 나간 소리 같지만 재밌기도 하고,
특히 RN으로 하는 개발은 수명을 걸고 하는 겜블 같아 흥겹다.
어쩌다 보니 RN 개발을 시작하는 사람들에게 팁을 제공하는 글이 아닌,
도망치라는 사인을 보내는 글이 되는 것 같아 좀 아쉽기는 하다.
하지만 혹시 모른다.
react 개발만을 하다 어느 날 갑자기 react-native를 하게 된 FE 개발자라면,
이 글은 나름 도움이 될 거라 생각한다.