
React Native와 웹이 공존하는 또 하나의 방법 (1)
React Native와 웹이 공존하는 또 하나의 방법 (1) 관련
FEConf2024에서 발표한 <React Native와 웹이 공존하는 또 하나의 방법>을 정리한 글입니다. 발표 내용을 2회로 나누어 발행합니다. 1회에서는 웹과 리액트 네이티브가 통신하며 생긴 문제점과 이를 해결하기 위한 방법에 대해 알아봅니다. 2회에서는 실제 사례를 해결하는 과정과 이 과정을 통해 Type-Safe와 웹과 앱의 동기화에 대해 알아봅니다. 본문에 삽입된 이미지의 출처는 모두 이 콘텐츠와 같은 제목의 발표 자료로, 따로 출처를 표기하지 않았습니다.
안녕하세요. ‘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와 같은 에러 추적 도구를 함께 활용하면 더욱 유용하게 사용할 수 있다고 생각합니다.

지금까지, 웹과 리액트 네이티브가 통신하며 생긴 문제점과 이를 해결하기 위한 방법에 대해 알아봤습니다. 다음 글에서는 이러한 개념을 활용해 실제 상황에서 문제를 해결한 과정들에 대해 소개하겠습니다.
이전 글에서 웹과 리액트 네이티브가 통신하며 생긴 문제점과 이를 해결하기 위한 방법에 대해 알아봤습니다. 이번 글에서는 앞선 글의 내용을 활용해 실제 사례를 해결하는 과정과 이 과정에서 겪은 Type-Safe, 웹과 앱의 동기화에 대해 소개합니다.
실제 사례 해결
실제 사례에서 발생한 문제점을 이제는 어느 정도 해결할 수 있습니다. ‘웹뷰의 제품을 누르면 네이티브 화면이 보이거나, 기존에 있던 웹 화면이 보여야 한다’는 요구 사항을 받았을 때, 기존에는 성공을 보장하는 코드를 작성해야 했지만 이제는 여러 도구들을 마련해 뒀습니다. 따라서 일부러 실패하게 해서 failover를 하면 되겠다고 생각했습니다.

아래 그림은 앞선 실제 사례에 대한 코드입니다. 이 브릿지에는 웹에서 리액트 네이티브의 화면을 호출하는 navigate를 throwOnError에 넣어 뒀습니다. navigate에서 오류가 나면 웹에서도 함께 오류가 나도록 되어 있습니다. 그러니 이 브릿지 navigate를 통해 ProductDetail 화면으로 이동할 때, 존재하는 화면이라면 잘 이동할 것이고, 그렇지 않다면 catch를 통해 구 버전인 레거시 페이지로 이동할 수 있습니다.

기존 방식의 문제를 해결한 방법을 정리합니다.
onMessage에 복잡한 로직이 몰려있는 현상은 메소드 별 관리를 통해 해결할 수 있습니다. 그리고 기존에는 기능 추가 시 양쪽에 반복적인 통신 함수를 작성해야 했지만, 통신 함수를 자동으로 생성하도록 해서 리액트 네이티브에만 유지하면 됩니다. 또, 중요한 문제인 단방향 통신은 프로미스 구조로 바꾸면서 해결되었고, 마지막으로 하위 호환성에 대한 문제에 대해서는 모든 것을 해결하지는 못했지만, failover를 도와주는 유틸을 통해 어느 정도 해소할 수 있었습니다.

타입 세이프
이제부터 소개할 내용은 이러한 과정을 거쳐 라이브러리를 만들며 중요하게 생각한 ‘타입 세이프’에 대한 것입니다. 타입 스크립트 진영에서 자주 언급되는 타입 세이프가 왜 필요한지 간단하게 알아보겠습니다.
타입 세이프가 필요한 이유: 타입 불일치
타입 세이프가 필요한 이유 중 하나는 타입 불일치입니다. 프론트엔드와 백엔드 프로젝트가 독립적으로 존재할 때 일반적으로 서로에 대해 알 수 없습니다. 프론트엔드가 백엔드로 API를 호출하면, 이 API의 응답에 대한 타입을 알 수 없기 때문에 타입을 따로 정의해야 합니다.
하지만 타입을 따로 정의하다 보면 실수가 발생하게 되고 당연히 타입 불일치가 발생하게 됩니다. 리액트 네이티브를 백엔드라고 생각하고 웹뷰를 프론트엔드라고 생각한다면, 마찬가지로 타입 불일치가 일어날 수밖에 없습니다.

