Notice
Recent Posts
Recent Comments
반응형
관리 메뉴

HM소프트

커피머신 내장 웹 서버 개발기 (1) — 기기 안에서 도는 UI 서버와 Yocto 레시피 본문

외주 개발일지

커피머신 내장 웹 서버 개발기 (1) — 기기 안에서 도는 UI 서버와 Yocto 레시피

HM소프트 2026. 6. 21. 06:16
반응형

임베디드 보드 위에 상주하는 조작 패널 웹 서버를 만든다. 화면(aiohttp 웹 서버)과 기계(제어 프로세스)를 분리하고, 40바이트 헤더 바이너리 프레임 IPC로 둘을 잇는다. 매직 넘버 재동기화 디코더, 와이파이 AP와 OS별 캡티브 포털, 부팅 자동기동을 박제한 Yocto/BitBake 레시피까지 다룬다.

데모가 아니라, 기기 안에 들어가는 버전

한 커피머신 제조사의 신형 머신에 들어갈 조작 패널 UI를 만들고 있다. 머신 전면에 붙는 작은 터치 디스플레이에서, 사용자는 레시피를 고르고 추출을 시작하고 진행률을 지켜보고 알람을 끈다. 앞서 나는 같은 화면을 서버 없이 링크 하나로 보여주는 정적 데모 버전으로 먼저 만들어 따로 기록했다. 이번 글은 그 반대편 — 실제 머신 보드 위에서 상주하며 도는 버전의 이야기다.

데모와 실기기 버전은 화면은 거의 같지만 생애 주기가 완전히 다르다. 데모는 빌드해서 정적 호스팅에 올리면 끝이다. 반면 실기기 버전은 임베디드 리눅스 위에서 웹 서버 프로세스로 상주해야 하고, 펌웨어 이미지에 패키지로 포함돼 출하되며, 기기 전원이 들어오면 사람 손 없이 부팅 직후 떠 있어야 한다. 게다가 추출 같은 실제 동작은 웹 서버가 아니라 별도의 제어 프로세스가 담당하므로, 둘 사이를 잇는 통신 규격까지 정의해야 했다.

그래서 이 저장소의 첫 커밋은 화면 코드가 아니라 "웹 서버 + 기기 통신 + Yocto 레시피"라는 인프라 한 덩어리였다. 화면을 예쁘게 그리는 일보다 먼저, 이 앱이 기기 안에서 어떻게 태어나고 어떻게 살아가는가를 정한 셈이다. 이번 글에서는 그 골격을 순서대로 풀어본다. 같은 화면을 서버 렌더링으로 다시 그린 이유, 기기 제어 프로세스와 주고받는 바이너리 프로토콜, 와이파이 접속점과 캡티브 포털, 그리고 이 모든 걸 펌웨어 이미지로 굽는 BitBake 레시피까지.

전체 그림 — 보드 한 대 안의 세 프로세스

먼저 머신 보드 안에서 무엇이 돌고 있는지부터 그리자. 핵심은 웹 서버는 화면만, 제어 프로세스는 기계만 담당하도록 역할을 갈라놓은 것이다.

세 덩어리다. ①접속을 받아주는 와이파이 접속점, ②화면을 렌더링하고 명령을 중계하는 웹 서버, ③실제 기계를 움직이는 제어 프로세스. 사용자의 브라우저는 와이파이로 보드에 붙어 웹 서버에 접속하고, 웹 서버는 제어 프로세스에 명령을 던지거나 그쪽에서 올라오는 이벤트를 받아 브라우저로 흘려보낸다.

이 분리를 처음부터 못박은 이유는 단순하다. 웹 서버는 자주 바뀌고 제어 로직은 함부로 바뀌면 안 되기 때문이다. 화면을 갈아엎는 작업이 추출 동작에 영향을 줄 수 있는 구조라면, UI를 손볼 때마다 기계 안전 검증을 다시 해야 한다. 경계를 명령/이벤트 메시지 규격으로 고정해두면 양쪽이 각자의 속도로 움직일 수 있다. 웹 쪽은 매주 고쳐도 제어 펌웨어는 그대로다.

왜 네이티브 앱이 아니라 로컬 웹 서버인가

