반응형

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. 실무 연동 시 예외 처리 및 메모리 관리 수칙

  1. Retain Cycle 방지: WKUserContentController.add() 호출 시 지정한 핸들러는 WKWebView가 내부적으로 강력 참조(Strong Reference)합니다. 뷰가 종결될 때 dismantleUIView 시점에 반드시 removeScriptMessageHandler(forName:)를 호출해야 메모리가 순환 참조로 방치되는 문제를 막을 수 있습니다.
  2. 보안 검증: userContentController(_:didReceive:) 수신 시 message.frame.url을 확인하여 허용된 도메인(White-list)에서 전송된 메세지인지 검증해야 악성 스크립트 실행 공격을 방지할 수 있습니다.
  3. 비동기 스레드 안전성: evaluateJavaScript는 반드시 메인 스레드(DispatchQueue.main)에서 수행되어야 예외 래시 현상 없이 뷰 업데이트가 동작합니다.

 

결론

SwiftUI UIViewRepresentable 기반의 WKWebView 래핑과 Coordinator 패턴을 활용하면 Retain Cycle 메모리 누수 없는 안전한 하이브리드 브릿지를 구축할 수 있으며, WKScriptMessageHandler 및 evaluateJavaScript를 조합하여 React/Next.js 웹 애플리케이션과 유기적인 양방향 이벤트를 전달할 수 있습니다.

 

 

 

반응형
Posted by 까칠코더
,