FEConf2024에서 발표한 <React Native와 웹이 공존하는 또 하나의 방법>을 정리한 글입니다. 발표 내용을 2회로 나누어 발행합니다. 1회에서는 웹과 리액트 네이티브가 통신하며 생긴 문제점과 이를 해결하기 위한 방법에 대해 알아봅니다. 2회에서는 실제 사례를 해결하는 과정과 이 과정을 통해 Type-Safe와 웹과 앱의 동기화에 대해 알아봅니다. 본문에 삽입된 이미지의 출처는 모두 이 콘텐츠와 같은 제목의 발표 자료로, 따로 출처를 표기하지 않았습니다.‘React Native와 웹이 공존하는 또 하나의 방법’ 강선규 인에디트 개발자 React Native와 웹이 공존하는 또 하나의 방법 (1)React Native와 웹이 공존하는 또 하나의 방법 (2)안녕하세요. ‘React Native와 웹이 공존하는 또 하나의 방법’이라는 제목의 발표를 진행할 강선규입니다. 저는 인에디트에서 브랜드와 크리에이터를 이어주는 플랫폼, ‘브랜더진’이라는 서비스를 개발하고 있습니다. 평소 오픈소스에 관심이 많고, 개발자 경험을 향상시킬 수 있는 방법에 대한 고민을 많이 하고 있습니다. 저는 리액트 네이티브 웹뷰와 웹 간의 통신 인터페이스를 만들어주는 라이브러리를 개발했습니다. 이번 발표에서는 이 라이브러리를 바탕으로 웹뷰 개발에 필요한 통신에 대한 내용을 소개합니다. 아래 주소로 들어가면 이 라이브러리에 대한 문서가 준비되어 있으니, 함께 보면서 따라오면 더 이해하기 수월할 것이라 생각됩니다. 코르도바에서 리액트 네이티브로의 전환본격적인 내용에 앞서 서비스를 만들며 있었던 일에 대해 먼저 소개하겠습니다. 기존 브랜더진 서비스는 코르도바로 개발되어 있었고, 이를 리액트 네이티브로 전환하게 되었습니다. 코르도바로 작성된 코드에는 레거시 코드가 많이 남아 있었고, 이를 점진적으로 리액트 네이티브로 전환하며 레거시에서 발생하는 다양한 문제들을 해결하고자 했습니다. 코르도바먼저 코르도바에 대해 간단하게 알아보겠습니다. 코르도바란 웹 앱으로 개발된 서비스를 앱으로 패키징 하여 배포하는 도구입니다. 회사의 기존 서비스는 Vue2 기반의 100% 웹 앱으로 만들어졌기 때문에 앱스토어에 출시하기 위해서는 웹 앱을 앱으로 래핑하는 도구가 필요했습니다. 이 래핑 부분을 코르도바를 활용해 개발하여 앱스토어에 출시한 상황이었습니다. 레거시 코드아래 이미지는 기존 서비스의 라이트하우스 점수입니다. 생각보다 처참한 점수를 보여주는데, 이렇게 된 이유는 기존 레거시 코드에서 찾을 수 있습니다. 앞서 말한 Vue2 기반으로 레거시하게 관리하다 보니, 점점 더 레거시 코드만 작성할 수밖에 없는 상황이었습니다. 이로 인해 레거시 코드를 청산하고 점진적으로 리액트 네이티브로 전환하자는 결정을 내리게 됐습니다. 결국 새로 만들어지는 화면은 리액트 네이티브로 개발하고, 기존에 있던 Vue.js 부분은 웹뷰로 래핑 하면서 리액트 네이티브와 웹뷰가 공존하는 상태의 앱이 되었습니다. 웹뷰 통신리액트 네이티브로의 전환을 통해 본격적으로 웹뷰를 개발하게 되었습니다. 웹뷰를 활용한 개발을 하다 보면 통신 문제를 만나게 됩니다. 웹뷰 통신은 왜 필요하고, 제가 겪은 통신 문제는 어떻게 해결하였을까요? 이번 글에서는 아래와 같은 3가지 상황에 대해 알아보겠습니다. 인앱 브라우저네이티브 내비게이션공유 데이터 인앱 브라우저란 네이티브 앱에서 인앱으로 브라우저를 실행해 주는 기능입니다. 해당 기능은 웹에서 사용할 수 없기 때문에 네이티브 앱의 힘을 빌려야 합니다. 또한, 리액트 네이티브로 점진적인 전환을 하고 있었기 때문에 웹에서도 리액트 네이티브의 화면으로 이동할 수 있어야 했습니다. 마지막으로 인증 정보의 경우 대부분 네이티브 앱에 저장되기 때문에 웹에서 네이티브 앱의 공유 데이터를 가져와서 사용할 수 있어야 합니다. 웹뷰 통신도 이해할 필요가 있습니다. 아래 코드는 리액트 네이티브에서 제공하는 기본적인 통신 방법입니다. 웹에서 리액트 네이티브로 통신할 때는 웹의 ReactNativeWebView의 postMessage를 통해 문자열을 전송할 수 있습니다. 웹뷰에서는 onMessage라는 props를 통해 전송받은 문자열을 처리합니다. 리액트 네이티브에서 웹으로 통신할 때는 injectJavascript라는 props를 통해서 자바스크립트를 주입할 수 있습니다. 또한 레퍼런스(ref)를 꺼내서 동일한 방식으로 자바스크립트를 주입하는 방법도 존재합니다. 처음에는 이런 방법을 활용해서 개발을 진행했고, 통신 인터페이스를 만들었습니다. 그대로 진행했다고 한다면, 성공적으로 개발을 마칠 수 있었을까요? 이 방법은 여러 가지 문제점을 가져왔습니다. 웹뷰 통신을 하며 겪은 문제점 1. onMessage의 분기첫 번째 문제점은 onMessage에서의 무수히 많은 분기입니다. 아래 코드처럼 모든 이벤트 기반 로직들을 onMessage에서 분기 처리해야 하는 문제점이 있습니다. 아래는 3가지 경우에 대해서만 대응하는 코드이지만, 실제 상황에서는 무수히 많은 통신 상황을 요구하기 때문에, 점점 더 복잡하고 무거운 코드가 될 것이라고 생각합니다. 따라서 유지 보수도 어려워지는 문제점이 생깁니다. 2. 비효율적인 함수 재정의두 번째 문제는 통신 함수를 아래와 같이 웹과 리액트 네이티브 양쪽에 선언해야 한다는 점입니다. 예를 들어 인앱 브라우저라는 기능이 추가됐을 때, 웹에서도 “오픈 인앱 브라우저를 열어줘”라는 메시지를 전달해야 하고, 리액트 네이티브에서도 이 것을 핸들링할 수 있는 코드를 작성해야 합니다. 따라서 기능이 하나 추가될 때마다 개발에 비효율이 발생한다고 생각했습니다. 3. 단방향 문제세 번째 문제는 단방향에 대한 문제입니다. 웹에서 PostMessage를 리액트 네이티브로 보냈을 때, 리액트 네이티브에서는 결과값을 돌려줄 수 없습니다. 이러한 구조의 가장 큰 문제는 성공과 실패를 알 수 없다는 점입니다. 그렇기 때문에 웹에서는 항상 성공을 보장하는 코드를 작성해야 하는 부담이 생깁니다. 저는 이 단방향 문제가 가장 큰 문제점이라고 생각했습니다. 4. 하위 호환성마지막 문제점은 하위 호환성에 있습니다. 웹과 다르게 애플리케이션은 배포를 해도 즉시 최신을 유지하지 않습니다. 앱스토어에서 심사를 하고 심사가 승인된 후에 실제 앱스토어에 업데이트되는 과정까지 끝나야 합니다. 반면에 웹은 배포를 하면 즉시 최신을 유지하는 성질을 가지고 있습니다. 유저는 과거의 애플리케이션을 가지고 있는 상황인데, 웹에서 새로운 네이티브 기능을 호출하면 어떻게 될까요? 과거 버전의 애플리케이션에서는 해당 최신 기능을 가진 코드를 핸들링할 수 없기 때문에 아무런 반응이 없거나 오류가 발생할 것입니다. 실제 사례앞서 소개한 문제점들에 대한 실제 사례를 살펴보겠습니다. 제가 만들던 서비스는 리액트 네이티브로 점진적인 전환을 하고 있었고, 저는 제품 디테일 화면 개선을 시작했습니다. 다만 그러다 보니 웹뷰 화면에서 제품을 눌렀을 때, 새로운 네이티브 화면으로 이동하는 경우와 기존에 있던 레거시 웹 화면으로 이동해야 하는 경우가 함께 존재했습니다. 즉, 사용자가 사용하는 앱의 버전을 확인하여 상황에 맞게 서로 다른 페이지 이동을 구현해야 하는 상황이었습니다. 따라서 성공을 보장하는 코드를 작성해야 했고, 유저 에이전트를 통해서 사용자의 앱 버전을 확인하고 확실하게 성공할 수 있는 이벤트를 호출하도록 개발되었습니다. 지금까지 설명한 기존 방식의 문제에 대해 간단하게 정리를 하면 이렇습니다. 먼저 모든 이벤트가 onMessage를 통해 핸들링 되기 때문에 유지 보수가 어려운 문제가 있습니다. 또, 기능이 추가될 때마다 웹과 앱 양쪽에 통신 함수를 작성해야 해서 다소 비효율적입니다. 다음으로 단방향 통신의 한계 때문에 기능의 성공과 실패를 알 수 없어서 반드시 성공을 보장하는 코드를 작성해야 합니다. 마지막으로 앱의 버전에 따라 하위호환성을 판별하는데 어려움이 있습니다. 문제점 해결을 위해 관점을 바꾸기이런 문제점을 처음부터 다시 생각해 보기로 했습니다. 웹 개발자에게 아주 익숙한 클라이언트 서버 구조를 살펴보면, 클라이언트는 서버로 요청을 보내고, 서버는 이 요청을 받아서 처리한 뒤 클라이언트에 응답을 보냅니다. 이 구조에서 클라이언트는 적절한 성공과 실패를 알 수 있습니다. 이런 구조를 차용한 간단한 인터페이스에 대해 소개하려고 합니다. 아래는 tRPC의 코드입니다. 먼저 tRPC란 서버와 클라이언트 간의 타입 안전성을 보장하며, 별도의 스키마 정의 없이 API를 구축할 수 있게 해주는 프레임워크입니다. 아래 그림의 왼쪽 코드가 서버고, 오른쪽이 클라이언트 코드입니다. 서버에서 프로시저를 선언하고 인풋과 결과값을 전달해 주고 있습니다. 클라이언트에서는 해당 프로시저를 바로 사용 가능한 형태로 코드를 작성할 수 있습니다. tRPC에서는 별도의 통신 코드가 존재하지 않는다는 것을 알 수 있습니다. 이 tRPC에서 영감을 받아 유사한 구조를 만들 수 있을 것 같다는 생각을 했습니다. 앞선 서버-클라이언트 구조에서 약간의 관점을 바꾸면 크게 다르지 않다는 것을 알 수 있습니다. 예를 들어 리액트 네이티브를 서버라고 생각하고 웹뷰를 클라이언트라고 생각하면 어떨까요? 웹뷰는 리액트 네이티브로 요청을 보내고, 리액트 네이티브에서는 적절한 리스폰스를 전달한다면 프론트엔드-백엔드 구조와 크게 다를 것 없이 tRPC의 구조도 사용할 수 있을 것입니다. 사용법 중심 설계: Usage이처럼 다양한 고민 끝에 결정한 사용법들을 소개해 보겠습니다. 아래 그림의 리액트 네이티브에서는 브릿지에 네이티브 메소드를 선언하고 getMessage에서 리턴 값을 보내고 있습니다. 그리고 이러한 구조를 브릿지라 칭하고 createWebView에 이 브릿지를 주입시켜 줍니다. 웹에서는 linkBridge라는 함수를 실행하면 이 브릿지 안에 담긴 openInAppBrowser를 바로 사용할 수 있어야 하고, 단방향 문제를 해결하기 위해 프로미스 구조로 반환되면서 then과 catch를 통해 성공과 실패를 유추할 수 있습니다. 또한, 리액트 네이티브에서 보낸 리턴 값을 받아서 출력할 수 있어야 하고, 브릿지에 선언하지 않은 asd와 같은 이상한 함수를 실행시키면 에러가 발생되면 좋겠다 생각했습니다. 사용법 중심 설계: Initialization사용법을 설계했으니 실제 기능을 개발할 차례입니다. 기능을 개발하면서 정립된 개념이 몇 가지 있습니다. 첫 번째 개념은 Initialization입니다. 처음에 네이티브 메소드들이 선언되었을때 해당 네이티브 메소드들의 이름들이 웹으로 주입되게 됩니다. 따라서 웹은 네이티브 메소드들의 이름을 가지고 있는 상태입니다. 사용법 중심 설계: Hydration두 번째 개념은 Hydration입니다. Next.js나 Remix를 사용해 보셨다면 Hydration 개념을 알고 있을 텐데, 여기서 영감을 받아서 정립한 개념입니다. 웹은 네이티브 메소드들의 이름을 가지고 있으니 이 이름을 이용해서 자동으로 통신 코드를 생성할 수 있게 했습니다. 즉, openInAppBrowser라는 것이 주입되었을 때, 아래의 postMessage처럼 런타임에서 자동으로 통신 코드를 만들어 주는 기능을 구현했습니다. 사용법 중심 설계: Event to Promise세 번째 개념은 이벤트 구조를 Promise 구조로 변경하는 것입니다. 웹에서 자동으로 만들어진 openInAppBrowser를 실행했을 때, 리액트 네이티브로 이벤트를 전송합니다. openInAppBrowser는 이와 동시에 EventEmitter가 설치되면서 리액트 네이티브에서는 리스폰스 이벤트를 전송합니다. openInAppBrowser에서는 해당 리스폰스 이벤트를 받으면 그때 Promise를 리졸브 하면서 결과 값을 웹으로 보내줍니다. 이 구조를 적용하면 단방향 문제를 어느 정도 해결할 수 있습니다. 사용법 중심 설계: 존재하지 않는 메소드 예외 처리앞서 말한 것처럼 통신 코드를 자동으로 만들기 때문에 존재하지 않는 메소드를 사용하는 실수가 생길 수 있습니다. 예를 들어 아래와 같이 브릿지가 선언되어 바로 사용할 수 있는 상태인데, 이 브릿지에 존재하지 않는 메소드를 실행한다면 아래와 같이 런타임에서 에러가 발생할 수 있습니다. 이렇게 발생한 에러는 프록시를 통해 간단하게 해결할 수 있습니다. 아래와 같이 원본 객체를 후킹 해서 기존에 존재하는 키라면 그대로 반환해 주는 반면에 존재하지 않는 키라면 익명의 함수를 반환하도록 설계했습니다. 따라서 브릿지에 존재하지 않는 메소드를 실행하면, 실행은 되지만 에러 핸들링이 가능한 상태가 됩니다. 앞선 설계 및 기능 개발을 바탕으로 정리하면, 아래와 같이 구현이 가능하다는 것을 알 수 있습니다. 브릿지에서 네이티브 메소드를 선언하고, 네이티브 메소드들은 createWebView를 통해 주입됩니다. 이 과정에서 initialization 과정을 통해 메소드들의 이름이 웹으로 주입됩니다. 웹에서 linkBridge를 실행하면 Hydration 과정을 거쳐 자동으로 통신 코드가 생성되고, bridge의 openInAppBrowser와 같이 바로 사용할 수 있는 형태의 함수가 생성됩니다. Promise 구조로 변경되었기 때문에 then과 catch를 통해 성공과 실패를 알 수 있고, asd와 같은 이상한 함수를 실행하더라도 프록시를 통해 에러 핸들링을 할 수 있습니다. 하위호환성: 사용 가능한 메소드 체크다음으로 하위 호환성 관련 문제를 해결한 방식에 대해 소개하겠습니다. 웹은 항상 최신을 보장하기 때문에 웹에서 사용 가능한 메소드를 체크한다면 하위 호환성 문제를 어느 정도 해결할 수 있다고 생각했습니다. Initialization 과정을 통해 openInAppBrowser와 getMessage가 주입됐을 때 아래와 같은 유틸 함수를 쉽게 만들 수 있습니다. 따라서 현재 사용할 수 있는 메소드인지 판별하고 사용이 가능하다면 실행하고, 아니라면 대체 코드를 실행할 수 있습니다. 하위호환성: throwOnError두 번째로 throwOnError라는 옵션도 도입했습니다. throwOnError에 openInAppBrowser를 넣게 된다면, 이 openInAppBrowser 메소드가 존재하지 않거나 실패를 하게 된다면 웹에서 함께 실패하도록 유도하는 장치입니다. 웹에서도 오류가 함께 나기 때문에 catch를 통해 에러 핸들링을 쉽게 할 수 있습니다. 하위호환성: onFallback마지막은 onFallback이라는 옵션입니다. 이 옵션은 브릿지에 대한 에러를 일괄 처리 가능하도록 하는 도구입니다. sentry와 같은 에러 추적 도구를 함께 활용하면 더욱 유용하게 사용할 수 있다고 생각합니다. 지금까지, 웹과 리액트 네이티브가 통신하며 생긴 문제점과 이를 해결하기 위한 방법에 대해 알아봤습니다. 다음 글에서는 이러한 개념을 활용해 실제 상황에서 문제를 해결한 과정들에 대해 소개하겠습니다. React Native와 웹이 공존하는 또 하나의 방법 (1)React Native와 웹이 공존하는 또 하나의 방법 (2) ©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.