iOS WebView Study

iOS 웹뷰(WKWebView) 학습

iOS 하이브리드 앱의 핵심인 WKWebView를 한 주제씩 배우고 문답으로 정리하는 학습 문서. 아키텍처 → 네비게이션 → JS 브리지 → 세션·보안 → 성능·통합 순으로 누적한다.

📋 학습 로드맵 (Tier별)

§1. WKWebView 기본 & 아키텍처

1) UIWebView는 왜 죽고 WKWebView로 갔나

UIWebView (사용 금지)WKWebView (WebKit, iOS 8+)
렌더링 위치앱 프로세스 안별도 프로세스(WebContent)
JS 속도느림(JIT 못 씀)빠름(Nitro JIT)
크래시 영향웹이 죽으면 앱도 위험웹만 죽고 앱은 생존
메모리앱 메모리 직접 압박별도 프로세스 격리
상태iOS 12 deprecated, 2020년~ App Store 거부표준

확정 사실: UIWebView는 심사 거부 대상. 웹뷰 = 무조건 WKWebView.

2) ⭐ 멀티 프로세스 아키텍처 (핵심)

┌─────────────────┐     ┌──────────────────────┐
│   App Process   │◄───►│  WebContent Process  │  ← 웹 렌더링 + JS 실행 (앱과 별도!)
│  (내 Swift 코드) │ IPC │  (JIT 허용 = 빠름)    │
│   WKWebView ────┼─────┤                      │
└─────────────────┘     └──────────────────────┘
        │               ┌──────────────────────┐
        └──────────────►│  Networking Process  │  ← 네트워크 로딩 (여러 웹뷰 공유)
                        └──────────────────────┘
                        ┌──────────────────────┐
                        │     GPU Process      │  ← 합성/렌더
                        └──────────────────────┘

이 구조의 실무적 결과 3가지:

왜 IPC를 알아야 하나: 앱↔웹 통신(JS 브리지·쿠키 주입)이 전부 프로세스 경계를 넘는 IPC비동기. evaluateJavaScript가 completion handler/async인 이유가 이것.

3) WKWebViewConfiguration — "생성 시점에만" 적용

import WebKit

let config = WKWebViewConfiguration()
config.defaultWebpagePreferences.allowsContentJavaScript = true
config.websiteDataStore = .default()          // 쿠키/캐시 저장소
// config.userContentController = ...          // JS 브리지 (§3에서)

let webView = WKWebView(frame: .zero, configuration: config)
⚠️ 핵심 함정: configurationinit(frame:configuration:) 그 순간에만 반영된다. 웹뷰를 만든 뒤에 webView.configuration.xxx를 바꿔도 적용 안 됨. 설정은 생성 전에 다 끝내야 한다.

담기는 대표 설정: defaultWebpagePreferences(JS 허용), websiteDataStore(쿠키·캐시), userContentController(JS 브리지), allowsInlineMediaPlayback, mediaTypesRequiringUserActionForPlayback.

4) processPool / websiteDataStore — 웹뷰끼리 세션 공유

5) 기본 로드 API

webView.load(URLRequest(url: URL(string: "https://example.com")!))   // 원격 URL
webView.loadHTMLString("<h1>hi</h1>", baseURL: nil)                   // 문자열 HTML
webView.loadFileURL(fileURL, allowingReadAccessTo: dirURL)           // 로컬 파일

관찰(KVO/프로퍼티): estimatedProgress(0~1 로딩바), title, url, isLoading, canGoBack/canGoForward.

한 줄 요약: WKWebView = "웹을 앱과 별도 프로세스에서 돌리는 뷰." 그래서 ① 크래시·메모리 격리, ② JIT로 빠른 JS, ③ 모든 앱↔웹 통신이 IPC(비동기). 설정은 생성 시점에만 먹는다.

✅ 체크 질문

  1. UIWebView 대신 WKWebView를 쓰면 크래시 관점에서 뭐가 좋아지나? → 별도 프로세스라 웹 크래시가 앱에 전파 안 됨
  2. evaluateJavaScript가 왜 동기 함수가 아니라 completion/async인가? → 프로세스 경계를 넘는 IPC라서
  3. 웹뷰를 만든 뒤에 webView.configuration을 바꾸면? → 소용없음(생성 시점에만 적용)

📌 §1 후속 Q&A

Q1. 멀티 프로세스 — 각자 담당과 나눈 이유

프로세스담당특징
App내 Swift/UIKit 코드. WKWebView 객체(뷰 껍데기)웹 콘텐츠 없음. JIT 금지
WebContentHTML 파싱·CSS 레이아웃·JS 실행·DOM(웹이 실제 사는 곳)웹뷰/사이트마다 하나 가능. 강한 샌드박스 + JIT 허용
Networking네트워크 요청·쿠키 저장소 관리여러 WebContent 공유
GPU렌더 결과 합성공유

나눈 이유: 보안(신뢰 못 할 웹 코드를 격리 샌드박스에), 안정성(WebContent 크래시·jetsam돼도 앱 생존 → webViewWebContentProcessDidTerminate 복구), 성능(WebContent만 JIT). App↔WebContent는 메모리 직접 공유 없이 IPC 메시지로만 통신.

Q2. "JIT 허용"이란?

JIT=Just-In-Time 컴파일. 실행 도중 자주 쓰는 JS를 그 자리에서 기계어로 컴파일해 빠르게 실행(Nitro). 이는 실행 중 새 기계어를 메모리에 써서 실행 → 쓰기+실행 권한 메모리가 필요. iOS는 W^X(쓰기 XOR 실행) 정책이라 일반 앱 프로세스는 JIT 불가(익스플로잇 방어). Apple은 WebContent 프로세스에만 특별 엔타이틀먼트로 JIT를 예외 허용하고 강하게 샌드박싱. → UIWebView(앱 프로세스)=JIT 없음 느림, WKWebView(WebContent)=JIT 빠름.

Q3. WKWebViewConfiguration는 클래스?

클래스(NSObject 상속, 참조 타입). 웹뷰 생성 설정을 담는 컨테이너 객체.

Q4. allowsContentJavaScript / "JS 허용"이란?

defaultWebpagePreferences(WKWebpagePreferences)의 allowsContentJavaScript(iOS 14+)=그 웹뷰가 로드하는 페이지의 JavaScript 실행 허용 여부. true=페이지 <script> 실행(정상 웹앱), false=JS 꺼짐(정적 HTML만, 보안↑). 구형 preferences.javaScriptEnabled의 후신. (페이지 자체 JS 얘기 — 브리지 주입/evaluateJavaScript와는 별개 축)

Q5. websiteDataStore = .default()란?

WKWebsiteDataStore=쿠키·캐시·localStorage·IndexedDB를 어디 저장할지. .default()=앱 기본 영구 저장소(디스크), 재실행해도 유지. .nonPersistent()=메모리만, 종료 시 소멸=시크릿.

Q6. "웹뷰끼리 쿠키·세션 공유"란?

웹뷰A 로그인 → 세션 쿠키 생성 (default 저장소)
       ↓ 같은 dataStore
웹뷰B도 그 쿠키를 봄 → 웹뷰B도 로그인 상태

같은 WKWebsiteDataStore를 쓰는 여러 웹뷰는 쿠키·localStorage를 공유(로그인 유지). 다른/nonPersistent면 격리. 앱 전체가 한 서비스면 .default() 공유로 로그인 1회 유지.

