[Flutter] 주소 검색 패키지 Web까지 확장하기

FlutterPyo·2026년 1월 28일

들어가며

Flutter로 이커머스 앱을 개발하면서 배송지 입력 기능을 구현해야 했다. 한국에서는 카카오(다음) 우편번호 서비스를 많이 쓰는데, Flutter에서 이걸 Web까지 지원하려니 예상보다 삽질을 많이 했다.

결론부터 말하면, 기존 패키지들로는 Web + Mobile 동시 지원이 안 돼서 결국 직접 패키지를 만들었다. 이 글에서는 어떤 문제가 있었고, 어떻게 해결했는지 정리해본다.


카카오 우편번호 서비스

카카오(구 다음)에서 제공하는 무료 우편번호 검색 서비스다. 웹페이지에 스크립트 한 줄 추가하면 주소 검색 UI가 뜨고, 사용자가 주소를 선택하면 콜백으로 결과를 받을 수 있다.

<script src="//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js"></script>
<script>
    new daum.Postcode({
        oncomplete: function(data) {
            console.log(data.zonecode);     // 우편번호
            console.log(data.roadAddress);  // 도로명주소
        }
    }).open();
</script>

API 키도 필요 없고 완전 무료라서 한국에서 주소 검색 구현할 때 거의 표준처럼 쓰인다. 문제는 이게 웹 기반이라 Flutter 같은 네이티브 앱에서는 WebView로 감싸서 써야 한다는 점이다.


기존 패키지들의 한계

kpostal

Flutter에서 카카오 우편번호 검색을 구현할 때 가장 많이 쓰는 패키지다. flutter_inappwebview로 WebView를 띄워서 카카오 우편번호 서비스를 보여주는 방식이다.

dependencies:
  kpostal: ^2.0.0

iOS, Android에서는 문제없이 잘 동작한다. 그런데 Web에서 실행하면 이런 에러가 난다.

Error: Unsupported operation: Platform._operatingSystem

flutter_inappwebview가 Web 플랫폼을 지원하지 않기 때문이다. 애초에 WebView라는 게 네이티브 앱 안에서 웹 콘텐츠를 보여주기 위한 건데, 웹 앱에서 WebView를 쓴다는 게 이상하긴 하다.

kpostal_web

Web 전용으로 만들어진 패키지도 있다. 이걸 쓰면 Web에서는 잘 되는데, 문제는 이 패키지를 dependencies에 추가하는 순간 모바일 빌드가 안 된다.

dependencies:
  kpostal_web: ^0.1.0
Error: Not found: 'dart:html'

kpostal_web은 웹 전용 패키지라서 내부적으로 웹 전용 라이브러리를 사용하는데, 모바일 빌드 시에는 이게 존재하지 않는다. 그래서 컴파일 에러가 나는 거다.

두 패키지를 같이 쓰면?

처음에는 Conditional Import로 해결할 수 있을 거라 생각했다. 플랫폼에 따라 다른 파일을 import하는 Dart 기능인데, 이런 식이다.

import 'stub.dart'
    if (dart.library.io) 'mobile.dart'
    if (dart.library.html) 'web.dart';

그래서 stub 파일, mobile 구현, web 구현, factory 파일까지 만들어서 시도해봤다. 이론상으로는 모바일 빌드할 때 mobile.dart만 import되고 web.dart는 무시될 것 같았다.

근데 안 된다.

왜냐하면 Conditional Import는 내 코드에서 어떤 파일을 import할지만 결정하는 거지, pubspec.yaml dependencies에 있는 패키지를 컴파일에서 제외하는 게 아니기 때문이다. kpostal_web이 dependencies에 있는 한, 그 패키지의 모든 코드가 컴파일 대상이 되고, 패키지 내부에서 웹 전용 라이브러리를 import하기 때문에 모바일 빌드가 실패한다.

내 코드에서 kpostal_web을 직접 import 안 해도, dependencies에 있으면 컴파일러가 그 패키지 코드를 분석하게 되는 거다.

dev_dependencies로 옮기거나, 빌드 스크립트로 dependencies를 동적으로 바꾸는 방법도 찾아봤는데, 깔끔한 해결책이 없었다.


해결: 직접 만들기

기존 패키지 조합으로는 안 되겠다 싶어서 직접 패키지를 만들기로 했다.

핵심 아이디어는 간단하다.

  1. 하나의 패키지 안에서 Conditional Import를 사용한다
  2. 웹 구현에서 dart:html 대신 web 패키지를 사용한다
  3. 모바일 구현에서는 기존처럼 flutter_inappwebview를 사용한다