이러한 타입 불일치를 코드로 살펴보겠습니다. 아래와 같이 edges는 node와 해당 node의 id로 구성된 객체를 배열로 가지고 있습니다. 그리고 이 리스폰스를 통해 오른쪽의 샘플 코드와 같이 리스폰스에 대한 타입을 정의하게 됩니다. 타입이 정상적으로 있으니 문제없이 렌더링될 것으로 예상됩니다.

하지만 만약 id 값에 null이 오게 되면 어떻게 될까요? 아마 렌더링이 제대로 되지 않고 아래 왼쪽 코드처럼 런타임 에러가 발생할 것입니다. 이런 타입 에러가 발생하면 먼저 옵셔널 체이닝을 통해 null에 대한 예외 처리를 하고 문제를 해결해야 합니다.

타입 불일치를 해결하기 위한 노력
이런 과정을 겪다 보니 사람의 손으로 작성된 타입이라면 100% 신뢰할 수 없다고 생각했습니다. 이런 타입 불일치 상황이 반복적으로 발생한다면 타입 스크립트의 목적성을 잃을 수 있습니다. 타입 스크립트의 가장 큰 목적은 컴파일 단계에서 버그를 미리 발견하는 것이라고 생각합니다. 타입 불일치를 자주 만나면 타입을 무시하고 개발하는 상황이 자주 생기게 되고, 이는 타입 스크립트의 목적성을 잃게 합니다.
하지만 그만큼 타입 스크립트 생태계는 아주 크기 때문에 이러한 타입 불일치를 해결하기 위한 노력들이 많이 존재합니다. REST API에서는 openapi-generator를 통해서 스웨거를 타입 스크립트로 변환해 줄 수 있습니다. 그리고 GraphQL에서는 graphql-codegen을 통해서 스키마를 타입 스크립트로 변환할 수 있습니다.

이런 도구들 역시 충분히 훌륭한 도구이지만, 그럼에도 스웨거 역시 사람의 손길을 타게 되고 이는 결국 타입 불일치와 같은 실수가 발생할 수 있습니다. 스웨거에 잘못 작성한 타입이 타입 스크립트로 만들어지면 이 역시도 타입 불일치로 이어질 수 있습니다.
타입 직접 정의하지 않기: 타입 추론
이 문제를 해결하기 위해 저는 타입을 직접 정의하지 않기로 했습니다. 정확히 말하면 타입 추론을 적극적으로 활용하기로 했습니다. 제가 라이브러리를 만들면서 타입 세이프를 위해 적용한 타입 추론 컨셉을 간단한 예제를 통해 알아보겠습니다.
typeof
브릿지에서 네이티브 메소드들을 선언할 때 인터페이스를 작성하지 않았지만, 해당 브릿지는 typeof를 통해 타입을 유추할 수 있습니다. 이와 같이 컴파일러의 도움을 받아서 모든 타입을 유추할 수 있고, 이를 웹으로 잘 보내준다면 웹에서 받은 타입은 네이티브에서 의도한 정상적인 코드로 반영될 수 있습니다.

keyof
다음으로 keyof와 같이 간단한 키워드를 활용해도 개발자 경험을 올릴 수 있습니다. 아래 그림의 hasMethod와 같이 keyof를 통해 타입을 추론할 수 있습니다. keyof의 도움을 받아 사용 가능한 타입인지 판별하여 사용할 수 있습니다.

generic
제가 타입 추론에서 가장 중요하다고 생각하는 것은 generic입니다. 아래의 브릿지 함수는 subscribe와 getState라는 함수를 반환합니다. 그리고 브릿지의 타입으로 generic 객체를 선언했습니다. 따라서 이 브릿지에 1234라는 값이 들어가면 객체가 아니기 때문에 타입 에러가 납니다. 반면 이곳에 객체가 들어가면 모든 타입을 완성할 수 있습니다.

앞선 내용에서 가장 중요한 부분은 인풋을 통해 타입을 완성한다는 것입니다. 아래와 같이 Tanstack Query의 useQuery를 사용할 때, 쿼리 펑션에서 리턴을 받게 된다면 리턴 값을 토대로 데이터를 완성할 수 있습니다. 간단한 코드처럼 보이지만, 실제 내부적으로는 타입 추론 과정을 거쳐 인풋을 통해 모든 타입이 완성된 상황입니다.

