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

HM소프트

WebSocket 실시간 기기 연동 구현 — 키오스크 UI에서 폴링과 푸시를 한 화면에 (1) 본문

외주 개발일지

WebSocket 실시간 기기 연동 구현 — 키오스크 UI에서 폴링과 푸시를 한 화면에 (1)

HM소프트 2026. 6. 13. 05:50
반응형

임베디드 기기의 키오스크 UI에서 WebSocket 푸시와 HTTP 폴링을 한 화면에 충돌 없이 공존시킨 기록. 이벤트 봉투 펍/섭 버스, 지수 백오프 재연결과 포기 가드, 소유권 집합으로 라이브·정적 필드를 분담시킨 인계 로직, mock WS 서버와 Playwright E2E 검증까지 다룬다.

시리즈를 시작하며 — 기기 상태가 곧 화면인 UI

여러 개의 추출구(스테이션)를 가진 음료 추출 기기의 터치 키오스크 UI를 만들었다. 임베디드 보드 위에서 웹(EJS 템플릿 + 바닐라 JS + CSS)으로 서빙되는 태블릿 화면이고, 화면에 보이는 거의 모든 것이 기기의 현재 상태다. 어느 추출구가 비어 있고, 어디서 추출이 진행 중이고, 진행률이 몇 퍼센트이고, 알람이 떴는지. 사용자가 입력하는 폼이 주인공인 보통의 웹 화면과는 출발점부터 다르다. 데이터의 원천이 사람이 아니라 기기 펌웨어에 있다.

이 시리즈는 세 편으로 나눠 기록한다.

  1. (이 글) WebSocket 실시간 기기 연동 — 기기가 푸시하는 상태를 화면에 어떻게 반영했나
  2. 키오스크/태블릿 UX와 PWA — 풀스크린·first-run 가드·가독성
  3. 배포 트러블슈팅 — CI와 정적 호스팅 장애 우회

이 글은 그 첫 편으로, 기기와 UI 사이의 실시간 통신 계층을 다룬다. WebSocket 클라이언트를 어떻게 짰고, 연결이 끊기는 임베디드 환경에서 어떻게 버텼고, 무엇보다 "실시간 푸시"와 "주기 폴링"이라는 두 갱신 경로를 한 화면 안에서 어떻게 충돌 없이 공존시켰는지가 핵심이다. 특정 제품명·브랜드·제조사명은 전부 일반화하고 구현 이야기만 다룬다. 코드에 박혀 있던 전역 객체 이름이나 식별자도 글에서는 중립적인 이름(RTBus 등)으로 바꿔 옮겼다.

왜 폴링이 아니라 WebSocket이었나 — 그리고 왜 폴링을 버리지 못했나

처음 기능 목록을 봤을 때는 단순해 보였다. 추출구 5개의 상태를 화면에 그리면 된다. 가장 쉬운 방법은 1초마다 /api/spouts를 호출해 화면을 다시 그리는 폴링이다. 실제로 초기 버전은 그렇게 짰다. 누적 추출 횟수, 현재 레시피, 단계 라벨 같은 값은 1초 간격이면 충분히 신선하다.

문제는 순간 이벤트였다. 추출이 막 완료된 순간, 알람이 막 올라온 순간 — 이건 "상태"라기보다 "사건"이다. 폴링은 사건을 놓친다. 두 번의 폴링 사이에 추출이 끝나고 다음 동작으로 넘어가 버리면, 화면은 완료 화면을 띄울 타이밍을 영영 놓친다. 진행률도 마찬가지다. 1초 폴링으로 받는 진행률 막대는 늘 한 박자 늦고 뚝뚝 끊긴다. 키오스크 앞에 선 사람은 막대가 부드럽게 차오르길 기대하는데 말이다.

그래서 기기와 UI를 WebSocket으로 이었다. 기기 펌웨어가 상태 변화를 이벤트로 푸시하면 화면이 즉시 반영한다. 그런데 여기서 흔히 빠지는 함정이 있다. "WebSocket을 붙였으니 폴링은 지운다"는 판단이다. 나는 둘 다 남겼다. 이유는 두 가지다.

