라즈베리파이를 아두이노 무선 업로더로 — 브라우저로 컴파일·플래시·시리얼까지
현장에 깔린 아두이노 장비의 펌웨어를 고칠 때마다 노트북에 Arduino IDE를 켜고, USB 케이블을 들고 장비 앞까지 가야 했습니다. 보드가 한두 개면 괜찮지만, 종류가 AVR·ESP32·RP2040으로 섞여 있고 위치도 제각각이면 이 왕복이 만만치 않습니다.
그래서 라즈베리파이 한 대를 “공용 업로더”로 만들기로 했습니다. 파이를 장비 옆에 두고 USB로 연결해두면, 나는 책상에서든 외부에서든 브라우저만 열어 스케치를 올리고 시리얼 로그를 본다 — 이게 목표였습니다.
결과적으로 arduino-cli를 감싼 FastAPI 서버 하나로, 보드 선택부터 컴파일·플래시·시리얼 콘솔·라이브러리 관리·VPN 원격 접속까지 모두 웹에서 처리하게 됐습니다. 이 글은 그 설계와 구현, 그리고 막혔던 지점들의 기록입니다. (전 과정을 Claude Code와 대화하며 만들었습니다.)
로그인부터

LAN 안이든 VPN 너머든 같은 화면으로 들어옵니다. 비밀번호는 공장 기본값(admin)으로 시작해 접속 후 바로 바꾸도록 했습니다. 인증을 켜기 전엔 LAN 내부 누구나 접속 가능하고, 한 번 비밀번호를 설정하면 모든 페이지가 세션 쿠키로 잠깁니다.
핵심 화면 — 업로드

왼쪽에서 .ino/.zip 소스나 .hex/.bin/.uf2 바이너리를 올려 저장해두고, 오른쪽에서 프로그램 + 보드(FQBN) + 포트를 골라 업로드합니다. 업로드를 누르면 검증 → 컴파일 → 보드 플래시 단계가 실시간 로그로 흘러내려옵니다. 컴파일 결과(Sketch uses ... bytes)부터 플래시 진행률, 종료 코드까지 그대로 보입니다.
처음엔 업로드를 누르면 “잡 시작 중…”에서 한참 멈춘 것처럼 보였습니다. 사실은 백그라운드에서 컴파일이 도는 중이었는데, 화면에 아무 변화가 없으니 멈춘 걸로 오해하게 됩니다. 그래서 단계 라벨 + 경과 시간 카운터(⏳ 컴파일 진행 중… 12초 경과)를 넣어, “느린 것”과 “멈춘 것”을 눈으로 구분할 수 있게 했습니다. 작은 변화지만 체감이 완전히 달라졌습니다.
보드와 라이브러리는 IDE처럼

Arduino IDE의 보드 매니저처럼, 코어를 검색해서 설치할 수 있습니다. 설치된 코어가 있으면 그 코어에 속한 보드들이 업로드 화면의 FQBN 드롭다운에 자동으로 채워집니다. AVR·ESP32·ESP8266·RP2040을 같은 방식으로 다룹니다.

라이브러리도 마찬가지입니다. 자주 쓰는 기본 라이브러리 13종(ArduinoJson, PubSubClient, Adafruit GFX, U8g2, FastLED …)은 한 번에 일괄 설치하고, 나머지는 이름으로 검색해 설치합니다. 레지스트리에 없는 사내 라이브러리는 ZIP을 그대로 업로드해서 넣습니다.
시리얼 콘솔

업로드한 펌웨어가 잘 도는지 보려면 시리얼이 필요합니다. 보드 포트를 골라 Connect 하면 WebSocket으로 양방향 시리얼 콘솔이 열립니다. 장비가 뱉는 로그를 실시간으로 보고, 입력란으로 명령을 보낼 수도 있습니다. 펌웨어를 올리고 → 바로 동작을 확인하는 흐름이 한 화면에서 끝납니다.
시스템 페이지 — 파이 자신을 관리

