본 포스팅은 Swift 5.3 기준으로 작성되었습니다.

Intro

저는 요새 Market Kurly 앱을 클론하는 프로젝트를 진행하고 있는데요. 회원가입 부분에서 Kakao 우편변호 찾기 기능을 지원하더라고요. 사용자로서는 분명히 사용해본 경험이 있는 것 같은데 막상 구현하는 방법은 배운적도 생각해본 적도 없었습니다 ㅎㅎ 어쨌든 중요한건 지금 어떻게든 구현을 해야한다는 것이고, 마켓컬리앱이 아니더라도 회원가입을 필요로하는 서비스라면 많이 지원하고 있는 기능이니까 잘 공부해서 정리해두면 나중에 쓸데가 많을 것 같아 포스팅을 남깁니다.


서비스 확인하기

Kakao 우편번호 서비스 웹페이지 에 접속해보면 평소 우리가 회원가입을 할 때 자주 볼 수 있었던 화면이 하나 보일거에요.

Kakao 우편번호 서비스 웹페이지 스크린샷

먼저 안내를 읽어보니 모든 조건에서 무료로 사용가능하고 별다른 요구사항도 없는 것 같네요. 그냥 가져다쓰고 WebView 형태로 앱 내에서 띄워주면 될 것 같습니다. 그럼 바로 구현을 시작해볼까요? ㅎㅎ


구현하기

저도 완전히 처음 시도해보는 과정이다보니 어디서부터 접근해야할지 감이 오지않아 검색할 수 있는만큼 최대한 찾아봤지만 Swift 로는 카카오 우편번호 서비스 구현방법을 상세히 정리해둔 곳을 찾지 못해 공부하는데 애를 정말 많이 먹었습니다.

아무튼 구현을 위한 순서를 정리해보면 우편번호 서비스가 구현된 웹페이지 주소가 있어야하고, 그 주소를 기반으로 Xcode 에서 WebView 를 이용해 웹페이지를 띄워주어야 합니다. 그래서 오늘 포스팅은 이런 순서로 진행될거에요.

  • Github Pages 를 사용해 카카오 우편번호 서비스 웹페이지 구현
  • Xcode 에서 WebView 를 사용해 웹페이지 띄우기

그럼 순서대로 차근차근 구현을 시작해볼게요.


Github Pages 를 사용해 카카오 우편번호 서비스 웹페이지 만들기

카카오 우편번호 서비스를 웹페이지에 구현할 수 있는 방법은 여러가지가 있겠지만 이번 포스팅에서는 Github Pages 를 사용해 웹페이지를 생성하겠습니다. Github 에 접속해서 Repo 를 하나 만들어주세요. 저는 Kakao-Postcode 라고 생성했습니다.

Github Repo 생성

생성된 RepoSetting 카테고리로 들어가 스크롤을 내리다보면 Github Pages 라는 항목이 보일거에요. 이곳에서 Sourcemaster branch 를 선택하고 save 버튼을 눌러줍니다.

Github Pages 생성 항목

Save 버튼을 누르면 페이지가 리프레시 되는데 다시 Github Pages 항목이 있는 곳으로 돌아가보면 우리가 생성한 웹페이지의 주소를 얻을 수 있습니다.

Github Pages 주소 확인

생성된 주소로 들어가보면 404 에러 페이지가 보일거에요. 지금은 우리가 아무 내용도 입력하지 않았으므로 이렇게 뜨는게 정상입니다.

404 Error Page

다시 Github 으로 돌아와 Create new file 을 클릭합니다.

Create new file

그리고 파일의 이름을 정확하게 index.html 이라고 입력해주세요. Github Pagesindex.html 이라는 파일을 자동적으로 찾아서 웹페이지로 생성해줍니다.

index.html 입력 장면

이제 웹페이지 구현에 필요한 html 코드를 입력하면 됩니다. 포스팅 초반에 보았던 공식 카카오 우편번호 서비스 웹페이지를 보면 통합로딩방식 항목을 찾을 수 있고 이걸 갖다 붙여넣으면 바로 구현이 되긴하지만 POP-UP 방식으로 구현된다는 문제점이 있습니다.

그래서 주소를 입력했을 때 팝업 방식이 아닌 페이지가 바로 보일 수 있도록 구현된 코드가 필요합니다. index.html 본문에 아래 코드를 입력하고 저장해주세요.

<!DOCTYPE html>

