Grafana로 실내/외 공기질 대시보드 구축하기
Last updated: Mar 14, 2024
Grafana를 이용하여 실내/외 공기질 대시보드를 구축한 과정을 설명하고자 한다. 공기질 대시보드를 구축하기 시작한 과정은 이전 포스트에서 확인할 수 있다.
대시보드 플랫폼 선택 - Grafana
Grafana는 Grafana Labs에서 관리하고 있는 오픈소스 대시보드 및 시각화 플랫폼이다. 다양한 데이터 소스로부터 한눈에 보기 편한 대시보드를 만들 수 있고, 이전 포스트에서 계기가 된 IT 개발자의 패시브 하우스 대시보드 또한 Grafana로 되어있었기 때문에 선택하게 되었다.
시계열 데이터 저장 - InfluxDB
우선 수집하고자 하는 데이터는 시간별 실내/외 공기질 데이터이다. 이러한 데이터를 저장하기 위해서는 시계열 데이터베이스가 효과적이다. InfluxDB는 시계열 데이터를 저장하고 관리하기 위한 오픈소스 데이터베이스이다.
처음에는 Grafana랑 연결성이 쉽고 많이들 사용한다는 Prometheus를 사용하려고 했으나,
- 인스턴스나 노드를 확장할 이유가 없는 환경
- 데이터 수집이 Push 방식이 아닌 Pull 방식
- 데이터 수집 포맷이 JSON이 아닌 OpenMetrics 포맷
등의 이유로 Prometheus 대신 InfluxDB를 선택하게 되었다. InfluxDB는 Push 방식으로 데이터를 수집하고, 데이터를 쉽게 Push 할 수 있는 REST API를 제공한다. 해당 REST API를 이용한 다양한 언어의 클라이언트 라이브러리도 제공하고 있어서, 데이터 수집 스크립트를 직접 짜기에 용이했다.
인프라 구축

인프라는 다음과 같이 구축하기로 결정했다. 현재 사는 집에 서버로 24시간 돌릴만한 디바이스가 없기 때문에 Grafana와 InfluxDB를 개인 클라우드에 설치하기로 했다.
우선 Grafana의 공식 문서에서 안내된 대로 Grafana를 설치한다.
Cloudflare Tunnel 원리
설치 및 Config 파일 수정 후 Grafana를 실행하면, 기본적으로 3000번 포트로 서비스된다. 웹 서버 및 인증서 관리 인프라를 따로 구축하기는 귀찮았기 때문에 포트를 따로 변경하지 않을 예정이다. 대신, Cloudflare Tunnel를 이용하고자 한다. Tunnel Daemon(cloudflared)를 origin 서버에 설치를 해두면 모든 트래픽을 클라우드플레어를 통과하게끔 만들 수 있다. 이를 통해 Origin 서버의 IP를 숨기고, 웹서버를 별도로 구축할 필요도 없고, SSL 인증서를 따로 관리하지 않아도 된다. 비슷한 제품으로는 ngrok이라는 것이 있다.
서버에 cloudflared를 설치하고 Zero Trust Console에서 터널을 생성하여 cloudflared와 연결한 후, Public Hostname으로 외부에서 접속할 URL 주소를 입력해 준다. cloudflared는 Grafana와 같은 서버에 있고, Grafana는 현재 3000번 포트로 서빙되고 있기 때문에 해당 hostname을 localhost:3000
으로 연결해주면 끝이다.

터널 설정을 완료하고 hostname으로 접속하면 Grafana 로그인 페이지가 정상적으로 나오는 걸 확인할 수 있다.

InfluxDB도 공식 문서에 나온 대로 설치를 진행한다. 설치를 완료하면, InfluxDB CLI를 추가로 설치한다.
우선 InfluxDB는 일반적인 RDB와 조금 다르기 때문에 InfluxDB의 Data Elements에 대해 설명해야 한다.

크게 Bucket, Measurement, Field, Point만 짚고 넘어가자.
- Bucket: InfluxDB에서 데이터를 저장하는 공간이다.
- Measurement: 저장된 데이터의 이름이라고 생각하면 편하다. 이 이름을 통해 해당 데이터에 어떤 Field가 있는지 확인이 가능하다.
- Field: InfluxDB 데이터의 Key-Value 쌍을 Field라고 한다. 따라서 Field key, Field value로 나뉜다.
- Point: Measurement, Field, Timestamp를 포함한 단일 레코드 데이터이다. ex)
2019-08-18T00:00:00Z census ants 30 portland mullen
인프라 구축 단계에서는 Bucket만 생성하고, 데이터 수집 단계에서 Bucket에 데이터를 저장할 예정이다. 실내 측정 데이터와 실외 측정 데이터를 따로 저장하게끔 Bucket을 두 개 생성하였다. 데이터를 자동으로 삭제해 주는 Retention Period는 1년으로 설정해두었다. 1년 이전의 데이터는 딱히 필요 없을 것 같았고, 운용하는 클라우드 용량이 크지 않기 때문에 적당히 저장하여야 한다.