여기서 중요한 건 web 패키지다. Dart 팀에서 dart:html을 대체하기 위해 만든 패키지인데, dart:html과 달리 Conditional Import와 함께 사용해도 컴파일 에러가 나지 않는다. 패키지 형태라서 조건부로 import할 수 있는 거다.

프로젝트 구조

lib/
├── kpostal_plus.dart           # 메인 위젯
└── src/
    ├── kpostal_model.dart      # 결과 모델
    ├── web_platform_stub.dart  # 모바일용 빈 구현
    └── web_platform_web.dart   # 웹용 실제 구현

핵심은 web_platform_stub.dartweb_platform_web.dart다. 같은 함수 시그니처를 가지고 있지만, 하나는 빈 구현이고 하나는 실제 웹 구현이다.

Conditional Import 적용

메인 파일에서 조건부로 다른 파일을 import한다.

// lib/kpostal_plus.dart

import 'src/web_platform_stub.dart'
    if (dart.library.html) 'src/web_platform_web.dart' as web_platform;

dart.library.html은 웹 환경에서만 true가 된다. 그래서 모바일로 빌드하면 web_platform_stub.dart가 import되고, 웹으로 빌드하면 web_platform_web.dart가 import된다.

Stub 파일

모바일에서는 웹 관련 코드가 실행될 일이 없으니까, 빈 함수들만 정의해둔다. 중요한 건 웹 구현체와 함수 시그니처가 똑같아야 한다는 점이다.

// lib/src/web_platform_stub.dart

import 'package:flutter/material.dart';

void registerWebPostcode() {}

void setupWebMessageListener(Function(String) handleMessage) {}

Widget buildWebPostcodeView() {
  return const Center(child: Text('웹에서만 사용 가능합니다.'));
}

void cleanupWebResources() {}

Web 구현체

웹에서는 web 패키지를 사용해서 DOM을 직접 조작한다. 카카오 우편번호 스크립트를 동적으로 로드하고, div 요소에 위젯을 embed하는 방식이다.

// lib/src/web_platform_web.dart

import 'dart:js_interop';
import 'dart:ui_web' as ui_web;
import 'package:flutter/material.dart';
import 'package:web/web.dart' as web;

void registerWebPostcode() {
  ui_web.platformViewRegistry.registerViewFactory(
    'kakao-postcode-web',
    (int viewId) {
      final div = web.HTMLDivElement();
      div.id = 'kakao-postcode-container-$viewId';
      div.style.setProperty('width', '100%');
      div.style.setProperty('height', '100%');

      _loadKakaoPostcode(div.id);
      return div;
    },
  );
}

Widget buildWebPostcodeView() {
  return const HtmlElementView(viewType: 'kakao-postcode-web');
}

platformViewRegistry.registerViewFactory로 HTML 요소를 Flutter 위젯처럼 사용할 수 있게 등록한다. 그리고 HtmlElementView로 그 요소를 렌더링한다.

카카오 우편번호 스크립트 로드 후 daum.Postcode 위젯을 초기화하고, 사용자가 주소를 선택하면 CustomEvent를 통해 Flutter로 결과를 전달한다.

JavaScript에서 Flutter로 데이터를 전달하는 부분은 이렇게 처리했다.

void setupWebMessageListener(Function(String) handleMessage) {
  // CustomEvent 리스너 등록
  web.document.addEventListener(
    'kpostalResult',
    (web.Event event) {
      final customEvent = event as web.CustomEvent;
      handleMessage(customEvent.detail.toString());
    }.toJS,
  );

  // JavaScript에서 호출할 전역 콜백 등록
  final script = web.HTMLScriptElement();
  script.text = '''
    window.flutterKpostalCallback = function(data) {
      document.dispatchEvent(new CustomEvent('kpostalResult', { detail: data }));
    };
  ''';
  web.document.head!.appendChild(script);
}

카카오 우편번호 위젯의 oncomplete 콜백에서 window.flutterKpostalCallback을 호출하면, Dart 쪽 이벤트 리스너가 받아서 처리하는 구조다. JavaScript와 Dart 사이에 CustomEvent로 브릿지를 만든 셈이다.

메인 위젯

메인 위젯에서는 kIsWeb으로 플랫폼을 체크해서 각각 다른 뷰를 보여준다.


Widget build(BuildContext context) {
  return Scaffold(
    appBar: /* ... */,
    body: kIsWeb ? _buildWebView() : _buildMobileView(),
  );
}