Q7. 영구 저장(디스크) 예시

Library/WebKit/… 디스크에 저장. .nonPersistent()면 메모리만이라 종료 시 소멸.

Q8. "모든 앱↔웹 통신이 IPC(비동기)"란?

App·WebContent가 별개 프로세스라 데이터를 직접 메모리 접근이 아니라 메시지(XPC)로 왕복 → 즉시 안 끝남 → 비동기. 그래서 앱↔웹 API가 전부 completion/async.

webView.evaluateJavaScript("document.title") { result, error in
    print(result as? String ?? "")   // 결과를 나중에 받음(동기 반환 불가)
}

같은 프로세스면 함수 호출로 즉시 받겠지만, 다른 프로세스라 메시지 왕복=비동기. WKWebView API가 completion 투성이인 근본 이유.

📌 §1 후속 — 웹/JS 기초 (네이티브 관점)

Q9. 웹뷰마다 WebContent 프로세스가 하나씩?

기본은 웹뷰당 하나(격리)지만 항상 1:1은 아니다. 같은 processPool/dataStore·같은 사이트면 공유·재활용, 시스템이 개수 상한도 둔다. "웹뷰 10개=프로세스 10개"가 아니라 WebKit이 상황 봐서 관리. 개념은 "웹뷰마다 독립 WebContent", 실제 개수는 WebKit이 알아서.

Q10. JS란? (HTML+서버 코드 아님)

역할iOS로 치면
HTML구조(뼈대) — 뭐가 있는지뷰 계층 / 스토리보드
CSS꾸밈(스타일) — 색·폰트·레이아웃오토레이아웃 + 뷰 속성
JavaScript동작(로직) — 이벤트·데이터·화면 변경Swift 코드

JS = 웹 페이지의 "Swift 코드". HTML(뷰)을 조작하고 이벤트 처리·계산·서버통신하는 로직 언어. "서버 쓰는 코드"가 아니라 브라우저(WebContent) 안에서 도는 프로그래밍 언어(서버통신은 JS가 하는 여러 일 중 하나).

Q11. IPC 메시지란?

프로세스는 각자 독립 메모리라 남의 메모리를 직접 못 본다(격리). 데이터를 주고받으려면 OS가 중개하는 메시지를 보내야 함 = IPC(iOS는 XPC/Mach).

같은 방(같은 프로세스)  → 바로 말함 = 함수 호출 (즉시)
다른 건물(다른 프로세스) → 편지/전화 = IPC 메시지 (왕복 시간↑, 비동기)

App이 WebContent에 "이 JS 실행해줘" 편지(IPC) → 실행 후 답장(결과). 왕복이라 비동기.

Q12. JIT는 "JS를 컴파일해서 웹뷰로 띄우는 것"인가?

아니다. JIT(컴파일)와 화면 표시는 별개. JIT는 오직 "JS를 빨리 실행"하기 위한 것. JS는 원래 인터프리터(한 줄씩 해석, 느림) → JIT는 자주 도는 JS 함수를 실행 중 기계어로 변환해 다음부턴 바로 실행(빠름). 화면 렌더링(HTML/CSS 레이아웃→픽셀, GPU 프로세스)은 완전히 다른 단계. 실행(로직) ≠ 렌더링(그리기).

Q13. "JavaScript의 실행"이 무엇?

JS 코드(로직)가 돌아가는 것: 버튼 클릭 처리, 서버 fetch, DOM 조작(화면 요소 추가/변경/삭제), 계산·분기. iOS로 치면 "Swift 코드가 실행돼 버튼 액션이 돌고 label.text를 바꾸는 것". (DOM=HTML 구조를 코드로 만지는 객체 트리 ≈ iOS 뷰 계층 트리)

Q14. 웹앱이란?

네이티브웹앱하이브리드
제작Swift/KotlinHTML+CSS+JS네이티브 껍데기 + 웹뷰
실행기기 설치브라우저/웹뷰앱 안 WKWebView
배포심사서버만 갱신(즉시)웹=즉시, 네이티브=심사

지금 배우는 게 하이브리드 앱 — 네이티브 앱 안에 WKWebView로 웹앱을 띄워 네이티브+웹 화면을 섞음. 웹 부분은 심사 없이 서버만 바꿔 즉시 배포되는 게 장점(이벤트·공지·결제 페이지 등).

Q15. 웹이 JS 없이 될 수도 있나? (Flutter 등)

브라우저(WebContent)가 이해하는 최종 실행 언어는 HTML/CSS/JS(+WebAssembly)뿐. React·Vue·Flutter Web 등은 다른 언어로 작성하지만 결국 JS(또는 WASM)로 컴파일되어 브라우저에서 돈다.

[작성]              [컴파일]           [브라우저 실제 실행]
React (JSX)   ──►                  ┐
Vue           ──►  JavaScript      ├─►  WebContent 프로세스
Flutter Web(Dart) ►  JS 또는 WASM  │    (HTML/CSS/JS/WASM만 이해)
Rust/C++      ──►  WebAssembly     ┘

(참고: Flutter 모바일 앱은 네이티브 컴파일되는 별개 얘기. 여기선 Flutter Web.)

Q16. "자주 도는 JS 함수"란? (hot path)

JIT는 모든 코드를 컴파일하지 않고 반복 실행되는(hot) 함수만 골라 기계어로 바꾼다(컴파일도 비용). 예: 매 프레임 애니메이션·스크롤 핸들러·수천 번 도는 계산. 반대로 1회 초기화 코드는 그냥 해석. JS 엔진이 실행 횟수를 카운트해 임계치 넘으면 컴파일(인터프리터→baseline JIT→optimizing JIT). 비유: 자주 다니는 길만 포장.

Q17. DOM 조작이란? (버튼 누르면 뷰 변화?)

맞다. JS로 화면 요소(DOM 노드)를 추가/삭제/변경하는 것.

button.addEventListener('click', () => {
    document.getElementById('title').textContent = '바뀐 제목';   // DOM 조작
});
JS DOM 조작iOS 대응
element.textContent = "..."label.text = "..."
container.appendChild(newItem)stackView.addArrangedSubview(...)
element.remove()view.removeFromSuperview()
element.style.display = "none"view.isHidden = true

DOM=HTML을 트리 객체로 표현 ≈ iOS 뷰 계층 트리. JS가 노드를 만지면 화면이 즉시 바뀜. 동적 웹(클릭 반응·실시간 갱신·무한스크롤)이 전부 DOM 조작. = iOS에서 Swift가 뷰 속성 바꾸는 것과 같은 역할.

§2. 네비게이션 제어 (WKNavigationDelegate)

먼저 헷갈리기 쉬운 구분 3:decidePolicyFor navigationAction(요청 전) vs navigationResponse(응답 후) — 둘 다 decidePolicyFor. ② provisional(커밋 전) vs committed(응답 확정) — 실패 콜백이 다름. ③ WKNavigationDelegate(로딩·네비게이션) vs WKUIDelegate(JS alert·새 창) — 새 창은 §4.

1) 두 "정책 결정" 메서드 (가로채기 핵심)

(a) decidePolicyFor navigationAction — 요청 나가기 전 허용/차단

가장 많이 쓰는 메서드. URL 가로채기가 여기서 일어난다(딥링크·외부 앱 분기).

