mybook

面接対策:コーディングラウンド — LeetCode ではない、FDE の実践問題

「ソウタさん、FDE の面接って、やっぱり LeetCode ゴリゴリですか?」

後輩エンジニアのユウキが、不安そうな顔でそう切り出した。Arclight AI のオフィスで、ソウタがコーヒーを淹れている最中のことだった。

ユウキは社内異動で FDE チームへの参加を希望しており、来月のコーディング面接に向けて準備を始めたところだった。手元には「競技プログラミング 300問」と書かれた分厚い参考書がある。

ソウタは苦笑した。

「その本、閉じていいよ。FDE の面接は LeetCode じゃない。二分木を反転させる問題は出ない」

「え、じゃあ何が出るんですか?」

「顧客の壊れた CSV を直す問題とか、Webhook の冪等処理を書く問題とか。つまり——月曜日の朝に実際にやる仕事が出る」

ユウキの目が変わった。


FDE コーディング面接の特徴

LeetCode との違い

FDE のコーディング面接は、一般的なソフトウェアエンジニアの面接とは根本的に異なる。

観点一般的なコーディング面接FDE コーディング面接
問題の性質アルゴリズム・データ構造実務シナリオの再現
評価基準計算量の最適化可読性・拡張性・エッジケース
入力データ整形済み配列・グラフ壊れた CSV・不正な JSON
正解1つの最適解トレードオフを説明した上での選択
質問の期待ヒントを求める要件を明確にする
テスト自動テストが通る顧客が困るケースを自分で考える

INFO

FDE 面接の核心は「この人と一緒に顧客先に行って、問題を解決できるか?」という問いに尽きる。アルゴリズムの知識よりも、曖昧な要件から実用的なコードを書く力が試される。

評価軸と時間配分

面接官が見ているのは 実用性・可読性・エッジケース対応・顧客視点 の4軸だ。

Loading diagram...

WARNING

最も多い失敗パターンは「理解フェーズを飛ばしていきなり実装する」こと。FDE 面接では、最初の10分で的確な質問をすること自体が評価対象になる。


頻出問題パターン 8 問

ソウタはホワイトボードにマーカーで番号を振り始めた。カイも「俺も面接官側で出す問題を補足するよ」と加わった。


問題1: CSV / JSON パーサー — 壊れたデータとの戦い

「顧客から送られてきた CSV を読み込み、バリデーションしてインポートする処理を実装してください。データには不正な行が含まれます」

顧客データは常に壊れている。エラーを集約して顧客にフィードバックする設計が鍵。

class DataImporter
  REQUIRED_COLUMNS = %w[name email age].freeze
  Result = Struct.new(:imported, :errors, keyword_init: true)
  RowError = Struct.new(:line, :column, :value, :message, keyword_init: true)
 
  def initialize(csv_content)
    @csv_content = csv_content
    @errors = []
    @imported = []
  end
 
  def import
    rows = parse_csv
    rows.each_with_index do |row, i|
      validated = validate_row(row, i + 2)
      @imported << validated if validated
    end
    Result.new(imported: @imported, errors: @errors)
  end
 
  private
 
  def parse_csv
    require "csv"
    CSV.parse(@csv_content, headers: true, liberal_parsing: true)
  rescue CSV::MalformedCSVError => e
    @errors << RowError.new(line: 0, column: nil, value: nil, message: "CSV解析エラー: #{e.message}")
    []
  end
 
  def validate_row(row, line)
    record = {}
    REQUIRED_COLUMNS.each do |col|
      value = row[col]&.strip
      if value.nil? || value.empty?
        @errors << RowError.new(line: line, column: col, value: value, message: "#{col} は必須です")
        return nil
      end
      record[col] = value
    end
    unless record["email"]&.match?(/\A[^@\s]+@[^@\s]+\z/)
      @errors << RowError.new(line: line, column: "email", value: record["email"], message: "メール形式不正")
      return nil
    end
    age = Integer(record["age"], exception: false)
    unless age && (0..150).include?(age)
      @errors << RowError.new(line: line, column: "age", value: record["age"], message: "年齢は0〜150の整数")
      return nil
    end
    record["age"] = age
    record
  end
