AI時代のPdMの武器は「土壌」にある。BUYMAの歴史をレバレッジに、高速で仮説を形にする方法

はじめに

KNと申します。 2025年2月に株式会社エニグモに入社し、プロダクトマネージャー(PdM)として約1年が経過しました。

前職では新卒でWeb系企業にエンジニアとして入社し、3年間従事しました。 文系出身ながらAWSでのインフラ構築・メンテナンスからバックエンド・フロントエンドの開発まで、幅広く経験しました。

その後、社内転職でPdMへとキャリアをシフトし、フィンテックサービスのグロースを担当していました。

私がエニグモへの転職を決めたのは、20年続く「BUYMA」というプロダクトが持つ圧倒的な蓄積に惹かれたからです。

しかし同時に、「歴史があるがゆえに、動きが遅く、部分最適の調整に追われるのではないか」という懸念もありました。

結果として、この1年間で得られたものは予想を遥かに超えるものでした。 この記事では、エニグモで経験した学びと、20年続くプロダクトの「厚み」がもたらす価値について記します。

自身の業務領域

BUYMAは世界180カ国、22.5万人以上のパーソナルショッパー(出品者)が支える、唯一無二の「お買い物代行」プラットフォームです。

現在、私はBUYMA「出品者領域(SELL)」と、決済・配送・基盤を支える「サービスインフラ(SI)」の2つを横断して担当しています。

  • SELL領域: 出品者がいかにストレスなく、質の高い出品を行えるか
  • SI領域: 配送、決済、CS、経理。取引の全工程を支える「心臓部」

この2つを同時に見ることは、一見すると負荷が高いように思えます。

しかし、「出品の仕様変更が、数カ月後の経理処理やCSの問い合わせにどう影響するか」を予見しながら動く経験は、プロダクトを「機能の集合体(点)」ではなく「エコシステム(面)」として捉える視座を私に与えてくれました。

エニグモの組織図

入社の決め手:20年の蓄積がもたらす土壌

転職活動をしていた当時、私が最も重視していたのは「自身の仮説構築や施策立案の精度を向上させること」でした。

前職の新規事業では、スピード感を持って施策を回していましたが、比較対象となる過去データが少なく、「打席には立つが、なぜ当たった(外れた)かの深い洞察」が不足している感覚がありました。

2005年から続くBUYMAには、膨大な成功と、それを上回る「失敗の経験」があるのではないかと仮定していました。それは、自身が望む次の仮説を研ぎ澄ませるために非常に魅力的な場所だと感じました。

あえて歴史のある環境に身を置くことで、中長期的な時間軸での「判断の軸」を手に入れ、今後どのようなフェーズのプロダクトでも通用するPdMになりたいと考えたのが、入社の最大の理由です。

www.buyma.com

入社後の学び

①バランス感覚が求められる

エニグモで最も鍛えられたのは、複数の視点を同時に持ち、全体最適を追求するバランス感覚です。

入社後に手がけた印象的なプロジェクトに、経理が企画を行った出品者に関連する機能開発がありました。 このプロジェクトは、経理、カスタマーサポート、出品者側のマーケティング、エンジニア、デザイナーという多様な職種が集まったチームで進行しました。

プロジェクトの仕様決めの場面で、私は初めて「歴史の重さ」を実感しました。 経理上の運用フローもあり、社内ニーズとユーザーニーズを調和させた運用となっていました。

その上で、機能開発という観点から出品者にとっての使いやすさ(ユーザービリティ)も確保しなければなりません。 さらに、問い合わせが発生した際のカスタマーサポートの対応負荷も事前に検討しておく必要がありました。

このように、1つの意思決定が複数の部署に影響を及ぼします。 そして、20年の歴史があるプロダクトでは、1つのルールを変更するだけでも、システム的にもビジネス的にも背景が膨大にあるため、定点を見て結論を出すことができません。

エニグモでは「出品者・購入者・プラットフォーマーとしてのルール作り・ルールを維持するための運用的可能性」という多面的な視点を同時に持つ必要があります。 この多面的なバランス感覚こそ、今後どのようなプロダクトに関わっても活かせる、PdMとしての生存戦略の核だと感じています。

②組織のノウハウに対するレバレッジ

エニグモには、社歴が長い人が多く在籍しています。 先輩方はBUYMAの歴史を肌で知り、過去の成功と失敗を体験してきています。 ※平均勤続年数は6.3年(2025年10月時点)

入社当初、私は自分がまだ知らない領域について不安を感じていました。 しかし、実際に働いてみて気づいたのは、「自分がすべてを知っている必要はない」ということでした。 重要なのは、知識を持っている人が何を気にしていて、どのようなデータがあり、組織としてどこでバランスを取るべきかを考え、意思決定に繋げることです。

多くのプロジェクトでは、過去の事例やデータが膨大にあるため、各部署の担当者の背景理解や考慮すべき点の想定を事前から広く取ることが可能です。

例えば、カスタマーサポートのメンバーに相談すると、「過去に類似の仕様変更を実施した際、こういった問い合わせが急増した」という実例を教えてくれます。 経理企画のメンバーに相談すると、「この処理フローは○年前にこういう理由で導入された」という背景を共有してくれます。

また、過去の意思決定に関するドキュメントが残っているため、ノウハウの探索がしやすいのも大きな利点です。 考慮しなければならない箇所や、とある対応策を取ろうとした時のメリット・デメリットの整理がしやすくなります。

これらの知見は、組織に蓄積された「ノウハウ」です。 エニグモでは「誰に聞けば良いか」「どのデータを見れば良いか」「過去のどのドキュメントを参照すれば良いか」を知っていれば、圧倒的に速く、精度の高い意思決定ができます。

そして、PdMの役割は、そのノウハウをレバレッジとして活用し、最適な意思決定を導くことです。 この組織知へのアクセス能力は、AIが進化しても決して代替されない、人間ならではの強みだと考えています。

③意思決定の質とスピード

エニグモでは、開発案件に応じてスクラムアジャイルを使い分けながら開発を進めています。 サービスインフラ(SI)領域を例にとる、ビジネスサイドは10〜15名程度、エンジニアが5〜8名程度、PdMが2名程度で進行しており、密に連携しながら施策を推進します。

驚いたのは、意思決定のスピードと質の両立です。 前職では、スピード重視で施策を回していましたが、データが不足しているために「やってみなければわからない」という状況が多くありました。 一方、エニグモでは20年分のデータとノウハウがあるため、「過去の類似施策ではこうだった」「このセグメントのユーザーはこう動く」という根拠に基づいた意思決定ができます。 また、AI活用も積極的に進められています。例えば、BUYMAには「AIでさがす」機能があり、Vertex AI SearchやGeminiを活用し、よりBUYMAらしい商品提案が可能になりました。

『「AIでさがす」サービスのリニューアル』について

このように、歴史という「深い土壌」とAIという「速い道具」が揃っていることで、意思決定の質とスピードが同時に高まっています。 AIによって解決できる課題の量と幅は拡張されていますが、「何を解くべきか」を判断するのは人間の役割です。 そして、その判断の精度を高めるのが、プロダクトの厚みから得られる「物事の見方」と「意思決定プロセスの判断軸」です。

歴史の重さと向き合う難しさ

ここまでポジティブな面を中心に書いてきましたが、正直に言えば、苦労したポイントもあります。

各部署との調整と、歴史の重さを考慮した意思決定は、想像以上に難しいものでした。 1つのルールを変更するにしても、その変更がシステム的にもビジネス的にも背景が膨大にあるため、定点を見て結論を出すことができません。

