WebView와 하이브리드 — Flutter 안에서 웹 콘텐츠 표시

결제 페이지, 약관, 외부 콘텐츠 등 웹 페이지를 앱 안에서 표시해야 하는 경우가 많습니다. Flutter의 WebView로 웹 콘텐츠를 임베딩하고 JavaScript와 통신하는 방법을 정리해보겠습니다.


설치

YAML
dependencies:
  webview_flutter: ^4.8.0
  # 플랫폼별 구현체
  webview_flutter_android: ^3.16.0
  webview_flutter_wkwebview: ^3.14.0  # iOS

기본 사용

DART
import 'package:webview_flutter/webview_flutter.dart';

class WebViewScreen extends StatefulWidget {
  final String url;
  const WebViewScreen({super.key, required this.url});

  @override
  State<WebViewScreen> createState() => _WebViewScreenState();
}

class _WebViewScreenState extends State<WebViewScreen> {
  late final WebViewController _controller;
  bool _isLoading = true;
  int _progress = 0;

  @override
  void initState() {
    super.initState();
    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setNavigationDelegate(
        NavigationDelegate(
          onPageStarted: (url) {
            setState(() => _isLoading = true);
          },
          onPageFinished: (url) {
            setState(() => _isLoading = false);
          },
          onProgress: (progress) {
            setState(() => _progress = progress);
          },
          onNavigationRequest: (request) {
            // 특정 URL은 외부 브라우저로
            if (request.url.startsWith('https://external.com')) {
              launchUrl(Uri.parse(request.url));
              return NavigationDecision.prevent;
            }
            return NavigationDecision.navigate;
          },
          onWebResourceError: (error) {
            print('에러: ${error.description}');
          },
        ),
      )
      ..loadRequest(Uri.parse(widget.url));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('웹뷰'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: () => _controller.reload(),
          ),
        ],
      ),
      body: Stack(
        children: [
          WebViewWidget(controller: _controller),
          if (_isLoading)
            LinearProgressIndicator(
              value: _progress / 100,
            ),
        ],
      ),
    );
  }
}

JavaScript 통신

Flutter → JavaScript 호출

DART
// JavaScript 코드 실행
await _controller.runJavaScript('''
  document.body.style.backgroundColor = 'lightblue';
  alert('Flutter에서 호출!');
''');

// JavaScript 실행 후 결과 받기
final result = await _controller.runJavaScriptReturningResult('''
  document.title;
''');
print('페이지 제목: $result');

JavaScript → Flutter 호출

DART
// JavaScript에서 Flutter로 메시지 보내기
_controller.addJavaScriptChannel(
  'FlutterChannel',
  onMessageReceived: (JavaScriptMessage message) {
    print('JS에서 받은 메시지: ${message.message}');

    // 메시지에 따라 처리
    final data = jsonDecode(message.message);
    if (data['action'] == 'close') {
      Navigator.pop(context);
    } else if (data['action'] == 'payment_complete') {
      _handlePaymentComplete(data['orderId']);
    }
  },
);

// 웹 페이지의 JavaScript에서 호출
// FlutterChannel.postMessage(JSON.stringify({action: 'close'}));
// FlutterChannel.postMessage(JSON.stringify({action: 'payment_complete', orderId: '12345'}));

HTML 문자열 로드

DART
_controller.loadHtmlString('''
  <!DOCTYPE html>
  <html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <style>
      body { font-family: sans-serif; padding: 16px; }
      .highlight { color: blue; font-weight: bold; }
    </style>
  </head>
  <body>
    <h1>Flutter WebView</h1>
    <p class="highlight">HTML 문자열을 직접 로드할 수 있습니다.</p>
    <button onclick="FlutterChannel.postMessage('버튼 클릭됨')">
      Flutter에 메시지 보내기
    </button>
  </body>
  </html>
''');

네비게이션 제어

DART
// 뒤로 가기
if (await _controller.canGoBack()) {
  await _controller.goBack();
} else {
  Navigator.pop(context);
}

// 앞으로 가기
if (await _controller.canGoForward()) {
  await _controller.goForward();
}

// 현재 URL 확인
final currentUrl = await _controller.currentUrl();

뒤로가기 버튼 처리

DART
PopScope(
  canPop: false,
  onPopInvokedWithResult: (didPop, result) async {
    if (didPop) return;
    if (await _controller.canGoBack()) {
      await _controller.goBack();
    } else {
      if (context.mounted) Navigator.pop(context);
    }
  },
  child: WebViewWidget(controller: _controller),
)

쿠키 관리

DART
final cookieManager = WebViewCookieManager();

// 쿠키 설정
await cookieManager.setCookie(
  const WebViewCookie(
    name: 'session',
    value: 'abc123',
    domain: 'example.com',
    path: '/',
  ),
);

// 모든 쿠키 삭제
await cookieManager.clearCookies();

실전: 결제 페이지

DART
class PaymentWebView extends StatefulWidget {
  final String paymentUrl;
  final Function(String orderId) onPaymentComplete;

  const PaymentWebView({
    super.key,
    required this.paymentUrl,
    required this.onPaymentComplete,
  });

  @override
  State<PaymentWebView> createState() => _PaymentWebViewState();
}

class _PaymentWebViewState extends State<PaymentWebView> {
  late final WebViewController _controller;

  @override
  void initState() {
    super.initState();
    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..addJavaScriptChannel(
        'PaymentBridge',
        onMessageReceived: (message) {
          final data = jsonDecode(message.message);
          if (data['status'] == 'success') {
            widget.onPaymentComplete(data['orderId']);
            Navigator.pop(context);
          }
        },
      )
      ..setNavigationDelegate(
        NavigationDelegate(
          onNavigationRequest: (request) {
            // 결제 앱 스킴 처리
            final url = request.url;
            if (url.startsWith('intent://') ||
                url.startsWith('kakaotalk://') ||
                url.startsWith('supertoss://')) {
              launchUrl(Uri.parse(url));
              return NavigationDecision.prevent;
            }
            return NavigationDecision.navigate;
          },
        ),
      )
      ..loadRequest(Uri.parse(widget.paymentUrl));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('결제')),
      body: WebViewWidget(controller: _controller),
    );
  }
}

면접 포인트: 한국 결제(PG)에서는 카카오페이, 토스 등 외부 앱 호출이 필요합니다. intent://, 커스텀 스킴을 NavigationDelegate에서 가로채서 launchUrl로 외부 앱을 실행해야 합니다.


정리

  • webview_flutter로 웹 콘텐츠를 앱 안에 임베딩합니다
  • addJavaScriptChannel로 JavaScript ↔ Flutter 양방향 통신이 가능합니다
  • NavigationDelegate로 URL 이동을 제어하고 외부 앱 호출을 처리합니다
  • 결제 페이지에서는 외부 앱 스킴 처리가 필수입니다
  • loadHtmlString으로 HTML 문자열을 직접 렌더링할 수도 있습니다
댓글 로딩 중...