end

評価ポイント: liberal_parsing: true で壊れた CSV も最大限読み取る。エラーを行番号・カラム名付きで集約し、顧客にそのまま返せる。全行を処理してからまとめてエラー報告する。


問題2: レートリミッター — マルチテナント対応

「マルチテナント SaaS の API にレートリミッターを実装してください。テナントごとに異なるレート制限を設定できるようにしてください」

トークンバケットアルゴリズムを採用。テナントごとの設定管理がポイント。

class RateLimiter
  Config = Struct.new(:max_tokens, :refill_rate, :refill_interval, keyword_init: true)
  DEFAULT = Config.new(max_tokens: 100, refill_rate: 10, refill_interval: 1.0)
 
  def initialize
    @buckets = {}
    @configs = {}
    @mutex = Mutex.new
  end
 
  def configure(tenant_id, max_tokens:, refill_rate:, refill_interval: 1.0)
    @configs[tenant_id] = Config.new(max_tokens: max_tokens, refill_rate: refill_rate, refill_interval: refill_interval)
  end
 
  def allow?(tenant_id, cost: 1)
    @mutex.synchronize do
      bucket = fetch_bucket(tenant_id)
      refill(bucket, tenant_id)
      return false unless bucket[:tokens] >= cost
      bucket[:tokens] -= cost
      true
    end
  end
 
  def remaining(tenant_id)
    @mutex.synchronize do
      bucket = fetch_bucket(tenant_id)
      refill(bucket, tenant_id)
      bucket[:tokens].floor
    end
  end
 
  private
 
  def config_for(id) = @configs[id] || DEFAULT
 
  def fetch_bucket(tenant_id)
    config = config_for(tenant_id)
    @buckets[tenant_id] ||= { tokens: config.max_tokens.to_f, last_refill: Time.now }
  end
 
  def refill(bucket, tenant_id)
    config = config_for(tenant_id)
    elapsed = Time.now - bucket[:last_refill]
    bucket[:tokens] = [bucket[:tokens] + (elapsed / config.refill_interval) * config.refill_rate, config.max_tokens.to_f].min
    bucket[:last_refill] = Time.now
  end
end

評価ポイント: Mutex でスレッドセーフ。テナントごとの設定分離とデフォルト値。cost パラメータで重い API にトークンを多く消費させる拡張性。


問題3: Webhook 受信システム — 冪等性の担保

「外部サービスから Webhook を受信するシステムを実装してください。同じイベントが複数回送られてくる前提で、冪等性を担保してください」

イベント ID での重複排除、署名検証、処理結果の記録が三本柱。

class WebhookProcessor
  Result = Struct.new(:status, :message, :event_id, keyword_init: true)
 
  def initialize(secret:)
    @secret = secret
    @processed = {}
    @handlers = {}
    @mutex = Mutex.new
  end
 
  def on(event_type, &handler) = @handlers[event_type] = handler
 
  def process(payload:, signature:, event_id:, event_type:)
    return duplicate_result(event_id) if processed?(event_id)
    return Result.new(status: :unauthorized, message: "署名不正", event_id: event_id) unless valid_signature?(payload, signature)
    handler = @handlers[event_type]
    return Result.new(status: :ignored, message: "未登録イベント", event_id: event_id) unless handler
 
    execute(handler, payload, event_id, event_type)
  end
 
  private
 
  def processed?(id) = @mutex.synchronize { @processed.key?(id) }
 
  def duplicate_result(id)
    Result.new(status: :duplicate, message: "処理済み", event_id: id)
  end
 
  def valid_signature?(payload, signature)
    require "openssl"
    expected = OpenSSL::HMAC.hexdigest("SHA256", @secret, payload)
    expected.bytesize == signature.bytesize && expected.bytes.zip(signature.bytes).reduce(0) { |a, (x, y)| a | (x ^ y) }.zero?
  end
 
  def execute(handler, payload, event_id, event_type)
    require "json"
    handler.call(JSON.parse(payload, symbolize_names: true))
    @mutex.synchronize { @processed[event_id] = { type: event_type, at: Time.now } }
    Result.new(status: :ok, message: "処理完了", event_id: event_id)
  rescue JSON::ParserError => e
    Result.new(status: :error, message: "JSON解析エラー: #{e.message}", event_id: event_id)
  rescue StandardError => e
    Result.new(status: :error, message: "処理エラー: #{e.message}", event_id: event_id)
  end
