mybook

プロダクトフィードバックループ — 現場の声をロードマップに変える

「カイさん、また同じ要望が来ました。3社目です」

ソウタは Slack の画面を見ながらため息をついた。TechNova のダッシュボードに「CSV エクスポートのカラム順をカスタマイズしたい」という要望が、異なる顧客から繰り返し届いている。個別対応で凌いできたが、もう限界だった。

Arclight AI の FDE チームリードであるカイは、画面を覗き込んで静かにうなずいた。

「ソウタ、その要望を何回聞いた?」

「3回です。でも全部微妙に違うんですよ。A社はカラムの並び替え、B社は不要カラムの非表示、C社はカスタムヘッダー名。同じ機能なのか別の機能なのか判断がつかなくて」

「それこそが FDE の腕の見せどころだ。個別の声をそのまま伝えるのは誰でもできる。声の奥にある共通の痛みを見つけて、プロダクトの方向性に変えるのが FDE の仕事だよ」

サイレント・アーキテクト — 権限なきロードマップの設計者

カイはホワイトボードに一本の線を引いた。

「FDE は "Silent Architect" だ。プロダクトマネージャーのように正式な権限はない。でも、現場で顧客と向き合っているからこそ見えるものがある。顧客が本当に困っていること、競合に流れかけている理由、次に来る波——それを構造化してプロダクトチームに届けるのが FDE の役割だ」

ソウタは首をかしげた。「でも、それって PM の仕事じゃないですか?」

「PM はデータと戦略から考える。FDE は現場の肌感覚から考える。両方が揃って初めて正しい判断ができる。FDE が現場の声を届けなければ、PM はデータだけで判断することになる。数字には現れない顧客の感情や、まだ言語化されていないニーズを拾えるのは FDE だけだ」

INFO

FDE の「サイレント・アーキテクト」としての影響力は、信頼の蓄積によって生まれる。顧客の声を正確に構造化し、プロダクトチームが実際に動ける形で届け続けることで、「この人が言うなら検討しよう」という信頼が築かれていく。

フィードバック駆動開発の5ステップ

カイはホワイトボードにフローを描き始めた。

「フィードバックを扱うには型がいる。場当たり的に『こんな声がありました』と伝えても、プロダクトチームは動けない。この5ステップで回すんだ」

Loading diagram...

ステップ1: 収集 — 声を漏らさない仕組み

「まず大事なのは、フィードバックを取りこぼさないことだ」とカイは言った。「Slack、メール、ミーティング、Zoom のチャット——声はあらゆるチャネルから飛んでくる。それを一箇所に集める」

ソウタは自分のやり方を振り返った。顧客との打ち合わせで出た要望は、議事録に埋もれていた。Slack で直接言われたことは、スレッドの奥に消えていた。

「Savio や Productboard のようなフィードバック管理ツールを使うのも手だ。Linear と連携させれば、フィードバックからそのままチケットを切れる。でもツールより大事なのは、声を拾う習慣だよ」

ステップ2: 構造化 — 生の声を分析可能にする

「『CSV のカラム順を変えたい』は生の声だ。これをそのまま伝えても、プロダクトチームは困る。構造化するんだ」

カイが示した構造化のフォーマットはこうだった。

  • 誰が: 企業規模、プラン、利用期間
  • 何を: 具体的な要望の内容
  • なぜ: その背景にある業務課題
  • 影響: 解決されないとどうなるか(解約リスク、ワークアラウンドのコスト)
  • 頻度: 同じ声が何回、何社から来ているか

ステップ3: 検証 — 一社の声か、市場の声か

「ここが一番難しい」とカイは真剣な表情で言った。「一社だけが言っていることなのか、多くの顧客が感じている共通の痛みなのか。見極めが必要だ」

ステップ4: 優先順位 — 限られたリソースで最大の価値を

「全部やることはできない。だから優先順位をつける。後で詳しくやろう」

