네이버 오픈 API · Map API를 활용하여 특정 장소의 위도, 경도 정보 DB에 저장하기

김민재·2025년 8월 7일
0

개인프로젝트에서 특정 공연장의 위치 정보를 지도로 보여주어야 했다. 이를 위해서는 해당 공연장의 위도와 경도 정보를 알아야 했다.

특정 공연 정보를 열 때마다 위도와 경도 정보를 api를 통해 불러오게 되면 불필요한 호출이 빈번하게 발생하게 되는 것이므로, 나는 API 호출 제한이 없는 Supabase DB의 공연장 데이터 테이블에 address, latitude, longitude라는 컬럼을 만들어 위치 정보를 저장해놓고 필요할 때 불러오기로 했다.

이를 위해서 데이터 흐름의 순서를 짜보면 다음과 같다.

  1. DB에서 공연장 이름 불러오기
  2. Naver 오픈 API로 해당 공연장 이름을 검색한 후 위도와 경도 받아오기
  3. 해당 위도와 경도 정보를 DB에 저장하기

우선 DB에 address, latitude, longitude 컬럼을 만든다.

이 때 latitude, longitude 컬럼의 경우 데이터의 타입으로 float8(64bit 부동소수점)을 지정한다.

네이버 검색 API를 통해 받아온 위도와 경도 값이 다음과 같이 10자리 정도의 유효숫자로 표현되며 위치 정보는 정확히 표현되어야 하므로, 7자리 정도까지만 표현가능한 float4는 적절하지 않다.


네이버 검색 api가 공연장의 위치 정보를 정확히 가져오는지 알아보기 위해 DB에 존재하는 여러 공연장명을 쿼리로 하여 결과를 모니터링 하던 중, 한 가지 문제가 있었다.

