본문 바로가기

프로그래밍

우편번호 단위 위경도 값

업무 중, 가입 회원들의 주소지 분포를 알아야 할 일이 생겼다.

모든 주소지에 대한 위/경도를 얻은 후, 점지도(dot map) 또는 열지도(heat map)을 찍을 수 있다.

하지만, 신규 회원이 생길 때마다 해당 주소지의 위/경도 값을 구하여 저장하지 않는다면 주기적으로 작업을 통해 데이터를 갱신해야 한다.

 

당장 회원 정보에 위/경도 칼럼을 둘 계획이 없었기 때문에 전국 우편번호 별 대표 위/경도가 있었으면 좋겠다고 생각했다.

회원 정보에는 주소지와 우편번호 값을 나타내는 칼럼을 이미 가지고 있었기 때문이다.

우편번호 별 위/경도 값을 갖는 테이블을 유지한다면, 가입 주소지 분포 지도를 구하기 수월할 것으로 생각했다.

 

구글링을 해 봐도 마음에 드는 데이터를 찾지는 못했다.

 

위/경도는 점(x, y)으로 표현되는데, 우편번호는 면(x1, x2, y1, y2)으로 표현된다.

우편번호를 대표하는 위/경도가 뭐냐고 묻는다면, 애매하다.

 

효율적으로 일하기 위해서, 특정 우편번호를 대표하는 위/경도를 찾고자 고민하지 않고 우편번호에 속한 여러 지번주소 중 하나를 택하여 표시하는 것으로 결정했다.

 

1) 우편번호 데이터 구하기

1) URL 위치 : https://www.epost.go.kr/search/zipcode/areacdAddressDown.jsp

2) 다운로드 파일 중, 아래 파일을 사용했음

 

2) 실행 환경 및 소스

 

 - 읽을 파일 (20221006_지번 범위.txt) 위치와 결과 파일 위치는 환경에 맞게 수정 필요

 - python 3.9.9 사용

 - mac OS에서 실행

 - 네이버 MAP 라이브러리를 사용했음

'''
zip code (우편번호) 별, 위/경도 구합니다.

1) 우편번호 데이터를 다운 받습니다. (https://www.epost.go.kr/search/zipcode/areacdAddressDown.jsp) 
   활용 파일 - 20221006_지번범위.txt
2) 지번범위.txt 파일을 파싱하여, 위경도 조회를 시도합니다.
 2-1) 우편번호별, 1개의 주소를 취합니다.
 2-2) 해당 주소의 위/경도 조회를 하여 값이 나오면, 해당 우편번호의 대표 위/경도로 설정합니다.
      해당 주소의 위/경도 조회가 되지 않는 경우, 동일 우편번호에 대한 다음 주소에 대해 위/경도 조회를 시도하여 설정합니다.
 2-3) 주소는, 시도 + 시군구 + 읍면동 + 리명 또는 행정동 + 산여부 값이 1 인 경우, '산-' + 시작주번지 + '-' 시작부번지
      예1) 25627|강원도|Gangwon-do|강릉시|Gangneung-si|강동면|Gangdong-myeon|모전리|0||2|3|880|0
        => '강원도 강릉시 강동면 모전리 2-3'
      예) 25620|강원도|Gangwon-do|강릉시|Gangneung-si|강동면|Gangdong-myeon|상시동리|1||20|3|199|0
        => '강원도 강릉시 강동면 상시동리 산 20-3
'''

import argparse
import json
from time import sleep

from urllib.request import urlopen
from urllib import parse
from urllib.request import Request
from urllib.error import HTTPError

class LineOfLotAddress:
    def __init__(self, in_list):
        self.zip_code = self._get_zipcode(in_list)
        self.addr = self._get_address(in_list)

    def _get_zipcode(self, in_list):
        return in_list[0].strip()

    def _get_address(self, in_list):
        addr1 = in_list[1]  # 시도
        addr2 = in_list[3]  # 시군구
        addr3 = in_list[5]  # 읍면동
        addr4 = in_list[7] or in_list[9]  # 7 - 리명, 9 - 행정동
        is_mountain = True if in_list[8] == '1' else False
        addr5 = in_list[10] if in_list[10] and in_list[10] != '0' else ''
        addr6 = in_list[11] if addr5 and in_list[11] and in_list[11] != '0' else ''

        return f"{addr1.strip()} {addr2.strip()} {addr3.strip()} {addr4.strip()}" + \
            f" {'산' if is_mountain else ''} {addr5 if addr5 else ''} {'-'+addr6 if addr6 else ''}"

