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

HM소프트

커피머신 컨트롤 패널 데모 개발기 (1) — 5구 추출 화면과 핸드드립 애니메이션 본문

외주 개발일지

커피머신 컨트롤 패널 데모 개발기 (1) — 5구 추출 화면과 핸드드립 애니메이션

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

추출구 5개 자동 드립 머신의 태블릿 컨트롤 패널을 빌드 없는 브라우저 Babel과 CDN React로 만든다. 5열 그리드 잠금, 진행률 링 대신 단면도 애니메이션, getBrewSnapshot 한 함수로 큰 화면과 미니 화면을 동기화한 시간 모델, 공전 주전자 vs 고정 물줄기 정렬 버그까지 다룬다.

시리즈를 시작하며

핸드드립 커피를 자동으로 내려주는 기기의 태블릿용 컨트롤 패널 데모를 만들었다. 실제 장비는 추출구(스파웃)가 다섯 개 달린 자동 드립 머신인데, 영업·전시 자리에서 "이 기기는 화면에서 이렇게 동작합니다"를 장비 없이 보여줄 프로토타입이 필요했다. 그래서 두 가지 제약이 처음부터 못 박혀 있었다. 첫째, 백엔드 없이도 돌아가야 한다 — 데모를 위해 서버를 운영하고 싶진 않았다. 둘째, 화면 자체가 곧 제품 설명이어야 한다 — 데모를 보는 사람은 매뉴얼을 읽지 않는다.

이 시리즈는 두 편이다.

  1. (이 글) 5구 추출 화면과 핸드드립 애니메이션 — 화면을 "장비"처럼, 그리고 추출을 "공연"처럼 보이게 만든 과정.
  2. 정적 빌드 전환과 라우트 감사 스크립트 — 서버 렌더링 프로토타입을 링크 하나로 공유 가능한 정적 데모로 바꾸고, 깨진 경로를 자동으로 잡아낸 이야기.

특정 제품명·브랜드명은 전부 제외하고 구현만 다룬다. 코드 식별자도 일반화했다.

출발점 — EJS 관리자 화면, 그리고 두 갈래의 화면

첫 커밋은 EJS 템플릿을 Express가 서버 렌더링하는 평범한 관리자 화면이었다. 좌측 사이드바에 메뉴를 늘어놓는 전형적인 어드민 구성. 라우팅은 server.js 하나가 모든 페이지와 목 API를 받는 단일 진입점 구조였다.

여기서 중요한 분기가 생겼다. 운영용으로 굳어진 EJS 화면들(대시보드·레시피·설정 등)은 그대로 두되, 시연용으로 따로 빠르게 돌아가는 화면이 필요했다. 그래서 public/preview/ 아래에 별도의 React 프로토타입을 두었다. 빌드 도구 없이 브라우저에서 Babel standalone으로 JSX를 즉석 트랜스파일하는, 의존성이라곤 CDN 스크립트 세 줄(React·ReactDOM·Babel)이 전부인 구성이다.

데모 프로토타입에 webpack/Vite를 끼얹는 건 과한 결정이었다. type="text/babel"로 브라우저가 JSX를 직접 컴파일하게 하면, 빌드 단계 없이 파일만 열어도 화면이 뜬다. 프로덕션이라면 절대 안 쓸 방식이지만, "장비 없이 화면을 보여주는" 목적에는 마찰이 가장 적은 선택이었다. 핵심은 목적에 맞는 무게의 도구를 고르는 것이다.

사이드바를 버리고 하단 내비게이션으로

EJS 화면이 올라갈 곳은 데스크톱이 아니라 기기 앞에 붙는 태블릿이다. 태블릿에서 좌측 사이드바는 가로 폭만 잡아먹고, 손가락이 닿기에도 위치가 애매하다. 그래서 초반에 사이드바를 걷어내고 하단 내비게이션 바로 교체했다. 엄지가 닿는 위치에 큰 터치 타깃을 두는, 모바일 앱의 문법을 그대로 가져온 셈이다.

React 프로토타입에서도 같은 결정을 따랐다. 화면 전환은 라우터 없이 단순한 상태 머신 하나로 처리하고, 하단 탭(NavRail)이 그 상태를 토글한다.

const NAV_ITEMS = [
  { id: 'brewing',   label: 'Brewing',   italic: 'in progress' },
  { id: 'dashboard', label: 'Spout',     italic: 'control' },
  { id: 'library',   label: 'Recipes',   italic: 'twelve' },
  { id: 'detail',    label: 'Recipe',    italic: 'sheet' },
  { id: 'settings',  label: 'Settings' },
];