간혹 공연장명에 파랑새극장(구. 샘터파랑새극장) 과 같이 이전 이름이 같이 명시되어 있는 경우가 있었는데, 이를 그대로 쿼리에 포함시켰을 때에는 검색 결과가 제대로 나오지 않는 경우가 많았다. 따라서 나는 모든 공연장 명의 (구. 이후 부분을 모두 제거하기로 했다.

이전 이름이 포함된 쿼리 검색 결과

이전 이름 제거 후 검색 결과

이를 위해 Supabase SQL Editor에서 regex를 포함한 쿼리를 실행하여 DB 테이블에 있는 데이터를 수정하였다.

UPDATE concert_halls
SET fcltnm = 
  TRIM(
    REGEXP_REPLACE(fcltnm, '\(구.*$', '')
  )
WHERE fcltnm LIKE '%(구%';


이제 코드를 짤 차례이다.

💻 Node.js용 검색 예제 코드

// 네이버 검색 API 예제 - 블로그 검색
var express = require('express');
var app = express();
var client_id = 'YOUR_CLIENT_ID';
var client_secret = 'YOUR_CLIENT_SECRET';
app.get('/search/blog', function (req, res) {
   var api_url = 'https://openapi.naver.com/v1/search/blog?query=' + encodeURI(req.query.query); // JSON 결과
//   var api_url = 'https://openapi.naver.com/v1/search/blog.xml?query=' + encodeURI(req.query.query); // XML 결과
   var request = require('request');
   var options = {
       url: api_url,
       headers: {'X-Naver-Client-Id':client_id, 'X-Naver-Client-Secret': client_secret}
    };
   request.get(options, function (error, response, body) {
     if (!error && response.statusCode == 200) {
       res.writeHead(200, {'Content-Type': 'text/json;charset=utf-8'});
       res.end(body);
     } else {
       res.status(response.statusCode).end();
       console.log('error = ' + response.statusCode);
     }
   });
 });
 app.listen(3000, function () {
   console.log('http://127.0.0.1:3000/search/blog?query=검색어 app listening on port 3000!');
 });

var api_url = "https://openapi.naver.com/v1/search/local?query=" + encodeURI(req.query.query);

여기서 encodeURI(req.query.query)를 해주는 이유는, req.query.query 의 값으로 공백을 포함한 문자열이 올 수도 있기 때문이다.

예를 들어, 사용자가 "서울 강남" 이라는 검색어를 입력했다고 가정해 보자.

https://openapi.naver.com/v1/search/local?query=서울 강남
여기서 공백(' ')은 URL에서 허용되지 않는 문자이다.

하지만 encodeURI() 보다는 encodeURIComponent() 를 쓰는 게 더 안전하다.

왜냐하면 encodeURI()는 URL 전체를 인코딩할 때 쓰라고 만들어진 함수라
쿼리 문자열에서 중요한 예약문자들인 ?, &, = 등을 인코딩하지 않는다.

그런데 쿼리 파라미터 값에 &, = 같은 문자가 들어가면,
이들이 URL에서 다른 의미(파라미터 구분자 등)로 해석될 수 있기 때문에
쿼리 값에 포함된 문자는 반드시 모두 인코딩해주는 encodeURIComponent() 를 써야 안전하다.

또한 보다시피 현재는 잘 사용하지 않는 변수 키워드 var 와 deprecated된 request 패키지를 사용하고 있어 코드의 수정이 필요했다. 수정된 API 호출 함수를 포함한 전체 코드는 다음과 같다.

주의해야 할 점은, 공연명을 통해 네이버 검색 API를 호출했을 시 검색 결과가 없는 경우도 존재하므로, 해당 경우도 따로 처리를 해주어야 한다.


💻 수정 코드

import supabase from "@/apis/supabase-client";

const naverSearchClientId = process.env.NAVER_SEARCH_CLIENT_ID;
const naverSearchClientSecret = process.env.NAVER_SEARCH_CLIENT_SECRET;

if (!naverSearchClientId) {
  throw new Error("환경변수 SUPABASE_ANON_KEY가 설정되어 있지 않습니다!");
}

if (!naverSearchClientSecret) {
  throw new Error("환경변수 SUPABASE_ANON_KEY가 설정되어 있지 않습니다!");
}

interface locationObj {
  roadAddress: string;
  mapx: string;
  mapy: string;
}

const searchLocationWithNaverAPI = async (fcltnm: string): Promise<locationObj> => {
  const api_url =
    "https://openapi.naver.com/v1/search/local?query=" +
    encodeURIComponent(fcltnm);

  const response = await fetch(api_url, {
    method: "GET",
    headers: {
      "X-Naver-Client-Id": naverSearchClientId,
      "X-Naver-Client-Secret": naverSearchClientSecret,
    },
  }).then((res) => res.json());

  console.log(response);

  const { roadAddress, mapx, mapy } = response.items[0];

  return { roadAddress, mapx, mapy };
};

const importToDB = async (name: string, locationInfo: locationObj): Promise<void> => {
  const address = locationInfo.roadAddress;
  const latitude = Number(locationInfo.mapy) / 10 ** 7;
  const longitude = Number(locationInfo.mapx) / 10 ** 7;

  const {error} = await supabase
    .from("concert_halls")
    .update({address, latitude, longitude})
    .eq("fcltnm", address);

  if (error) {
    console.log("DB concert_halls에 위치 데이터 삽입 실패", error);
  } else {
    console.log("DB concert_halls에 위치 데이터 삽입 성공");
  }
}

const importLocation = async (): Promise<void> => {
  const { data, error } = await supabase.from("concert_halls").select("fcltnm");

  if (error) {
    console.log("DB concert_halls에서 공연장명 데이터 가져오기 실패", error);
  } else {
    data.forEach(async (element) => {
      const name = element.fcltnm;
      const locationInfo = await searchLocationWithNaverAPI(name);
      await importToDB(name, locationInfo);
    });
  }
};

importLocation();

⚠️ Naver 검색 API 속도 제한 초과 에러

그런데, 해당 파일을 실행한 뒤 다음과 같은 오류가 발생했다.

npm run insert-location-info

> insert-location-info
> ts-node -r tsconfig-paths/register ./database/insert-location-info.ts

{
  errorMessage: 'Rate limit exceeded. (속도 제한을 초과했습니다.)',
  errorCode: '012'
}
TypeError: Cannot read properties of undefined (reading '0')
    at /Users/MJKIM/Desktop/dev/project/classichub/classic-hub/server/database/insert-location-info.ts:35:53
    at Generator.next (<anonymous>)
    at fulfilled (/Users/MJKIM/Desktop/dev/project/classichub/classic-hub/server/database/insert-location-info.ts:5:58)
    at processTicksAndRejections (node:internal/process/task_queues:95:5)

원인은 초당 10건 이상의 요청을 했기 때문이었다. 운영 서버에 부하를 가중시키는 사항이므로 제한을 둔 것 같다.


따라서 나는 setInterval() 을 통해 0.1초의 간격으로 네이버 검색 API가 호출되도록 코드를 작성했다. 이 때 혹시 모를 상황에 대비하여 데이터 삽입이 완전하게 이루어지도록 하기 위해 시간 간격을 0.11초로 지정했다.

const importLocation = async (): Promise<void> => {
  const { data, error } = await supabase.from("concert_halls").select("fcltnm");

  if (error) {
    console.log("DB concert_halls에서 공연장명 데이터 가져오기 실패", error);
  } else {
    let idx = 0;
    const interval = setInterval(async () => {
      if (idx === data.length) clearInterval(interval);

      const name = data[idx++].fcltnm;
      const locationInfo = await searchLocationWithNaverAPI(name);
      await importToDB(name, locationInfo);
    }, 110); // 0.11초 간격으로 API 호출
  }
};

⚠️ 데이터가 일부만 삽입되는 문제

속도 제한 초과 에러는 해결했지만, 이번에는 전체 공연장의 위치 정보 중 일부만 삽입되는 문제가 발생했다.

원인은 supabase client API의 select 때문이었다.

select 메서드는 기본적으로 데이터 테이블의 1,000 개 row까지만 가져오는데, 내가 가지고 있는 공연장 정보 데이터는 1643개의 row였으므로 일부가 삽입되지 않은 것이었다.

따라서 나는 Settings > Data API > Max rows 를 2,000으로 설정하여 문제를 해결하였다.

데이터가 잘 삽입된 모습이다.

profile
넓이보단 깊이있게

0개의 댓글