React Native로 전환하고 신세계를 경험중입니다

우리 학교 앱센터에서 굴리는 앱 중에 카페테리아라고 있는데, 이 앱을 최근에 처음부터 다시 작성헀습니다. 이걸 하면서 느낀 점들을 써 봅니다. 사실 5월달에 완성하고 지금 글을 쓰는 시점에서는 2주 정도가 지난 후라 기억이 잘 안 나지만(오늘만 보고 삽니다..) 일단 써볼게요. 기술적인 사실을 전달하기보다는 주절주절에 가까운 글이니 가볍게 읽어주시면 되겠습니다 ㅎㅎ

배경

5월 초에 학교 내 소비자생활협동조합에서 전화가 왔습니다. 카페테리아 iOS 앱에 잘못 표기된 문구가 있다는 것이었습니다. 정말 간단하게 단어 하나만 바꾸면 그 문제는 해결되는 것이었는데, 생각해보니까 문제가 그게 다가 아니었습니다. 학교 학식당에는 코너가 여러 개 있고, 학기마다 코너 이름이나 메뉴 편성이 자주 바뀌어 왔습니다. 그런데 iOS 앱에는 코너 이름이 하드코딩되어 있어 서버쪽 API의 변화를 따라가지 못하는 문제가 있었습니다.

사실 카페테리아 서비스를 뜯어고치기로 결심한 후로 1년 조금 넘는 시간 동안 안드로이드 앱을 두 번 갈아엎고 서버도 갈아엎고 배포와 운영을 위한 시스템까지 만들어 놓았으나 iOS 앱은 손도 대지 못한 상태였습니다(XCode 싫어요…앱등이지만 이건 좀..). 전체 서비스 구성 중에 딱 하나 손대지 못한 부분이 있는 것도 아쉬웠고 또 iOS 네이티브 개발을 새로 배워서 할 만큼 개발 경험에 크게 매력이 느껴지지도 않아서 어떻게 해야 하나 싶던 참에 머릿속에 React Native와 Flutter라는 두 이름이 스쳐 지나갔습니다.

결정의 순간

보통 모바일 앱을 만든다 하면 가장 기본적인 선택지가 각 플랫폼 위에서 네이티브 코드를 짜는 것입니다. 이게 사실 개발자 입장에서는 자연스러운건데 사용자 입장에서는 iOS든 안드로이드든 같은 앱이면 똑같이 생겼기를 기대하고 업데이트도 동시에 되기를 기대하기 마련이니 개발을 하면서 이를 따라가려면 시간과 에너지가 꽤 많이 소요됩니다. 생각만 해도 골치아팠던 것은 iOS와 안드로이드 두 플랫폼에서 어떻게 똑같은 UI를 제공하지였습니다. 패딩 맞추고 빌드하고 실행하고 관찰하고, 2dp만 더하고 다시 반복하고… 이걸 두 번 씩이나 하지는 못하겠더라구요.

전체 앱을 네이티브로 짜는 대신에 뼈대만 각 플랫폼 위에서 직접 만들고 내부 UI는 웹 화면으로 구성하는 것도 생각해 보았습니다. 요즈음 많은 앱들이 이렇게 구현되어 있는 것 같습니다. 일단 쇼핑몰 앱은 무조건 해당되는 것 같구요, 대부분의 은행 앱도 그랬던 것 같습니다(카카오뱅크는 네이티브래요). 그런데 사실 이 방법을 깊게 고민하지는 않았습니다. 앱 안의 브라우저에서 UI를 구동하는 것과 네이티브 위젯을 직접 띄우는 것은 분명 다르거든요. 스크롤할 때에 반응하는 속도, 무언가를 눌렀을 때의 어색한/또는 없는 페이딩 효과, 전혀 줌이 필요 없는데 핀치 투 줌이 되는 화면, 스크롤 끝자락에 보이는 여백 배경의 색상, 새로고침될 때에 깜빡이는 화면이나 화면을 뒤덮는 위협적인(?) 로딩 팝업 등등… 사용자 경험 면에서 웹 기반 앱은 압도적으로 밀립니다. 푸시알림이나 블루투스 같은 것을 제외하면 돌아간다는 점만 똑같고 전반적인 사용자 경험은 매우 다.릅.니.다. 사담이지만 알리익스프레스같은 앱을 되게 싫어합니다… 앱인 척 하는 조잡한 웹페이지라서요.

