이탈리아 정육점 주문관리 시스템 개발기
왜 만들었나
이탈리아의 작은 정육점 Mazzucchi는 전화와 수기 메모로 주문을 관리하고 있었습니다. 고객이 원하는 부위와 중량을 전화로 주문하면, 주인이 메모장에 적어두고 준비하는 방식이었습니다.
이 과정에서 반복적으로 발생하는 문제가 있었습니다:
- 전화 주문 중 부위명이나 중량이 잘못 전달되는 경우
- 피크 시간대에 전화를 받지 못해 놓치는 주문
- 매출 현황을 파악하려면 영수증을 하나하나 넘겨봐야 하는 번거로움
이 문제를 해결하기 위해 고객용 모바일 웹과 관리자 전용 앱을 개발했습니다.
무엇을 만들었나
고객용 모바일 웹
고객은 스마트폰 브라우저에서 상품 목록을 확인하고, 원하는 부위와 중량을 선택한 뒤 픽업 날짜와 시간대를 예약합니다. 별도의 앱 설치 없이 URL 접속만으로 주문이 가능합니다.
관리자 앱 (Android / Web)
정육점 주인은 전용 관리자 앱에서 다음 기능을 사용합니다:
- 주문 관리: 접수된 주문 확인, 상태 변경 (대기 → 준비 중 → 완료)
- 중량 확정: 고객이 500g을 주문했지만 실제 계량 결과 520g인 경우, 실측값을 입력하여 최종 금액 확정
- 상품/카테고리 관리: 부위별 상품 등록, 가격 수정, 소프트 삭제
- 매출 분석: 일간/월간/연간 매출 추이, 카테고리별 매출 비중, 인기 상품 순위
- FCM 푸시 알림: 주문 상태가 변경되면 고객에게 자동 알림 발송
기술 스택과 아키텍처
| 영역 | 기술 | |---|---| | Frontend | Flutter (Web + Android), BLoC 패턴 | | Backend | Kotlin, Ktor, Exposed ORM | | Database | PostgreSQL | | Infrastructure | Docker Compose, Nginx, Let's Encrypt | | Monitoring | Grafana + Prometheus | | Push Notification | Firebase Cloud Messaging | | Server | Hetzner CX22 (Nuremberg), Ubuntu 24.04 |
이탈리아어 단일 언어 앱이지만, 번역 테이블을 분리하여 향후 다국어 확장이 가능하도록 설계했습니다. 상품명과 카테고리명은 ProductTranslations, CategoryTranslations 테이블에서 관리됩니다.
기술적 챌린지
1. 소프트 삭제와 FK 무결성
상품을 삭제하더라도 과거 주문 내역에는 해당 상품 정보가 남아야 합니다. 하드 삭제 시 Foreign Key 제약 조건에 위배되므로, is_deleted 플래그를 활용한 소프트 삭제 패턴을 적용했습니다.
- 삭제된 상품은 목록에서 숨겨지지만, 기존 주문에서는 "(deleted)" 접미사와 함께 표시
- 삭제된 상품으로 신규 주문 생성 시 서버 단에서 차단
- 카테고리도 동일한 패턴 적용 (상품이 카테고리를 FK로 참조)
2. 주문 중량 확정 플로우
정육점의 특수한 요구사항으로, 고객의 요청 중량과 실제 계량 중량이 다를 수 있습니다. 이를 위해 finalize API를 설계했습니다:
- 고객 주문: "소고기 안심 500g, 2팩"
- 관리자 확정: 실제 측정값 520g 입력 → 최종 금액 재계산
- 상태가 COMPLETED로 전환되며 고객에게 FCM 알림 발송
3. 날짜 기반 주문 조회 최적화
주문은 예약 날짜(scheduledAt)와 완료 날짜(completedAt)가 다를 수 있습니다. 관리자가 특정 날짜의 주문을 조회할 때, 완료된 주문은 완료일 기준으로, 미완료 주문은 예약일 기준으로 필터링합니다.
4. 캐시 전략
상품과 카테고리 데이터는 주문 조회 시 매번 JOIN하면 비효율적이므로, 서버 시작 시 인메모리 캐시에 로드합니다. 상품 생성/수정/삭제 시 캐시를 갱신하여 일관성을 유지합니다.
배포와 운영
월 5유로의 Hetzner VPS에 Docker Compose로 전체 스택을 배포합니다. Nginx가 리버스 프록시와 SSL 종단을 담당하고, Let's Encrypt로 무료 인증서를 발급받습니다.
배포는 rsync 기반 스크립트로 처리합니다. Docker의 멀티스테이지 빌드에서 의존성 레이어를 분리하여, 소스 코드만 변경되었을 때 빌드 시간을 단축했습니다.
회고
실제 사용자의 요구사항을 기반으로 개발하면서, 처음에는 예상하지 못했던 도메인 특성을 많이 발견했습니다. 중량 확정 플로우, 소프트 삭제의 연쇄적 영향, 날짜 기반 조회의 복잡성 등이 대표적입니다.
"되는 것"을 만드는 것보다 "안 부서지는 것"을 만드는 것이 훨씬 어렵다는 것을 체감한 프로젝트였습니다.
Building an Order System for an Italian Butcher Shop
Why I Built This
Mazzucchi, a small butcher shop in Italy, managed orders through phone calls and handwritten notes. Customers would call in their requests for specific cuts and weights, and the owner would jot them down on a notepad.
This process had recurring problems:
- Miscommunication of cut names or weights during phone orders
- Missed orders during peak hours when the phone couldn't be answered
- Reviewing sales meant flipping through receipts one by one
To solve these problems, I built a customer-facing mobile web app and a dedicated admin app.
What I Built
Customer Mobile Web
Customers browse the product catalog on their smartphone browser, select cuts and weights, then book a pickup date and time slot. No app installation required.
Admin App (Android / Web)
The shop owner uses the admin app for:
- Order management: Review incoming orders, update status (Pending, Processing, Ready, Completed)
- Weight confirmation: If a customer ordered 500g but the actual weight is 520g, the owner enters the measured value to finalize the price
- Product & category management: Register products by cut, update prices, soft-delete discontinued items
- Sales analytics: Daily/monthly/yearly revenue trends, category breakdowns, top products
- FCM push notifications: Automatic alerts to customers when order status changes
Tech Stack & Architecture
| Layer | Technology | |---|---| | Frontend | Flutter (Web + Android), BLoC pattern | | Backend | Kotlin, Ktor, Exposed ORM | | Database | PostgreSQL | | Infrastructure | Docker Compose, Nginx, Let's Encrypt | | Monitoring | Grafana + Prometheus | | Push Notifications | Firebase Cloud Messaging | | Server | Hetzner CX22 (Nuremberg), Ubuntu 24.04 |
While the app currently targets Italian only, the database schema separates translations into dedicated tables (ProductTranslations, CategoryTranslations), making future multi-language support straightforward.
Technical Challenges
1. Soft Delete & FK Integrity
Deleting a product shouldn't erase it from historical orders. Hard deletes violate Foreign Key constraints, so I implemented a soft-delete pattern using an is_deleted flag.
- Deleted products are hidden from listings but appear with a "(deleted)" suffix in past orders
- New orders referencing deleted products are blocked server-side
- Categories follow the same pattern since products reference them via FK
2. Weight Finalization Flow
A domain-specific requirement: the customer's requested weight and the actual measured weight often differ. I designed a finalize API to handle this:
- Customer orders: "500g beef tenderloin, 2 packs"
- Owner confirms: enters actual measured weight of 520g, price recalculated
- Status transitions to COMPLETED, FCM notification sent to customer
3. Date-Based Order Query Optimization
Orders have both a scheduled date (scheduledAt) and a completion date (completedAt). When querying orders for a specific date, completed orders filter by completion date while pending orders filter by scheduled date.
4. Caching Strategy
Product and category data is loaded into an in-memory cache at server startup to avoid repetitive JOINs on every order query. The cache is refreshed whenever products or categories are created, updated, or deleted.
Deployment & Operations
The entire stack runs on a Hetzner VPS at 5 euros per month via Docker Compose. Nginx handles reverse proxying and SSL termination with free Let's Encrypt certificates.
Deployment uses an rsync-based script. Docker multi-stage builds separate the dependency layer from the source layer, so builds are fast when only source code changes.
Reflections
Building for a real user revealed many domain-specific complexities I hadn't anticipated: the weight finalization flow, cascading effects of soft deletes, and the nuances of date-based queries.
This project taught me that building something that "works" is far easier than building something that "doesn't break."