Skip to main content

잔디 날씨 알림 봇 개발기

· 23 min read
서민수

안녕하세요. R&D부 백엔드팀 서민수입니다. 저희 백엔드팀에는 신규 입사자가 들어왔을 때 기본적인 입사 가이드를 진행하고 나서 마지막으로 사내 템플릿을 이용해 간단한 토이 프로젝트를 직접 만들어보는 시간이 있습니다. 이 시간에 제가 개발한 날씨 알림 봇 개발과정에 대해 알아보겠습니다.

잔디 날씨 알림 봇 개발기

주제 선정

비브로스에서는 사내 업무용 메신저로 잔디를 사용합니다. 잔디에는 다른 서비스를 잔디에 연결해서 메시지 알림을 받거나 명령어를 입력하여 외부 서버에 메시지를 전달하고 그에 따른 응답을 받을 수 있는 잔디 커넥트라는 기능이 있습니다. 잔디 커넥트를 이용하면 알림봇이나 검색봇, 번역봇같은 봇들을 만들어서 업무에 유용하게 사용할 수 있습니다.

비브로스 잔디 팀 대화방에는 이미 Github, JIRA, AWS CloudWatch, 개발 일정 알림, 똑닥 통계 및 병원 검색 등을 제공하는 봇들이 존재합니다. 저는 그 중에 기본적인 날씨를 알려주는 봇이 존재하지 않다는 것을 발견하고 날씨 알림 봇을 만들어보기로 했습니다. 날씨는 외출 전에 하루에 한번씩은 찾아보는 정보이기도 하고 기상청에서 제공하는 Open API가 있기 때문에 토이 프로젝트의 주제로 적합할 것 같다고 생각하였습니다. 그리고 얼마 지나지 않아 오늘 뭐먹을지 알려주는 봇을 만들걸이라고 후회하게 됩니다.

기상청 API

기상청에서 제공하는 날씨 API는 약 34개의 여러가지 API가 있는데 이 중에서 저는 동네예보 조회서비스중기예보 조회서비스를 이용하여 날씨 정보를 조회해보기로 했습니다.

동네예보 조회서비스는 최근 1일간 특정 시간의 날씨 정보를 알려주는 초단기 실황 조회 서비스와 1~4시간 이후의 예보 정보를 알려주는 초단기 예보 서비스, 최대 3일까지의 날씨 예보를 조회할 수 있는 동네예보조회 서비스 등으로 구성되어있습니다.

중기예보 조회서비스는 3일~10일 후까지의 강수확률과 날씨 예보를 알려주는 중기 육상 예보조회 서비스와 최저/최고 기온을 알려주는 중기 기온조회 서비스 등으로 구성되어있습니다.

날씨 조회를 어떤식으로 할까 생각을 하다 현재 날씨 조회, 오늘의 날씨 조회, 한주간의 날씨 조회 기능을 만들기로 결정을 했는데 기능 구현을 위해 여러가지 기상청 API로 테스트를 하다보니 몇가지 불편사항이 발생하였습니다.

우선, 기상청의 중기 육상 예보조회 서비스와 중기 기온조회 서비스는 3일부터 10일까지의 예보를 조회할 수 있기 때문에 내일, 모레의 기상정보를 조회하려면 동네예보조회 서비스를 이용하고 3일 이후의 기상정보를 조회하려면 중기 육상 예보조회와 중기 기온조회를 조회해야하여 뒤에서 설명할 Geocoding과 미세먼지 API 등까지 포함하면 한번 호출할 때 5번 이상의 API가 호출되어 미리 조회를 하지 않고 실시간으로 조회하는 방식에서는 속도가 너무 느리다는 문제가 존재하였습니다.

또한 기상청 API만의 특징인 장소조회 시 다른 API들에서 많이 사용하는 위, 경도 방식 조회가 아니라 기상청에서만 사용하는 X좌표 Y좌표가 따로 존재하여 추가 데이터베이스를 구축해 장소 매칭을 해줘야 한다는 단점이 있었습니다.

결국 기상청 API 대신 다른 API를 찾은 결과 OpenWeather라는 API를 찾게 되었습니다.

OpenWeather

OpenWeather는 IT전문가와 데이터 사이언티스트로 구성된 영국의 팀입니다. OpenWeather에서는 전세계의 날씨 정보를 제공하고 있으며 이 데이터를 유/무료 API를 통해 개발자에게 제공하고 있습니다. 무료 플랜에서는 현재 날씨, 1시간의 분당 예보, 2일의 시간당 예보, 7일의 일간 예보, 주요 국가 기상 경보 알림, 지난 5일간의 날씨 조회, 공기 질 조회, Geocoding등의 서비스를 제공합니다. 그리고 One Call API가 존재하여 한번의 호출로 현재 날씨, 분당/시간당/일당 날씨 조회를 한번에 할 수 있다는 장점이 있습니다.

Geocoding?

Geocoding은 지역의 명칭으로 위도와 경도의 좌표값을 얻는 것입니다. 반대로 위경도에서 지역의 명칭을 얻는 것을 Reverse Geocoding이라고 합니다. Geocoding API는 크게 Google, Naver, Kakao 등 지도를 제공하는 서비스 회사에서 주로 제공되고 있습니다.

보통 날씨 관련 서비스를 만들 때 Google의 Geocoding 서비스를 많이 이용하지만 저는 OpenWeather에서도 Geocoding 서비스를 제공하는 것을 보고 기왕이면 하나의 서비스로 해보자는 생각에 OpenWeather의 서비스를 사용하였습니다. 하지만 OpenWeather의 Geocoding API는 다른 서비스와 다르게 도시 이름, 지역 코드, 나라 코드를 전부 입력해야하는 방식이고 해당 코드들을 ISO 3166형식으로 입력하는 방식이라서 실사용이 곤란하였습니다.

결국 다른 API를 찾다가 OpenWeather의 Current Weather data API에서 날씨 검색을 할 때 도시 이름으로 검색을 한 다음 위도, 경도 정보를 반환한다는 것을 발견하고 해당 API를 이용하기로 했습니다.

그리고 반환된 위도와 경도를 가지고 One Call API에서 오늘의 날씨, 7일간의 날씨 정보만 추출해서 사용하고 Air Pollution API를 사용해 초미세먼지(PM 2.5)와 미세먼지(PM10)의 농도를 가져오도록 하였습니다.

잔디 Outgoing Webhook

잔디 커넥트의 Webhook 발신(Outgoing Webhook)은 잔디 메신저에 키워드를 입력하여 외부 서버를 호출하고 그 응답을 전달받아 메신저에 출력할 수 있도록 하는 기능입니다. 봇을 추가할 때 등록한 키워드를 잔디에 입력하면 봇에 등록된 서버에 아래와 같은 메시지가 전송됩니다.

HTTP/1.1 POST
{
"token" : "YE1ronbbuoZkq7h3J5KMI4Tn",
"teamName" : "Toss Lab, Inc.",
"roomName" : "토스랩 코리아",
"writerName" : "김잔디",
"text" : "/날씨 내일 대전 날씨 어때?",
"keyword" : "날씨",
"data": "내일 대전 날씨 어때?",
"platform": "web",
"ip": "127.0.0.1",
"createdAt" : "2017-05-15T11:34:11.266Z"
}

여기에서 keyword 필드는 잔디에서 보낸 키워드 명령어이고 data 필드는 메시지에서 키워드를 제외한 부분입니다. 이 두가지의 필드를 사용해 텍스트를 파싱하여 서버로 요청을 보낼 수 있습니다.

요청을 처리한 서버가 응답 메시지를 보내고 싶은 경우에는 아래와 같은 형식으로 응답하면 잔디 메신저에 응답이 출력됩니다.

{
"body" : "[[PizzaHouse]](http://url_to_text) You have a new Pizza order.",
"connectColor" : "#FAC11B",
"connectInfo" : [{
"title" : "Topping",
"description" : "Pepperoni"
},
{
"title": "Location",
"description": "Empire State Building, 5th Ave, New York",
"imageUrl": "http://www.esbnyc.com/top_deck.png"
}]
}

1

잔디봇이 서버에서 전달된 응답을 대화방에 표시하는 모습

응답의 body가 기본적으로 메시지에 출력되고 connectColorconnectInfo는 선택적으로 필드값이 있을 때 추가 메시지와 메시지 좌측의 색상바로 출력됩니다. imageUrl은 현재 PC용 잔디 메신저에서만 이미지가 출력되고 모바일용에서는 출력되지 않습니다.

개발 과정

똑닥에서는 메인 언어로 Javascript를 사용하여 NodeJS 프로젝트들을 개발하고 있지만 Typescript로의 전환도 활발하게 이루어지고 있는 중입니다. 신규 프로젝트들은 Typescript로 개발되고 있고 사내 템플릿과 ESLint, Prettier를 통해 코딩 컨벤션을 지킬 수 있도록 하고 있습니다. 저는 Typescript 템플릿을 이용해 잔디 날씨 봇을 개발했으며 잔디 봇 구동부날씨 데이터 송수신 부로 나누어서 작업을 진행했습니다.

잔디 봇 구동부분은 잔디가 서버로 전송한 메시지를 MongoDB에 저장하는 부분과 메시지를 파싱하여 각각의 모듈로 보내는 실행 부로 나누어져 있습니다. 추후에 날씨 데이터 출력만이 아닌 다른 기능을 추가할 수 있도록 봇 기능과 날씨 기능을 분리하여 모듈화시켜 확장성있게 설계하였습니다.