반면 매우 뛰어난 퀄리티를 가진 앱 중에 네이티브가 아니어서 꽤나 놀랐던 앱들이 몇 있습니다. 페이스북, 인스타그램, 디스코드, 그리고 자주 사용하던 배민커넥트 앱 까지요. 앞서 나열한 앱들은 React Native 기반으로 작성되었습니다. 사실 RN이 세상에 공개된지 벌써 6년이 다 되어가지만 React나 React Native 둘 다 관심이 별로 없었습니다. 그냥 React를 모바일 앱 만들 때에 쓸 수 있는거구나 싶었죠(React보단 Vue쪽을 선호하기도 했었구요.. React를 써보진 않았지만 ㅎㅎ). 그러다가 인스타그램이 RN으로 쓰인 것을 알고는 다시 한 번 인스타그램을 열어보았습니다. 배민커넥트 앱도 다시 열어보았습니다. 네이티브와 구분이 전혀 안 되는 스크롤 감도, 부드러운 화면 전환, 충분한 퍼포먼스. 이 정도로 퀄리티 높은 앱들을 떠받치는 기술이라면 도전해볼만 하겠다 싶었습니다. 그리고 React Native의 핫-리로딩과 코드푸시를 사용한 OTA 업데이트를 보고 마음이 확 기울었습니다. 그렇게 RN으로 결정했습니다.

왜 Flutter는 고려하지 않았냐면요… Flutter 예제 앱을 열어보고는 조금 실망했기 때문입니다. 버벅이는 스크롤과, 손가락 위치와 어긋난 스크롤 오프셋, 부자연스러운 화면전환 등등, 그냥 웹 앱과 크게 달라보이지 않았습니다. 물론 Flutter로 짜인 좋은 앱이 많습니다만, 어…그냥 RN이 맘에 들었어요 ㅎㅎ

기술 스택 선택

완전히 새로운 세계에 첫 발을 내딛었습니다. 분명히 모바일 앱 개발인데 그 과정은 웹 개발의 모습인게 처음에는 조금 어지러웠습니다. 정말이지 하루가 다르게 출시되는 라이브러리의 홍수와, 1년만 지난 예제도 오늘 써보면 deprecated 경고가 주루룩 뜨는, 정말 빠르게 변화하는 세계였습니다. 특정 기업이 주도해서 개발 프랙티스나 기술을 정하고 이끄는게 아니기 때문에 정말 많은 선택지가 있었고, 그 중에서 무얼 찾아야 하는지도 몰라서 꽤나 헤맸습니다.

네비게이션

템플릿으로 프로젝트를 생성해서 Hello world를 찍은 다음에 제일 먼저 고민한 것이 네비게이션이었습니다. 모바일 앱의 아주 아주 아주 아주 중요하고 기초적인데 너무 많은 사람들이 간과하는 부분이지요. 네비게이팅이 직관적이고 부드러워야 앱을 쓸 맛이 납니다! 그래서 고민을 좀 많이 했습니다.

