회사에서 얼마 전까지 flutter를 사용한 웹뷰 앱을 만드는 프로젝트를 진행했었다. 진행하며 겪었던 문제들과 해결법들을 정리하고자 한다.
flutter 로 웹뷰 앱을 만들 때 주로 사용하는 플러그인은 webview_flutter
, flutter_inappwebview
두 가지가 있다. 처음엔 기능이 더 많고 docs가 잘 작성되어진 flutter_inappwebview
를 사용하다가 결제 부분에서 플러그인 변경 외에 해결방법이 없는 문제를 마주쳐서 webview_flutter
로 전환하였다.
플러그인을 바꾸게 된 이유..
프로덕션 웹에 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;
}
카카오 로그인 기능을 생각해보자. 웹에서 카카오 로그인을 시도한다면 카카오톡 계정과 비밀번호를 입력하는 페이지가 열리고 올바른 값을 입력하면 로그인이 완료될 것이다. 이 플로우가 앱에서도 그대로 유지된다면 유저경험 측면에서 굉장히 불편할 것이다. 대부분의 앱에서 카카오톡 로그인을 시도하면 계정과 비밀번호 입력 없이 기기에 이미 연동 된 계정으로 진행되기 때문이다.
위의 경우를 생각해보면 웹에서 앱 내부의 함수를 실행시키고, 그 결과로 생긴 데이터를 웹으로 보내주어야 하는 상황이 종종 발생한다는 것을 알 수 있다. 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를 가지고 지지고 볶고 하면 로그인 완료.
앱 내부에서 웹 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'};
웹에서 window.confirm
을 통해 유저의 선택을 유도하는 경우가 결제, 환불, 회원가입 등 상당히 많았다. 그런데 ios 앱에서는 window.confirm
을 비롯한 alert, prompt
가 동작하지 않았다. alert
는 이후의 코드가 실행은 되었기 때문에 큰 문제가 되지는 않았지만 confirm
은 아니이었다. confirm
이 완료되지 않으니 이후의 코드가 실행이 되지 않았고, 결제, 환불을 진행할 수 없었다.
오류의 원인은 Swift에서 원래 지원을 안하기 때문이라고 한다. (...???) 해결하려면 직접 delegate를 심어야한다고 .. 뭔지 모르겠으니 웹에서 해결해보려했다.
제일 먼저 생각한 방법은 confirm
을 모달로 띄우자 였다. confirm()
만으로 모달을 띄우고 ok를 누를 시 이후의 코드가 동작하도록 하는 패키지를 이것 저것 찾아보았지만 전부 원하는 대로 동작을 하지 않았고, 직접 만들기로 했다. 여기를 참고했으며 원하는 대로 동작하는 useConfirm custom hook
을 쉽게 만들 수 있었다.
const { confirm } = useConfirm();
const isConfirmed = await confirm('정말 환불 하시겠습니까?');
그런데 웹 프로젝트가 만들어진지 꽤 오래되어 절반 정도는 class 컴포넌트로 이루어져 있어서useConfirm
을 사용할 수 없었다. HOC로 confirm을 전달하는 것을 시도해보았으나 NextJS getInitialProps
의 ctx를 포함해서 전달할 방법을 알지 못해 실패했다. 그렇다고 class 컴포넌트를 functional로 바꾸자니 리소스가 너무 많이 들어 다른 방법을 찾아야했다.
다행히도 같은 문제를 겪고 이를 해결한 방법을 설명해둔 글이 꽤 있었고 리샤님의 블로그 포스트를 참고해서 해결했다.
최신 버전의 flutter_webview
를 사용한다면 위 링크에서 말하는 FlutterWebveiw.m
파일이 존재하지 않는다. pubspec.yaml
에 webview_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를 제대로 읽지 않아 몇 시간을 날린 기억이 난다. 꼼꼼하게 읽어보자.
끝