2026년 10월 26일 · @martin
들어가며
집에서 서버로 쓰는 팬리스 PC에 DC 모터를 붙이고, 밖에서 휴대폰으로 REST API를 호출해 돌려보기로 했다. 웹 API는 매일 만드는 일이니 쉬울 줄 알았는데, 첫 질문에서 막혔다. "이 서버에서 모터로 가는 선은 어디에 꽂지?"
라즈베리파이라면 GPIO 핀에서 바로 PWM을 뽑으면 된다. 하지만 내 서버는 팬 없는 일반 x86 PC다. GPIO 핀이 없다. 이 글은 그 빈자리를 어떻게 메웠는지, 그리고 25년 동안 백엔드만 만들던 사람이 하드웨어 앞에서 정리한 설계 기록이다.
전체 구성
결론부터 말하면, 서버는 명령만 보내고 실제 펄스는 아두이노가 만든다. 요청은 HTTPS로 들어와 Nginx, CI4, Python 데몬을 거쳐 USB 시리얼로 아두이노에 닿고, 아두이노가 모터 드라이버에 PWM을 걸어 모터를 돌린다.

각 계층이 하는 일은 하나씩이다. Nginx는 TLS와 프록시, CI4는 인증과 API, Python 데몬은 시리얼 포트, 아두이노는 타이밍, 드라이버는 전류를 맡는다.
왜 아두이노를 중간에 두나
GPIO가 없는 x86에서 선택지는 두 가지였다. FT232H나 MCP2221 같은 USB-GPIO 어댑터로 서버가 직접 핀을 흔들거나, 마이크로컨트롤러를 하나 두고 명령만 넘기는 방식이다.
어댑터 방식은 PWM 타이밍이 USB 통신 지연에 그대로 흔들린다. 리눅스는 실시간 OS가 아니라서, 서버가 직접 펄스를 만들면 부하가 걸릴 때마다 모터가 덜컥거린다. 그래서 역할을 나눴다.
- 타이밍 분리: 서버는 "속도 50, 정방향"만 보내고, PWM은 아두이노가 일정하게 만든다.
- 장애 격리: 서버가 재부팅되거나 API가 죽어도 아두이노가 워치독으로 모터를 안전하게 멈춘다.
- 전기적 격리: USB 시리얼 한 가닥만 연결되니, 모터 쪽 노이즈가 서버로 넘어올 길이 좁아진다.
덤으로 ESP32를 쓰면 WiFi가 있어 USB 케이블 없이도 붙일 수 있다. 다만 처음 만드는 구성이라 디버깅이 쉬운 USB 시리얼을 기본으로 잡았다.
하드웨어 준비물
부품은 많지 않다. 핵심은 방향과 속도를 함께 다루는 H-브리지 드라이버다.
|
구성 요소 |
선택 |
메모 |
|---|---|---|
|
서버 |
팬리스 x86 PC + Ubuntu |
이미 집에서 24시간 돌고 있는 것 |
|
중계 MCU |
Arduino Uno/Nano 또는 ESP32 |
USB로 연결하면 |
|
모터 드라이버 |
L298N 또는 TB6612FNG |
L298N은 싸고 흔하다, TB6612FNG는 효율이 좋다 |
|
모터 전원 |
모터 정격에 맞는 별도 어댑터 |
서버·MCU 전원과 반드시 분리 |
|
모터 |
소형 DC 모터 |
정·역회전 + PWM 속도 제어 |
가장 중요한 원칙은 전원 분리다. 모터는 역기전력과 노이즈가 커서, 로직 전원과 같이 쓰면 MCU가 리셋되거나 최악에는 USB 포트를 통해 서버까지 상할 수 있다. 접지는 드라이버와 MCU 사이에서만 공통으로 묶는다.
소프트웨어 3단
소프트웨어는 아래에서 위로 세 겹이다. 각 겹은 바로 위아래와만 이야기한다. 아래 코드는 설계를 설명하기 위한 뼈대이며, 실제 핀 번호와 경로는 환경에 맞게 바꿔야 한다.
1. 아두이노 펌웨어 — 명령 파싱과 워치독
시리얼 명령은 사람이 읽을 수 있는 한 줄 텍스트로 정했다. CMD:START,SPEED:50,DIR:FWD 같은 형식이다. 일정 시간 명령이 없으면 스스로 멈추는 것이 핵심이다.
const int PIN_PWM = 9, PIN_IN1 = 7, PIN_IN2 = 8;
const unsigned long TIMEOUT_MS = 3000;
unsigned long lastCmd = 0;
void stopMotor() { analogWrite(PIN_PWM, 0); }
void loop() {
if (Serial.available()) {
String line = Serial.readStringUntil('\n');
// CMD:START,SPEED:50,DIR:FWD 파싱 → 방향 핀 설정, analogWrite(PIN_PWM, map(speed,0,100,0,255))
lastCmd = millis();
Serial.println("OK");
}
if (millis() - lastCmd > TIMEOUT_MS) stopMotor(); // 서버가 조용하면 안전 정지
}
2. Python 데몬 — 시리얼 전담
PHP는 시리얼 포트를 오래 붙잡고 있는 일에 약하다. 요청마다 포트를 열고 닫으면 아두이노가 리셋되기도 한다. 그래서 pyserial로 포트를 계속 열어 두는 작은 데몬을 systemd로 띄우고, CI4와는 Unix 소켓으로 이야기한다.
import socket, os, serial
SOCK = "/run/motord/motord.sock"
ser = serial.Serial("/dev/ttyACM0", 115200, timeout=1)
if os.path.exists(SOCK): os.remove(SOCK)
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
srv.bind(SOCK); srv.listen(1)
while True:
conn, _ = srv.accept()
cmd = conn.recv(256).decode().strip() # 예: CMD:START,SPEED:50,DIR:FWD
ser.write((cmd + "\n").encode())
conn.sendall(ser.readline())
conn.close()
3. CI4 REST API — 늘 하던 일
여기부터는 익숙한 영역이다. 라우트와 컨트롤러는 평소 API와 똑같고, 다른 점은 응답 대신 소켓에 한 줄을 쓴다는 것뿐이다.
// app/Config/Routes.php
$routes->group('api/motor', ['filter' => 'hmac'], static function ($routes) {
$routes->post('start', 'Motor::start');
$routes->post('stop', 'Motor::stop');
});
// app/Controllers/Motor.php
public function start()
{
$speed = max(0, min(100, (int) $this->request->getJsonVar('speed')));
$dir = $this->request->getJsonVar('dir') === 'rev' ? 'REV' : 'FWD';
$reply = $this->send("CMD:START,SPEED:{$speed},DIR:{$dir}");
return $this->respond(['result' => trim($reply)]);
}
private function send(string $cmd): string
{
$fp = stream_socket_client('unix:///run/motord/motord.sock', $errno, $err, 2);
fwrite($fp, $cmd . "\n");
$reply = fgets($fp);
fclose($fp);
return (string) $reply;
}
입력값은 컨트롤러에서 범위를 잘라 화이트리스트로만 조립한다. 외부 문자열이 그대로 시리얼로 흘러가면 그게 곧 하드웨어 인젝션이다.
인터넷에 여는 법과 보안
우리 집은 공유기 포트를 열 수 있는 환경이라, 외부 공개는 포트포워딩으로 단순하게 갔다. 대신 웹페이지가 아니라 물리적으로 움직이는 물건이 걸린 API라서 보안은 평소보다 한 단계 더 조였다.
- 공유기: 443만 서버로 포워딩한다. SSH는 외부에 열지 않는다.
- Nginx + Let's Encrypt: HTTPS만 받고, CI4로 리버스 프록시한다.
- API Key + HMAC-SHA256 서명: 요청 본문과 타임스탬프를 비밀키로 서명해 헤더에 싣는다. CI4 필터에서 서명을 다시 계산해 비교한다.
- 재전송 방지: 타임스탬프가 수십 초 이상 지난 요청은 버린다. 가로챈 "켜기" 요청을 나중에 다시 쏘는 공격을 막는다.
- 레이트 리밋: CI4 Throttler 필터로 같은 키의 연속 호출을 제한한다.
- UFW + fail2ban: 방화벽은 필요한 포트만 열고, 인증 실패가 반복되는 IP는 자동 차단한다.
마지막 안전장치는 소프트웨어 바깥에 있다. 아두이노의 타임아웃 정지와, 손으로 끊을 수 있는 물리 전원 스위치다. 원격 제어는 언제든 실패할 수 있다는 전제로 설계했다.
곁가지: 모터 대신 전등이라면
설계를 하다 보니 "모터 말고 집 전등을 켜고 끄는 건 어떨까" 하는 생각이 들었다. 온오프만, 기존 배선을 개조하는 조건이면 구성이 훨씬 단순해진다.
|
항목 |
DC 모터 |
전등 (온오프) |
|---|---|---|
|
제어 |
방향 + 속도(PWM) |
켜기/끄기만 |
|
구동부 |
H-브리지 드라이버 |
릴레이 또는 SSR |
|
실시간성 |
중요 |
거의 무관 |
|
위험 |
역기전력, 노이즈 |
AC 220V 감전·화재 |
알리익스프레스에서 산 WiFi 릴레이 모듈(WHD01, Tuya 계열)이 있다면 아두이노조차 필요 없다. L-in/L-out으로 활선을 끊고, S1/S2에 기존 벽 스위치를 물리면 원격과 수동을 함께 쓸 수 있다. 소프트웨어 경로는 둘이다.
- Tuya Cloud REST API: 플래싱 없이 바로 쓸 수 있지만 클라우드에 의존한다.
- Tasmota 플래싱: 칩셋(ESP8266이면 tuya-convert, BK7231이면 cloudcutter 계열)에 맞게 펌웨어를 바꾸면
http://<모듈IP>/cm?cmnd=Power%20ON같은 로컬 HTTP API가 열린다. CI4는 내부망으로 HTTP 한 줄만 보내면 된다.
전기 작업은 소프트웨어와 다른 세계다. 검전 드라이버로 활선(L)과 중성선(N)을 확인하고, 차단기를 내린 뒤 다시 한 번 확인하고 만진다. 릴레이는 반드시 활선 쪽에 넣는다. 스위치 박스에 중성선이 없는 배선(스위치 루프)일 수도 있으니 열어보기 전에 단정하지 않는다.
마치며
정리하면, GPIO 없는 서버로 하드웨어를 움직이는 답은 "서버에게 하드웨어를 시키지 않는 것"이었다. 서버는 늘 하던 대로 API를 받고 인증하고 명령을 넘기며, 시간에 민감한 일은 아두이노가 맡는다. 백엔드 개발자에게 익숙한 MSA식 역할 분리가 책상 위 모터에도 그대로 통했다.
다음 단계는 순서대로 이렇게 잡았다.
- 아두이노 ↔ L298N ↔ 모터 결선, USB로 서버에 연결해 포트 확인
- 펌웨어: 명령 파싱 + 타임아웃 정지
- Python 데몬을 systemd 서비스로 등록
- CI4 라우트·컨트롤러·HMAC 필터
- Nginx + HTTPS로 외부 공개, 휴대폰에서 첫 회전
실제로 모터가 돌기 시작하면 결선 사진과 함께 후속편으로 남기겠다.

댓글 0개