複数の部署の意見を聞き、過去のドキュメントを読み込み、データを分析し、そして全体最適を追求する。 このプロセスは、スピード重視の新規事業とは異なる難しさがあります。

しかし、この「難しさ」こそが、PdMとしての成長を促してくれていると感じています。 なぜなら、今後どのようなプロダクトに関わっても活きる「面と深さを考えながらプロダクトを進行する」という実践知を経験できているからです。

今後やっていきたいこと

この1年間で、私はプロダクトを「点」ではなく「面」で捉える視座を手に入れました。

SELL領域とSI領域を横断して担当することで、出品者の体験、購入者の体験、そしてそれらを支える基盤(経理・配送・決済)、すべてが繋がっていることを実感しています。

しかし、まだ「面」のすべてを理解しているわけではありません。 今後は、一部門だけでなく、ユーザー体験全体を「面」で捉え、形にしていくことに挑戦したいと考えています。

エニグモには、歴史という「深い土壌」と、AIという「速い道具」が揃っています。 この環境だからこそ、幅広く、深く、そして「形」にして、面としてのプロダクト磨きに取り組めると確信しています。

BUYMAは20周年を迎え、さらなる進化を続けています。私自身も、この蓄積の中で、PdMとしての生存戦略を磨き続けていきたいと思います。

エニグモで働く魅力

最後に、エニグモで働く魅力をまとめます。

プロダクト・人材面でしっかりとした土壌がある

20年分のデータとノウハウ、そして社歴の長いメンバーが持つ知見。これらは、新規事業では決して得られない「厚み」です。 過去の意思決定に関するドキュメントが残っているため、ノウハウの探索がしやすく、考慮すべき点の整理が圧倒的に速くなります。

幅広く・高速にチャレンジができる

AI活用やアジャイル開発により、意思決定のスピードと質が両立されています。 若手でも裁量を持ってプロジェクトを推進できる環境です。 「AIでさがす」機能や類似画像検索の内製化など、最先端の技術を活用した施策に取り組めます。

バランス感覚が磨かれる

経理・出品者・カスタマーサポート・プロダクト全体という多面的な視点を持つことで、どのプロダクトにも通用する「究極のバランス感覚」が身につきます。

 「専門性か、汎用性か」で迷うあなたへ。

エニグモには、専門性を深めながら、汎用性も高められる環境があります。 AIに奪われない組織知をレバレッジとして活用し、プロダクト開発を通じて自己成長できる場所です。 もし、あなたがキャリア形成に不安を感じているなら、エニグモという「最強の土壌」で、一緒にプロダクトを磨いていきませんか。

株式会社エニグモ すべての求人一覧 hrmos.co

【BigQuery】過去データの再作成が超絶楽になる!ループ処理でシャーディングテーブルを一気に作成する方法

こんにちは、エニグモの嘉松です。普段はデータ活用推進室にて、データ分析・データ活用の推進やMAツールを用いたCRM施策などを担当しています。

本記事はEnigmo Advent Calendar 2025の最終日(25日目)の記事です。1ヶ月間にわたり様々なテーマで繋いできたバトンも、いよいよ今回が最終回となります!

最終回は、データ分析・データ活用の裏側を支える技術にフォーカスし、BigQueryに関する(少しディープな)知見を共有します。

時点データとは?

データ分析において、現時点のデータだけでなく「過去のある時点」のデータを保持しておくことは極めて重要です。例えば、ユーザーの注文回数、会員ランク、保有ポイント数、メール購読の有無などが挙げられます。

これらの時点データを毎月1日などのタイミングでスナップショットとして蓄積しておくことで、「過去と現在の比較」や「特定の期間における推移」といった分析が容易になり、分析の幅は劇的に広がります。

しかし、過去に遡ってこれらのデータを作成しようとすると、なかなかの手間が発生します。例えば月次データを5年分作成する場合では60回のクエリ実行が必要となります。

そこで今回は、BigQueryの手続き型言語(Procedural language)を使い、ループ処理で過去分のシャーディングテーブルを一気に作成する方法をご紹介します。

BigQueryのシャーディングテーブルとは?

table_YYYYMMDD という命名規則に基づき、物理的にテーブルを分割して管理する手法です。 例えば、user_summary_20251201 のようにテーブル名の末尾に日付を付与します。

シャーディングを行うことで、必要な期間のデータだけをスキャン対象にできるため、処理に必要なデータ量およびクエリ費用を大幅に抑えることが可能です。

シャーディングテーブル作成の処理フロー

今回の処理の流れは以下の通りです。

  1. 指定した「開始年月」から「現在」まで、1ヶ月ごとにループさせる。
  2. 各月ごとに集計クエリを実行し、table_YYYYMMDD 形式のテーブルを作成(または置換)する。
  3. 処理対象が現在を超えたらループを終了する。
START_MONTH (2022-01-01)
    ↓
[ LOOP開始 ]
    ↓
1回目: 対象 2022-01-01 → CREATE TABLE dataset.table_20220101
2回目: 対象 2022-02-01 → CREATE TABLE dataset.table_20220201
    ...
終了: 対象が「今月」を超えたら LEAVE

サンプルコード

以下は、ループ処理を用いて過去テーブルを作成するスクリプトです。

-- 1. 変数の宣言と初期化
DECLARE START_MONTH DATE DEFAULT DATE '2022-01-01'; -- 開始日を指定
DECLARE CURRENT_MONTH DATE;
DECLARE yyyymmdd STRING;
DECLARE LOOP_CNT INT64 DEFAULT 0;

-- 2. ループ処理の開始
LOOP
  -- 処理対象年月をセット(開始月からLOOP_CNT分だけ月を加算)
  SET CURRENT_MONTH = DATE_ADD(START_MONTH, INTERVAL LOOP_CNT MONTH);

  -- 3. 終了判定:処理対象年月が「今月」を超えたらループを抜ける
  IF CURRENT_MONTH > DATE_TRUNC(CURRENT_DATE('Asia/Tokyo'), MONTH) THEN
    LEAVE;
  END IF;

  -- テーブル接尾辞用にYYYYMMDD形式の文字列を作成
  SET yyyymmdd = FORMAT_DATE("%Y%m%d", CURRENT_MONTH);
  
  -- 4. 動的SQLの生成と実行
  -- EXECUTE IMMEDIATE FORMAT() でSQLを動的に組み立てて実行します
  EXECUTE IMMEDIATE FORMAT("""
    -- ここに実行したいDDL(テーブル作成)を記述
    CREATE OR REPLACE TABLE `your-project.your_dataset.user_summary_%s` AS
    SELECT
      user_id,
      -- 注文回数
      count(*) as purchase_count
    FROM
      `your-project.source_dataset.transactions`
    WHERE
      -- 基準日(CURRENT_MONTH)以前の注文データに絞り込み
      DATE(created_at, 'Asia/Tokyo') < '%s'
    GROUP BY
      1
  """, yyyymmdd, CAST(CURRENT_MONTH AS STRING));

  -- 5. カウンタを進める
  SET LOOP_CNT = LOOP_CNT + 1;

END LOOP;

サンプルコードの解説

実装のポイントは以下の3点です。

1. LOOPLEAVE による制御

BigQueryの手続き型言語には FOR 文もありますが、日付を柔軟に加算しながら処理したい場合は LOOP が適しています。無限ループを防ぐため、必ず IF ... THEN LEAVE; END IF; による脱出条件を記述しましょう。今回は DATE_TRUNC を使い、実行時の年月を超えた時点で停止するように設定しています。

