안녕하세요, Client Team의 iOS 개발자 김규리입니다.

SwiftUI로 앱을 만들다 보면, WKWebView 같은 명령형 컴포넌트는 상태 관리나 생명주기, 이벤트 처리 면에서 자주 어긋나곤 합니다. PFCT는 이 문제를 선언형으로 다시 풀어냈고, 덕분에 화면이 늘어나도 설정은 일관되고, 이벤트는 타입 안전하게 흘러가며, SwiftUI의 상태나 생애주기와도 자연스럽게 어우러지는 구조를 만들 수 있었습니다.

이 글에서는 그 과정을 차근차근 공유해 보려 합니다. 핵심 아이디어는 Builder 중심의 구성, 타입 안전한 브릿지 통신, 그리고 쿠키 무결성과 토큰 재발급까지 아우르는 상태/생명주기 관리입니다.

명령형으로 구성된 기존 WKWebView는 화면이 늘어날수록 재사용성·확장성·사용성 측면에서 한계를 드러냈습니다. 공통 설정과 개별 로직이 뒤섞이고, 이벤트 처리는 점점 비대해지며, 선언형 UI 흐름과의 결합도 매끄럽지 않았습니다.

쿠키·토큰처럼 모든 화면에 필요한 공통 설정과, 화면마다 다른 브릿지·핸들링 로직이 한곳에 섞여 관리 포인트가 흩어집니다. 결국 같은 설정이 반복되고, 변경 영향 범위를 파악하기도 어려워집니다.

모든 이벤트를 한 메서드에서 다루는 방식은 테스트가 어렵고, 규모가 커질수록 인터페이스 분리 원칙을 해칩니다. Dictionary 기반 파싱은 타입 안전성이 낮아 휴먼 에러와 디버깅 비용을 키우며, 새로운 이벤트 추가 시 기존 코드 수정이 불가피해 개방-폐쇄 원칙에도 어긋납니다.

UIKit의 명령형 API를 Coordinator로 감싸 쓰다 보면 클래스가 쉽게 비대해지고, SwiftUI의 상태·생명주기와 자연스럽게 연결하기도 까다로워집니다. 선언형 View에서 기대하는 단순한 사용성, 예측 가능한 데이터 흐름을 확보하기도 어렵습니다.

PFCT는 이러한 문제들을 해결하기 위해 선언형 인터페이스를 최우선으로 삼았습니다. 웹뷰의 복잡한 설정과 동작을 하나의 거대한 객체에 몰아넣는 대신, 역할이 명확한 모듈로 나누고 선언적으로 조립하는 방식을 택했습니다.

화면마다 필요한 옵션은 체이닝 가능한 빌더에 담고, 웹-앱 통신은 이벤트 단위로 분리하여 타입 추론이 가능하도록 각기 명확한 프로토콜로 연결했습니다. 이를 통해 디버깅을 쉽게 하고 컴파일 타임에 오류를 최대한 걸러내는 것을 목표로 했습니다. 결과적으로 View는 “표현”에만 집중하며 나머지 로직은 모듈들이 담당하는 균형 잡힌 구조를 만들었습니다.

SwiftUI에서 WKWebView를 붙일 때 흔히 쓰는 방식은 UIViewRepresentable + Coordinator가 인스턴스를 보유하고 delegate를 처리하는 구조입니다. 여기서 “Coordinator의 책임”을 WebViewModel 프로토콜로 명확히 정의해, View는 표현만 담당하고 WebViewModel이 생성·설정·이벤트·상태를 오케스트레이션하도록 정리했습니다.

아래 다이어그램은 핵심 요소만 간단히 담았습니다. SwiftUI 래퍼가 웹뷰를 생성·보유하고, “설정용 빌더”와 “인스턴스 빌더”가 각각 설정과 행태를 선언적으로 조립합니다. 브릿지는 파서와 핸들러를 레지스트리에 등록해 확장하며, 세션 쿠키는 관찰자 기반으로 점검합니다.

웹뷰의 “설정”은 구성 빌더가, “동작”은 인스턴스 빌더가 담당합니다. 작은 Setter 모듈을 순서대로 적용해 완성된 설정/인스턴스를 만들고, 클로저 기반의 커스텀도 수용합니다. 쿠키, 카메라, 프로세스 풀, 스크립트 이벤트 핸들러 같은 설정은 재사용 가능한 단위로 분리했고, 쿠키 세팅은 콜백과 함께 비동기 완료 시점을 명확히 합니다.

