SwiftUI WKWebView - React 양방향 브릿지 통신: ScriptMessageHandler 연동 실무
SwiftUI 기반 iOS 앱과 React/Next.js 웹 애플리케이션을 연동하는 하이브리드 앱 구조에서는 웹뷰(WKWebView) 환경과 네이티브(Swift) 영역 간 데이터 교환을 위한 양방향 브릿지 설계가 필수적입니다. WKScriptMessageHandler와 evaluateJavaScript API를 활용하여 메모리 누수 없이 안전하게 양방향 메세지를 주고받는 실무 연동 패턴을 정리합니다.
1. 하이브리드 브릿지 아키텍처 및 통신 방향 비교
웹뷰와 네이티브 간 데이터 흐름은 크게 웹에서 네이티브 기능을 호출하는 방식과 네이티브 이벤트를 웹 화면에 전달하는 방식으로 구분됩니다.
| 구분 | 통신 방향 | 사용 API / 메커니즘 | 주요 유즈케이스 |
| Web ➔ Native | React/Web ➔ SwiftUI/iOS | window.webkit.messageHandlers.[HandlerName].postMessage() | 네이티브 카메라/앨범 호출, 토스트 메시지, GPS 위치 요청, 앱 설정 이동 |
| Native ➔ Web | SwiftUI/iOS ➔ React/Web | WKWebView.evaluateJavaScript() 또는 callAsyncJavaScript() | 네이티브 결제 완료 이벤트 전달, 푸시 알림 데이터 전달, 네트워크 상태 변경 감지 |
2. Web ➔ Native: WKScriptMessageHandler 구현 (Swift)
SwiftUI에는 기본 WKWebView 컴포넌트가 없으므로 UIViewRepresentable을 사용해 WKWebView를 래핑해야 합니다. 메모리 누수(Retain Cycle)를 방지하기 위해 Coordinator 패턴을 활용합니다.
import SwiftUI
import WebKit
// 1. 네이티브에서 수신할 브릿지 메시지 구조체
struct ScriptMessagePayload: Codable {
let action: String
let data: [String: String]?
}
// 2. SwiftUI 웹뷰 래퍼
struct BridgeWebView: UIViewRepresentable {
let url: URL
@Binding var responseFromNative: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> WKWebView {
let contentController = WKUserContentController()
// 브릿지 핸들러 등록 ("nativeBridge" 핸들러 명칭 지정)
contentController.add(context.coordinator, name: "nativeBridge")
let config = WKWebViewConfiguration()
config.userContentController = contentController
let webView = WKWebView(frame: .zero, configuration: config)
context.coordinator.webView = webView
let request = URLRequest(url: url)
webView.load(request)
return webView
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
// 메모리 해제 시 핸들러 제거 (Retain Cycle 방지)
static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
uiView.configuration.userContentController.removeScriptMessageHandler(forName: "nativeBridge")
}
// 3. ScriptMessageHandler 코디네이터
class Coordinator: NSObject, WKScriptMessageHandler {
var parent: BridgeWebView
weak var webView: WKWebView?
init(_ parent: BridgeWebView) {
self.parent = parent
}
// 웹에서 postMessage 호출 시 실행
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.name == "nativeBridge",
let bodyDict = message.body as? [String: Any],
let action = bodyDict["action"] as? String else {
return
}
// 웹에서 수신한 액션 분기 처리
switch action {
case "openCamera":
print("네이티브 카메라 호출")
// 네이티브 처리 후 웹으로 결과 응답 전달
sendToWeb(action: "cameraResult", data: ["status": "success", "imagePath": "/temp/img.jpg"])
case "showToast":
if let messageText = bodyDict["message"] as? String {
print("토스트 출력: \(messageText)")
}
default:
break
}
}
// Native -> Web 메세지 전송 함수
func sendToWeb(action: String, data: [String: String]) {
guard let jsonData = try? JSONSerialization.data(withJSONObject: ["action": action, "payload": data]),
let jsonString = String(data: jsonData, encoding: .utf8) else { return }
let jsCode = "window.receiveFromNative('\(jsonString)');"
DispatchQueue.main.async {
self.webView?.evaluateJavaScript(jsCode) { result, error in
if let error = error {
print("JavaScript 실행 에러: \(error.localizedDescription)")
}
}
}
}
}
}
3. Web 영역: React/Next.js 브릿지 연동 코드 (TypeScript)
React 프론트엔드 애플리케이션에서는 iOS 전용 객체인 window.webkit.messageHandlers 존재 여부를 확인한 후 메시지를 전송하고, 네이티브 응답 수신을 위한 전역 리스너를 등록합니다.
import React, { useEffect, useState } from 'react';
// Window 객체 타입 확장
declare global {
interface Window {
webkit?: {
messageHandlers?: {
nativeBridge?: {
postMessage: (message: any) => void;
};
};
};
receiveFromNative?: (responseJson: string) => void;
}
}
export const HybridWebPage: React.FC = () => {
const [nativeLog, setNativeLog] = useState<string>('응답 대기 중...');
useEffect(() => {
// Native -> Web 수신 리스너 등록
window.receiveFromNative = (responseJson: string) => {
try {
const parsedData = JSON.parse(responseJson);
console.log('네이티브 수신 데이터:', parsedData);
setNativeLog(`[${parsedData.action}] ${JSON.stringify(parsedData.payload)}`);
} catch (error) {
console.error('JSON 파싱 실패:', error);
}
};
return () => {
delete window.receiveFromNative;
};
}, []);
// Web -> Native 메세지 전송
const requestCamera = () => {
if (window.webkit?.messageHandlers?.nativeBridge) {
window.webkit.messageHandlers.nativeBridge.postMessage({
action: 'openCamera',
params: { mode: 'front' }
});
} else {
alert('네이티브 앱 환경이 아닙니다.');
}
};
const sendToast = () => {
if (window.webkit?.messageHandlers?.nativeBridge) {
window.webkit.messageHandlers.nativeBridge.postMessage({
action: 'showToast',
message: 'React에서 전달된 토스트 메시지입니다.'
});
}
};
return (
<div style={{ padding: '20px' }}>
<h2>하이브리드 웹-앱 브릿지 테스트</h2>
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px' }}>
<button onClick={requestCamera}>네이티브 카메라 요청</button>
<button onClick={sendToast}>네이티브 토스트 요청</button>
</div>
<div>
<h3>네이티브 수신 로그:</h3>
<pre>{nativeLog}</pre>
</div>
</div>
);
};
4. 실무 연동 시 예외 처리 및 메모리 관리 수칙
- Retain Cycle 방지: WKUserContentController.add() 호출 시 지정한 핸들러는 WKWebView가 내부적으로 강력 참조(Strong Reference)합니다. 뷰가 종결될 때 dismantleUIView 시점에 반드시 removeScriptMessageHandler(forName:)를 호출해야 메모리가 순환 참조로 방치되는 문제를 막을 수 있습니다.
- 보안 검증: userContentController(_:didReceive:) 수신 시 message.frame.url을 확인하여 허용된 도메인(White-list)에서 전송된 메세지인지 검증해야 악성 스크립트 실행 공격을 방지할 수 있습니다.
- 비동기 스레드 안전성: evaluateJavaScript는 반드시 메인 스레드(DispatchQueue.main)에서 수행되어야 예외 래시 현상 없이 뷰 업데이트가 동작합니다.
결론
SwiftUI UIViewRepresentable 기반의 WKWebView 래핑과 Coordinator 패턴을 활용하면 Retain Cycle 메모리 누수 없는 안전한 하이브리드 브릿지를 구축할 수 있으며, WKScriptMessageHandler 및 evaluateJavaScript를 조합하여 React/Next.js 웹 애플리케이션과 유기적인 양방향 이벤트를 전달할 수 있습니다.


