개발/library, framework

[node.js] 위도, 경도 입력시 대한민국 시군구를 반환하는 라이브러리

prpn97 2025. 9. 10. 10:56

https://github.com/sh5080/korea-sigungu-geocoding

 

GitHub - sh5080/korea-sigungu-geocoding: api를 호출하지 않고 대한민국 시군구 지리정보를 처리하는 라이

api를 호출하지 않고 대한민국 시군구 지리정보를 처리하는 라이브러리입니다. . Contribute to sh5080/korea-sigungu-geocoding development by creating an account on GitHub.

github.com

 

계기

 

일반적으로 지리 데이터는 너무 방대하기 때문에 클라이언트가 들고 있기엔 너무 무거워서 정확한 데이터를 출력하기 위해서는 계산해주는 별도의 서버가 존재하고, api를 호출해야 한다. 네이버 api의 경우 월에 1000건 이상은 요청 당 과금이 되는데(물론 매우 저렴하다.), 내가 원하는 기능은 자세한 데이터까지는 아니라서 단순히 별도의 서버를 거치지 않고도 구현할 수 있지 않을까 고민하다가 구현하게 되었다. 

 

과정

1. 데이터 준비

먼저 대한민국 행정구역의 데이터가 필요하다. 공공데이터포털에서 링크된 vworld 페이지에는 서비스가 종료되어 '법정구역정보'를 통해 데이터를 얻어낼 수 있었다. 

https://www.vworld.kr/dtmk/dtmk_ntads_s002.do?searchBrmCode=&datIde=&searchFrm=&dsId=21&pageSize=10&pageUnit=10&listPageIndex=1&gidsCd=&searchKeyword=%EB%B2%95%EC%A0%95%EA%B5%AC%EC%97%AD&searchSvcCde=&searchOrganization=&dataSetSeq=21&svcCde=NA&searchTagList=&pageIndex=1&gidmCd=&sortType=00&datPageIndex=1&datPageSize=10&startDate=2024-09-10&endDate=2025-09-10&fileGbnCd=AL&dsNm=&formatSelect=

 

브이월드

국가가 보유하고 있는 공개 가능한 공간정보를 모든 국민이 자유롭게 활용할 수 있도록 다양한 방법을 제공합니다.

www.vworld.kr

 

2. 데이터 간소화

처음 데이터를 다운로드받았을 때 약 244mb정도의 압축파일을 받게 된다. 압축풀고 확인해보면 SIG,LIO, EMD라는 네이밍으로 구분된 별도의 압축파일이 있고, SIG 확장자가 shx, shp, qmd, prj, dbf, cpg 로 확인되는데, 우리에게 필요한 파일은 SIG의 shp파일이다.

  • SIG (시군구) → 광역시·도 밑의 시·군·구 단위
    • .shp : 지리 데이터(도형, 좌표)
  • EMD (읍면동) → SIG 밑의 읍·면·동
  • LIO (리) → 읍·면 밑의 “리”

그런데 여전히 SIG.shp 파일만 해도 173mb정도 되기 때문에 그대로 서비스하면 브라우저에서 불러오는 데만 수십초가 소요될 수 있어 라이브러리로 쓰기엔 너무나도 무거워서 간소화를 하기로 했다.

mapshaper를 사용하면 .shp파일을 확인, 수정할 수 있고 아래 명령어를 통해 .shp의 내용을 확인해볼 수 있었다. 

npx mapshaper AL_D001_00_20250804_SIG.shp -info -sample 5

[info] 
==================================
Layer:    AL_D001_00_20250804_SIG
----------------------------------
Type:     polygon
Records:  252
Bounds:   -10044.953281908005,57965.0100976777,632508.7802004848,669329.1079199004
CRS:      +proj=tmerc +x_0=200000 +y_0=600000 +lon_0=127 +lat_0=38 +ellps=GRS80
Source:   AL_D001_00_20250804_SIG.shp

Attribute data
-------+--------------------------
 Field | First value
-------+--------------------------
 A0    | 11
 A1    | '11110'
 A2    | '종로구'
 A3    | 2025-06-01T00:00Z (Date)
 A4    | '11110'
-------+--------------------------

그렇다면 우리에게 필요한 것은 해당 좌표에 대한 코드와 지역명만 있으면 된다.