ステップ5: 伝達 — プロダクトチームが動ける形で届ける

「最後に、プロダクトチームが実際にアクションを取れる形で伝える。『顧客が困ってます』じゃ足りない。データ、コンテキスト、推奨アクションをセットで届けるんだ」

ワンオフ修正とロードマップギャップの見分け方

翌日、ソウタはカイに相談した。

「CSV カスタマイズの件、調べてみました。3社とも言っていることは微妙に違いますが、根っこは同じだと思います。でも、これがロードマップに載せるべき機能なのか、個別対応で済ませるべきなのか判断がつきません」

カイはうなずいた。「いい疑問だ。プロダクトセンスの核心だよ。判断基準を教えよう」

カイが挙げた判断軸はこうだった。

判断軸ワンオフ修正ロードマップギャップ
頻度1社だけ3社以上が同様の声
汎用性その顧客固有の業務多くの顧客に共通
解約リスク低い中〜高
実装コスト小(数時間)中〜大
競合状況競合も未対応競合は対応済み

「CSV カスタマイズの件はどうだ?」

ソウタは表を見ながら答えた。「3社から声が来ている。データ出力のカスタマイズは汎用的なニーズ。B社は『これができないなら他のツールを検討する』と言っていた。競合の DataSync は半年前に同じ機能をリリースしている」

「それは明確にロードマップギャップだ。ワンオフ修正で済ませるべきじゃない」

WARNING

ワンオフ修正の積み重ねは技術的負債になる。個別対応のコードが5つ、10つと増えていくと、保守コストが爆発する。「今は個別対応で」という判断が正しい場面もあるが、3回同じパターンが来たら立ち止まって汎用化を検討すべきだ。

クロスファンクショナル・フィードバック会議

「声を集めて構造化しても、伝える場がなければ意味がない」

カイは TechNova に「フィードバックレビュー会議」の導入を提案した。隔週で30分、以下のメンバーが集まる。

  • プロダクトマネージャー: ロードマップの意思決定者
  • エンジニアリングリード: 技術的実現可能性の判断
  • カスタマーサクセス: 日常的な顧客接点からの声
  • セールス: 商談で聞こえる競合情報と要望
  • FDE: 現場での技術的フィードバック

「この会議のポイントは3つ」とカイは強調した。

  1. データで語る: 「お客さんが困ってる」ではなく「過去30日で同一要望が8件、うち3件は Enterprise プラン」
  2. 解決策を提案しない: 問題の共有に集中する。解決策はプロダクトチームが考える
  3. 結果を顧客に返す: 検討開始を伝えることで顧客の信頼が深まる

ソウタは最初の会議で CSV カスタマイズの件を発表した。構造化したデータと解約リスクの見積もり。PM は真剣にメモを取っていた。

「ソウタさん、これは次の四半期のロードマップに入れましょう。詳細な要件定義を一緒にやりませんか」

ソウタは驚いた。これまで要望を伝えても「検討します」で終わっていたのに、データを揃えて構造化しただけで、こんなにスムーズに動くのか。

「確信のリリース」と「祈りのリリース」

カイがコーヒーを飲みながら語った。

「世の中のチームは2種類に分かれる。リリース前に『これは顧客が求めていたものだ』と確信できるチームと、『うまくいくといいな』と祈るチームだ」

「祈りのリリース、耳が痛いです」ソウタは苦笑した。

「フィードバックループが回っているチームは、リリースの前に答えを知っている。なぜなら、顧客の声から機能を作り、開発中も顧客に確認し、リリース後もフィードバックを集めているからだ。不確実性が圧倒的に低い」

カイは対比を示した。

観点祈りのリリース確信のリリース
機能の起点社内のアイデア顧客の痛み
開発中の検証なしプロトタイプを顧客に見せる
リリース後利用率を祈る事前に約束した顧客が即利用
失敗時の学び「なぜ使われない?」「次はこう改善しよう」

