Personal Project · 매장 주문 시스템
손님의 주문부터
매장 운영까지 한 벌로
테이블 태블릿 두 종, 관리자 웹, API 서버 — 네 클라이언트를 혼자 설계하고 만들어 운영합니다. 화면 뒤의 실시간 통신과 원격 배포까지가 이 프로젝트의 범위입니다.

- 4종
- 클라이언트
- 34개
- 도메인 엔티티
- 184개
- API 엔드포인트
- 13개국어
- 메뉴까지 번역
- 1인
- 설계 · 개발 · 운영
메뉴를 고르고, 담고, 주문한다
메뉴 이름·가격·이미지·품절 여부가 전부 서버에서 내려온다. 앱에 박아둔 메뉴가 하나도 없어서, 메뉴판이 바뀌어도 앱은 그대로다.

이런 것도 한다
- 장바구니에 담기는 순간 애니메이션으로 위치를 알린다
- 품절 처리된 메뉴는 담기 전에 잠긴다
- 직원 호출 — 호출 항목을 골라서 부른다
- 손님이 없을 땐 광고·스크린세이버가 돈다

새 주문은 소리로, 상태 변경은 조용히
'주문이 들어왔다'와 '주문 내용이 바뀌었다'를 다른 이벤트로 나눴다. 안 그러면 매장에서 종일 벨이 울린다.

이런 것도 한다
- 주문 접수 · 제공 처리, 직원 호출 접수
- 테이블 현황과 각 테이블 태블릿의 배터리 원격 조회
- 품절 · 가격 즉석 변경 — 손님 태블릿 전체에 즉시 반영
- 단말 재시작 등 매장 내 기기 제어
메뉴판부터 단말까지, 매장을 웹에서 다룬다
메뉴 마스터(무엇을 파는가)와 메뉴판 배치(어떻게 보여줄 것인가)를 분리했다. 같은 메뉴가 여러 메뉴판에 다른 순서로 놓인다.

이런 것도 한다
- 배지 플래그(신메뉴 · 추천 등)와 품절을 한 화면에서
- 메뉴 · 옵션 · 호출 · 매장명 번역 편집
- 변경 이력을 스냅샷으로 남겨 되돌린다
- 본사 · 매장그룹 · 매장 스코프 권한 — 화면을 숨기는 게 아니라 서버가 막는다


손님이 앉는 순간을 어떻게 아는가
메뉴판을 띄워두는 것만으로는 광고가 손님 눈에 닿지 않는다. 손님 동선에서 화면을 확실히 보는 세 순간을 골라 노출 지점으로 잡았다.
- 01
착석
카메라가 얼굴을 연속으로 잡으면
앉은 직후 한 번. 지나가는 사람과 구분하려고 몇 초를 기다린다.
- 02
대기
설정한 시간 동안 아무 조작이 없으면
메뉴판 대신 광고·스크린세이버가 돈다. 손님이 화면을 만지면 즉시 걷힌다.
- 03
주문 완료
주문이 서버에 접수되면
주문을 넣은 손님은 결과 화면을 반드시 보기 때문에, 도달이 가장 확실한 지점이다.
뒤의 둘은 앱이 이미 아는 사건이라 어렵지 않다. 문제는 첫 번째였다. 앱은 손님이 앉았는지를 알 방법이 없다 — 아무도 화면을 만지지 않았으니까. 그래서 전면 카메라를 센서로 썼다.
앉은 사람과 지나가는 사람
몇 초를 기다린다
얼굴이 끊김 없이 연속으로 잡혀야 착석으로 본다. 지나가는 사람은 이 문턱을 못 넘는다. 기다리는 시간은 매장이 정한다.
한 손님에게 한 번
앉아 있는 동안은 다시 뜨지 않는다. 자리를 비운 시간이 쿨다운을 넘어야 다음 손님으로 친다.
주문 중에는 뜨지 않는다
이미 주문이 있는 테이블, 화면을 조작 중인 손님에게는 감지 자체를 걸지 않는다.
카메라를 켜기로 한 이상
손님 얼굴을 향한 카메라라 여기서 정한 것들이 이 기능의 성립 조건이었다.
저장하지 않는다
얼굴 검출은 단말 안에서만 돈다. 영상도 사진도 저장하거나 서버로 보내지 않고, 훅으로 넘어가는 값은 '얼굴이 있다/없다' 하나뿐이다.
초당 3프레임
착석은 초 단위로 판정하니 30fps는 과잉이다. 프레임을 10분의 1로 줄여 발열과 전력을 낮췄다. 앉는 동작은 몇 초에 걸쳐 일어나서 인식 품질은 그대로다.
필요한 것만 켠다
표정·윤곽·좌표·추적을 전부 끄고 가장 가벼운 모드로 돌린다. 앱이 백그라운드로 가면 카메라를 즉시 닫는다.
없어도 안 망가진다
권한을 거부했거나 카메라가 없는 단말이면 조용히 아무 일도 하지 않는다. 착석 광고만 안 뜰 뿐 주문은 그대로 된다.
신호가 흐르는 길
주문 · 품절 · 호출 · 단말 제어가 전부 이 경로로 흐른다. 서버는 지금 누가 접속해 있는지 몰라도 된다.
토픽이 곧 권한 경계
토픽 문자열에 매장 식별자를 반드시 포함시켰다. 다른 매장의 신호를 받을 방법이 구조적으로 없다.
페이로드엔 데이터가 없다
이벤트 이름 한 줄만 싣는다. 받은 쪽이 다시 조회하므로, 메시지가 유실돼도 데이터 불일치로 이어지지 않는다.
재접속은 프로토콜의 일
단말은 꺼지고 켜지고 교체된다. 재접속과 구독 복원을 직접 짜는 대신 프로토콜이 가진 것을 썼다.
어떻게 매장에 들어가나
설치부터 운영까지 네 단계. 두 번째 단계에서 단말이 매장과 테이블에 묶이고, 거기서 받은 식별자가 이후 모든 통신의 경계가 된다.
- 01
단말 설치
테이블마다 손님용 태블릿을, 카운터에 점원용 태블릿을 둔다. 스토어가 아니라 자체 배포 경로로 설치한다.
- 02
단말 페어링
관리자 웹에서 발급한 6자리 코드를 태블릿에 입력하면 매장과 테이블에 귀속된다.
- 03
메뉴판 구성
메뉴를 등록하고 메뉴판에 배치한다. 번역이 필요하면 같은 화면에서 언어별 이름을 채운다.
- 04
운영
주문이 실시간으로 흐르고, 고칠 게 생기면 원격으로 내려보낸다.

