[Flutter] 도로명 주소 검색하기 / Daum Postcode Search Package 배포기

2021. 7. 26. 09:27Programming/Flutter

반응형

Flutter로 도로명 주소 검색하기

때는 바야흐로 2021년 초여름, Flutter를 사용하고 있던 프로젝트에 위치를 지정해줘야하는 요구사항이 생겼습니다. 지도를 이용해서 위도와 경도를 저장하는 방법과, 주소를 이용해서 저장하는 방법 중 어떤 방법을 사용할지에 대해 고민한 결과... 시간도 얼마 안남았으니 일단 간단해보이는 녀석을 먼저 하자는, 나름대로 합리적인 듯 보이지만 실제로는 별 생각없는 방법으로 결정을 내리고 방법을 찾기 시작합니다.

카카오에서 제공하는 API를 사용하자

놀랍게도 카카오에서 제공하는 다음 우편번호 서비스를 사용하면, Key를 발급받을 필요도 사용량에 제한이 걸리는 일도 없이 도로명 주소를 검색하는 게 가능합니다. 와! 편리해! 이 정도면 누군가 라이브러리로 만들어놓지 않았을까, 싶은 마음에 검색을 해봅니다. 엥, 진짜로 있잖아?

KOPO 0.1.3

pub.dev에 배포된 라이브러리 중에 카카오의 우편번호 서비스를 사용해서 도로명 주소를 검색하는, KOPO 패키지가 있습니다. KOPO GitHub Repository를 살펴보면 문서도 잘 되어있고, 사용법도 간단합니다. 엥, 근데 네트워크에 문제가 있어서 그런가. 페이지를 로딩할 때 오류가 나네요?

꼬진 회사라 인터넷도 꼬졋네요, 증말.

KOPO 패키지는 네트워크 환경이 좋지 않을때에 대한 처리는 전혀 되어있지 않고, 아쉽게도 커스터마이징도 불가능합니다. 고작해야 AppBar에 있는 타이틀 정도만 바꿔줄 수 있고, 에러가 발생했을 때에 대한 처리도 별도로 해줄 수 없죠. 결국 소스코드를 참조해서, 해당 내용을 보완할 겸 직접 구현해보고자 합니다.

Assets에 있는 HTML 파일을 웹 뷰로 띄우자

KOPO의 문제는 무엇일까?

