개요
내가 별로 안좋아하는 웹뷰
그 웹뷰에 대해서 알아보자
웹 페이지의 생명주기 (CRP)
- Critical Rendering Path의 약자로 브라우저 엔진이 HTML, CSS, JS를 처리하고 화면에 그리기까지의 과정
- 이 과정을 알아야 흰 화면이 왤캐 오래있는지, 렉이 왜걸리는지 알 수 있음
- DOM 생성
- 브라우저가 서버에서 받은 html 파일을 위에서 아래로 한 줄씩 읽음
- 텍스트를 누가 누구의 자식인지 파악하여, 메모리 안에 객체 트리 구조로 만듬, 이를 DOM 트리라고 함
- iOS에서 xib나 storyboard의 xml을 읽어서 메모리에 UIView의 계층구조를 올리는 과정과 비슷
- CSSOM 생성
- HTML을 읽다가 style 관련 태그를 만나면 브라우저는 css파일을 다운로드하고 파싱함, 이를 CSSOM 트리라고 함
- iOS에서 Manager같은 곳에 앱의 UI스타일을 정의해두는것과 비슷
- CSS는 Render-Blocking 리소스임
- 브라우저는 모든 CSS를 다운로드하고 CSSOM트리를 완성할 때까지 그리기를 하지않음, 따라서 처음에 로딩이 긴것
- JS 실행
- HTML 코드를 실행하다가 JS 코드를 만나면 파싱을 중단하고 브라우저는 렌더링 엔진은 JS엔진에 제어권을 넘김
- JS 실행이 끝나면 DOM트리 생성을 재개함
- 렌더 트리 생성
- DOM트리와 CSSOM트리를 결합하여 실제로 화면에 그려질 노드들만 모아놓은 트리 생성
- Layout
- 화면의 픽셀 좌표와 크기를 계산
- Paint
- 웹뷰에서 스크롤하거나 버튼을 클릭할 때 버벅이면 Layout이 다시 실행되었기 때문
iOS 웹뷰
- 앱 내에 웹페이지를 넣는 ui 컴포넌트
- 앱의 컨텐츠, 리소스, 화면이 자주 바뀌는 경우 사용
- 앱 배포 없이 바꿀 수 있다는 것이 가장 큰 장점
- WKWebView : 일반적으로 많이 쓰고 커스터마이징이 가능한 웹 렌더링 캔버스
- UIView를 상속받아 웹을 네이티브 뷰처럼 활용가능함
- 웹과 네이티브 양방향 통신이 가능함
- WKWebsiteDataStore라는 앱 고유의 독립된 저장소를 가짐
- 모든 보안 책임은 개발자에게 있음, apple이 해주지않음
- SFSafariViewController : 앱 내부의 소형 사파리 브라우저
- 프로세스가 격리되어 앱은 사파리가 뭐하는지 모름
- 양방향 통신 불가능, 그냥 웹페이지 보여주는 용
- 사파리 앱의 세션을 공유함, 쿠키, 로그인 세션등을 가져올 수 있음
- 커스터마이징 불가능
- ASWebAuthenticationSession을 쓰지 않고 WKWebView로 로그인 페이지를 직접 띄우는 것은 보안이슈
- evaluateJavaScript를 이용해서 사용자가 입력하는 아이디와 비밀번호를 가로챌수있음
- 로그인 후 세션 쿠키나 토큰도 훔칠 수 있음
- 소셜 로그인(OAuth), SSO(Single Sign-On)을 처리할 때 사용해야됨
WKWebView의 심장: WKWebViewConfiguration
- WKWebView가 무엇을, 어떻게, 누구와등등 모든 초기설정을 담고 있는 객체
- WKWebViewConfiguration는 WKWebView가 init(frame:configuration:)으로 생성되는 시점에 단 한 번 설정
- WKWebView는 Web Content Process라는 앱과 분리된 별도의 프로세스에서 실행됨
- config객체는 모든 설정값을 이 프로세스에 전달하고 그것은 다시 못바꿈
핵심 객체
- userContentController : iOS -> Web 브릿지
- add(_:name:)으로 이벤트 리스너 등록 [Web → Native]
- self가 WKScriptMessageHandler 프로토콜을 준수해야됨
- JS에서는 window.webkit.messageHandlers.~~~.postMessage(…)로 호출
- add는 강한 참조이므로 deinit에서 removeScriptMessageHandler 반드시 호출해야됨
- addUserScript(_:)로 js를 웹에 주입 [Native → Web]
- injectionTime을 설정할 수 있음
- .atDocumentStart: <html> 태그가 생성된 직후, <body> 태그가 파싱되기 전 가장 먼저 실행
- 요즘 react, vue는 로드되자마자 localStorage를 확인해서 로그인 상태를 결정, 따라서 인증 토큰 주입할 때 사용
- 웹의 console.log를 가로채서 swift의 print하고 싶을 때
- .atDocumentEnd: DOM 로드가 완료된 후 실행
- DOM 요소 조작할 때
- 유틸리티 JS를 주입할 때
- .atDocumentStart: <html> 태그가 생성된 직후, <body> 태그가 파싱되기 전 가장 먼저 실행
- websiteDataStore : 웹뷰의 메모리
- WKWebsiteDataStore.default(): 기본값, 앱 내의 모든 wkwebview가 공유하는 저장소(디스크)
- 웹뷰A에서 로그인하면 default에 쿠키가 저장되고 웹뷰B로 띄워도 로그인 세션이 유지됨
- WKWebsiteDataStore.nonPersistent() : 시크릿모드, 디스크가 아닌 메모리에만 저장됨
- 인스턴스가 메모리에서 해제되면 데이터 모두 삭제, 일회용 세션에서 자주사용
- WKWebsiteDataStore.default(): 기본값, 앱 내의 모든 wkwebview가 공유하는 저장소(디스크)
- suppressesIncrementalRendering : 웹 페이지가 전부 렌더링이 완료 되면 보여줌 or 로드되는 대로 바로 보여줌(기본값)
- preferences : 웹페이지 자체의 동작을 제어하는 설정 모음
- javaScriptEnabled : js 허용여부 기본값은 true
- javaScriptCanOpenWindowsAutomatically: window.open() 허용여부(기본값 false), true로 해도 createWebViewWith메서드 구현해야됨
- minimumFontSize : 최소 폰트 크기 강제
WKWebView의 머리: WKNavigationDelegate
- 페이지가 이동하는 모든 과정을 가로채고 추적할 수 있음
- decidePolicyFor (Action) : 사용자가 링크를 클릭하거나 window.location이 변경되는등 새로운 내비게이션 동작이 수행되기전 호출
- navigationAction.request.url을 검사하여 허용된 도메인인지 확인가능
- URL이 tel:1234, mailto:~~, res://등이면 웹뷰 내비게이션을 막고 네이티브 기능을 대신 수행
- decidePolicyFor (Action, Preferences) : 다음에 로드될 페이지에서만 적용될 설정값
- decidePolicyFor (Response) : 응답을 받은 뒤 화면에 그리기 직전
- http 상태코드 검사, MIME 타입 검사
- didStartProvisionalNavigation : decidePolicyFor action에서 .allow가 리턴된 후 웹뷰가 콘텐츠를 받기 시작할 때 (로딩 화면 구현)
- didCommit : 데이터를 수신하고 화면에 렌더링하기전에 호출됨, 이 시점부터 새로운 URL로 변경됨
- didFinish : 웹뷰가 성공적으로 로드되어 렌더링하였음
- didReceiveServerRedirectForProvisionalNavigation : HTTP 리디렉션(301, 302등)응답을 받았을 때 호출, 디버깅할 때 사용, 평상시는 거의 사용안함
- didFailProvisionalNavigation : didCommit이 호출되기 전 초기 탐색 과정에서 오류 발생 시
- 인터넷 연결 없음
- DNS 오류
- SSL/TLS 인증서 오류
- 개발자가 decidePolicy에서 .cancel을 호출했을 때
- didFail : didCommit이 호출된 후, 즉 메인 콘텐츠를 로드하는 중 오류 발생 시
- webViewWebContentProcessDidTerminate : 웹 페이지가 크래쉬 났을 때
- didReceive : 인증을 요구할 때
- didBecome:WKDownload : 파일 다운로드 할 때
- shouldGoTo:willUseInstantBack : 사용자가 스와이프 제스처를 사용할 때
WKWebView의 얼굴: WKUIDelegate
- WKWebView는 일반 브라우저와 다름. 기본적으로 window.open()이나 alert() 같은 JavaScript 함수를 못씀
- 웹에서 띄우는 못생긴 기본 alert 창이 iOS 앱의 네이티브 디자인과 어울리지 않기 때문에 막아놓음
- 따라서 WKUIDelegate를 이용해서 그에 상응하는 네이티브 UI를 띄워줘야함
- webView(_:createWebViewWith:for:windowFeatures:)
- window.open()을 처리하는 팝업 핸들러
- javaScriptCanOpenWindowsAutomatically가 true이고 JS에서 window.open이 호출되었을 때
- WKWebViewConfiguration를 활용해서 새로운 WKWebView 인스턴스를 생성
- 화면에 네이티브코드로 띄운 뒤 해당 인스턴스를 리턴
- webViewDidClose
- createWebViewWith로 띄웠던 팝업 웹뷰가 닫힐 때 호출
- 팝업 페이지에서 JS로 window.close()가 호출될 때
- 네이티브 코드로 띄운 모달을 닫아주는 작업을 해야함
- runJavaScriptAlertPanelWithMessage : JS의 alert 메서드를 네이티브로 처리
- runJavaScriptConfirmPanelWithMessage : JS의 confirm 메서드를 네이티브로 처리
- runJavaScriptTextInputPanelWithPrompt : JS의 prompt 메서드를 네이티브로 처리
- runOpenPanelWith:initiatedByFrame : HTML의 태그를 처리
- 사진을 원하면 PHPickerViewController, 파일을 원하면 UIDocumentPickerViewController를 띄움
- requestMediaCapturePermissionFor : navigator.mediaDevices.getUserMedia()를 통해 카메라 또는 마이크 접근을 요청할 때 호출
- requestDeviceOrientationAndMotionPermissionFor : 기기의 기울임, 흔들림의 데이터를 가져오려할 때 호출
WKWebView의 양손: WKScriptMessageHandler, evaluateJavaScript
- WKWebView를 쓴다는 건, 하나의 앱 안에 서로 다른 언어와 환경을 가진 두 개의 세계가 공존한다는 뜻
- 그럼 그 세계를 이어주는 소통창구가 있어야할 것 아님 그것들이 바로 WKScriptMessageHandler, evaluateJavaScript
- WKScriptMessageHandler는 Web-to-Native (JS → Swift)용
- 웹페이지를 로드할 때 웹 세계의 window 객체 안에 webkit이라는 통로를 만듬
- iOS 개발자가 WKWebView를 만들기 전, config.userContentController.add(self, name: “myAppHandler”) 코드로 내 존재 알림
- 웹 개발자가 작성한 window.webkit.messageHandlers.myAppHandler.postMessage(data) 코드로 네이티브한테 데이터 전송
- WebKit이 이 호출을 가로채서 수신자로 등록된 델리겟의 userContentController(_:didReceive:) 호출
- evaluateJavaScript는 Native-to-Web (Swift → JS)용
- iOS 개발자가 webView.evaluateJavaScript(“myJsFunction(‘Hello’);”) 코드를 실행
- WKWebView의 WebKit 엔진이 이 문자열을 받아서, 웹 세계의 JavaScript 콘솔에 그대로 붙여넣고 실행
- 결과 값이 있다면 webkit은 이 결과 값을 가로채서 iOS의 비동기 콜백 함수를 실행함
- 그러나 evaluateJavaScript는 웹페이지 로딩이 끝난 후에 호출하는 것임
- 그렇다면 웹이 알고있어야하는 JS를 주입할 수 있음
- config.userContentController.addUserScript(…)로 스크립트를 등록
- webView.load(request)가 호출됨
- DOM트리를 생성하기 전 등록한 js부터 실행시킴
- WebKit은 페이지의 body를 만들고 웹페이지의
- Promise와 evaluateJavaScript
- Promise는 JS에서 비동기 작업의 상태값을 나타냄, JS의 비동기 함수를 실행하면 일단 Promise 객체를 반환하고 나중에 함수가 실행되면 객체의 값이 바뀜
- evaluateJavaScript(“myAsyncFunction()”)를 호출하면, WebKit의 엔진이 이 문자열을 파싱하고 실행
- myAsyncFunction은 async 함수이므로, 실행 즉시 pending 상태의 Promise 객체를 동기적으로 반환
- evaluateJavaScript는 내가 실행한 비동기 함수의 진짜 결과값을 기다려주지않음
- 따라서 콜백 Id를 백업해놓은 뒤 실행할 스크립트에 .then을 이용해서 콜백id와 함께 데이터를 보내줌
- 이럴때는 callAsyncJavaScript를 쓰면 되는데 Promise면 기다려주는 기능이 추가되었음 iOS 14 이상이면 이거 쓰면 됨
웹뷰 사용 시 주의할 점
WKWebView에서 직접 OAuth 로그인을 처리하지 마라
- 앱이 만약 evaluateJavaScript를 통해 키보드를 입력할 때마다 그 값을 가로채는 로직이 있다면? 고객의 개인정보는 유출되기 쉽다.
- 사용자는 Google에 비밀번호를 입력하고 있다고 생각하지만, 실제로는 앱으로 전송되고 있는 것
- 로그인 이후에도 웹뷰의 datastore에 로그인 세션 쿠키나 토큰을 저장함
- 앱은 이 정보 또한 훔쳐서 google의 계정을 해킹할 수 있음
- 네이티브 앱은 Authorization Code Grant + PKCE라는 보안 플로우를 사용해야함. ASWebAuthenticationSession는 이 플로우임
- 이 방식은 인증코드라는 1회용 토큰만 받고 이를 로그인에 활용하는것(다른 포스팅에서 정리)
- ASWebAuthenticationSession는 앱의 프로세스와 완전히 분리된 iOS의 보안 프로세스에서 실행됨
Keychain에 Access Token / Refresh Token 저장하라
- Keychain은 데이터가 하드웨어 수준에서 암호화됨
- 데이터는 기기의 보안 칩과 연결된 키로 암호화 됨
- Access Token은 로그인 상태 그 자체
- Refresh Token은 Access Token이 만료되었을 때 서버에 제시하면 다시 발급해줌
- 따라서 앱이 다시 실행되어도 로그인 절차 없이 다시 로그인 가능해짐
토큰은 WKUserScript를 이용해 WKWebView로 전달하라
- ASWebAuthenticationSession으로 토큰을 얻었다 그러나 웹뷰는 이를 모른다
WKWebView는 단순한 UIView가 아니다.
- 웹뷰는 별도의 프로세스에서 실행되는 미니 브라우저임, UIView처럼 마구잡이로 생성하면 OOM 에러 발생함
- UITableViewCell이나 CollectionViewCell같은 재사용 뷰 안에서 생성하지 마라
WKScriptMessageHandler의 순환 참조를 해제하라
- config.userContentController.add는 self가 WKUserContentController에 대해 강한 참조
- WKWebView도 self를 잡고, self도 WKWebView를 잡고 있는 순환 참조 상태임
- self의 deinit 시점에 removeScriptMessageHandler를 호출해야됨
- 아래처럼 애초에 아래 프록시 패턴처럼 weak 변수를 만들어서 순환 참조 구조를 깨버려도됨
1 | |
userContentController(_:didReceive:)는 API의 엔드포인트
- 네이티브 세계의 창구
- 웹뷰에서 오는 모든 요청은 신뢰할 수 없는 외부라고 간주해야함
- 의도하지 않은 동작에 대해 확실한 예외처리
- WKAppBoundDomains를 이용해서 허용되지 않은 도메인은 이동 못하도록
웹뷰를 네이티브처럼
- 웹뷰에서 가장 어색한 부분은 화면이 이동할 때 생기는 지연
- WKWebView를 화면 전체에 띄우는 것이 아니라, 네이티브 UIViewController의 콘텐츠 영역으로만 활용
- 네비게이션의 주도권은 네이티브가 갖고 세부 UI는 웹뷰가 처리
- 링크 클릭을 가로채서 웹의 새로고침을 막고 네이티브의 push를 실행
- decidePolicyFor메서드에서 .cancel 리턴
로그인 구현
- iOS에서 로그인을 구현하는 방법은 총 3가지다
- 어떤 방식으로 로그인을 하든 iOS 앱의 최종 목표는 서버가 발급한 JWT 토큰을 키체인으로 받아서, 네이티브 API 통신 시 헤더에 사용하는 것
- 직접 로그인을 토큰방식으로 구현한 방법
- 이방법은 로그인 정보를 서버로 보내면 서버는 access, refresh 토큰을 발급해주고
- 해당 토큰을 키체인에 들고있는다 그리고 뭔가 서버에 요청을 할때마다 해당 토큰을 키체인에서 확인한뒤 http 헤더에 넣어서 통신한다.
- 웹뷰로 로그인을 구현한 방법
- 그냥 웹이 로그인 다하고 결과물인 토큰만 앱으로 전달한다.
- 네이티브는 따로 할건 없고 이후 방법은 1과 같다.
- ASWebAuthenticationSession을 이용해서 로그인을 구현한다.
- OAuth2를 할 때 많이 사용하며 1회용 인증코드를 콜백으로 받으면 그 키를 가지고 서버랑 통신한다.
- ASWebAuthenticationSession을 띄워 구글/카카오 등의 로그인 웹페이지를 열고 로그인을 한다.
- 앱이 등록한 콜백 URL(딥링크)로 리다이렉트 시키면서 인증 코드를 url 쿼리 파라미터로 붙여줌
- 이 코드를 웹서버에 보내면 서버는 카카오 서버에 인증한 뒤 자체 JWT(JSON WEB TOKENS)를 발급
- 서버로부터 받은 토큰을 네이티브(앱)에 저장하고 활용하는 방식 이후 방법은 1과 같다.
- 네이티브에서 로그인을 구현했으면 웹뷰에 전달해줘야한다.
- 토큰방식 : localStorage.setItem(‘accessToken’, ‘…네이티브에서-꺼내온-토큰값…’);으로 웹뷰에 주입
- 세션방식 : WKWebsiteDataStore.default().httpCookieStore API를 사용해 이 쿠키를 웹뷰의 쿠키 저장소에 직접 삽입