이 글은 기술 편입니다. 가게와 앱의 이야기가 궁금하시다면 코모 호수 옆 정육점 이야기를, 화면별 기능 소개는 기능 투어를 봐주세요.
왜 만들었나
이탈리아의 작은 정육점 Mazzucchi는 전화와 메모장으로 주문을 관리하고 있었습니다. 손님이 전화로 부위와 중량을 말하면 사장님이 메모장에 적어두고 준비하는 방식입니다.
이 방식에는 반복해서 터지는 문제들이 있었습니다:
- 전화로 부위 이름을 주고받다가 서로 다르게 알아듣는 일
- 바쁜 시간에 전화를 못 받아서 그대로 사라지는 주문
- 매출이 궁금하면 영수증 뭉치를 처음부터 넘겨봐야 하는 수고
이 문제를 해결하기 위해 고객용 앱과 관리자 앱 두 개를 개발했습니다.


하나의 코드베이스, 두 개의 앱
고객 앱(Macelleria Mazzucchi)과 관리자 앱(Mazzucchi Admin)은 한 Flutter 프로젝트에서 갈라져 나옵니다. 엔트리포인트(main_customer.dart / main.dart)만 다르고 네트워크·모델·테마 계층은 공유합니다.
플레이버 전환 방식은 플랫폼마다 다릅니다. Android는 gradle product flavor를 쓰고:
// android/app/build.gradle.kts
flavorDimensions += "app"
productFlavors {
create("customer") {
dimension = "app"
applicationId = "dev.nariinfo.mazzucchi.customer"
resValue("string", "app_name", "Macelleria Mazzucchi")
}
create("admin") {
dimension = "app"
applicationId = "dev.nariinfo.mazzucchi"
resValue("string", "app_name", "Mazzucchi Admin")
}
}iOS는 Xcode 스킴 대신 빌드 스크립트가 xcconfig를 갈아끼우는 방식을 택했습니다. 번들 ID·표시 이름·엔트리먼트·Firebase 설정까지 한 번에 전환되고, 스크립트가 끝나면 기본값으로 복원됩니다:
# scripts/build_ios_ipa.sh
case "$flavor" in
customer)
target="lib/main_customer.dart"
bundle_id="dev.nariinfo.mazzucchi.customer"
entitlements="Runner/Runner.entitlements"
;;
admin)
target="lib/main.dart"
bundle_id="dev.nariinfo.mazzucchi.admin"
entitlements="Runner/Admin.entitlements"
;;
esac
cat > "$app_flavor_file" <<EOF
APP_BUNDLE_ID=$bundle_id
APP_DISPLAY_NAME=$display_name
APP_ENTITLEMENTS=$entitlements
EOF기술 스택과 아키텍처
| 영역 | 기술 |
|---|---|
| Frontend | Flutter (iOS + Android, 고객/관리자 2개 플레이버), BLoC 패턴 |
| Backend | Kotlin, Ktor, Exposed ORM |
| Database | PostgreSQL |
| Auth | 관리자: 자체 JWT(access/refresh) · 고객: Apple/Google 소셜 로그인 |
| Infrastructure | Docker Compose, Nginx, Let's Encrypt |
| Monitoring | Grafana + Prometheus (Telegram 알림) |
| Push Notification | iOS: APNs 직접 발송 · Android: FCM |
| Server | Hetzner CX22 (Nuremberg), Ubuntu 24.04 |
상품명과 카테고리명은 처음부터 ProductTranslations, CategoryTranslations 번역 테이블로 분리 설계했고, 덕분에 출시 후 이탈리아어 하나에서 10개 언어로 확장할 때 스키마 변경 없이 데이터만 추가하면 됐습니다. 번역이 없는 언어는 이탈리아어로 폴백합니다.
기술적 챌린지
1. 소프트 삭제와 FK 무결성
상품을 지워도 과거 주문에는 그 상품 이름이 남아 있어야 합니다. 진짜로 지우면 Foreign Key 제약에 걸리기 때문에, is_deleted 플래그를 쓰는 소프트 삭제로 풀었습니다.
// models/Products.kt — Exposed 테이블 정의
object Products : UUIDTable("products") {
val pricePerUnit = decimal("price_per_unit", 10, 2)
val unitType = varchar("unit_type", 20) // "kg" | "pz"
val isAvailable = bool("is_available").default(true)
val isDeleted = bool("is_deleted").default(false) // 소프트 삭제 (FK 보호)
val categoryId = reference("category_id", Categories)
val createdAt = datetime("created_at").defaultExpression(CurrentDateTime)
}- 삭제된 상품은 목록에서 숨겨지지만, 기존 주문에서는 "(deleted)" 접미사와 함께 표시
- 삭제된 상품으로 신규 주문 생성 시 서버 단에서 차단
- 카테고리도 동일한 패턴 적용 (상품이 카테고리를 FK로 참조)
2. 주문 중량 확정 플로우
정육점에는 다른 가게에 없는 사정이 하나 있습니다. 주문받은 중량과 실제로 썬 중량이 다르다는 것. 그래서 주문 시점 금액은 어디까지나 "예상"이고, 저울에 올려본 뒤에야 최종 금액이 정해집니다. 이 흐름을 finalize API로 만들었습니다.
@Serializable
data class FinalizeOrderItemRequest(
val orderItemId: String, // OrderItem UUID
val actualQuantity: Double // 실제 측정 수량
)
@Serializable
data class FinalizeOrderRequest(
val items: List<FinalizeOrderItemRequest>,
val totalPrice: Double // 최종 청구 금액
)주문 하나에 아이템이 여러 개라도 UPDATE는 한 번만 날립니다. UUID를 먼저 파싱·검증한 뒤 CASE 식을 조립해 일괄 갱신합니다:
// repositories/OrderRepository.kt
suspend fun finalizeOrder(id: String, request: FinalizeOrderRequest): Boolean = runCatching {
dbQuery {
// 1. 아이템 실측 수량 일괄 업데이트 (단일 SQL)
val validated = request.items.map {
UUID.fromString(it.orderItemId) to it.actualQuantity // 파싱 실패 시 예외
}
val cases = validated.joinToString(" ") { (uuid, qty) -> "WHEN '$uuid' THEN $qty" }
val ids = validated.joinToString(",") { (uuid, _) -> "'$uuid'" }
TransactionManager.current().exec("""
UPDATE order_items
SET actual_quantity = CASE id $cases END
WHERE id IN ($ids)
""".trimIndent())
// 2. 최종 금액 확정 + COMPLETED 전환
Orders.update({ Orders.id eq UUID.fromString(id) }) {
it[totalPrice] = request.totalPrice.toBigDecimal()
it[status] = "COMPLETED"
it[completedAt] = LocalDateTime.now()
} > 0
}
}.getOrDefault(false)
3. 날짜 기반 주문 조회 최적화
주문은 예약 날짜(scheduledAt)와 완료 날짜(completedAt)가 다를 수 있습니다. 관리자가 특정 날짜의 주문을 조회할 때, 완료된 주문은 완료일 기준으로, 미완료 주문은 예약일 기준으로 필터링합니다. 인덱스도 실제 쿼리 패턴에 맞춰 걸었습니다:
// models/Orders.kt
object Orders : UUIDTable("orders") {
// ...
init {
index(false, status, completedAt) // 매출 리포트: WHERE status='COMPLETED' AND ...
index(false, scheduledAt) // 슬롯 조회 · 날짜별 주문 목록
index(false, customerId) // 고객별 주문 내역
}
}4. 캐시 전략
상품과 카테고리 데이터는 주문 조회 시 매번 JOIN하면 비효율적이므로, 서버 시작 시 인메모리 캐시(ConcurrentHashMap)에 전부 올립니다. 이때 소프트 삭제된 상품도 함께 올리는 것이 포인트입니다 — 과거 주문에 이름을 붙여줘야 하니까요.
// plugins/Cache.kt
object ProductCache {
private val cache = ConcurrentHashMap<String, ProductData>()
suspend fun reload() = dbQuery {
// 소프트 삭제 상품 포함 — 과거 주문 조회 시 필요
val rows = (Products leftJoin ProductTranslations).selectAll().toList()
val newCache = rows
.groupBy { it[Products.id].value.toString() }
.mapValues { (_, productRows) -> /* 언어별 번역 맵 구성 */ }
// ...
}
}상품 생성/수정/삭제 시 캐시를 갱신하여 일관성을 유지합니다.
5. 푸시 알림 이중 경로 (APNs 직발 + FCM)
처음에는 FCM 하나로 양 플랫폼을 커버했지만, 운영하며 iOS는 백엔드가 APNs에 직접 발송하는 구조로 전환했습니다. .p8 키로 ES256 JWT를 직접 서명해 APNs HTTP/2 API를 호출합니다. 외부 SDK 없이 JDK 표준 라이브러리로만 구현했습니다.
// plugins/ApnsService.kt — provider token 서명 (50분 캐시)
private fun providerToken(): String {
cachedProviderToken?.let { if (it.expiresAt.isAfter(now.plusSeconds(60))) return it.token }
val header = """{"alg":"ES256","kid":"${keyId()}"}"""
val payload = """{"iss":"${teamId()}","iat":${now.epochSecond}}"""
val signingInput = "${base64Url(header)}.${base64Url(payload)}"
val token = "$signingInput.${base64Url(signEs256(signingInput.toByteArray()))}"
cachedProviderToken = CachedProviderToken(token, now.plusSeconds(50 * 60))
return token
}여기서 만난 함정 하나 — Java의 Signature는 DER 인코딩 서명을 내놓지만, APNs(JWT)는 r‖s를 이어 붙인 JOSE 형식을 요구합니다. 변환기를 직접 짜야 했습니다:
// DER (SEQUENCE of two INTEGERs) → JOSE (r ‖ s, 각 32바이트)
private fun derToJose(der: ByteArray, outputLength: Int): ByteArray {
var offset = 2
val rLength = der[offset + 1].toInt()
val r = der.copyOfRange(offset + 2, offset + 2 + rLength)
offset += 2 + rLength
val sLength = der[offset + 1].toInt()
val s = der.copyOfRange(offset + 2, offset + 2 + sLength)
val jose = ByteArray(outputLength)
copyUnsigned(BigInteger(1, r).toByteArray(), jose, 0, outputLength / 2)
copyUnsigned(BigInteger(1, s).toByteArray(), jose, outputLength / 2, outputLength / 2)
return jose
}APNs는 sandbox/production 환경이 빌드 서명 방식과 짝이 맞아야 합니다. Xcode 개발 빌드의 토큰은 sandbox로, TestFlight/App Store 빌드의 토큰은 production으로 보내야 하죠. 짝이 어긋나면 에러도 요란하지 않습니다 — 조용히 BadDeviceToken만 돌아오고 알림은 증발합니다. 환경 변수(APNS_ENV) 하나로 엔드포인트를 전환하도록 만들어 두고, 배포 체크리스트에 "빌드 종류와 APNS_ENV 짝 확인"을 박아두는 것으로 마무리했습니다.
디바이스 토큰은 push_device_tokens 테이블에서 고객/관리자·플랫폼별로 관리합니다. Android는 여전히 FCM을 사용하고, 관리자 새 주문 알림은 토픽(admin_new_orders) 방식이라 기기 토큰 관리가 필요 없습니다.
배포와 운영
월 5유로의 Hetzner VPS에 Docker Compose로 전체 스택을 배포합니다. Nginx가 리버스 프록시와 SSL 종단을 담당하고, Let's Encrypt로 무료 인증서를 발급받습니다.
배포는 rsync 기반 스크립트로 처리합니다. Docker의 멀티스테이지 빌드에서 의존성 레이어를 분리하여, 소스 코드만 변경되었을 때 빌드 시간을 단축했습니다.
# deploy.sh — 복잡할 것 없는 배포
rsync -avz --exclude 'backend/build' --exclude 'frontend' ./ "$SERVER:$REMOTE_DIR/"
ssh "$SERVER" "cd $REMOTE_DIR/infra && docker compose --profile production up -d --build"Prometheus가 백엔드·PostgreSQL·서버 지표를 긁고, Grafana 알림은 Telegram으로 옵니다. 새벽에 뭔가 잘못되면 코모 호수보다 먼저 제 휴대폰이 압니다.
회고
실제로 장사하는 사람 바로 옆에서 만들다 보니, 책상 앞에서는 몰랐을 사정들을 계속 만났습니다. 저울이 정하는 최종 금액, 소프트 삭제가 번지는 범위, 날짜 조회의 미묘함, 플랫폼마다 다른 푸시 인프라까지.
"되는 것"을 만드는 것보다 "안 부서지는 것"을 만드는 것이 훨씬 어렵다는 것을 체감한 프로젝트였습니다.
This is the technical installment. For the story of the shop and the app, see By Lake Como; for a screen-by-screen walkthrough, see the feature tour.
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 two apps — one for customers, one for the owner.