KOPO 패키지가 느린 네트워크 환경에 취약한 이유 중 하나는, 외부에 공개된 html 파일(https://salondecode.github.io/kopo/assets/daum.html)을 다운로드 받고, 로딩이 완료되면 다음CDN에 등록된 자바스크립트 파일(t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js)을 받습니다. 솔직히 말하면 파일 자체가 크지 않아서 문제가 될 소지는 없어보이지만, Assets에 Html파일을 저장해놓은 뒤 WebView로 이 파일을 실행할 수 있다면 다운로드에 걸리는 시간을 단축할 수 있을 겁니다.

추가적으로 오류에 대한 처리도 필요합니다. 저의 경우에는 느린 네트워크 속도때문에 타임아웃이 발생했지만, 네트워크 속도가 빠르더라도 오류가 발생할 소지는 있습니다. 만약 오류가 발생했을 때 사용자가 해당 페이지를 종료했다가 다시 켜야한다면, 꽤나 어이가 없을꺼에요. 브라우저라면 새로고침이라도 하겠지만, KOPO 0.1.3 버전에는 새로고침도 할 수 없으니까 말이죠.

Assets에 있는 HTML 파일을 웹뷰로 띄우려면?

아무튼 KOPOwebview_flutter 패키지에 의존성을 가지고 있습니다. 애석하게도 이 패키지 자체에는, assets에 위치한 html 파일을 실행할 방법은 없습니다. 애시당초 네이티브로 제공되는 WebView를 플러터 상에서 실행시키기 위한 목적이었기 때문이려나요. 결국 Assets에 위치한 html 파일을 WebView로 띄우기 위해서는 flutter_inappwebview와 같은 패키지를 사용해야 합니다. Assets에 있는 Html 파일을 WebView로 띄워주는 패키지는 여러개가 존재하지만, 문서도 잘 되어있는 flutter_inappwebview를 선택했습니다.

실패한 얘기부터 할꺼에요.

뭐 당연하다면 당연하달까, 실패한 얘기를 중간에 끼워넣지 않으면 서사적으로 좀 이상해보인달까, 혹시 이 글을 보면서 KOPO를 대체할 무언가를 만드려고 한다면, 잠시 멈춰주세요. 지금부터는 flutter_inappwebview의 InitialFile을 사용해서 assets에 있는 html 파일을 띄웠다가 실패한 얘기를 할 거에요. '에잇, 구현하는데 방해만 된단 말이다!'라고 생각하시는 분들은, 딱 요 문단을 건너뛰시면 됩니다. 우선은 Flutter WebView JavaScript Communication - InAppWebView 5, Lorenzo Pichilli를 읽어봅시다. JavaScriptHandlers, Web Message Channels, Web Message Listeners를 사용해서 Dart와 JavaScript간 통신을 하는 예제에 대해 설명한 글이에요.

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    </head>
    <body>
        <h1>JavaScript Handlers</h1>
        <script>
            window.addEventListener("flutterInAppWebViewPlatformReady", function(event) {
                window.flutter_inappwebview.callHandler('handlerFoo')
                  .then(function(result) {
                    // print to the console the data coming
                    // from the Flutter side.
                    console.log(JSON.stringify(result));

                    window.flutter_inappwebview
                      .callHandler('handlerFooWithArgs', 1, true, ['bar', 5], {foo: 'baz'}, result);
                });
            });
        </script>
    </body>
</html>

링크에서는 이 Html 코드를 InitialData로 선언해서, 파일 로드 없이 직접 사용하고 있습니다. 이 경우 Dart로 작성된 내용과 Html로 작성된 내용이 혼재되어 코드의 가독성이 떨어지기도하고, Html코드를 작성할 시 린트의 도움을 받을수도 없죠. IDE가 스크립트의 내용을 문자열로 인식하기 때문에, 코드 수정에 대한 제안도 받을 수 없어요. 이런 문제때문에 assets에 위치한 html파일을 띄우려고 하는 것이기도 하고 말이죠.

import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';

Future main() async {
  WidgetsFlutterBinding.ensureInitialized();

  if (Platform.isAndroid) {
    await AndroidInAppWebViewController.setWebContentsDebuggingEnabled(true);
  }

  runApp(new MyApp());
}

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => new _MyAppState();
}

class _MyAppState extends State<MyApp> {

  InAppWebViewGroupOptions options = InAppWebViewGroupOptions(
      android: AndroidInAppWebViewOptions(
        useHybridComposition: true,
      ),);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
          appBar: AppBar(title: Text("JavaScript Handlers")),
          body: SafeArea(
              child: Column(children: <Widget>[
                Expanded(
                  child: InAppWebView(,
                    initialOptions: options,
                    onWebViewCreated: (controller) {
                      controller.addJavaScriptHandler(handlerName: 'handlerFoo', callback: (args) {
                        // return data to the JavaScript side!
                        return {
                          'bar': 'bar_value', 'baz': 'baz_value'
                        };
                      });

                      controller.addJavaScriptHandler(handlerName: 'handlerFooWithArgs', callback: (args) {
                        print(args);
                        // it will print: [1, true, [bar, 5], {foo: baz}, {bar: bar_value, baz: baz_value}]
                      });
                    },
                    onConsoleMessage: (controller, consoleMessage) {
                      print(consoleMessage);
                      // it will print: {message: {"bar":"bar_value","baz":"baz_value"}, messageLevel: 1}
                    },
                  ),
                ),
              ]))),
    );
  }
}

onWebViewCreated를 살펴보면 controller.addJavaScriptHandler를 사용해서 자바스크립트 핸들러를 컨트롤러에 추가하고 있어요. 여기서 핸들러의 이름을 문자열로 전달하는데, Javascript에서 넘겨준 핸들러의 이름을 호출하면 Flutter에서 작성한 코드가 실행됩니다. 반대로 이 핸들러에서 return을 하게되면, Javascript쪽으로 값이 넘어가게되죠.