간단 예시

웹에서 전달되는 메시지는 우선 파서가 액션/데이터로 해석하고, 이벤트 타입에 맞는 핸들러가 처리합니다. 이때 “어떤 파서를 어떤 핸들러와 묶을지”는 레지스트리 빌더가 선언적으로 조립합니다. 제네릭 기반의 어댑터가 델리게이트의 handle(_:)을 타입 소거된(type-erased) 인터페이스에 안전하게 연결해주기 때문에, 런타임 캐스팅 오류 없이 컴파일 타임에 일관성을 확보할 수 있었습니다. 또한 새로운 이벤트는 파서와 델리게이트만 추가하면 기존 코드 변경 없이 확장됩니다.

빌더를 통해 메소드 체이닝 형태로 간단하게 추가하고, 세부 로직은 분리된 Delegate 함수를 통해 각각 구현합니다. 이를 통해 관심사를 분리하고 디버깅 효율을 높입니다.

타입 식별을 위한 이벤트 모델, Parser, Handler를 추가해주면 기존 코드의 수정 없이 쉽게 기능 확장이 가능합니다.

웹뷰에서 직면하는 문제 중 하나는 토큰 관리입니다. 웹뷰에 전달한 토큰이 앱에 저장되어 있는 토큰과 생명주기가 상이할 때가 있어, 예상치 못하게 만료 토큰 이슈가 발생하곤 합니다. 토큰 갱신의 데이터 흐름을 명확히 하지 않으면 데드락이 발생하거나 유저가 매끄럽지 않은 UI/UX를 경험하게 됩니다.

이를 해결하기 위해 가장 먼저 토큰 업데이트 주체를 일원화했습니다. 무효 토큰을 감지하면, 상태 머신(로딩/완료/에러)과 락으로 동시 재발급을 방지합니다. 리로드/브릿지 통지 시점을 분리해 순서를 보장하고, 예측 가능한 단일 경로로 흐름을 단순화하여 레이스 컨디션을 예방합니다.

여러 경계(로딩, 브릿지, 세션, 프로세스)에서 이벤트가 동시에 발생합니다. 콘솔 로그만으로는 맥락 파악이 어려워, 표준화된 이벤트명과 최소 컨텍스트(url, action, params, status, error)로 기록합니다. 화면의 상태 전이는 상태 관리자가 이벤트로 변환해 같은 타임라인에 올리고, 이 외의 잦은 신호는 스로틀링으로 노이즈를 줄입니다. 결과적으로 “무엇이 언제 일어났는지”를 로그만 봐도 빠르게 재구성할 수 있습니다.

이 모듈은 설정(WKWebViewConfiguration 빌더), 인스턴스(WKWebView 빌더), 브릿지(이벤트 레지스트리 빌더)처럼 서로 다른 빌더가 협업합니다. 특히 브릿지 레이어는 ”이벤트 타입별로 다른 처리”가 핵심이라 제네릭과 타입 소거가 함께 필요했습니다. 여기서 많은 고민을 하게 된 지점은 다음과 같았습니다.

  1. type erasure | 서로 다른 이벤트 타입을 하나의 데이터에 담아 주입하려면 공통 인터페이스(타입 소거)가 필요함
  2. type-safe | 동시에, 사용처(ViewModel)에서는 개별 이벤트에 대해 “타입 안전한 API”로 독립적으로 등록 및 처리하고 싶음
  3. declarative API | 제네릭이 노출되면 호출부가 복잡해져 선언형 사용성이 떨어짐

컴파일 단계에서 에러 감지가 가능해야하면서도 웹뷰 특성상 기능 확장성이 열려있어야 하기에 무엇도 타협할 수 없는 조건이었습니다. 반드시 모든 요구사항을 만족할 수 있도록 여러 방법을 시도해보았던 기억이 납니다. 고민 끝에 결국 내부는 제네릭과 타입 소거, 외부는 확장 메소드로 단순화하여 해결하였습니다.

  • 내부: EventHandlerWrapper<SpecificEvent> 같은 제네릭 어댑터로 타입을 안전하게 연결하고, 저장은 any Handler(타입 소거)로 통일
  • 외부: 빌더 확장(예: .invalidToken(delegate:), .openApp(delegate:))으로 fluent API 제공

