iOS 웹뷰(WKWebView) 학습
iOS 하이브리드 앱의 핵심인 WKWebView를 한 주제씩 배우고 문답으로 정리하는 학습 문서. 아키텍처 → 네비게이션 → JS 브리지 → 세션·보안 → 성능·통합 순으로 누적한다.
📋 학습 로드맵 (Tier별)
- Tier 1 — 뼈대
- ① WKWebView 기본·아키텍처 §1 정리됨
- ② 네비게이션 제어 (WKNavigationDelegate) §2 정리됨
- ③ JS ↔ Native 브리지 (WKScriptMessageHandler·evaluateJavaScript·WKUserScript·retain cycle 함정) §3 정리됨
- ④ WKUIDelegate (JS alert·새 창·파일 선택) §4 정리됨
- Tier 2 — 세션·인증·보안
- ⑤ 쿠키·세션 공유 (WKHTTPCookieStore) §5 정리됨
- ⑥ 인증 (challenge·서버 신뢰·피닝) 예정
- ⑦ 보안 (App-Bound Domains·WKContentRuleList) §7 정리됨
- ⑧ 캐시·데이터 관리 (WKWebsiteDataStore) 예정
- Tier 3 — 성능·통합·디버깅
- ⑨ 성능 (프리워밍·processPool 공유) §9 정리됨
- ⑩ SwiftUI 통합 (UIViewRepresentable·Coordinator) §10 정리됨
- ⑪ 스크롤·UI (scrollView·키보드·다크모드) 예정
- ⑫ 다운로드/업로드 (WKDownloadDelegate) 예정
- ⑬ 디버깅 (Safari Web Inspector·isInspectable) 예정
§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가지:
- 크래시 격리 — 웹 페이지(JS·렌더)가 죽어도 앱은 생존. 웹뷰만 하얗게 뜸 →
webViewWebContentProcessDidTerminate로 감지 후reload복구. - 메모리 격리 — 웹 콘텐츠 메모리는 WebContent 프로세스에 잡혀 앱 메모리 압박 완화(단 시스템 전체론 여전히 소비, jetsam 대상).
- JIT로 빠른 JS — 앱 프로세스는 보안상 JIT 금지, WebContent 프로세스는 JIT 허용 엔타이틀먼트 → JS 빠름(Nitro).
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)
configuration은 init(frame:configuration:) 그 순간에만 반영된다. 웹뷰를 만든 뒤에 webView.configuration.xxx를 바꿔도 적용 안 됨. 설정은 생성 전에 다 끝내야 한다.담기는 대표 설정: defaultWebpagePreferences(JS 허용), websiteDataStore(쿠키·캐시), userContentController(JS 브리지), allowsInlineMediaPlayback, mediaTypesRequiringUserActionForPlayback.
4) processPool / websiteDataStore — 웹뷰끼리 세션 공유
- 같은
WKWebsiteDataStore를 공유하면 여러 웹뷰가 쿠키·로컬스토리지·세션 공유(로그인 유지) → Tier2 ⑤에서 깊게. WKProcessPool(구형) 공유 = 프로세스 계열 공유. 근래엔 dataStore 공유로 세션 공유를 표현하는 게 명확..default()=영구 저장(디스크),.nonPersistent()=시크릿(메모리, 앱 종료 시 소멸).
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.
✅ 체크 질문
- UIWebView 대신 WKWebView를 쓰면 크래시 관점에서 뭐가 좋아지나? → 별도 프로세스라 웹 크래시가 앱에 전파 안 됨
evaluateJavaScript가 왜 동기 함수가 아니라 completion/async인가? → 프로세스 경계를 넘는 IPC라서- 웹뷰를 만든 뒤에
webView.configuration을 바꾸면? → 소용없음(생성 시점에만 적용)
📌 §1 후속 Q&A
Q1. 멀티 프로세스 — 각자 담당과 나눈 이유
| 프로세스 | 담당 | 특징 |
|---|---|---|
| App | 내 Swift/UIKit 코드. WKWebView 객체(뷰 껍데기) | 웹 콘텐츠 없음. JIT 금지 |
| WebContent | HTML 파싱·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. 영구 저장(디스크) 예시
- 로그인 세션 쿠키(앱 껐다 켜도 로그인 유지)
- localStorage 값(다크모드 등 사용자 설정)
- 캐시된 리소스(이미지·JS·CSS) → 재방문 빠른 로드
- IndexedDB 오프라인 데이터
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를 컴파일해서 웹뷰로 띄우는 것"인가?
Q13. "JavaScript의 실행"이 무엇?
JS 코드(로직)가 돌아가는 것: 버튼 클릭 처리, 서버 fetch, DOM 조작(화면 요소 추가/변경/삭제), 계산·분기. iOS로 치면 "Swift 코드가 실행돼 버튼 액션이 돌고 label.text를 바꾸는 것". (DOM=HTML 구조를 코드로 만지는 객체 트리 ≈ iOS 뷰 계층 트리)
Q14. 웹앱이란?
| 네이티브 | 웹앱 | 하이브리드 | |
|---|---|---|---|
| 제작 | Swift/Kotlin | HTML+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 Web: Dart→JS/WASM 컴파일, 화면은 DOM 대신 canvas(CanvasKit/Skia)로 그리기도 하나 실행은 JS/WASM.
- WASM: C/Rust/Dart를 컴파일해 near-native로 실행("JS 없이"에 가장 근접, 보통 JS glue 붙음).
- 요점: 손으로 JS를 안 쓸 순 있어도 최종 산출물은 JS/WASM. 웹뷰 입장에선 뭘로 만들었든 HTML/CSS/JS/WASM로 도착해 WebContent가 실행(브리지 방식 동일).
(참고: 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)
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) // 일반 웹 페이지는 로드
}
}
- 반환:
.allow(로드) /.cancel(차단) /.download(iOS 14.5+). - WKNavigationAction 정보:
request,navigationType(.linkActivated=사용자 클릭 / .formSubmitted / .backForward / .reload / .other=JS location.href 등),targetFrame(nil이면 새 창 target="_blank"),sourceFrame.
(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 복구.
3) navigationType이 중요한 이유
사용자가 눌러 간 건지(.linkActivated) JS가 이동시킨 건지(.other) 구분해야 할 때. 예: "사용자가 직접 누른 외부 링크만 Safari로, JS 내부 이동은 웹뷰에 둠". .backForward는 가로채면 안 되는 경우 많음.
4) 뒤로가기 / 히스토리
webView.allowsBackForwardNavigationGestures = true→ 가장자리 스와이프 뒤로/앞으로.canGoBack/goBack(),canGoForward/goForward(),backForwardList(WKBackForwardList).- 네이티브 통합: 커스텀 뒤로가기 →
webView.canGoBack ? webView.goBack() : navigationController?.popViewController(...)("웹 히스토리 먼저, 없으면 네이티브 pop").
5) async 버전 (iOS 15+)
func webView(_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
if await shouldBlock(navigationAction.request.url) { return .cancel }
return .allow
}
decidePolicyFor navigationAction에서 URL을 가로채 네이티브 라우팅/외부 앱으로 분기. 로딩 실패는 대부분 didFailProvisionalNavigation.✅ 체크 질문
- myapp://profile/123 딥링크를 네이티브로 처리하려면 어느 메서드에서 무엇을 반환? → decidePolicyFor navigationAction에서 route 후 .cancel
- DNS 실패로 페이지가 안 뜰 때 콜백은? → didFailProvisionalNavigation(커밋 전)
- 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
- 네비게이션 시작(Q8) = 웹뷰가 URL 로드 프로세스 개시 = didStartProvisionalNavigation. 요청은 보냈고 응답은 아직.
- provisional(잠정적)(Q4·Q7) = "시작했지만 확정 전" 단계. 서버가 실패·리다이렉트·에러할 수 있어 응답 확정 전엔 임시. 비유: 출발했지만 도착 미확정.
- 서버 응답으로 확정(commit)(Q9) = 서버가 응답을 줬고 WebKit이 이 웹뷰에 표시하기로 결정 = didCommit. 이 순간부터 확정된 네비게이션.
- didCommit ≠ didFinish(Q6): 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 ← 요청 전, 허용/차단 (제일 먼저!)
↓ .allow → 네트워크 요청 시작
② didStartProvisionalNavigation ← 요청 보냄, provisional 시작
↓ (서버가 301/302 리다이렉트할 때만)
③ didReceiveServerRedirect… ← 리다이렉트 시에만 (+ 대상 URL로 ① 다시)
↓ 최종 응답 헤더 도착
④ decidePolicyFor navigationResponse ← 응답 보고 표시/취소/다운로드 결정
↓ .allow
⑤ didCommit ← 표시 확정, 본문 받기 시작, 렌더 시작
↓ (본문·서브리소스·JS 로드/렌더)
⑥ didFinish ← 전부 완료 ✅
- MIME(Content-Type): text/html·image/*·application/pdf·json=표시 / application/zip·octet-stream=표시 불가 → 다운로드.
navigationResponse.canShowMIMEType로 판단. - .download 반환(④): 응답을 화면 표시 대신 파일로 내려받기(iOS 14.5+ WKDownload). zip·설치파일·표시 불가 형식.
- ④→⑤ 인과: navigationResponse는 "결정 메서드"(didResponse 콜백은 없음). ④ .allow → ⑤ didCommit. .cancel/.download면 커밋 안 됨.
- didStartProvisional은 첫 진입·링크 이동 등 모든 새 페이지 네비게이션마다(SPA의 JS DOM 변경만은 제외).
- didCommit ≠ 본문 다 받음: "받기 시작 + 표시 확정"일 뿐, 서브리소스는 아직 로딩 중. 완료는 didFinish. (비유: didCommit=밥상 차리기 시작, didFinish=다 차림)
📌 §2 후속 — 리다이렉트/응답 시점 & 다운로드(WKDownload vs iOS14 blob 우회)
Q. WKNavigationAction이 담는 것 (URL + 유저가 어떻게 왔나)
| 필드 | 담는 것 |
|---|---|
request | URLRequest — URL(request.url)·메서드·헤더 |
navigationType | 어떻게 왔나: .linkActivated(클릭)/.formSubmitted/.backForward/.reload/.formResubmitted/.other(JS·초기로드·리다이렉트) |
sourceFrame | WKFrameInfo — 이동을 시작한 프레임(항상 존재) |
targetFrame | WKFrameInfo? — 가려는 프레임. 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 미리보기 */ }
- 흐름: shouldPerformDownload → .download → didBecome download → delegate → decideDestinationUsing(저장) → downloadDidFinish.
- .download는 강제 아님(힌트). download 속성 링크면 의도 명확 → 보통 .download로 존중. .allow로 그냥 열 수도.
- 두 진입점: navigationAction:didBecome:(요청 단계) / navigationResponse:didBecome:(응답 단계) 둘 다 WKDownload 획득 가능.
- temp 저장만으론 사용자가 못 찾음 → downloadDidFinish에서 공유 시트/QuickLook로 보여줌.
- iOS 14.0~14.4: shouldPerformDownload·.download·WKDownload 전부 14.5+라 없음 → §2의 JS 브릿지 base64 우회(fetch→FileReader→postMessage→네이티브 저장)로 버전 분기.
Q. 리다이렉트 콜백 순서 — 짝/개수 규칙
아니다(파라미터). didReceiveServerRedirect 파라미터는 WKNavigation(토큰)뿐. 리다이렉트 대상 URL은 decidePolicyFor navigationAction이 별도로 다시 호출됨(리다이렉트도 가로채기 가능).
// 리다이렉트 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 브리지
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/bool | NSNumber |
| string | NSString |
| object | NSDictionary |
| array | NSArray |
| null | NSNull |
커스텀 타입 불가 → 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)
.atDocumentStart: 페이지 스크립트보다 먼저(브리지 전역 세팅)..atDocumentEnd: DOM 완성 후(요소 조작).forMainFrameOnly: true: 메인 프레임에만(iframe 제외, 보안).
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 입력을 믿지 말고 검증(네이티브 기능 여는 통로).
✅ 체크 질문
- 웹이 postMessage 부르면 네이티브 어느 메서드가 받나? 채널 이름 확인은? → userContentController(_:didReceive:), message.name
- add(self, name:)가 왜 릭? 어떻게 푸나? → ContentController가 self 강한 참조 → 순환. WeakScriptMessageHandler + removeScriptMessageHandler
- 브리지 헬퍼를 페이지 스크립트보다 먼저 깔려면? → WKUserScript .atDocumentStart
§4. WKUIDelegate (JS 대화상자·새 창·파일 선택)
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 = 별도 웹뷰 안 만듦
}
- (A) 같은 웹뷰에서 열기(모바일 최다): return nil + webView.load(request). target="_blank"라도 현재 웹뷰에서.
- (B) 팝업 웹뷰 생성:
WKWebView(frame:, configuration: configuration)— 넘어온 configuration 필수(새로 만들면 세션·프로세스 끊김) → delegate 연결 → present → return popup(WebKit이 로드해줌, 내가 load 부르면 이중 로드).
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.
✅ 체크 질문
- alert("저장됨")이 안 뜬다. 왜/어떻게? → WKUIDelegate runJavaScriptAlertPanel 미구현 → 구현+present
- target="_blank"를 같은 웹뷰에서 열려면? → createWebViewWith에서 webView.load(request); return nil
- confirm completionHandler 안 부르면? → JS가 그 자리에서 영원히 멈춤
§5. 쿠키·세션 공유 (WKHTTPCookieStore)
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) // ★ 주입 "완료 후" 로드
}
4) 다른 방법과 비교 (WKHTTPCookieStore가 정석)
| 방법 | 한계 |
|---|---|
| URLRequest에 Cookie 헤더 수동 | 그 요청 1번만. 이후 네비게이션·서브리소스엔 안 붙음 |
| WKUserScript로 document.cookie= | HttpOnly 쿠키는 JS로 못 넣음 |
| WKHTTPCookieStore.setCookie ✅ | 정석. HttpOnly 포함 모든 쿠키, 이후 요청에도 유지 |
핵심 포인트: HttpOnly 세션 쿠키는 document.cookie로 못 넣으니 WKHTTPCookieStore를 써야 한다.
5) 반대 방향 & 여러 웹뷰
- 웹→네이티브:
WKHTTPCookieStoreObserver(cookiesDidChange)로 웹 로그인/로그아웃 감지 → 네이티브 반영. - 같은
WKWebsiteDataStore인스턴스 공유 = 여러 웹뷰 쿠키·세션 공유(로그인 1회 유지).
6) 알아둘 것
WKHTTPCookieStore는 iOS 11+(이전엔 WKProcessPool 트릭). 속성: .domain(앞 점=서브도메인)·.secure(HTTPS만)·HttpOnly·SameSite·.expires(없으면 세션 쿠키=종료 시 소멸).
✅ 체크 질문
- 네이티브 로그인했는데 웹뷰가 로그아웃? → 웹뷰 쿠키 저장소가 URLSession과 분리
- 주입 후 바로 load했더니 첫 로드 비로그인? → setCookie 비동기, 완료 전 load됨 → completion에서 load
- 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 하나로 네이티브+여러 웹뷰 공유?
[웹뷰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처럼 자동으로 붙나?
| 방식 | 범위 |
|---|---|
| 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 로드
- 프리워밍: 진입 전 웹뷰 생성 + about:blank 로드 → 프로세스 앞당겨 기동.
- 재사용 풀: 매번 새로 만들지 말고 풀에서 꺼내 씀(생성·기동 비용 절약).
- processPool 공유: 여러 웹뷰가 같은 WKProcessPool → WebContent 프로세스 재사용(+세션). 근래엔 dataStore=세션, processPool=프로세스 재사용.
3) ② 네트워크 — 캐시·프리페치·페이로드
- 캐시: WKWebsiteDataStore(디스크)+URLCache → 재방문 네트워크 스킵. HTTP 헤더(max-age·ETag).
- 프리페치: 진입 확실한 URL 미리 로드(프리워밍 결합).
- 초기 페이로드 축소(서버 협업): 크리티컬 CSS 인라인·lazy·코드 스플리팅.
- WKContentRuleList로 광고·트래커·불필요 리소스 차단 → 로드량↓.
4) ③ 렌더 & 체감 개선
- 네이티브 셸 먼저: 네이티브 헤더/네비바 즉시 그리고 웹은 아래에서 로딩.
- 스켈레톤 UI: 웹 로드 중 네이티브 스켈레톤 → 빠르게 느껴짐.
- 로딩 인디케이터: estimatedProgress 진행바.
- 렌더 최적화(웹 협업): 무거운 동기 JS↓, 레이아웃 thrashing 방지, 이미지 서버 리사이즈.
5) WebContent 프로세스 관리 (안정성=성능)
메모리 압박으로 프로세스 종료 시 흰 화면 → webViewWebContentProcessDidTerminate에서 reload 복구. 안 쓰는 웹뷰 해제·개수 제한·백그라운드 정리.
✅ 체크 질문
- 첫 진입이 유독 느리다. 먼저 의심할 병목·해결? → cold start → 프리워밍/재사용/processPool
- 프리워밍은 뭘 미리? → 웹뷰 미리 생성 + about:blank 로드로 WebContent 프로세스 기동
- 실제 오래 걸릴 때 빠르게 느껴지게? → 네이티브 셸/스켈레톤 먼저 + 진행바
📌 §9 후속 — about:blank는 뭘 데우나 & WebContent 프로세스
Q. about:blank 로드는 왜? 프로세스만 켜면 되지 않나? 실제 페이지는 내용이 다른데?
첫 로드 비용은 두 종류인데, about:blank는 (A) 고정 인프라 비용만 데운다:
첫 로드 총비용 =
(A) 고정 인프라(페이지 무관): WebContent 프로세스 기동·WebKit dylib 매핑·
JS 엔진 초기화(JIT)·IPC 채널 수립·렌더 파이프라인 셋업
(B) 페이지별: 네트워크 다운로드 + 파싱 + 실행 + 레이아웃 + 페인트
- about:blank가 데우는 건 (A)뿐. 내용이 없어도 엔진·프로세스·IPC를 "준비 완료"로 강제. (A)는 페이지와 무관한 고정비용이라 미리 치르면 진입 때 (B)만 남아 빨라짐.
- 맞는 지적: 실제 페이지의 CSS/HTML/JS 파싱·렌더(B)는 about:blank가 안 해줌 → 프리워밍은 페이지를 미리 그리는 게 아니라 엔진을 미리 켜는 것.
- "프로세스만 켜면 된다"가 목표는 맞고, about:blank는 그걸 네트워크 0·콘텐츠 0으로 확실히·싸게 트리거. WKWebView() 생성만으론 프로세스·엔진 초기화가 lazy하게 첫 로드로 밀릴 수 있음.
- (B)까지 데우려면: 진입 확실한 URL이면 about:blank 대신 실제 URL을 프리페치(A+B 둘 다). 캐시로 (B)의 네트워크 부분 스킵. about:blank=범용(어디 갈지 몰라도 엔진 준비), 실제 URL 프리페치=강력(갈 게 확실할 때).
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. 웹뷰 보안
0) 개념 정정 — 노출 범위·iframe
- 방향: evaluateJavaScript=Native→JS(코드 전송), WKScriptMessageHandler=JS→Native(수신 채널). 쿠키 주입=앱 쿠키(URLSession HTTPCookieStorage)를 웹뷰 WKHTTPCookieStore에 setCookie.
- 노출 기준 = 프로세스가 아니라 configuration(userContentController). 핸들러 등록한 userContentController를 쓰는 웹뷰의 페이지 모든 프레임(메인+iframe)이 브리지 접근 가능. 다른 configuration 웹뷰엔 없음. 여러 웹뷰가 같은 userContentController 공유하면 전부 노출.
- 제3자 iframe = 새 창이 아니라 내 페이지 HTML 안에 박힌 다른 URL의 서브 프레임(
<iframe>, 광고·결제위젯). 같은 웹뷰·같은 페이지 안이라 그 JS도 브리지 호출 시도 가능 → 방어 필요. (새 창=target=_blank·window.open=§4 createWebViewWith, 별도 웹뷰)
0-2) 용어 — userContentController / 프레임 계층 / postMessage 방향
- WKUserContentController = 웹뷰의 "JS↔Native 제어판"(config에 1개). ①
add(handler, name:)=JS→Native 채널 등록 ②addUserScript=JS 주입. §7 "노출 기준=이 컨트롤러에 등록했나"의 등록처. - 프레임 계층(중요): WKWebView=네이티브 뷰 1개 → 페이지 1개 → 메인 프레임 + iframe들(서브 프레임). iframe은 "웹뷰 안의 다른 웹뷰"가 아니라 "한 페이지 안의 서브 프레임"(같은 웹뷰·같은 WebContent). 별도 웹뷰는 새 창(§4)뿐.
window.webkit.messageHandlers.<name>.postMessage()= 웹→네이티브(JS가 네이티브 handler 호출). 반대(네이티브→웹)는 evaluateJavaScript.
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 · 기타
WKUserScript(..., forMainFrameOnly: true)→ 내가 주입한 헬퍼 스크립트를 iframe에 안 깔음(공격면 축소). ⚠️ 단 핸들러 호출 자체는 못 막음:window.webkit.messageHandlers.<name>는 WebKit이 모든 프레임에 기본 제공이라 iframe도 원본 채널을 직접 부를 수 있음 → forMainFrameOnly는 단독으론 불충분.- iframe의 브리지 호출을 실제로 막는 것: ① origin 검증(frameInfo, 런타임 실질 게이트) ② App-Bound Domains(비-app-bound 도메인은 messageHandlers API 자체 비활성) ③ (고급) Content World 격리(핸들러를 별도 world에 등록 → 페이지/iframe JS가 못 봄).
WKContentRuleList: 광고·트래커 차단, http→https 강제, 도메인 차단(보안·프라이버시+성능).- ATS: 기본 HTTPS 강제. loadHTMLString(baseURL:)에 사용자 입력=XSS 위험(살균). 불필요하면 JS 끄기(allowsContentJavaScript=false). 비밀값(토큰·키)을 JS로 넘기지 말 것. 인증은 ASWebAuthenticationSession(OAuth) 고려.
5) 방어 계층
① 로드: App-Bound Domains(강력 API를 내 도메인만) + ATS(HTTPS)
② 런타임(실질 게이트): origin 검증(frameInfo.isMainFrame·host·https) + 액션 화이트리스트 + 입력 검증
③ 주입 보조: forMainFrameOnly(헬퍼만 메인 프레임 — 원본 채널은 못 막음, 단독 불충분)
④ 콘텐츠: WKContentRuleList(차단/https 업그레이드)
※ 원본 messageHandlers는 모든 프레임 기본 노출 → iframe 차단은 origin 검증·App-Bound Domains·Content World로
✅ 체크 질문
- 제3자 iframe이 내 postMessage 브리지 호출 막는 2가지? → forMainFrameOnly(주입 배제) + frameInfo origin 검증
- App-Bound Domains 켜면 지정 안 한 도메인선 뭐가 막히나? → evaluateJavaScript·메시지 핸들러·쿠키 조작·커스텀 스킴(브라우저형 앱엔 부적합)
- 브리지 메시지 받을 때 검증 2가지? → origin(메인 프레임·호스트·https) + 액션 화이트리스트/입력 검증
§10. SwiftUI 통합 (UIViewRepresentable · Coordinator)
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 학습과 연결
| Representable | SwiftUI 개념 |
|---|---|
| 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) 함정
- updateUIView는 매 갱신 호출 → 불필요 reload 방지(값 같으면 스킵), 안 그러면 리렌더마다 재로딩.
- delegate에서 @Binding 수정은 메인에서("modifying state during view update" 경고 시 DispatchQueue.main.async).
- 웹뷰 자체 상태(뒤로가기 등)는 웹뷰가 갖고, SwiftUI엔 필요한 것만 @Binding 노출.
✅ 체크 질문
- makeUIView/updateUIView 언제 몇 번? SwiftUI 개념으론? → makeUIView 1번(@State처럼)·updateUIView 매 갱신(body처럼)
- Coordinator를 왜 class로? → Representable은 struct(재생성)라 delegate 부적합, class Coordinator가 안정적 소유자·브릿지
- updateUIView에서 매번 load하면? → 리렌더마다 재로딩 → 값 같으면 스킵해야
관련 문서
ARC 생명주기 (JS 브리지 retain cycle 대비)
SwiftUI some View 심화 (UIViewRepresentable 통합 대비)