[Flutter] flutter_webview 를 사용한 웹뷰 앱 제작 후기

김기영·2022년 12월 10일
0

회사에서 얼마 전까지 flutter를 사용한 웹뷰 앱을 만드는 프로젝트를 진행했었다. 진행하며 겪었던 문제들과 해결법들을 정리하고자 한다.

0. Webview plugin 선택

flutter 로 웹뷰 앱을 만들 때 주로 사용하는 플러그인은 webview_flutter, flutter_inappwebview 두 가지가 있다. 처음엔 기능이 더 많고 docs가 잘 작성되어진 flutter_inappwebview 를 사용하다가 결제 부분에서 플러그인 변경 외에 해결방법이 없는 문제를 마주쳐서 webview_flutter로 전환하였다.

1. iamport 페이북 결제

플러그인을 바꾸게 된 이유..
프로덕션 웹에 iamport 결제모듈이 연결되어있고 PC웹, 모바일 웹 모두 정상 동작을 하지만 앱 내 웹뷰에서는 페이북 결제가 진행되지 않았다. 오류 문구는 [1025]유효한 거래를 찾을 수 없습니다.

문제의 원인은 flutter_inappwebview 내부에 있었는데 웹뷰 내 URL 변경을 알아차리는 shouldOverrideUrlLoading method가 URL을 모두 소문자로 치환된 채로 동작하는 것이 원인이였다.

shouldOverrideUrlLoading:
  (controller, NavigationAction navigationAction) async {
  var uri = navigationAction.request.url!;
  print('uri : $uri'); // 여기서 이미 소문자로 치환되어있음
  IamportUrl iamportUrl = IamportUrl(uri.toString());
  if (uri.scheme == 'intent' || iamportUrl.isAppLink()) {
    _launchURL(iamportUrl, uri);
    return NavigationActionPolicy.CANCEL;
  } else {
    return NavigationActionPolicy.ALLOW;
  }
}

실제 접속 url이 ispmobile://ABCDE 라면 ispmobile://abcde로 변경되어 오류가 났던것. 이런 문제 때문에 webview_flutter 플러그인으로 변경 하는 것으로 해결하였다.

navigationDelegate: (NavigationRequest request) {
  var uri = request.url;
  IamportUrl iamportUrl = IamportUrl(uri.toString());
  if (uri.startsWith('intent') || iamportUrl.isAppLink()) {
    _launchURL(iamportUrl, uri);
    return NavigationDecision.prevent;
  }
  return NavigationDecision.navigate;
}

2. Flutter - ReactJS communication

카카오 로그인 기능을 생각해보자. 웹에서 카카오 로그인을 시도한다면 카카오톡 계정과 비밀번호를 입력하는 페이지가 열리고 올바른 값을 입력하면 로그인이 완료될 것이다. 이 플로우가 앱에서도 그대로 유지된다면 유저경험 측면에서 굉장히 불편할 것이다. 대부분의 앱에서 카카오톡 로그인을 시도하면 계정과 비밀번호 입력 없이 기기에 이미 연동 된 계정으로 진행되기 때문이다.

위의 경우를 생각해보면 웹에서 앱 내부의 함수를 실행시키고, 그 결과로 생긴 데이터를 웹으로 보내주어야 하는 상황이 종종 발생한다는 것을 알 수 있다. webview 플러그인에는 이를 해결하기 위한 방법이 존재한다.

기존에 사용하던 flutter_inappwebview 플러그인에는 flutter와 js 간의 communication 방식이 3가지가 있다. Docs 에서 확인이 가능하며 그 중 JavaScript Handlers 방식으로 flutter에서 js로 데이터를 보내는 것이 가능했다.

그런데 위의 문제 때문에 플러그인을 변경하고 보니 webview_flutter에는 flutter에서 js로 데이터를 직접 보낼 수가 없고, webviewController.runJavascript() 를 통해 js 함수를 실행 시킬때 담아서 보내야했다. 그런데 React 컴포넌트 내부의 함수는 외부에서 실행이 불가능하다. 이를 어떻게 해결할 수 있을까? 빌드된 html, js파일을 flutter 프로젝트에 넣고 어떻게 어떻게 하면 될 것도 같았지만 유지보수를 쉽게하기 위해 웹뷰 앱을 만드는 것인데 이 방식은 아닐거라 생각했다.