이를 통해 컴파일 타임 타입 안전성을 유지하면서, 호출부는 체이닝 한 줄로 읽히는 선언형 코드를 만들 수 있었습니다. 이 접근은 내부 복잡도를 호출부에 새지 않게 막아 주고, 신규 이벤트 추가 시에도 Parser/Delegate/Register method만 보완하면 되므로 확장에 매우 유연합니다. 제네릭의 힘을 내부 구현에 집중시키고, 바깥에는 간결한 빌더 체이닝만 보이도록 한 것이 핵심 포인트였습니다.

WKWebView의 WKNavigationDelegateWKUIDelegate는 Obj‑C 기반(@objc, optional)이라, Swift의 프로토콜 지향 방식(Protocol Oriented Programming)으로 “공통 기본 구현 + 화면별 선택적 구현”을 깔끔히 적용하기가 쉽지 않았습니다. optional 메서드의 동적 디스패치, 저장 프로퍼티 부재, 제네릭 제약 등으로 인해 프로토콜 익스텐션만으로는 재사용성과 테스트성을 동시에 확보하기 어려웠습니다.

이 간극은 두 단계로 풀었습니다. 먼저 공용 베이스를 두고(DefaultWebViewModel) 두 delegate를 직접 구현한 뒤, 기본 동작을 open 메서드로 제공해 화면에서 필요한 지점만 오버라이드하도록 했습니다. 이후 설계를 정리하면서 역할별 delegate 객체를 분리해 화면마다 필요한 것만 선택·조합하는 구조로 발전시켰고, 주입은 빌더 API로 선언적으로 처리했습니다. 지금은 베이스 타입 의존이 필수가 아니라 선택 사항이며, 공통 동작을 재사용해야 하는 국소적인 상황에서만 쓰입니다.

결과적으로, Obj‑C 인터롭의 제약을 우회하면서도 재사용성·가독성·테스트성을 모두 확보할 수 있었습니다.

  • 선언적 조립으로 신규 웹뷰 화면의 설정/브릿지 도입 시간이 크게 단축되었습니다.
  • 타입 안전한 이벤트 모델로 런타임 오류 감소, 디버깅 비용이 절감되었습니다.
  • 쿠키 무결성 자동 복구로 세션 이슈에 강인한 운영 안정성을 확보했습니다.
  • 테스트가 가능한 모듈 단위로 분해되어 커버리지 확대가 용이해졌습니다.

올해 6월 WWDC25에서 공개된 WebKit for SwiftUI와의 정합성을 기준으로 아키텍처를 점검하고자 합니다. 이벤트 브릿지 레지스트리와 타입 어댑터 계층을 공용 계층으로 끌어올려, 표준 API의 업데이트에도 최소 변경으로 대응할 계획입니다.

또한 로딩 시간·메모리·JS 실행 성능 같은 핵심 지표를 통합적으로 수집·분석하는 체계를 확장해, 실시간 진단과 알림, 원인 구간 식별, 최적화 후보 제안까지 이어지는 파이프라인을 마련하려 합니다.

명령형 WKWebView를 선언형으로 재구성한다는 건 단순히 컴포넌트를 교체하는 일이 아니라, “시스템을 조립하는 방식”을 새롭게 설계하는 일이었습니다. 설정은 빌더로, 이벤트는 파서–핸들러–레지스트리로, 상태는 관찰과 반응형 흐름으로 다루면서 확장성과 안정성이 자연스럽게 뒤따랐습니다.

이 구조는 실제 ‘크플’ 앱 내 웹뷰 화면들에 실제로 적용되어 있습니다. 앱을 직접 사용하시면서 웹뷰 화면을 경험해 보신다면, 이 글에서 이야기한 변화가 어떻게 녹아 있는지 체감하실 수 있을 것입니다. 이 글이 비슷한 고민을 하는 팀에 작은 기준점이 되길 바랍니다.

#SwiftUI #WKWebView #MVVM #BuilderPattern #AdapterPattern #ObserverPattern #DependencyInjection #Combine #SwiftConcurrency #Generics #TypeErasure #POP #StateMachine #StructuredLogging #MessageHandler

하이브리드 환경에서 웹뷰 성능은 곧 사용자 경험입니다. 다음 편에서는 선언형 아키텍처 위에 성능을 체계적으로 개선하는 방법을 다룰 예정입니다.

  • 리소스 프리페치 등을 활용한 로딩 시간 최적화
  • 안정성 향상을 위한 모니터링 시스템 구축
  • 디버깅 효율화를 위한 로깅 시스템 설계
  • 복잡한 시스템에서 메모리 누수를 분석하고 해결한 과정

다음 편도 지켜봐주세요!

written by gyuree