첫째, 임베디드 기기의 WebSocket은 언제든 끊긴다. 네트워크가 출렁이거나, 펌웨어가 재시작하거나, 보드가 잠깐 바쁘면 소켓이 떨어진다. 그 순간 화면이 멈춰 버리면 키오스크로서 실격이다. 폴링이 살아 있으면 소켓이 끊겨도 1초 뒤엔 어쨌든 화면이 갱신된다.

둘째, 모든 필드가 실시간일 필요는 없다. 진행률·남은 시간 같은 라이브 필드는 WebSocket이 책임지는 게 맞지만, 누적 추출 횟수·단계 라벨·레시피 이름 같은 정적 필드는 폴링으로 충분하다. 굳이 펌웨어가 1초마다 누적 횟수까지 푸시하게 만들 이유가 없다.

그래서 이 글의 진짜 주제는 "WebSocket을 어떻게 붙였나"가 아니라, "폴링과 푸시가 같은 카드를 동시에 그리는데, 어떻게 서로를 덮어쓰지 않게 했나"다. 이게 이 프로젝트에서 가장 많은 시행착오를 남긴 부분이다.

WS 클라이언트의 뼈대 — 이벤트 봉투와 펍/섭

WebSocket 클라이언트는 화면 코드 어디에서나 가져다 쓸 수 있는 싱글턴으로 만들었다. 즉시실행함수(IIFE)로 감싸 전역에 객체 하나(RTBus)만 노출하고, 나머지 상태(소켓, 재연결 타이머, 시도 횟수 등)는 클로저 안에 숨겼다. 외부에 드러나는 건 connect, disconnect, 그리고 이벤트 구독용 on 세 가지뿐이다.

통신의 단위는 이벤트 봉투(envelope)로 통일했다. 기기가 보내는 모든 메시지는 { event, data, ts_recv } 꼴의 JSON이다. event가 종류(예: spout_state, alarm_raised), data가 알맹이, ts_recv가 수신 시각이다. 클라이언트는 이 봉투의 event 필드를 키로 삼아, 그 이벤트를 구독한 콜백들에게 봉투를 그대로 넘긴다. 작은 펍/섭(pub-sub) 버스인 셈이다.

listenersObject.create(null)로 만든 건 프로토타입 오염을 피하려는 작은 습관이다. 이벤트 이름이 toString이나 constructor 같은 단어와 우연히 겹쳐도 엉뚱한 함수가 끌려 나오지 않는다.

emit에서 콜백 하나하나를 try/catch로 감싼 건 의도적이다. 한 화면에 같은 이벤트(spout_state)를 구독하는 핸들러가 여럿 있을 수 있다 — 하나는 카드 UI를 갱신하고, 다른 하나는 "이 추출구는 지금 WS가 책임진다"고 표시한다. 그중 하나가 예외를 던져도 나머지는 계속 돌아야 한다. 실시간 UI에서 핸들러 하나의 버그가 화면 전체를 얼리는 건 최악이다.

마지막 줄의 root 처리도 의식적이었다. window가 있으면 브라우저, 없으면 globalThis. 이 한 줄 덕분에 같은 파일이 브라우저에서도, Node 테스트 환경에서도 그대로 로드된다. 뒤에서 다룰 테스트가 이 클라이언트 코드를 vm으로 통째로 실행하는데, 그때 window를 가짜로 주입하기만 하면 된다.

끊겨도 버티기 — 지수 백오프와 "포기" 상태

임베디드 환경에서 가장 공들인 부분이 재연결이다. 소켓은 끊기는 게 정상이라고 전제하고 짰다. 다만 단순한 재연결은 두 가지 함정이 있다.

하나는 재연결 폭주다. 끊기자마자 곧바로 다시 붙으려 하면, 서버가 잠깐 죽었을 때 클라이언트가 0초 간격으로 두드려 댄다. 그래서 시도할 때마다 간격을 두 배씩 늘리는 지수 백오프를 썼다. 1초 → 2초 → 4초 → 8초 … 식으로 늘리되, 너무 길어지지 않게 상한(30초)을 뒀다.