INFO

「確信のリリース」を実現するには、開発の早い段階から顧客を巻き込むことが重要だ。Figma のモックアップを見せる、ベータ版を限定公開する、機能フラグで段階的にロールアウトする——いずれも FDE が顧客との接点を持っているからこそ実行できる施策だ。

顧客の痛みを定量化する — RICE フレームワーク

「でもカイさん、複数の要望が同時に来たらどう優先順位をつけるんですか?」

「いい質問だ。感覚で決めると声の大きい顧客の要望が通りやすくなる。定量的なフレームワークを使うんだ」

カイが紹介したのは RICE スコアリングだ。

  • Reach(到達度): この機能で何社・何ユーザーが影響を受けるか
  • Impact(影響度): 1人あたりの影響はどの程度か(1=微小、2=低、3=中、4=高、5=甚大)
  • Confidence(確信度): この見積もりにどれだけ自信があるか(0.5〜1.0)
  • Effort(工数): エンジニアの作業量(人週)
RICE スコア = (Reach × Impact × Confidence) / Effort

ソウタは3つの要望を RICE で評価してみた。

機能ReachImpactConfidenceEffortRICE
CSVカスタマイズ45社30.83週36.0
ダッシュボード共有20社40.65週9.6
Webhook 通知30社20.92週27.0

「CSV カスタマイズが最もスコアが高い。しかも確信度も高い——3社から直接聞いているからだ。FDE が現場にいるからこそ、Confidence の精度が上がるんだ」

Rails で構築するフィードバック集約システム

ソウタはフィードバックを構造化して蓄積するシステムを Rails で構築することにした。スプレッドシート管理の限界を感じていたからだ。

データモデル

# db/migrate/20240115_create_feedback_tables.rb
class CreateFeedbackTables < ActiveRecord::Migration[7.1]
  def change
    create_table :feedback_categories do |t|
      t.string :name, null: false
      t.string :slug, null: false
      t.text :description
      t.timestamps
    end
 
    add_index :feedback_categories, :slug, unique: true
 
    create_table :feedbacks do |t|
      t.references :feedback_category, foreign_key: true
      t.references :user, foreign_key: true
      t.string :company_name, null: false
      t.string :plan_tier, null: false
      t.string :title, null: false
      t.text :body, null: false
      t.text :business_context
      t.integer :churn_risk, default: 0
      t.integer :status, default: 0
      t.decimal :rice_score, precision: 8, scale: 2
      t.integer :reach, default: 0
      t.integer :impact, default: 1
      t.decimal :confidence, precision: 3, scale: 2, default: 0.5
      t.integer :effort, default: 1
      t.timestamps
    end
 
    create_table :feedback_votes do |t|
      t.references :feedback, foreign_key: true, null: false
      t.references :user, foreign_key: true, null: false
      t.integer :weight, default: 1
      t.text :comment
      t.timestamps
    end
 
    add_index :feedback_votes, %i[feedback_id user_id], unique: true
  end
end

モデル定義

# app/models/feedback_category.rb
class FeedbackCategory < ApplicationRecord
  has_many :feedbacks, dependent: :restrict_with_error
 
  validates :name, presence: true
  validates :slug, presence: true, uniqueness: true
end
 
# app/models/feedback.rb
class Feedback < ApplicationRecord
  belongs_to :feedback_category
  belongs_to :user
  has_many :feedback_votes, dependent: :destroy
 
  enum :status, {
    submitted: 0,
    under_review: 1,
    planned: 2,
    in_progress: 3,
    shipped: 4,
    declined: 5
  }
 
  enum :churn_risk, {
    none: 0,
    low: 1,
    medium: 2,
    high: 3,
    critical: 4
  }, prefix: :churn
 
  validates :title, presence: true, length: { maximum: 200 }
  validates :body, presence: true
  validates :company_name, presence: true
  validates :plan_tier, presence: true
 
  scope :by_rice_score, -> { order(rice_score: :desc) }
  scope :high_churn_risk, -> { where(churn_risk: %i[high critical]) }
  scope :roadmap_candidates, -> {
    where(status: %i[submitted under_review])
      .where("rice_score >= ?", 10)
      .by_rice_score
  }
 
  def vote_count
    feedback_votes.sum(:weight)
  end