<html lang="ko">
  <head>
    <title>주소 찾기</title>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width,height=device-height,initial-scale=1.0"
    />
  </head>
  <body onload="execDaumPostcode()">
    <div
      id="layer"
      style="display:block; position:absolute; overflow:hidden; z-index:1; -webkit-overflow-scrolling:touch; "
    ></div>
    <script src="https://spi.maps.daum.net/imap/map_js_init/postcode.v2.js"></script>
    <script>
      window.addEventListener("message", onReceivedPostMessage, false);

      function onReceivedPostMessage(event) {
        //..ex deconstruct event into action & params
        var action = event.data.action;
        var params = event.data.params;
        console.log("onReceivedPostMessage " + event);
      }

      function onReceivedActivityMessageViaJavascriptInterface(json) {
        //..ex deconstruct data into action & params
        var data = JSON.parse(json);
        var action = data.action;
        var params = data.params;
        console.log("onReceivedActivityMessageViaJavascriptInterface " + event);
      }

      function postMessageToiOS(postData) {
        window.webkit.messageHandlers.callBackHandler.postMessage(postData);
      }

      var element_layer = document.getElementById("layer");
      function execDaumPostcode() {
        new daum.Postcode({
          oncomplete: function (data) {
            var jibunAddress = "";

            if (data.jibunAddress == "") {
              jibunAddress = data.autoJibunAddress;
            } else if (data.autoJibunAddress == "") {
              jibunAddress = data.jibunAddress;
            }

            var postData = {
              roadAddress: data.roadAddress,
              jibunAddress: jibunAddress,
              zonecode: data.zonecode,
            };
            window.postMessageToiOS(postData);
          },
          width: "100%",
          height: "100%",
        }).embed(element_layer);
        element_layer.style.display = "block";
        initLayerPosition();
      }

      function initLayerPosition() {
        var width = window.innerWidth || document.documentElement.clientWidth;
        var height =
          window.innerHeight || document.documentElement.clientHeight;
        element_layer.style.width = width + "px";
        element_layer.style.height = height + "px";
        element_layer.style.left =
          ((window.innerWidth || document.documentElement.clientWidth) -
            width) /
            2 +
          "px";
        element_layer.style.top =
          ((window.innerHeight || document.documentElement.clientHeight) -
            height) /
            2 +
          "px";
      }
    </script>
  </body>
</html>

적용되는데 시간이 조금 필요하기 때문에 잠시 기다리고나서 Github Pages 주소로 들어가보면 우리가 구현하고자 했던 카카오 우편번호 서비스 화면이 보일거에요!

혹시 계속 기다려봐도 안된다면 Repo 의 이름을 한번 변경해주세요. 분명히 잘못된 부분이 없는데 계속 페이지 오류가 뜨길래 Repo 이름을 바꿔보니까 이후부터 웹페이지가 정상적으로 뜨기 시작했습니다.

우편번호 서비스 Github Pages

잘 나오나요~? ㅎㅎ 이제 Xcode 에서 WebKit 을 이용해 이 페이지를 띄워주면 됩니다.


WebView 로 카카오 우편번호 서비스 웹페이지 표시하기

Xcode Project 를 하나 만들어 실습을 시작해볼게요. 먼저 간단하게 뷰를 세팅하겠습니다.


Setup View

View 위에 ButtonLabel 을 하나씩 올려놓았어요. Button 을 누르면 카카오 우편번호 서비스 창이 뜨고, 이곳에서 주소를 입력받은 뒤 Label 에 표시될 수 있도록 데이터를 전달해 볼거에요.

import UIKit

class ViewController: UIViewController {

    // MARK: - Properties
    let button = UIButton(type: .system)
    let label = UILabel()

