제휴 금융사 확정 금리·한도 비교 서비스
본인인증 한 번으로 제휴 금융사 확정 금리를 한자리에
심사를 기다릴 수 없는 금리표
2020년 하반기 프로젝트입니다. 본인인증 한 번으로 제휴 금융사 전체의 확정 금리와 한도를 조회하는 것이 요구사항이었습니다.
문제는 그 데이터를 얻는 경로였습니다. 마이데이터 이전이라 계좌와 소득을 확인할 방법은 공인인증서 스크래핑뿐이었고, 스크래핑 SDK는 네이티브 전용이었어요. 반면 제휴사 목록과 금리 표기, 약관 문구는 앱 심사에 묶이지 않고 바꿀 수 있어야 했습니다.
그래서 이 프로젝트는 처음부터 어디까지를 네이티브로 남길 것인가를 정하는 일이었습니다. 인증서, 스크래핑, 보안 키패드, 생체인증, 푸시. 이 다섯 가지만 네이티브에 남기고 나머지 85개 화면은 전부 웹으로 그렸습니다. 여기에 앱을 깔지 않은 방문자를 위한 웹 퍼널과 소개 사이트가 별도 레포로 하나 더 붙었고요.

인증서는 네이티브, 화면은 웹
WKWebView 셸 + Vue 2 SPA 하이브리드
금리표와 약관 문구를 앱 심사 없이 당일 배포해야 했습니다. 화면을 웹으로 두면 vue-cli-service build 결과물만 갈아끼우면 되니까요. iOS 쪽은 Config.swift의 BASE_FINSET_URL을 읽어 웹을 띄우는 것 외에 화면을 그리는 코드가 사실상 없습니다. 스크래핑 SDK와 보안 키패드는 네이티브 전용이었고, 웹뷰만 띄우는 앱은 앱스토어 가이드라인 4.2 리젝 위험이 있었습니다. 그래서 인증서 관리·생체인증·보안 키패드처럼 네이티브가 아니면 불가능한 기능을 앱의 정체성으로 두는 방향으로 갔습니다.
Swift가 런타임에 주입하는 window.Native 브리지
JavascriptInterface.swift의 createScriptToAddFunctions()가 함수 이름과 파라미터 배열만 받아 JS 코드를 문자열로 찍어내고, 이걸 WKUserScript로 넣습니다. 웹은 window.Native.startAutoScrap(code, info)처럼 평범한 함수를 부르면 되고, 시그니처는 Swift 한 곳에서만 관리됩니다. WKScriptMessageHandler는 반환값이 없는 단방향입니다. 그래서 응답은 전부 네이티브가 evaluateJavaScript로 window.resultAutoScrap(...) 같은 전역 함수를 호출해 되돌려주는 구조가 됐어요. 콜백을 받을 화면이 created()에서 자기 핸들러를 window에 심어두어야 합니다.

브리지 하나로 이어붙인 두 런타임
iOS 앱은 화면을 그리지 않습니다. Info.plist의 BASE_FINSET_URL을 읽어 웹뷰 하나를 채우고, 그 위에 window.Native를 주입한 뒤로는 브리지 요청을 받아 처리하는 일만 합니다. 인증서 목록 조회, 인증서 비밀번호 확인, 스크래핑 SDK 구동, 보안 키패드 표시, Face ID, FCM 토픽 구독, AppsFlyer 이벤트가 전부 여기에 있어요. 심지어 상태바 색을 바꾸는 것도 chageStatusBar라는 브리지 함수입니다. 웹이 화면 배경색을 바꾸면 상태바도 따라와야 하니까요.
이 글에서는 네이티브와 웹의 경계, 그리고 그 경계 위에서 완료를 판정하는 문제만 다룹니다. 백엔드 컨텍스트별 axios 분리, 대출 견적 폴링과 FCM 통보, 앱 웹뷰용 SPA와 마케팅 웹의 레포 분리는 각각 따로 다룰 가치가 있는 이야기라 이 글에서는 접고, 웹뷰 안에서 iOS 화면 전환을 흉내 낸 이야기는 요약만 남깁니다.

