Personal Project · 매장 주문 시스템
손님 태블릿부터 배포 파이프라인까지,
혼자 설계하고 만든 주문 시스템
손님이 태블릿으로 주문하면 주방이 받고, 점주는 웹에서 메뉴를 고치고, 단말은 원격으로 업데이트된다. 그 사이에 있는 서버·실시간 통신·앱 배포를 전부 직접 만들었습니다. 이 사이트는 그 과정에서 내린 판단을 정리한 기록입니다.
- 클라이언트
- 4종
- 도메인 엔티티
- 34개
- 메뉴까지 번역
- 13개국어
- 설계 · 개발 · 운영
- 1인
Screens
실제 화면
손님이 주문하고, 점원이 받고, 점주가 매장을 설정한다. 세 화면이 같은 서버를 보고 있다.

오더앱
손님 · 테이블 태블릿메뉴 이름·가격·이미지가 전부 서버에서 내려온다. 좌하단 언어 칩을 누르면 UI뿐 아니라 메뉴 이름까지 13개국어로 바뀐다.

마스터앱
점원 · 매장 태블릿새 주문은 소리로 알리고, 점원이 상태를 바꾼 건 조용히 갱신된다. 두 신호를 나눠둔 이유가 이 화면에 있다.

어드민
점주 · 운영자 웹좌측에서 색과 효과를 바꾸면 우측 미리보기가 즉시 따라온다. 이 값이 그대로 손님 태블릿의 렌더로 넘어간다.
Problem
무엇이 어려운 문제였나
주문 화면을 예쁘게 만드는 건 이 프로젝트에서 제일 쉬운 부분이었다. 실제로 시간을 쓴 곳은 따로 있다.
매장 인터넷이 끊기면 주문도 멈춘다
현장에서 직접 겪은 것 중 가장 치명적인 문제다. 회선이 끊기면 주문만 안 되는 게 아니라 메뉴판조차 안 뜬다. 지금 이 시스템도 그렇다 — 단말이 클라우드에 직접 붙는 구조라, 끊김을 감지해 손님에게 알리는 데까지가 전부다. 매장 안에서 자체적으로 도는 경로를 만드는 게 남은 가장 큰 과제고, 설계 방향은 정해뒀다.
설치되고 나면 손이 닿지 않는다
버그 하나를 고치려고 매장을 방문할 수는 없다. 원격으로 갱신하고, 잘못되면 되돌리고, 어떤 단말이 어떤 버전인지 확인할 수 있어야 비로소 운영이 성립한다.
매장마다 이미 쓰는 것들이 있다
POS도, 메뉴 구성도, 쓰는 언어도 매장마다 다르다. 이 차이를 코드 곳곳의 분기로 흡수하기 시작하면 매장이 늘어날수록 손댈 수 없는 코드가 된다. 그래서 다른 부분은 경계 뒤로 몰았다.
Architecture
시스템 한눈에
매장 안의 태블릿들, 점주가 쓰는 웹, 그리고 그 뒤의 서버·브로커·배포 파이프라인. 전체가 한 장에 들어간다.
Components
무엇을 만들었나
화면 세 개와 서버 하나. 각각 쓰는 사람이 다르고, 그래서 요구하는 것도 다르다.
오더앱
React Native · 가로 고정손님이 테이블에서 쓰는 태블릿
- 화면 구성 파일
- 25
- 지원 언어
- 13
- 메뉴 탐색과 장바구니, 주문 확정까지의 전 과정
- 언어 칩으로 즉시 전환되는 13개국어 — 메뉴 이름까지 서버 번역으로
- 직원 호출과 호출 항목 관리
- 유휴 상태 광고·스크린세이버, 카메라로 사람을 감지해 띄우는 웰컴 화면
- QR 페어링으로 매장·테이블에 귀속, 원격 제어 수신
손님이 만지는 화면이라 실패가 눈앞에서 일어난다. 이미지가 안 뜨거나 터치가 늦는 문제를 가장 많이 판 곳이기도 하다.
마스터앱
React Native · 상단 탭 구조점원이 매장에서 쓰는 태블릿
- 탭
- 5
- 알림
- 소리 + 실시간
- 들어온 주문을 받고 제공 처리 — 신규 주문은 소리로 알린다
- 손님 직원 호출 접수와 처리
- 테이블 현황, 그리고 각 테이블 태블릿의 배터리 원격 조회
- 메뉴 품절·가격 즉석 변경 (바꾸면 손님 태블릿 전체에 즉시 반영)
- 단말 재시작 등 매장 내 기기 제어
'주문이 들어왔다'와 '주문 내용이 바뀌었다'를 다른 이벤트로 나눴다. 앞은 소리를 내고 뒤는 조용히 갱신한다. 안 그러면 매장에서 종일 벨이 울린다.
어드민 웹
Next.js · App Router점주와 운영자가 브라우저에서
- 라우트
- 40
- 권한 롤
- 9
- 메뉴 마스터와 메뉴판 배치, 옵션 3단 구조 관리
- 매장 화면 디자인 편집 — 실시간 목업 미리보기와 되돌리기
- 앱 업데이트 콘솔: 번들 발행 현황, 단말별 허용 토글, 롤백
- 테이블·단말 관리와 원격 재시작
- 메뉴·옵션·호출·매장명 번역 편집
본사·매장그룹·매장으로 스코프가 나뉘고, 롤과 권한 등급을 조합해 접근을 판정한다. 화면을 숨기는 게 아니라 서버에서 막는다.
API 서버
Spring Boot · PostgreSQL위 셋이 전부 여기로 붙는다
- 도메인
- 16
- 엔티티
- 34
- 엔드포인트
- 184
- 마이그레이션
- 77
- 주문·메뉴·옵션·매장·조직·다국어·광고·감사로그 도메인
- JWT 무상태 인증 + 롤·권한 등급·스코프 조합 인가
- 상태가 바뀌면 MQTT로 관련 단말에 신호를 던진다
- 앱 번들 매니페스트 조립과 릴리스 활성/비활성
- 스키마는 전부 마이그레이션이 소유 — 코드가 테이블을 만들지 않는다
마이그레이션 파일에 DDL만 쓰지 않고 왜 이 구조로 바꿨는지를 같이 적어뒀다. 몇 달 뒤에 그 결정을 되짚을 사람이 나 자신이라서.
Decisions
기술적 의사결정
선택한 것보다 버린 것을 기록해두는 편이 나중에 더 쓸모 있었다. 왜 다른 길로 가지 않았는지가 남아 있어야 판단을 되짚을 수 있어서다.
매장 내 실시간 통신은 MQTT 브로커로
주문·호출·품절·단말 제어를 토픽 pub/sub으로. 서버는 지금 누가 접속해 있는지 몰라도 된다.
앱 업데이트 파이프라인을 직접 만든다
매장에 가지 않고 고칠 수 있어야 한다. 번들 발행·롤백·매장별 허용 토글까지 자체 구축.
APK 빌드와 번들 발행 워크플로를 분리
26분짜리 네이티브 빌드에 2분이면 끝날 JS 배포를 묶어두면, 빠른 배포 경로를 스스로 없애는 셈이다.
코드값의 주인은 언제나 서버
언어·기능 플래그 같은 목록을 클라이언트 타입으로 닫지 않는다. 모르는 값이 와도 앱이 깨지지 않게.
번역을 컬럼이 아니라 행으로 저장한다
언어를 늘릴 때 스키마가 바뀌면 안 된다. 언어 추가가 INSERT 한 줄이 되도록 구조를 갈아엎었다.
디자인은 앱에 박지 않고 서버에서 내린다
매장마다 다른 색·레이아웃을 코드 분기 대신 토큰으로. 관리 웹이 원본이고 앱은 그걸 그린다.
Debugging
오래 걸렸던 문제들
완성된 결과보다 이쪽이 실제 작업에 가깝다. 증상과 원인이 멀리 떨어져 있던 것들만 골랐다.
번들 ID는 계속 바뀌는데, 화면은 한 단계 옛것
업데이트를 발행하면 단말의 시스템 정보에 찍히는 번들 ID는 정상적으로 새 값으로 바뀐다. 그런데 화면에 나오는 건 이전 코드다. 매번, 정확히 한 단계씩 밀린 채로.
원인
발행 스크립트가 실행 번들의 asset key를 매번 같은 상수로 보내고 있었다. 단말 쪽 업데이트 라이브러리는 이 key로 디스크 파일명을 만든다. 즉 모든 번들이 디스크에서 같은 파일명을 차지했다. 새 번들을 받을 때 라이브러리는 '그 key의 파일이 이미 디스크에 있다'는 이유로 해시를 비교하지도 않고 다운로드를 건너뛰었다. 그래서 DB의 업데이트 레코드만 새것으로 갱신되고, 실제 실행되는 번들 파일은 옛것 그대로 남았다.
남은 것
라이브러리가 '값'이 아니라 '식별자'로 캐시를 판단하는 지점을 놓치면, 상위 레이어의 모든 지표가 정상이라고 말한다. 서버 로그도, DB도, 단말이 보고하는 버전도 전부 맞았고 오직 화면만 틀렸다. 원인을 찾은 건 라이브러리 소스에서 파일명을 만드는 함수를 직접 읽고 나서였다.
매장을 옮겼는데 옛 매장 이벤트를 계속 듣고 있었다
태블릿을 초기화하고 다른 매장으로 다시 등록하면 화면에는 새 매장 메뉴가 정상적으로 뜬다. 그런데 원격 제어가 하나도 먹지 않는다. 앱을 완전히 종료했다 켜면 그때부터 정상 동작한다.
원인
네이티브 백그라운드 서비스의 초기화 함수가 '이미 연결되어 있으면 아무것도 하지 않고 빠져나가는' 구조였다. 재등록으로 구독할 토픽 목록은 새 매장 것으로 갱신됐지만, 연결이 살아 있다는 이유로 재구독 자체를 건너뛰었다. 결과적으로 앱이 알고 있는 토픽과 브로커에 실제로 걸린 구독이 어긋났다.
남은 것
'이미 되어 있으면 넘어간다'는 최적화는 설정이 절대 변하지 않는다는 가정 위에 서 있다. 단말이 매장을 옮겨 다니는 순간 그 가정이 깨졌다. 그리고 급한 불은 배포가 빠른 레이어에서 먼저 끄고, 근본 수정은 배포 비용이 큰 레이어에서 따로 했다.
이미지가 에러 없이 그냥 안 그려진다
메뉴 카드를 여러 장 동시에 띄우면 그중 몇 장의 이미지 자리가 빈 채로 남는다. 에러 콜백도 안 뜨고 로그도 조용하다. 다시 들어가면 다른 카드가 비어 있다.
원인
새 렌더링 아키텍처로 올린 상태에서, 아직 그 아키텍처를 지원하지 않는 이미지 라이브러리를 쓰고 있었다. 미지원 조합은 예외를 던지지 않고 그냥 그리지 않는다. 실패가 조용해서 '가끔 안 나온다'로만 보였다.
남은 것
런타임 아키텍처를 바꾸는 마이그레이션에서는 '에러가 안 난다'가 정상 동작의 증거가 되지 못한다. 이후로는 화면 라이브러리를 올릴 때 지원 여부를 먼저 확인하고 넘어간다.
터치가 1초 늦게 먹는다
메뉴판에서 카드를 누르면 반응이 눈에 띄게 늦다. 태블릿 성능 문제로 보였다.
원인
카드 컴포넌트에 메모이제이션을 걸어뒀지만, 부모가 콜백을 인라인 함수로 내려주고 있었다. 렌더마다 새 함수 참조가 만들어지니 메모이제이션이 통째로 무효가 됐고, 카드 하나를 누를 때마다 화면의 카드 전부가 다시 렌더됐다.
남은 것
메모이제이션은 단독으로는 아무것도 보장하지 않는다. props 참조를 안정화하는 쪽과 항상 한 세트로 다뤄야 하고, 둘 중 하나만 하면 '건 것 같은데 안 듣는' 상태가 된다.
Pipeline
코드가 매장 태블릿에 닿기까지
개발 환경과 운영 환경을 나눠 두고, 운영으로 올리는 건 항상 사람이 결정한다. 자동으로 넘어가지 않는다.
- 01
푸시
바뀐 경로를 보고 어떤 파이프라인이 돌지 결정된다. 네이티브에 영향을 주는 변경인지 아닌지가 갈림길이다.
- 02
빌드
서버와 관리 웹은 컨테이너 이미지로, 앱은 빌드 지문을 먼저 계산해서 그 값을 빌드에 못 박은 뒤 패키징한다.
- 03
배포
이미지를 받아 컨테이너를 갈아끼우고, 앱 번들은 정적 서버로 올린 뒤 릴리스로 등록한다.
- 04
적용
단말은 부팅할 때 확인하거나, 관리 웹에서 신호를 던지면 즉시 받아 간다. 매장별로 업데이트를 막아둘 수도 있다.
JS만 바뀐 커밋은 2분이면 단말까지 닿는다. 네이티브가 바뀌면 앱을 다시 빌드해야 하고 그건 30분 가까이 걸린다. 이 차이가 커서, 급한 수정은 배포가 빠른 쪽 레이어에서 먼저 막는 습관이 생겼다.
POS Bridge
지금 만들고 있는 것
매장에는 이미 POS가 있다. 주문 시스템은 거기에 맞춰 들어가는 쪽이고, 그 접점을 시스템 안에 넣는 대신 별도 미들웨어로 떼어냈다 — 매장 PC에 상주하는 에이전트와 그걸 중계하는 서버로.
이 설계는 백지에서 나오지 않았다. 매장 연동이라는 문제를 오래 들여다보면 반복해서 마주치는 성질들이 있고, 구조는 대부분 거기서 결정됐다.
제약홉이 하나 늘면 장애 지점도 하나 는다
그래서중계 계층을 두지 않는다. 서버는 하나고, 연결은 에이전트가 밖으로 나가는 방향뿐이다.
제약갱신할 수 없는 프로그램은 현장에서 굳는다
그래서자가 업데이트 채널을 첫날 설계에 넣는다. 서버가 새 버전을 알리면 에이전트가 받아서 스스로 교체하고 재시작한다.
제약조용히 실패하는 동기화는 정산에서 터진다
그래서에이전트가 로컬 큐에 쌓고, 서버가 받았다고 답해야 큐에서 지운다. 동기화 작업마다 이력이 남는다.
제약중계 결과를 플랫폼이 모르면 아무도 책임질 수 없다
그래서중계에 상태를 부여하고 전이를 전부 기록한다. 정해진 시간 안에 못 보내면 만료로 끊고, 플랫폼이 조회할 수 있게 한다.
제약스택을 두 벌 운영하면 부담도 두 벌이다
그래서경계마다 다르게 고른다. 서버는 플랫폼과 같은 스택으로 통일하고, 매장 데몬 언어는 매장 PC 안에만 가둔다.
Not yet
아직 없는 것들
만든 것만 적어두면 어디서 멈췄는지가 안 보인다. 멈춘 지점과 이유를 같이 남긴다.
인터넷이 끊겨도 도는 매장
설계됨
회선이 죽어도 매장 안에서는 주문이 오가야 한다. 별도 장비를 매장마다 두는 대신, 이미 매장에 있고 항상 켜져 있는 점원 태블릿을 로컬 허브로 삼는 방향으로 정했다. 주문은 추가만 있고 수정이 없어서 나중에 합칠 때 충돌이 생기지 않는다는 점이 설계를 크게 단순하게 만든다. 아직 코드로는 시작하지 않았다.
POS 연동
부분 구현
주문 시스템 안에는 메뉴의 외부 코드와 벤더를 담을 자리까지만 있다. 실제 연동은 이 안에 넣지 않고 별도 미들웨어로 떼어냈다 — 매장 POS PC에 상주하는 에이전트와 그걸 중계하는 서버. 설계는 끝났고 구현은 초반이다.
결제 도메인
설계됨
결제 수단과 결제 단말 정보를 매장 설정에 두는 데까지는 왔다. 실제 승인·취소 흐름은 붙이지 않았다.
번역 자동 채우기
미착수
번역을 담는 구조와 편집 화면은 있지만, 빈 칸을 자동으로 채워주는 기능은 넣지 않았다. 화면 라벨이 앞서 나가 있어서 이건 정리 대상이기도 하다.
공통코드 도메인
미착수
코드값의 주인이 서버라는 원칙은 이미 지키고 있지만, 그걸 한 테이블로 일반화하는 작업은 미뤘다. 지금 규모에서는 각 도메인이 자기 값을 들고 있는 편이 읽기 쉬웠다.
iOS
미착수
프로젝트 구조상 자리는 있지만 빌드·배포 파이프라인은 안드로이드 전용이다. 대상 단말이 안드로이드 태블릿이라 우선순위를 두지 않았다.