func webView(_ webView: WKWebView,
             decidePolicyFor navigationAction: WKNavigationAction,
             decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
    guard let url = navigationAction.request.url else { decisionHandler(.cancel); return }
    switch url.scheme {
    case "myapp":                      // 커스텀 스킴 → 네이티브 라우팅
        route(to: url)                 // myapp://profile/123 → 네이티브 화면 push
        decisionHandler(.cancel)       // 웹뷰는 이 URL 로드 안 함
    case "tel", "mailto", "sms":       // 시스템 앱으로
        UIApplication.shared.open(url)
        decisionHandler(.cancel)
    default:
        decisionHandler(.allow)        // 일반 웹 페이지는 로드
    }
}

(b) decidePolicyFor navigationResponse — 응답 받은 뒤 허용/차단

func webView(_ webView: WKWebView,
             decidePolicyFor navigationResponse: WKNavigationResponse,
             decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
    if let http = navigationResponse.response as? HTTPURLResponse, http.statusCode >= 400 {
        decisionHandler(.cancel); showErrorPage(); return   // 에러 응답이면 표시 안 함
    }
    decisionHandler(.allow)
}

응답 기반(상태코드·MIME·다운로드 전환). action=나가기 전, response=받은 후. 시점이 다르다.

2) 로딩 생명주기

decidePolicyFor navigationAction   (허용 결정)
   ↓ .allow
didStartProvisionalNavigation      ← 시작 (아직 "잠정적")
   ↓ (서버 응답·리다이렉트)
didReceiveServerRedirectForProvisionalNavigation   (리다이렉트 시)
   ↓
decidePolicyFor navigationResponse (응답 허용 결정)
   ↓ .allow
didCommit                          ← 응답 확정, 콘텐츠 도착 시작
   ↓
didFinish                          ← 로드 완료 ✅

실패 경로: didFailProvisionalNavigation=커밋 전 실패(DNS·네트워크 없음·취소 등, 대부분의 로딩 에러) / didFail=커밋 후 실패(드묾) / webViewWebContentProcessDidTerminate=WebContent 크래시 → reload 복구.

"provisional(잠정적)": 네비게이션이 시작은 했지만 아직 서버 응답으로 확정(commit)되지 않은 상태. 이 단계 실패=didFailProvisionalNavigation, 확정(didCommit) 이후 실패=didFail. → 에러 처리는 주로 didFailProvisionalNavigation.

3) navigationType이 중요한 이유

사용자가 눌러 간 건지(.linkActivated) JS가 이동시킨 건지(.other) 구분해야 할 때. 예: "사용자가 직접 누른 외부 링크만 Safari로, JS 내부 이동은 웹뷰에 둠". .backForward는 가로채면 안 되는 경우 많음.

4) 뒤로가기 / 히스토리

5) async 버전 (iOS 15+)

func webView(_ webView: WKWebView,
             decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
    if await shouldBlock(navigationAction.request.url) { return .cancel }
    return .allow
}
한 줄 요약: WKNavigationDelegate = "어디로 갈지 허용/차단하고 로딩을 추적하는" 델리게이트. 핵심은 decidePolicyFor navigationAction에서 URL을 가로채 네이티브 라우팅/외부 앱으로 분기. 로딩 실패는 대부분 didFailProvisionalNavigation.

✅ 체크 질문

  1. myapp://profile/123 딥링크를 네이티브로 처리하려면 어느 메서드에서 무엇을 반환? → decidePolicyFor navigationAction에서 route 후 .cancel
  2. DNS 실패로 페이지가 안 뜰 때 콜백은? → didFailProvisionalNavigation(커밋 전)
  3. navigationAction.targetFrame == nil이면? → 새 창(target="_blank") 열려는 상황

📌 §2 후속 Q&A

Q1. "요청 나가기 전 허용/차단" = 앱 프로세스 판단 코드?

맞다. 웹뷰가 URL 이동을 시도하면 네트워크 요청 전에 WebKit이 델리게이트(앱 프로세스, 메인 스레드)에게 "가도 돼?"를 물음. 그래서 여기서 UIApplication.open·네이티브 push 같은 앱 코드를 부를 수 있다. = 웹의 이동을 앱이 가로채 판단하는 지점.

Q2. decisionHandler(.allow)의 의미

내 결정을 WebKit에 통보하는 콜백(반드시 1회 호출). .allow=진행 허용, .cancel=중단. 안 부르면 웹뷰가 멈춘다(대기 상태로 굳음). 콜백인 이유는 판단이 비동기일 수 있어서.

Q3. navigationResponse는 이동 후 에러 처리?

절반. 정확히는 "응답을 받은 뒤 표시할지 결정"(에러 처리는 그 용도 중 하나). 순서: navigationAction(요청 전) → 네트워크 → 서버 응답 헤더 → navigationResponse(상태코드·MIME 보고 .allow/.cancel/.download) → didCommit(표시 시작).

Q4~9. provisional / "시작" / "commit 확정"

[요청 보냄] ──provisional(임시)── [서버 응답 도착·표시 결정] ──committed(확정)── [완료]
 didStartProvisional              didCommit                        didFinish

Q5. 리다이렉트 콜백은 리다이렉트 안 하면?

안 불린다. didReceiveServerRedirectForProvisionalNavigation은 서버 리다이렉트(301/302) 때만. 직접 로드면 건너뜀(조건부 단계).

Q10. 확정(commit) 이후 실패는 어떻게?

didFail(커밋 후)은 드물지만: 페이지 커밋 후 본문 받는 중 연결이 끊기거나, 렌더 중 치명적 오류. "표시는 확정됐는데 끝까지 받다 중간에 깨진" 경우. 대부분 실패는 커밋 전(provisional)이라 didFailProvisionalNavigation이 잡음.

Q11. "JS가 프로그램으로 이동"이 가능?

자주 있다. JS가 사용자 클릭 없이 코드로 페이지 이동:

window.location.href = "https://other.com";   // 이동
window.location.replace("...");                // 히스토리 안 남기고 이동
setTimeout(() => location.href = "/next", 3000);  // 3초 뒤 자동 이동
document.forms[0].submit();                    // 폼 자동 제출

그래서 navigationType으로 구분: .linkActivated(사용자 탭) vs .other(JS 이동·초기 로드·리다이렉트). "직접 누른 외부 링크만 Safari, JS 내부 이동은 웹뷰에" 같은 정책 가능.

📌 §2 생명주기 — 정확한 순서 (정밀판)

흔한 착각 교정: decidePolicyFor navigationAction이 didStartProvisionalNavigation보다 먼저다("허용 결정 → 시작"). 그리고 리다이렉트 콜백은 navigationResponse보다 먼저(provisional 단계)다.
① decidePolicyFor navigationAction   ← 요청 전, 허용/차단 (제일 먼저!)
    ↓ .allow → 네트워크 요청 시작
② didStartProvisionalNavigation      ← 요청 보냄, provisional 시작
    ↓ (서버가 301/302 리다이렉트할 때만)
③ didReceiveServerRedirect…          ← 리다이렉트 시에만 (+ 대상 URL로 ① 다시)
    ↓ 최종 응답 헤더 도착
④ decidePolicyFor navigationResponse ← 응답 보고 표시/취소/다운로드 결정
    ↓ .allow
⑤ didCommit                          ← 표시 확정, 본문 받기 시작, 렌더 시작
    ↓ (본문·서브리소스·JS 로드/렌더)
⑥ didFinish                          ← 전부 완료 ✅