먼저 iOS의 UIViewContriller와 안드로이드의 Fragment를 사용해 진짜 네이티브 화면 전환을 제공하는 React Native Navigation, 그리고 이런 네비게이션을 마치 네이티브인 것 마냥 모조하는 React Navigation, 이렇게 두 가지 선택지가 있었습니다. 일단 결론부터 꺼내자면 후자로 갔습니다. 처음에는 네비게이션은 앱의 중추인데 당연히 네이티브로 가야지! 생각이었는데, 문서와 예제, 그리고 사용자 수를 보고 생각을 바꿨습니다(ㅋㅋㅋ). 그리고 React Navigation의 큰 장점이 있었는데, 네비게이션 바를 조작하는게 정말 편했습니다. 바 크기부터 타이틀 텍스트 정렬, 크기, 색상, 바가 화면에 붙어있는지 또는 애니메이션과 같이 움직이는지 여부 등등, 네이티브로 구현하려면 한 개당 이틀씩 잡아먹을 문제들이 모두 깔끔하게 옵션으로 준비되어 있었습니다. 결정적으로, React Navigation을 적용한 앱을 여럿 써 보았는데요(오픈소스 라이센스 꼭 넣어주세요! 이럴 때에 도움이 된다구욧), 이때까지 네이티브인 줄로만 알았습니다. 그 정도로 깔끔한 사용자 경험을 제공한다면 믿을 수 있었죠.

상태 관리

다음으로는 React에 항상 따라다니는 상태 관리 프레임워크(?)가 고민이었습니다. React 하면 Redux가 보통 많이 언급되는 것 같았습니다. 아 이게 best practice구나 하고 예제를 받아서 간단하게 돌려보는데 어.. 느낌이 영 아닌겁니다. 무슨 일을 하고 싶으면, 아무리 간단한 것이어도, 먼저 action을 정하고 reducer를 만들고 store를 만들고 dispather를 만들고… typescript를 쓴다면 일이 더 복잡해집니다. 으악! 이런 건 페이스북 같은 초대형 앱에나 어울리겠다 싶었습니다. 저에게 필요한 것은 그냥 양방향 바인딩을 지원하는 옵저버 패턴 구현체인데 말이죠.

그게 바로 MobX입니다. 안드로이드의 LiveData처럼 작동합니다. Store를 만들고 안에다가 observable 속성을 만들어 주고 그걸 컴포넌트에서 가져다가 쓰면 값에 변화가 생길 때마다 알아서 업데이트가 되는 기적같은 일이 일어나는거죠!

업데이트가 일어나면 그 하위 컴포넌트까지 모두 업데이트되기 때문에, 어떤 컴포넌트가 새로 로드되어야 하는지는 구현하면서 조금 생각해 보아야 합니다. 그리고 변경이라는게 무엇인지도 고민해 보아야 합니다. 특히 컨테이너(배열, 딕셔너리 등) 객체에서는요. 배열의 한 원소만 업데이트했는데 그 배열의 다른 원소를 참조하는 컴포넌트까지 모두 업데이트되면 조금 곤란하잖아요..?

카페테리아 앱에서는 MobX의 든든한 지원을 받는 store를 사실상의 뷰모델로 사용했습니다. 항상 전역적으로 떠있고, 라이프사이클의 영향을 안 받긴 하지만 말이죠. 정말 사소한 뷰 상태, 예를 들어 입력 필드의 현재 값 같은 것은 useState()를 사용해 컴포넌트 내의 상태로 두었습니다. Store에는 조금 더 핵심에 가까운 것들을 두었습니다. 현재 식당 정보의 배열이라든가, 사용자가 로그인되었는지 여부 같은 것들이요.

React Native 개발 경험(DX)

핫 리로딩

위에서 언급했던 이야기인데, 그냥 iOS나 안드로이드 앱을 만들 때에는 조금만 수정을 해도 앱을 다시 빌드하고 실행해야 합니다. 어찌 보면 당연한 이야기지만 React Native를 쓸 때에는 안 그래도 됩니다. React가 그러듯, 웹 개발하듯이 수정 사항이 자동으로 화면에 반영됩니다.

요게 별 거 아닌 것 같지만, 안드로이드만 하던 저에게는 좀 큰 충격이었습니다. 웹도 아니고 모바일 환경에서, 디자인을 조금씩 바꿀 때마다 1초도 안 기다리고 실시간으로 결과를 확인할 수 있는건 실로 대단한 편리함이었습니다.