One Codebase, Two Apps
The customer app (Macelleria Mazzucchi) and the admin app (Mazzucchi Admin) branch off from a single Flutter project. Only the entry points differ (main_customer.dart / main.dart); the network, model, and theme layers are shared.
Flavor switching works differently per platform. Android uses gradle product flavors:
// android/app/build.gradle.kts
flavorDimensions += "app"
productFlavors {
create("customer") {
dimension = "app"
applicationId = "dev.nariinfo.mazzucchi.customer"
resValue("string", "app_name", "Macelleria Mazzucchi")
}
create("admin") {
dimension = "app"
applicationId = "dev.nariinfo.mazzucchi"
resValue("string", "app_name", "Mazzucchi Admin")
}
}On iOS, instead of Xcode schemes, a build script swaps the xcconfig — bundle ID, display name, entitlements, and Firebase config flip together, and defaults are restored when the script exits:
# scripts/build_ios_ipa.sh
case "$flavor" in
customer)
target="lib/main_customer.dart"
bundle_id="dev.nariinfo.mazzucchi.customer"
entitlements="Runner/Runner.entitlements"
;;
admin)
target="lib/main.dart"
bundle_id="dev.nariinfo.mazzucchi.admin"
entitlements="Runner/Admin.entitlements"
;;
esac
cat > "$app_flavor_file" <<EOF
APP_BUNDLE_ID=$bundle_id
APP_DISPLAY_NAME=$display_name
APP_ENTITLEMENTS=$entitlements
EOFTech Stack & Architecture
| Layer | Technology |
|---|---|
| Frontend | Flutter (iOS + Android, customer/admin flavors), BLoC pattern |
| Backend | Kotlin, Ktor, Exposed ORM |
| Database | PostgreSQL |
| Auth | Admin: first-party JWT (access/refresh) · Customer: Apple/Google social login |
| Infrastructure | Docker Compose, Nginx, Let's Encrypt |
| Monitoring | Grafana + Prometheus (Telegram alerts) |
| Push Notifications | iOS: direct APNs · Android: FCM |
| Server | Hetzner CX22 (Nuremberg), Ubuntu 24.04 |
Product and category names were designed as translation tables (ProductTranslations, CategoryTranslations) from day one — so when the app later expanded from Italian-only to ten languages, it was a data change, not a schema change. Missing translations fall back to Italian.
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.
// models/Products.kt — Exposed table definition
object Products : UUIDTable("products") {
val pricePerUnit = decimal("price_per_unit", 10, 2)
val unitType = varchar("unit_type", 20) // "kg" | "pz"
val isAvailable = bool("is_available").default(true)
val isDeleted = bool("is_deleted").default(false) // soft delete (protects FKs)
val categoryId = reference("category_id", Categories)
val createdAt = datetime("created_at").defaultExpression(CurrentDateTime)
}- 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. The price at order time is only an estimate — the final amount exists only after weighing. The finalize API handles this:
@Serializable
data class FinalizeOrderItemRequest(
val orderItemId: String, // OrderItem UUID
val actualQuantity: Double // measured weight
)
@Serializable
data class FinalizeOrderRequest(
val items: List<FinalizeOrderItemRequest>,
val totalPrice: Double // final billed amount
)Even with multiple items per order, only one UPDATE is issued. UUIDs are parsed and validated first, then a CASE expression batch-updates everything:
// repositories/OrderRepository.kt
suspend fun finalizeOrder(id: String, request: FinalizeOrderRequest): Boolean = runCatching {
dbQuery {
// 1. Bulk-update measured weights (single SQL)
val validated = request.items.map {
UUID.fromString(it.orderItemId) to it.actualQuantity // throws on bad UUID
}
val cases = validated.joinToString(" ") { (uuid, qty) -> "WHEN '$uuid' THEN $qty" }
val ids = validated.joinToString(",") { (uuid, _) -> "'$uuid'" }
TransactionManager.current().exec("""
UPDATE order_items
SET actual_quantity = CASE id $cases END
WHERE id IN ($ids)
""".trimIndent())
// 2. Fix the final price + transition to COMPLETED
Orders.update({ Orders.id eq UUID.fromString(id) }) {
it[totalPrice] = request.totalPrice.toBigDecimal()
it[status] = "COMPLETED"
it[completedAt] = LocalDateTime.now()
} > 0
}
}.getOrDefault(false)
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. Indexes follow the actual query patterns:
// models/Orders.kt
object Orders : UUIDTable("orders") {
// ...
init {
index(false, status, completedAt) // sales report: WHERE status='COMPLETED' AND ...
index(false, scheduledAt) // slot lookups · daily order list
index(false, customerId) // per-customer order history
}
}4. Caching Strategy
Product and category data is loaded into an in-memory cache (ConcurrentHashMap) at server startup to avoid repetitive JOINs on every order query. The key detail: soft-deleted products are loaded too — past orders still need their names.
// plugins/Cache.kt
object ProductCache {
private val cache = ConcurrentHashMap<String, ProductData>()
suspend fun reload() = dbQuery {
// includes soft-deleted products — needed for past-order lookups
val rows = (Products leftJoin ProductTranslations).selectAll().toList()
val newCache = rows
.groupBy { it[Products.id].value.toString() }
.mapValues { (_, productRows) -> /* build per-language translation map */ }
// ...
}
}The cache is refreshed whenever products or categories are created, updated, or deleted.
5. Dual-Path Push (Direct APNs + FCM)
FCM initially covered both platforms, but in production the iOS path moved to backend-signed direct APNs delivery: the server signs an ES256 JWT with the .p8 key and calls the APNs HTTP/2 API itself — no external SDK, just the JDK standard library.
// plugins/ApnsService.kt — provider token signing (cached 50 min)
private fun providerToken(): String {
cachedProviderToken?.let { if (it.expiresAt.isAfter(now.plusSeconds(60))) return it.token }
val header = """{"alg":"ES256","kid":"${keyId()}"}"""
val payload = """{"iss":"${teamId()}","iat":${now.epochSecond}}"""
val signingInput = "${base64Url(header)}.${base64Url(payload)}"
val token = "$signingInput.${base64Url(signEs256(signingInput.toByteArray()))}"
cachedProviderToken = CachedProviderToken(token, now.plusSeconds(50 * 60))
return token
}One trap along the way — Java's Signature emits DER-encoded signatures, but APNs (JWT) expects the JOSE format: r and s concatenated. The converter had to be handwritten:
// DER (SEQUENCE of two INTEGERs) → JOSE (r ‖ s, 32 bytes each)
private fun derToJose(der: ByteArray, outputLength: Int): ByteArray {
var offset = 2
val rLength = der[offset + 1].toInt()
val r = der.copyOfRange(offset + 2, offset + 2 + rLength)
offset += 2 + rLength
val sLength = der[offset + 1].toInt()
val s = der.copyOfRange(offset + 2, offset + 2 + sLength)
val jose = ByteArray(outputLength)
copyUnsigned(BigInteger(1, r).toByteArray(), jose, 0, outputLength / 2)
copyUnsigned(BigInteger(1, s).toByteArray(), jose, outputLength / 2, outputLength / 2)
return jose
}The APNs sandbox/production environment must match the build's signing. Tokens from Xcode development builds belong to sandbox; TestFlight/App Store tokens belong to production. Mismatch them and there's no loud error — just a quiet BadDeviceToken, and the notification evaporates. The fix: a single APNS_ENV variable switches the endpoint, and "check APNS_ENV against the build type" is now a permanent line in the deployment checklist.
Device tokens live in a push_device_tokens table, scoped by owner (customer/admin) and platform. Android stays on FCM, and the admin "new order" alert uses a topic (admin_new_orders), so no device-token bookkeeping is needed there.
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.
# deploy.sh — deployment, kept boring on purpose
rsync -avz --exclude 'backend/build' --exclude 'frontend' ./ "$SERVER:$REMOTE_DIR/"
ssh "$SERVER" "cd $REMOTE_DIR/infra && docker compose --profile production up -d --build"Prometheus scrapes backend, PostgreSQL, and host metrics; Grafana alerts arrive via Telegram. If something breaks at 3 a.m., my phone knows before Lake Como does.
Reflections
Building for a real user revealed many domain-specific complexities I hadn't anticipated: the weight finalization flow, cascading effects of soft deletes, the nuances of date-based queries — and push infrastructure that differs per platform.
This project taught me that building something that "works" is far easier than building something that "doesn't break."