📌 §2 후속 — 리다이렉트/응답 시점 & 다운로드(WKDownload vs iOS14 blob 우회)

Q. WKNavigationAction이 담는 것 (URL + 유저가 어떻게 왔나)

필드담는 것
requestURLRequest — URL(request.url)·메서드·헤더
navigationType어떻게 왔나: .linkActivated(클릭)/.formSubmitted/.backForward/.reload/.formResubmitted/.other(JS·초기로드·리다이렉트)
sourceFrameWKFrameInfo — 이동을 시작한 프레임(항상 존재)
targetFrameWKFrameInfo? — 가려는 프레임. nil=새 창(target="_blank")
shouldPerformDownload(14.5+)다운로드로 처리돼야 하는지

URL=request.url, 유저 경로=navigationType, 새 창=targetFrame == nil. sourceFrame은 항상 있고 targetFrame만 nil 가능 — 이 비대칭이 새 창 감지 포인트.

Q. sourceFrame "어느 iframe/메인에서" 무슨 말?

iframe = 한 웹페이지 안에 박힌 독립된 미니 웹페이지(자기 URL·문서). iOS의 child VC/서브뷰 비슷. 예: 광고 iframe·유튜브 임베드·결제 위젯. sourceFrame(WKFrameInfo)은 이 이동을 메인 페이지에서 시작했는지 iframe에서 시작했는지 알려줌.

if navigationAction.sourceFrame.isMainFrame {
    // 메인 페이지(최상위)에서 발생
} else {
    // 박힌 iframe(광고·결제위젯 등)에서 발생
}

보안: "메인 링크는 허용, 제3자 광고 iframe 이동은 차단" 가능. WKFrameInfo=isMainFrame·request·securityOrigin.

Q. shouldPerformDownload "다운로드로 처리돼야 하는지"는 어떻게 아나?

시점판단 근거
navigationAction(요청 전)HTML download 속성 → shouldPerformDownload
navigationResponse(응답 후)서버 Content-Disposition: attachment / 표시 불가 MIME
<a href="/files/report.pdf" download>다운로드</a>   
// 응답 헤더 예: Content-Disposition: attachment; filename="report.pdf"

요청 전엔 서버 응답을 몰라 HTML 힌트(download 속성)만 앎 → shouldPerformDownload. 응답 후엔 서버 헤더로 판단. 실제 전환은 decisionHandler(.download) 반환. shouldPerformDownload는 "다운로드 같다"는 WebKit 힌트.

Q. shouldPerformDownload==true면 보통 어떻게 처리? (.download → WKDownloadDelegate)

맞다. .download 반환 → WKDownloadDelegate 플로우(iOS 14.5+).

// 1) 정책 — 다운로드 의도면 .download
func webView(_ w: WKWebView, decidePolicyFor a: WKNavigationAction,
             decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
    if #available(iOS 14.5, *), a.shouldPerformDownload { decisionHandler(.download) }
    else { decisionHandler(.allow) }
}
// 2) .download → WKDownload 획득 → delegate 연결
func webView(_ w: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
    download.delegate = self
}
// 3) WKDownloadDelegate — 저장 위치 지정(안 하면 저장 안 됨)
func download(_ d: WKDownload, decideDestinationUsing r: URLResponse,
              suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) {
    completionHandler(FileManager.default.temporaryDirectory.appendingPathComponent(suggestedFilename))
}
func downloadDidFinish(_ d: WKDownload) { /* 공유시트·QuickLook 미리보기 */ }

Q. 리다이렉트 콜백 순서 — 짝/개수 규칙

아니다(파라미터). didReceiveServerRedirect 파라미터는 WKNavigation(토큰)뿐. 리다이렉트 대상 URL은 decidePolicyFor navigationAction이 별도로 다시 호출됨(리다이렉트도 가로채기 가능).

핵심 규칙: 리다이렉트 N번 → navigationAction (N+1)개 + didReceiveServerRedirect N개 + navigationResponse 1개(최종 응답에만). navigationAction과 didReceiveServerRedirect가 짝(리다이렉트 1회)을 이룬다.
// 리다이렉트 1번 (A→B)
navigationAction(A) → didStartProvisional
  → navigationAction(B) → didReceiveServerRedirect     ← 리다이렉트 1회(짝)
  → navigationResponse(B) → didCommit → didFinish       ← 최종 응답에만 response

// 리다이렉트 2번 (A→B→C): action 3개, redirect 2개, response 1개
navigationAction(A) → didStartProvisional
  → navigationAction(B) → didReceiveServerRedirect
  → navigationAction(C) → didReceiveServerRedirect
  → navigationResponse(C) → didCommit → didFinish

중간 리다이렉트엔 (표시용) navigationResponse가 안 오고 최종(리다이렉트 완료) 응답에만 온다. ⚠️ navigationAction(리다이렉트)과 didReceiveServerRedirect의 정확한 앞뒤는 iOS 버전 따라 다를 수 있으나, 개수/짝 관계는 불변.

Q. navigationResponse 시점 = 데이터 다 옴? / didCommit이 왜 필요?

헤더(응답 메타)만 받은 시점. WKNavigationResponse.response=URLResponse(상태·헤더·MIME), 본문은 아직 스트리밍 중. navigationResponse=결정("표시할까"), didCommit=통보("표시 시작·옛 페이지 교체"). 역할이 달라 별개 이벤트(didCommit에서 로딩 UI·CSS 주입·이전 상태 정리).

Q. "다운로드 전환" = 알아서 저장? WKDownloadDelegate?

.download 반환 = 응답을 표시 대신 WKDownload로 전환WKDownloadDelegate로 넘김(iOS 14.5+). 알아서 저장 안 함decideDestinationUsing에서 저장 위치를 지정해야 실제 기록. iOS 14.0~14.4는 WKDownload 불가.

Q. ⭐ iOS 14(및 blob) 다운로드 우회 — JS 브릿지 base64

blob: URL은 JS가 메모리에 만든 임시 참조(URL.createObjectURL)라 네이티브가 직접 fetch 못 함. 그래서 JS 안에서 blob을 읽어 base64로 넘기고, 네이티브가 디코딩·저장:

// 주입 JS: blob → base64 → 네이티브 postMessage
async function downloadBlob(blobUrl, filename) {
    const res  = await fetch(blobUrl);          // 같은 JS 컨텍스트라 blob: fetch 가능
    const blob = await res.blob();
    const reader = new FileReader();
    reader.onloadend = () => {
        const base64 = reader.result.split(',')[1];   // data:...;base64,XXXX → XXXX
        window.webkit.messageHandlers.blobDownload.postMessage({ filename, base64, mime: blob.type });
    };
    reader.readAsDataURL(blob);
}
// 네이티브: 메시지 받아 디코딩 → 파일 저장
func userContentController(_ uc: WKUserContentController, didReceive message: WKScriptMessage) {
    guard message.name == "blobDownload",
          let d = message.body as? [String: Any],
          let b64 = d["base64"] as? String,
          let data = Data(base64Encoded: b64) else { return }
    let name = d["filename"] as? String ?? "file"
    let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
    try? data.write(to: url)
    // UIActivityViewController(activityItems: [url]) 로 저장/공유
}

다운로드 클릭 후킹: 주입 JS로 a[download] 클릭·blob: 이동을 가로채 downloadBlob() 호출(또는 decidePolicyFor에서 blob: 스킴 감지 → JS 호출). 이 postMessage 브릿지가 §3의 핵심. (14.5+였다면 .download + WKDownloadDelegate로 간단.)