2. EXECUTE IMMEDIATE による動的SQLの実行

通常のSQL文には変数を直接埋め込むことができない箇所(テーブル名など)があります。そのため、クエリ全体を文字列として組み立てて実行する EXECUTE IMMEDIATE を使用します。 FORMAT() 関数を用いると、%s を使って変数値を流し込めるため、文字列結合(||)を繰り返すよりも可読性が高く、メンテナンスもしやすくなります。

3. 文字列のクォート扱いに注意

ここが最も重要なポイントです。動的SQLの中で日付をリテラルとして扱いたい場合、%s の周りをシングルクォートで囲む必要があります。

  • NG: DATE(created_at, 'Asia/Tokyo') < %s
  • 展開後: ... < 2022-01-01 (数値の引き算として処理されてしまう)

  • OK: DATE(created_at, 'Asia/Tokyo') < '%s'

  • 展開後: ... < '2022-01-01' (正しい日付文字列として認識される)

ループ処理活用のススメ

今回はシャーディングテーブルの作成を例に挙げましたが、このループ処理のテクニックは「API制限を回避するために1日ずつ処理する」「リソース枯渇を避けるために重たいクエリを分割実行する」といったシーンでも非常に有効です。

手作業による「温かみのある運用」から卒業し、スマートで快適なデータ基盤ライフを送りましょう!

25日間の感謝を込めて

これにて Enigmo Advent Calendar 2025 は全25記事のバトンが繋がり、無事完走となります!

今年は様々な職域のメンバーが、それぞれの視点から技術や知見を共有してくれました。これらの記事が、皆様の日々の業務や課題解決のヒントとなれば望外の喜びです。

来たる2026年も、エニグモBUYMAをはじめとするサービスを通じて新しい価値を創造してまいります。どうぞよろしくお願いいたします。


株式会社エニグモ すべての求人一覧 hrmos.co

Ruby on Rails アプリのパフォーマンス最適化10選

こんにちは!Webアプリケーションエンジニアのレミーです!

この記事はEnigmo Advent Calendar 2025の24日目の記事です。

Ruby on Rails アプリが遅いと感じるのは、ほぼ次の3の原因になります。

  1. DBクエリが多すぎる(特に N+1、COUNT/EXISTS の使い分けミス、インデックス不足)
  2. 不要なデータを読み込みすぎる(テーブル全て/重いカラム全て取得、あるいは全部を RAM に書き込む)
  3. ビューのレンダリング/コールバックが働きすぎる(partial の多用、重いフォーマット処理、不要なコールバック/バリデーションの実行)

この記事では、効果が見えやすいものに絞って、自分が特によく使う最適化10個をまとめます。

1. includes で N+1 クエリを防ぐ

問題: posts の一覧を取得して、view 側で post.user.namepost.comments.size のように関連を参照すると、20件なら20回(あるいはそれ以上)追加クエリが飛ぶ可能性があります。

解決: includes で関連を事前ロードします。

改善前: N+1 が発生

@posts = Post.order(created_at: :desc).limit(20)

@posts.each do |post|
  post.user.name # 毎回 SELECT users... WHERE id = ? が走る可能性
end

改善後: includes を使用

@posts = Post.includes(:user).order(created_at: :desc).limit(20)

Railsposts に紐づく users を1回のクエリでまとめてロードし、ループ内での追加クエリを防ぎます。

一覧をレンダリングして、ループ内で association を参照する(post.commentspost.userorder.items など)場合ではよく使われます。

重要ポイント: includes には 3 パターンがあり、Rails が状況に応じて選びます。

  • preload: 常に 2 クエリ(postsとusers)
  • eager_load: 常に LEFT OUTER JOIN(大きい 1 クエリ)
  • includes: Rails が自動判断(preload になる場合も join になる場合もある)

2. 必要なカラムだけ取る: select / pluck

問題: User.allUser.where(...).to_a全カラムを引いてきます。bio (text)settings (jsonb)avatar_data のような重いカラムも含まれがちです。実際には idname だけで十分なケースも多いはずです。

解決:

ActiveRecord オブジェクトは欲しい(でも最小限にしたい)

select を使います。

users = User.where(active: true).select(:id, :name)
users.first.name # OK

値の配列だけで十分(高速 + allocations 少なめ)

pluck を使います。

ids   = User.where(active: true).pluck(:id)
pairs = User.where(active: true).pluck(:id, :name) # [[1, "A"], [2, "B"]]

DB処理時間の短縮、返ってくるデータ量の削減、Ruby 側の allocations 削減。

dropdown/select box で idname だけが必要な時とか、バックグラウンドジョブで処理対象 id だけが必要な時などが使われます。

3. 存在するかどうかの確認なら exists? を使う

問題: relation に対して any? / present? で存在チェックをすると、不要にレコードを読み込んだり、最適でないクエリになったりすることがあります。

解決: exists? は EXISTS を使うため、目的に対して効率的になりやすいです。

改善前: 不要なロードが起こり得る

User.where(email: email).any?

改善後: EXISTS を使う

User.exists?(email: email)

メール重複チェック、ユーザーが注文を持っているか、対象レコードが既にあるか、などの場合に使われます。

4. count / size / length を正しく使い分ける

メソッド DB クエリは走る? どんなクエリ レコードをロードする? 使いどころ
count あり(常に) SELECT COUNT(*) なし DB から正確な件数が必要なとき
length 未ロードなら走る SELECT * あり(全件ロード) すでに records がロード済みだと確実できる時だけ
size 状況による COUNT(*) または なし 自動 ActiveRecord / association では基本これが安全

association がロード済みか不明なときは、次のように size が安全です。

comments_count = post.comments.size

view で association の件数を表示するなら、まずは size(または counter cache)を優先。

5. よく絞り込み/ソートするカラムに Index を貼る

問題: WHERE user_id = ... などがインデックスがないと、DB がテーブル全体をスキャンして重くなりがちです。

解決: WHERE / JOIN / ORDER BY によく出てくるカラムに index を追加します。

例:

add_index :orders, :user_id
add_index :users, :email, unique: true
add_index :orders, [:user_id, :created_at]

インデックス選びの目安:

  • WHERE / JOIN によく出るカラム: index を追加
  • ORDER BY と filter がセットでよく出る:複合インデックス(例 [:user_id, :created_at])を追加
  • email/username のようなユニーク値: unique: true

確認方法: ログで遅いクエリを見つける、DB で EXPLAIN を実行して index が使われているか確認。

注意: index は読み込みを速くしますが、書き込みは少し遅くなる傾向があります。

6. 大量データは batch で処理する: find_each / in_batches

問題: User.where(...).each は全件を RAM に書き込む可能性があります。件数が多い(数万〜数百万)と、メモリが不足になって、worker/job が落ちる原因になります。

解決: find_eachバッチ処理します。

find_each は 主キーによるページングで、メモリには 1 バッチ分だけ保持します。

User.where(active: true).find_each(batch_size: 1000) do |user|
  # user を1件ずつ処理
end

バッチ単位で処理したい(特に一括更新)なら in_batches が便利です。

User.where(active: true).in_batches(of: 1000) do |relation|
  relation.update_all(flag: true)
end

rake タスク、データの移行作業、数万件以上のレコードを扱うジョブなどに使われます。

7. コールバックが不要なら bulk update/delete: update_all / delete_all