class NaverGeocording:
    API_URL = 'https://naveropenapi.apigw.ntruss.com/map-geocode/v2/geocode?query='

    def __init__(self, client_id, client_secret):
        self.id = client_id
        self.secret = client_secret

    def request(self, address):
        url = NaverGeocording.API_URL + parse.quote(address)
        request = Request(url)
        request.add_header('X-NCP-APIGW-API-KEY-ID', self.id)
        request.add_header('X-NCP-APIGW-API-KEY', self.secret)
        try:
            response = urlopen(request)
        except Exception as e:
            print(f"request 실패 {address} - HTTP Error {e}")
            sleep(3)
            return (0.0, 0.0)
        else:
            resp_code = response.getcode()
            if resp_code == 200:
                resp_data = response.read().decode('utf-8')
                j_data = json.loads(resp_data)
                if not j_data['addresses']:
                    return (0.0, 0.0)
                return (j_data['addresses'][0]['y'],j_data['addresses'][0]['x'])
            else:
                print(f"Response Status [{resp_code}] {address}")
                return (0.0, 0.0)


def get_addr_file(ref_file):
    with open(ref_file, 'r', encoding='utf-8') as r_fd:
        line = r_fd.readline()  # 우편번호|시도|시도영문|시군구|시군구영문|읍면동|읍면동영문|리명|산여부|행정동|시작주번지|시작부번지|끝주번지|끝부번지
        while True: 
            line = r_fd.readline()
            if not line:
                break
            in_list = line.split('|')
            oLine = LineOfLotAddress(in_list)
            yield oLine


def load_zip_dict(json_file_name):
    try:
        with open(json_file_name, 'r') as r_fd:
            zip_dict = json.load(r_fd)
        return zip_dict
    except FileNotFoundError:
        return {}


def out_lat_lng_by_zip(out_file, ref_file, client_id, client_secret):
    '''zip 코드 기반의 위경도 값을 구하여, 파일에 저장합니다.'''
    zip_dict = load_zip_dict(out_file)
    geo = NaverGeocording(client_id, client_secret) 

    for oLine in get_addr_file(ref_file):
        if oLine.zip_code in zip_dict:
            continue
        latitude, longitude = geo.request(oLine.addr)
        if not latitude or not longitude:
            continue
        zip_dict[oLine.zip_code] = {'lat':latitude, 'lng':longitude, 'addr':oLine.addr}
        print(f"-- DEB: {oLine.zip_code}, {latitude}, {longitude}, {oLine.addr}")

    with open(out_file, 'w', encoding='utf-8') as w_fd:
        json.dump(zip_dict, w_fd, ensure_ascii=False, indent=4)

def proc_parser():
    "입력값 파싱"
    parser = argparse.ArgumentParser()
    parser.add_argument('--ref_file', '-ref', help='지번범위 파일', default='/Users/jhchoi/Project/map/data/20221006_지번범위.txt')
    parser.add_argument('--out_file', '-out', help='출력 파일명', default='/Users/jhchoi/Project/map/data/20221006_latlng_by_zip.txt')
    parser.add_argument('--client_id', '-id', help='네이버 MAP 서비스 클라이언트 id')
    parser.add_argument('--client_secret', '-secret', help='네이버 MAP 서비스 클라이언트 secret')
    return parser.parse_args()

if __name__ == '__main__':
    PARAMS = proc_parser()
    out_lat_lng_by_zip(PARAMS.out_file, PARAMS.ref_file, PARAMS.client_id, PARAMS.client_secret)
    # 위/경도 값을 얻는 과정에서 누락된 값이 있을 수 있어 한번 더 실행합니다.
    out_lat_lng_by_zip(PARAMS.out_file, PARAMS.ref_file, PARAMS.client_id, PARAMS.client_secret)

 

3) 결과 파일 (22년 10월 29일 결과 파일)

20221006_latlng_by_zip.txt
4.74MB