function App() {
  const [screen, setScreen] = useState('dashboard');
  // ...추출 시뮬레이션 상태(active, brew, activeSpout)는 App이 단일 소유
  let body = null;
  if (screen === 'brewing')        body = <BrewingScreen ... />;
  else if (screen === 'dashboard') body = <DashboardScreen ... />;
  else if (screen === 'library')   body = <LibraryScreen ... />;
  // ...
  return (
    <div className="w-full h-full flex flex-col bg-cream">
      <main className="flex-1 min-h-0">{body}</main>
      <NavRail items={NAV_ITEMS} active={screen} onSelect={setScreen} ... />
    </div>
  );
}

데모는 화면 다섯 개를 오가는 게 전부라 React Router를 붙일 이유가 없었다. screen 문자열 하나로 분기하고, 추출 시뮬레이션 상태(어느 레시피가 어느 스파웃에서 돌고 있는지)는 App이 단독으로 들고 자식에게 내려준다. 상태 출처가 하나라, 대시보드의 미니 추출 그림과 추출 전용 화면이 같은 데이터를 보고 항상 동기화된다. 이게 뒤에서 큰 차이를 만든다.

같은 시기에 목데이터도 손봤다. 실제 기기 통신 규격(IPC 메시지·기기 설정 파일 포맷)에 맞춰 JSON을 갈아끼웠다. 데모라도 데이터 구조는 진짜와 같아야, 나중에 실기기를 붙일 때 화면을 다시 짜지 않는다.

5구 추출 패널 — 물리 배치를 화면에 그대로

이 기기는 추출구가 5개다. 메인 화면을 5구 추출 컨트롤 패널로 설계했다. 추출구 하나가 카드 하나, 각 카드에 상태·진행률·레시피·미니 추출 그림이 들어간다. 카드의 상태는 idle(대기)·loaded(레시피 장착)·brewing(추출 중)·error(정지)로 나뉜다.

const SPOUT_BASE = [
  { id: 1, state: 'idle',  label: 'Ready' },
  { id: 2, state: 'idle',  label: 'Ready' },
  { id: 3, state: 'idle',  label: 'Ready' },
  { id: 4, state: 'idle',  label: 'Ready' },
  // 5번은 일부러 에러 상태로 — '정상만 보이는 데모'는 신뢰가 안 간다
  { id: 5, state: 'error', label: 'Halted', recipe: 'Sidamo Natural', pct: 18, stage: 'Flow obstructed' },
];

여기서 작은 디자인 결정 하나를 의도적으로 박았다. 5번 스파웃은 처음부터 에러 상태다. 데모는 보통 모든 게 초록불인 행복 경로만 보여주려는 유혹이 큰데, 그러면 오히려 "실제로 문제가 생기면 어떻게 보이나"가 안 보인다. 정지·경고 상태가 화면에 어떻게 표현되는지를 함께 보여줘야, 보는 사람이 이 패널을 진짜 운영 도구로 신뢰한다.

레이아웃에서는 CSS 그리드가 끈질기게 속을 썩였다. auto-fit 계열로 두면 화면 폭에 따라 4+1, 3+2로 줄바꿈이 일어나는데, 추출구는 물리적으로 한 줄에 다섯 개 늘어선 장치다. 화면도 무조건 한 줄에 5칸이어야 직관적이다. 결국 명시적으로 5열을 박고, 카드 폭이 0 이하로 찌그러지지 않도록 minmax(0, 1fr)로 하한을 잠갔다.

minmax(0, 1fr)0이 핵심이다. 기본값인 auto로 두면 카드 안의 긴 텍스트가 칸의 최소 폭을 밀어내, 다섯 칸이 균등하게 안 나뉜다. 하한을 0으로 잠가야 다섯 칸이 정확히 같은 폭으로 떨어진다. 거기에 추출구 이름이 길면 카드 높이가 들쭉날쭉해지는 문제도 있어서, 이름은 한 줄로 고정하고 넘치면 말줄임 처리했다. 카드 5장의 높이가 맞아야 패널이 "장비"처럼 보인다.

프리미엄 라이트 테마 — 흰색에 갈색 한 방울

초기 테마는 흔한 다크 어드민 느낌이었는데, 커피 기기 옆에 놓일 화면치고 너무 차가웠다. 프리미엄 라이트 테마로 전면 재설계하면서 색 토큰을 통째로 다시 잡았다. 핵심은 순백을 쓰지 않은 것이다. #fff 배경은 의외로 싸늘하다. 베이스 흰색에 따뜻한 갈색을 아주 옅게 섞어 종이 같은 톤을 만들고, 강조색은 원두·황동(brass) 계열로 통일했다.