import { NotFoundError } from '~/components/error';
import {
JandiResponse,
JandiModuleKeywords,
JandiModuleExecFunction,
} from '../jandi.interface';
import * as weatherModule from './weather/';

const moduleMap = new Map<JandiModuleKeywords, JandiModuleExecFunction>();

moduleMap.set(weatherModule.KEYWORD, weatherModule.exec);

/**
* 잔디 봇 명령어를 실행합니다.
* @param command
* @param parameters
* @returns
*/
export async function exec(
command: JandiModuleKeywords,
parameters: string[]
): Promise<JandiResponse> {
const execModule = moduleMap.get(command);

if (!execModule) {
throw new NotFoundError('모듈이 존재하지 않습니다.');
}

return execModule(parameters);
}

위와 같이 모듈이 들어갈 변수를 Map으로 구성하고 모듈을 import한 다음에 Map에 모듈의 메인 키워드(잔디 봇 명령어)와 메인 실행 함수를 추가해 잔디 봇 명령 실행 시 키워드에 해당하는 함수를 실행하도록 하였습니다.

import * as constants from './weather.constants';
import * as modules from './weather.modules';

export const KEYWORD = constants.KEYWORD;
export const exec = modules.exec;

날씨 모듈쪽의 index에는 키워드와 함수를 등록하는 로직만 넣고 나머지 모듈 파일에서 날씨를 등록하는 메소드를 연결하여 날씨값을 반환하도록 하였습니다.

/**
* 지점의 좌표값을 조회하기 위해 OpenWeatherMap 날씨 예보 API를 호출합니다.
* @param location
* @returns
*/
async function getForecast(
location: string
): Promise<OpenWeatherMapForecastApiResponse> {
try {
const forecast = await got(encodeURI(getForecastApiUrl(location)));

return JSON.parse(forecast.body);
} catch (err) {
throw new NotFoundError(NOT_FOUND_LOCATION);
}
}

/**
* 공기질 조회 API를 호출합니다.
* @param latitude
* @param longitude
* @returns
*/
async function getAirPollution(
latitude: number,
longitude: number
): Promise<OpenWeatherMapPollutionApiResponse> {
const pollution = await got(
encodeURI(getAirPollutionUrl(latitude, longitude))
);

return JSON.parse(pollution.body);
}

/**
* OpenWeatherMap 날씨 조회 API를 호출합니다.
* @param location
* @returns
*/
async function getWeather(
location: string
): Promise<OpenWeatherMapWeatherApiResponse> {
const forecast = await getForecast(location);

const response = await got(
encodeURI(
getWeatherApiUrl(forecast.city.coord.lat, forecast.city.coord.lon)
)
);

const weather: OpenWeatherMapWeatherApiResponse = JSON.parse(response.body);

weather.latitude = forecast.city.coord.lat;
weather.longitude = forecast.city.coord.lon;

return weather;
}

OpenWeather와의 HTTP 통신에는 got 모듈을 사용하였습니다. 이전에는 HTTP 요청 모듈로 axios를 많이 사용하였는데 axios는 유지보수가 느리게 되는 이슈가 있어 서버간 통신에는 got, 클라이언트 fetch용 모듈로는 ky가 떠오르고 있다고 합니다. 저는 got을 사용해서 우선 OpenWeather 현재 날씨 API를 호출하고 One Call API에서 그 값을 받아 위도와 경도를 추출하여 One Call API 반환값에 추가하였습니다.

2

서버 기능 개발 완료 후 Postman을 이용해 API 테스트를 완료하고 잔디 커넥트 봇을 추가하여 잔디에서 날씨 명령어 실행 결과 정상적으로 날씨를 조회하는 것을 확인할 수 있었습니다.

개선할 점

현재 개발된 기능은 현재 날씨 조회, 오늘의 날씨 조회, 7일간의 날씨 조회입니다. 여기에 더해서 일반적으로 날씨에 관해 궁금한 부분인 내일의 날씨 조회어제 온도와 오늘 온도를 비교하는 기능을 추가하고 싶었습니다. 그리고 사내 메신저에서 사용하는 용도로는 그렇게 많은 호출이 일어나지는 않기 때문에 OpenWeather API 무료 플랜 호출 횟수인 분당 60회, 한달에 100만회까지 가지는 않겠지만 날씨라는 것이 그렇게 수시로 변하는 부분이 아니고 현재 날씨 API 호출 시 Current Weather Data -> One Call -> Air Pollution의 세번의 API 호출이 발생하기 때문에 응답시간이 대략 1초정도로 다소 느린 것을 확인할 수 있었습니다.