아래 이미지의 문장은 Tanstack Query의 메인테이너가 한 말입니다. 이를 보면, 타입 추론을 잘 활용하면 코드만 봤을 때는 자바스크립트를 사용하는 것 같지만, 실제로는 모든 타입이 안전한 상태로 사용할 수 있다고 합니다. useQuery의 인터페이스가 따로 존재하지 않지만 인풋을 토대로 모든 타입이 완성되기 때문입니다.

타입 추론에 대해 깊게 들어가 보면, 타입 정의도 엄청 복잡하고 유지 보수도 어렵게 보입니다. 하지만 이런 것은 라이브러리의 책임이며, 사용자의 책임이 아니라는 그의 말을 보고 크게 공감했습니다.
최종 타입 추론에 대한 결과물은 아래와 같습니다. 브릿지에 네이티브 메소드들이 선언되어 있고, 이에 해당하는 타입을 typeof를 거쳐 내보내고 있습니다. 그다음 링크브릿지에서 generic으로 이타입을 넣어주면 브릿지에서는 이것을 기점으로 모든 타입이 완성됩니다.

따라서 이 브릿지는 openInAppBrowser와 같이 사용 가능한 메소드를 추론할 수 있고, 사용 가능한 메소드들이 추천되는 모습을 확인할 수 있습니다.
이것을 조금 응용하면 리액트 네비게이션과의 통합도 가능합니다. 아래 그림처럼 리액트 네이티브의 StackRootParams에는 이동 가능한 모든 화면이 정의되어 있습니다. 웹에서 브릿지의 navigate를 사용하면 앞서 정의한 네임과 파라미터를 모두 추론할 수 있습니다.
즉, 리액트 네이티브에서 정의한 화면 목록을 웹에서의 추가적인 타입 정의 없이 사용할 수 있습니다. 이러한 경험은 개발자 경험을 크게 향상시키고, 개발자들이 오타를 적는 상황도 없앨 수 있습니다. 이는 웹에서 이동할 화면에 대한 실수를 줄이는 효과로 이어집니다.

리액트 네이티브와 웹의 동기화
웹뷰에서 네이티브 앱의 인증 정보 가져오기
이처럼 라이브러리에 타입 추론을 잘 구현하여 첫 버전을 배포했습니다. 모든 것이 완벽할 거라고 생각했지만, 또 하나의 문제점을 만났습니다. 바로 인증 정보에 대한 이슈였습니다.
웹과 앱의 통신이 필요한 이유 중 하나가 바로 인증 정보를 전송하고 사용하는 것입니다. 현재 구조에서는 먼저 브릿지에 getToken을 선언하여 토큰을 반환하고, 웹에서는 이 getToken으로 값을 꺼내 사용할 수 있습니다.
그러나 만약 네이티브 앱에서 이 토큰이 만료되고, 토큰이 변경된다면 어떻게 될까요? 리액트 네이티브는 최신 토큰을 가지고 있지만 웹에서는 만료된 토큰을 가지고 있어서 좋지 않은 상황이 발생할 것 같습니다.

통합을 위한 웹 코어 로직 분리: Shared State
이 상황을 해결하기 위해 리액트 네이티브와 웹의 상태 동기화가 필요하다고 생각했습니다. 이런 생각을 바탕으로 만든 개념이 바로 Shared State입니다.
이 개념은 상태에 대한 개념이기 때문에 다른 모던 프레임워크와도 쉽게 통합이 가능해야 했습니다. 따라서 저는 웹 코어부터 분리하고, 웹 코어 로직부터 시작해서 상태에 관한 라이브러리를 만들게 되었습니다.