다른 하나는 영원히 죽은 서버에 매달리기다. 데모 모드나 오프라인 상태처럼 서버가 아예 없는 경우, 무한히 재시도하면 콘솔이 경고로 도배되고 배터리도 갉아먹는다. 그래서 "한 번도 연결에 성공한 적이 없는데 연속으로 N번 실패하면 포기한다"는 규칙을 넣었다. 핵심은 *한 번도 성공한 적이 없을 때만* 이 상한이 작동한다는 점이다. 일단 한 번이라도 붙었던 서버라면(즉 실재하는 서버라면) 잠깐 끊겨도 포기하지 않고 계속 재시도한다 — 임베디드 네트워크는 출렁이는 게 정상이니까.

설계에서 마음에 드는 부분은 everConnected라는 한 개의 불리언이 "데모/오프라인"과 "운영 중 일시 끊김"을 갈라낸다는 점이다. 운영 중인 기기는 부팅 시 한 번 성공하면 그 뒤로는 everConnected = true라, 밤사이 수십 번 끊겼다 붙어도 절대 포기하지 않는다. 반대로 개발자가 서버 없이 화면만 띄워 본 경우엔 121초쯤 시도하다 깔끔히 멈추고 ws_giveup 봉투를 한 번 쏜다. 화면은 그 신호를 받아 "오프라인" 표시를 하면 된다.

여기서 두 개의 합성 봉투가 등장한다. 기기가 보낸 게 아니라 클라이언트가 스스로 만들어 emit하는 이벤트다. 소켓이 닫히면 ws_disconnected, 영구 포기하면 ws_giveup. 화면 입장에서는 기기가 보낸 진짜 이벤트와 똑같은 모양의 봉투라, 구독 코드를 다르게 짤 필요가 없다. "연결 상태 변화"마저 하나의 이벤트로 흘려보낸 것이다.

function open() {
  if (!url) return;
  socket = new WebSocket(url);
  socket.addEventListener('open', () => { attempts = 0; everConnected = true; });
  socket.addEventListener('message', (ev) => {
    let env;
    try { env = JSON.parse(ev.data); }
    catch (e) { console.warn('[RTBus] bad json', ev.data); return; }  // 깨진 메시지는 버린다
    if (env && typeof env.event === 'string') emit(env.event, env);
  });
  socket.addEventListener('close', () => {
    socket = null;
    emit('ws_disconnected', { event: 'ws_disconnected', data: {}, ts_recv: Date.now() });
    scheduleReconnect();
  });
  socket.addEventListener('error', () => { /* close 로 흘러감 */ });
}

message 핸들러에서 JSON 파싱을 try/catch로 감싼 건, 기기가 어쩌다 깨진 프레임을 보내도 클라이언트가 죽지 않게 하기 위해서다. 파싱에 실패한 메시지는 경고만 남기고 조용히 버린다. event 필드가 문자열일 때만 emit하는 것도 같은 방어다 — 형식을 못 갖춘 봉투는 분배 자체를 하지 않는다.

error 이벤트에서 일부러 아무것도 안 하는 것도 의식적이다. WebSocket은 오류가 나면 대개 곧이어 close도 발생한다. 두 군데서 재연결을 거는 대신 재연결의 진입점을 close 한 곳으로 모아 두 번 예약되는 일을 막았다.

이벤트 봉투 한 종류 = 화면의 한 반응

클라이언트가 봉투를 잘 분배하면, 그다음은 "어떤 이벤트에 화면이 어떻게 반응하는가"를 정하는 일이다. 여기서 지킨 원칙은 단순하다. 이벤트 한 종류는 화면의 한 가지 반응에만 매핑한다. 그래야 나중에 "지금 화면이 왜 이렇게 됐지?"를 추적할 때, 이벤트 이름만 보고 원인을 역추적할 수 있다.

  • spout_state — 특정 추출구의 상태가 바뀜 → 그 스테이션 카드 하나를 갱신
  • brew_completed — 추출이 끝남 → 완료 화면으로 라우팅
  • alarm_raised — 알람 발생 → 토스트 띄우고 상단 표시등을 경고색으로

알람 핸들러는 앱 진입점에 배선했다. 앱이 뜨면 소켓을 연결하고, 알람 이벤트가 오면 토스트와 상단 표시등을 동시에 건드린다. 봉투 하나가 시각적 경고 두 가지(토스트 + 표시등 색 변경)를 일으키는, 가장 단순한 형태의 매핑이다.