// in VanillaJS
function funcRunInFlutter(message) {  // 실행 가능
  console.log(message);
}

// in React
const PageOne = () => {
  const funcRunInFlutter = (message) => {  // 실행 불가능
    console.log(message);
  }
  return (
    <div>...</div>
  )
}

고민 끝에 생각해낸 방법은 Custom Event 였다. Custom Event는 엘리먼트에 바인딩 된 특정 함수를 onClick, onLoad 등의 트리거를 통해 동작시키는 일반적인 이벤트와 비슷하다. 차이점은 dispatchEvent(event) 함수가 트리거가 되고 인자로 입력되는 event는 detail 이라는 값을 포함하는 CustomEvent 인스턴스가 되어야 한다는 것이다.

const catFound = new CustomEvent('animalfound', {
  detail: {
    name: 'cat'
  }
});
obj.addEventListener('animalfound', (e) => console.log(e.detail.name));
obj.dispatchEvent(catFound);

mdn에서 가져온 예시를 보면 좀 더 쉽게 이해할 수 있을 것이다. 이를 React - Flutter 환경에 적용해 보면 다음과 같다.

const LoginPage = () => {
  useEffect(() => {
    window.addEventListener('customEventForKakaoLogin', finishKakaoLoginForApp);
    return () => window.removeEventListener('customEventForKakaoLogin', finishKakaoLoginForApp);
  }, [])
  
  const finishKakaoLoginForApp = (event) => {
    console.log(event.detail.useData);
  }
  
  const kakaoLogin = () => {
  	if (env === 'APP') {
      window.KakaoLogin.postMessage('앱 카카오 로그인 시도');
      return;
    }
    ...
  }
  return (
    <div>
      <button onClick={kakakoLogin}>카카오 로그인</button>
    </div>
  )
}
Webview(
  ...
  javascriptChannels: <JavascriptChannel>{
    _forKakaoLoginJavascriptChannel(context),
  }
  ...
)
  JavascriptChannel _forKakaoLoginJavascriptChannel(BuildContext context) {
    return JavascriptChannel(
        name: 'KakaoLogin', // js의 window.KakaoLogin.postMessage 의 KakaoLogin 과 같아야 한다.
        onMessageReceived: (JavascriptMessage message) async {
          // message.message === '앱 카카오 로그인 시도'
          if (await isKakaoTalkInstalled()) {
            await UserApi.instance.loginWithKakaoTalk();
          } else {
            await UserApi.instance.loginWithKakaoAccount();
          }
          final user = await UserApi.instance.me();
          String json = jsonEncode(user);
          _webController.future.then((controller) async {
            controller.runJavascript(
                "(function() { window.dispatchEvent(new CustomEvent('customEventForKakaoLogin', { detail: { userData: $json } })) })();");
          });
        });
  }

앱 환경에서 카카오 로그인 버튼을 누르면 kakaoLogin 함수가 동작하며 JavascriptChannel에 메세지를 보낸다. 메세지를 받은 KakaoLogin 채널의 onMessageReceived method가 동작하는데 이는 로그인 시도, 성공 이후 유저데이터를 customEvent 인스턴스에 담아 dispatch 시키는 역할을 한다. 이로 인해 바인딩된 finishKakaoLoginForApp 이 동작하면서 userData가 로그에 찍힌다. 전달된 userData를 가지고 지지고 볶고 하면 로그인 완료.

3. android 키패드 컨트롤

앱 내부에서 웹 input 요소에 포커스가 이동하면 키패드가 팝업되고 포커스가 풀리면 키패드가 사라진다. 그런데 android에서는 항상 화면에 떠 있는 취소 버튼으로도 키패드를 사라지게 할 수 있었다. 키패드가 팝업 되면 웹에 구현되어 있는 하단 네비게이션 바가 사라져야하는데, 이 차이 때문에 제대로 동작하지 않았다.

키패드 팝업 / 사라짐을 input focus on / off 가 아닌 window.resize 이벤트로 viewport높이가 작아짐 / 커짐을 체크하여 하단 네이게이션을 on / off 하는 것으로 해결했다.