이게 다 Metro라는 친구 덕분인데요, 페이스북이 만든 React Native용 자바스크립트 번들러입니다. 더 자세한 내용은 여기에 잘 정리되어 있습니다.

가끔 핫 리로딩이 안될 때

그러나 새 NPM 모듈을 설치하거나 어떤 파일이 경로를 변경하거나 삭제해 사라지면 잠깐 빌드 오류가 납니다. 이럴 때에는 Metro 실행 화면에서 r를 눌러주거나 그냥 다시 시작해버리면 됩니다.

OTA 배포

우리가 React Native로 열심히 작성한 코드의 빌드 결과는 번들 파일 하나입니다. 그 말인 즉슨, 이 번들 파일만 교체해서 앱을 업데이트할 수 있습니다.

React Native에서 OTA 업데이트를 지원하는 솔루션 중 유명한 것으로 CodePush가 있습니다. OTA 업데이트는 이런 식으로 작동합니다:

  1. 앱이 켜질 때, CodePush 서버에 자신의 버전과 현재 bundle id를 들고 가서 새 업데이트가 있는지 확인합니다.
  2. 만약 현재 사용 가능한 업데이트가 있다면 앱 내 번들 저장소에 다운로드합니다.
  3. 다음에 앱이 켜질 때에 사용할 번들 파일의 경로를 새로 다운로드할 번들의 경로로 바꿉니다.
  4. 앱이 다시 시작될 때까지 기다리거나, 또는 즉시 앱을 재시작합니다.

다음 번에 앱이 켜지면 그 때에는 기존의 번들이 아닌 새로 내려받은 번들을 실행하게 됩니다. 덕분에 급한 버그 패치나 사소한 디자인 변경 정도는 티나지 않게 슥 진행할 수 있습니다.

다만 한계가 있다면

물론 CodePush가 만능은 아닙니다. 만약에 각 플랫폼별 빌드를 다시 해야 하는 상황이 찾아오면 다시 스토어에 올려서 배포해야 합니다. 의존성이 많고 linking이 필요한 라이브러리를 설치하는 경우가 있겠네요.

군더더기 없는 구현

React Native에는 웹 개발을 기반으로 한 쏘울(?)과 컨벤션이 살아 숨쉽니다. 복잡한 뷰 상속 트리를 이해하고 라이프사이클에 신경을 곤두세우며 한땀한땀 수공예하듯이 만들어야 하는 네이티브 모바일 앱 개발과는 다르게, 그냥 컴포넌트 단위로 쉽게쉽게 만들어버리고 그걸 그냥 태그처럼 사용하여 간단한 마크업 언어 다루듯이 화면을 짤 수 있습니다.

React에서 Function Component와 Hook이 도입된 이후로 이런 쉽게쉽게 개발 패러다임에 힘이 붙고 있습니다. 단적인 예시로, 아래 코드는 스트링 배열을 화면에 리스트로 표시하는 컴포넌트입니다.

function App() {
    return (
        <FlatList
            data={['안녕', '세상', '여긴', '어디지']}
            renderItem={i => <Text>{i.item}</Text>}
        />
    );
}

JSX를 사용하는 덕분에 마크업 부분까지 코드처럼 다룰 수 있습니다.

이런 시나리오를 보겠습니다. 로그인되지 않았으면 Onboarding 화면을, 로그인되었으면 Content 화면을 띄워주고 싶습니다. 그럴 때에 이렇게 하면 됩니다:

function App() {
    const {loggedIn} = useUserState(); // 따로 정의해 주었어요.

    const onboarding = <Onboarding />;
    const content = <Content />;

    return loggedIn ? content : onboarding
}

위에서 다룬 상태관리 라이브러리 MobX와 같이 사용하면 로그인 직후에 화면이 자동으로 교체되도록 사용하는 것도 가능합니다. 전반적으로 뷰와 화면을 다루는 일이 아주 간단하고 가벼운 느낌입니다.

