面接対策:コーディングラウンド — LeetCode ではない、FDE の実践問題
「ソウタさん、FDE の面接って、やっぱり LeetCode ゴリゴリですか?」
後輩エンジニアのユウキが、不安そうな顔でそう切り出した。Arclight AI のオフィスで、ソウタがコーヒーを淹れている最中のことだった。
ユウキは社内異動で FDE チームへの参加を希望しており、来月のコーディング面接に向けて準備を始めたところだった。手元には「競技プログラミング 300問」と書かれた分厚い参考書がある。
ソウタは苦笑した。
「その本、閉じていいよ。FDE の面接は LeetCode じゃない。二分木を反転させる問題は出ない」
「え、じゃあ何が出るんですか?」
「顧客の壊れた CSV を直す問題とか、Webhook の冪等処理を書く問題とか。つまり——月曜日の朝に実際にやる仕事が出る」
ユウキの目が変わった。
FDE コーディング面接の特徴
LeetCode との違い
FDE のコーディング面接は、一般的なソフトウェアエンジニアの面接とは根本的に異なる。
| 観点 | 一般的なコーディング面接 | FDE コーディング面接 |
|---|---|---|
| 問題の性質 | アルゴリズム・データ構造 | 実務シナリオの再現 |
| 評価基準 | 計算量の最適化 | 可読性・拡張性・エッジケース |
| 入力データ | 整形済み配列・グラフ | 壊れた CSV・不正な JSON |
| 正解 | 1つの最適解 | トレードオフを説明した上での選択 |
| 質問の期待 | ヒントを求める | 要件を明確にする |
| テスト | 自動テストが通る | 顧客が困るケースを自分で考える |
INFO
FDE 面接の核心は「この人と一緒に顧客先に行って、問題を解決できるか?」という問いに尽きる。アルゴリズムの知識よりも、曖昧な要件から実用的なコードを書く力が試される。
評価軸と時間配分
面接官が見ているのは 実用性・可読性・エッジケース対応・顧客視点 の4軸だ。
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 を使いますが、原理を示すためにスクラッチ実装します」と前置きすると効果的。
コーディング面接の「隠れた評価軸」
「ソウタさん、コードの品質以外にも見られてるんですか?」
ユウキがペンを止めて聞いた。
「むしろ、コード以外の部分で差がつく」とカイが答えた。
質問力 — 曖昧さを解消する
面接の最初の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日 | 軽い復習 + 休息 |
模擬面接の進め方
ペアプロ練習では「質問の的確さ」「コードの可読性」「トレードオフの説明」「顧客視点の発言」の4観点でフィードバックをもらう。
WARNING
練習では必ず「声に出しながらコードを書く」癖をつけること。FDE 面接はペアプロ形式であり、沈黙の30分は最悪の印象を与える。実況が自然にできるまで練習する。
エピローグ: 自信の源
2週間後——。
ユウキは面接を終えてオフィスに戻ってきた。ソウタとカイが待つデスクに近づくと、静かに微笑んだ。
「どうだった?」
「CSV パーサーと、レートリミッターの組み合わせ問題が出ました」
「で?」
「最初の10分で5つ質問しました。"CSV のエンコーディングは? ヘッダー行は保証されますか? エラー行は何パーセント想定ですか? レートリミットの単位はリクエスト数ですか、データ量ですか? マルチテナントの想定テナント数は?"って」
カイが頷いた。「それで面接官の反応は?」
「"いい質問ですね"って、3回言われました」
ソウタは笑った。
「それはもう受かったようなもんだよ。FDE の面接は、コードを書く前に勝負が決まるんだ」
「LeetCode の問題を解ける人は多い。でも、壊れた CSV を受け取って"これ、どの行が壊れていますか?"とまず聞ける人は少ない。FDE 面接は、その"まず聞く力"を見ている」——カイ