end

評価ポイント: タイミング攻撃を防ぐ定数時間比較。冪等性キーとして event_id を使用。ハンドラ登録のブロックによる柔軟な設計。

INFO

面接中に「冪等性キーの保存先はインメモリでいいですか? 本番では Redis や DB に永続化しますが、今日はインメモリで進めます」と宣言すると、プロダクション意識が伝わる。


問題4: 設定管理 CLI — 顧客環境のコンフィグ管理

「複数の顧客環境の設定ファイルを管理する CLI ツールを実装してください。バリデーション、差分表示、ドライラン機能を備えてください」

FDE は顧客ごとに異なる設定を管理する。変更前に差分を見せる「ドライラン」は顧客信頼の要。

class ConfigManager
  SCHEMA = {
    "app_name" => { type: String, required: true },
    "max_connections" => { type: Integer, required: true, range: 1..1000 },
    "log_level" => { type: String, required: true, enum: %w[debug info warn error] },
  }.freeze
 
  def initialize(config_path)
    @path = config_path
    @config = load_config
  end
 
  def validate
    errors = []
    SCHEMA.each do |key, rules|
      value = @config[key]
      if value.nil? && rules[:required]
        errors << { path: key, message: "必須項目が未設定" }
        next
      end
      next if value.nil?
      errors << { path: key, message: "型不正 (期待: #{rules[:type]})" } unless value.is_a?(rules[:type])
      errors << { path: key, message: "範囲外 (#{rules[:range]})" } if rules[:range] && !rules[:range].include?(value)
      errors << { path: key, message: "不許可値 (#{rules[:enum].join(',')})" } if rules[:enum] && !rules[:enum].include?(value)
    end
    errors
  end
 
  def diff(new_config)
    all_keys = (@config.keys + new_config.keys).uniq
    all_keys.filter_map do |key|
      old_val, new_val = @config[key], new_config[key]
      next if old_val == new_val
      { path: key, old: old_val, new: new_val }
    end
  end
 
  def apply(new_config, dry_run: false)
    changes = diff(new_config)
    return { applied: false, changes: changes, reason: "ドライラン" } if dry_run
    merged = @config.merge(new_config)
    save_config(merged)
    @config = merged
    { applied: true, changes: changes }
  end
 
  private
 
  def load_config
    require "yaml"
    File.exist?(@path) ? (YAML.safe_load(File.read(@path)) || {}) : {}
  end
 
  def save_config(config)
    require "yaml"
    File.write(@path, YAML.dump(config))
  end
end

評価ポイント: dry_run で安全な事前確認。差分表示で「何が変わるか」を顧客に説明可能。スキーマベースのバリデーション。


問題5: 指数バックオフ + ジッター — リトライ戦略

「外部 API 呼び出しのリトライ戦略を実装してください。指数バックオフ、ジッター、リトライ可否の判別を含めてください」

単純な指数バックオフは Thundering Herd 問題を起こす。フルジッターとデコレレイテッドジッターを使い分ける。