const Layout = () => {
  const [initialHeight, setInitialHeight] = useState(0);
  
  useEffect(() => {
    setInitialHeight(window.innerHeight);
    
    window.addEventListener('resize', throttle(handleResize));
    return () => window.removeEventListener('resize', throttle(handleResize));
  }, []);
  
  const handleResize = () => {
    if (window.visualViewport.height < initialHeight) {
      uiStore.keyboardUp();
    } else {
      uiStore.keyboardDown();  
    }
  }
  return (
    <div>
      <BottomNavigationBar isKeyboardUp={uiStore.isKeyboardUp} />}
    </div>
  )
}

// css
display: ${({isKeyboardUp}) => isKeyboardUp ? 'block' : 'fixed'};

4. ios window.alert, confirm, prompt

웹에서 window.confirm 을 통해 유저의 선택을 유도하는 경우가 결제, 환불, 회원가입 등 상당히 많았다. 그런데 ios 앱에서는 window.confirm 을 비롯한 alert, prompt 가 동작하지 않았다. alert는 이후의 코드가 실행은 되었기 때문에 큰 문제가 되지는 않았지만 confirm 은 아니이었다. confirm이 완료되지 않으니 이후의 코드가 실행이 되지 않았고, 결제, 환불을 진행할 수 없었다.

오류의 원인은 Swift에서 원래 지원을 안하기 때문이라고 한다. (...???) 해결하려면 직접 delegate를 심어야한다고 .. 뭔지 모르겠으니 웹에서 해결해보려했다.

시도 1 모달로 띄우자.

제일 먼저 생각한 방법은 confirm을 모달로 띄우자 였다. confirm() 만으로 모달을 띄우고 ok를 누를 시 이후의 코드가 동작하도록 하는 패키지를 이것 저것 찾아보았지만 전부 원하는 대로 동작을 하지 않았고, 직접 만들기로 했다. 여기를 참고했으며 원하는 대로 동작하는 useConfirm custom hook을 쉽게 만들 수 있었다.

const { confirm } = useConfirm();
const isConfirmed = await confirm('정말 환불 하시겠습니까?');

그런데 웹 프로젝트가 만들어진지 꽤 오래되어 절반 정도는 class 컴포넌트로 이루어져 있어서useConfirm을 사용할 수 없었다. HOC로 confirm을 전달하는 것을 시도해보았으나 NextJS getInitialProps의 ctx를 포함해서 전달할 방법을 알지 못해 실패했다. 그렇다고 class 컴포넌트를 functional로 바꾸자니 리소스가 너무 많이 들어 다른 방법을 찾아야했다.

시도 2 뭔진 모르겠지만 delegate를 심어보자.

다행히도 같은 문제를 겪고 이를 해결한 방법을 설명해둔 글이 꽤 있었고 리샤님의 블로그 포스트를 참고해서 해결했다.

최신 버전의 flutter_webview 를 사용한다면 위 링크에서 말하는 FlutterWebveiw.m 파일이 존재하지 않는다. pubspec.yamlwebview_flutter_wkwebview: 2.8.1 버전으로 낮춰서 고정시키니 .symlinks/plugins/webview_flutter_wkwebview/ios/Classes 의 경로에 FlutterWebveiw.m 파일이 추가되었다. alert, confirm 코드만을 추가해서 새로 빌드하니 잘 동작하는 것을 확인할 수 있었다.

이 글을 작성하던 중에 찾게된 포스트인데 버전을 낮추지 않고도 해결 할 수 있는 것 같다.
https://islet4you.tistory.com/entry/Flutter-webviewflutter-%EC%82%AC%EC%9A%A9%EC%A4%91-iOS-alert-confirm-%EB%9D%84%EC%9A%B0%EA%B8%B0

회고

잘 동작하는 웹이 존재하는 상태에서, 프론트엔드 개발자 두 명이 그걸 껍데기(+ 푸시알림)만 씌워 앱으로 배포하는데 두 달이 조금 안되는 시간이 걸렸다. 처음 접하는 언어에 야근도 많이하고 힘들긴 했지만 목표했던 일정에 맞출 수 있어 좋았다.

위에 적은 것 외에도 여러 가지 많은 문제가 있었는 것 같은데 메모해두지 않아서 생각은 잘 나지 않는다. 다음 프로젝트부터는 문제 - 해결 - 결과를 자세하게 기록해두어야겠다.

docs를 제대로 읽지 않아 몇 시간을 날린 기억이 난다. 꼼꼼하게 읽어보자.

참고

profile
FE Developer

0개의 댓글