§3. JS ↔ Native 브리지

두 방향: Native→JS = evaluateJavaScript(내가 JS 실행) / JS→Native = WKScriptMessageHandler+postMessage(웹이 나를 호출). 최대 함정 = add(self) retain cycle.

1) JS → Native: WKScriptMessageHandler

채널 이름을 정해 등록한다.

// 등록
let cc = WKUserContentController()
cc.add(self, name: "bridge")            // "bridge" 채널
config.userContentController = cc
// 웹(JS)
window.webkit.messageHandlers.bridge.postMessage({ action: "share", url: "https://..." });
// 네이티브 수신 (WKScriptMessageHandler)
func userContentController(_ uc: WKUserContentController, didReceive message: WKScriptMessage) {
    guard message.name == "bridge", let body = message.body as? [String: Any] else { return }
    switch body["action"] as? String {
    case "share": presentShareSheet(body["url"] as? String)
    default: break
    }
}
JS 값Swift(message.body)
number/boolNSNumber
stringNSString
objectNSDictionary
arrayNSArray
nullNSNull

커스텀 타입 불가 → JSON 직렬화 가능한 딕셔너리로 주고받음.

2) ⭐ Retain Cycle 함정 (ARC 학습이 여기서 터짐)

WKWebView → configuration → userContentController → (강한 참조) messageHandler(self=VC)
   ↑                                                                    │
   └──────────────── VC가 webView 소유 ←────────────────────────────────┘

WKUserContentController가 핸들러(self)를 강하게 잡음 → VC가 영원히 deinit 안 됨(릭). 해결: WeakScriptMessageHandler 래퍼(실제 핸들러를 weak로).

final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
    weak var delegate: WKScriptMessageHandler?
    init(_ delegate: WKScriptMessageHandler) { self.delegate = delegate }
    func userContentController(_ uc: WKUserContentController, didReceive m: WKScriptMessage) {
        delegate?.userContentController(uc, didReceive: m)   // 약하게 포워딩
    }
}
// cc.add(WeakScriptMessageHandler(self), name: "bridge")

추가 필수: deinit에서 removeScriptMessageHandler(forName:). 안 하면 핸들러 잔존/같은 이름 재등록 시 크래시(NSInvalidArgumentException). = 지난 ARC 학습(보관되는 객체가 self를 강하게 잡으면 릭)과 동일 원리, 여기선 WKUserContentController가 그 객체.

3) Native → JS: evaluateJavaScript

webView.evaluateJavaScript("document.title") { result, error in
    print(result as? String ?? "")     // 결과는 나중에(IPC 비동기)
}
// iOS 14+: 인자 안전 전달 + async
let r = try await webView.callAsyncJavaScript(
    "return greet(name);", arguments: ["name": "Kim"], contentWorld: .page)

결과도 JSON 직렬화 가능해야 브리지로 넘어옴. 문자열 조합으로 값 넣기는 injection 위험 → callAsyncJavaScript의 arguments로 안전 전달.

4) WKUserScript — JS 미리 주입

let js = "window.nativeBridge = { post: (m) => window.webkit.messageHandlers.bridge.postMessage(m) };"
let script = WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: true)
cc.addUserScript(script)

5) 요청-응답(양방향) 패턴

postMessage는 단방향(웹→네이티브)이라 응답 필요 시: iOS 14+ WKScriptMessageHandlerWithReply(네이티브가 JS에 값 반환, JS쪽 Promise) / 구형·범용: JS가 callbackId 함께 전송 → 네이티브가 evaluateJavaScript("window.cb['id'](result)")로 콜백 호출.

6) 보안

메시지 핸들러는 로드된 모든 콘텐츠(제3자 iframe 포함)에 노출 → forMainFrameOnly·origin 검증·App-Bound Domains(§7). JS 입력을 믿지 말고 검증(네이티브 기능 여는 통로).

한 줄 요약: JS→Native=postMessage+WKScriptMessageHandler(채널 이름), Native→JS=evaluateJavaScript. 최대 함정=add(self) retain cycle → WeakScriptMessageHandler + deinit에서 removeScriptMessageHandler.

✅ 체크 질문

  1. 웹이 postMessage 부르면 네이티브 어느 메서드가 받나? 채널 이름 확인은? → userContentController(_:didReceive:), message.name
  2. add(self, name:)가 왜 릭? 어떻게 푸나? → ContentController가 self 강한 참조 → 순환. WeakScriptMessageHandler + removeScriptMessageHandler
  3. 브리지 헬퍼를 페이지 스크립트보다 먼저 깔려면? → WKUserScript .atDocumentStart

§4. WKUIDelegate (JS 대화상자·새 창·파일 선택)

구분: WKNavigationDelegate(어디로·로딩) vs WKUIDelegate(웹이 띄우는 UI: JS 대화상자·새 창·파일). §2에서 targetFrame==nil로 새 창을 감지만 했다면 실제로 여는 건 여기.

1) ⭐ JS 대화상자 — 구현 안 하면 무시됨

WKWebView는 JS alert/confirm/prompt를 기본으로 안 띄운다. WKUIDelegate로 네이티브 얼럿을 직접 띄워야 함(안 하면 조용히 무시).

// alert → 확인만, completionHandler()
func webView(_ w: WKWebView, runJavaScriptAlertPanelWithMessage m: String,
             initiatedByFrame f: WKFrameInfo, completionHandler: @escaping () -> Void) {
    let a = UIAlertController(title: nil, message: m, preferredStyle: .alert)
    a.addAction(UIAlertAction(title: "확인", style: .default) { _ in completionHandler() })
    present(a, animated: true)
}
// confirm → Bool
func webView(_ w: WKWebView, runJavaScriptConfirmPanelWithMessage m: String,
             initiatedByFrame f: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
    let a = UIAlertController(title: nil, message: m, preferredStyle: .alert)
    a.addAction(UIAlertAction(title: "취소", style: .cancel) { _ in completionHandler(false) })
    a.addAction(UIAlertAction(title: "확인", style: .default) { _ in completionHandler(true) })
    present(a, animated: true)
}
// prompt → String?
func webView(_ w: WKWebView, runJavaScriptTextInputPanelWithPrompt p: String,
             defaultText: String?, initiatedByFrame f: WKFrameInfo,
             completionHandler: @escaping (String?) -> Void) {
    let a = UIAlertController(title: nil, message: p, preferredStyle: .alert)
    a.addTextField { $0.text = defaultText }
    a.addAction(UIAlertAction(title: "확인", style: .default) { _ in completionHandler(a.textFields?.first?.text) })
    present(a, animated: true)
}

completionHandler 반드시 호출 — 안 부르면 JS가 그 자리에서 영원히 멈춤(alert은 동기 API라 응답 대기). 반환: alert=(), confirm=Bool, prompt=String?.

2) ⭐ 새 창 (target="_blank") — createWebViewWith

func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
             for navigationAction: WKNavigationAction,
             windowFeatures: WKWindowFeatures) -> WKWebView? {
    if navigationAction.targetFrame == nil {          // 새 창 요청(§2)
        webView.load(navigationAction.request)        // ★ 흔한 처리: 같은 웹뷰에서 열기
    }
    return nil                                         // nil = 별도 웹뷰 안 만듦
}

3) 창 닫기 — webViewDidClose