end
 
# app/models/feedback_vote.rb
class FeedbackVote < ApplicationRecord
  belongs_to :feedback, counter_cache: true
  belongs_to :user
 
  validates :weight, inclusion: { in: 1..5 }
  validates :user_id, uniqueness: { scope: :feedback_id }
end

スコアリング・サービス

# app/services/feedback_scorer.rb
class FeedbackScorer
  MIN_VOTES_FOR_ROADMAP = 3
  HIGH_RICE_THRESHOLD = 20
 
  def initialize(feedback)
    @feedback = feedback
  end
 
  def calculate_rice_score
    return 0 if @feedback.effort.zero?
 
    score = (reach * impact * confidence) / effort.to_f
    score.round(2)
  end
 
  def update_score!
    new_score = calculate_rice_score
    @feedback.update!(rice_score: new_score)
    auto_promote_if_eligible(new_score)
    new_score
  end
 
  def self.recalculate_all
    Feedback.find_each do |feedback|
      new(feedback).update_score!
    end
  end
 
  private
 
  def reach    = @feedback.reach
  def impact   = @feedback.impact
  def confidence = @feedback.confidence
  def effort   = @feedback.effort
 
  def auto_promote_if_eligible(score)
    return unless @feedback.submitted?
    return unless score >= HIGH_RICE_THRESHOLD
    return unless @feedback.vote_count >= MIN_VOTES_FOR_ROADMAP
 
    @feedback.update!(status: :under_review)
    notify_product_team
  end
 
  def notify_product_team
    FeedbackMailer.roadmap_candidate(@feedback).deliver_later
  end
end

コントローラと API エンドポイント

# app/controllers/api/v1/feedbacks_controller.rb
module Api
  module V1
    class FeedbacksController < ApplicationController
      before_action :authenticate_user!
      before_action :set_feedback, only: %i[show update vote]
 
      # GET /api/v1/feedbacks
      def index
        feedbacks = Feedback
          .includes(:feedback_category, :feedback_votes)
          .then { |scope| filter_by_status(scope) }
          .then { |scope| filter_by_category(scope) }
          .by_rice_score
          .page(params[:page])
 
        render json: {
          feedbacks: feedbacks.map { serialize(_1) },
          meta: pagination_meta(feedbacks)
        }
      end
 
      # POST /api/v1/feedbacks
      def create
        feedback = current_user.feedbacks.build(feedback_params)
 
        if feedback.save
          FeedbackScorer.new(feedback).update_score!
          render json: serialize(feedback), status: :created
        else
          render json: { errors: feedback.errors }, status: :unprocessable_entity
        end
      end
 
      # PATCH /api/v1/feedbacks/:id
      def update
        if @feedback.update(feedback_params)
          FeedbackScorer.new(@feedback).update_score!
          render json: serialize(@feedback)
        else
          render json: { errors: @feedback.errors }, status: :unprocessable_entity
        end
      end
 
      # POST /api/v1/feedbacks/:id/vote
      def vote
        existing = @feedback.feedback_votes.find_by(user: current_user)
 
        if existing
          existing.update!(vote_params)
        else
          @feedback.feedback_votes.create!(
            user: current_user,
            **vote_params
          )
        end
 
        FeedbackScorer.new(@feedback).update_score!
        render json: serialize(@feedback.reload)
      end
 
      private
 
      def set_feedback
        @feedback = Feedback.find(params[:id])
      end
 
      def feedback_params
        params.require(:feedback).permit(
          :title, :body, :company_name, :plan_tier,
          :business_context, :churn_risk,
          :feedback_category_id,
          :reach, :impact, :confidence, :effort
        )
      end
 
      def vote_params
        params.permit(:weight, :comment)
      end
 
      def filter_by_status(scope)
        return scope unless params[:status]
 
        scope.where(status: params[:status])
      end
 
      def filter_by_category(scope)
        return scope unless params[:category]
 
        scope.joins(:feedback_category)
             .where(feedback_categories: { slug: params[:category] })
      end
 
      def serialize(feedback)
        {
          id: feedback.id, title: feedback.title, body: feedback.body,
          company_name: feedback.company_name, plan_tier: feedback.plan_tier,
          status: feedback.status, churn_risk: feedback.churn_risk,
          rice_score: feedback.rice_score, vote_count: feedback.vote_count,
          category: feedback.feedback_category&.name,
          created_at: feedback.created_at.iso8601
        }
      end
    end
  end
