회사 프로젝트

탄천 악취 모니터링

Cesium 3D 지형 위의 악취 측정 · 확산 시뮬레이션 관제

맡은 일
단독 개발 — Frontend · 수집 API(Express·Prisma) · DB 스키마 · 온프레미스 배포
기간
2025.06 — 2025.08
소속
플럭시티DX 기술팀

무엇을 만들었나

서울물재생시설공단 탄천물재생센터의 악취를 모니터링하는 관제 시스템입니다. 화면부터 수집 API와 DB 스키마, 온프레미스 배포까지 혼자 맡았습니다.

처리장 부지에 고정형 악취측정기 8대가 깔려 있고, 드론이 돌며 고도별로 재고, 별도로 CALPUFF 대기확산 모델이 격자 단위 예측값을 뱉습니다. 세 가지가 전부 형식이 달랐고, 각각 다른 표로만 쌓이고 있었습니다.

악취 민원에서 정작 쟁점은 '지금 몇 ppb인가'가 아니라 '어디서 나와서 어디로 가는가'인데, 그 질문에 답하는 화면이 없었습니다. Cesium 3D 지형 위에 센서 POI·고도별 포인트클라우드·파티클 확산을 함께 올려, 측정값과 확산을 한 화면에서 보도록 만들었습니다.

수집 API는 Express + Prisma + PostgreSQL로 직접 세웠고, 임계값을 넘으면 SSE로 관제 화면에 알람이 바로 뜨게 했습니다. 배포는 인터넷이 닿지 않는 온프레미스 서버라, 도커 이미지를 파일로 떠서 옮겨 올리는 방식으로 진행했습니다.

앞뒤를 다 쥐고 있었기 때문에, 화면에서 불편한 응답 형태는 API 쪽을 고쳐서 풀 수 있었습니다.

아래로

  1. PROBLEM01

    측정값은 쌓이는데 '어디서 나와 어디로 퍼지는지'를 보여 주는 화면이 없었습니다. 고정 센서·드론·CALPUFF 모델링 결과가 전부 다른 형식이었고, 특히 모델링 결과는 하루치가 격자 × 고도 × 분 단위라 그대로 내려받으면 브라우저가 버티지 못했습니다.

  2. ACTION02

    셋을 Cesium 3D 지형이라는 하나의 좌표계로 끌어왔습니다. CALPUFF 격자는 서버에서 분 단위로 집계해 compact 튜플로 내려주게 했고, 드론 GPS 포인트는 IDW 보간으로 격자화했으며, 실시간 확산은 센서의 풍향·풍속·농도를 먹는 파티클 시스템으로 그렸습니다. 응답 형식을 바꿔야 하는 일이라 수집 API와 임계값 알람(SSE)까지 직접 세웠습니다.

  3. RESULT03

    측정·확산·통계·이벤트를 한 화면에서 보는 관제 화면으로 묶었고, Docker Compose로 온프레미스 서버에 올려 운영에 들어갔습니다.

처리장 전경을 3D로 띄우고 악취측정기 POI를 올립니다. POI를 누르면 황화수소·암모니아·휘발성유기화합물·복합악취 4종의 농도와 등급(정상·주의·경고·위험)이 뜨고, 임계점을 넘긴 측정기는 붉은 카드로 따로 표시됩니다.

이 프로젝트의 핵심

서로 다른 세 데이터(고정 센서 8대 · 드론 GPS 측정 · CALPUFF 격자 모델링)를 Cesium 3D 지형이라는 하나의 좌표계로 통합

CALPUFF 확산 결과 재생

하루치 격자×고도×분 데이터를 서버에서 분 단위로 집계하고 compact 튜플([x,y,alt,value])로 내려받아 프레임 단위로 재생

드론 포인트클라우드

GPS 좌표를 IDW(역거리가중) 보간으로 격자화하고, 그리드·고도 단위로 평균을 내 고도별 레이어로 표시

실시간 확산 파티클

센서의 풍향·풍속·농도를 읽어 방출량·수명·확산 반경을 동적으로 계산하는 Cesium ParticleSystem 구현, 60초마다 최신 센서값으로 갱신

나머지 5개 더 보기

6단계 로그 스케일 색상 범례(0.0001~0.1ppm)

낮은 농도 구간이 뭉개지지 않게 색을 농도에 로그로 매핑

SSE 임계값 알람

수집 시점에 임계값을 평가해 초과분만 알람으로 남기고 연결된 관제 화면에 브로드캐스트, 30초 하트비트 + 5초 자동 재연결

Express + Prisma + PostgreSQL 수집 API

고정/드론 센서 단건·배치 수집, 트랜잭션으로 공통 데이터와 타입별 데이터를 함께 적재

CSV 내보내기

화면의 필터·컬럼 구성을 그대로 들고 전체 건을 다시 조회해 BOM 붙인 CSV로 저장(엑셀 한글 깨짐 방지)

Docker Compose 온프레미스 배포

인터넷이 닿지 않는 서버라 이미지를 tar로 떠서 옮겨 로드하는 배포 절차를 정리

화면

좌우로 넘겨 보실 수 있습니다

01/04