colors: {
  cream:        '#F5EDE0',  // 종이빛 베이스
  paper:        '#FCFAF4',  // 카드 표면
  walnut:       '#2C2317',  // 본문 텍스트 (진한 호두색)
  bark:         '#6A5B48',  // 보조 텍스트
  brass:        '#C5A059',  // 강조 (황동)
  'brass-aged': '#8B6F3D',  // 강조 다크
  ok:           '#5C7C5C',  // 상태색 — 채도 낮은 자연 톤
  warning:      '#A8841A',
  error:        '#8B2A1F',
  info:         '#1B3A4B',  // 물줄기 색으로도 재사용
},

상태색(ok·warning·error)까지 일부러 채도를 낮춰 잡았다. 형광 초록·빨강을 쓰면 종이 톤 위에서 혼자 튄다. 갈색·황동과 같은 채도대로 맞추니 경고 표시조차 "이 화면의 일부"로 읽힌다. 그리고 정보색 #1B3A4B(짙은 청록)는 뒤에 나올 핸드드립 애니메이션에서 물줄기 색으로 재사용된다 — 팔레트를 좁게 잡으면 이렇게 색 하나가 여러 맥락에서 일관되게 쓰인다.

핸드드립 애니메이션 — 진행률 링을 버리고 단면도를 그리다

여기가 1편에서 가장 공을 들인 부분이다. 추출 진행 상태를 처음엔 원형 진행률 링으로 그렸다. 동작은 하는데, 이 기기의 정체성인 "핸드드립"이 전혀 안 느껴졌다. 숫자와 링은 어떤 기기에 붙여도 똑같아 보인다. 그래서 추출 화면을 드립 기구의 단면도(cross-section) 자체로 바꿨다. 주전자(케틀)→물줄기→V60 콘→커피 베드→서버(잔)가 하나의 수직 축 위에 도면처럼 쌓인다.

이걸 React + SVG로 그렸다. 좌표를 상수로 빼서 모든 요소가 공유된 수직 중심선 위에 정렬되게 했다. 이게 단면도가 "두 개의 따로 노는 그림"이 아니라 "하나의 도구"로 읽히는 비결이다.

추출 단계(stageKind)에 따라 그림이 살아 움직인다. 물을 붓는 단계(Pour·Bloom)에서는 주전자에 김(steam)이 오르고, 물줄기가 점선으로 흐르고, 베드 위에 동심원 물결이 번지고, 콘 끝에서 방울이 떨어진다. 뜸(Bloom) 단계에서는 베드 타원의 세로 반경(bedRy)이 8로 커져 커피가 부푸는 모습을 표현한다. 멈춤(Pause) 단계에서는 물줄기가 사라지고 방울만 천천히 떨어진다. 잔 속 액체는 fillPct로 계산한 높이까지 차오른다.

한 줄의 시간이 두 화면을 같이 움직인다

애니메이션을 그럴듯하게 만든 진짜 장치는 그림이 아니라 시간 모델이다. 레시피마다 뜸·붓기·멈춤으로 이뤄진 단계 배열이 있고, 경과 초(elapsed)를 넣으면 현재 단계·누적 물 양·유량·온도를 한 번에 계산해 주는 순수 함수 getBrewSnapshot을 뒀다.

이 함수가 데모의 심장이다. App이 1초마다 elapsed를 1씩 올리고(끝나면 0으로 되돌려 데모가 무한 반복), 그 값으로 getBrewSnapshot을 부른다. 결과는 추출 전용 화면의 큰 단면도와, 대시보드 3번 스파웃 카드의 미니 단면도가 동시에 받아 쓴다. 두 그림이 같은 함수의 같은 출력을 그리므로, 절대 어긋나지 않는다.

작은 디테일도 여기 숨어 있다. 유량과 온도에 Math.sin으로 미세한 흔들림을 넣었다. 4.0 g/s로 고정된 숫자는 가짜처럼 보이지만, 4.2 → 3.9 → 4.1로 떨리면 "센서가 실제로 측정 중"인 느낌이 난다. 데모의 설득력은 이런 작은 노이즈에서 나온다.

가장 비싼 버그 — 주전자는 도는데 물줄기는 가만히

처음엔 욕심을 더 냈다. 핸드드립의 핵심 동작인 "물줄기를 나선으로 돌리며 붓기"를 표현하려고, 주전자 전체를 중심선 둘레로 공전(orbit)시키는 CSS 애니메이션을 넣었다. CSS offset-path로 주전자 그룹을 작은 원 위에서 돌게 한 것이다. 데모에서 보기엔 그럴듯했다.