Shared State 역시 사용법 중심으로 설계를 시작했습니다. 이 브릿지는 원래 네이티브 메소드들만 선언할 수 있는 상태였습니다. 따라서 프로미스 함수만 받을 수 있는데, 토큰 값 같을 저장하기 위해 null이나 문자열 같은 Primitive 타입도 입력이 가능하게 했습니다.
그리고 위 그림의 리액트 네이티브 선언부를 보면 get과 set을 노출시켜 현재에 대한 상태와 값도 설정할 수 있는데, 이는 Zustand와 많이 닮아 있습니다. 상태를 관리하는 라이브러리인 만큼 Zustand에서 많은 영감을 받아 개발했기 때문입니다.
웹에서는 기존의 리액트 네이티브 메소드를 노출하는 것과 더불어 스토어 또한 브릿지에서 노출합니다. 이 스토어에는 구독 기능이 있고, 이 구독 기능을 통해 리액트 네이티브의 상태 변화를 감지할 수 있습니다. 이렇게 웹에서 동기화가 가능하도록 구현했습니다.
리액트와의 통합
앞선 예시는 바닐라 자바 스크립트로 되어 있습니다. 그 덕분에 리액트와 쉽게 통합이 가능합니다. 특히 리액트 18에서는 useSyncExternalStore라는 훅을 제공하는데, 바닐라 스토어를 리액트로 렌더링 하게 도와주는 훅입니다. 저는 이 훅을 래핑하여 webview-bridge/react라는 리액트 상태 라이브러리로 확장할 수 있었습니다.
useBridge에 스토어를 넣어주면 state에서 토큰을 가져와 사용할 수 있습니다. 이 토큰은 웹에 존재하지만 리액트 네이티브와 동기화되어 함께 반응하는 상태가 됩니다.

최종 사용법: Shared State
이제 최종 사용법에 대해 알아보겠습니다. 아래 리액트 네이티브에서 count를 0으로 선언하고 increase를 통해 이 count를 1씩 늘려줍니다. 리액트 네이티브도 리액트이기 때문에 useBridge에 appBridge를 넣어주면 count라는 상태와 increase라는 메소드를 사용할 수 있습니다.
웹에서는 linkBridge와 AppBridge를 통해 브릿지를 선언하고, 이 브릿지의 스토어와 useBridge를 통해 네이티브 코어 로직의 count와 increase를 꺼내 사용할 수 있습니다. 이를 통해 리액트 네이티브의 상태와 동기화되어 함께 반응하는 상태로 사용할 수 있습니다.

최종 사용법: Native Method
또한, 아래 그림처럼 브릿지에서는 greeting에 인풋을 넣고 msg라는 리턴 값을 반환해 줍니다. 이를 외부로 보내면 이 브릿지는 generic을 통해서 모든 타입이 완성되기 때문에 브릿지에 존재하지 않는 함수는 에러가 발생합니다. 또한 인풋이 잘못되었을 때는 타입 에러가 발생하고, 정상적으로 입력받으면 리스폰스 값에 대한 타입이 올바르게 보여지는 것을 알 수 있습니다.

마치며: 라이브러리를 만들며 얻은 교훈
결국 타입 세이프한 웹뷰 통신 라이브러리 개발을 성공적으로 완료했고, 타입을 수동으로 정의하는 것을 줄이며 최대한 추론을 활용하여 인풋을 기반으로 모든 타입이 완성되도록 했습니다. 이렇게 라이브러리를 만들며 다양한 교훈을 얻었습니다.
최고의 개발자 경험은 사용법에서 나온다고 생각하기 때문에, 개발 과정에서는 바로 기능을 개발한 것이 아니라 사용법부터 개발했습니다. 그 결과 만족스러운 추상화 및 결과물을 얻을 수 있었습니다. 또, 제 라이브러리는 tRPC와 Zustand와 많이 닮아 있습니다. 개발을 하며 여러 라이브러리를 사용해 볼 수 있고, 그 자체로 많은 학습을 할 수 있었습니다. 나아가 학습한 개념을 직접 개발에 적용하는 값진 경험을 할 수 있었습니다.
마지막으로 처음부터 웹 코어 로직을 시작으로 개발했기 때문에 다른 모던 리액트 프레임워크와도 통합이 쉽게 가능했는데, 만약 Vue.js로 상태 라이브러리를 만들었다면 다른 프레임워크와의 통합은 쉽지 않았을 것이라 생각합니다. 이를 통해 확장성 높은 구조가 어떤 것인지 역시 배울 수 있었습니다.

오늘 설명한 라이브러리는 webview-bridge라는 이름으로 공개되어 있습니다. 설명한 내용 외에도 더 많은 기능이 구현되어 있으니 흥미가 생긴다면 아래 주소를 방문해 더 자세하게 알아보고, 관련된 문서도 확인하시면 좋을 것 같습니다. 마음에 드신다면 Star도 눌러주세요. 감사합니다.