func webViewDidClose(_ webView: WKWebView) { dismissPopup(webView) }  // JS window.close()

4) 파일 업로드 (<input type="file">)

iOS는 자동 처리 — WKWebView가 문서/사진 피커를 알아서 띄움(델리게이트 불필요). (macOS만 runOpenPanelWith 구현.)

5) 기타

미디어 권한(iOS 15+): requestMediaCapturePermissionFor(카메라/마이크, getUserMedia). 롱프레스 미리보기(iOS 13+): contextMenuConfigurationForElement.

한 줄 요약: WKUIDelegate = 웹이 띄우는 UI 담당. JS alert/confirm/prompt는 여기 구현 안 하면 무시(completionHandler 필수), 새 창은 createWebViewWith에서 "같은 웹뷰 로드" 또는 "팝업 생성(넘어온 config로)". 파일 업로드는 자동.

✅ 체크 질문

  1. alert("저장됨")이 안 뜬다. 왜/어떻게? → WKUIDelegate runJavaScriptAlertPanel 미구현 → 구현+present
  2. target="_blank"를 같은 웹뷰에서 열려면? → createWebViewWith에서 webView.load(request); return nil
  3. confirm completionHandler 안 부르면? → JS가 그 자리에서 영원히 멈춤

§5. 쿠키·세션 공유 (WKHTTPCookieStore)

실무 빈출: "네이티브에서 로그인했는데 웹뷰도 로그인 상태로 만들려면?" 핵심은 웹뷰 쿠키 저장소가 URLSession과 분리돼 있다는 것.

1) ⭐ 왜 문제냐

네이티브 로그인(URLSession) 쿠키 → HTTPCookieStorage. 웹뷰 쿠키 → WKWebsiteDataStore.httpCookieStore. 두 저장소가 별개라 네이티브 로그인해도 웹뷰는 로그아웃. → 쿠키를 명시적으로 주입해야 함.

2) WKHTTPCookieStore

let store = webView.configuration.websiteDataStore.httpCookieStore
store.setCookie(cookie) { /* 완료 */ }       // 주입
store.getAllCookies { cookies in ... }        // 읽기
store.add(observer)                            // 변경 관찰(WKHTTPCookieStoreObserver)

전부 비동기(IPC).

3) ⭐ 네이티브 세션 주입 + 타이밍 함정

let cookie = HTTPCookie(properties: [
    .domain: ".example.com",   // 앞 점 = 서브도메인 전체
    .path: "/", .name: "sessionId", .value: token,
    .secure: true, .expires: Date().addingTimeInterval(3600)
])!
let store = webView.configuration.websiteDataStore.httpCookieStore
store.setCookie(cookie) {
    webView.load(request)      // ★ 주입 "완료 후" 로드
}
⚠️ 타이밍 함정: setCookie는 비동기. 완료를 안 기다리고 바로 load하면 첫 요청에 쿠키가 안 실려 비로그인. → completion 안에서 load(여러 쿠키면 DispatchGroup).

4) 다른 방법과 비교 (WKHTTPCookieStore가 정석)

방법한계
URLRequest에 Cookie 헤더 수동그 요청 1번만. 이후 네비게이션·서브리소스엔 안 붙음
WKUserScript로 document.cookie=HttpOnly 쿠키는 JS로 못 넣음
WKHTTPCookieStore.setCookie정석. HttpOnly 포함 모든 쿠키, 이후 요청에도 유지

핵심 포인트: HttpOnly 세션 쿠키는 document.cookie로 못 넣으니 WKHTTPCookieStore를 써야 한다.

5) 반대 방향 & 여러 웹뷰

6) 알아둘 것

WKHTTPCookieStore는 iOS 11+(이전엔 WKProcessPool 트릭). 속성: .domain(앞 점=서브도메인)·.secure(HTTPS만)·HttpOnly·SameSite·.expires(없으면 세션 쿠키=종료 시 소멸).

한 줄 요약: 웹뷰 쿠키 저장소는 URLSession과 분리 → 네이티브 세션을 WKHTTPCookieStore.setCookie로 주입, 주입 완료 후 load(타이밍 함정). HttpOnly라 JS론 못 넣으니 이게 정석.

✅ 체크 질문

  1. 네이티브 로그인했는데 웹뷰가 로그아웃? → 웹뷰 쿠키 저장소가 URLSession과 분리
  2. 주입 후 바로 load했더니 첫 로드 비로그인? → setCookie 비동기, 완료 전 load됨 → completion에서 load
  3. document.cookie로 세션 쿠키가 안 들어감? → HttpOnly라 JS 접근 불가 → WKHTTPCookieStore 사용

📌 §5 후속 Q&A

Q1. 네이티브 로그인 → URLSession 쿠키 자동 처리?

맞다. 로그인 응답의 Set-Cookie → URLSession이 자동으로 HTTPCookieStorage에 저장 → 이후 같은 도메인 API 호출 시 자동으로 Cookie 헤더 부착(수동 X).

Q2. setCookie는 네이티브 쿠키를 웹뷰 저장소에 넣는 것?

맞다. 네이티브 HTTPCookieStorage의 세션 쿠키를 꺼내 웹뷰 httpCookieStore.setCookie로 복사 → 웹뷰도 로그인.

Q3. 전부 비동기인 이유 = 웹→네이티브 읽기라서?

아니다. 방향 때문이 아니라 쿠키 저장소가 별도 프로세스(WebKit 네트워킹 프로세스)에 있어서. 읽든 쓰든 앱 프로세스 접근은 프로세스 경계 IPC → 비동기.

Q4. URLRequest Cookie 헤더 — 웹뷰가 URLRequest 씀?

var request = URLRequest(url: url)
request.setValue("sessionId=xxx", forHTTPHeaderField: "Cookie")
webView.load(request)   // 웹뷰 로드는 URLRequest 사용

한계: 이 헤더는 첫 요청에만. 이후 링크·리다이렉트·서브리소스는 WebKit이 스스로 만들어 안 붙음 → 세션 유지 안 됨.

Q5. HttpOnly 쿠키란?

서버 보안 플래그(Set-Cookie: ...; HttpOnly). JS가 못 읽고 못 씀(document.cookie 불가) → XSS 세션 탈취 방지. 세션 쿠키는 보통 HttpOnly → JS 주입 불가 → HTTP/네이티브 레벨 WKHTTPCookieStore로만.

Q6. cookiesDidChange 사용 예시

final class WebCoordinator: NSObject, WKHTTPCookieStoreObserver {
    func setup(_ webView: WKWebView) {
        webView.configuration.websiteDataStore.httpCookieStore.add(self)
    }
    func cookiesDidChange(in store: WKHTTPCookieStore) {
        store.getAllCookies { cookies in
            if let s = cookies.first(where: { $0.name == "sessionId" }) {
                HTTPCookieStorage.shared.setCookie(s)   // 웹 로그인 → 네이티브 반영
            } else {
                self.handleLogout()                     // 세션 쿠키 사라짐 → 네이티브도 로그아웃
            }
        }
    }
}

대표: 웹 화면 로그아웃 → 쿠키 삭제 → cookiesDidChange → 네이티브도 로그아웃(불일치 방지).

Q7. ⭐ WKWebsiteDataStore 하나로 네이티브+여러 웹뷰 공유?

