Frontend
로봇을 웹 기술로 제어하기 — Electron + React로 만든 서비스 로봇 컨트롤 UI
전성진··수정됨 2026.07.21

배경
사무실에 서비스 로봇이 들어왔습니다. 안내, 순찰, 방문객 응대까지 수행할 수 있는 OrionStar의 로봇이었습니다.
문제는 공식 앱이었습니다. 제공되는 기능은 충분했지만, 우리가 원하는 업무 흐름 자동화는 지원하지 않았습니다.
로봇을 한 번 움직이려면 매번 같은 과정을 반복해야 했습니다.
태블릿을 꺼낸다
목적지를 수동으로 선택한다
로봇이 충전 중이면 먼저 충전을 해제한다
그 다음 이동 명령을 내린다
이 과정이 반복되다 보니 자연스럽게 이런 생각이 들었습니다.
"이걸 자동화할 수 없을까?"
다행히 OrionStar는 OpenAPI를 제공하고 있었습니다. REST API로 로봇에 직접 명령을 내릴 수 있었고, 이를 활용하면 우리가 원하는 형태의 자동화를 구현할 수 있었습니다.
그래서 우리는 Electron + React 기반 데스크탑 앱을 만들기로 결정했습니다.
기술 스택 선택
이번 프로젝트의 기술 스택은 다음과 같습니다.
Electron 29 — 데스크탑 앱 래퍼 (macOS / Windows / Linux)
React 18 — UI 렌더링
Vite 5 — 빠른 개발 환경
TypeScript — 타입 안정성 확보
Zustand — 전역 상태 관리
Axios — HTTP 통신
Framer Motion — UI 애니메이션
웹 기술을 선택한 이유는 단순했습니다.
팀이 이미 React에 익숙했고, 로봇 제어라고 해서 특별한 네이티브 기능이 필요한 것도 아니었습니다. REST API 기반이라면 Electron만으로 충분히 구현 가능했습니다.
핵심 문제 1 — 충전 중인 로봇은 이동하지 않는다
가장 먼저 부딪힌 문제는 꽤 당황스러웠습니다.
로봇이 충전 중일 때 이동 명령을 보내면,
API는 성공 응답을 반환하지만 로봇은 아무런 반응을 하지 않았습니다.
처음에는 버그라고 생각했지만, 문서를 자세히 확인해보니 이유가 있었습니다.
충전 중인 상태에서는 반드시
cmd_stop_charging을 먼저 호출해야 한다.
즉, 이동 명령에는 숨겨진 사전 조건이 존재했던 것입니다.
이 문제를 해결하기 위해 모든 이동 로직을 하나의 함수로 통합했습니다.
async navigateWithChargingCheck(destination: string): Promise<ApiResult<CommandResponse>> {
// 1. 로봇 상태 조회
const statusResult = await this.getRobotInfo()
if (!statusResult.success || !statusResult.data) {
return { success: false, error: '로봇 상태를 확인할 수 없습니다' }
}
// 2. 충전 중이면 먼저 해제
if (statusResult.data.is_charging === '1') {
const stopResult = await this.stopCharging()
if (!stopResult.success) {
return { success: false, error: '충전 중지에 실패했습니다' }
}
// 하드웨어 안정화를 위한 짧은 대기
await new Promise(resolve => setTimeout(resolve, 1000))
}
// 3. 이동 명령 실행
return this.navigate(destination)
}이 함수는 이후 모든 이동 관련 기능(순찰, 안내, 수동 이동)의 단일 진입점이 되었습니다.
👉 중요한 포인트는 이것입니다:
하드웨어 API는 종종 명시되지 않은 전제 조건이 있다
따라서 “명령”이 아니라 “워크플로우” 단위로 추상화해야 한다
핵심 문제 2 — 토큰은 반드시 만료된다
OrionStar API는 access_token 기반 인증을 사용합니다.
초기 구현에서는 앱 시작 시 한 번만 토큰을 발급받았는데, 시간이 지나면 토큰이 만료되면서 모든 API 요청이 실패하는 문제가 발생했습니다.
이 문제를 해결하기 위해 토큰 자동 갱신 로직을 추가했습니다.
class OrionStarApi {
private accessToken: string | null = null
private tokenExpiry: number = 0
private async ensureValidToken(): Promise<void> {
const TOKEN_REFRESH_MARGIN_MS = 60_000 // 만료 1분 전
if (!this.accessToken || Date.now() >= this.tokenExpiry - TOKEN_REFRESH_MARGIN_MS) {
await this.refreshToken()
}
}
private async apiGet<T>(endpoint: string): Promise<ApiResult<T>> {
await this.ensureValidToken()
// API 호출
}
private async apiPost<T>(endpoint: string): Promise<ApiResult<T>> {
await this.ensureValidToken()
// API 호출
}
}이 구조의 장점은 명확합니다.
모든 API 호출 전에 자동으로 토큰을 검사
만료 임박 시 자동 갱신
외부에서는 토큰 존재를 신경 쓸 필요 없음
인증 로직을 “중앙 집중화”하면 코드 복잡도가 크게 줄어듭니다.
핵심 문제 3 — 로봇은 생각보다 느리다
일반적인 웹 API는 수백 밀리초 내에 응답을 반환합니다.
하지만 로봇은 다릅니다.
이동 명령의 경우, 로봇이 목적지에 도착할 때까지 응답을 보내지 않을 수도 있습니다.
초기에는 Axios 타임아웃을 30초로 설정했는데, 로봇이 멀리 있을 경우 계속 타임아웃이 발생했습니다.
결국 타임아웃을 다음과 같이 조정했습니다.
this.client = axios.create({
baseURL: API_BASE_URL,
timeout: 180_000, // 3분
})교훈:
하드웨어는 네트워크보다 훨씬 느리다
타임아웃은 UX에 직접적인 영향을 준다
순찰 기능 — 로봇이 스스로 돌아다니게 만들기
단순 이동이 가능해진 후, 다음 목표는 자율 순찰 기능이었습니다.
전체 흐름은 다음과 같습니다.
순찰 위치 목록 가져오기 (충전소 제외)
첫 위치로 이동
도착할 때까지 위치 폴링
도착 시 인사(TTS)
다음 위치로 이동
배터리 부족 시 충전소 복귀
핵심은 “도착 판단”입니다.
API가 완료 이벤트를 주지 않기 때문에, 직접 현재 위치를 계속 확인해야 했습니다.
const waitForArrival = (targetPosition: string): Promise<boolean> => {
return new Promise(resolve => {
const startTime = Date.now()
const checkArrival = () => {
if (!patrolLoopRef.current) {
resolve(false)
return
}
if (Date.now() - startTime > 60_000) {
resolve(false)
return
}
const currentPos = useRobotStore.getState().robotInfo?.pos_info
if (currentPos === targetPosition) {
resolve(true)
return
}
setTimeout(checkArrival, 2000)
}
checkArrival()
})
}여기서 중요한 구현 포인트는 useRef입니다.
👉 비동기 루프 내부에서는 React state 대신 ref를 사용해야
👉 클로저 문제 없이 최신 값을 즉시 읽을 수 있습니다.
상태 관리 — Zustand 선택 이유
로봇 상태(배터리, 위치, 온라인 여부)는 주기적으로 갱신해야 했습니다.
Zustand를 선택한 이유는 단순합니다.
보일러플레이트가 적다
비동기 처리 작성이 쉽다
전역 상태 접근이 간단하다
export const useRobotStore = create<RobotState>((set) => ({
robotInfo: null,
fetchRobotInfo: async () => {
const result = await api.getRobotInfo()
if (result.success && result.data) {
set({ robotInfo: result.data })
} else {
// 실패 시 기존 데이터 유지
set({ error: result.error })
}
},
}))중요한 UX 포인트:
API 실패 시 데이터를 지우지 않는다
“마지막 정상 상태”를 유지한다
개발 생산성을 높인 Mock 데이터 전략
로봇은 항상 사용 가능한 상태가 아니었습니다.
그래서 초기부터 Mock 데이터를 사용할 수 있도록 구조를 설계했습니다.
fetchRobotInfo: async () => {
if (get().useMockData) {
set({ robotInfo: MOCK_ROBOT_INFO })
return
}
}결과:
로봇 없이도 UI 개발 가능
QA / 데모 환경에서 매우 유용
배터리 데이터 불일치 문제
배포 이후 배터리가 0%로 표시되는 문제가 발생했습니다.
원인을 분석해보니 API가 동일 데이터를 두 군데에서 내려주고 있었습니다.
robot.battery_rate (캐시)
robot_report_status.battery.battery_rate (실시간)
해결 방법은 간단했습니다.
battery_rate: battery?.battery_rate || robot?.battery_rate || '0'항상 실시간 데이터를 우선 사용하도록 변경
결과
현재 이 앱은 실제 사무실에서 운영 중입니다.
아침 자동 순찰 시작
배터리 부족 시 자동 충전
방문객 안내 자동화
웹 기술만으로 물리적인 로봇을 제어하는 것이 충분히 가능하다는 것을 확인할 수 있었습니다.
핵심 교훈
1. 하드웨어 API는 반드시 사전 조건이 있다
→ 상태 확인 없이 명령을 보내면 실패한다
2. 타임아웃은 넉넉하게 설정하라
→ 로봇은 웹보다 훨씬 느리다
3. 에러 메시지는 사용자 언어로 변환하라
→ UX의 핵심 요소
4. 비동기 루프에서는 ref를 사용하라
→ 클로저 문제 방지
5. Mock 데이터는 초반부터 설계하라
→ 개발 속도 차이가 크다
마무리
웹 개발자에게 로봇 제어는 낯설 수 있지만, 본질은 크게 다르지 않습니다.
결국 로봇도 API다.
다만 그 API 뒤에는 물리적인 제약과 시간이 존재한다는 점만 다를 뿐입니다.
이 글이 하드웨어 연동 프로젝트를 시작하는 분들께 도움이 되기를 바랍니다.
읽기 도구
약 11분 읽기
이 글이 도움이 되었나요?