*FEConf2024에서 발표한 <React Native와 웹이 공존하는 또 하나의 방법>을 정리한 글입니다. 발표 내용을 2회로 나누어 발행합니다. 1회에서는 웹과 리액트 네이티브가 통신하며 생긴 문제점과 이를 해결하기 위한 방법에 대해 알아봅니다. 2회에서는 실제 사례를 해결하는 과정과 이 과정을 통해 Type-Safe와 웹과 앱의 동기화에 대해 알아봅니다. 본문에 삽입된 이미지의 출처는 모두 이 콘텐츠와 같은 제목의 발표 자료로, 따로 출처를 표기하지 않았습니다.‘React Native와 웹이 공존하는 또 하나의 방법’강선규 인에디트 개발자 React Native와 웹이 공존하는 또 하나의 방법 (1)React Native와 웹이 공존하는 또 하나의 방법 (2) 이전 글에서 웹과 리액트 네이티브가 통신하며 생긴 문제점과 이를 해결하기 위한 방법에 대해 알아봤습니다. 이번 글에서는 앞선 글의 내용을 활용해 실제 사례를 해결하는 과정과 이 과정에서 겪은 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도 눌러주세요. 감사합니다. React Native와 웹이 공존하는 또 하나의 방법 (1)React Native와 웹이 공존하는 또 하나의 방법 (2) ©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.