問題: 1万件を each { update } すると、1万回のクエリに加えて validations/callbacks が走ります。場合によってはメール送信なども巻き込まれて重くなります。

解決: 1クエリでまとめて処理します。

# 一括更新
User.where(id: ids).update_all(active: false)

# 一括削除
Log.where("created_at < ?", 30.days.ago).delete_all

重要: update_all / delete_all は バリデーションとコールバックを完全にスキップします。

フラグを一括変更、単純なデータ修正、ログの削除などに使われます。

8. association の件数表示には counter cache を使う

問題: 一覧で「コメント数」を表示するために post.comments.count を多用すると重くなります。includes(:comments) にしても comments が多いとそれ自体が重くなることがあります。

解決: comments_count のようなカラムに件数を保持します(counter cache)。

# posts に comments_count を追加
add_column :posts, :comments_count, :integer, default: 0, null: false

# counter cache を有効化
class Comment < ApplicationRecord
  belongs_to :post, counter_cache: true
end

以降は post.comments_count を使えます。

これは、一覧ページや管理画面など「件数表示」があちこちに出てくる画面で特に効きます。

注意: 読み込みは非常に速くなりますが、コメント作成/削除時に post 側のカラム更新が 1 回増えます。

9. 高コストな処理は Rails.cache.fetch でキャッシュする

問題: 重いクエリや計算(トップの記事、統計、設定値など)を毎リクエスト再計算してしまう。

解決: TTLと分かりやすい key を持ったキャッシュを使います。

top_posts = Rails.cache.fetch(["top_posts", Date.current], expires_in: 10.minutes) do
  Post.published.order(score: :desc).limit(20).pluck(:id, :title)
end

キー設計は ["feature_name", version, params...] の配列にすると、管理しやすいです。expires_in も付けて意図せず永続化する事故を避けましょう。

10. view をキャッシュする(fragment / collection caching)

問題: DB はそこまで遅くないのに、partial が多い、フォーマット処理が重いなどで view のレンダリングが遅くなる。

解決1: record 単位の fragment cache

<% @posts.each do |post| %>
  <% cache(post) do %>
    <%= render "posts/post", post: post %>
  <% end %>
<% end %>

Railsはレコードのcache key(バージョン付き)を使うので、post が更新されるとキーも変わり、自然に無効化されます。

解決2: collection caching(短くて速い)

<%= render partial: "posts/post", collection: @posts, cached: true %>

レンダリング時間とCPU使用量が大きく減ります。


Rails が遅い原因は、framework そのものというより、余計なことをしてしまっているコードにあることがほとんどです。まずは上の基本最適化から入れるだけで、複雑なキャッシュ設計やサーバー増強をしなくても、速くなるケースが珍しくありません。

明日の記事の担当はエンジニアの嘉松さんです。お楽しみに。

Rails + SQL Server環境でハマったBooleanの罠

こんにちは、WEBエンジニアのChoi(チェ)です。 BUYMAの購入者向け機能を開発するチームで、主にSEO改善の業務を担当しています。

この記事はEnigmo Advent Calendar 2025の23日目の記事です。

Railsを使用する際は一般的にMySQLPostgreSQLが使われますが、BUYMAでは用途に応じてSQL Serverも使用しています。

最初は「どのSQLも大差ないだろう」と思っていましたが、運用を開始するとRails + SQL Server特有のトラブルに遭遇しました。

今回はその中でも、

エラーは一切出ないのに、結果だけが返ってこない

という、かなり気づきにくかったケースをご紹介します。


boolean処理をめぐる誤解

ある APIで、次のようにリクエストパラメータを条件に使っていました。

User.where(active: params[:active])

エラーは発生せず、一見すると問題なさそうに見えました。

しかし実際には、エラーは発生しないものの、条件に一致するデータがまったく返ってこないという現象が起きていました。

SQLログを確認すると、発行されていたのは次のようなSQLです。

WHERE active = 'true'

この時点ではまだ、

「文字列になっているのが問題なんだ」

という程度の認識でした。

しかし「文字列になっていたら、trueに暗黙的に変換されるのではないのか?」という疑問も浮かびました。

RubySQLの違い

Rubyでは、次のようなコードが成立します。

if 'false'
  # 実行される
end

Rubyの条件分岐では、falsenil以外はすべてtruthyとして扱われるため、'false'という文字列も「真」として評価されます。

しかしこれは Rubyレベルでの話です。

ActiveRecordが生成するSQLでは、値は型変換されることなく、そのままバインドされます。

テーブル定義を見て、ようやく原因に気づく

改めてテーブル定義を確認してみると、activeカラムはbooleanではありませんでした。

  • カラム型:CHAR(1)
  • 想定値:'1'(有効) / '0'(無効)

つまりこのコードは、

HTTP パラメータとして受け取った 文字列をそのままSQLの条件に渡していた

という状態だったのです。

SQL Server側では、'true'booleanとして解釈されません。

あくまで 文字列同士の比較として評価されます。

その結果、

  • エラーは出ない

  • 条件にも一致しない

  • 常に0件が返る

という、分かりづらい不具合になっていました。

MySQLPostgreSQLであれば、どうなっていたか

ここで気になったのが、 「もしMySQLPostgreSQLだったら、同じ問題は起きていたのか?」 という点でした。

PostgreSQLの場合

PostgreSQLには明確なboolean型があります。

WHERE active = 'true'

のような条件でも、'true'booleantrueとして解釈します。

そのため、今回のケースでは 意図した通りにデータが返ってきていた可能性が高いです。

結果として、問題に気づかないまま運用が続いていたかもしれません。

MySQLの場合

MySQLでは、booleanは実体としてはTINYINT(1)です。

'true''false'といった文字列は、暗黙的に数値へ変換され、結果が返ることもあります。

ただしこの挙動は、明確な仕様というより暗黙の型変換に依存したものです。

なぜ SQL Server環境で表面化されたのか

この問題自体は、SQL Server固有の文法エラーではありません。

しかし、SQL Serverを採用しているサービスでは

  • レガシーなテーブルでは、有効/無効フラグをCHAR型で管理しているケースが今も存在する

という背景から、Rails + SQL Serverの組み合わせで特に踏みやすい落とし穴だと感じました。

この経験から学んだこと

このトラブルをきっかけに、次の点を強く意識するようになりました。

  • DBのカラム型は「現在のRails の常識」と一致するとは限らない
  • パラメータをそのまま条件に渡さず、ドメイン上の値('1' / '0' など)に変換してから使う
  • 結果が返らない場合は、生成されたSQLを必ず確認する

Rails側で正しく書けていても、DB側の前提が異なるだけで、意図しない挙動にハマることがあると学びました。


明日の記事の担当はWebエンジニアのレミーさんです。お楽しみに。

Rails 8のSolid Queue:Sidekiqに代わる新しい非同期処理システム

こんにちは!Webアプリケーションエンジニアのレミーです!

この記事はEnigmo Advent Calendar 2025の21日目の記事です。

Rails 8がリリースされてから、バックグラウンドジョブシステムである Solid Queue に興味を持ち、調べてみました。

バックグラウンドジョブは、Ruby on Railsアプリケーションに重要な部分です。メール送信、画像処理、データ同期、キャッシュ更新、CSVファイルのエクスポートなど、これらはすべてアプリケーションの高速化とスムーズな動作を維持するために非同期で実行すべきタスクです。

長年、Railsのバックグラウンドジョブにおいて「Sidekiq + Redis」はほぼ基準とされてきました。しかし、Rails 8からは、Railsは公式にSolid Queueを導入しました。これはRedisを必要とせず、補助的なサーバーも不要な、ネイティブなキューシステムです。