데이터 수집
데이터 수집은 간단히 설명하자면 파이썬 스크립트를 작성하여 이를 Crontab으로 주기적으로 실행하게끔 만들었다. 스크립트에는 데이터를 받아오고 이를 올바르게 DB에 저장하는 단계까지 수행한다.
실내 공기질
실내 공기질은 현재 사용하고 있는 AirGradient에서 직접 데이터를 가져오고자 한다. AirGradient는 친절하게도 API를 제공하고 있어서 이를 이용해 데이터를 가져올 수 있다. AirGradient 대시보드에 접속해서 API Key를 발급받은 후, 내가 소유한 센서의 데이터를 가져오는 API를 호출하면 다음과 같이 값을 반환한다.
{
"locationId": "<location_id_in_int>",
"locationName": "Home monitor",
"pm01": 3,
"pm02": 6,
"pm10": 6,
"pm003Count": 440,
"atmp": 26.04,
"rhum": 38,
"rco2": 848,
"tvoc": 140.51997,
"wifi": -31,
"timestamp": "2024-03-13T16:47:52.000Z",
"ledMode": "co2",
"ledCo2Threshold1": 1000,
"ledCo2Threshold2": 2000,
"ledCo2ThresholdEnd": 4000,
"serialno": "<serial_number_in_string>",
"firmwareVersion": null,
"tvocIndex": 146,
"noxIndex": 2
}
InfluxDB에 기록하고자 하는 값들은 모두 API가 반환하고 있다. 이제 이 값을 스크립트가 돌아갈 서버에서 받아와서 InfluxDB에 저장하면 된다. 파이썬에는 InfluxDB Client 라이브러리가 있기 때문에 이를 이용하여 스크립트를 작성한다.
import requests
import time
from influxdb_client import InfluxDBClient, Point
from datetime import datetime
def get_indoor_data() -> dict:
indoor_api_url = 'https://api.airgradient.com/public/api/v1/locations/<location id>/measures/current?token=<api token>'
try:
response = requests.get(indoor_api_url)
json_data = response.json()
except Exception as e:
print(e)
return {}
return json_data
def main():
indoor_data = get_indoor_data()
current_timestamp = int(time.time())
if not indoor_data:
print(f"[{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(current_timestamp))}] - No data written")
return
if indoor_data:
indoor_point = Point('indoor') \
.field('temp', float(indoor_data['atmp'])) \
.field('humidity', int(indoor_data['rhum'])) \
.field('pm01', int(indoor_data['pm01'])) \
.field('pm25', int(indoor_data['pm02'])) \
.field('pm10', int(indoor_data['pm10'])) \
.field('co2', int(indoor_data['rco2'])) \
.field('tvoc', int(indoor_data['tvocIndex'])) \
.field('nox', int(indoor_data['noxIndex'])) \
.time(current_timestamp * 10 ** 9, write_precision='ns')
with InfluxDBClient.from_config_file("config.toml") as client:
with client.write_api() as writer:
if indoor_data:
writer.write(bucket="indoor", record=[indoor_point])
print(f"[{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(current_timestamp))}] - Indoor data written")
if __name__ == '__main__':
main()
Point를 생성하여 indoor
라는 measurement에 각 센서별 수치를 Field로 Key-Value 쌍으로 저장하고, 이를 InfluxDB에 기록한다. config.toml
파일에는 InfluxDB의 연결 정보가 담겨있다. InfluxDB 연결을 위한 Token 값이 config.toml에 필요하기 때문에 Influx CLI로 이를 생성해 주자. (참고1, 참고2)
실외 공기질
실외 공기질은 대기질 관련 API를 사용하여 받아와야 한다. 하지만 현재 공개되어 있는 API들 중에 원하는 규격의 API가 없어서 직접 다른 API들을 호출해서 원하는 규격으로 반환하게끔 Wrapping 하는 API를 따로 제작하였다. 실내 공기질과 마찬가지로 코드를 작성한다. API 남용 방지를 위해 URL 및 파라미터는 공개하지 않았다.
import requests
import time
from influxdb_client import InfluxDBClient, Point
def main():
outdoor_data = get_outdoor_data() # Fetch Custom Made AirQuality API
current_timestamp = time.time_ns()
if not outdoor_data:
print(f"[{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(current_timestamp / 10 ** 9))}] - No data written")
return
if outdoor_data:
outdoor_point = Point('outdoor') \
.field('temp', int(outdoor_data['temp'])) \
.field('humidity', int(outdoor_data['humidity'])) \
.field('pm25', float(outdoor_data['pm25'])) \
.field('pm10', float(outdoor_data['pm10'])) \
.field('atm', float(outdoor_data['atm'])) \
.time(current_timestamp, write_precision='ns')
with InfluxDBClient.from_config_file("config.toml") as client:
with client.write_api() as writer:
if outdoor_data:
writer.write(bucket="outdoor", record=[outdoor_point])
print(f"[{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(current_timestamp / 10 ** 9))}] - Outdoor data written")
if __name__ == '__main__':
main()
스크립트 작성이 끝났다면 두 스크립트를 모두 Crontab에 등록하여 주기적으로 실행되게끔 한다. Indoor는 2분에 한번, Outdoor는 10분에 한번 측정하게끔 설정하였다. Crontab log를 확인하여 정상적으로 실행되고 있는지도 확인하자.