    // MARK: - Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        configureUI()
    }

    // MARK: - UI
    private func configureUI() {
        setContraints()
        setAttributes()
    }

    private func setAttributes() {
        button.setTitle("Button", for: .normal)
	button.addTarget(self, action: #selector(handleButton(_:)), for: .touchUpInside)

        label.text = "Label"
        label.font = UIFont.systemFont(ofSize: 20)
    }

    private func setContraints() {
        [button, label].forEach {
            view.addSubview($0)
            $0.translatesAutoresizingMaskIntoConstraints = false
        }
        NSLayoutConstraint.activate([
            button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button.centerYAnchor.constraint(equalTo: view.centerYAnchor),

            label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            label.bottomAnchor.constraint(equalTo: button.topAnchor, constant: -40),
        ])
    }

    // MARK: - Selectors
    @objc
    private func handleButton(_ sender: UIButton) {
        print(#function)
    }
}

코드를 작성하고 Simulator 를 실행해보면 이렇게 보입니다.

Simulator 실행화면


카카오 우편번호 서비스 구현하기

기본적인 뷰 세팅은 마쳤으니 본격적으로 우편번호 서비스 구현을 위한 코드를 작성해보도록 할게요. 서비스가 보여질 새로운 UIViewController 를 하나 생성해줍니다. 저는 KakaoZipCodeVC 라고 만들었어요~

이제 이곳에 조금 전 Github Pages 를 사용해 미리 만들어둔 페이지를 WebView 를 사용해서 띄워줄거에요. 그러니까 WebView 를 사용하기 위해 WebKitimport 해주도록 할게요.

import WebKit

그리고 WKWebView 인스턴스를 optional 로 미리 하나 생성하고, webView 가 로딩될 동안 보여줄 UIActivityIndicatorView 도 인스턴스를 생성하겠습니다. 나중에 사용자가 선택한 주소를 저장할 변수도 하나 생성할게요.

var webView: WKWebView?
let indicator = UIActivityIndicatorView(style: .medium)
var address = ""

인스턴스 생성까지 마쳤고 Java Script 를 읽을 수 있게 도와주는 WKUserContentController 를 생성해야합니다. 처음보는 클래스니까 공식문서를 먼저 한번 볼까요.

WKUserContentController 공식문서

특별한 내용은 없고 Java Script 가 메세지를 Post 할 수 있게 도와준다고 나와있네요. 우리가 Github Pages 에 작성한 htmlJava Script 를 불러오는 내용이니까 이걸 쓰는게 맞을 것 같아요.

let contentController = WKUserContentController()

인스턴스를 생성해줬으니 이제 Java Script 가 보내는 메세지를 읽을 수 있어야 합니다. 여기서 메세지란 사용자가 선택한 정보가 무엇인지 우리가 받는 것을 뜻하겠죠?

아무튼 이 메세지를 받으려면 WKUserContentController 가 제공하는 add(_:name:) method 를 사용해야 하는데요.

이 method 를 사용하기 위해서는 WKScriptMessageHandler 프로토콜을 채택해주어야 합니다. 그리고 이 프로토콜을 채택하면 필수적으로 구현해야하는 userContentController(_:didReceive:) method 도 구현하도록 할게요.

프로토콜을 채택하기 전에 add(_:name:) method 의 공식문서를 잠시 살펴봤는데…

WKUserContentController 의 add method 공식문서

Script Message Handler 를 추가한다고 합니다. Hanlder 가 있어야 Java Script 가 보내는 Message 를 정상적으로 수신할 수 있는 것 같아요.


WKScriptMessageHandler 프로토콜 채택

WKScriptMessageHandler 를 채택하고 필수함수를 구현했습니다.

extension KakaoZipCodeVC: WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {

    }
}

직접 실험을 해보니 이 함수가 호출되는 타이밍은 유저가 주소를 검색하고 어떤 값을 최종적으로 선택했을 때 호출되게 됩니다.

프로토콜을 채택했으니 add(_:name:) method 를 사용할 수 있게 되었어요. 다시 contentController 를 인스턴스화 한곳으로 돌아가 코드를 입력해주세요.

contentController.add(self, name: "callBackHandler")

이제 초반에 optional 로 생성해두었던 WKWebView 를 인스턴스화 해주어야하는데요. 그 전에 먼저 방금 생성한 contentControllerWKWebView 와 연결할 수 있도록 도와주는 WKWebViewConfiguration 이 필요합니다.

let configuration = WKWebViewConfiguration()
configuration.userContentController = contentController

webView 를 인스턴스화 해줍니다. 레이아웃은 아래에서 오토레이아웃으로 잡을거니까 .zero 로 세팅하고 방금 만들어둔 configuration 을 연결합니다.

webView = WKWebView(frame: .zero, configuration: configuration)

webView 가 로드될 때 indicator 를 보여줄 수 있도록 WKNavigationDelegate 프로토콜을 채택하고 코드를 구현해보겠습니다.

extension KakaoZipCodeVC: WKNavigationDelegate {
    func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
        indicator.startAnimating()
    }

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        indicator.stopAnimating()
    }
}

함수의 Parameter 이름만 봐도 알 수 있듯이

  • webView(_:didStartProvisionalNavigation:)webView로드가 시작될 때
  • webView(_:didFinish:)webView로드가 끝날 때

각각 호출되게 됩니다. 그러니까 indicator 는 각각의 함수에 시작과 끝을 세팅하면 되겠죠?

Delegate 위임도 잊지말고 해주세요.

webView?.navigationDelegate = self

이제 webView 가 우편번호 서비스 웹페이지를 띄울 수 있도록 URL 만 전달해주면 됩니다. guard 문을 사용해 optional 을 unwrapping 해주고 URLRequest 를 생성해 webView 가 해당 URL 을 load 할 수 있도록 넘겨줍니다.

guard let url = URL(string: "https://kasroid.github.io/Kakao-Postcode/"),
    let webView = webView
    else { return }
let request = URLRequest(url: url)
webView.load(request)
indicator.startAnimating()