この記事では、Solid Queueとは何か、その仕組み、どうしてRails 8以上のプロジェクトでSolid Queueを使用すべきかについて解説します。また、Sidekiqとの比較も行います。

Solid Queueとは?

Solid Queueは、データベースをジョブキューとして使用するバックグラウンドジョブシステムであり、Rails Solid Suite(Solid Cache, Solid Queue, Solid Cableを含む)の一部として開発されました。

Redisを使用するSidekiqとは異なり、Solid Queueはジョブをデータベースのテーブルに保存し、ワーカープロセスがそのジョブを読み取って実行します。

つまり:

  • Redisが不要
  • Sidekiqのインストールが不要
  • 補助サーバーのコストがかからない
  • ActiveJobと深く統合されている
  • インストールが非常に簡単

これは、Railsをシンプルにするために生まれました。特にスタートアップ、小規模〜中規模のプロジェクト、またはコストを抑える必要がある環境に最適です。

仕組みとデータベース構造

Solid Queueは単一のシンプルなテーブルだけではなく、ジョブのライフサイクルを管理し、安全性とパフォーマンスを確保するために複数のテーブルを使用します。

重要なテーブルは以下の通りです:

  • solid_queue_jobs: ジョブのメタデータ(クラス名、引数、キュー名、優先度、遅延ジョブの場合はscheduled_at、ジョブIDなど)を保存します。
  • solid_queue_ready_executions: 「実行準備完了」となったジョブを含みます。つまり、エンキューされたジョブで、ワーカーが拾える状態のものです。
  • solid_queue_scheduled_executions: スケジュールされたジョブを含みます。まだ実行タイミングには達していません。
  • solid_queue_claimed_executions: ワーカーが実行のために確保)したジョブ情報を保存し、複数のワーカーが同じジョブを実行しないためです。
  • solid_queue_blocked_executions: ブロックされており、すぐに実行できないジョブを含みます。
  • solid_queue_failed_executions: 実行後にエラーになったジョブを保存し、監視やデバッグに役立ちます。

このように明確に複数のテーブルに設計されているため、Solid Queueは役割を明確に分離でき、ロジックがクリアになり、管理しやすくなります。

Solid Queueにおけるジョブのライフサイクル

Solid Queueの仕組みと、なぜ複数の異なるテーブルが必要なのかを理解するために、ジョブがエンキューされ、ワーカーに拾われ、実行され、削除されるまでの完全なライフサイクルを説明します。

1. ジョブが呼び出される時(エンキュー)

MyJob.perform_later(args) を呼び出すと、Solid Queueはデータベースに対して2つの書き込み操作を行います:

  • solid_queue_jobs テーブルへの書き込み:ジョブのメタデータ("queue_name", "class_name", "arguments", "priority", "active_job_id", "scheduled_at", "finished_at", "concurrency_key" など)を保存します。
  • すぐに実行するジョブの場合:solid_queue_ready_executions にデータを追加します。このテーブルには、ワーカーが処理可能な準備完了ジョブが含まれます。

2. ワーカーが実行するジョブを探す(ポーリング)

ワーカーは solid_queue_ready_executions テーブルを継続的に「ポーリング」して、新しいジョブを取得します。ワーカーは以下の2つの作業を行います:

  1. 確保: ワーカーが solid_queue_ready_executions からジョブを選択すると、solid_queue_claimed_executions テーブルにレコードを書き込みます。このレコードにより、2つのワーカーが同じジョブを実行することができません。
  2. 実行: クレームした後、ワーカーはジョブクラスの perform メソッドを呼び出して実行します。

3. ジョブ完了時、レコードの削除

ジョブが正常に実行されると、ワーカーは関連するすべてのテーブル(solid_queue_jobs, solid_queue_ready_executions, solid_queue_claimed_executions)からジョブを削除します。

ジョブのライフサイクルの簡単なまとめ

段階 関連テーブル 目的
ジョブのエンキュー solid_queue_jobs ジョブのメタデータを保存
ジョブ準備完了 solid_queue_ready_executions ワーカーが拾える状態
ワーカーによる確保 solid_queue_claimed_executions 1つのジョブを1つのワーカーが実行することを保証
実行 なし ワーカーが perform関数を呼び出す
完了 複数のテーブルから削除 レコードのクリーンアップ

安全性、ジョブの「失う」を防ぐ仕組み

重要な要件の一つは、エンキューされたジョブが少なくとも1回は実行され、失われないことです。Solid Queueは、ワーカーのクラッシュ、強制終了、プロセスの不具合などのケースを以下の形式で処理します:

  • 各ワーカーは起動時に solid_queue_processes にレコードを作成し、定期的に last_heartbeat_at を更新します。
  • ワーカーがジョブをクレームする際、solid_queue_claimed_executions にプロセスIDと共にレコードを書き込みます。
  • デフォルトはスーパーバイザープロセスがバックグラウンドで実行され、processes テーブルをチェックします。許容時間を超えて heartbeatがないプロセス(例:5分以上)が見つかった場合、それを「失敗したワーカー」と見なします。
  • スーパーバイザーはそのプロセスを削除し、そのワーカーが保持していたジョブを ready キューに再エンキューして、他のワーカーが拾えるようにします。

これにより、ワーカーがクラッシュしてもジョブは失われず、データの整合性が保証されます。

Solid Queue と Sidekiq の比較

Solid QueueとSidekiqはどちらもRailsで人気のある非同期処理のシステムですが、以下の表で違いを明確にします。

基準 Solid Queue (Rails 8) Sidekiq
ストレージバックエンド データベース (PostgreSQL / MySQL / SQLite) Redis (インメモリ、非常に高速)
Railsとの統合 ネイティブ、Rails 8からの公式組み込み コアじゃない、gem経由で使用
パフォーマンス 小〜中規模のワークロードに良好 非常に高い、大規模ワークロードに最適
遅延 DB使用のため比較的高い 低い (Redis インメモリ)
インストール 簡単、補助サービス不要 複雑、RedisとSidekiqの設定が必要
運用コスト ほぼゼロ (既存DBを使用) Redisのコストがかかる (特に本番環境)
信頼性 高い (SQLトランザクション + ジョブクレーム) 非常に高いがRedisに依存
リトライのロジック あり、DBに保存 あり、強力かつ柔軟
ダッシュボード 強力なUIはまだない Web UIが充実、リアルタイム監視が可能

いつ Solid Queue を選ぶべきか?

シンプル、軽量、ネイティブ、コスト節約を望むならSolid Queueを選びましょう。

いつ Sidekiq を選ぶべきか?

高速、強力、大規模システムに適したものを望むならSidekiqを選びましょう。

結論

Solid Queueは、インフラを簡素化し、Rails 8の大きな進歩を示しています。バックグラウンドジョブをコアフレームワークに直接統合することで、中小規模のプロジェクトはRedisやSidekiqに依存する必要がなくなり、安定性、信頼性の高いジョブ処理能力を確保しながら、運用コストを大幅に削減できます。

明日の記事の担当はエンジニアの宮川さんです。お楽しみに。

アジャイルは会社ごとに別物。でも、あるあるは共通だった

こんにちは、BUYMA TRAVEL Webエンジニア の赤間です。 

この記事はEnigmo Advent Calendar 202520日目の記事です。

この記事では、転職をきっかけに感じたことを基に、アジャイルスクラムの基本と、現場で起きがちな"あるある"とその対策について紹介します。