오해 정정: WKWebsiteDataStore 공유는 웹뷰끼리만. 네이티브(URLSession)=HTTPCookieStorage, 웹뷰=WKWebsiteDataStore.httpCookieStore로 서로 다른 저장소 → 네이티브↔웹뷰 자동 공유 안 됨.
[웹뷰A] ┐
[웹뷰B] ┼─ 같은 WKWebsiteDataStore → 자동 공유 ✅
[웹뷰C] ┘
[네이티브 URLSession] ─ HTTPCookieStorage (별도!) → setCookie 주입/Observer 동기화 필요

네이티브↔웹뷰는 setCookie 주입(Q1~3)·Observer 동기화(Q6)로 수동 연결 — 이게 §5 전체의 이유.

Q8. HTTPCookieStorage가 URLSession 안에 있나?

"안에 있다"보다 "URLSession이 쓴다". HTTPCookieStorage는 독립된 시스템 쿠키 저장소(기본 .shared 싱글톤). URLSession은 기본적으로 이걸 쓰도록 설정됨(URLSessionConfiguration.httpCookieStorage). 비유: 저장소=냉장고, URLSession=그 냉장고를 쓰는 사람.

Q9. "같은 도메인" = 같은 API 호출?

아니다. 도메인(호스트)이지 엔드포인트가 아니다. 쿠키는 도메인(+경로) 단위로 붙는다.

sessionId (domain: example.com, path: /)
 → example.com/api/profile   ✅   → example.com/api/orders  ✅   → example.com/api/settings ✅
 → other.com/api/x           ❌ (다른 도메인)

엔드포인트가 달라도 같은 도메인 아래 아무 API나 쿠키가 붙는다.

Q10. "세션 유지"란?

세션=서버가 기억하는 "이 사용자는 로그인함" 상태(sessionId로 식별). 세션 유지=그 상태가 요청을 건너 계속 살아있음. 쿠키가 매 요청에 붙으면 서버가 계속 로그인으로 인식(재로그인 불필요), 안 붙으면 로그아웃 취급.

webView.load(request) 첫 요청 → Cookie 헤더 O → 로그인 ✅
그 뒤 링크 클릭·이미지 로드 → WebKit이 만든 요청 → Cookie 헤더 X → 비로그인 ❌
// = 첫 화면만 로그인, 이후 끊김. WKHTTPCookieStore에 넣어야 모든 요청에 붙어 유지

Q11. HttpOnly — 핵심 (누가 쿠키를 만지나)

계층HttpOnly 쿠키 접근
HTTP 계층(URLSession·WKHTTPCookieStore·브라우저 네트워크)✅ 정상(저장·전송)
JS 계층(document.cookie)❌ 차단(읽기·쓰기 불가)

서버 Set-Cookie: sessionId=abc; HttpOnly → URLSession(HTTP)은 지장 없이 저장·전송하지만 웹페이지 JS는 못 봄(XSS로 세션 탈취 방지). 그래서 세션 쿠키(보통 HttpOnly)를 웹뷰에 넣을 때 document.cookie(JS)론 실패 → HTTP 계층인 WKHTTPCookieStore로만 가능. 한 줄: HttpOnly=HTTP 계층만 다루고 JS는 못 본다 → 웹뷰 주입은 WKHTTPCookieStore.

Q12. WKHTTPCookieStore에 넣으면 URLSession처럼 자동으로 붙나?

그렇다 — 완전히 같은 원리. URLSession의 HTTPCookieStorage처럼 WKWebsiteDataStore.httpCookieStore도 웹뷰의 "cookie jar". 한 번 넣으면 WebKit이 도메인·경로 맞는 모든 요청(첫 로드·링크·리다이렉트·서브리소스 이미지/JS/XHR)에 자동 부착.
방식범위
URLRequest Cookie 헤더(수동)그 요청 1번(임시방편) → 첫 화면만 로그인
WKHTTPCookieStore(저장소 주입)이후 모든 요청 자동(세션 유지)

둘 다 HTTP 계층의 쿠키 처리라 자동 첨부 성질은 같고, 차이는 한 번(요청)이냐 상시(저장소)냐.

§9. 웹뷰 성능

질문 형태: "웹뷰 느린데 어떻게 개선?" → 먼저 병목 측정 → 병목별 해결 구조로 답하는 게 핵심.

1) ⭐ 왜 느린가 — 3가지 병목

웹뷰 화면 진입
 ① 프로세스 cold start — WebContent 프로세스 처음 띄우는 비용
 ② 네트워크           — HTML·CSS·JS 다운로드
 ③ 파싱·실행·렌더     — JS 파싱/실행, 레이아웃, 페인트
= 순차로 쌓여 "첫 화면까지 오래"

측정 먼저: estimatedProgress(KVO), didStartProvisional→didFinish 시간, 웹 performance.timing(Navigation Timing), os_signpost.

2) ① cold start — 프리워밍 & 재사용 (임팩트 최대)

// 진입 전 미리 생성 + 워밍업
let webView = WKWebView(frame: .zero, configuration: sharedConfig)
webView.load(URLRequest(url: URL(string: "about:blank")!))   // WebContent 프로세스 기동
// 진입 시 준비된 webView에 실제 URL 로드

3) ② 네트워크 — 캐시·프리페치·페이로드

4) ③ 렌더 & 체감 개선

5) WebContent 프로세스 관리 (안정성=성능)

메모리 압박으로 프로세스 종료 시 흰 화면 → webViewWebContentProcessDidTerminate에서 reload 복구. 안 쓰는 웹뷰 해제·개수 제한·백그라운드 정리.

답변 구조: 병목 측정(프로세스/네트워크/렌더) → ① cold start=프리워밍·재사용·processPool, ② 네트워크=캐시·프리페치·페이로드·차단, ③ 체감=네이티브 셸/스켈레톤, + 프로세스 종료 대비 reload.

✅ 체크 질문

  1. 첫 진입이 유독 느리다. 먼저 의심할 병목·해결? → cold start → 프리워밍/재사용/processPool
  2. 프리워밍은 뭘 미리? → 웹뷰 미리 생성 + about:blank 로드로 WebContent 프로세스 기동
  3. 실제 오래 걸릴 때 빠르게 느껴지게? → 네이티브 셸/스켈레톤 먼저 + 진행바

📌 §9 후속 — about:blank는 뭘 데우나 & WebContent 프로세스

Q. about:blank 로드는 왜? 프로세스만 켜면 되지 않나? 실제 페이지는 내용이 다른데?

첫 로드 비용은 두 종류인데, about:blank는 (A) 고정 인프라 비용만 데운다:

첫 로드 총비용 =
 (A) 고정 인프라(페이지 무관): WebContent 프로세스 기동·WebKit dylib 매핑·
     JS 엔진 초기화(JIT)·IPC 채널 수립·렌더 파이프라인 셋업
 (B) 페이지별: 네트워크 다운로드 + 파싱 + 실행 + 레이아웃 + 페인트

Q. WebContent 프로세스란?

항목내용
하는 일HTML 파싱·DOM·CSS 레이아웃·JS 실행(JavaScriptCore/Nitro JIT)·페인트(합성은 GPU 프로세스)
개수대략 웹뷰당 하나(WebKit이 공유·재사용·상한 관리)
샌드박스강한 격리(신뢰 못 할 웹 코드 실행)
주소공간앱과 완전 분리 → 크래시·메모리 격리
통신앱과 IPC(XPC)로만(§1·§3 브리지가 이 위)
종료메모리 압박 시 jetsam → 흰 화면 → webViewWebContentProcessDidTerminate → reload

