TypeScript 로 구현한 디지털트윈 방역공조 모니터링 시스템
프로젝트명: 디지털트윈 감염병 위험 알람시스템 (Preventative)
기술 스택: TypeScript · React 18 · Umi Max 4 · Cesium · Ant Design · WebSocket
작성일: 2026-07-04
목차
- 프로젝트 소개
- 모노레포 아키텍처
- TypeScript 기반 프론트엔드 구조
- 핵심 기능 구현
- API · WebSocket 연동
- Cesium 3D 지도 통합
- 개발 환경 설정
- 이슈 및 트러블슈팅
- 회고 및 개선 방향
1. 프로젝트 소개
이 프로젝트는 3D 디지털트윈 지도 위에서 감염병·공기질·공조기(살균기)를 실시간 모니터링하고 원격 제어하는 웹 애플리케이션입니다.
경남도청, 마산병원, 한마음병원, 예산군 노인요양원 등 여러 시설의 센서 데이터를 Cesium 기반 3D 지도에 시각화하고, 공기질(PM10, PM2.5, CO₂, VOC), 바이러스(부유균) 센서, 공조기(전원·모드·풍량·플라즈마)를 통합 제어합니다.
주요 목표
- IoT 센서 데이터의 실시간 수신 및 시각화
- 공조기 자동/수동 모드 전환 및 풍량 임계치 기반 자동 제어
- 3D 건물 모델(B3DM Tileset) 위 센서·공조기 아이콘 배치
- 공기질 상태에 따른 바람길(Line Flow) 시각 효과 표현
2. 모노레포 아키텍처
프로젝트는 Yarn Workspaces + Turborepo 기반 모노레포로 구성되어 있습니다.
preventative/
├── packages/
│ ├── servicecomponents/ # Cesium Map 래퍼 컴포넌트 (@component/service)
│ ├── uicomponents/ # 공통 UI 컴포넌트 (@component/ui)
│ └── utils/ # Cesium 유틸리티 (@packages/utils)
└── projects/
├── master/ # 메인 앱 (방역공조 디지털트윈)
├── cultureheritage/ # 문화유산 프로젝트
├── terrain/ # 지형 편집
└── ...
패키지역할
| @component/service | Cesium Viewer 초기화, MapProps 타입 정의 |
| @packages/utils | 지도 드로잉, 측정, 시각 분석 등 Cesium 유틸 |
| master | 방역공조 메인 애플리케이션 |
빌드 · 실행
# 의존성 설치
yarn
# master 프로젝트 개발 서버 실행
cd projects/master
yarn start
# Turborepo로 특정 프로젝트 빌드
turbo run build --filter=master
3. TypeScript 기반 프론트엔드 구조
3.1 프레임워크 스택
레이어기술
| 프레임워크 | Umi Max 4 (@umijs/max) |
| UI | React 18 + Ant Design 4 |
| 상태 관리 | DVA (Redux 기반, connect HOC) |
| 3D 지도 | @cesiumgs/cesium-analytics 1.96.0 |
| HTTP | umi-request (axios 래퍼) |
| 차트 | @ant-design/charts (Line Chart) |
3.2 핵심 컴포넌트 구조
방역공조 기능은 초기에 cesium-view.tsx에 모두 포함되어 있었으나, 리팩터링을 통해 Preventative.tsx로 분리되었습니다.
projects/master/src/components/cesium/
├── cesium-view.tsx # Cesium 지도 컨테이너 (Map + 패널 조합)
├── Preventative.tsx # 방역공조 메인 로직 (~3,700줄)
├── Preventative.less # 방역공조 전용 스타일
├── sensorlist.ts # REST API 호출 레이어
└── sensorchart.tsx # 부유균 차트 + 자동 모드 제어
3.3 TypeScript 타입 활용 예시
Cesium Map 컴포넌트 Props (@component/service)
// packages/servicecomponents/src/lib/MapComponent/Map.tsx
export interface MapProps extends Viewer.ConstructorOptions {
getViewInstance?: (v: Viewer) => void;
accessToken?: string;
cesium_base_url?: string;
widgets_css?: string;
}
Viewer.ConstructorOptions를 확장하여 Cesium Viewer의 모든 옵션을 타입 안전하게 전달할 수 있습니다.
Redux Connect 타입 (cesium-view.tsx)
import type { ConnectState } from '@/states/connect';
import type { CesiumMapProps } from '@/states/MapState';
const CesiumView: React.FC<CesiumMapProps> = (props) => {
// ...
};
export default connect(
({
cesiumview,
comPanel,
newHistory,
// ...
}: ConnectState) => ({
cesiumview,
comPanel,
newHistory,
// ...
}),
)(CesiumView);
DVA connect와 ConnectState를 통해 Redux 스토어 상태를 타입 안전하게 매핑합니다.
센서 차트 데이터 파싱 (sensorchart.tsx)
const extractHour = (time: string): string => {
return time.substring(11, 13);
};
const extractValue = (avg_vol: string): number => {
const match = avg_vol.match(/-?(\d+)/);
return match ? parseInt(match[1], 10) : 0;
};
API 응답 문자열에서 시간·수치를 추출하는 헬퍼 함수에 명시적 반환 타입을 지정했습니다.
4. 핵심 기능 구현
4.1 실시간 센서 모니터링 (WebSocket)
Preventative.tsx 마운트 시 3개의 WebSocket 채널을 연결합니다.
채널URL데이터
| 혼잡도 | ws://.../wsapp/prev-motion | 건물 혼잡도 |
| 공기질 | ws://.../wsapp/prev-air-quality | PM10, PM2.5, CO₂, VOC |
| 바이러스 | ws://.../wsapp/prev-virus | 부유균 센서 |
ws.current = new WebSocket("ws://112.217.210.138:11080/wsapp/prev-air-quality");
ws.current.onmessage = (message) => {
const airQualityDataSet = JSON.parse(message.data);
if (airQualityDataSet.place2 === 'suyoo_room_hall') {
setAirQualityData3(airQualityDataSet);
localStorage.setItem('airQualityData3', JSON.stringify(airQualityDataSet));
CheckAirQuality(airQualityDataSet, 3);
}
// ...
};
공기질 데이터는 place2 필드로 구역(left / right / hall)을 구분하고, localStorage에 캐싱하여 새로고침 후에도 마지막 값을 유지합니다.
4.2 공조기 원격 제어 (REST API)
sensorlist.ts에서 공조기 제어 API를 모듈화했습니다.
// 공조기 전원 제어
export const controlPower = async (param) => {
const url = '/kims-api/v1/air_fan';
const headers = {
'Content-Type': 'application/json',
'x-onpoomkey': 'SH25B3K-668488C-PHFEGHN-131S6NA',
};
return request.post(url, { data: param, headers });
};
// 자동/수동 모드 변경
export const modeChange = async (param) => {
return request.put('/i1/settings/mode/change', { data: param });
};
// 풍량 임계치 설정
export const airvolChange = async (param) => {
return request.put('/i1/settings/standard/change', { data: param });
};
제어 항목:
- 전원 (power): ON/OFF
- 모드 (status): 자동 / 수동
- 풍량 (wind): quiet · weak · middle · strong · turbo
- 플라즈마 (plazma): ON/OFF
- 풍량 임계치 (settings): 부유균 농도 구간별 풍량 매핑
4.3 자동 모드 — 부유균 기반 풍량 제어
sensorchart.tsx는 10분(600,000ms) 주기로 부유균 평균값을 조회하고, 자동 모드인 공조기의 풍량을 자동 조절합니다.
const autoModeContorl = async (acData, bacteria) => {
if (acData && acData.status === '자동') {
const lastValue = bacteria[bacteria.length - 1].value;
let fanSpeed: string;
if (lastValue < 100) fanSpeed = 'quiet';
else if (lastValue < 200) fanSpeed = 'weak';
else if (lastValue < 300) fanSpeed = 'middle';
else if (lastValue < 400) fanSpeed = 'strong';
else fanSpeed = 'turbo';
await controlSterilization({ dev_id: acData.device, wind: fanSpeed });
}
};
4.4 3D 지도 위 센서·건물 시각화
Cesium Entity API로 각 시설의 센서·공조기 위치에 아이콘을 배치합니다.
cesiumview.entities.add({
id: '한마음_공조기1',
position: Cesium.Cartesian3.fromDegrees(lon, lat, height),
billboard: {
image: new Cesium.ConstantProperty('images/preventative/mapIcon03-nor.png'),
scale: 0.5,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
},
});
3D 건물 모델은 Cesium3DTileset으로 로드합니다.
cesiumview.scene.primitives.add(
new Cesium.Cesium3DTileset({ url: '/data/nursinghome/tileset.json' })
);
4.5 바람길(Line Flow) 시각 효과
공기질 구역 선택 시 Cesium PolylineGeometry + 커스텀 Material로 공기 흐름(바람길) 을 3D 지도 위에 애니메이션으로 표현합니다. lineFlow 상태 토글로 on/off를 제어합니다.
5. API · WebSocket 연동
5.1 개발 프록시 설정 (.umirc.ts)
로컬 개발 시 CORS를 우회하기 위해 Umi proxy를 설정합니다.
proxy: {
'/kims-api': {
target: 'http://kims-api.askstoryboard.com:3003',
changeOrigin: true,
pathRewrite: { '^/kims-api/': '/' },
},
'/i1': {
target: 'http://i1.askstoryboard.com:8080',
changeOrigin: true,
pathRewrite: { '^/i1/': '/' },
},
}
5.2 API 엔드포인트 요약
메서드경로설명
| GET | /i1/status?dev_id={id} | 공조기 상태 조회 |
| POST | /i1/control | 공조기 제어 |
| PUT | /i1/settings/mode/change | 자동/수동 모드 변경 |
| PUT | /i1/settings/standard/change | 풍량 임계치 변경 |
| GET | /i1/data?place={place} | 2시간 부유균 평균 조회 |
| GET | /kims-api/v1/place/kims | 건물·장소 정보 |
| POST | /kims-api/v1/air_fan | 공조기 전원·풍량·플라즈마 제어 |
6. Cesium 3D 지도 통합
6.1 지도 초기화 (cesium-view.tsx)
<Map
terrainProvider={new CesiumTerrainProvider({ url: AppCfg.koreaTerrain })}
imageryProvider={new WebMapTileServiceImageryProvider({
url: AppCfg.worldUrl,
layer: '', style: '', tileMatrixSetID: '',
})}
sceneMode={SceneMode.SCENE3D}
shadows={true}
cesium_base_url={"./ionsdk"}
widgets_css={"./ionsdk/Widgets/widgets.css"}
contextOptions={{
webgl: { preserveDrawingBuffer: true } // 스크린샷 캡처 지원
}}
getViewInstance={(v) => {
setTimeout(() => {
dispatch({ type: 'cesiumview/initViewV2', payload: { viewer: v } });
}, 100);
}}
/>
<Preventative />
6.2 Webpack Externals
Cesium, React, Ant Design 등 대용량 라이브러리는 CDN 스크립트로 로드하고 Webpack externals로 번들에서 제외합니다.
config.externals({
'@cesiumgs/cesium-analytics': 'Cesium',
react: 'React',
'react-dom': 'ReactDOM',
antd: 'antd',
echarts: 'echarts',
});
이 방식으로 초기 번들 크기를 줄이고, Cesium Worker 파일은 public/ionsdk/에 정적 배포합니다.
7. 개발 환경 설정
7.1 사전 요구사항
- Node.js 16+
- Yarn (Workspaces)
- Turbo (글로벌): npm install turbo -g
7.2 master 프로젝트 실행
cd projects/master
yarn # 의존성 설치 (postinstall: max setup)
yarn start # theme 생성 + max dev
7.3 프로덕션 빌드
yarn build # publicPath: /map/
빌드 결과물은 /map/ 경로 기준으로 배포됩니다 (hash 라우팅).
8. 이슈 및 트러블슈팅
개발 과정에서 발생한 주요 이슈와 해결 방법을 커밋 히스토리 기반으로 정리했습니다.
Issue #1 — 방역공조 API 변경 대응
증상: 백엔드 API 스펙이 변경되면서 기존 공조기 제어·상태 조회가 동작하지 않음.
커밋:
- 방역공조 수정된 API 반영 (2024-05-21)
- 공조기/공기질 신규 API 적용 (2024-05-30)
원인: 기존 단일 API에서 /kims-api, /i1 두 개의 서로 다른 API 서버로 분리됨.
해결:
- .umirc.ts에 /kims-api, /i1 프록시 추가
- sensorlist.ts에 API 함수별 엔드포인트·헤더(x-onpoomkey) 분리
- sensorchart.tsx에 자동 모드 제어 로직 추가
// Before: 단일 API
request.get('/server/api/sensor/list');
// After: 역할별 분리
request.get('/i1/status?dev_id=all'); // 상태 조회
request.post('/kims-api/v1/air_fan', { ... }); // 제어
request.put('/i1/settings/mode/change', { ... }); // 모드 변경
Issue #2 — cesium-view.tsx 비대화 (3,000줄+)
증상: cesium-view.tsx에 방역공조 로직·스타일·지도 초기화가 모두 혼재하여 유지보수가 어려움.
커밋: 방역공조 소스 정리 (2024-07-04)
해결: 방역공조 관련 로직을 Preventative.tsx + Preventative.less로 분리.
파일BeforeAfter
| cesium-view.tsx | ~3,500줄 | ~150줄 |
| Preventative.tsx | 없음 | ~3,000줄 |
| cesium-view.less | ~400줄 | 최소화 |
// cesium-view.tsx — 분리 후
<Map {...mapProps} />
<Preventative />
교훈: 단일 책임 원칙(SRP)을 적용해 Cesium 지도 컨테이너와 비즈니스 로직을 분리하면, TypeScript 타입 추론과 코드 탐색이 훨씬 수월해집니다.
Issue #3 — 3D Tileset CORS 오류
증상: 노인요양원 3D 건물 모델을 외부 URL로 로드할 때 브라우저 CORS 정책에 의해 차단됨.
커밋: model 외부 경로 지정 시 CORS 오류로 인해 프로젝트 내부에 저장 (2024-07-16)
원인: 외부 서버(http://...)의 B3DM 타일셋에 Access-Control-Allow-Origin 헤더가 없음.
해결: 157개의 .b3dm 타일 파일과 tileset.json을 projects/master/public/data/nursinghome/에 직접 배치.
// Before
new Cesium.Cesium3DTileset({ url: 'http://external-server/nursinghome/tileset.json' })
// After
new Cesium.Cesium3DTileset({ url: '/data/nursinghome/tileset.json' })
교훈: Cesium 3D Tiles는 same-origin 또는 CORS 허용 서버에서만 안정적으로 로드됩니다. 프로덕션 배포 시 정적 파일 내장 또는 CDN CORS 설정이 필수입니다.
Issue #4 — 풍량 임계치 입력값 타입 불일치
증상: 풍량 임계치 설정 UI에서 숫자 입력 후 API 전송 시 문자열로 전달되어 서버에서 거부되거나 비교 연산이 오동작.
커밋: value parseInt (2024-10-28)
원인: <input type="number">의 onChange 이벤트에서 event.target.value는 TypeScript/JavaScript 모두 string 타입.
해결:
// Before
const newValue = event.target.value; // string
// After
const newValue = parseInt(event.target.value); // number
// JSX 속성도 string → number로 수정
min={0} // Before: min="0"
max={9999}
step={1}
교훈: React controlled input에서 숫자 상태를 다룰 때는 parseInt/parseFloat로 명시적 변환이 필요합니다. TypeScript strict mode에서는 value: string과 value: number 불일치를 컴파일 타임에 잡을 수 있습니다.
Issue #5 — WebSocket 다중 연결 덮어쓰기
증상: 혼잡도·공기질·바이러스 3개 채널 중 마지막 WebSocket만 유효하고, 이전 연결이 끊김.
원인: 단일 useRef에 WebSocket을 3번 할당.
// 문제 코드
ws.current = new WebSocket(".../prev-motion"); // 1번째 → 덮어씀
ws.current = new WebSocket(".../prev-air-quality"); // 2번째 → 덮어씀
ws.current = new WebSocket(".../prev-virus"); // 3번째만 살아남음
해결 방향 (개선 제안):
const wsMotion = useRef<WebSocket | null>(null);
const wsAirQuality = useRef<WebSocket | null>(null);
const wsVirus = useRef<WebSocket | null>(null);
// 또는 Map/Record로 관리
const wsMap = useRef<Record<string, WebSocket>>({});
useEffect(() => {
wsMap.current.motion = new WebSocket(".../prev-motion");
wsMap.current.airQuality = new WebSocket(".../prev-air-quality");
wsMap.current.virus = new WebSocket(".../prev-virus");
return () => {
Object.values(wsMap.current).forEach(ws => ws?.close());
};
}, []);
교훈: TypeScript의 useRef<WebSocket | null> 타입을 활용해 ref 구조를 명확히 설계하면, 다중 WebSocket 관리 실수를 줄일 수 있습니다.
Issue #6 — 예산군 노인요양원 신규 시설 추가
증상: 기존 3개 시설(경남도청, 마산병원, 한마음병원) 외 예산군 노인요양원 시설 추가 요구.
커밋:
- 예산군 노인요양원 작업 (2024-07-16) — UI·Entity 1,500줄 추가
- 공조기, 공기질 센서 추가 (2024-07-22) — 센서 3대 추가
작업 내용:
- 노인요양원 전용 Entity(공조기 3대, 공기질 센서) Cesium 지도 배치
- checkNursingHome 상태 및 전용 UI 패널 추가
- 3D Tileset 로컬 배치 (Issue #3 연계)
- sensorchart.tsx에 place 매핑 추가 (left / right / corridor)
Issue #7 — 바람길(Line Flow) 효과 적용
증상: 공기질 구역 선택 시 공기 흐름을 시각적으로 표현하는 바람길 효과가 동작하지 않음.
커밋: 바람길 효과 적용 (2024-07-05)
해결: Cesium PolylineGeometry + 커스텀 Material Property로 라인 애니메이션 구현. lineFlow 상태와 useEffect 연동.
useEffect(() => {
if (lineFlow) {
lineFlowFunction();
} else {
// Primitive 제거
}
}, [lineFlow]);
9. 회고 및 개선 방향
잘한 점
- 모노레포 + 공통 패키지 구조로 Cesium Map 컴포넌트를 재사용
- API 레이어 분리 (sensorlist.ts)로 HTTP 호출과 UI 로직 분리
- Preventative 컴포넌트 분리로 cesium-view.tsx 가독성 대폭 개선
- localStorage 캐싱으로 WebSocket 끊김 시에도 마지막 센서값 유지
개선이 필요한 부분
항목현재개선 방향
| Preventative.tsx 크기 | ~3,700줄 단일 파일 | 시설별·기능별 Hook/컴포넌트 분리 |
| API 타입 | param: any 암시적 | Request/Response interface 정의 |
| WebSocket | 단일 ref 덮어쓰기 | ref Map 또는 커스텀 Hook |
| 상태 관리 | useState 50+ | useReducer 또는 DVA model로 이전 |
| 테스트 | 없음 | sensorlist API mock + Vitest 도입 |
TypeScript strict 모드 적용 시 기대 효과
// sensorlist.ts — 타입 정의 예시 (개선안)
interface AirFanControlParam {
dev_id: string;
power?: 'ON' | 'OFF';
wind?: 'quiet' | 'weak' | 'middle' | 'strong' | 'turbo';
plazma?: 'ON' | 'OFF';
}
interface SterilizationStatus {
device: string;
power: string;
status: '자동' | '수동';
wind: string;
plazma: string;
settings: StandardVolumeSettings;
}
interface StandardVolumeSettings {
weak: number;
middle: number;
strong: number;
quiet: number;
}
export const controlSterilization = async (
param: AirFanControlParam
): Promise<{ status: 'SUCCESS' | 'FAIL' }> => {
// ...
};
부록: 커밋 히스토리 요약
날짜커밋내용
| 2024-05-21 | 방역공조 수정된 API 반영 | API 엔드포인트 변경 대응 |
| 2024-05-30 | 공조기/공기질 신규 API 적용 | kims-api, i1 프록시 추가 |
| 2024-07-04 | 방역공조 소스 정리 | Preventative.tsx 분리 |
| 2024-07-05 | 바람길 효과 적용 | Line Flow 시각화 |
| 2024-07-16 | 예산군 노인요양원 작업 | 신규 시설 UI·Entity |
| 2024-07-16 | CORS → 로컬 Tileset | 3D 모델 내장 배치 |
| 2024-07-22 | 공조기, 공기질 센서 추가 | 센서 3대 확장 |
| 2024-10-28 | value parseInt | 임계치 입력 타입 수정 |
이 문서는 preventative 모노레포의 projects/master 방역공조 모듈을 기준으로 작성되었습니다.
'Frontend' 카테고리의 다른 글
| [Frontend] 3D 시뮬레이션 기능 구현 (feat. MSA) (1) | 2026.07.05 |
|---|---|
| [Frontend] JavaScript 정복하기 (0) | 2026.07.05 |
| [Frontend] 3D 재난 모니터링 플랫폼(feat. R&D 과제) (0) | 2026.07.04 |
| [Frontend] Web Fundamentals 정리 (1) | 2026.07.03 |
| [Frontend] Framwork 정리 (0) | 2026.07.03 |