学祭で自作のレジと注文管理システムを開発・運用した話
皆様はじめまして。
この記事は2人の大学2年生が完全自作のPOSレジと注文管理システムを約2ヶ月で開発し、学祭で本番運用した奮闘の記録です。
(春休みに入って時間ができたので、学祭から4ヶ月、ようやく投稿できました…)
開発者:
- 山口(X: @KaguraGateway)
- フロントエンド、バックエンド、インフラ担当
- なが(X: @naga_homelab)
- UI設計、フロントエンド、ネットワーク構築担当
1. 導入背景
2023年10月14日、15日に金沢工業大学で行われた「第56回工大祭」にて、弊サークル「CirKitプロジェクト」も豆、ブレンド、ドリップにこだわった本格コーヒーを提供する「カフェロゴス」を出店しました。
弊サークルは2022年に引き続き、学祭には2回目の出店です。
初回出店では、POSレジは既製品を使用し、注文管理は紙を用いて行っており、注文が入ったら注文を紙に書いてキッチンに渡すという流れで注文を管理していました。
しかしこれは完全に人間に依存したシステムで、重大な問題が発生しました。
- 注文が通ってない(注文を書いた紙をキッチンに渡し忘れる)
- 指定されたコーヒーのドリップ方法を間違える
3種類あるドリップ方法ごとに商品名を書いた紙を置く場所が決まっていたが、間違った場所に置く問題が多発した。 - 注文表示モニターがない&ドリップに時間がかかるため、本当に注文が通っているのかの確認や、まだ完成しないのかと聞かれることが発生
これに伴い、注文管理をシステム化しようという流れになったわけです。
当然既製品も検討しますが、2日間の学祭で利用するにはあまりにもお金がかかりすぎるため断念…
学習がてらPOSレジと注文管理システム、全部自分たちでつくっちゃおう!という流れで作成するに至ったわけです。
POSレジについても、キャッシュドロア・サーマルプリンター一体型のmPOPという機械から引換券を印刷したいこともあって、どうせならとこっちも内製することにしました。
2. システム概要
POSレジの「LogoREGI」、注文管理システムの「OrderLink」、ハンディ注文システムの「OrderLink Handy」の3つのシステムを作成しました。
全システムにおいて目的はただ1つ、「ロジカルなオペレーションをDXすることでスタッフへの負担を減らす」ことです。
2-1 POSレジの主な機能
POSレジ「LogoREGI」の主な機能は以下の7つです。
- 会計機能
- イートインの注文ロード機能(イートインは後払いのため、ハンディ端末で登録した注文をレジにロードできる必要がある)
- 引換券印刷機能(テイクアウト向け)
- 釣り銭準備金の入力・点検機能
- 商品追加・変更・削除機能
- 在庫管理機能
- 割引機能
実際のレジ画面(注文登録画面)
2-2 注文管理システムの主な機能
注文管理システム「OrderLink」の主な機能は以下の3つです。
- スタッフモニター: 受け渡し口・ウェイター用モニター
- 注文の品と個数、注文番号、イートイン・テイクアウトの表示
- テイクアウト・イートイン、商品ごとの絞り込み機能
- キッチンモニター: 調理者用モニター
- 調理する品を表示
- 商品ごとの絞り込み機能
- カスタマーモニター: テイクアウトのお客様用モニター
- 注文番号が表示され、調理中か提供中かを表示する
実際のスタッフモニター
2-3 ハンディ注文システムの主な機能
イートイン向けのシステムで、店内のお客様から注文を聞き取り、ウェイターがスマホやタブレットから注文を送信してキッチンに注文が飛ぶシステムです。
機能はただ1つで、
- 座席番号を入力して注文を送信する
だけです。
実際のハンディ画面
3. 使用技術
バックエンドはGo、フロントエンドは SwiftUI と Next.js で開発しました。
バックエンドのアーキテクチャについては ドメイン駆動設計 と オニオンアーキテクチャを採用しました。
データベースとしてはPostgreSQLを使用しています。
UIモックについては Figma で作成しています。
デプロイ先はGCPのCloud Runを一貫して利用しています。
- POSレジ
- フロントエンド(会計用): SwiftUI
- フロントエンド(管理用): Next.js x PandaCSS
- バックエンド: Golang
- 注文管理システム
- フロントエンド: Next.js x ChakraUI
- バックエンド: Golang
- ハンディ注文
- フロントエンド: Next.js x ChakraUI
- バックエンド: Golang(POSレジのバックエンドと共通)
3-1 採用理由
SwiftUI(POS会計フロントエンド)
PWAで運用する案もありましたが、mPOPというキャッシュドロア・サーマルプリンターのSDKがSwift(iOS)
かKotlin(Android)
、React Native(iOS, Android, Windows)
もしくは.NET(Windows)
のみで、どうせなら触ったことない新技術を学ぼうということで SwiftUI を選びました。
新技術を触りたい以外にも選定理由が3点あります。
- 開発メンバーのどちらもiPadを所有している上、サークルメンバーも多数iPadを所有していることからiPadの調達が一番楽であること
- iPadのOSとしての安定度や完成度が高く、本番運用中の障害リスクを減らせること
- 開発メンバー2名のうち、どちらもMacを持っていたので、iOSネイティブアプリ開発の障壁が少なかったこと
Next.js(POS管理・注文管理・ハンディ注文フロントエンド)
POSレジの管理画面について、なぜこちらもSwiftUIのアプリの方に統合しなかったのか?と思うかもしれません。商品の追加などはWebからやれたほうが利便性が高いだろうということで管理者画面は Next.js で開発することにしました。選定理由は、自分が一番分かるフレームワークであるためです。SwiftUIだけで手一杯なので、Webフロントエンドまで新規技術を広げすぎるのは無理との考えです。
PandaCSS(POS管理フロントエンド)
Panda CSSはZero RuntimeなCSS in JSフレームワークです。
POS管理フロントエンドはスマホやタブレットではなく、基本的にPC上のブラウザから閲覧・操作することを想定していたため、冒険しても大丈夫だろうと思い、以前から気になっていた技術を採用してみました。
Zero RuntimeでChakraUIのカスタマイズみたいな書き方で、書き馴染みがありすぐに使い慣れることができました。個人的にはTailwind CSSやCSS Modulesよりも扱いやすくて好きでした。
ChakraUI(注文管理・ハンディ注文フロントエンド)
ChakraUIはUIライブラリの1種です。これの採用理由としては、FigmaのUI KitがありUIデザインがしやすく、使い慣れていること、多種多様なデバイスが入り混じる環境でも動いてくれることでした。
注文管理用のデバイスとして、サークル所有のiPad mini 2を利用したかったためにPandaCSSは避けました(iPad mini2だとPandaCSSは動かないため)。
パッと見た目をいい感じに整えたいときには、このような初めからスタイルが当たっているコンポーネントUIライブラリの利用が適していると改めて感じました。デザインが上がってきてから、わずか1日程度でモックの実装を完了でき、時間がない今回のようなケースでは大変助かりました。
Golang(バックエンド全般)
バックエンドすべてGolangで開発しました。
選定した理由は3つです。
- 静的型付け言語であること
- 初めて触る言語であること
- とはいえ時間がないため、ある程度学習コストが低いこと
初めて触りましたが、Goは書きやすいと思います。
ただ言語機能が弱いため、読みやすいコードを書こうと思うとライブラリありきになってしまうなと感じました。またドメイン駆動設計とオニオンアーキテクチャを採用したのですが、Goのパッケージ仕様と相性が悪く感じました。(パッケージの切り方が悪かっただけかもしれないです)
gRPC
フロントエンドや他のバックエンドとの通信には一貫し、gRPCを使用しています。
もうちょっと具体的に言うと、フロントエンドとは connect-web
で通信しています。
Protocol Buffersを使用して強力なスキーマ定義をできること、関数を呼び出すかのようにAPIを利用できること、フロントエンド<->バックエンド間でgRPCを採用したことがなかったので採用してみたかったことが採用理由です。
バックエンド間の通信は今後もgRPCを利用したいと思うほど使い勝手は良かったです。
ただフロントエンドについてはJSONでやり取りしたほうが都合がいいかも、と思いました。バックエンド間通信のコードをまんま使えるので、場面によっては生きそうですが今回はデメリットのほうが目立った感じでした。
(通信がすべてPOST通信になるのでキャッシュが効かない、Protocol Buffersの定義を取り回すのに苦労した、connect-webのライブラリの導入がうまくいかなかった等)
Bun(ORM)
Golangで実装するバックエンドのORMには bun を採用しました。
ドキュメントが充実している、オートジェネレート不要なこと、速度が速いこと、機能がある程度絞られていることが魅力的でした。また 国内のGolangで有名な方がオススメしていたこともあり、採用にいたりました。
4. システム全体の流れ
4-1 (前段階)商品の追加
POSレジの管理画面から商品を追加します。
コーヒーは淹れ方ごとに商品価格が異なる可能性があったため、淹れ方ごとに商品価格を設定できるようにしています。
4-2 注文・イートインの場合
イートインの場合は後払いで、ハンディ注文端末を持った店員が注文を聞き、ハンディ端末から注文を送信するスタイルとなります。
- 注文元のテーブルを選びます。
- 注文をお客様から聞いて、注文を入力します。
- コーヒーの場合は淹れ方が3種類あるので、淹れ方を選ぶモーダルを出して淹れ方を指定してもらいます。
- 注文を確認した後、注文を送信
- 注文を送信すると、キッチンにおいてある注文管理モニターに注文が飛んできます。調理を開始するときに、「調理開始」、調理が完了したときに「調理完了」を押してもらいます。
- 調理が完了したら、ホールスタッフがテーブルまで品を運ぶ。運んだら「提供完了」を押してもらう。
- お会計時はPOSレジアプリでテーブル番号を入力すると、そのテーブルのお会計ができるようになっている
テーブルを選択すると、未会計の注文を取得してくる
4-3 注文・テイクアウトの場合
テイクアウトの場合は先払いで、レジで注文を入力します。
- 注文を聞いて、レジスタッフが注文をレジに入力してもらう。こちらも淹れ方の選択画面があります。
淹れ方を入力するモーダル - 注文を確認して、お客様にお支払いしてもらう。お預かり額を入力し、会計するボタンを押すと、キッチンに注文が飛ぶようになっている。(注文管理部分は被るため省略)
5. 開発で詰まったところ
5-1 古い端末とWebKit
今回、キッチンで注文管理をするデバイスとして、サークルで多数保有している「iPad mini2 (2013発売)」を利用することにしたのですが、iOS12とOSが古いため問題が生じました。
- Flexboxのgapプロパティが使えず、全てmarginプロパティで対応しなければならない
- ブラウザデフォルトのスタイルであるUserAgentStylesheetの影響でPC版ChromeとSafariでスタイルが違うため、実機テスト時にレイアウトが崩れいていた
iOSとWebKitは分離して欲しいと非常に思いました。(そもそもそんな古いデバイス使うなって話ですが、大量に新しいタブレットを用意するのは難しかったです)
5-2 新技術を使いすぎた
新しい技術を積極的に導入したこともあり、言語やライブラリの細かい挙動を把握していないことによる不具合やエラーが発生し、それの対処に戸惑いました。
特にGolangでOptional型を表現するときにNULLとポイントの仕様が少し特殊で詰まりました。
5-3 アーキテクチャの迷い
今回この規模のアプリケーションで初めてゼロからドメイン駆動設計とオニオンアーキテクチャを採用しました。そのため、どこに何を置くのか、そもそもドメインとは何なのか、どこまでをドメインとするか、トランザクション処理はどうするのかなど様々なポイントで迷いました。
最終的には一度開発を止めてドメイン駆動設計とは何なのか、オニオンアーキテクチャとは何なのかを自分の中で納得がいくまで本を読むことと、インターネット上の記事を多数読むことで解決しました。概念的には理解しているつもりでしたが、実際に0からドメイン定義して開発するというのは全く別の難しさがありました。
5-4 SwiftUIでボタンがすべて反応する
本来ボタンというのは独立しており、1つのボタンを押したからといって全てのボタンが押された判定になることはないはずです。しかし、なぜかあるグループのボタンすべてが押された判定になってしまい大変困りました。
そんなときに次の記事に出会い、無事に解決できました。本当にありがとうございました。
5-4 Cloud Runへのデプロイ
Next.jsを使ったフロントエンドについては当初はランタイムとしてNode.jsではなく、Bunを利用していました。しかし、Cloud Runにデプロイする際に問題が発生しました。
通信に利用しているgRPC周りの定義ファイルや生成ファイルを別リポジトリで管理していたため、そのリポジトリを依存関係として追加していました。
開発環境下では問題なかったのですが、Cloud Runのビルド環境下ではその依存関係を解決できずビルドに失敗するという問題が発生しました。
デプロイにたどり着いた時点で、すでに本番当日を迎えていたため時間がなく、解決するのを諦めてNode.jsを利用することにしました。
6. 本番当日
6-1 1日目
デプロイが完了していない焦りとほとんど寝ていない状況で当日の朝を向けてしまい、かなり絶望していました。しかしなんとか営業開始ギリギリでデプロイと調整が完了し、無事に1日目をスタートしました!
不具合:コーヒーが全て同じ種類になってしまう
営業開始すぐに問題が発生しました。
今回のカフェでは2種類のコーヒーブレンドを用意しており、「ロゴスブレンド」と「茜ブレンド」の2種類がありました。しかし、どちらを選択しても注文が「ロゴスブレンド」になってしまうという不具合が発生し、注文時にどちらのブレンドなのかを大声で叫んでもらうという事件が発生しました。
問題発覚後すぐに対応にあたり、すぐに解決することができました。
これはコーヒーの淹れ方を指定するモーダルの制御が間違っていたことが原因でした。
sheet
を使用したのですが、それの書く場所が1行ズレていたという単純なミスによって、重大な問題になってしまい痛恨の極みでした。
不具合:注文管理システムで一部のドリップ方法が 'undefined' になる
前述の通り、ブレンドは「ロゴスブレンド」と「茜ブレンド」の2種類があります。
しかし、片方の「茜ブレンド」が注文された際にキッチンの注文管理システムでドリップ方法が 'undefined'になってしまう不具合が発生しました。
各商品の各ドリップ方法ごとに固有のIDが振られてます。しかし、以下のコードのように商品配列の先頭に入っている商品、つまりは「ロゴスブレンド」のブレンド方法配列に対してfind
した結果を返すようになっており、「茜ブレンド」の方を見ていなかったのです。そのため、茜ブレンドを注文するとドリップ方法が見つからず undefined
になるといった具合です。これもすぐに直しました。
- return product.coffeeBrews.find((brew) => brew.id === coffeeBrew);
+ if (product.coffeeBrews.find((brew) => brew.id === coffeeBrew) != null) {
+ return product.coffeeBrews.find((brew) => brew.id === coffeeBrew);
+ }
不具合:同じブレンドでドリップ方法が異なる注文を受けると片方が消える
たとえば、2人でご来店された方に、「ロゴスブレンド」のドリップ方法「ネル」1つと「ロゴスブレンド」の「ペーパー」1つといった注文がされた場合に片方の注文が消えて、「ロゴスブレンド」の「ネル」1つだけといった注文になります。イートインの方が会計する際に会計が合わない事象が発生しました。テイクアウトの場合、先払いのため処理が異なることもありこの事象は発生しなかったのです。
これの解決には時間を要しました。ややこしかったのが、注文管理システム上ではちゃんと本来の注文通り、注文が存在したからです。そのためコーヒーの提供自体は正しく提供が行われる上、大抵の場合同じブレンドで異なるドリップ方法を注文されること自体が少なかったため、事象の発覚にまず時間がかかり、同様のケースが少なかったため調査に時間がかかりました。
原因としてはPOSレジのテーブル設計が誤っていたのが原因でした。当初、注文された商品を保存するテーブル「order_items」の主キーは「注文ID」と「商品ID」のみで、ドリップ方法IDを含んでいなかったのです。
そのため、1つの注文(=同じ注文ID)で同じブレンド(=同じ商品ID)を異なるドリップ方法で注文されると、片方INSERTされないことになるわけです。普通は主キーが重複するとエラーになるはずですが、DDDの永続化指向リポジトリパターンを採用していたために、save
という関数名にしており、それに引きずられてINSERT SQLに「ON CONFLICT DO NOTHING句」をつけていたためエラーにならなかったということでした。
ドリップ方法IDを主キーに加えた上で、「NOTHING句」を消すことで解決しました。
ヒューマンエラー:お預かり金額を間違う
自動釣り銭機を導入するわけにもいかないので、お客さんがいくら出したのかは人間の手で数える必要があります。お預かり額の認識を誤ったり、入力を間違う問題はやはり発生しました。
金額を間違うのは仕様策定時で想定していましたが、実装時間が足りず実装してなかったので本番環境に接続してSQL文を直書きして実行することで辻褄合わせをしました。
ヒューマンエラー:イートインの座席指定を間違う
当初、イートイン用のハンディ注文システムの座席指定方法は、座席番号をキーボードで入力する方法でしたが、入力ミスが多発しました。
そのため、1日目が終わった段階でボタンで選択するように改修しました。
結果、入力ミスは0になりました。
想定外:座席を移動される
仕様策定時で全く想定していなかったことなのですが、イートインご利用のお客様で座席移動をしたいという方がいらっしゃいました。当然、想定していないのでシステム的に対応できるものではなかったので、手書きのメモに座席移動のことを記入し情報共有を行うというヒューマンパワーで対応しました。
想定外:別会計
筆者は基本的に別会計ではなく、自分がまとめて支払ってあとから徴収する派なので、別会計という概念を完全に忘れていました。学祭ということもあり、グループでいらっしゃるお客様が多く、別会計したい人は多くいました。
どうしようもないので、誰か1人にまとめて払ってもらうようお願いすることで対応しましたが次回使用時には対応させておきたいですね。
ただ現状のドメインが会計:注文=1:nの構造になっているため、改修するのは骨が折れます。
(1:nなのはイートイン時の追加注文に対応するためです。)
各会計を新規注文扱いするのが一番楽ですが、注文管理システムに通知しないようにする改修が必要だったり、注文時間が変わってしまうので統計データとして不適切になってしまう問題だったりとこちらも一筋縄ではいかないです。
その他:注文管理システムの反映が遅い
今回、デプロイ先としてCloudRunを使っているため、WebSocketの接続先コンテナが同一である保証がありません(ベストエフォート)。そのため、コンテナ間のデータ同期のためにPub/Subを用いています。
しかし、このPub/Subのパブリッシュが遅く、端末で操作してから操作が反映されるまで1秒〜3秒程度遅延する事象がそこそこの頻度で発生しました。
当初はこのPub/SubにRedis Pub/SubもしくはGCPのCloud Pub/Subを用いる予定でしたが、予算の関係でPostgreSQLのNOTIFY/LISTEN機能を使ってみました。それが仇になったと推察しています。
というのもPostgreSQLのNOTIFY/LISTEN機能は、トランザクションとトランザクションの合間にのみ配送され、トランザクション処理中は配送されないらしい。
これに気づいたのは2日目だったため、解決することはできませんでした。
6-2 2日目
1日目でもあった想定外の対応できない問題以外は安定して動作してくれました。
不具合は1日目の早いうちに発見しきれてたようで、終わったときは安心感がすごかったです。
7. 反省点
悪かった点
- 仕様策定段階でもっとレビューを受けるべきだった。
- 1人には見てもらってましたが、レビューがまず足りなかったと感じてます。
- もっと複数人に見てもらっていれば想定外の2つの事態は想定できていたのではないか、と考えてます。
- ドメイン設計が拙かった
- ドメイン設計が下手だったため、ドメインの取り回しが大変でした。(DBからの取得が特に)
- DDDを採用した初の大規模アプリケーションということもあり、勝手がわからなかったのでこれは次に活かしたいと思います。
- テスト・デバッグが足りなさすぎた
- 今回本番で発生した不具合はどれもテストをしていれば発見できた不具合でした。
- そもそも実装に本格的に取り掛かり始めたのが9月というのは規模に対して遅すぎました。おかげで営業開始ギリギリまでデプロイと戦うためになりました。
良かった点
- 間に合ったこと。動いたこと
- 正直間に合わないと思いましたし、動かないと思いました。
- なんとか間に合って、無事動いたときは本当に嬉しかったです。
- 技術的挑戦ができたこと
- SwiftUI、Golang、gRPC、PandaCSS、PostgreSQL NOTIFY/LISTENと触ったことのない技術スタックに触れられた良い経験でした。
- 大規模アプリケーションに挑戦できたこと
- ここまで大規模で、かつ実際に多くの人に使ってもらう経験はこれが初めてでした。
- 大規模だからこそ考えないといけないこと、マイクロサービスだから考えないといけないことが多く、実務でも役に立ちそうな知識をつけることができました。
- 前年の学祭営業で発生していた問題を解決できたこと
- システム化することでオペレーションの負担を減らすことができました。
- このシステムが「なくてはならない」と言われた時は、やってよかったとすごく思えました。
- またヒューマンパワーに頼る率が減らせたことで、より美味しいコーヒーを淹れるということに注力できたと思います。
8. 最後に
構想をはじめてから6ヶ月、無事にプロジェクトを完遂することができました。
終わった時の達成感が今まででもっともすごかったです。
当日、運用に協力いただいた全ての皆様に感謝です。本当にありがとうございました!
そして次の運用ではもっとパワーアップして、もっと使いやすくして万全を期して運用できたらな、と思います。
最後までお読みいただき、ありがとうございました。
Discussion