01
좌측 패널은 악취물질 4종을 측정기 8대별 시계열로 겹쳐 보여 줍니다. 측정기 기준·물질 기준 두 가지로 보는 축을 바꿀 수 있고, 같은 시각의 8개 값을 툴팁 하나에 모아 읽습니다.
02
시뮬레이션 모드 — CALPUFF 확산 모델링 결과와 드론 측정값을 고도별 히트맵으로 재생합니다. 하단 타임라인으로 하루치를 돌려 보고, 고도 레이어(30·60·90·120·150m)를 골라 특정 높이의 분포만 볼 수 있습니다.
03
통계 — 고정형·이동형(드론)을 나눠 기간·악취물질·센서 단위로 조회합니다. 드론은 고도와 위경도까지 함께 보고, 조회 결과를 그대로 CSV로 내려받습니다.
04
이벤트 현황 — 임계값을 넘겨 발생한 알람을 측정기·물질·등급·기간으로 조회하고 해제 시각까지 함께 봅니다. 발생 순간에는 SSE로 관제 화면에 토스트가 먼저 뜹니다.

운영 중인 시스템이라 공개 가능한 범위의 화면만, 값은 더미 데이터로 띄워 캡처했습니다.

자세히

궁금한 항목을 펼치면 그때 무엇이 문제였고 어떻게 판단했는지 나옵니다.

고정형 악취측정기 8대

처리장 부지 안 고정 위치에서 VOC·H2S·NH3·복합악취(OU)와 온습도·풍향·풍속을 올립니다. 지도 위 POI로 띄우고 클릭하면 최신값과 등급을 보여 줍니다.

이동형(드론) 측정

같은 4종을 재지만 위경도와 고도가 매번 다릅니다. 고정 센서처럼 '지점'으로 둘 수 없어 포인트클라우드로 따로 그렸습니다.

CALPUFF 대기확산 모델링

외부 모델이 뱉은 격자(x, y, 고도) 단위 예측값입니다. 측정이 아니라 예측이라 축이 아예 다릅니다.

셋을 각각의 화면에 두면 관제가 안 됩니다. Cesium 3D 지형을 공통 바닥으로 두고 위경도·고도로 전부 환산해 같은 공간에 겹쳤습니다.

VWorld 위성 타일 + Cesium Ion 지형 + 처리장 GLTF 모델을 깔고, 부지 경계선을 폴리라인으로 그려 관제 범위를 명확히 했습니다.

CALPUFF 결과는 하루치가 격자 × 고도 × 분 단위라, 하루를 통째로 받아 그리려다 브라우저가 멈췄습니다.

서버에서 분 단위로 DATE_TRUNC 집계해 버킷으로 묶고, compact 플래그를 주면 `[x, y, altitude, value]` 튜플 배열로 내려주도록 응답 형식을 둘로 나눴습니다

키 이름이 반복되는 것만 빼도 양이 크게 줄었습니다.

클라이언트는 버킷을 받아 격자 프레임으로 변환하고, 재생/이전/다음과 배속(1·2·4·8×)으로 타임라인을 돌립니다.

고도 레이어(30·60·90·120·150m)를 따로 켜고 끌 수 있게 해, 지표 근처와 상공의 분포를 나눠 보도록 했습니다.

드론 측정값은 IDW(역거리가중) 보간으로 격자를 채우고, 그리드 셀·고도 단위로 평균을 내 포인트 수를 줄인 뒤 가우시안 빌보드로 그렸습니다.

색은 0.0001~0.1ppm을 6단계 로그 스케일로 매핑

선형으로 두면 평소 농도대가 전부 같은 색이 됩니다.

이 프로젝트에서 배운 것

끝난 뒤에 남은 것들을 적었습니다.

  1. 01배운 것

    3D는 예쁘게 띄우는 게 목적이 아니라는 걸 배웠습니다. 악취는 눈에 안 보이는 것이고, 그걸 보이게 만들려고 3D를 쓰는 것이었습니다. 그래서 '멋있는 화면'이 아니라 '어디가 진한지 한눈에 들어오는 화면'을 기준으로 삼았고, 색 범례를 로그 스케일로 바꾼 것도 그 기준 때문이었습니다. 선형으로 두면 낮은 농도 구간이 전부 같은 색으로 뭉개져서, 정작 평소에 봐야 할 변화가 안 보였습니다.

  2. 02배운 것

    데이터 양이 화면 구조를 정한다는 걸 처음으로 몸으로 알았습니다. 모델링 결과를 그대로 받아 그리려다 브라우저가 멈췄고, 결국 '얼마나 잘게 보여 줄 것인가'를 먼저 정한 다음 그 단위로 서버에서 집계해 내려주는 쪽으로 바꿨습니다. 프론트에서 줄이는 게 아니라 내려오기 전에 줄여야 한다는 것, 그리고 응답 형식도 비용이라는 것을 이때 배웠습니다.

  3. 03배운 것

    프론트만 하다가 수집 API까지 맡으면서, 화면에서 불편했던 응답 형태를 제가 직접 고칠 수 있다는 게 어떤 의미인지 알게 됐습니다. 동시에 한쪽을 편하게 만들면 다른 쪽이 복잡해진다는 것도요. 이후 다른 프로젝트에서 백엔드에 수정 요청을 쓸 때 상대 입장에서 비용을 먼저 적게 된 건 이 경험 때문입니다.

  4. 04배운 것

    인터넷이 닿지 않는 서버에 올리는 일을 처음 해 봤습니다. 빌드해서 올리면 끝이 아니라, 이미지를 파일로 떠서 옮기고 로드하고 다시 띄우는 절차 전체가 배포라는 걸 알았습니다. 부산 도시철도의 폐쇄망 작업과 함께, 바깥과 끊긴 환경을 전제로 설계하는 습관이 이때 생겼습니다.

쓴 기술

ReactTypeScriptViteCesiumJSResiumWebGLZustandReact QueryAxiosRechartsTailwind CSSReact RouterZodSSENode.jsExpressPrismaPostgreSQLDockerNginxpnpm