조작부 UI를 만드는 방법은 여럿이다. 보드용 네이티브 GUI 프레임워크로 짤 수도 있었다. 그런데 나는 보드 위에 작은 웹 서버를 띄우고 브라우저(내장 디스플레이 또는 사용자 폰)가 로컬 주소로 접속하는 구조를 택했다. 이유는 세 가지다.

  • 화면 갱신이 곧 패키지 갱신: UI 수정이 펌웨어 전체 재빌드가 아니라 웹 앱 파일 교체로 끝난다. HTML/CSS/JS만 갈아끼우면 된다.
  • 개발 환경과 실기기의 간극 최소화: 똑같은 서버를 개발 PC에서 그대로 띄워 보며 작업할 수 있다. 보드가 없어도 화면 작업이 가능하다.
  • 데모와 코드 공유: 이미 만들어 둔 정적 데모와 같은 템플릿·같은 페이지 컴포넌트를 재사용할 수 있다.

여기서 의식적으로 한 선택이 하나 더 있다. 화면을 클라이언트에서 다 그리는 SPA가 아니라 서버 렌더링으로 갔다는 점이다. 페이지 라우트는 서버가 받아 레이아웃 템플릿에 어떤 페이지를 끼울지만 정하고, 데이터는 페이지 안의 JS가 /api/...로 가져온다. 정적 데모를 만들 때도 같은 템플릿을 정적 HTML로 구워냈기 때문에, 실기기 서버는 그 템플릿을 런타임에 렌더링하기만 하면 됐다.

apiIpcThenMock이 이 서버의 성격을 가장 잘 보여준다. 기기 제어 프로세스(IPC)가 살아 있으면 진짜 데이터를, 죽어 있거나 개발 PC라면 목(mock) 파일을 같은 형태로 내려준다. 덕분에 보드 없이도 화면 전체가 동작하고, 보드에 올리면 출처만 진짜로 바뀐다. 정적 데모 때 목데이터를 실제 통신 규격에 맞춰 만들어 둔 게 여기서 그대로 보상으로 돌아왔다 — 화면 코드를 거의 손대지 않고 데이터 출처만 갈아끼울 수 있었다.

한 서버를 두 언어로 — Node에서 Python으로 포팅한 이유

처음 웹 서버는 Node.js(Express + EJS)로 짰다. 정적 데모를 EJS로 빌드하고 있었으니 자연스러운 선택이었다. 그런데 실기기 펌웨어에 올릴 때는 같은 서버를 Python(aiohttp + Jinja2)으로 한 벌 더 만들었다. 똑같이 동작하는 서버를 두 언어로 들고 있는 건 보기엔 낭비지만, 임베디드 쪽 사정이 그렇게 만들었다.

이유는 의존성이다. Yocto로 굽는 머신 이미지에는 이미 Python3가 기본으로 들어 있고, aiohttp·jinja2 같은 패키지도 레이어에 잘 갖춰져 있다. 반대로 Node 런타임과 node_modules를 통째로 이미지에 욱여넣는 건 용량과 빌드 복잡도 모두에서 부담이 컸다. 그래서 개발/데모는 Node, 실기기는 Python으로 가르고, 두 서버가 라우트·API·웹소켓 동작을 1:1로 맞추도록 유지했다. 실제로 Python 서버 파일 맨 위 주석에 "Node.js Express 서버(server.js)를 Python으로 포팅"이라고 못박아 두었다.

def make_ipc_post(cmd_code: int):
    async def handler(req: web.Request):
        ipc = req.app.get(IPC_KEY)
        body = {}
        if req.content_length and req.content_length > 0:
            try:
                body = await req.json()
            except Exception:
                pass
        if ipc:                                   # 보드: 진짜 기기로 명령 전달
            try:
                rsp = await ipc.request(cmd_code, body)
                return web.json_response({'result': rsp['result'], 'body': rsp['body']})
            except Exception as e:
                return web.json_response({'error': str(e)}, status=503)
        return web.json_response({'result': 0, 'body': {'mode': 'mock'}})  # 개발 PC
    return handler

# Node의 app.post('/api/brew/start', ...) 와 1:1 대응
app.router.add_post('/api/brew/start', make_ipc_post(CMD.START_BREW))
app.router.add_post('/api/brew/stop',  make_ipc_post(CMD.STOP_BREW))

두 서버가 동일한 라우트 테이블과 동일한 IPC→mock 폴백 규칙을 공유한다. 한쪽을 고치면 다른 쪽도 같이 고쳐야 하는 부담은 있었지만, 라우트가 단순한 평면 목록이라 디프(diff)로 비교하며 동기화하기 어렵지 않았다. 무엇보다 개발 중엔 익숙한 Node로 빠르게 돌리고, 출하 이미지엔 가벼운 Python을 넣는다는 실리를 챙겼다.