02 · 단말 페어링
페어링이 이 시스템의 주소 체계다
테이블과 단말을 분리해 두고 페어링으로 묶는다. 단말은 교체되고 자리를 옮기지만, 테이블은 남는다. 한 테이블에 여러 단말이 붙을 수도 있다.
- 관리자 웹에서 6자리 코드를 발급하고, 태블릿에 입력한다
- 이때 받은 매장 · 테이블 식별자가 모든 토픽의 경계가 된다
- 해제하면 단말은 다시 무소속 — 다른 테이블에 다시 묶을 수 있다
매장에 가지 않고 고친다
태블릿은 매장에 설치되어 있고, 버그 하나를 고치러 방문할 수는 없다. 원격으로 갱신하고 되돌릴 수 있어야 운영이 성립한다.
- 01
푸시
바뀐 경로를 보고 어떤 파이프라인이 돌지 갈린다. 네이티브에 영향을 주는 변경인지가 갈림길.
- 02
빌드
앱은 빌드 지문을 먼저 계산해 그 값을 못 박고 패키징한다.
- 03
배포
컨테이너를 갈아끼우고, 앱 번들은 릴리스로 등록한다.
- 04
적용
단말은 부팅할 때 확인하거나, 관리 웹에서 신호를 던지면 즉시 받아 간다.
JS만 바뀐 커밋은 2분이면 단말까지 닿고, 네이티브가 바뀌면 앱을 다시 빌드한다. 이 차이가 커서 급한 수정은 배포가 빠른 쪽 레이어에서 먼저 막는 습관이 생겼다.