다음 우편번호 서비스 예시 코드의 oncomplete를 보면, 사용자가 검색한 결과를 선택했을 때 어떤 동작을 취할지 정할 수 있어요. 여기서 우리가 얻어낸 결과값을 가지고, Flutter에 작성한 코드로 돌아올 수 있게됩니다.

다만 initialFile을 사용해서 assets에 있는 html파일을 실행했을 때, oncomplete의 내용은 잘 동작하지 않을거에요. onConsoleMessage에 브레이크포인트를 걸면, 다음과 같은 에러가 발생하는 걸 알 수 있습니다.

Failed to execute 'postMessage' on 'DOMWindow': The target origin provided ('file://') does not match the recipient window's origin ('null').

DOMWindow.postMessage를 실행하려고 했는데, window의 origin은 null이지만 제공된 origin은 file://이라서 실행을 못한다는 메시지에요. file://에서 대충 눈치채셨겠지만, initialFile을 이용해서 assets을 사용했기 때문에 나타나는 현상이에요. 다행스럽게도 InAppWebView는 로컬 서버를 돌리는 기능을 제공합니다(!). 이 기능을 사용하면, 위의 에러를 회피할 수 있게 되죠.

InAppWebView의 문서 중 InAppLocalHostServer 페이지를 참조하면 어떻게 사용할 지 쉽게 감이 올겁니다.

final InAppLocalhostServer localhostServer = new InAppLocalhostServer();
await localhostServer.start();

간단하게 위의 코드를 실행하면 localHostServer가 8080포트로 실행되고, assets에 있는 페이지에 접근이 가능해집니다. 서버를 종료할때는 반대로, InAppLocalhostServerclose() 메소드를 호출하면 됩니다.

InAppWebView를 사용해서 Widget을 패키지로 배포해놓으면 편리하겠군.

위의 개선안을 가지고 패키지를 배포해놓으면, 나중에 동일한 기능을 구현할 때 편리하겠다는 생각이 자연스럽게 떠올랐어요. 그야, 뭐, 똑같은 삽질을 두 번 하는 건 귀찮은 일이니까말이죠. 결론부터 얘기하면, Pub.get - Daum Postcode Search Package에 배포해뒀어요. 아직 갈 길은 멀었지만 말이죠. Github Repository - Daum Postcode Search Package에서 소스 코드를 확인할 수 있습니다. 이제 마무리로 구현하면서 헷깔렸던 내용들을 정리하고 끝낼거에요.

패키지 내의 assets 경로에 대해서

Daum Postcode Search Package에서 다음 우편번호 서비스를 사용하기 위한 기본 html 파일의 경로는 packages/daum_postcode_search/lib/assets/daum_search.html입니다. 1) 패키지를 사용하는 사람이 자신이 원하는 html 파일을 사용할 수 있어야했고 2) html 파일을 별도로 작성하지 않더라도 다'음 우편번호 서비스'를 사용할 수 있어야했죠.

문제는 이 녀석이 빌드 타임에 제대로 들어갔는지, 패키지를 만들면서 알 방법이 없다는 거였습니다. 시행착오를 제일 많이 겪었던 부분이기도 하죠. 패키지 내부의 example을 빌드하면서, pubspec.yaml에 등록해놓은 assets 경로가 생성되는지 확인해야 했습니다. 한참을 빌드하면서 알게된건데, 안드로이드 기준으로 library/daum_postcode_search/example/build/app/intermediates/merged_assets/debug/out/flutter_assets/daum_postcode_search/lib에 파일이 포함되는지 포함이 안되는지를 체크하면 된다는 걸 알게됐죠. 그래도 이후에는 진행이 수월해서 다행이었습니다.

  • 요약) 패키지 내에 assets을 포함할 경우 example을 빌드할 때 [패키지 명]/example/build/app/intermediates/merged_assets/debug/out/flutter_assets/[패키지 명]/lib 경로를 확인하면 assets이 빌드 결과물에 포함됐는지 확인할 수 있다.

마무리

패키지 내의 assets 경로를 지정하는 데 삽질을 많이했지만, 그 이후로는 별다른 어려움 없이 진행됐던 것 같습니다. 이후에는 포트 번호 지정이라던가 문구 정도만 수정하면 되지 않을까싶네요. 쓸데없이 긴 글 읽어주셔서 감사합니다. ' ㅂ')/

반응형