// app.js — 앱 진입점에서 연결과 알람 배선
document.addEventListener('DOMContentLoaded', () => {
  const isPreview = location.pathname.startsWith('/preview/');
  if (!isPreview && typeof RTBus !== 'undefined') {
    const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
    RTBus.connect(`${proto}//${location.host}/ws/spouts`);

    RTBus.on('alarm_raised', (env) => {
      const d = (env && env.data) || {};
      Toast.error(`알람: ${d.file} (${d.bytes}B)`);
      const live = document.querySelector('.tb-live');
      if (live) { live.classList.remove('tb-live-ok'); live.classList.add('tb-live-err'); }
    });
  }
});

작은 디테일이 둘 있다. 하나는 proto 분기다. 화면이 HTTPS로 서빙되면 소켓도 wss:, 아니면 ws:로 맞춘다. 보안 페이지에서 평문 소켓을 열면 브라우저가 막아 버리니, 페이지 프로토콜에 소켓을 따라가게 했다. 다른 하나는 isPreview 가드다. 디자인 미리보기 경로(/preview/)에서는 실제 기기 소켓을 열 필요가 없으므로 연결 자체를 건너뛴다. 미리보기는 기기 없이 컴포넌트만 보는 용도라, 거기서 소켓을 열면 끊김 경고만 무의미하게 쌓인다.

핵심 난제 — 폴링과 푸시가 같은 카드를 그릴 때

이제 이 프로젝트에서 가장 까다로웠던 부분이다. 메인 화면은 폴링과 WebSocket 둘 다로 갱신된다. 폴링은 1초마다 모든 카드를 다시 그리려 하고, WebSocket은 봉투가 올 때마다 특정 카드를 갱신한다. 그냥 두면 끔찍한 일이 벌어진다. WebSocket이 진행률을 41%로 막 칠해 놨는데, 0.3초 뒤 폴링이 /api/spouts의 오래된 값 39%로 덮어쓴다. 막대가 41에서 39로 뒤로 튄다. 실제로 초기 버전에서 이 떨림이 그대로 보였다.

해결의 핵심 발상은 이거다. "이 추출구는 지금 WS가 책임지고 있다"는 사실을 폴링이 알게 만든다. 그래서 spout_state 봉투를 한 번이라도 받은 추출구의 id를 집합(Set)에 넣어 둔다. 폴링은 카드를 그릴 때 이 집합을 확인해서, WS가 소유한 추출구의 "라이브 필드"는 건드리지 않는다.

여기서 필드를 두 종류로 갈랐다.

  • 라이브 필드 — 상태·진행률·남은 시간·진행 링·진행 바. 빠르게 변하고, WS가 가장 정확하다. WS가 소유한 추출구라면 폴링이 손대지 않는다.
  • 정적 필드 — 누적 추출 횟수·단계 라벨·레시피 이름·즐겨찾기 슬롯. 천천히 변하고, 폴링이 항상 갱신한다. WS가 살아 있어도 이 필드들은 폴링이 계속 채우므로 stale(오래된 값)이 되지 않는다.

그리고 이 집합을 채우고 비우는 쪽이 WebSocket이다. spout_state 봉투가 오면 그 추출구 id를 집합에 넣어 "이제 WS가 책임진다"고 표시하고, 소켓이 끊겨 ws_disconnected가 오면 집합을 통째로 비운다. 그러면 다음 폴링부터는 모든 추출구를 폴링이 다시 인계한다.

이 구조의 우아한 점은 두 갱신 경로가 서로를 모른다는 것이다. 폴링은 WebSocket의 존재를 모른 채 그냥 집합 하나만 확인한다. WebSocket은 폴링이 어떻게 그리는지 모른 채 집합에 id를 넣고 뺄 뿐이다. 둘을 잇는 유일한 접점이 __wsLiveSpouts라는 작은 Set 하나다. 덕분에 "WS가 살아 있으면 WS가, 끊기면 폴링이"라는 인계가 자동으로 일어난다. 사람이 "지금 WS 켜졌나?"를 어디서도 일일이 분기하지 않는다.