대시보드 생성
스크립트가 정상적으로 실행되고 있다면, DB에 데이터도 올바르게 기록되고 있을 것이다. 이를 대시보드를 만들면서 확인해 보자.

우선 대시보드에 멋지게 데이터를 보여주기 위해서는 데이터 소스를 연결해야 된다. Grafana 메뉴에서 데이터 소스 추가하기를 누르고, InfluxDB를 선택한다. Grafana와 InfluxDB는 같은 서버에 있기 때문에 주소는 http://localhost:8086
이다.
Details 섹션에 Token에 아까 config.toml
때 설정해둔 Token 값을 입력하거나 새로 CLI로 생성해서 그 값을 넣어주면 된다. 하단에 Save & Test
버튼이 있으니 눌러서 연결이 잘 되는지 확인해 보자.
데이터 소스를 성공적으로 추가했으면 이제 대시보드를 만들어 보자. 좌측 메뉴에 대시보드로 들어가서 새 대시보드를 만들고, 우측 상단에 추가 버튼을 통해 Visualization을 추가한다.

Visualization을 선택하면 위와 같이 나오는데, 여기서 시각적으로 나타내고자 하는 데이터를 쿼리하고, 해당 쿼리로 가져온 데이터가 시각화되는 방식이다. 시각화 종류를 보면 우측 상단에서 선택할 수 있는데, 기본 값이 Time Series이다. 우선 하나의 Field에 대한 값을 쿼리해보자. 예시로 Indoor bucket에 저장한 온도 값을 쿼리해보자.
from(bucket: "indoor")
|> range(start: v.timeRangeStart, stop:v.timeRangeStop)
|> filter(fn: (r) =>
r._measurement == "indoor" and
r._field == "temp"
)
이 쿼리는 indoor
bucket에서 temp
field를 시각화를 요청한 시작 시간부터 끝 시간까지 가져오는 쿼리이다. 시작과 끝 시간 설정은 우측 상단에 보이는 시간 탭을 이용하면 된다. 이 쿼리를 실행하면 아래와 같이 시각화된 데이터를 볼 수 있다.

위와 같은 방법으로 취향에 맞게 대시보드를 꾸며보자. 대시보드에 Visualization을 추가하고, 크기랑 배치도 적절히 조절한 후 저장하는 것을 잊지 말자!
결과

이렇게 만들어진 대시보드는 모바일 UI도 지원하기 때문에, 어디서든 실내/외 공기질 정보를 한눈에 확인할 수 있다. EBS에서 방영된 분과 비슷하게 만들어보려고 했으나, Grafana를 다루는 건 이번이 처음이라 아직 미숙한 부분이 많았다. 대시보드를 추후에 더 시각적으로 보기 편하게 개선하고 싶다.
앞으로 계속 공기질에 대한 데이터가 유의미하게 쌓일 텐데, 이를 이용하여 더 많은 인사이트를 얻어보고 싶다. 미래에는 이를 이용하여 부모님이 계시는 본가에도 도입하여 Home Assistant를 이용해 실내 환경을 자동으로 제어하는 시스템을 구축해 보고자 한다.