class RetryStrategy
  RetryExhausted = Class.new(StandardError)
  RETRYABLE = [Errno::ECONNRESET, Errno::ETIMEDOUT, IOError].freeze
 
  def initialize(max_retries: 5, base_delay: 0.5, max_delay: 30.0, jitter: :full)
    @max_retries = max_retries
    @base_delay = base_delay
    @max_delay = max_delay
    @jitter = jitter
    @attempts = []
  end
 
  def execute
    attempt = 0
    last_delay = @base_delay
    loop do
      begin
        result = yield(attempt)
        @attempts << { attempt: attempt, outcome: :success }
        return result
      rescue *RETRYABLE => e
        attempt += 1
        raise RetryExhausted, "#{@max_retries}回リトライ後も失敗: #{e.message}" if attempt > @max_retries
        delay = calc_delay(attempt, last_delay)
        last_delay = delay
        @attempts << { attempt: attempt, outcome: :retry, delay: delay }
        sleep(delay)
      end
    end
  end
 
  def stats
    { total: @attempts.size, retries: @attempts.count { _1[:outcome] == :retry }, total_delay: @attempts.sum { _1[:delay] || 0 } }
  end
 
  private
 
  def calc_delay(attempt, last_delay)
    case @jitter
    when :full          then rand * [@base_delay * (2**(attempt - 1)), @max_delay].min
    when :decorrelated  then rand(@base_delay..[last_delay * 3, @max_delay].min)
    else [@base_delay * (2**(attempt - 1)), @max_delay].min
    end
  end
end

評価ポイント: 2方式のジッター実装。RETRYABLE でリトライ可否を明確分類。stats で運用時のリトライ状況を可視化。

WARNING

「なぜジッターが必要か?」と聞かれたら Thundering Herd を説明する。「1000台が同時に障害を検知し、全員が2秒後にリトライするとサーバーが再ダウンする」——この具体例が効く。


問題6: 簡易キャッシュシステム — LRU + TTL

「インメモリのキャッシュシステムを実装してください。LRU による容量制限と TTL による有効期限管理を備えてください」

スレッドセーフかつメトリクス付きの実装を目指す。

class SmartCache
  Entry = Struct.new(:value, :expires_at, keyword_init: true)
 
  def initialize(max_size: 1000, default_ttl: 300)
    @max_size = max_size
    @default_ttl = default_ttl
    @store = {}
    @order = []
    @mutex = Mutex.new
    @stats = { hits: 0, misses: 0, evictions: 0 }
  end
 
  def get(key)
    @mutex.synchronize do
      entry = @store[key]
      return (@stats[:misses] += 1; nil) unless entry
      if entry.expires_at && Time.now > entry.expires_at
        evict(key)
        return (@stats[:misses] += 1; nil)
      end
      touch(key)
      @stats[:hits] += 1
      entry.value
    end
  end
 
  def set(key, value, ttl: @default_ttl)
    @mutex.synchronize do
      evict_lru while @store.size >= @max_size && !@store.key?(key)
      @store[key] = Entry.new(value: value, expires_at: ttl ? Time.now + ttl : nil)
      touch(key)
      value
    end
  end
 
  def fetch(key, ttl: @default_ttl)
    value = get(key)
    return value unless value.nil?
    computed = yield
    set(key, computed, ttl: ttl)
  end
 
  def stats
    @mutex.synchronize do
      total = @stats[:hits] + @stats[:misses]
      rate = total.zero? ? 0.0 : (@stats[:hits].to_f / total * 100).round(2)
      @stats.merge(size: @store.size, hit_rate: "#{rate}%")
    end
  end
 
  private
 
  def touch(key) = (@order.delete(key); @order.push(key))
  def evict(key) = (@store.delete(key); @order.delete(key))
 
  def evict_lru
    key = @order.shift
    return unless key
    @store.delete(key)
    @stats[:evictions] += 1
  end
end

評価ポイント: fetch でキャッシュ・アサイド・パターンを実装。ヒット率をメトリクスとして公開。TTL 切れはアクセス時に遅延削除。


問題7: ログ集約・分析ツール — パターンマッチアラート

「複数サーバーのログを集約し、パターンに基づいてアラートを発報するツールを実装してください」

正規表現パターンマッチと閾値ベースアラートを組み合わせる。

