Skip to content

Repository files navigation

KiOrder

Next.js React TypeScript NestJS Prisma Supabase Tailwind CSS

테이블오더 · 키오스크 웨이팅 · 주방 · 홀 · 포스를 하나로 묶은 매장 운영 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로 대기현황 확인 → 사장님 호출 → 손님 응답까지, 전 과정이 끊기지 않고 이어집니다.

키오스크 웨이팅 풀플로우

모바일 대응 (before / after)

테이블오더는 손님 개인 휴대폰에서도 열립니다. 3열 고정 레이아웃이 390px에서 무너지던 것을 세로 적층으로 바꾸고, 장바구니는 하단 바 + 시트로 분리했습니다.

Before After
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), 카테고리 필터링을 지원합니다.

기술 스택

Frontend

기술 역할
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

Backend

기술 역할
NestJS 11 REST API, 모듈/서비스/컨트롤러 구조
Prisma 7 타입세이프 ORM (@prisma/adapter-pg 어댑터 방식)
Supabase (PostgreSQL) 메인 DB + Realtime CDC
Passport JWT + bcrypt HttpOnly 쿠키 기반 인증
class-validator DTO 입력 검증, ValidationPipe 전역 등록

DB 스키마

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)

Backend

cd backend
npm install
# .env 를 만들고 아래 환경변수를 채웁니다
npx prisma migrate dev
npm run start:dev

Frontend

cd frontend
npm install
# .env.local 을 만들고 아래 환경변수를 채웁니다
npm run dev

환경변수

backend/.env

DATABASE_URL=    # Supabase pooler (port 6543)
DIRECT_URL=      # Supabase direct (port 5432)
JWT_SECRET=      # 64바이트 랜덤 hex
FRONTEND_URL=    # CORS 허용 origin

frontend/.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.tsjose jwtVerify 가 이 값으로 서명을 검증하기 때문에, 값이 다르면 로그인은 되는데 모든 보호 라우트에서 튕깁니다.

Note

Prisma 7 주의schema.prismadatasource url 을 쓸 수 없습니다. 런타임은 @prisma/adapter-pg 로 직접 연결하고, CLI 마이그레이션은 prisma.config.tsDIRECT_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 를 두고 주기적으로 깨웁니다.


Made with ☕ by jcdororo

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages