
Electron + React로 1000대 드론을 실시간 제어하는 관제 시스템 만들기
Electron + React로 1000대 드론을 실시간 제어하는 관제 시스템 만들기
Google Maps 위에서 수백~천 대의 드론을 실시간으로 시각화하고 제어하는 데스크톱 애플리케이션을 개발한 과정을 공유합니다. 특히 대량의 마커를 렌더링할 때 발생하는 성능 문제를 어떻게 해결했는지에 초점을 맞춰 설명합니다.
프로젝트 개요
기술 스택
- 프레임워크: Electron + React 19 + TypeScript
- 상태관리: React Query v5 + WebSocket Context
- 지도: Google Maps API (@vis.gl/react-google-maps)
- 실시간 통신: WebSocket
- 빌드: electron-vite + electron-builder
주요 기능
- 실시간 드론 위치 추적 및 시각화
- 개별/전체 드론 제어 (이륙, 착륙, 복귀, 이동)
- 드론 클러스터링 (대량 드론 효율적 표시)
- 베이스 위치 관리 및 이동 애니메이션
- WebSocket 기반 양방향 통신
시뮬레이션
아키텍처 설계
전체 구조
┌────────────────────────────────────────────────────────────┐ │ Electron App │ ├────────────────────────────────────────────────────────────┤ │ Main Process │ Renderer Process │ │ ┌─────────────────┐ │ ┌─────────────────────────────┐ │ │ │ DroneServer │◄─┼──┤ React App │ │ │ │ (WebSocket) │ │ │ ┌─────────────────────┐ │ │ │ │ │──┼─►│ │ WebSocket Context │ │ │ │ │Drone simulation │ │ │ └─────────────────────┘ │ │ │ └─────────────────┘ │ │ ▼ │ │ │ ▲ │ │ ┌─────────────────────┐ │ │ │ │ │ │ │ React Query │ │ │ │ IPC Communicatio │ │ │ (State cache) │ │ │ │ │ │ │ └─────────────────────┘ │ │ │ ┌───────┴────────┐ │ │ ▼ │ │ │ │ index.ts │ │ │ ┌─────────────────────┐ │ │ │ │(App life cycle)│◄──┼──┤ │ Components │ │ │ │ └────────────────┘ │ │ │ (Map, Markers...) │ │ │ │ │ │ └─────────────────────┘ │ │ └───────────────────────┴──┴─────────────────────────────────┘
디렉토리 구조
src/ ├── main/ # Main Process - Electron 앱 진입점 ├── server/ # WebSocket 서버 + 드론 시뮬레이션 ├── preload/ # Main ↔ Renderer 보안 브릿지 └── renderer/src/ # React UI ├── contexts/ # WebSocket Context (연결 관리) ├── hooks/ # React Query 기반 상태 훅 ├── components/ # UI 컴포넌트 └── utils/ # 유틸리티 (클러스터링 등)
개발 과정: 커밋 히스토리로 보는 발전 과정
Git 커밋 로그를 통해 프로젝트가 어떻게 발전해왔는지 살펴보겠습니다.
Phase 1: 기초 세팅
468a9fe init electron app cad78de 지도 띄우기 기초 0443c6c 맵 기본 설정 완료 및 사이드 탭 추가
Electron 앱 초기화 후 Google Maps를 연동하고 기본 UI 구조를 잡았습니다.
Phase 2: 베이스 마커와 기본 기능
aa3e717 베이스 위치 설정 기능 추가 77c716c 베이스먼트 이동 애니메이션 추가 82a18ad 리액트 쿼리 추가
드론들의 기지 역할을 하는 베이스 마커를 구현하고, React Query를 도입해 상태 관리 기반을 마련했습니다.
Phase 3: 드론 구현
9731979 드론 생성기능 추가 e496a46 드론상태 값 업데이트 및 드론 수직 이륙 구현 5d01c5d 드론 이동 구현
드론 엔티티를 만들고 이륙, 이동, 착륙 등 기본 동작을 구현했습니다.
Phase 4: 성능 문제 발생과 해결 (핵심)
6733377 성능 일부 개선 및 전체 동작 기능 추가 f72bea5 드론 렌더링 최적화 da10e5e 드론 클러스터 적용, 뷰포트 바깥 렌더링 제외
여기서 가장 큰 난관을 만났습니다. 드론 수가 100대를 넘어가자 앱이 심각하게 버벅이기 시작했습니다.
핵심 문제: 100대 이상 드론 렌더링 시 성능 저하
문제 상황
드론 100대 이상에서 다음과 같은 증상이 발생했습니다:
- 지도 패닝/줌 시 심한 렉
- 드론 위치 업데이트가 눈에 띄게 지연
- 전체 UI 반응 속도 저하
원인 분석
-
매 업데이트마다 전체 드론 리렌더링
- 서버에서 200ms마다 드론 위치가 업데이트됨
- 1대만 움직여도 1000개 DroneMarker 컴포넌트가 전부 리렌더링
-
React와 Google Maps Advanced Marker 충돌
@vis.gl/react-google-maps의<AdvancedMarker>사용 시- React의 가상 DOM과 Google Maps의 실제 DOM 조작이 충돌
- 불필요한 마커 생성/삭제 반복
-
화면 밖 드론도 렌더링
- 뷰포트 밖에 있는 드론도 모두 렌더링 중
해결 과정
1단계: 변경 감지 최적화
문제: 서버에서 드론 배열을 받으면 무조건 새 배열로 교체
// Before: 항상 새 배열로 교체 → 모든 드론 리렌더링 queryClient.setQueryData(queryKeys.drones.list(), drones)
해결: 실제로 변경된 드론만 새 객체로 교체
// After: 변경된 드론만 새 객체로 queryClient.setQueryData<Drone[]>(queryKeys.drones.list(), (prev) => { if (!prev) return newDrones let hasChanges = false const updatedDrones = prev.map((prevDrone) => { const newDrone = newDrones.find((d) => d.id === prevDrone.id) if (!newDrone) return prevDrone // 위치, 상태, 배터리, 고도 비교 const changed = prevDrone.position.lat !== newDrone.position.lat || prevDrone.position.lng !== newDrone.position.lng || prevDrone.status !== newDrone.status || prevDrone.battery !== newDrone.battery || prevDrone.altitude !== newDrone.altitude if (changed) { hasChanges = true return newDrone // 새 객체 } return prevDrone // 기존 참조 유지 }) // 변경이 없으면 이전 배열 참조 유지 return hasChanges ? updatedDrones : prev })
효과: 변경되지 않은 드론은 동일 참조를 유지해 React가 리렌더링을 건너뜀
2단계: 마커 렌더링 방식 전면 교체
문제: <AdvancedMarker> 래퍼 컴포넌트 사용 시 React 렌더링 사이클마다 마커 재생성
// Before: React 렌더링마다 마커 조작 const DroneMarker = ({ drone }: DroneMarkerProps): React.JSX.Element => ( <AdvancedMarker position={drone.position}> <div className={styles.marker}> <Plane /> </div> </AdvancedMarker> )
해결: Google Maps API 직접 제어 + React는 내용만 렌더링
interface DroneMarkerProps { drone: Drone isSelected?: boolean onClick?: (droneId: string) => void } const DroneMarker = ({ drone, isSelected, onClick }: DroneMarkerProps): null => { const map = useMap() const markerRef = useRef<google.maps.marker.AdvancedMarkerElement | null>(null) const rootRef = useRef<Root | null>(null) const handleSelectDrone = useCallback((): void => { onClick?.(drone.id) }, [onClick, drone.id]) // 마커 생성 (최초 1회만) useEffect(() => { if (!map) return const content = document.createElement('div') const root = createRoot(content) rootRef.current = root root.render( <MarkerContent status={drone.status} isSelected={isSelected} onClick={handleSelectDrone} /> ) const marker = new google.maps.marker.AdvancedMarkerElement({ map, position: drone.position, content, zIndex: 10 }) markerRef.current = marker return () => { marker.map = null } }, [map]) // 위치 업데이트 (React 렌더링 없이 직접 DOM 조작) useEffect(() => { if (markerRef.current) { markerRef.current.position = drone.position } }, [drone.position.lat, drone.position.lng]) // 상태/선택 변경 시에만 React 리렌더링 useEffect(() => { rootRef.current?.render( <MarkerContent status={drone.status} isSelected={isSelected} onClick={handleSelectDrone} /> ) }, [drone.status, isSelected, handleSelectDrone]) return null }
핵심 포인트:
- 마커 생성/삭제:
map변경 시에만 (사실상 1회) - 위치 업데이트: React 거치지 않고
marker.position직접 변경 - 스타일 업데이트: 상태 변경 시에만
root.render()호출
3단계: 뷰포트 필터링
화면에 보이지 않는 드론은 렌더링할 필요가 없습니다.
// 뷰포트 내 드론만 필터링 export const filterDronesInViewport = ( drones: Drone[], bounds: google.maps.LatLngBounds | null ): Drone[] => { if (!bounds) return drones return drones.filter((drone) => bounds.contains({ lat: drone.position.lat, lng: drone.position.lng }) ) }
적용:
interface DroneMarkersLayerProps { mapBounds: google.maps.LatLngBounds | null mapZoom: number } const DroneMarkersLayer = ({ mapBounds, mapZoom }: DroneMarkersLayerProps): React.JSX.Element => { const { data: drones = [] } = useDrones() const { clusters, singles } = useMemo( () => getVisibleClustersAndDrones(drones, mapBounds, mapZoom), [drones, mapBounds, mapZoom] ) return ( <> {clusters.map((cluster) => <ClusterMarker key={cluster.id} cluster={cluster} />)} {singles.map((drone) => <DroneMarker key={drone.id} drone={drone} />)} </> ) }
4단계: 클러스터링 구현
6대 이상의 드론이 가까이 있으면 하나의 클러스터 마커로 합칩니다.
// 위경도 → 픽셀 좌표 변환 (Mercator 투영) const latLngToPixel = (lat: number, lng: number, zoom: number): { x: number; y: number } => { const scale = Math.pow(2, zoom) * 256 const x = ((lng + 180) / 360) * scale const latRad = (lat * Math.PI) / 180 const y = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * scale return { x, y } } // 그리디 클러스터링 알고리즘 export const clusterDrones = ( drones: Drone[], zoom: number, clusterRadius: number = 40 ): ClusteringResult => { const dronePixels = drones.map((drone) => ({ drone, pixel: latLngToPixel(drone.position.lat, drone.position.lng, zoom) })) const clustered = new Set<string>() const clusters: Cluster[] = [] const singles: Drone[] = [] for (let i = 0; i < dronePixels.length; i++) { const current = dronePixels[i] if (clustered.has(current.drone.id)) continue const nearby = [current] for (let j = i + 1; j < dronePixels.length; j++) { const other = dronePixels[j] if (clustered.has(other.drone.id)) continue if (pixelDistance(current.pixel, other.pixel) <= clusterRadius) { nearby.push(other) } } if (nearby.length > 5) { nearby.forEach((item) => clustered.add(item.drone.id)) clusters.push({ id: `cluster-${current.drone.id}`, drones: nearby.map((item) => item.drone), center: calculateCenter(nearby) }) } else { clustered.add(current.drone.id) singles.push(current.drone) } } return { clusters, singles } }
클러스터링
최적화 결과
| 항목 | 최적화 전 | 최적화 후 |
|---|---|---|
| 100대 드론 렌더링 | 버벅임 심함 | 부드러움 |
| 1000대 드론 렌더링 | 사용 불가 | 정상 동작 |
| 드론 위치 업데이트 | 전체 리렌더링 | 변경분만 업데이트 |
| 뷰포트 밖 드론 | 모두 렌더링 | 렌더링 제외 |
핵심 최적화 전략 요약:
- 변경 감지: 실제 변경된 드론만 새 객체로 교체
- 마커 분리: 생성(1회) / 위치(직접 DOM) / 스타일(필요시만)
- 뷰포트 필터링: 화면 밖 드론 렌더링 제외
- 클러스터링: 밀집 드론 그룹화로 마커 수 감소
상태 관리 구조
WebSocket + React Query 조합
서버 (WebSocket) │ ▼ heartbeat (3초마다) ▼ drones:update (200ms마다) │ WebSocketContext │ messageHandlers.ts ▼ queryClient.setQueryData() │ ▼ React Query Cache │ ▼ useDrones() → 컴포넌트 리렌더링
장점:
- WebSocket: 실시간 양방향 통신
- React Query: 캐시 기반 상태 관리, 자동 리렌더링 최적화
- 두 가지를 조합해 실시간성과 성능 모두 확보
Query Keys 구조
const queryKeys = { connection: { status: () => ['connection', 'status'] }, server: { config: () => ['server', 'config'], running: () => ['server', 'running'] }, map: { basePosition: () => ['map', 'basePosition'], baseMovement: () => ['map', 'baseMovement'] }, drones: { list: () => ['drones', 'list'] } }
WebSocket 프로토콜
Server → Client
| Type | 설명 |
|---|---|
heartbeat | 연결 상태 확인 + 초기 데이터 (3초 주기) |
drones:update | 드론 위치/상태 업데이트 (200ms 주기) |
basePosition:updated | 베이스 위치 변경 완료 |
basePosition:moving | 베이스 이동 시작 (애니메이션용) |
Client → Server
| Type | 설명 |
|---|---|
drone:takeoff | 개별 드론 이륙 |
drone:land | 개별 드론 착륙 |
drone:returnToBase | 드론 베이스 복귀 |
drone:move | 드론 이동 (웨이포인트) |
drone:allTakeoff | 전체 드론 이륙 |
drone:allReturnToBase | 전체 드론 복귀 |
드론 상태 머신
┌──────────────────────────────────────────────┐ ▼ │ [idle] ── takeoff ──► [ascending] ──► [hovering] │ ▲ │ │ │ │ │ [landing] ◄────── land ───────────────────┘ │ ▲ │ │ move/waypoint │ │ └── land ──── [moving] ◄───────────────────────┘ │ │ returnToBase ▼ [returning] ──► [landing] ──► [idle]
빌드 및 배포
빌드 명령어
yarn build:win # Windows 설치 파일 생성 npm run build:mac # macOS 빌드 (yarn 빌드 버그로 인해 mac 에서는 npm 빌드사용)
배포 파일
Drone.Control.System-1.0.0-arm64-mac.zipDrone.Control.System-1.0.0-mac.zipDrone.Control.System-1.0.0-Setup.exewin-unpacked.zip
각 환경에 맞춰 사용, Node.js 없이 독립 실행 가능 (Electron이 런타임 포함)
마무리
이 프로젝트에서 가장 큰 배움은 React의 렌더링 최적화였습니다. 특히:
-
React의 한계를 인식하고 우회하기: 수천 개의 빠르게 변하는 요소를 다룰 때는 React의 가상 DOM이 오히려 병목이 될 수 있습니다. 직접 DOM을 조작하는 것이 더 효율적인 경우도 있습니다.
-
변경 감지의 중요성: 객체/배열의 참조가 바뀌면 React는 리렌더링합니다. 실제로 변경된 부분만 새 참조를 만드는 것이 핵심입니다.
-
렌더링할 대상 줄이기: 클러스터링과 뷰포트 필터링으로 실제 렌더링해야 할 요소 수를 줄이는 것이 가장 효과적입니다.
이 경험이 비슷한 대규모 실시간 시각화 프로젝트를 진행하는 분들께 도움이 되길 바랍니다.
프로젝트 링크
- GitHub: Drone-Control-System
- 다운로드: Releases