기기와 대화하기 — 직접 정의한 바이너리 프로토콜

가장 임베디드다운 부분은 웹 서버와 제어 프로세스 사이의 통신이다. 둘은 같은 보드 안의 별개 프로세스라, 두 개의 유닉스 도메인 소켓으로 대화한다. 하나는 요청/응답용(cmd 소켓), 하나는 기기가 일방적으로 밀어 보내는 이벤트용(evt 소켓)이다.

메시지는 JSON을 그냥 던지는 게 아니라 고정 40바이트 헤더 + JSON 바디의 바이너리 프레임으로 감쌌다. 헤더에는 매직 넘버, 버전, 메시지 타입(요청/응답/이벤트), 명령 코드, 시퀀스 번호, 결과 코드, 바디 길이, 타임스탬프가 리틀 엔디언으로 들어간다. 매직 넘버를 둔 이유는 스트림이 깨졌을 때 재동기화하기 위해서다. 소켓은 바이트 스트림이라 프레임 경계가 보장되지 않으니, 중간이 어긋나면 다음 매직 넘버를 찾아 그 지점부터 다시 읽는다.

명령과 이벤트는 코드로 번호를 매겼다. 상태 조회는 0x1001대, 추출 제어는 0x1101대, 알람 처리는 0x1201대, 펌웨어 업데이트는 0x1301대 — 이렇게 영역별로 묶었다. 기기가 밀어 보내는 이벤트도 추출(0x21xx), 알람·경고(0x22xx), 펌웨어(0x23xx)로 나뉜다. 번호를 영역별로 끊어두면 로그만 봐도 "지금 추출 쪽이 바쁘구나"가 한눈에 보인다.

스트림에서 프레임을 꺼내는 디코더가 이 프로토콜의 심장이다. 들어온 바이트를 버퍼에 쌓아두고, 헤더가 다 모였는지, 바디까지 다 왔는지 확인한 뒤에야 한 프레임을 완성한다.

push()는 "지금까지 받은 만큼으로 만들 수 있는 프레임만 만들고, 나머지는 버퍼에 남겨둔다"는 규칙으로 동작한다. 한 번에 여러 프레임이 붙어 와도, 한 프레임이 두 번에 쪼개져 와도 똑같이 동작한다. TCP/소켓 프로그래밍에서 흔히 깨지는 부분이 바로 이 프레이밍인데, 디코더를 따로 떼어 클래스로 만들고 단위 테스트(tests/test_ipc_protocol.py)로 못박아 둔 덕에 위층은 마음 편히 프레임 단위로만 생각하면 된다.

요청/응답과 이벤트 푸시 — 소켓을 둘로 가른 이유

소켓을 둘로 나눈 건 두 통신의 성격이 완전히 다르기 때문이다. 요청/응답은 짧고 동기적이다. "추출 시작해" 던지고 결과 한 번 받으면 끝. 반면 이벤트 푸시는 길고 비동기적이다. 추출 진행률, 알람 발생, 펌웨어 업데이트 단계 같은 게 언제 올지 모른 채 계속 흘러든다.

그래서 클라이언트는 명령 채널은 요청할 때마다 연결했다 끊고(per-request), 이벤트 채널은 항상 연결을 유지하며 끊기면 자동 재연결하도록 설계했다. 요청에는 타임아웃을 걸어, 제어 프로세스가 응답을 안 주면 영원히 매달리지 않고 예외로 끊는다.

이렇게 받은 이벤트는 웹 서버가 그대로 WebSocket으로 브라우저에 브로드캐스트한다. 즉 기기 제어 프로세스에서 올라온 "추출 진행률 60%" 같은 이벤트가 IPC 이벤트 소켓 → 웹 서버 → WebSocket → 브라우저 화면의 게이지까지 한 줄기로 흐른다. 반대로 사용자가 버튼을 누르면 WebSocket 메시지 → 웹 서버 → IPC 명령 소켓 → 제어 프로세스로 흐른다. 전체 데이터 흐름을 한 장으로 그리면 이렇다.

웹 서버 입장에서는 들어온 IPC 이벤트 이름을 WebSocket 타입 문자열로 바꿔 뿌리는 매핑 테이블 하나만 들고 있으면 된다. 추출·알람·펌웨어 이벤트 20여 종이 전부 이 테이블을 거쳐 브라우저로 나간다. 새 이벤트가 늘어도 테이블에 한 줄 추가하는 것으로 끝나서, 실시간 채널을 확장하기가 쉬웠다.