ws-client.jsdefer로 로드하는 탓에 페이지 스크립트가 도는 시점에 RTBus가 아직 undefined일 수 있다. 그래서 핸들러 등록을 DOMContentLoaded까지 미루는 가드를 한 겹 뒀다. typeof RTBus === 'undefined'면 조용히 빠져나가, 로드 순서 때문에 화면이 깨지는 일을 막았다.

봉투를 카드로 — spout_state 디스패처

spout_state 봉투가 도착하면 해당 스테이션 카드를 실제로 갱신하는 디스패처가 따로 있다. 봉투의 data에는 spout_id, status, progressPct, elapsedSec, totalSec 등이 담겨 온다. 이걸 받아 카드의 활성 표시, 상태 텍스트, 진행률 숫자와 막대, 남은 시간을 갱신한다.

// main.ejs — spout_state 봉투 → 스테이션 카드 갱신
RTBus.on('spout_state', function (env) {
  var d = env.data || {};
  var id = d.spout_id;
  if (!id || id < 1 || id > 5) return;            // 5개 추출구 범위 밖이면 무시
  var root = document.querySelector('.st.st-' + id);
  if (!root) return;

  root.classList.toggle('active', d.status === 'brewing');

  if (typeof d.progressPct === 'number') {
    root.querySelector('.spout-pct-val').textContent = String(Math.round(d.progressPct));
    root.querySelector('.st-bar-fill').style.width = d.progressPct + '%';
  }

  if (typeof d.elapsedSec === 'number' && typeof d.totalSec === 'number') {
    var remain = Math.max(0, d.totalSec - d.elapsedSec);   // 남은 시간 = 총 - 경과
    var mm = String(Math.floor(remain / 60)).padStart(2, '0');
    var ss = String(remain % 60).padStart(2, '0');
    root.querySelector('.spout-time-val').textContent = mm + ':' + ss;
  }
});

방어적으로 짠 곳이 여럿이다. id가 1~5 범위를 벗어나면 무시한다 — 펌웨어가 잘못된 추출구 번호를 보내도 엉뚱한 DOM을 건드리지 않는다. 각 필드도 타입을 먼저 확인(typeof d.progressPct === 'number')한 뒤에만 쓴다. 봉투에 일부 필드가 빠져 와도, 온 필드만 갱신하고 나머지는 직전 값을 유지한다. 외부(기기)에서 들어오는 데이터는 언제나 불완전할 수 있다는 전제를 디스패처 전체에 깔았다.

남은 시간을 Math.max(0, totalSec - elapsedSec)로 계산한 것도 작은 안전장치다. 경과 시간이 총 시간을 넘는 봉투가 오더라도(반올림 오차나 펌웨어 지연으로 충분히 생길 수 있다) 음수 시간이 화면에 뜨지 않는다. -00:03 같은 표시는 사용자에게 그냥 버그로 보인다.

기기 없이 검증하기 — Mock WS 서버와 테스트

실시간 연동에서 가장 곤란한 건 테스트다. 실제 기기가 있어야만 검증할 수 있다면, CI에서 자동으로 돌릴 수도 없고 개발 중에 빠르게 반복할 수도 없다. 그래서 기기가 보낼 봉투를 흉내 내는 mock WebSocket 서버를 만들었다. 임의 포트에서 listen하다가, 클라이언트가 붙으면 곧바로 hello 봉투를 보내고, push()로 아무 봉투나 모든 클라이언트에 흘려보낼 수 있다.

포트를 0으로 연 게 핵심이다. OS가 비어 있는 포트를 알아서 골라 주므로, 테스트를 병렬로 여러 개 돌려도 포트 충돌이 없다. 할당된 실제 포트는 wss.address().port로 받아 URL을 조립한다.

이 mock 서버 위에서 클라이언트 단위 테스트를 짰다. 흥미로운 트릭이 하나 있다. 클라이언트 코드를 그냥 require하지 않고, 파일을 읽어 vm으로 가짜 전역 안에서 실행한다. 앞서 클라이언트를 window/globalThis 어디서든 살게 짜 둔 게 여기서 빛을 발한다. window: {}를 가짜로 주입하면, 브라우저용 코드가 Node 테스트 안에서 그대로 깨어난다.

