테이블오더 · 키오스크 웨이팅 · 주방 · 홀 · 포스를 하나로 묶은 매장 운영 SaaS
손님이 주문을 넣는 순간부터 주방·홀·포스까지, 매장 안의 모든 화면이 같은 상태를 봅니다.
식당 한 곳에서 벌어지는 일은 생각보다 여러 화면에 걸쳐 있습니다. 입구에서는 웨이팅을 받고, 테이블에서는 주문이 들어오고, 주방은 그 주문을 조리 순서로 봐야 하고, 홀은 서빙 여부를, 포스는 결제를 처리합니다. 보통 이 화면들은 서로 다른 제품이고, 그래서 상태가 어긋납니다.
KiOrder는 이 흐름 전체를 하나의 데이터 소스 위에 올립니다. 주문 하나가 생기면 주방·홀·포스가 폴링 없이 동시에 그 사실을 압니다. 웨이팅 등록도 마찬가지로, 키오스크에서 손님이 번호를 받는 순간 사장님 관리 화면에 나타납니다.
포트폴리오 프로젝트로 6개 사용자 유형(손님 · 주방 · 홀 · 포스 · 사장님 · 시스템 관리자)의 화면을 실제로 구현했습니다.
Tip
바로 둘러보기 — https://kiorder.vercel.app
랜딩 페이지의 화면 카드를 누르면 로그인 없이 해당 화면으로 들어갑니다.
직접 로그인하려면 owner1@test.com / test1234 를 쓰세요.
| 개발 기간 | 2026.04.25 ~ 2026.08.08 (약 3.5개월) |
| 페이지 라우트 | 17개 (매장 운영 화면 14개) |
| REST 엔드포인트 | 22개 |
| DB 테이블 | 7개 |
| 머지된 PR | 20건 |
손님이 테이블오더로 주문을 넣으면 주방 칸반에 즉시 카드가 생깁니다. 폴링이 아니라 Supabase Realtime의 postgres_changes 구독입니다.
웨이팅 등록 → QR 대기표 발급 → 손님이 QR로 대기현황 확인 → 사장님 호출 → 손님 응답까지, 전 과정이 끊기지 않고 이어집니다.
테이블오더는 손님 개인 휴대폰에서도 열립니다. 3열 고정 레이아웃이 390px에서 무너지던 것을 세로 적층으로 바꾸고, 장바구니는 하단 바 + 시트로 분리했습니다.
| Before | After |
|---|---|
![]() |
![]() |
두 갈래의 손님 입력이 하나의 NestJS API로 모이고, DB 변경이 Realtime으로 매장 운영 화면에 퍼집니다. NestJS는 순수 REST만 담당하고 실시간 전파는 Supabase가 맡는 구조입니다.
[손님 — 키오스크] [손님 — 테이블오더]
│ │
POST /waiting POST /orders
└───────────┬────────────┘
▼
NestJS API (REST · JWT · class-validator)
│
Prisma 7 (@prisma/adapter-pg)
│
Supabase PostgreSQL
│
Supabase Realtime (CDC)
│
┌─────────────┼─────────────┬──────────────┐
▼ ▼ ▼ ▼
[주방 칸반] [홀 주문] [포스 결제] [오너 웨이팅]
로그인 → NestJS가 HttpOnly 쿠키(access_token) 발급
│
▼
Next.js proxy.ts — jose jwtVerify 로 서명 검증
│
┌─────────┴─────────┐
▼ ▼
SYSTEM_ADMIN STORE_OWNER
/system-admin/* /owner/* · /kitchen · /hall · /pos · /kiosk
- XSS 방어 — HttpOnly라 JS에서 쿠키에 접근할 수 없습니다
- CSRF 방어 —
SameSite=lax - 서명 검증 — 단순 디코딩이 아니라
jose jwtVerify
Note
DB의 Role enum은 SYSTEM_ADMIN, STORE_OWNER 2종입니다.
위에서 말한 "6개 사용자 유형"은 화면 기준이며, 손님·주방·홀·포스 화면은 로그인 계정이 아니라 경로로 구분합니다.
| 사용자 유형 | 경로 | 하는 일 |
|---|---|---|
| 손님 — 키오스크 | /kiosk/waiting |
웨이팅 등록, QR 대기표 수령 |
| 손님 — 대기현황 | /waiting/[waitingId] |
내 순번 확인, 사장님 호출에 응답 |
| 손님 — 테이블오더 | /table-order/[tableId]/menu |
메뉴 조회, 장바구니, 주문 제출 |
| 주방 | /kitchen/orders |
주문 수신, 조리 상태 전환 |
| 홀 | /hall/orders |
주문 확인 및 서빙 처리 |
| 포스 | /pos |
테이블별 주문 집계, 결제 |
| 사장님 | /owner/dashboard |
메뉴 · 웨이팅 · 테이블 설정 |
| 시스템 관리자 | /system-admin/stores |
매장 목록, 구독 승인/해제 |
터치 키패드로 전화번호를 입력하고 인원수를 고르면 POST /waiting 으로 등록됩니다. 완료 화면은 태블릿 가로 기준 2분할(대기번호 + QR)이고, QR을 찍으면 손님 전용 대기현황 페이지로 넘어갑니다.
유휴 상태가 이어지면 광고 화면보호기가 뜹니다 — 실제 매장 키오스크가 대기 시간에 놀지 않게 하는 장치입니다.
15초마다 자동 갱신되고(setInterval + fetchRef 패턴으로 stale closure 회피), 수동 새로고침은 1초 쿨다운을 둡니다. 사장님이 호출하면 손님은 가고있어요 / 늦어요 / 취소 로 응답할 수 있습니다(PATCH /waiting/:id/guest-response). 입장완료·취소 상태가 되면 만료 페이지로 대체합니다.
- 주방 — 접수됨 → 조리중 → 완료 칸반. 조리 시간을 재고 15분을 넘기면 타이머가 빨갛게 바뀝니다
- 홀 —
PATCH /orders/:id/hall-receive로 서빙 처리 - 포스 — 테이블별 주문을 모아 결제 처리, 결제 후 테이블 점유 해제
세 화면 모두 진입 시 GET /orders 로 초기 로드한 뒤 Realtime 구독으로 이어받습니다.
키오스크 신규 등록이 Realtime으로 즉시 나타납니다. 입장완료·취소한 팀은 목록에서 지우지 않고 맨 뒤로 보내며 취소선 처리 합니다 — 방금 무슨 일이 있었는지가 운영 중에는 정보이기 때문입니다. 전화번호는 010-****-5678 로 마스킹합니다.
react-hook-form + zod 스키마 검증, 판매중/품절 Switch 토글(PATCH /menu/:id), 카테고리 필터링을 지원합니다.
| 기술 | 역할 |
|---|---|
| Next.js 16 (App Router) | Route Group (public) / (owner) / (system) 으로 역할별 레이아웃 분리 |
| React 19 · TypeScript | 전 레이어 타입 안전성 |
| TanStack Query v5 | 서버 상태 관리, 캐시 무효화 |
| Tailwind CSS v4 + shadcn/ui | 다크 테마 디자인 시스템 (DESIGN.md) |
| Supabase Realtime | WebSocket 기반 DB 변경 구독 |
| motion v12 | fly-to-cart, 순차 등장 등 인터랙션 |
| react-hook-form + zod | 폼 상태 + 스키마 검증 |
| jose | proxy.ts 에서 JWT 서명 검증 |
| react-qr-code · sonner | QR 생성 · Toast |
| 기술 | 역할 |
|---|---|
| NestJS 11 | REST API, 모듈/서비스/컨트롤러 구조 |
| Prisma 7 | 타입세이프 ORM (@prisma/adapter-pg 어댑터 방식) |
| Supabase (PostgreSQL) | 메인 DB + Realtime CDC |
| Passport JWT + bcrypt | HttpOnly 쿠키 기반 인증 |
| class-validator | DTO 입력 검증, ValidationPipe 전역 등록 |
User 1──1 Store
Store 1──N Table
Store 1──N MenuItem
Store 1──N Order
Store 1──N WaitingEntry
Order 1──N OrderItem
Table 1──N Order
MenuItem 1──N OrderItem
왜 Supabase Realtime인가 WebSocket 서버를 직접 세우는 대신 PostgreSQL의 CDC(Change Data Capture)를 구독합니다. NestJS는 REST에만 집중하고 실시간 레이어는 Supabase가 담당하는 관심사 분리입니다. 주방·홀·포스가 각각 폴링했다면 화면 수만큼 요청이 늘었을 것입니다.
왜 HttpOnly 쿠키인가 localStorage 방식은 XSS 한 번이면 토큰이 통째로 털립니다. 키오스크·테이블오더처럼 불특정 다수가 만지는 기기에서는 JS가 토큰에 접근할 수 없어야 합니다.
앞 대기 팀 수를 왜 서버에서 세는가
클라이언트에서 계산하려면 전체 대기 목록을 내려줘야 하고, 그 목록에는 다른 손님의 전화번호가 들어 있습니다. 서버에서 status IN ('대기중','호출중') AND number < 내번호 로 집계값만 반환해 개인정보 노출 경로를 없앴습니다.
주문 중복은 왜 서버에서 막는가 버튼 연타로 같은 주문이 두 번 들어가는 문제를 클라이언트 가드로 먼저 막았지만, 그것만으로는 요청을 직접 만들어 보내는 경우를 못 막습니다. 서버에서 품절 여부·수량·가격·타매장 메뉴까지 다시 검증합니다. 가격을 클라이언트가 보내준 값으로 믿지 않는 것이 핵심입니다.
메뉴는 왜 소프트 삭제인가
주문 이력이 있는 메뉴를 하드 삭제하면 OrderItem 의 FK가 깨져 500이 납니다. 지난 주문서에서 메뉴 이름이 사라지는 것은 매장 입장에서도 사고이므로, 삭제 대신 비활성 처리해 참조 무결성과 과거 데이터를 함께 지킵니다.
- Node.js 20+
- Supabase 프로젝트 (PostgreSQL)
cd backend
npm install
# .env 를 만들고 아래 환경변수를 채웁니다
npx prisma migrate dev
npm run start:devcd frontend
npm install
# .env.local 을 만들고 아래 환경변수를 채웁니다
npm run devbackend/.env
DATABASE_URL= # Supabase pooler (port 6543)
DIRECT_URL= # Supabase direct (port 5432)
JWT_SECRET= # 64바이트 랜덤 hex
FRONTEND_URL= # CORS 허용 originfrontend/.env.local
NEXT_PUBLIC_BACKEND_URL=
NEXT_PUBLIC_FRONTEND_URL=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
JWT_SECRET= # 백엔드와 반드시 동일한 값Important
JWT_SECRET 은 프론트·백엔드가 같아야 합니다. proxy.ts 의 jose jwtVerify 가 이 값으로 서명을 검증하기 때문에, 값이 다르면 로그인은 되는데 모든 보호 라우트에서 튕깁니다.
Note
Prisma 7 주의 — schema.prisma 에 datasource url 을 쓸 수 없습니다.
런타임은 @prisma/adapter-pg 로 직접 연결하고, CLI 마이그레이션은 prisma.config.ts 의 DIRECT_URL 을 사용합니다.
kiorder/
├── frontend/ # Next.js 16 App Router
│ ├── app/
│ │ ├── page.tsx # 랜딩 — 화면 둘러보기 + 자동 로그인
│ │ ├── (public)/ # 인증 불필요 (로그인, 손님 대기현황)
│ │ ├── (owner)/ # kiosk · kitchen · hall · pos · table-order · owner/*
│ │ └── (system)/ # 시스템 관리자
│ ├── components/ # kiosk · kitchen · hall · owner · table-order · screensaver · shared · ui
│ ├── hooks/ # useTableMenu · useOwnerMenu · usePosOrders · useFlyToCart · useIdle …
│ ├── lib/
│ │ ├── api.ts # apiFetch 공통 클라이언트
│ │ ├── supabase.ts # Realtime 클라이언트
│ │ └── motion.ts # 모션 토큰
│ ├── types/ # menu · order · store · waiting
│ └── proxy.ts # Route Guard (jose jwtVerify)
│
└── backend/ # NestJS 11
├── src/
│ ├── auth/ # JWT 인증, HttpOnly 쿠키
│ ├── store/ # 매장 관리
│ ├── menu/ # 메뉴 CRUD (소프트 삭제)
│ ├── order/ # 주문 · 상태 전환 · 서버 검증
│ ├── table/ # 테이블
│ ├── waiting-entry/ # 웨이팅 (앞 대기 팀 집계 포함)
│ └── prisma/ # PrismaService (@prisma/adapter-pg)
├── prisma/
│ └── schema.prisma
└── prisma.config.ts # Prisma 7 CLI 설정 (DIRECT_URL)
| 대상 | 플랫폼 |
|---|---|
| Frontend | Vercel |
| Backend | Render |
| Database | Supabase |
Render 무료 플랜의 슬립을 피하기 위해 GET /health 를 두고 주기적으로 깨웁니다.