軽く自己紹介になりますが、私は2025年8月に転職してきたエンジニアです。前職でもエンジニアとして開発を行なっており、時期によってはスクラムマスターの役割も担当していました。

その経験から、転職後に「これってアジャイルか?」と戸惑ったことがあり、同じように悩む人のヒントになればと思いこの記事を書いています。

1. はじめに: 同じ「アジャイル」なのに、転職したら別物だった

前職では「スクラム」を実践していました。1週間という短いスプリントで開発・スプリントレビュー・ふりかえりを繰り返し、要件定義も(プロジェクト毎に)持ち回りで実施していました。

ところが転職後、同じく「アジャイル」を実践する現場に入ったものの、運用はスプリントよりも「この機能をいつ出すか」というリリース単位が中心です。参加した当初、実装やリリーススピードは前より速いはずなのに、私自身はどこか噛み合わず、「これってアジャイルなのかな?」と戸惑いました。いま考えると、アジャイルの形が違うというより、最適化している対象が違ったのだと思います。

この記事では、まずアジャイル/スクラムの基本をできるだけわかりやすく整理します。そのうえで、転職前後の現場を例に「同じアジャイルでも会社 (チーム) でこう違う」を簡単に比較し、それぞれの特徴や転職を通して見つけたよくある課題(あるある)と解決策を、アジャイルのことを知らない人にも伝わる形でまとめていきます。

2. そもそもアジャイルとは

アジャイルは一言でいうと、変化を前提に「小さく作って試し、フィードバックを受け軌道修正する」開発の考え方です。

最初に要件を固めて、計画通りに作り切る (いわゆるウォーターフォール) と対比するとイメージしやすいと思います。

 

ここで大事なのは、朝会・夕会・カンバンのような手段そのものではなく、フィードバックを得て、次に反映するサイクルが回っているかです。

たとえば「作ったものを早めに見せる → 反応をもらう → 次の方針を変える」というループが速ければ速いほど、価値や計画のズレが小さいまま進行できます。

3. スクラムとは

スクラムは、アジャイルの考え方を現場でうまく回すための代表的なフレームワークです。

アジャイル=考え方」だとすると、スクラムはそれを実践するために、役割・イベント (会議) ・成果物をセットで定義し、チームが迷いにくい形にしたもの、と考えるとわかりやすいです。

 

スクラムの用語

  • スプリント: 固定期間 (一般に1〜4週間) で区切られた開発サイクル

  • スプリントゴール: そのスプリントで達成したい目的 (「何のためにやるか」の軸)

  • プランニング: 次のスプリントで「何をどれだけやるか」をチームで決める

  • デイリースクラム: スプリントゴールに向けて、進捗確認と調整を行う毎日の短い打ち合わせ

  • スプリントレビュー: 出来上がった成果物を共有し、フィードバックをもらう場

  • レトロスペクティブ (ふりかえり) : やり方のカイゼンを話し合う場

 

重要: スクラム儀式ではなく、検証とカイゼンを回す仕組み

 

スクラムは「イベントをこなすこと」が目的ではありません。

短い周期で 検査 (Inspect) = いま正しい方向に進んでいるかを確かめ、適応 (Adapt) = 必要ならやり方・優先順位・計画を変える、という検証と改善を回すための仕組みです。

 

つまり、スクラムの各イベントは全て「Inspect / Adapt」のためにあります。

スプリントで区切ることも、スプリントレビューで見せることも、ふりかえりをすることも、全て価値や計画のズレを小さくするためです。

4. 前職スクラムの特徴 (メリット・デメリット)

  • 6人チーム/分業なし

    • + 依存が減って、詰まっても助け合いやすい (柔軟に回る)

    • - 何でも屋になりやすく、社内での育成コストと属人化リスクが上がる

  • 要件定義・見積もりが持ち回り

    • プロダクト理解が深まり、当事者意識が育つ

    • 得意不得意の差が出やすく、ブレや認識差が起きることも

  • スプリント1週間/ふりかえり重視

    • 追われやすく、レビュー品質が落ちると「忙しいだけ」になりがち

  • 朝会夕会で進捗確認

    • 見える化が効き、抱え込みや遅延を早く発見できる

    • 運用次第で報告会・監視っぽくなり、心理的安全性を下げる可能性あり

5. 現職アジャイルの特徴 (メリット・デメリット)

  • エンジニア4人+周辺職種は別チームで参加

    • 必要な専門性が適切なタイミングで入り、品質が上がりやすい

    • 意思決定や仕様の往復が増えると、スピードが落ちることがある

  • 半分業 (フロント/サーバ/インフラ) 

    • 専門性が積み上がり、品質とスピードを出しやすい

    • ボトルネックが固定化すると、待ちが増えてリードタイムが伸びやすい

  • 要件定義はCSが主導、デザイナーやエンジニアがブラッシュアップ

    • 顧客の声が仕様の入口にあり、「作ったけど使われない」を減らしやすい

    • 技術制約・実現方法の検討が遅れると、手戻りが増えることがある

  • スプリントが長期間

    • 機能にフォーカスしやすく、リリース目的がブレにくい

    • フィードバックが遅れると、気づいた時にはズレが大きくなっている

  • 毎日夕会/週1でふりかえり (乖離確認) 

    • 見える化が効き、抱え込みや遅延を早く発見できる

    • 運用次第で報告会・監視っぽくなり、心理的安全性を下げる可能性あり

6. スクラムの「ズレやすいポイント」あるある

ここまでアジャイル/スクラムの概要と、前職・現職それぞれの特徴を書きました。

面白いのは、運用の形は違っていても、実際に現場でつまずきやすい「ズレやすいポイント」には共通点があったことです。

ここからは、スクラムを回すときに起きがちな"あるある"を整理していきます。

1)  スプリントが長くなる/ズレ続ける

本来スプリントは固定期間で区切りますが、実際には意外とズレがちです。

祝日やメンバーの休み、突発対応、大きなリリースが重なると、「期間は決めているのに、結局終わらない」という問題が発生します。

この状態が続くと、スプリントレビューやふりかえりのタイミングが曖昧になってしまいます。

2) どうしても納期が先に確定してしまう

色々な要因で、納期が先に決まること自体は珍しくないと思います。問題は、納期が固定なのにスコープも固定になっていることです。この場合、現場がデスマーチになりやすいです。

3) 会議 (夕会) はあるが、スプリントレビューで顧客フィードバックが薄い

夕会などで進捗は把握できていても、スプリントレビューで"価値"を確かめられないと、やっていることはただの「進捗管理」になってしまいます。

その結果、予定通り作ったのに、想定していた価値が出ないというズレが発生しやすくなります。

4) ふりかえりはするが、カイゼンが実験になっていない

ふりかえり自体はやっていても、内容が「反省会」になってしまう。

ふりかえりの狙いは、誰かを責めることではなく、仕組みを少しずつカイゼンすることです。

5) 分業で詰まりが固定化する

分業は専門性を伸ばしやすい一方で、特定領域にタスクが集中すると、そこがボトルネックになってしまいます。

スクラムでは、個人の稼働率よりも、チームとしてのリードタイムが重要になるため、ここがズレの原因になりがちです。

7. 「じゃあどうすれば?」具体的なカイゼン

では、こうした"あるある"はどう解消すればいいでしょうか。

ここからは、私なりに考えたカイゼン案を紹介します。前職、現職で実際にチームで議論して試した工夫も、一部取り入れています。