'서울특별시' 종로구 로 보여주려 하는데 당장에는 종로구만 보여줄 수 있겠지만 지역코드를 변환한다면 될 것 같다. 

찾아보니 앞 2자리를 통해서 ~시, ~도 를 확인할 수 있었고, 당장 A0만으로도 보여줄 수 있지만 누군가는 지역코드 자체가 필요한 니즈도 있다고 판단해서 A1과 A2를 남기기로 했다. 

npx mapshaper AL_D001_00_20250804_SIG.shp \
  -rename-fields sig_cd=A1,sig_kor_nm=A2 \
  -simplify 1% \
  -proj wgs84 \
  -filter-fields sig_cd,sig_kor_nm \
  -o format=geojson sigungu.json

-rename-fields

A1, A2필드는 구분이 편하도록 sig_cd, sig_kor_nm으로 네이밍

 

-simplify 1%

원래 행정구역 경계는 수천~수만 좌표점으로 구성돼서 용량이 큰데 1% → 원래 꼭짓점의 1%만 남기고 단순화하도록 했다. 이렇게 했을 때 150mb를 넘는 파일이 10mb미만으로 줄게 된다. 

 

-proj wgs84 (해당 옵션은 꼭 필요하다고 하나 이해하고 진행한건 아님. ai도움을 받음.)

좌표계를 변환하는 옵션, 원본 SIG 데이터는 보통 EPSG:5179 (중부원점, UTM-K) 같은 한국 TM 좌표계로 되어 있고 wgs84는 EPSG:4326 (위도·경도) 좌표계 → 전 세계적으로 표준, GeoJSON/웹지도에서 필수. 이 옵션을 안 해주면 Leaflet, Mapbox 같은 라이브러리에서 제대로 안 찍힌다고 한다. 

 

-filter-fields

필요한 옵션만 필터링함

 

-o

결과를 geojson으로 출력하고 sigungu.json으로 저장함.

 

3. 필요한 기능 정리

  1. 좌표 → 시군구 찾기 (Geocoding)
    • 위도·경도를 입력받아 해당 점이 포함된 시군구를 반환
    • 행정구역 경계 다각형 내부 포함 여부를 판별해야 함
  2. 시군구 코드로 조회
    • sig_cd를 입력하면 시군구 코드/이름/시도 정보 반환
  3. 시군구 이름으로 조회
    • 한글 행정구역명(sig_kor_nm)으로 검색
  4. 전체 시군구 목록 조회
    • 라이브러리 내에 포함된 모든 시군구 데이터를 리스트로 제공
  5. 좌표 거리 계산
    • 두 좌표 간 거리 계산 (하버사인 공식)
    • 단위 선택: km, m, mile

 

구현

위 기능 정의를 토대로 KoreaSigunguGeocoding 클래스를 구현했다.

  • GeoJSON을 메모리에 올려두고 재사용
  • 좌표가 시군구 경계 다각형에 속하는지 판별
  • 검색 편의성을 위해 코드/이름 기반 검색 메소드 추가
  • 부가 기능으로 두 좌표 간 거리 계산까지 지원

그리고 테스트코드를 작성했다. 

위도, 경도는 여기를 참고했다. https://www.ride.bz/%EC%A7%80%EB%8F%84/

 

위도/경도 검색 - 라이드(RIDE)

위도/경도 검색 위도 : 37.3595704 경도 : 127.105399 사용 방법 1. 검색하고 싶은 위치를 클릭하세요. (건물 등은 인근 보도를 활용하여 탑승할 정확한 위치를 클릭하세요) 2. 화면 확대/축소 방법 PC : 마