업로더가 돌아가는 라즈베리파이 자체의 상태도 같은 UI에서 봅니다. CPU 온도, 메모리, 업타임, OS/런타임 버전이 상단 칩으로 뜨고, 장비 이름을 바꾸면 going-arduino.local 같은 mDNS 주소로 바로 접속됩니다. IP를 외울 필요가 없습니다.
원격 접속은 Tailscale VPN으로 붙였습니다. 외부에서도 사내망에 들어온 것처럼 같은 주소로 접속하고, 필요하면 공개 HTTPS(Funnel)까지 열 수 있습니다(이건 비밀번호 설정을 강제해서 무방비 노출을 막았습니다).
도구 스택
| 영역 | 선택 | 역할 |
|---|---|---|
| 서버 | FastAPI + Uvicorn (Python 3.13) | REST + WebSocket |
| 빌드/플래시 | arduino-cli 래퍼 | 코어·라이브러리·컴파일·업로드 위임 |
| 작업 큐 | 단일 워커 비동기 큐 + 로그 파일 | 업로드/설치를 순차 실행 |
| 실시간 | WebSocket (잡 로그 / 시리얼 / 장치) | 진행 상황 스트리밍 |
| 시리얼/장치 | pyserial · pyudev | 포트 감지 + 양방향 콘솔 |
| 원격 | Tailscale · mDNS(avahi) | VPN 접속 + *.local 주소 |
| 시스템 | nmcli · hostnamectl · systemd | Wi-Fi/이름변경/서비스 제어 |
| 프런트 | 바닐라 HTML/CSS/JS (빌드 없음) | 다크 테마 SPA |
설계에서 신경 쓴 것들
1) arduino-cli에 전부 위임
보드 매트릭스(AVR/ESP32/ESP8266/RP2040)를 직접 다루는 대신, 모든 작업을 arduino-cli에 넘기는 얇은 래퍼로 짰습니다. 덕분에 “새 보드 지원”이 사실상 코어 하나 설치로 끝납니다. 컴파일·업로드 옵션도 CLI가 검증해주니, 서버는 명령을 조립하고 출력을 스트리밍하는 일에만 집중합니다.
2) 포트가 사라져도 선택은 유지
RP2040은 플래시할 때 BOOTSEL 모드로 재부팅하면서 시리얼 포트가 잠깐 사라졌다 다시 나타납니다. 이때 드롭다운 선택이 초기화되면 업로드가 끊깁니다. 그래서 한 번 선택(또는 자동 선택)된 포트는 장치가 잠시 사라져도 “재연결 대기 중…”으로 붙들어두도록 했습니다.
3) ZIP 라이브러리 정규화
arduino-cli는 ZIP 최상위에 폴더가 여러 개면 설치를 거부합니다. 사내에서 주고받는 라이브러리 ZIP이 종종 이 형태라, 업로드 시 최상위가 폴더 하나가 되도록 자동으로 감싸서 설치 성공률을 높였습니다.
4) 최소 권한 sudo
시스템 제어(이름 변경/서비스 재시작/재부팅)는 root가 필요하지만, 서비스 계정에 통째로 sudo를 주긴 싫었습니다. 그래서 sudoers에 딱 세 명령만 무암호로 허용했습니다 — hostnamectl set-hostname(장비 이름 변경), systemctl restart(업로더 서비스 재시작), reboot(재부팅). 와일드카드도 이름 인자 하나로 제한해 임의 명령이 끼어들 여지를 없앴습니다.
여기서 하나 데었습니다. systemd 유닛에 NoNewPrivileges=true를 켜두면 sudo 권한 상승 자체가 막혀 위 세 명령의 sudo 호출이 전부 실패합니다. 보안 옵션이라 무심코 켰다가, 권한 상승이 필요한 유닛에서는 빼야 한다는 걸 알았습니다.
5) UI는 사내 컨트롤러와 통일
이미 쓰고 있던 사내 PLC 컨트롤러(Going ZPI)의 웹 관리 UI와 같은 다크 테마·사이드바·카드 레이아웃으로 맞췄습니다. 장비는 달라도 관리 화면의 사용감이 같으면, 새 도구를 따로 익힐 필요가 없습니다.
회고
- 위임의 힘. 어려운 부분(보드별 컴파일/업로드 토글)을
arduino-cli에 모두 떠넘기니, 내가 짠 코드는 “명령 조립 + 스트리밍 + 파일 관리”라는 얇은 층만 남았습니다. 지원 보드를 늘리는 비용이 거의 0입니다. - “진행 중”을 보여주는 것도 기능이다. 같은 14초라도, 빈 화면 14초와 단계·경과시간이 도는 14초는 전혀 다른 경험입니다. 장시간 작업일수록 피드백이 곧 신뢰입니다.
- 보안 기본값의 함정.
NoNewPrivileges같은 “켜두면 좋은” 옵션이 권한 상승과 충돌합니다. 보안 옵션도 무조건이 아니라 이 서비스가 뭘 해야 하는지에 맞춰 골라야 합니다. - AI 협업은 재시도를 싸게 만든다. “포트가 중간에 바뀌어요”, “잡 시작에서 멈춘 것처럼 보여요” 같은 한 줄 피드백이 곧 다음 패치가 됐습니다. 본질적으로 반복인 개발에서, 매 시도의 비용이 낮아지는 게 가장 큰 차이였습니다.
라즈베리파이 +
arduino-cli+ FastAPI 조합은 “현장 장비를 브라우저로 다룬다”는 목표에 잘 맞았습니다. 한 대 세팅해두면, 펌웨어 업데이트가 ‘장비 앞으로 가는 일’에서 ‘탭 하나 여는 일’로 바뀝니다.
댓글
로그인 없이 이름만 적고 남기실 수 있어요. 남긴 댓글은 바로 게시됩니다.
댓글을 불러오는 중…