이 프로세스의 cold start 비용(exec·샌드박스·dylib 매핑·JS 엔진 초기화)이 (A)이고, 프리워밍이 없애려는 게 정확히 이것.

§7. 웹뷰 보안

방어 3층 구분: App-Bound Domains(강력 API를 내 도메인만 잠금) / forMainFrameOnly(주입을 메인 프레임으로 제한) / origin 검증(런타임에 메시지 출처 확인).

0) 개념 정정 — 노출 범위·iframe

0-2) 용어 — userContentController / 프레임 계층 / postMessage 방향

1) 왜 위험한가

브리지·evaluateJavaScript·쿠키 주입은 네이티브 기능을 여는 강력한 통로. 메시지 핸들러는 로드된 모든 콘텐츠(제3자 iframe·주입 스크립트 포함)에 노출 → 악성/변조 웹이 postMessage로 내 기능 호출 가능. 원칙: 신뢰 도메인만, 검증된 메시지만.

2) ⭐ App-Bound Domains (iOS 14+) — 강력 API 잠금

<!-- Info.plist -->
<key>WKAppBoundDomains</key>
<array><string>example.com</string><string>app.example.com</string></array>  <!-- 최대 10 -->
config.limitsNavigationsToAppBoundDomains = true

켜면 app-bound 도메인에서만 evaluateJavaScript·메시지 핸들러·쿠키 조작·커스텀 스킴이 동작(다른 도메인선 비활성). 목적: 특권 브리지가 아무 웹에서나 악용되는 걸 원천 차단. 하이브리드 앱(내 도메인만)에 적합. 트레이드오프: 브라우저처럼 임의 URL 여는 앱엔 부적합(그 도메인들에서 API가 막힘).

3) ⭐ origin 검증 (런타임)

func userContentController(_ uc: WKUserContentController, didReceive message: WKScriptMessage) {
    let origin = message.frameInfo.securityOrigin        // 스킴+호스트+포트
    guard message.frameInfo.isMainFrame,                  // 메인 프레임만
          origin.host == "example.com", origin.protocol == "https" else { return }  // 제3자 iframe·비신뢰 무시
    guard let body = message.body as? [String: Any],
          let action = body["action"] as? String,
          allowedActions.contains(action) else { return }  // 액션 화이트리스트 + 입력 검증
    handle(action, body)
}

4) forMainFrameOnly · WKContentRuleList · 기타

5) 방어 계층

① 로드: App-Bound Domains(강력 API를 내 도메인만) + ATS(HTTPS)
② 런타임(실질 게이트): origin 검증(frameInfo.isMainFrame·host·https) + 액션 화이트리스트 + 입력 검증
③ 주입 보조: forMainFrameOnly(헬퍼만 메인 프레임 — 원본 채널은 못 막음, 단독 불충분)
④ 콘텐츠: WKContentRuleList(차단/https 업그레이드)
※ 원본 messageHandlers는 모든 프레임 기본 노출 → iframe 차단은 origin 검증·App-Bound Domains·Content World로
한 줄 요약: 웹뷰 보안 = "신뢰 도메인만, 메인 프레임만, 검증된 메시지만". App-Bound Domains로 강력 API를 내 도메인 잠금, forMainFrameOnly로 iframe 배제, frameInfo로 origin 검증+액션 화이트리스트. 비밀값은 JS로 안 넘김.

✅ 체크 질문

  1. 제3자 iframe이 내 postMessage 브리지 호출 막는 2가지? → forMainFrameOnly(주입 배제) + frameInfo origin 검증
  2. App-Bound Domains 켜면 지정 안 한 도메인선 뭐가 막히나? → evaluateJavaScript·메시지 핸들러·쿠키 조작·커스텀 스킴(브라우저형 앱엔 부적합)
  3. 브리지 메시지 받을 때 검증 2가지? → origin(메인 프레임·호스트·https) + 액션 화이트리스트/입력 검증

§10. SwiftUI 통합 (UIViewRepresentable · Coordinator)

질문: "SwiftUI에서 WKWebView(UIKit 뷰)를 어떻게 쓰나?" WKWebView는 UIKit이라 UIViewRepresentable(UIView)/UIViewControllerRepresentable(VC)로 감싼다.

1) 세 메서드 + Coordinator

struct WebView: UIViewRepresentable {
    let url: URL
    @Binding var isLoading: Bool

    func makeUIView(context: Context) -> WKWebView {          // (1) 딱 1번 생성
        let webView = WKWebView()
        webView.navigationDelegate = context.coordinator
        return webView
    }
    func updateUIView(_ webView: WKWebView, context: Context) { // (2) 매 갱신 호출
        if webView.url != url { webView.load(URLRequest(url: url)) }  // ★ 값 같으면 스킵(불필요 reload 방지)
    }
    func makeCoordinator() -> Coordinator { Coordinator(self) }  // (3) delegate 브릿지
    static func dismantleUIView(_ webView: WKWebView, coordinator: Coordinator) { webView.stopLoading() }  // (4) 정리

    final class Coordinator: NSObject, WKNavigationDelegate {
        let parent: WebView
        init(_ parent: WebView) { self.parent = parent }
        func webView(_ w: WKWebView, didStartProvisionalNavigation n: WKNavigation!) { parent.isLoading = true }
        func webView(_ w: WKWebView, didFinish n: WKNavigation!) { parent.isLoading = false }
    }
}

2) ⭐ 생명주기 — SwiftUI 학습과 연결

RepresentableSwiftUI 개념
makeUIView — 딱 1번@State/@StateObject — 1번 생성·유지
updateUIView — 매 갱신body 재실행
Coordinator·WKWebView 재생성 안 됨struct(Representable)는 값이라 재생성되지만 SwiftUI가 identity에 묶어 유지

Representable struct는 매 업데이트 재생성(값)되지만 WKWebView·Coordinator는 SwiftUI가 보관해 유지 → "struct 재생성돼도 상태 유지"가 여기서도. 그래서 makeUIView 웹뷰가 재렌더에도 안 날아감.

3) Coordinator를 왜 class로?

Representable은 값 타입(struct)이라 delegate 대상으론 부적합(재생성·복사). Coordinator(class)가 delegate·메시지 핸들러의 안정적 소유자 → UIKit 콜백을 SwiftUI @Binding/클로저로 브릿지. §3 retain cycle 함정 적용: 메시지 핸들러는 WeakScriptMessageHandler + dismantleUIView에서 removeScriptMessageHandler.

4) 함정

한 줄: SwiftUI에서 UIKit 뷰=UIViewRepresentable. makeUIView(1번=@State처럼)·updateUIView(매 갱신=body처럼)·Coordinator(class, delegate 브릿지, 재생성 안 됨). 값 같으면 updateUIView 스킵, 정리는 dismantleUIView.

✅ 체크 질문

  1. makeUIView/updateUIView 언제 몇 번? SwiftUI 개념으론? → makeUIView 1번(@State처럼)·updateUIView 매 갱신(body처럼)
  2. Coordinator를 왜 class로? → Representable은 struct(재생성)라 delegate 부적합, class Coordinator가 안정적 소유자·브릿지
  3. updateUIView에서 매번 load하면? → 리렌더마다 재로딩 → 값 같으면 스킵해야

관련 문서

ARC 생명주기 (JS 브리지 retain cycle 대비)
SwiftUI some View 심화 (UIViewRepresentable 통합 대비)