webview

개요

내가 별로 안좋아하는 웹뷰

그 웹뷰에 대해서 알아보자



웹 페이지의 생명주기 (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를 주입할 때
  • websiteDataStore : 웹뷰의 메모리
    • WKWebsiteDataStore.default(): 기본값, 앱 내의 모든 wkwebview가 공유하는 저장소(디스크)
      • 웹뷰A에서 로그인하면 default에 쿠키가 저장되고 웹뷰B로 띄워도 로그인 세션이 유지됨
    • WKWebsiteDataStore.nonPersistent() : 시크릿모드, 디스크가 아닌 메모리에만 저장됨
      • 인스턴스가 메모리에서 해제되면 데이터 모두 삭제, 일회용 세션에서 자주사용
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class ScriptLeakAvoider: NSObject, WKScriptMessageHandler {
    weak var delegate: WKScriptMessageHandler?
    init(delegate: WKScriptMessageHandler) {
        self.delegate = delegate
        super.init()
    }

    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        self.delegate?.userContentController(userContentController, didReceive: message)
    }
}


class SchemeLeakAvoider : NSObject, WKURLSchemeHandler {
    weak var delegate : WKURLSchemeHandler?
    init (delegate:WKURLSchemeHandler) {
        self.delegate = delegate
        super.init()
    }
    
    func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
        self.delegate?.webView(webView, start: urlSchemeTask)
    }
    
    func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
        self.delegate?.webView(webView, stop: urlSchemeTask)
    }
}


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를 사용해 이 쿠키를 웹뷰의 쿠키 저장소에 직접 삽입