end

INFO

then メソッド(Ruby 2.6+)を使ったスコープチェインは、条件付きフィルタを読みやすく書けるパターンだ。yield_self のエイリアスで、パイプライン的にスコープを組み立てられる。

AWS で構築する非同期フィードバックパイプライン

フィードバックが増えてくると、スコア計算やカテゴリ分類を同期処理で行うのは非効率だ。ソウタは AWS のマネージドサービスを使って非同期パイプラインを構築した。

Loading diagram...

SQS へのメッセージ送信

# app/services/feedback_pipeline.rb
class FeedbackPipeline
  SQS_QUEUE_URL = ENV.fetch("FEEDBACK_QUEUE_URL")
 
  def initialize(feedback)
    @feedback = feedback
  end
 
  def enqueue!
    sqs_client.send_message(
      queue_url: SQS_QUEUE_URL,
      message_body: build_message.to_json,
      message_attributes: {
        "feedback_id" => {
          string_value: @feedback.id.to_s,
          data_type: "String"
        },
        "priority" => {
          string_value: priority_level,
          data_type: "String"
        }
      }
    )
  end
 
  private
 
  def sqs_client
    @sqs_client ||= Aws::SQS::Client.new
  end
 
  def build_message
    {
      feedback_id: @feedback.id,
      title: @feedback.title,
      body: @feedback.body,
      company_name: @feedback.company_name,
      plan_tier: @feedback.plan_tier,
      churn_risk: @feedback.churn_risk,
      submitted_at: @feedback.created_at.iso8601
    }
  end
 
  def priority_level
    @feedback.churn_critical? ? "high" : "standard"
  end
end

Lambda ハンドラ

Lambda 関数でフィードバックの自動分類とスコア計算を行う。

# lambda/feedback_processor/handler.rb
require "json"
require "aws-sdk-dynamodb"
require "aws-sdk-sns"
 
DYNAMODB = Aws::DynamoDB::Client.new
SNS = Aws::SNS::Client.new
 
TABLE_NAME = ENV.fetch("FEEDBACK_TABLE")
TOPIC_ARN = ENV.fetch("NOTIFICATION_TOPIC_ARN")
HIGH_SCORE_THRESHOLD = 20
 
def handler(event:, context:)
  event["Records"].each do |record|
    process_record(record)
  end
 
  { statusCode: 200, body: "OK" }
rescue StandardError => e
  puts "Error: #{e.message}"
  raise e
end
 
def process_record(record)
  payload = JSON.parse(record["body"])
  feedback_id = payload["feedback_id"]
 
  category = classify_feedback(payload)
  score = calculate_score(payload)
 
  store_result(feedback_id, category, score, payload)
 
  notify_if_high_priority(feedback_id, score, payload)
end
 
def classify_feedback(payload)
  body = payload["body"].downcase
 
  categories = {
    "data_export" => %w[csv export download],
    "integration" => %w[api webhook connect],
    "ui_ux" => %w[dashboard ui design layout],
    "performance" => %w[slow timeout speed]
  }
 
  categories.each do |cat, keywords|
    return cat if keywords.any? { |kw| body.include?(kw) }
  end
 
  "general"
