본 포스팅은 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 생성

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

Github Pages 생성 항목

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

Github Pages 주소 확인

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

404 Error Page

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

Create new file

그리고 파일의 이름을 정확하게 index.html 이라고 입력해주세요. Github Pages 는 index.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 위에 Button 과 Label 을 하나씩 올려놓았어요. 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 를 사용하기 위해 WebKit 을 import 해주도록 할게요.

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 에 작성한 html 이 Java 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 를 인스턴스화 해주어야하는데요. 그 전에 먼저 방금 생성한 contentController 를 WKWebView 와 연결할 수 있도록 도와주는 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 함수에 KakaoZipCodeVC 를 Present 할 수 있도록 구현합니다.

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 를 실행하고 원하는 주소를 검색한 뒤에 클릭하면 내가 선택한 데이터가 ViewController 의 label 로 전달되는 것을 확인할 수 있습니다.

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()
    }
}