세 갈래로 흩어진 스크래핑이 언제 끝났는지 알아내기
연동 버튼을 누르면 은행·카드·국세청 스크래핑을 시작하고, 끝나면 "업데이트 완료"를 띄우면 됩니다. 처음 만나면 startAutoScrap을 세 번 부르고 await 하거나 Promise.all로 묶으려 하기 마련이에요. 그런데 브리지는 반환값이 없습니다. window.webkit.messageHandlers.iOS.postMessage는 그냥 던지고 끝이라 기다릴 대상 자체가 없죠. 그래서 다음으로 생각하는 게 "콜백이 오면 완료 처리"인데, 이러면 은행 하나가 먼저 끝난 순간 완료 스낵바가 뜨고 카드와 국세청은 아직 돌고 있습니다. 여기에 증권은 단말이 아니라 서버가 긁어옵니다(startScrapSt.json). 즉 완료 신호가 네이티브 콜백과 HTTP 응답이라는 성격이 다른 두 경로로 도착하고, 어느 쪽이 먼저일지 알 수 없습니다.
완료 판정을 두 층으로 나눴습니다. 아래층은 네이티브가 접습니다. AutoScrapManager가 요청 건수를 cntBankScrap/cntCardScrap으로 세어두고 델리게이트가 돌아올 때마다 하나씩 깎아, 0이 되는 순간에만 결과를 서버에 저장하고 resultAutoScrap을 한 번 호출합니다. 은행 계좌가 몇 개든 카드사가 몇 곳이든 웹에는 콜백 한 번으로 접혀 도착하는 거죠.
위층은 웹이 맡습니다. Vuex scrap 상태에 isFcScrapDone과 isStockScrapDone 두 플래그만 두고, 종료 지점 두 곳(App.vue의 resultAutoScrap, actions.ts의 ACTION_START_STOCK_SCRAP)에서 각자 자기 플래그를 세운 뒤 상대 플래그를 확인합니다. 상대가 이미 끝나 있으면 그때 saveScrapData()를 부르고 스낵바를 띄운 뒤 네 개 플래그를 한꺼번에 리셋합니다. 아직이면 아무것도 하지 않고 조용히 빠집니다.
핵심은 순서를 가정하지 않았다는 점입니다. 어느 쪽이 먼저 끝나든 나중에 도착한 쪽이 마무리를 책임지는 구조라, 네이티브가 빠른 기기든 서버가 빠른 시간대든 완료 처리가 정확히 한 번만 일어납니다. 그리고 마무리 코드를 두 곳에 똑같이 써야 했는데, 이 중복을 감수한 대신 "완료를 판정하는 주체"를 하나로 몰지 않아도 됐어요. 판정 주체를 웹에 몰면 네이티브가 몇 건을 처리 중인지 웹이 알아야 하고, 그러면 브리지에 진행 상태 조회 함수가 하나 더 생깁니다. 진행률을 물어보는 폴링이 늘어나는 것보다는 카운트다운을 네이티브 안에 가두는 쪽이 경계를 얇게 유지했습니다.
웹뷰 안에서 iOS 화면 전환을 흉내 내다 만난 세 가지 붕괴
<transition>에 transform: translateX(100%)만 걸면 끝날 것 같지만, 뒤로 나올 때 스크롤이 맨 위로 튀고 position: fixed 헤더와 하단 버튼이 함께 밀려 나가고 전환 0.4초 동안 body 높이가 두 배가 됩니다.
전환 구간에만 규칙을 바꿨습니다. scrollBehavior에서 복원을 한 틱 미루고, html에 .transitioning을 붙여 높이를 잠그고, 고정 요소는 setPositionFixedElement가 픽셀 좌표로 박았다가 afterEnter에서 되돌립니다.
CSS로 이길 수 없는 규칙이라면 그 규칙이 적용되는 시간을 좁히는 게 답이었습니다. 세 가지 붕괴를 하나씩 뜯어보는 이야기는 따로 적겠습니다.
유저가 할 수 있는 일
문자열 브리지가 남긴 비용
가장 크게 남는 아쉬움은 브리지가 문자열로 이어져 있다는 점입니다. Swift가 함수 이름을 문자열로 찍어 JS를 만들고, 웹은 window에 콜백을 심어 기다립니다. 이름 한 글자만 틀려도 컴파일도 통과하고 린트도 통과한 뒤 런타임에서 조용히 아무 일도 일어나지 않아요. 실제로 updateAvaliableLoginScrapInfo처럼 철자가 굳어버린 함수가 양쪽에 그대로 남아 있습니다. 다시 만든다면 브리지 스펙을 스키마 한 곳에 정의해 Swift 확장과 .d.ts를 함께 생성하고, callNativeFunction에 requestId를 붙여 Promise로 감쌌을 겁니다. 그러면 resultAutoScrap 같은 전역 콜백 대신 await Native.startAutoScrap(...)을 쓸 수 있었겠죠.
