다음 단계 — 매장에는 이미 POS가 있다
주문 시스템은 거기에 맞춰 들어가는 쪽이다. 그 접점을 시스템 안에 넣는 대신 별도 미들웨어 XBridge로 떼어냈다 — 클라우드 중계 서버와, 매장 POS 단말에 설치해 POS와 직접 통신하는 데몬으로.
연결은 밖으로만
매장 공유기에 포트를 열거나 POS망 설정을 바꾸지 않는다. 연결은 언제나 데몬이 서버 쪽으로 나가는 방향뿐이다.
중계에 상태를 부여한다
접수 · 대기 · 전송 · 확정 · 실패 · 만료. 보냈는지, 도착했는지, 거절당했는지를 구분해 두지 않으면 장애가 났을 때 원인을 가릴 방법이 없다.
상대역까지 직접
실제 POS 장비가 없어서, 무응답 · 지연 · 절단을 손으로 주입할 수 있는 가상 POS를 함께 만들었다. 장애를 만들 수 있어야 복구를 보여줄 수 있고, 실제로 이걸 상대로 여섯 시나리오를 통과시켰다.
무엇으로 만들었나
상용 랜딩이라면 요금제가 올 자리. 이 사이트에서는 무엇으로 만들었는지가 그 역할을 한다.
백엔드
주문·메뉴·매장 도메인과 실시간 신호의 출발점
Java 17
언어
Spring Boot 3.3
웹 · 데이터 · 시큐리티 · 검증
PostgreSQL
JSONB를 실제로 쓰는 컬럼 여덟 곳
Flyway
스키마의 단일 소유자 — 코드는 검증만
JWT
무상태 인증, 액세스/리프레시 분리
MQTT (v5 클라이언트)
상태 변경을 단말에 알리는 발행자
태블릿 앱
손님용·점원용 두 앱이 같은 기반을 공유한다
React Native 0.85
새 렌더링 아키텍처 적용
Expo 56
빌드와 업데이트 런타임
Zustand
상태 관리
MMKV
동기 읽기가 가능한 로컬 저장소
i18next
UI 문구 13개국어
Kotlin 포그라운드 서비스
MQTT 수신을 네이티브로 — 앱이 내려가도 살아 있어야 해서
관리자 웹
점주와 운영자가 매장을 다루는 화면
Next.js 16
App Router
React 19
TypeScript
Tailwind CSS 4
TanStack Query
서버 상태
Zustand
클라이언트 상태
인프라 · 배포
개인 서버 위에서 개발 환경과 운영 환경을 나눠 돌린다
Docker
API·웹·DB·브로커·스토리지 컨테이너
GitHub Actions
빌드 후 이미지 푸시, SSH로 재기동
nginx
정적 파일과 앱 번들 서빙
자체 OTA 서버
매니페스트 조립과 릴리스 제어
왜 이렇게 만들었나
선택한 것보다 버린 것을 적어두는 편이 나중에 더 쓸모 있었다.
실시간 통신에 왜 MQTT를 썼나요?
폴링은 체감 지연과 서버 부하를 맞바꾸는 구조라 단말 수만큼 비용이 늘고, WebSocket 직결은 서버가 '이 매장에 지금 어떤 단말이 붙어 있는지'를 직접 들고 있어야 합니다. MQTT는 발행자가 토픽에만 던지면 되고 재접속·구독 복원이 프로토콜에 이미 들어 있습니다.
메시지가 유실되면 데이터가 어긋나지 않나요?
페이로드에 데이터를 싣지 않습니다. 이벤트 이름 한 줄만 보내고 받은 쪽이 다시 조회하게 했습니다. 유실이 곧 데이터 불일치가 되지 않도록 하기 위해서입니다.
앱 업데이트를 왜 직접 만들었나요?
매장 단말은 스토어를 통해 설치되지 않고, 심사를 거치는 배포 주기로는 현장 대응이 안 됩니다. JS 변경은 번들 교체로 2분, 네이티브 변경은 앱 갱신으로 갈라뒀습니다. 릴리스 비활성화가 곧 롤백입니다.
매장 인터넷이 끊기면 어떻게 되나요?
지금은 멈춥니다. 단말이 클라우드에 직접 붙는 구조라 끊김을 감지해 손님에게 알리는 데까지가 전부입니다. 매장 안에서 자체적으로 도는 경로를 만드는 게 남은 가장 큰 과제이고, 설계 방향은 정해뒀습니다.
매장마다 다른 요구는 어떻게 흡수하나요?
매장 이름으로 분기하기 시작하면 매장이 늘어날수록 손댈 수 없는 코드가 됩니다. 색·레이아웃은 디자인 토큰으로, 기능 on/off는 서버 테이블의 플래그로 내립니다. 앱은 받은 값을 소비만 합니다.
POS 연동은 되나요?
이 시스템 안에는 없습니다. 스키마 슬롯과 화면 설계만 있고, 실제 연동은 별도 미들웨어 XBridge로 떼어내 따로 만들었습니다 — 클라우드 중계 서버와, 매장 POS 단말에 설치되어 POS와 직접 통신하는 데몬으로. 통신 상대역을 상대로 검증까지 마쳤고, 실제 POS 장비 검증과 이 시스템에서 그리로 넘기는 구간이 남았습니다.