단점

여기서부터는 절망편입니다… React Native가 좋지만 만능은 아닙니다(!)

React Native Android의 한계

일단 안드로이드에서 느립니다. 매우 느려요.

rn-ios-android-performance-google.png

구글에 각각 react native android sl, react native ios sl로 검색어를 입력했을 때에 나오는 자동완성. 빨간 밑줄은 인 앱 퍼포먼스 저하와 관련 있는 키워드입니다.

일단 실행 속도(=번들 로딩 속도)부터 조금 차이가 납니다. 테스트 기기의 성능 차이를 감안하고서라도, 앱 cold start에서 iOS보다 2초 이상 지연될 때도 있었습니다.

앱 내에서도 개발이 힘들 정도로 애니메이션 프레임이 떨어지는 경우가 종종 있었습니다. 릴리즈 빌드에서는 그나마 낫지만 디버그 모드를 켜면 정말이지 이 간단한 앱이 이렇게 느릴 일인가 싶습니다…

OS마다 다르게 분기하는 코드

React Native를 사용하면 자바스크립트로 코드를 작성하지면 결국 기기에 뜨는 화면은 다 네이티브 위젯입니다. 그렇기 때문에 플랫폼마다 버전마다 어떻게 보일지는 실행해보기 전에는 아무도 모릅니다!

이걸 맞추기 위해서는 Platform.OS === 'ios'와 같은 플랫폼 분기문을 여기저기에다가 도배하는 수 밖에 없습니다. 예를 들어 저에게 가장 큰 고통을 주었던, 터치할 때에 눌린 효과를 주는 Touchable 컴포넌트가 있습니다.

export default class Touchable extends React.Component<TouchableWithoutFeedbackProps> {
  render() {
    if (Platform.OS === 'android') {
      const ripple = TouchableNativeFeedback.Ripple(
        colors.rippleColorLight,
        false,
      );

      return (
        <TouchableNativeFeedback
          background={ripple}
          useForeground={true}
          {...this.props}
        />
      );
    } else {
      return <TouchableOpacity activeOpacity={0.5} {...this.props} />;
    }
  }
}

React Native에서 기본으로 제공하는 TouchableNativeFeedback이나 TouchableOpacity는 둘 다 어느 하나의 플랫폼에서는 제대로 작동을 안 합니다.

그래서 이 앱에서 플랫폼 무관하게 쓸 목적으로 Touchable을 만들었습니다. 플랫폼별로 분기를 하여, 안드로이드에서는 React Native가 제공하는 TouchableNativeFeedback 기반으로 안드로이드에 잘 어울리는 ripple 효과를 주었고, iOS에서는 TouchableOpacity로 투명도 조절 효과를 주었습니다.

결국 네이티브 개발을 알아야 한다

React Native가 제공하는 기본 컴포넌트를 사용하면 보통 어지간한 네이티브 뷰 조작은 알아서 잘 해줍니다. 그런데 세세한 부분으로 들어가면, 플랫폼별 속성을 일일이 지정해 주어야 합니다. 잠시 예시를 보겠습니다.

IMG_5791.jpg

카페테리아 앱에 있는, 옆으로 넘기는 식단 뷰입니다.

옆으로 넘길 수 있는 카드 뷰를 만들고 있었습니다. 스냅 효과가 필요하고, 카드끼리는 떨어져 있지만 옆의 카드가 조금씩 삐져나와 보여야 합니다. 그리고 카드는 화면 가운데에 위치해야 합니다.

카드를 가운데에 배치하기 위해 리스트의 전체 내용에 padding을 주는 contentOffset 옵션을 사용하였습니다. 그런데 iOS에서만 이상하게 몇 dp씩 빗나가는 것이었습니다. 알고 보니 iOS only 속성인 contentInset을 따로 설정해 주어야 하는 것이었습니다.

또 다른 경우로, iOS에서는 뷰에 그림자를 주려면 shadowOffset, shadowRadius, shadowOpacity를 설정해 주어야 하는 반면에 안드로이드에서는 elevation을 사용합니다.