スクラムの型に無理やり戻すことが目的ではありません。Inspect / Adapt (検査と適応) がきちんと回る状態に近づけることが目的です。

1)  スプリントが長くなる/ズレ続ける

スプリントの価値は"期限内に全部終える"ではなく、"短い周期での検証とカイゼン"にある

  • 祝日が多い週は最初からタスク数を減らす (期間は固定) 
  • どうしても溢れるなら、「終わらせる」ではなくスプリントを中止する

2) どうしても納期が先に確定してしまう

納期固定を成立させる条件は「変更できる何か (スコープ/品質/順序) 」があること

  • Must/Should/Could で削れる部分を先に決めておく
  • 「この日までにここまで」ではなく「この日までに価値が出る最小形」を合意する

3) 会議 (夕会) はあるが、スプリントレビューで顧客フィードバックが薄い

フィードバックが薄いと、"作ったものが刺さらない"がよく起きる

  • スプリントレビューは「説明」より「動くもの」を中心にする
  • 参加者が広げづらいなら、CSや営業から顧客の反応を持ち込むだけでもOK

4) ふりかえりはするが、カイゼンが実験になっていない

ふりかえりは反省会ではなく、カイゼンのA/Bテストに近い

  • 毎回、カイゼンアクションは1つ程度に絞る
  • 次回のふりかえりで「やったかどうか、効いたかどうか」を確認し、効かなければそのカイゼンはやめる
  • 見積もりが外れた原因は「前提が変わった」「分割が大きい」など仕組み側として考える

5) 分業で詰まりが固定化する

個人最適より、チームのリードタイム最適を狙う

  • 着手しすぎをやめる
  • 詰まりやすい領域は、スプリントレビュー待ちを減らすためにペア/モブを試す

8. 今後の展望

転職直後に「同じアジャイルなのに、なんだか噛み合わない」と感じたのは、今思えば当然でした。

前職で体験していたのは、短いスプリントでレビューとふりかえりを回し続ける"スクラム寄りのアジャイル"。一方で現職は、機能リリースを軸に、CSや周辺職種の知見も取り込みながら進める"プロダクト寄りのアジャイル"です。言葉は同じでも、狙っている最適解が少し違っていたんだと思います。

 

アジャイルスクラムに"唯一の正解"はありません。会社のフェーズ、プロダクトの性質、チームの人数やスキルで、うまく回る形は変わります。大事なのは「どの型が正しいか」を決めることではなく、いまの自分たちにとって必要なカイゼンを見つけて、小さく試して、調整し続けること。そのプロセス自体が、アジャイルの面白さだと感じています。

 

今後も現職の強み (専門性・顧客起点・リリース志向) を活かしながら、ズレが大きくなるポイントを修正していきます。

 

明日の記事担当はBUYMAのWebエンジニアレミーさんです。お楽しみに。

機械学習実験を加速させる dbt による特徴量管理の実践

こんにちは、AI テクノロジーグループ データサイエンティストの髙橋です。業務では企画/分析/機械学習モデル作成/プロダクション向けの実装/効果検証を一貫して行っています。この記事は Enigmo Advent Calendar 2025 の 19 日目の記事です。

本記事では、 dbt を利用した機械学習モデルの特徴量管理について紹介します。この特徴量管理を活用することで、機械学習を利用したプロジェクトで多くの実験を効率的に実施でき、利益増加というビジネス成果に繋げることができました。

www.getdbt.com

※文中に記載するディレクトリやファイル名、 SQL コード、コマンドなどは全てイメージです。

特徴量管理の目的・成果

dbt を利用した特徴量管理を導入した目的は、ある機械学習プロジェクトにて効率的に数多くの特徴量を試す必要があったためです。まず、そのプロジェクトと得られた成果について説明します。
弊社が運営している CtoC ECサービス BUYMA では、MA (Marketing Automation) ツールを通じてクーポンなどのインセンティブをメールで配布しています。これまで、様々な会員セグメントを定義し、各セグメントに対してのインセンティブ配布ルールの運用を行っており、そのルールのチューニングで改善を図っていました。このアプローチではルールをもとに一律配布しているため、機械学習による最適化余地があると考えました。具体的には、インセンティブがなくても購入する会員にもコストをかけてしまったり、インセンティブがあれば購入する会員に配布できていなかったりなどの最適化の余地があるのではないかと考えました。そこで、機械学習を活用してデータに基づくより効果的なインセンティブ配布を実現することを目指しました。

BUYMA はリリースから 20年を超えるサービスであり、様々な MA シナリオ(会員セグメントと配布ルールの組み合わせを以降 MA シナリオと呼びます)が運用されています。出来るだけ多くの MA シナリオに対して機械学習による最適化を適用したく、そのためには様々な特徴量を効率良く試せるようにすべきと考えました。

そこで、今回紹介する dbt を利用した特徴量管理を導入しました。その結果、約1年間でおよそ8個の MA シナリオに機械学習による配布最適化を試すことができ、シナリオによってばらつきはあるものの約20%の利益増加が実現できました。

数多くの特徴量を試す上での課題

まず前提として、特徴量は BigQuery で作成する方針としました。理由は、既に BUYMA のデータは BigQuery に保存されていたことと、Python 実行環境(ノートブックなど)への特徴量作成のもとになる行動データのダウンロードに非常に時間がかかったためです。時間がかかる理由は、BUYMA は会員数・商品数が非常に多く、それに伴いユーザーの行動ログのデータ量も非常に多いためです。具体的には、会員数1185万人、商品数590万品であり*1、利用する行動ログのレコード数は数千万件になることもあります。

この前提のもと、BigQuery で数多くの特徴量を効率的に試すには以下の課題がありました。

  • 特徴量の数が多くなると SQL が肥大化して可読性が低下する。
    • 例えば、弊社で過去別の機能で作成した特徴量 SQL ファイルは2000行を超えており読むのが大変でした。
  • 特徴量作成のロジックが複雑になると可読性が低下する
    • 例えば、過去 n 日間の閲覧数という特徴量を複数 n に対して記述すると、SQL が長くなり可読性が低下します。
  • 別の実験で特徴量を再利用しづらい
    • 再利用する場合は、その特徴量部分を毎回コピペする必要があるためです。

dbt による課題解決

そこで、 dbt を利用して特徴量管理を行うことで、これら課題の解決を図りました

まず、 dbt について簡単に説明します。ただし、dbt は多くの機能があるため今回の課題解決に関連する機能に絞って説明します。全体を詳しく知りたい方は公式ドキュメントを参照ください。 今回役立ったのは以下の機能です。

  • SQL ファイルの分割
  • for などのロジックの記述

SQL ファイルの分割について、dbt を利用することで CTE (Common Table Expression) を別のファイルに分けることができます。例として、以下のような CTE を使った SQL を考えます。

-- main.sql
WITH users AS (
    SELECT
        id AS user_id,
        first_name,
        last_name
    FROM `project.dataset.users`
)

SELECT
    *
FROM
    users

dbt を利用することでこの SQL を2つのファイルに分けることができます。

-- user.sql
SELECT
    id AS user_id,
    first_name,
    last_name
FROM `project.dataset.users`

-- main.sql
SELECT
    *
FROM
    {{ ref("user") }}

ここで、 {{ (ref("user")) }} は user.sql を参照することを意味します。 dbt run コマンドを実行すると、 user.sqlmain.sql のテーブルビューが作成されます。この機能を利用することで、特徴量作成などの SQL を分割して可読性を向上させることが出来ます。具体的には、以下のように SQL ファイルを分割しました。