www.ride.bz

 

  describe('geocode', () => {
    test('서울시 중구 좌표로 시군구 찾기', () => {
      const result = geocoding.geocode(126.975470, 37.554279);

      expect(result.sigungu).toBeTruthy();
      expect(result.sigungu?.sig_kor_nm).toBe('중구');
      expect(result.sigungu?.sido_nm).toBe('서울특별시');
      expect(result.sigungu?.sido_cd).toBe('11');
    });

    test('부산광역시 해운대구 좌표로 시군구 찾기', () => {
      const result = geocoding.geocode(129.1623529, 35.1610318);
      expect(result.sigungu).toBeTruthy();
      expect(result.sigungu?.sig_kor_nm).toBe('해운대구');
      expect(result.sigungu?.sido_nm).toBe('부산광역시');
      expect(result.sigungu?.sido_cd).toBe('26');
    });

    test('경기도 수원시 좌표로 시군구 찾기', () => {
      const result = geocoding.geocode(127.0498022, 37.2690603);
      expect(result.sigungu).toBeTruthy();
      expect(result.sigungu?.sig_kor_nm).toBe('수원시 영통구');
      expect(result.sigungu?.sido_nm).toBe('경기도');
      expect(result.sigungu?.sido_cd).toBe('41');
    });

    test('제주특별자치도 제주시 좌표로 시군구 찾기', () => {
      const result = geocoding.geocode(126.5312, 33.4996);
      expect(result.sigungu).toBeTruthy();
      expect(result.sigungu?.sig_kor_nm).toBe('제주시');
      expect(result.sigungu?.sido_nm).toBe('제주특별자치도');
      expect(result.sigungu?.sido_cd).toBe('50');
    });

    test('해당하지 않는 좌표', () => {
      const result = geocoding.geocode(0, 0);
      expect(result.sigungu).toBeNull();
    });
  });

  describe('findByCode', () => {
    test('시군구 코드로 찾기', () => {
      const sigungu = geocoding.findByCode('11500');
      expect(sigungu).toBeTruthy();
      expect(sigungu?.sig_kor_nm).toBe('강서구');
    });

    test('존재하지 않는 코드', () => {
      const sigungu = geocoding.findByCode('99999');
      expect(sigungu).toBeNull();
    });
  });

  describe('findByName', () => {
    test('시군구 이름으로 찾기', () => {
      const sigungu = geocoding.findByName('강서구');
      expect(sigungu).toBeTruthy();
      expect(sigungu?.sig_cd).toBe('11500');
    });

    test('존재하지 않는 이름', () => {
      const sigungu = geocoding.findByName('존재하지않는구');
      expect(sigungu).toBeNull();
    });
  });

  describe('getAllSigungu', () => {
    test('모든 시군구 목록 반환', () => {
      const allSigungu = geocoding.getAllSigungu();
      expect(allSigungu.length).toBeGreaterThan(0);
      expect(allSigungu[0]).toHaveProperty('sig_cd');
      expect(allSigungu[0]).toHaveProperty('sig_kor_nm');
    });
  });

  describe('calculateDistance', () => {
    test('서울에서 부산까지 거리 계산 (km)', () => {
      // 서울시청 (37.5665, 126.9780)에서 부산시청 (35.1796, 129.0756)까지
      const distance = geocoding.calculateDistance(37.5665, 126.9780, 35.1796, 129.0756, 'km');
      expect(distance).toBeCloseTo(325, 0); // 약 325km
    });

    test('서울에서 부산까지 거리 계산 (m)', () => {
      const distance = geocoding.calculateDistance(37.5665, 126.9780, 35.1796, 129.0756, 'm');
      expect(distance).toBeCloseTo(325000, -3); // 약 325,000m
    });

    test('서울에서 부산까지 거리 계산 (mile)', () => {
      const distance = geocoding.calculateDistance(37.5665, 126.9780, 35.1796, 129.0756, 'mile');
      expect(distance).toBeCloseTo(202, 0); // 약 202마일
    });

    test('같은 좌표 간 거리는 0', () => {
      const distance = geocoding.calculateDistance(37.5665, 126.9780, 37.5665, 126.9780);
      expect(distance).toBe(0);
    });

    test('기본 단위는 km', () => {
      const distance = geocoding.calculateDistance(37.5665, 126.9780, 35.1796, 129.0756);
      expect(distance).toBeCloseTo(325, 0);
    });
  });

});

 

후기

내부적으로 사용할 npm 패키지를 업로드해본 적이 있긴 하지만, 내가 사용할겸 유의미한 라이브러리를 만들어 올린다는게 참 뿌듯한 것 같다. 당장은 간단한 라이브러리고 내가 필요한 내용들을 구현해서 업데이트하고 있지만, 커져서 다른 개발자들과 신규 기능추가나 메인테이닝해보면 좋을 것 같다.

728x90