마지막으로 오토레이아웃을 잡고 Simulator 를 실행시켜 보도록할게요.

guard let webView = webView else { return }
view.addSubview(webView)
webView.translatesAutoresizingMaskIntoConstraints = false

webView.addSubview(indicator)
indicator.translatesAutoresizingMaskIntoConstraints = false

NSLayoutConstraint.activate([
    webView.topAnchor.constraint(equalTo: view.topAnchor),
    webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
    webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
    webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),

    indicator.centerXAnchor.constraint(equalTo: webView.centerXAnchor),
    indicator.centerYAnchor.constraint(equalTo: webView.centerYAnchor),
])

한가지 더!! ViewController 로 돌아가 handleButton 함수에 KakaoZipCodeVCPresent 할 수 있도록 구현합니다.

let nextVC = KakaoZipCodeVC()
present(nextVC, animated: true)

이제 Simulator 를 실행시키고 Button 을 눌러보세요.

Simulator 실행 화면

Success!!


데이터 전달하기

정상적으로 우편번호 서비스를 띄우는데 성공했으니 이제 사용자가 입력한 데이터를 받아올 일만 남았습니다.

userContentController(_:didReceive:) 함수 내부에 코드를 구현할게요.

if let data = message.body as? [String: Any] {
    address = data["roadAddress"] as? String ?? ""
}
guard let previousVC = presentingViewController as? ViewController else { return }
previousVC.label.text = address
self.dismiss(animated: true, completion: nil)

지금은 “roadAddress” 라는 항목만 추출했지만 message.body 를 print 해보면 다음과 같은 값들을 가져오는 것을 확인할 수가 있습니다.

{
    jibunAddress = "\Uacbd\Uae30 \Uc131\Ub0a8\Uc2dc \Ubd84\Ub2f9\Uad6c \Uc6b4\Uc911\Ub3d9 1017-3";
    roadAddress = "\Uacbd\Uae30 \Uc131\Ub0a8\Uc2dc \Ubd84\Ub2f9\Uad6c \Ud310\Uad50\Ub85c 35";
    zonecode = 13467;
}

필요에 따라 지번 주소, 도로명 주소, 우편번호를 가져와서 사용할 수 있어요.

이제 Simulator 를 실행하고 원하는 주소를 검색한 뒤에 클릭하면 내가 선택한 데이터가 ViewControllerlabel 로 전달되는 것을 확인할 수 있습니다.

Simulator 실행화면


Wrap Up

처음 시도할 떄는 조금 복잡하게 느껴질 수 있지만 한번만 잘 따라해보면 그렇게까지 어려운 내용은 아니에요. 워낙 어려운게 많아야지…

Github Pages 로 한번 구현해두면 계속해서 사용할 수 있으니 꼭 한번 만들어두고 앞으로 다양한 곳에서 활용해보세요. 그럼 오늘도 공부하느라 수고 많으셨습니다!

import UIKit
import WebKit

class KakaoZipCodeVC: UIViewController {

    // MARK: - Properties
    var webView: WKWebView?
    let indicator = UIActivityIndicatorView(style: .medium)
    var address = ""

    // MARK: - Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        configureUI()
    }

    // MARK: - UI
    private func configureUI() {
        view.backgroundColor = .white
        setAttributes()
        setContraints()
    }

    private func setAttributes() {
        let contentController = WKUserContentController()
        contentController.add(self, name: "callBackHandler")

        let configuration = WKWebViewConfiguration()
        configuration.userContentController = contentController

        webView = WKWebView(frame: .zero, configuration: configuration)
        self.webView?.navigationDelegate = self

        guard let url = URL(string: "https://kasroid.github.io/Kakao-Postcode/"),
            let webView = webView
            else { return }
        let request = URLRequest(url: url)
        webView.load(request)
        indicator.startAnimating()
    }

    private func setContraints() {
        guard let webView = webView else { return }
        view.addSubview(webView)
        webView.translatesAutoresizingMaskIntoConstraints = false

        webView.addSubview(indicator)
        indicator.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            webView.topAnchor.constraint(equalTo: view.topAnchor),
            webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),

            indicator.centerXAnchor.constraint(equalTo: webView.centerXAnchor),
            indicator.centerYAnchor.constraint(equalTo: webView.centerYAnchor),
        ])
    }
}

extension KakaoZipCodeVC: WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if let data = message.body as? [String: Any] {
            address = data["roadAddress"] as? String ?? ""
        }
        guard let previousVC = presentingViewController as? ViewController else { return }
        previousVC.label.text = address
        self.dismiss(animated: true, completion: nil)
    }
}

extension KakaoZipCodeVC: WKNavigationDelegate {
    func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
        indicator.startAnimating()
    }

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        indicator.stopAnimating()
    }
}