class LogAnalyzer
  Alert = Struct.new(:rule, :count, :threshold, :samples, keyword_init: true)
  LogEntry = Struct.new(:timestamp, :level, :server, :message, keyword_init: true)
 
  def initialize
    @rules = {}
    @buffer = Hash.new { |h, k| h[k] = [] }
    @alerts = []
  end
 
  def add_rule(name, pattern:, threshold:, window:, level: nil)
    @rules[name] = { pattern: pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern),
                      threshold: threshold, window: window, level: level }
  end
 
  def ingest(line, server: "unknown")
    entry = parse(line, server)
    return unless entry
    @rules.each do |name, rule|
      next if rule[:level] && entry.level != rule[:level]
      next unless rule[:pattern].match?(entry.message)
      @buffer[name] << entry
      @buffer[name].reject! { |e| e.timestamp < entry.timestamp - rule[:window] }
      if @buffer[name].size >= rule[:threshold]
        @alerts << Alert.new(rule: name, count: @buffer[name].size, threshold: rule[:threshold],
                             samples: @buffer[name].last(3).map { "[#{_1.server}] #{_1.message}" })
        @buffer[name].clear
      end
    end
  end
 
  def ingest_file(path, server: nil)
    name = server || File.basename(path, ".*")
    File.foreach(path) { |l| ingest(l.chomp, server: name) }
  end
 
  def summary = { total_alerts: @alerts.size, by_rule: @alerts.group_by(&:rule).transform_values(&:size) }
 
  private
 
  def parse(line, server)
    m = line.match(/\A\[(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})\]\s*(\w+)\s*(.+)\z/)
    return unless m
    LogEntry.new(timestamp: Time.parse(m[1]), level: m[2].downcase, server: server, message: m[3])
  rescue ArgumentError
    nil
  end
end

評価ポイント: File.foreach でメモリを圧迫しないストリーム処理。スライディングウィンドウでの閾値チェック。アラートにサンプル行を添付。


問題8: 小規模 RAG パイプライン — 検索拡張生成

「社内ドキュメントを検索して質問に答える簡易 RAG パイプラインを実装してください。TF-IDF ベースの検索部分を実装してください」

チャンキング、ベクトル化、類似度検索を分離して実装する。

class SimpleRag
  Chunk = Struct.new(:id, :text, :source, :vector, keyword_init: true)
  Result = Struct.new(:chunk, :score, keyword_init: true)
 
  def initialize(chunk_size: 200, overlap: 50)
    @chunk_size = chunk_size
    @overlap = overlap
    @chunks = []
    @idf = {}
  end
 
  def add_document(text, source: "unknown")
    splits = split(text)
    splits.each_with_index { |s, i| @chunks << Chunk.new(id: "#{source}:#{i}", text: s, source: source, vector: nil) }
    rebuild_index
    splits.size
  end
 
  def search(query, top_k: 3)
    qvec = tfidf(query)
    @chunks.map { |c| Result.new(chunk: c, score: cosine(qvec, c.vector)) }
           .select { _1.score > 0 }
           .sort_by { -_1.score }
           .first(top_k)
  end
 
  def answer(query, top_k: 3)
    results = search(query, top_k: top_k)
    return "関連ドキュメントが見つかりませんでした。" if results.empty?
    results.map { |r| "--- #{r.chunk.source} (#{r.score.round(3)}) ---\n#{r.chunk.text}" }.join("\n\n")
  end
 
  private
 
  def split(text)
    words = text.split
    step = [(@chunk_size - @overlap), 1].max
    (0...words.size).step(step).map { |i| words[i, @chunk_size].join(" ") }.reject(&:empty?)
  end
 
  def rebuild_index
    df = Hash.new(0)
    @chunks.each { |c| tokenize(c.text).uniq.each { |t| df[t] += 1 } }
    @idf = df.transform_values { |v| Math.log(@chunks.size.to_f / v) }
    @chunks.each { |c| c.vector = tfidf(c.text) }
  end
 
  def tokenize(text) = text.downcase.scan(/[a-z0-9\p{Han}\p{Hiragana}\p{Katakana}]+/)
 
  def tfidf(text)
    terms = tokenize(text)
    tf = terms.tally
    total = terms.size.to_f
    tf.each_with_object({}) { |(t, c), h| h[t] = (c / total) * @idf.fetch(t, 0) if @idf[t] }
  end
 
  def cosine(a, b)
    common = a.keys & b.keys
    return 0.0 if common.empty?
    dot = common.sum { |k| a[k] * b[k] }
    dot / (Math.sqrt(a.values.sum { _1**2 }) * Math.sqrt(b.values.sum { _1**2 }))
  end