따라서 이를 개선하기 위해 현재 날씨 조회 데이터는 명령어 실행 시 Redis에 날씨 값을 저장하고 다음에 명령어를 실행했을 때 Redis에 저장된 시간과 현재 시간을 확인하여 10분이 넘어갔을 때는 새로운 데이터를 가져오고 10분 미만이면 Redis에서 조회하는 캐싱기능을 개발하려고 시도해보았습니다. 오늘의 날씨와 7일 날씨 조회도 MongoDB에 데이터를 저장하여 하루나 반나절 단위로 캐싱하는 기능을 추가하려고 했지만 Redis에서는 2 depth 이상의 Object를 저장할 수 없는 등 Mongoose와 Redis의 데이터 저장형식 간에 차이가 있어서 어려움을 겪다 시간 관계상 나중으로 미루게 되었습니다.

그리고 OpenWeather에서 사용중인 Geocoding 시스템이 어떤 경우엔 구로 검색할 때만 결과값이 나오고 어떤 경우엔 동으로 검색할 때만 결과값이 나오는 등 애매하게 검색이 되는 문제가 있어서 추후에 Google, Naver, Kakao등의 Geocoding을 사용하여 지명 검색을 개편할 예정입니다.

3

4

날씨 봇을 이용해서 여의도라고 검색한 결과와 Google Geocoding에서 여의도를 검색한 결과

마지막으로 OpenWeather API는 날씨 정보는 비교적 현재 날씨와 비슷한 값을 나타냈지만 미세먼지 정보같은 경우에 현재와 많이 다른 값이 조회되기도 하였습니다.

5

6

기상청에서 수집된 미세먼지 데이터와 다른 값이 출력되는 모습

날씨 봇 개편 시 날씨는 기존의 OpenWeather API를 활용하고 Geocoding과 미세먼지 정보는 Google, 기상청 API를 활용하도록 개선하면 더 정확한 날씨 정보를 얻을 수 있을 것입니다.

마치며

여기까지 토이 프로젝트로 만들어본 잔디 메신저 날씨 알림 봇 개발과정이었습니다. 비브로스 백엔드 팀의 사내 템플릿을 처음으로 사용해서 만들어본 기념비적인(?) 프로젝트이기도 해서 열심히 작업했지만 개인적으로 아쉬운 기능들이 몇가지 남아있는 프로젝트였던 것 같습니다. 이후에 보완할 부분을 개선하면 더 쓰기 편한 날씨 봇이 될 수 있을 것이라고 생각합니다. 개발을 마치고 비브로스 봇 전용 채팅방에 날씨봇을 올려보니 감사하게도 많은 분들이 열심히 사용해주셔서 나름 뿌듯했습니다.

7

열심히 취약점을 체크해주시는 직원분의 모습

백엔드 팀의 토이 프로젝트는 신규 입사자분이 팀 내에서 사용하는 서버 템플릿 코드를 한번 경험해보고 CI/CD 과정까지 진행해서 테스트 서버에 배포해볼 수 있도록 해보는 과정입니다. 이 과정을 진행하면서 팀에서 사용하는 코드들과 친숙해질 수 있고 개발 -> PR -> CI/CD로 이어지는 개발 프로세스의 전체 플로우를 직접 경험해 볼 수 있습니다. 저도 토이 프로젝트를 진행하면서 제가 기존에 작성했었던 Typescript 프로젝트의 구조에서 좀 더 팀의 개발문화에 가까운 형식으로 맞추면서 코드의 구조를 다져나갈 수 있었습니다. 아무래도 새로운 회사에서 일을 하게 되면 기존의 일하던 방식과 다른 부분이 많기 때문에 적응하는데 시간도 많이 들고 토이 프로젝트 없이 실무코드를 직접 접하는 경우에는 세상에서 가장 보기 힘든 코드는 남이 짠 코드라는 말도 있듯이 파악하는데 어려움이 많은데 백엔드 팀에서는 템플릿 코드가 있어서 그 구조를 바탕으로 실무 코드를 익히기가 수월하다는 느낌을 받았습니다. 또한 이전 회사에서는 코드리뷰 문화가 없어서 똑닥에서 코드리뷰와 스크럼을 경험해보면서 하루하루의 계획을 세우고 팀원들과 같이 고민하면서 소통을 통해 코드를 발전시켜 나갈 수 있었습니다. 저도 PR을 통해 다른 분들의 코드리뷰를 진행하기도 했는데 다른 분들의 코드를 살펴보면서 여러가지 개발 패턴과 방법에 대해 학습할 수 있는 유익한 시간이 되었다고 생각합니다.

이상으로 잔디 봇 날씨 개발기를 마치겠습니다. 긴 글 읽어주셔서 감사합니다.