이 테스트가 검증하는 건 "서버를 죽였다가 다시 띄우면 클라이언트가 알아서 재연결해 다시 hello를 받는다"는 시나리오다. 백오프 기본값(_reconnectBaseMs)을 외부에서 바꿀 수 있게 열어 둔 덕분에, 운영에서는 1초이지만 테스트에서는 50ms로 줄여 빠르게 돌린다. ws_disconnected 봉투가 실제로 발행되는지, 그리고 죽은 포트로만 연결을 시도하면 N번 뒤 ws_giveup이 뜨는지도 각각 별도 테스트로 못 박았다.

화면까지 진짜로 도는지 — Playwright E2E

단위 테스트가 "클라이언트가 봉투를 잘 받고 재연결한다"를 증명한다면, 봉투가 실제 DOM을 바꾸는지는 또 다른 문제다. 그래서 Playwright로 브라우저를 띄워, mock WS 서버가 보낸 spout_state 봉투가 실제 스테이션 카드의 진행률 숫자를 바꾸는지까지 확인했다.

이 E2E 테스트가 검증하는 가장 중요한 시나리오가 바로 앞에서 설명한 폴링↔푸시 인계다. 흐름은 이렇다.

테스트의 단언이 영리하다. WS가 살아 있는 동안 1번 추출구의 진행률은 봉투 값 10을 보여야 하고, 동시에 정적 필드인 누적 추출 횟수는 폴링이 채운 mock 값(1,247)이 그대로 보여야 한다. 즉 "라이브 필드는 WS가, 정적 필드는 폴링이"라는 분담이 화면에서 실제로 지켜지는지를 한 화면에서 동시에 확인한다. 그다음 mock 서버를 강제 종료하면, 클라이언트가 ws_disconnected로 소유권 집합을 비우고, 폴링이 라이브 필드까지 다시 인계해 /api/spouts의 값(39)으로 진행률을 되돌린다. 이 마지막 단언("끊기면 폴링 값으로 복원된다")이야말로 이 글 전체에서 가장 자랑스러운 한 줄이다. 초기 버전의 떨림 버그가 정확히 이 지점에서 났었기 때문이다.

정리하며 — 실시간 UI에서 배운 것

기기와 실시간으로 연동되는 키오스크 UI를 만들며 얻은 교훈을 줄이면 이렇다.

  • 순간 이벤트엔 푸시, 느린 상태엔 폴링. 추출 완료·알람 같은 사건은 WebSocket으로 잡고, 누적 횟수·레시피 같은 값은 폴링으로 충분했다. 둘 중 하나를 고르는 게 아니라 역할을 나눠 둘 다 썼다.
  • 두 갱신 경로의 충돌은 "소유권"으로 푼다. __wsLiveSpouts 집합 하나로 "이 추출구는 지금 WS가 책임진다"를 표시해, 폴링이 WS가 칠한 값을 덮어쓰지 않게 했다. 끊기면 집합을 비워 폴링이 자동 인계한다.
  • 연결 상태도 하나의 이벤트로. ws_disconnected·ws_giveup 합성 봉투를 만들어, 화면은 "기기 이벤트"와 "연결 변화"를 똑같은 구독 코드로 다룬다.
  • 임베디드는 끊기는 게 정상. 지수 백오프 + 상한, 그리고 "한 번도 성공 못 했을 때만 포기"라는 가드로, 실재하는 기기엔 끈질기게 붙고 데모/오프라인엔 깔끔히 멈춘다.
  • 외부 데이터는 늘 불완전하다 가정. 깨진 JSON은 버리고, 빠진 필드는 건너뛰고, 범위 밖 id는 무시하고, 음수 시간은 0으로 막았다.
  • 기기 없이도 검증 가능하게. mock WS 서버 + vm 격리 실행 단위 테스트 + Playwright E2E로, 실시간 연동의 핵심 시나리오를 CI에서 자동으로 돌린다.

다음 2편에서는 이 화면을 태블릿 키오스크답게 만든 부분 — 풀스크린·first-run 가드 같은 PWA 설정과 가독성 UX를 다룬다. 실시간으로 들어온 숫자를, 키오스크 앞에 선 사람이 한눈에 읽게 만드는 이야기다.

반응형