그런데 문제가 있었다. 주전자는 원을 그리며 도는데, 물줄기는 중심선에 고정돼 있었다. 둘이 따로 노니, 주전자 주둥이가 왼쪽으로 가 있는 순간에도 물줄기는 정중앙에서 떨어진다. 자세히 보면 물이 주전자에서 안 나오고 허공에서 떨어지는, 어색하기 짝이 없는 그림이었다. "물줄기를 함께 공전시키면 되지 않나" 싶었지만, 그러면 물줄기 끝이 베드 중심을 벗어나 또 다른 정렬 문제가 생긴다.

결국 공전 자체를 버렸다. 주전자는 주둥이가 중심선 바로 위에 오도록 정적으로 배치하고, "회전하며 붓는다"는 동작은 다른 곳 — 평면도(plan-view) 메달리온의 나선 경로 애니메이션 — 으로 옮겼다. 단면도의 주전자는 가만히 물을 떨어뜨리고, 옆에 작게 띄운 위에서 본 평면도에서 나선이 그려진다.

교훈이 분명했다. 물리적으로 연결된 두 요소(주둥이와 물줄기)는 같은 좌표계에서 함께 움직이거나, 둘 다 가만히 있어야 한다. 한쪽만 움직이면 "연결"이라는 환상이 깨진다. 그리고 표현하고 싶은 동작이 한 그림에서 안 되면, 그 동작을 보여줄 다른 뷰(여기선 평면도)를 만드는 게 깔끔하다. 정면 단면도는 "구조"를 보여주고, 평면도는 "패턴"을 보여준다 — 역할을 나누니 둘 다 정직해졌다.

비슷한 결의 버그가 하나 더 있었다. 뜸(bloom) 단계에서 베드 그림 전체가 위아래로 들썩이는 펄스 애니메이션을 넣었었는데, 뜸은 수면만 살짝 부풀어야지 베드가 통째로 점프하면 어색하다. 이건 베드 타원의 세로 반경을 bedRy = 8로 키우는 정적 표현 + 거품 + 캡션("BLOOMING")으로 충분했다. 그래서 펄스 키프레임은 아무 변형도 안 하는 빈 껍데기로 남기고(transform: none), 기존 참조가 깨지지 않게만 했다. 움직임은 적을수록 정직하다는 걸 두 번 배운 셈이다.

멈춤(Pause)도 정직하게 — 애니메이션 일시정지

추출 화면에는 일시정지 버튼이 있다. 여기서 신경 쓴 건, 멈춤을 눌렀을 때 모든 살아있는 요소가 동시에 얼어붙어야 한다는 점이다. 물줄기만 멈추고 김은 계속 오르면 "정지" 상태가 거짓말이 된다. CSS animation-play-state: paused 하나로 관련 요소를 한꺼번에 동결했다.

루트 SVG에 paused일 때 hd-paused 클래스를 붙이면, 그 아래 모든 애니메이션이 진행 위치 그대로 멈춘다. JS로 각 애니메이션을 일일이 제어하지 않고 CSS 캐스케이드에 맡긴 것이다. 상태(멈춤)는 React가 들고, 표현(동결)은 CSS가 한다 — 책임이 깔끔하게 갈린다.

정리하며

  • 데모 프로토타입은 목적에 맞는 무게의 도구를 골라야 한다. 빌드 없는 브라우저 Babel + CDN React 세 줄로 충분했다.
  • 태블릿 전제라면 사이드바보다 하단 내비게이션. 화면 전환은 라우터 없이 상태 문자열 하나로도 된다.
  • 물리 장치를 비추는 화면은 물리 배치 그대로(한 줄 5칸, minmax(0,1fr)로 칸 폭 잠금)가 직관적이다.
  • 데모는 에러 상태도 함께 보여줘야 신뢰를 얻는다. 행복 경로만 있는 데모는 의심받는다.
  • 추출 같은 도메인 동작은 진행률 링이 아니라 도메인 단면도로 그릴 때 설명 없이 읽힌다. 핵심은 그림이 아니라 시간 모델 한 함수(getBrewSnapshot)로 큰 화면과 미니 화면을 동기화한 것.
  • 가장 비싼 버그는 공전하는 주전자 vs 고정된 물줄기 — 물리적으로 연결된 요소는 함께 움직이거나 함께 멈춰야 한다. 표현이 한 뷰에서 안 되면 뷰를 나눠라(정면=구조, 평면=패턴).
  • 멈춤은 animation-play-state: paused 한 클래스로 전체를 동시에 동결해야 정직하다.

다음 2편에서는 이 서버 렌더링 프로토타입을 서버 없이 배포하기 위한 정적 빌드 전환과, 깨진 경로를 자동으로 잡아낸 라우트 감사 스크립트를 다룬다.

반응형