end

評価ポイント: チャンキング・インデックス構築・検索が明確に分離。オーバーラップ付きチャンクでコンテキスト欠落を防ぐ。日本語トークナイズに Unicode プロパティを使用。

INFO

この問題は「AI を作れるか」ではなく「AI 製品の仕組みを顧客に説明できるか」を見ている。「本番では Embedding API と Vector DB を使いますが、原理を示すためにスクラッチ実装します」と前置きすると効果的。


コーディング面接の「隠れた評価軸」

「ソウタさん、コードの品質以外にも見られてるんですか?」

ユウキがペンを止めて聞いた。

「むしろ、コード以外の部分で差がつく」とカイが答えた。

Loading diagram...

質問力 — 曖昧さを解消する

面接の最初の10分で適切な質問ができるかが合否を分ける。「入力データの想定サイズは?」「エラー時は中断か続行か?」「マルチスレッド前提か?」「ユーザーはエンジニアか非エンジニアか?」

トレードオフの説明

「LRU にしたのは実装が簡単で予測可能だからです。LFU のほうがヒット率は高いですが、複雑さを考えるとまず LRU で始めてメトリクスを見てから移行を検討します」

テストの考え方

FDE は 正常系(典型入力)、境界値(空・上限・ゼロ)、異常系(ネットワーク断・不正データ)、顧客の失敗パターン(「顧客は絶対にこういうデータを送ってくる」)の4軸で考える。

顧客視点

エラーメッセージは顧客が読んで理解できるか。レスポンスに「次にどうすべきか」が含まれているか。ログに運用者が必要とする情報があるか。


練習方法と対策タイムライン

「よし、じゃあ具体的な練習プランを組もう」

ソウタはカレンダーアプリを開き、ユウキの面接日までの2週間を見渡した。

2週間プラン

内容
1週目1〜2日CSV パーサー + エッジケース追加
1週目3〜4日レートリミッター + Webhook 受信
1週目5〜6日設定管理 CLI + リトライ戦略
1週目7日振り返り模擬面接(45分通し)
2週目8〜10日キャッシュ + ログ分析 + RAG
2週目11〜12日全問エッジケース + ペアプロ練習
2週目13日本番形式模擬面接(60分)
2週目14日軽い復習 + 休息

模擬面接の進め方

Loading diagram...

ペアプロ練習では「質問の的確さ」「コードの可読性」「トレードオフの説明」「顧客視点の発言」の4観点でフィードバックをもらう。

WARNING

練習では必ず「声に出しながらコードを書く」癖をつけること。FDE 面接はペアプロ形式であり、沈黙の30分は最悪の印象を与える。実況が自然にできるまで練習する。


エピローグ: 自信の源

2週間後——。

ユウキは面接を終えてオフィスに戻ってきた。ソウタとカイが待つデスクに近づくと、静かに微笑んだ。

「どうだった?」

「CSV パーサーと、レートリミッターの組み合わせ問題が出ました」

「で?」

「最初の10分で5つ質問しました。"CSV のエンコーディングは? ヘッダー行は保証されますか? エラー行は何パーセント想定ですか? レートリミットの単位はリクエスト数ですか、データ量ですか? マルチテナントの想定テナント数は?"って」

カイが頷いた。「それで面接官の反応は?」

「"いい質問ですね"って、3回言われました」

ソウタは笑った。

「それはもう受かったようなもんだよ。FDE の面接は、コードを書く前に勝負が決まるんだ」

「LeetCode の問題を解ける人は多い。でも、壊れた CSV を受け取って"これ、どの行が壊れていますか?"とまず聞ける人は少ない。FDE 面接は、その"まず聞く力"を見ている」——カイ