-
[Django] 1Hz 실시간 입력 JSON 형식 자료 대시보드로 표출하기Django 2024. 3. 11. 10:56
실생활에서 (준)실시간으로 자료를 수집 후 처리하여 웹에 표출 및 모니터링하는 시스템이 꽤 있다. 뚜벅이인 내가 가장 자주 보는 케이스는 대중교통의 현재 위치 및 도착 예정 시간이다.
해당 시스템들의 기본적인 원리가 무엇일까? 자료를 수집하는 서버쪽과 이를 표출하는 클라이언트쪽, 즉 서버(Server) - 클라이언트(Client) 구조를 생각해볼 수 있다. 실제 대중교통의 경우 1Hz 간격으로 자료가 입력되는지 모르겠으나, 내가 참여하고 있는 프로젝트의 개발 환경에서는 생산 주기가 1Hz이다.
서버 - 클라이언트 구조 위 서버-클라이언트 구조에 대한 다이어그램을 간략하게 설명해보면 다음과 같다.
1. 센서에서 수집된 1Hz 주기 데이터를 서버로 패킷을 보낸다.
2. 서버는 패킷을 parsing하여 DB에 저장한다.
3. 클라이언트인 웹페이지에서 요청이 오면 DB에서 자료를 찾는다.
4. DB에서 찾은 데이터를 클라이언트로 전송한다.
5. 웹페이지 내 대시보드에 자료를 표출한다.
음.. 이정도에 시퀀스가 머리 속에 그려진다면, 코드를 작성하기 전 세부 설계가 가능해진다. 서버의 엔드포인트를 설정하여 정해진 규약에 따라 센서로부터 생산된 자료가 입력되어야 하며, 해당 자료의 패킷을 분석하여 로직에 맞게 parsing 후 DB에 저장하는 기능이 필요하다.
또한, 서버는 클라이언트 요청에 따라 JSON과 같은 형식으로 응답을 줘야하며, 웹페이지에서는 응답받은 JSON을 Jquery와 같은 라이브러리를 통해 비동기 처리하여 대시보드에 업데이트가 필요하다. (1개가 아닌 여러개의 센서로부터 동시에 자료가 들어와 웹에 보여져야 하므로 비동기 처리가 필요하다)
본론으로 들어와서.. 오늘 포스팅에서는 Django의 서버쪽 처리 로직에 관해 코드를 공유하려 한다. 먼저 센서로부터 서버에 입력되는 자료의 패킷부터 보자.
{"SENSOR": "MAIN_BOARD", "TIME": "2023-10-25 11:41:33", "PW_3.3V": "3.280", "PW_3.3A": "0.078", "PW_5.0V": "5.037", "PW_5.0A": "1.094", "PW_12.0V": "11.713", "PW_12.0A": "0.716", "PW_24.0V": "17.843", "PW_24.0A": "-0.007", "TEMPERATURE": "24.86", "PRESSURE": "1017.70", "ALTITUDE": "-36.96", "ACC_X": "-0.014", "ACC_Y": "-0.010", "ACC_Z": "1.004", "GYR_X": "-0.092", "GYR_Y": "0.092", "GYR_Z": "0.061", "ROLL": "-1.019", "PITCH": "-1.416", "YAW": "-2181.030", "SHOCK_SCALAR": "1.004", "DG_HDT": "0"} {"SENSOR": "CT4319", "SECTION": "2", "TIME": "2023-10-30 13:38:53", "SET_PW": "12", "SENSOR_PW_VOLT": "11.730", "SENSOR_PW_CURT": "0.000"} {"SENSOR": "DCS4420", "SECTION": "4", "TIME": "2023-10-30 13:38:53", "SET_PW": "12", "SENSOR_PW_VOLT": "11.740", "SENSOR_PW_CURT": "0.000"}
해당 패킷은 "KEY" : "VALUE" 쌍으로 구성되는 전형적인 JSON 형식이다. 센서의 이름(SENSOR)과 관측시간(TIME)이 공통 항목이며, 나머지는 센서마다 조금씩 다르다.
우리는 여기서 힌트를 얻을 수 있다. '아! 서버측에서 센서 이름을 기준으로 parsing 후 클라이언트 요청에 따라 필요한 인자를 JSON으로 담아 전달하면 되겠구나'
보통은 이 단계에서 미리 설계된 DB 내 테이블에 INSERT 되어 관리된다. 하지만, 본 포스팅에서는 DB에 저장하지 않는다. 왜냐? 이전 포스팅에서 말한 것 처럼 '임베디드 시스템 환경'에서 웹서비스가 운영되며, 굳이 저장될 자료를 볼 필요 없이 실시간으로 올라오는 값만 '대시보드'로 보여주면 그만이다.
from django.shortcuts import render from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from datetime import datetime, timedelta import json import queue sensor_data_queue = queue.Queue() last_cleaned_time = datetime.now() @csrf_exempt def store_sensor_data(request): global last_cleaned_time clean_interval = timedelta(minutes=1) if request.method in ['GET', 'POST']: data_string = request.GET.get('data') if request.method == 'GET' else request.body.decode('utf-8') if not data_string: return JsonResponse({'error': 'No data provided'}, status=400) try: data = json.loads(data_string) sensor_data_queue.put(data) if datetime.now() - last_cleaned_time >= clean_interval: with sensor_data_queue.mutex: sensor_data_queue.queue.clear() last_cleaned_time = datetime.now() print("Queue cleaned! Time: ", last_cleaned_time) except json.JSONDecodeError: return JsonResponse({'error': 'Invalid JSON data'}, status=400)
Django의 ORM을 이해하고 있다는 가정 하에 위 코드를 설명해본다. 아, DB에 저장하는 것이 아니므로 사실 models.py에 대한 이해는 필요없다. views.py와 그에 맞는 urls.py, 그리고 웹쪽 템플릿만 이해하면 된다. 위 store_sensor_data 함수는 views.py에 정의되어 있다.
일단, 1Hz 자료가 동시에 여러 센서에서 들어오기 때문에 자료를 놓치고 싶지 않아 선입선출(FIFO)이 가능한 자료구조인 Queue를 사용했다. Python에서는 queue.Queue()를 사용하면 된다.
store_sensor_data 함수의 URL 엔드포인트에 센서의 자료가 1Hz 간격으로 들어오고 있다. urls.py는 다음과 같이 설정되어 있다.
from django.urls import path from .views import * from django.views.generic.base import TemplateView app_name = 'control' urlpatterns = [ path('store_sensor_data/', store_sensor_data, name='store_sensor_data'), path('get_all_data/', get_sensor_data_combined, name='get_sensor_data_combined'), ]
control이란 앱에 해당 함수가 views.py 내 정의되어 있으며, 'store_sensor_data/'라는 엔드포인트로 자료를 받겠다는 의미이다. 정확히는 'control/store_sensor_data/' 이다.
get_all_data는 밑에서 설명할 parsing한 자료를 클라이언트에 넘겨줄 때 사용하는 엔드포인트이다.
views.py 코드를 다시 보면 로직에 대한 부분은 거의 없다. 입력 받은 JSON 형태의 자료를 sensor_data_queue에 그대로 저장하는 것과 1분이라는 시간을 기준으로 이전 자료를 Queue에서 비워내는 정도이다. 왜 비워낼까?
(임베디드 시스템에서 웹서버는 항상 켜져있어 store_sensor_data는 웹페이지에 접속하지 않아도 계속 자료를 받는다. 만약, 비워내지 않는다면 현재 시점의 자료가 아닌 과거 시점의 자료가 먼저 보여질 것이다)
def get_sensor_data_combined(request): main_board_data = None sensor_data_dict = {} while not sensor_data_queue.empty(): data = sensor_data_queue.get_nowait() sensor_type = data.get('SENSOR') if sensor_type == 'MAIN_BOARD': main_board_data = data else: sensor_data_dict[sensor_type] = data response_data = {} if main_board_data: response_data['main_board'] = main_board_data if sensor_data_dict: response_data['sensors'] = list(sensor_data_dict.values()) if not response_data: return JsonResponse({'error': 'No data stored yet'}, status=204) return JsonResponse(response_data)
수집된 자료를 실제 parsing 시 사용되는 get_sensor_data_combined 함수이다. Java를 사용할 땐 카멜 케이스로 함수를 정의하는 것이 원칙인데, Python은 예제코드를 뒤져봐도 언더바로 구분을 한다. 신기하다.
여튼, 자료 패킷을 보면 눈치를 챘을 수 있지만 패킷에도 형식이 2개가 존재한다. SENSOR라는 KEY값에 대한 VALUE 중 MAIN_BOARD라는 녀석과 나머지 센서와 다르다.
음.. 살짝 TMI일 수 있지만, MAIN_BOARD는 말 그대로 임베디드 시스템에 연결된 보드에 대한 정보라 전체 전압/전류 정보, 내장센서 정보가 존재한다. 나머지 센서는 입력된 전원에 따른 전압/전류 출력 값만 반환한다.
그래서 함수 내 main_board_data와 sensor_data_dict를 따로 정의해뒀다. 그리고 sensor_data_dict에는 SENSOR 값이 다른 패킷들이 담기므로 딕셔너리로 정의하여 분리하여 담도록 설정했다.
앞서 store_sensor_data의 엔드포인트로 입력되어 sensor_data_queue에 저장된 자료들을 get_nowait()이라는 메서드를 통해 자료를 main_board_data와 sensor_data_dict에 담는다. get_nowait() 메서드는 블로킹(blocking) 없이 큐 객체에 들어있는 아이템을 반환하는 메서드이다.
그렇다면 클라이언트 측에서 호출 시 get_sensor_data_combined의 엔드포인트인 'control/get_all_data/'에는 어떤 자료가 전달되는 것일까?
클라이언트로 전달되는 JSON 자료 자료의 패킷과 마찬가지로 클라이언트 호출에 따라 서버에서 JSON이 전달되는 것을 볼 수 있으며, MAIN_BOARD는 독립으로 나머지 센서는 함께 담겨 13개 종류의 자료가 전달되는 것을 확인할 수 있다.
템플릿 자체는 보안 이슈가 있어 첨부할 수 없지만, 클라이언트쪽 대시보드는 대략 다음과 같은 Javascript 코드로 정해진 테이블에 JSON 자료를 업데이트하는 구조이다.
function fetchAllData() { fetch('/control/get_all_data/') .then(response => response.json()) .then(data => { if (data.main_board) { console.log(data.main_board); updateData(data.main_board); } if (data.sensors) { console.log(data.sensors); data.sensors.forEach(sensorData => { const sensorName = sensorData.SENSOR; updateTableWithData(sensorName, sensorData); }); } }) .catch(error => console.error('Fetch operation error:', error)); } setInterval(fetchAllData, 6000); function updateTableWithData(sensorName, data) { var row = document.querySelector(`tr[data-nm="${sensorName}"]`); if (row) { row.querySelector('.SET_PW').textContent = data.SET_PW || ''; row.querySelector('.SENSOR_PW_VOLT').textContent = data.SENSOR_PW_VOLT || ''; row.querySelector('.SENSOR_PW_CURT').textContent = data.SENSOR_PW_CURT || ''; } } function updateData(data) { document.getElementById('PW_3.3V').innerText = data["PW_3.3V"] || ''; document.getElementById('PW_3.3A').innerText = data["PW_3.3A"] || ''; document.getElementById('PW_5.0V').innerText = data["PW_5.0V"] || ''; document.getElementById('PW_5.0A').innerText = data["PW_5.0A"] || ''; document.getElementById('PW_12.0V').innerText = data["PW_12.0V"] || ''; document.getElementById('PW_12.0A').innerText = data["PW_12.0A"] || ''; document.getElementById('PW_24.0V').innerText = data["PW_24.0V"] || ''; document.getElementById('PW_24.0A').innerText = data["PW_24.0A"] || ''; }
Javascript의 fetch를 통해 비동기를 구현하였고, MAIN_BOARD와 각 센서명에 따라 테이블에 알맞게 업데이트 되도록 구성하였다.
위 기능을 구현하고, 디버깅하고, 업데이트하면서 가장 크게 고민했던 부분은 ARM Cortex-A7 CPU, 512MB DDR3, 4GB eMMC라는 싱글코어 기반 임베디드 시스템에서 최대한 유저가 경험이 좋아질 수 있도록 대시보드를 표출하는 것이다.
하드웨어/소프트웨어 개발자는 해당 보드의 제한된 환경이라는 인식이 머리 속에 남아있지만, 실제 사용자는 일반 브라우저 내 웹사이트 접속하듯이 사용하려고 한다.
이에 어떻게 최대한 실시간으로 업데이트 되는 것 처럼 보일 수 있을지 고민을 많이했다. 실제 서버로 입력되는 자료의 주기와 전체 센서를 모두 업데이트할 수 있는 주기를 조절하고, DB에 저장하지 않고 딕셔너리 구조를 활용해 넘겨 바로 반영되는 것 처럼 보이듯이 구현이되었다.
많은 서비스 회사들이 현존하는 성능 좋은 서버에서의 부하에 관한 성능 개선에 관심이 있다면, 나는 반대로 제한된 하드웨어 환경에서의 성능 개선을 목표로 프로젝트를 수행했었다. 결국, '성능 개선' 이라는 측면에서 좋은 경험을 쌓은 예가 아닐까 싶다.
'Django' 카테고리의 다른 글
[Django] 임베디드 리눅스 환경에서 Python 및 Django 활용하기 (1) 2024.03.08