AIが書いた「完璧な」コード、シニアエンジニアに却下される
2026-02-27
AI writes code. Experience writes code that survives.
出典: AI Wrote ‘Perfect’ Code. My Senior Dev Rejected It. Here’s Why He Was Right.
金曜日の午後4時47分
プルリクエストの準備が整った。
Claudeを使って認証ミドルウェアを書いた。プロンプトを2回打っただけ。テストはパス、リンターも文句なし。あとはマージして週末を楽しむだけだ。
そこにSlackの通知が届いた。
「PR却下。月曜日に話しましょう」
意味がわからなかった。コードは完璧に動いているのに。
月曜の朝、自分のAIコードに対する考え方が根本から変わる出来事が起きた。
「動いていた」コード
Claudeが書いてくれたのはこんなコードだった。
async function authenticate(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = await User.findById(decoded.userId);
if (!req.user) {
return res.status(401).json({ error: 'Invalid token' });
}
next();
} catch (error) {
return res.status(401).json({ error: 'Authentication failed' });
}
}一見、何も問題なさそうだ。テスト結果も完璧だった。
✓ 正常なトークン → 認証成功
✓ トークンなし → 401エラー
✓ 無効なトークン → 401エラー
✓ 期限切れトークン → 401エラーいったい何が問題なのか。
月曜日の朝、Marcusとの会話
シニアエンジニアのMarcusがコーヒーを片手に話しかけてきた。
Marcus:「このミドルウェア、説明してみて。何をするコードなの?」
自分:「ユーザーを認証します。JWTトークンを確認して、検証して、ユーザーを読み込む処理です」
Marcus:「なるほど。じゃあ、データベースが落ちたらどうなる?」
自分:「えっと……エラーが発生します?」
Marcus:「何行目で?」
コードを見つめる。「……10行目です。await User.findById のところ」
Marcus:「それで、次はどうなる?」
自分:「catchブロックが捕まえて、401を返します」
Marcus:「つまり、データベースが落ちているとき、ユーザーには『認証失敗』が返るわけだ」
自分:(まずい)「……はい」
Marcus:「それって正確な情報?」
自分:(冷や汗が出てくる)「いいえ。認証自体は成功しています。データベースが落ちているだけで」
Marcus:「そのとおり。では、データベースが落ちているときに1000人のユーザーがログインしようとしたら、何が起きる?」
AIが見えていなかった問題
データベースが落ちると、ログインのたびにこんなことが起きる。
- トークンの検証は成功する(DBは不要)
User.findByIdがエラーを投げる- catchブロックが「認証失敗」を返す
- ユーザーはパスワードが間違っていると思い込む
- もう一度ログインを試みる
- これが1000人規模で繰り返される
結果として、すでに苦しんでいるデータベースが大量のリトライでさらに追い詰められる。
Marcus:「8ヶ月前に全く同じことが起きた。AWSの請求が1万2400ドル跳ね上がって、4時間ダウンした」
自分:「でも……テストはパスしていたのに」
Marcus:「君のテストは、『物事がうまくいかないとき』を試していなかった」
AIに欠けていた3つの視点
AIはハッピーパス(正常系)で動くコードを書く。Marcusは本番環境で生き残るコードを書く。AIが見落としていたのは次の3点だ。
1. サーキットブレーカー
外部サービスが落ちているなら、そもそも呼び出しを止める。
// AIが書いたコード
req.user = await User.findById(decoded.userId);
// 本当に必要だったコード
if (dbCircuitBreaker.isOpen()) {
// DBが落ちているのでユーザー検索をスキップ
req.user = { id: decoded.userId, cached: true };
} else {
try {
req.user = await User.findById(decoded.userId);
} catch (error) {
dbCircuitBreaker.recordFailure();
throw error;
}
}2. エラーの分類
すべてのエラーは同じではない。
// AIが書いたコード
catch (error) {
return res.status(401).json({ error: 'Authentication failed' });
}
// 本当に必要だったコード
catch (error) {
if (error.name === 'JsonWebTokenError') {
// トークンが本当に無効
return res.status(401).json({ error: 'Invalid token' });
}
if (error.name === 'TokenExpiredError') {
// 期限切れ。ユーザーはトークンを更新できる
return res.status(401).json({
error: 'Token expired',
code: 'TOKEN_EXPIRED'
});
}
// DBやネットワークの問題。認証失敗ではない
logger.error('Auth middleware error', { error, userId: decoded.userId });
return res.status(503).json({
error: 'Service temporarily unavailable',
code: 'SERVICE_ERROR'
});
}3. リトライの制御
ユーザーが何度も試みたときのことを、AIは考えない。
// AIが書かなかったコード
if (error.code === 'ECONNREFUSED') {
// クライアントにすぐリトライさせない
res.setHeader('Retry-After', '60');
return res.status(503).json({
error: 'Service temporarily unavailable',
retryAfter: 60
});
}AIとシニアエンジニアの本質的な違い
AIが書くコードは、
- 想定された入力を処理できる
- テストをパスする
- きれいに見える
- ローカル環境で動く
シニアエンジニアが書くコードは、
- 本番障害を生き延びる
- 障害時に優雅に失敗する
- 下流のサービスを守る
- 「すべては壊れる」前提で設計されている
AIは深夜3時にデータベースのサーキットブレーカーがないせいで叩き起こされたことがない。
Marcusにはある。
それが違いだ。
コードを書き直した結果
その会話の後、ミドルウェアを書き直した。
- 修正前(AI版): 23行
- 修正後(本番対応版): 87行
増えた64行の中身は、
- サーキットブレーカーの統合
- 適切なエラー分類
- Retry-Afterヘッダー
- ログとメトリクスによる可観測性
- グレースフルデグラデーション(緩やかな機能低下)
- キャッシュフォールバック
AIは2分で23行をくれた。Marcusは23行に何が足りないかを教えてくれた。
「動く」だけでは足りない理由
昔の自分は言っただろう。「でも、コードは動いているじゃないですか」と。
Marcusの答えはこうだ。
「君のラップトップではね。データベースがlocalhostにあって、ネットワーク遅延がゼロで、何も壊れない世界では」
本番環境はlocalhostではない。本番環境とは、
- データベースが落ちる場所
- ネットワークがタイムアウトする場所
- キャッシュが古くなる場所
- 負荷が突然スパイクする場所
- サービスが劣化する場所
- ユーザーが何度もリトライする場所
ローカルで「動く」コードは出発点にすぎない。本番で生き残るコードが、本当のゴールだ。
AIは出発点を与えてくれる。経験がゴールを教えてくれる。
あの障害の全容
Marcusが話していた1万2400ドルの事件、実際に何が起きたかというとこうだ。
- 2:47 データベースのプライマリがレプリカに切り替わる
- 2:48 レプリカのデータは30秒遅れている(レプリケーションラグ)
- 2:48 認証ミドルウェアがレプリカにアクセス
- 2:48 ユーザーのデータがまだレプリカに反映されていないため「認証失敗」
- 2:48 ユーザーが一斉にリトライ
- 2:49 1万件のリトライがレプリカに殺到
- 2:50 レプリカが処理しきれなくなる
- 2:51 レプリカがクラッシュ
- 3:15 手動対応が必要になる
- 6:30 サービス復旧
被害総額は、
- 4時間のダウンタイム
- AWSの請求1万2400ドル(トラフィック急増+緊急スケーリング)
- インシデント対応とポストモーテムに40時間
- 失われたユーザーの信頼(計測不能)
根本原因は、認証ミドルウェアが「トークンが無効」と「データベースが使えない」を区別していなかったこと。
そのミドルウェアは、AIが書いてくれたコードとまったく同じ構造をしていた。
自分が変えたこと
今もAIは使っている。ただし、「タイピングが異常に速いけど本番障害を見たことがない新人エンジニア」として扱うようにした。
AIの役割: 最初のドラフトを書く
自分の役割: AIには見えないものをすべて加える
AIで書いたコードに対して、今は必ずこう問いかける。
- 外部サービスが落ちたらどうなる?
- 毎秒1000回呼ばれたらどうなる?
- ネットワークが遅いときはどうなる?
- データが古かったらどうなる?
- ユーザーが何度もリトライしたらどうなる?
- 深夜3時のデバッグに役立つエラーメッセージになっているか?
- 本番で状況を把握するためのメトリクスは十分か?
1年後
先週、データベースのフェイルオーバーが起きた。
あのとき書き直した認証ミドルウェアは、完璧に動いた。
リトライの嵐も、ユーザー向けエラーも、インシデントも、何も起きなかった。ユーザーには「サービスが一時的に混雑しています。60秒後にお試しください」と表示されただけで、ほとんどの人は気づきもしなかった。
それはAIが良いコードを書いたからじゃない。
Marcusが「良いコード」の本当の意味を教えてくれたからだ。
そのミドルウェアは今も本番で動いている。行数は今や147行になった(最初は23行だった)。増えた124行、一行一行に、過去の障害から学んだ教訓が込められている。
AIが書いたのは23行。経験が書いたのは124行。
Marcusは今も自分のPRをレビューしている。今でも自分が見落としたものを見つける。それがこの仕事だ。