models
├── datasets
│   └── dataset.sql
└── features
│   ├── features_user_attributes.sql
│   └── features_user_action_log.sql
└── labels
    ├── label_type_1.sql
    └── label_type_2.sql

ここで、 models ディレクトリは dbt でデータ取得のための SQL を配置するディレクトリです。*2

各 datasets ファイルの中身は以下のようにしました。

SELECT
    *
FROM
    {{ var("user_ids_table_name") }}
LEFT JOIN
    {{ ref("features_user_attributes") }}
USING(user_id)
LEFT JOIN
    {{ ref("features_user_action_log") }}
USING(user_id)
LEFT JOIN
    {{ ref("label_type_1") }}
USING(user_id)

ここで、 var は dbt で利用できる変数です。*3 様々な会員セグメントについて実験するために、セグメントごとの会員 ID を別テーブルにあらかじめ保存しておき、変数として切り替えられるようにしました。 features ディレクトリ配下のファイルは意味がある粒度で特徴量を分けて再利用しやすくしました。また、機械学習の目的変数である label も複数パターン試せるようにしました。 こうしたことで、実験が進むごとに特徴量が増加しても、SQL が肥大化して読みにくくなることを防げました。具体的には、1つの SQL ファイルあたり長くとも約100行におさまるようになりました。また、 dataset.sql を見ればどのような特徴量が利用されているかが一目で分かるようになりました。

for などのロジックの記述について、dbt では Jinjaというテンプレートエンジン の記法で for などのロジックを記述することができます。これを利用して、例えば過去 1、3、7日間の閲覧、お気に入り回数の集計は以下のように記述できます。

{%- set agg_actions = ["view", "like"] -%}
{%- set last_n_days = [1, 3, 7] -%}

SELECT
    user_id,
    {% for agg_action in agg_actions %}
        {% for last_n_day in last_n_days %}
            COUNTIF(
                day_from_base_date <= {{ last_n_day }}
                AND action = "{{ agg_action }}"
            ) AS cnt_action_{{ agg_action }}_last_{{ last_n_day }}_days,
        {% endfor %}
    {% endfor %}
FROM
    `project.dataset.user_action_log`
GROUP BY
    user_id

ここで、簡単のために user_action_log テーブルに特徴量作成の基準日から何日前のログかを示す day_from_base_date カラムが存在すると仮定しています。これを通常の SQL で記述すると以下のようになります。

SELECT
    user_id,
    COUNTIF(
        day_from_base_date <= 1
        AND action = "view"
    ) AS cnt_action_view_last_1_days,
    COUNTIF(
        day_from_base_date <= 3
        AND action = "view"
    ) AS cnt_action_view_last_3_days,
    COUNTIF(
        day_from_base_date <= 7
        AND action = "view"
    ) AS cnt_action_view_last_7_days,
    COUNTIF(
        day_from_base_date <= 1
        AND action = "like"
    ) AS cnt_action_like_last_1_days,
    COUNTIF(
        day_from_base_date <= 3
        AND action = "like"
    ) AS cnt_action_like_last_3_days,
    COUNTIF(
        day_from_base_date <= 7
        AND action = "like"
    ) AS cnt_action_like_last_7_days
FROM
    `project.dataset.user_action_log`
GROUP BY
    user_id

比較してみると、 dbt を利用することで SQL が短くなり、かつ変数を定義できるためどの行動を過去何日分集計するかが一目で分かるようになりました。これにより特徴量が複雑になっても可読性が低下することを防げました。また、集計する日数や行動が増えたとしても、変数のリストに要素を追加するだけで対応できるようになりました。

dbt による特徴量管理時の工夫

より多くの特徴量を素早く試せるように行った工夫があるため、それらも紹介します。ここでは2つ紹介します。

1つ目はデータセット管理表を用意し、実験で利用するデータセットごとに ID を採番し、 dataset_001dataset_002 のようにファイルを作成していく方針としたことです。

データセット管理表のイメージ:

データセットID データセット説明
1 セグメント1に対して特徴量 A を利用したデータセット
2 セグメント2に対して特徴量 A, B を利用したデータセット

作成したファイルのイメージ:

models
└── datasets
    ├── dataset_001.sql
    └── dataset_002.sql

こうすることで、新しいデータセットを簡単に追加できるようにし、かつ過去のデータセットも参照しやすくしました。実際に2025年12月時点ではデータセット ID は 100 を超えていますが、問題なく運用出来ています。

2つ目は dbt で作成したテーブルのビューから Python 実行環境でデータを取得する際は、以下のような SQL で一度 GCS にエクスポートしてダウンロードするようにしたことです。これは、Python で BigQuery SDK を利用してデータ取得するとレコード数が多い場合非常に時間がかかるためです。

-- analyses/export_dataset.sql
{%-
    set bucket_folder = "datasets/" + var("dataset_id")
-%}
{%-
    set table_name = 
        target.database
        + "."
        + target.schema
        + "."
        + "dataset_"
        + var("dataset_id")
-%}

-- BigQuery の export data 構文において _table_suffix を含んでいるとエラーが発生するため
-- CREATE TEMP TABLE 構文を利用。
-- https://stackoverflow.com/a/70033601
CREATE TEMP TABLE temp_dataset AS (
    SELECT
        *
    FROM
        {{ table_name }}
);

EXPORT DATA
OPTIONS (
    uri = "gs://{{ var('bucket_name') }}/{{ bucket_folder }}/*.gz",
    format = "Parquet",
    overwrite = true,
    compression = "GZIP"
)
AS (
    SELECT
        *
    FROM
        temp_dataset
);

ここで、GCS バケット名やフォルダ、エクスポートするデータセット ID を dbt 変数としており、これによりデータセットによってエクスポート先を変更できるようにしました。また、この SQL はテーブルビューを作成する必要がないため、 analyses ディレクトリに配置して、以下のコマンドでコンパイルして実行するようにしました。

dbt compile \
    --select analyses/export_dataset.sql \
    --vars '{bucket_name: "your_bucket_name", bucket_folder: "your_bucket_folder", dataset_id: "001"}' && \
bq query < target/compiled/your_dbt_project_name/analyses/export_dataset.sql

ここで、 dbt compile コマンドは Jinja 記法などを解決して実行可能な SQLコンパイルするコマンドであり、コンパイルされたファイルは target/compiled 配下に保存されます。また、 dbt の analyses ディレクトリとは models ディレクトリとは異なり一時的な分析用 SQL などを配置するのに適したものになります。*4

まとめ

本記事では、dbt を利用した特徴量管理について紹介しました。SQL の肥大化や特徴量の再利用しづらさという課題を、 SQL ファイルの分割や for などのロジック記述により解決しました。また、データセット管理の方法や Python 実行環境でのデータ取得の高速化というより効率的に多くの特徴量を試す方法も紹介しました。これにより、複数の MA シナリオに対して機械学習を利用したインセンティブ配布最適化を試すことができ、利益増加という成果に繋げることが出来ました。

本記事が特徴量管理の参考になれば幸いです。

明日の記事は BUYMA TRAVEL のエンジニアの赤間さんです。お楽しみに!


株式会社エニグモ すべての求人一覧 hrmos.co

*1:2025年10月末時点の数値です。https://enigmo.co.jp/ir/

*2:dbt models について詳細は dbt 公式ドキュメントを参照ください。

*3:dbt の変数について詳細は dbt 公式ドキュメントを参照ください。

*4:dbt analyses について詳細は dbt 公式ドキュメントを参照ください。