와이파이 접속점과 캡티브 포털 — "연결하면 화면이 뜬다"

머신은 인터넷에 연결되지 않은 환경에서도 써야 한다. 그래서 보드 자체가 와이파이 접속점(AP) 이 된다. 사용자는 폰의 와이파이 목록에서 머신 이름을 골라 접속하고, 그 순간 별도로 주소를 치지 않아도 조작 화면이 떠야 한다. 이걸 가능하게 하는 게 캡티브 포털이다. 카페나 호텔 와이파이에 붙으면 로그인 페이지가 자동으로 뜨는 바로 그 동작이다.

원리는 두 가지를 합친 것이다. 첫째, 모든 도메인 질의를 보드 IP로 답한다. 둘째, 운영체제들이 연결 직후 보내는 "인터넷 되니?" 확인 요청을 가로채 우리 화면으로 유도한다. 이 두 가지를 부팅할 때마다 셸 스크립트가 설정한다.

# 모든 도메인(/#/)을 보드 IP로 응답 → 캡티브 포털 트리거
cat > /etc/dnsmasq.d/ap.conf <<EOF
interface=$WLAN_IF
bind-interfaces
dhcp-range=192.168.4.10,192.168.4.100,12h
dhcp-option=3,$AP_IP          # gateway = 보드
dhcp-option=6,$AP_IP          # DNS     = 보드
address=/#/$AP_IP             # 모든 도메인 → 보드 IP
EOF

# 80/443으로 들어오는 트래픽을 웹 서버 포트로 리다이렉트
iptables -t nat -A PREROUTING -i "$WLAN_IF" -p tcp --dport 80  -j REDIRECT --to-port "$WEB_PORT"
iptables -t nat -A PREROUTING -i "$WLAN_IF" -p tcp --dport 443 -j REDIRECT --to-port "$WEB_PORT"

여기서 실제로 시간을 잡아먹은 건 운영체제별로 캡티브 포털 감지 방식이 제각각이라는 점이었다. iOS는 /hotspot-detect.html을 받아 특정 문구가 없으면 포털로 인식하고, 안드로이드는 /generate_204에 204 대신 리다이렉트가 오면 포털로 본다. 윈도우는 /connecttest.txt에 정해진 텍스트를 기대한다. 이걸 전부 서버에서 받아 알맞게 응답해줘야 폰이 "어, 로그인 필요한 와이파이네" 하고 화면을 자동으로 띄운다.

작은 핸들러 묶음이지만, 이게 있고 없고가 사용자 경험을 가른다. 없으면 사용자는 와이파이에 붙은 뒤 브라우저를 직접 열어 IP 주소를 외워 쳐야 한다. 있으면 와이파이를 고르는 순간 조작 화면이 저절로 뜬다. 매일 머신을 만질 사람에게 이 한 단계 차이는 생각보다 크다.

Yocto 레시피 — 앱이 펌웨어의 일부가 되는 순간

이제 이 모든 걸 어떻게 펌웨어 이미지 안에 집어넣느냐다. 답은 BitBake 레시피다. Yocto 빌드에 .bb 레시피를 하나 넣어두면, 이미지를 구울 때 웹 서버·라이브러리·템플릿·정적 파일·목데이터가 정해진 경로로 설치되고, 의존 패키지(Python3, aiohttp, jinja2, hostapd, dnsmasq, iptables)가 함께 끌려 들어오며, systemd 서비스가 부팅 시 자동 기동되도록 등록된다.

데스크톱·클라우드 웹 개발과 가장 결이 다른 지점이 여기다. "서버를 어디에 어떻게 띄울까"가 배포 플랫폼의 설정 화면이 아니라 이미지 빌드 시스템의 레시피 파일로 표현된다. 설치 경로, 실행 계정, 의존 패키지, 부팅 순서가 전부 코드로 박제되고, 이미지를 구울 때마다 같은 결과가 나온다.

그리고 부팅 순서 자체도 코드다. systemd 유닛 셋이 "AP 설정 → hostapd 기동 → 웹 서버 기동" 순서를 강제한다. 와이파이 설정이 끝나기 전에 웹 서버가 떠 봐야 접속할 길이 없으니, 의존 관계로 순서를 못박았다.

[Unit]
Description=Coffee Machine Web Server
After=hostapd-ap.service network.target   # AP가 먼저 떠야 함
Wants=hostapd-ap.service