Widget _buildWebView() {
  return web_platform.buildWebPostcodeView();
}

Widget _buildMobileView() {
  return InAppWebView(
    onWebViewCreated: (controller) async {
      await controller.addWebMessageListener(/* ... */);
      await controller.loadUrl(urlRequest: URLRequest(url: WebUri.uri(targetUri)));
    },
  );
}

모바일에서는 InAppWebView로 GitHub Pages에 호스팅해둔 HTML을 로드하고, 웹에서는 HtmlElementView로 직접 DOM에 카카오 위젯을 렌더링한다.


사용법

dependencies:
  kpostal_plus: ^1.0.3

Android는 인터넷 권한이 필요하다.

<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET"/>

기본 사용법은 간단하다.

import 'package:kpostal_plus/kpostal_plus.dart';

// 주소 검색 페이지로 이동
Navigator.push(
  context,
  MaterialPageRoute(
    builder: (_) => KpostalPlusView(
      callback: (Kpostal result) {
        print(result.postCode);      // 우편번호
        print(result.roadAddress);   // 도로명주소
        print(result.jibunAddress);  // 지번주소
      },
    ),
  ),
);

iOS, Android, Web 모두 동일한 코드로 동작한다. 별도 분기 처리 없이 그냥 쓰면 된다.

좌표가 필요할 때

주소만 있으면 되는 경우도 있지만, 지도에 마커를 찍거나 거리 계산을 하려면 좌표가 필요하다. 기본적으로 플랫폼 Geocoding을 사용해서 좌표를 가져오는데, 정확도가 좀 떨어질 수 있다.

정확한 좌표가 필요하면 카카오 Geocoding을 사용할 수 있다. 카카오 개발자 콘솔에서 JavaScript 키를 발급받아서 넣어주면 된다.

KpostalPlusView(
  kakaoKey: 'YOUR_KAKAO_JAVASCRIPT_KEY',
  callback: (Kpostal result) {
    // 플랫폼 Geocoding 결과
    print(result.latitude);
    print(result.longitude);

    // 카카오 Geocoding 결과 (더 정확함)
    print(result.kakaoLatitude);
    print(result.kakaoLongitude);
  },
)

카카오 Geocoding을 쓰려면 카카오 개발자 콘솔에서 도메인 등록도 해줘야 한다. 로컬 테스트는 http://localhost:8080, 배포 환경은 실제 도메인을 등록하면 된다.

UI 커스터마이징

AppBar 색상이나 타이틀을 바꿀 수 있다.

KpostalPlusView(
  title: '배송지 검색',
  appBarColor: Colors.indigo,
  titleColor: Colors.white,
  callback: (result) { /* ... */ },
)

완전히 커스텀 AppBar를 쓰고 싶으면 appBar 파라미터에 직접 넣어주면 된다.

KpostalPlusView(
  appBar: AppBar(
    title: Text('주소 찾기'),
    centerTitle: true,
    elevation: 0,
  ),
  callback: (result) { /* ... */ },
)

주의사항

iOS 로컬 서버 사용 시

useLocalServer: true 옵션을 쓰면 로컬 서버에서 HTML을 로드하는데, iOS에서는 HTTP 로드가 기본적으로 막혀있다. Info.plist에 설정을 추가해줘야 한다.

<key>NSAppTransportSecurity</key>
<dict>
  <key>NSAllowsLocalNetworking</key>
  <true/>
</dict>

웹에서 주소 선택 후 반응이 없을 때

간혹 카카오 스크립트 로딩이 느려서 초기화가 안 되는 경우가 있다. 내부적으로 3초까지 재시도하도록 해뒀는데, 네트워크가 많이 느리면 그래도 실패할 수 있다. 이 경우 페이지 새로고침하면 대부분 해결된다.


정리

기존 방식 (kpostal + kpostal_web)kpostal_plus
필요한 패키지2개1개
추가로 작성해야 하는 파일stub, factory 등 4~5개없음
Web 지원복잡한 설정 필요바로 사용 가능
모바일 지원별도 분기 처리 필요바로 사용 가능
API패키지마다 다름통일된 API

Flutter에서 Web까지 지원하는 우편번호 검색을 구현하려면 생각보다 고려할 게 많았다. dart:html 문제 때문에 기존 패키지 조합으로는 안 되고, 결국 web 패키지를 사용해서 직접 만들어야 했다.

비슷한 상황에서 삽질하고 있는 분들에게 도움이 됐으면 좋겠다.


링크

질문이나 버그 리포트는 GitHub Issue로 남겨주시면 됩니다.

0개의 댓글