사실 이보다 더 많은 플랫폼 특정 문제를 겪었지만 기억이 많이 증발하여 이 정도만 씁니다… 아무튼 요점은, React Native를 하려면 결국 네이티브 개발을 어느 정도 윤곽은 알고 있어야 한다는 것입니다.

혼란스러운 자바스크립트 생태계

React Native를 도입하기 시작할 때에 RN 0.64.0이 rc를 마치고 정식으로 나왔습니다. 앱을 구현하는 한 달여 남짓의 기간 동안 0.64.1과 0.64.2가 나왔구요, 이 글을 쓰는 지금에는 0.65.0을 지나 0.65.1이 출시되었습니다. 6개월마다 릴리즈하는 안드로이드도 빠른 줄 알았는데, 이 쪽은 더 빠릅니다.

0.60 이상 부터는 플랫폼별 네이티브 구현이 딸린 라이브러리의 linking이 자동으로 이루어집니다. 그래서 대부분의 라이브러리 문서를 보면 < 0.60>= 0.60의 설치 방법이 따로 안내됩니다.

React Native로 리팩터링을 결정하며 네비게이션 라이브러리로 채택한 React Navigation로드맵을 공개한지 1년도 되지 않아 새 메이저 버전을 내놓았습니다. 이제 앱을 막 완성했는데, 마이그레이션 가이드가 벌써 나와버렸어요!

2년 전 즈음부터는 Functional Component와 Hook이 본격적으로 자리잡기 시작하면서 예제 코드 스타일도 모두 바뀌었습니다. 그래서 무언가 찾아보려 검색을 할 때에 날짜를 상당히 의식하게 되었습니다.

카페테리아도 1년 후에는 완전 구닥다리 코드가 되어 다 갈아엎어야 하는 날이 올 지도 모릅니다.

라이브러리 지원이 언제 끊길 지 모르는 두려움

React Native, React Navigation, React Animation이나 React Native Paper같은 큰 라이브러리들은 오픈소스임에도 기업을 포함한 코어 기여자들의 후원을 받거나 어마무지하게 큰 커뮤니티를 보유하고 있어 지원이 끊길 일은 없다고 보면 됩니다 만….

앱을 만들다 보면 개인이 만든 자잘한 라이브러리들을 가져와 써야 할 때가 많습니다. 그런데 이런 라이브러리들은 개발자가 관심을 끊으면 더 이상 변화에 적응해 살아남기가 힘든 태생적 한계가 있기 때문에 꼭 필요함에도 사용하지 못하거나 쓰면서도 불안할 때가 많습니다.

어떤 라이브러리들은 React Native의 버전업에 따른 breaking change를 못 따라가서 빌드에서 실패하기도 합니다. 화면 밝기를 조절하기 위해 react-native-screen-brightness라는 라이브러리를 가져다 썼는데, 안드로이드 네이티브 코드 쪽의 compileSdkVersionbuildToolsVersion이 React Native가 요구하는 것에 한참 못 미쳤습니다.

Fork를 떠서 고치기는 귀찮고, pull request를 만들기는 더더욱 귀찮아서 로컬에서 patch-package를 사용하여 패치를 적용해 쓰고 있습니다.

큰 기업들이야 라이브러리 정도는 직접 만들고 관리하겠지만, 개인 개발자에게 RN 업데이트했더니 뭐가 안 됨은 크나큰 고통이자 두려움입니다…ㅠ

마치며

이 글을 쓰는데 미루고 미루다 두 달 걸렸습니다.

사실 배포 이야기, 앱 시작 시간 줄인 이야기 등등 쓸 이야기가 더 많았는데 늦어지는 바람에 기억이 다 증발했습니다. ㅎㅎ

아무튼 RN 좋습니다. 다음 프로젝트에도 앱을 만들어야 한다면 RN을 쓸 것 같아요.

댓글