[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/share/coffee-web/server.py
Environment=IPC_CMD_SOCK=/tmp/ipc_cmd.sock   # 제어 프로세스와의 약속된 소켓 경로
Environment=IPC_EVT_SOCK=/tmp/ipc_evt.sock
Restart=on-failure
RestartSec=10
StartLimitBurst=3                            # 무한 재시작 폭주 방지

[Install]
WantedBy=multi-user.target

Restart=on-failure로 서버가 죽으면 다시 띄우되, StartLimitBurst=3으로 짧은 시간에 세 번 넘게 죽으면 멈추게 했다. 무한 재시작 루프로 보드 자원을 태우는 사고를 막기 위해서다. 그리고 웹 서버는 환경변수로 받은 IPC 소켓 경로로 제어 프로세스를 찾는다 — 코드 어디에도 경로가 박혀 있지 않고, 서비스 유닛이 주입한다. 이 한 줄 덕에 개발 PC에서는 환경변수를 빼고 띄워 자동으로 mock 모드가 되고, 보드에서는 유닛이 진짜 경로를 넣어 bridge 모드가 된다.

같은 코드, 다른 생애 — 데모 빌드와 비교하며

마지막으로 정적 데모 버전과 견주면 이 구조의 의미가 또렷해진다. 데모 빌드 스크립트는 같은 EJS 템플릿을 정적 HTML로 구워 호스팅에 올리고, 목데이터를 /api/*.json 파일로 복사한다. 즉 데모에서는 "API"가 그냥 박제된 JSON 파일이다.

// EJS 템플릿 → 정적 HTML (실기기 server.js의 render와 같은 템플릿)
function writePage(outPath, pageData) {
  let html = renderEjs(pageData);
  html = fixHtmlPaths(html, BASE_PATH);   // 호스팅 하위경로에 맞게 링크 보정
  fs.writeFileSync(path.join(DOCS, outPath), html);
}
// mock 데이터를 그대로 정적 API 엔드포인트로 복사
for (const file of fs.readdirSync(path.join(SRC, 'mock'))) {
  fs.copyFileSync(path.join(SRC, 'mock', file), path.join(apiDir, file));
}

같은 템플릿, 같은 목데이터가 한쪽에서는 정적 파일로 박제되고 다른 쪽에서는 기기에서 실시간으로 흘러나온다. 화면 코드는 출처가 정적 JSON이든 IPC든 신경 쓰지 않는다 — /api/...를 부르면 같은 형태의 응답이 온다는 약속만 지키면 된다. 이 약속이 데모와 실기기를 한 코드베이스로 묶어주는 접착제였다.

정리하며

  • 같은 조작 패널이라도 데모(정적 빌드)와 실기기(상주 웹 서버)는 생애 주기가 완전히 다르다. 그래서 첫 커밋부터 화면이 아니라 "기기 안에서 어떻게 태어나고 사는가"를 정했다.
  • 기기 UI를 로컬 웹 서버 + 브라우저 구조로 두면 UI 갱신이 펌웨어 갱신에서 분리된다. 개발은 Node로, 출하 이미지는 의존성이 가벼운 Python(aiohttp)으로 포팅해 라우트를 1:1로 맞췄다.
  • 화면(웹)과 제어(별도 프로세스)는 40바이트 헤더 + JSON 바디의 바이너리 프레임으로 대화한다. 요청/응답은 per-request 소켓에 타임아웃을, 이벤트는 상시 소켓에 자동 재연결을 두고, 스트림 디코더에 매직 넘버 재동기화를 넣어 프레이밍을 단단히 했다.
  • 보드가 와이파이 접속점 + 캡티브 포털이 되어, 와이파이를 고르는 순간 조작 화면이 자동으로 뜬다. OS별 감지 규약(iOS·안드로이드·윈도우)을 서버에서 각각 응답해줘야 했다.
  • Yocto/BitBake 레시피로 앱을 펌웨어 이미지의 일부로 만들면, 배포가 곧 이미지 빌드가 되고 설치 경로·의존성·부팅 순서가 전부 코드로 박제된다. systemd 유닛 의존 관계로 "AP 설정 → hostapd → 웹 서버" 순서를 강제했다.
  • 데모와 실기기를 한 코드베이스로 묶은 접착제는 /api/...의 응답 형태를 동일하게 유지한다는 약속이었다. 출처가 정적 JSON이든 기기 IPC든 화면은 같은 코드로 돈다.
반응형