end
 
def calculate_score(payload)
  churn_weight = case payload["churn_risk"]
                 when "critical" then 5
                 when "high"     then 4
                 when "medium"   then 3
                 when "low"      then 2
                 else 1
                 end
 
  plan_weight = case payload["plan_tier"]
                when "enterprise" then 3
                when "business"   then 2
                else 1
                end
 
  (churn_weight * plan_weight).to_f
end
 
def store_result(feedback_id, category, score, payload)
  DYNAMODB.put_item(
    table_name: TABLE_NAME,
    item: {
      "feedback_id" => feedback_id,
      "category" => category,
      "priority_score" => score,
      "company" => payload["company_name"],
      "plan_tier" => payload["plan_tier"],
      "processed_at" => Time.now.iso8601
    }
  )
end
 
def notify_if_high_priority(feedback_id, score, payload)
  return unless score >= HIGH_SCORE_THRESHOLD
 
  SNS.publish(
    topic_arn: TOPIC_ARN,
    subject: "High Priority Feedback ##{feedback_id}",
    message: <<~MSG
      高優先度フィードバックが検出されました。
 
      企業: #{payload["company_name"]}
      プラン: #{payload["plan_tier"]}
      解約リスク: #{payload["churn_risk"]}
      スコア: #{score}
 
      内容: #{payload["title"]}
    MSG
  )
end

WARNING

Lambda のキーワードベース分類は簡易的な実装だ。本番環境では、Amazon Comprehend や OpenAI API を使った自然言語分類に置き換えることで精度が大幅に向上する。ただし、まずはシンプルに始めて、分類ミスのパターンを蓄積してから ML モデルに切り替えるのが現実的なアプローチだ。

フィードバック作成時にパイプラインを起動

# app/controllers/api/v1/feedbacks_controller.rb(create アクションに追加)
def create
  feedback = current_user.feedbacks.build(feedback_params)
 
  if feedback.save
    FeedbackScorer.new(feedback).update_score!
    FeedbackPipeline.new(feedback).enqueue!
    render json: serialize(feedback), status: :created
  else
    render json: { errors: feedback.errors }, status: :unprocessable_entity
  end
end

フィードバックループの全体像

ソウタは構築したシステムの全体像を振り返った。顧客の声が、どのようにプロダクトの改善につながるのか。その流れが一本の線として見えるようになっていた。

Loading diagram...

フィードバックレビュー会議で CSV カスタマイズ機能が正式にロードマップに載った日、ソウタは B 社の担当者に連絡した。

「田中さん、以前ご要望いただいた CSV カスタマイズ機能ですが、次の四半期で開発が決まりました。詳細な要件について、改めてお話を聞かせていただけますか」

「本当ですか!正直、別のツールに移ろうかと考えていたんです。ちゃんと声が届いていたんですね」

ソウタは電話を切った後、カイのメッセージを思い出した。

まとめ — 声を届ける技術

カイが最後に言った言葉が、ソウタの胸に残っていた。

「プロダクトの成功は、コードの品質だけでは決まらない。正しいものを作っているかどうかで決まる。そして『正しいもの』を知っているのは顧客だ。FDE は顧客とプロダクトチームをつなぐ翻訳者であり、サイレント・アーキテクトだ。声を届ける技術を磨け」

しかし、本当に変わったのはツールではなかった。顧客の声を「ただの要望」ではなく「プロダクトを形作る素材」として扱う視点が変わったのだ。フィードバックループの回転速度を上げ続けること——それがプロダクトの競争力を決める。

INFO

毎月のふりかえりで「最も価値のあったフィードバック」を共有し、ループ自体を改善していこう。声を集めてからプロダクトに反映されるまでのリードタイムこそが、チームの成熟度を測る指標だ。