-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathfeed.rss
More file actions
1888 lines (1389 loc) · 233 KB
/
feed.rss
File metadata and controls
1888 lines (1389 loc) · 233 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Pepabo Tech Portal</title>
<link>https://tech.pepabo.com/</link>
<description>GMOペパボのエンジニア・デザイナーによる技術情報のポータルサイト</description>
<atom:link href="https://tech.pepabo.com/feed.rss" rel="self" type="application/rss+xml"/>
<pubDate>Fri, 01 May 2026 00:00:00 +0900</pubDate>
<item>
<title>ORDER BY id DESC が招くインデックス誤選択 — 直近のレコードを N 件取り出すクエリを実測 約 658 倍に高速化</title>
<link>https://tech.pepabo.com/2026/05/01/minne-order-by-limit-pitfall/</link>
<guid>https://tech.pepabo.com/2026/05/01/minne-order-by-limit-pitfall/</guid>
<pubDate>Fri, 01 May 2026 00:00:00 +0900</pubDate>
<description><h2 id="はじめに">はじめに</h2>
<p>「<strong>ある所有者の直近のレコード(注文・投稿・取引など)から、最新の N 件を取りたい</strong>」というユースケースは、Web アプリケーションを書いていれば頻出するパターンです。Rails 風に書けば次のような形になります。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">current_user</span><span class="p">.</span><span class="nf">orders</span>
<span class="p">.</span><span class="nf">where</span><span class="p">.</span><span class="nf">not</span><span class="p">(</span><span class="ss">id: </span><span class="vi">@order</span><span class="p">.</span><span class="nf">id</span><span class="p">)</span> <span class="c1"># 自分自身は除外する</span>
<span class="p">.</span><span class="nf">order</span><span class="p">(</span><span class="ss">id: :desc</span><span class="p">)</span> <span class="c1"># 「直近」を id 降順で表現</span>
<span class="p">.</span><span class="nf">limit</span><span class="p">(</span><span class="n">n</span><span class="p">)</span>
</code></pre></div>
<p>このパターンが、minne では特定のユーザーで安定的に MySQL のクエリタイムアウトを起こしていました。</p>
<div class="highlight"><pre class="highlight plaintext"><code>Mysql2::Error: Query execution was interrupted, maximum statement execution time exceeded
</code></pre></div>
<p>調査の結果、原因は <strong><code>ORDER BY id DESC</code> + <code>LIMIT N</code> の組み合わせによる MySQL optimizer のインデックス誤選択</strong> で、worst case で <strong>2,300 万行をスキャン</strong> して <code>max_statement_time</code> を踏み抜いていることが判明しました。<code>ORDER BY</code> のカラムを既存インデックスに合わせて変えるだけで、</p>
<ul>
<li>スキャン推定行数: <strong>23,297,383 行 → 65,024 行(約 358 倍削減)</strong></li>
<li>実測実行時間: <strong>56.6ms → 0.086ms(約 658 倍高速化)</strong></li>
</ul>
<p>まで縮みました。本記事では、その原因分析と修正アプローチを EXPLAIN ANALYZE とあわせて紹介します。「直近のレコードを N 件取り出す」クエリを書くすべての人に還元できる知見となることを願っています。</p>
<ol id="markdown-toc">
<li><a href="#はじめに" id="markdown-toc-はじめに">はじめに</a></li>
<li><a href="#何が起きていたか" id="markdown-toc-何が起きていたか">何が起きていたか</a></li>
<li><a href="#原因--order-by-id-desc--limit-n-で-optimizer-が-primary-を選ぶ" id="markdown-toc-原因--order-by-id-desc--limit-n-で-optimizer-が-primary-を選ぶ">原因 — <code>ORDER BY id DESC</code> + <code>LIMIT N</code> で optimizer が PRIMARY を選ぶ</a> <ol>
<li><a href="#limit-を外せば直るのではない" id="markdown-toc-limit-を外せば直るのではない">「LIMIT を外せば直る」のではない</a></li>
</ol>
</li>
<li><a href="#修正--order-by-を既存インデックスのソート順に揃える" id="markdown-toc-修正--order-by-を既存インデックスのソート順に揃える">修正 — <code>ORDER BY</code> を既存インデックスのソート順に揃える</a> <ol>
<li><a href="#補足-id-desc-と-ordered_at-desc-の意味的な差" id="markdown-toc-補足-id-desc-と-ordered_at-desc-の意味的な差">補足: <code>id DESC</code> と <code>ordered_at DESC</code> の意味的な差</a></li>
</ol>
</li>
<li><a href="#比較サマリ" id="markdown-toc-比較サマリ">比較サマリ</a></li>
<li><a href="#学び" id="markdown-toc-学び">学び</a></li>
<li><a href="#参考" id="markdown-toc-参考">参考</a></li>
</ol>
<h2 id="何が起きていたか">何が起きていたか</h2>
<p>問題のあったコードは、ある作家の <strong>直近の注文を N 件取り出す</strong> ためのクエリでした。本筋に関係しない部分を削ぎ落とすと、概形は次のようになります。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">current_user</span><span class="p">.</span><span class="nf">orders</span>
<span class="p">.</span><span class="nf">where</span><span class="p">.</span><span class="nf">not</span><span class="p">(</span><span class="ss">id: </span><span class="vi">@order</span><span class="p">.</span><span class="nf">id</span><span class="p">)</span> <span class="c1"># 自分自身は除外する</span>
<span class="p">.</span><span class="nf">order</span><span class="p">(</span><span class="ss">id: :desc</span><span class="p">)</span> <span class="c1"># 「直近」を id 降順で表現</span>
<span class="p">.</span><span class="nf">limit</span><span class="p">(</span><span class="n">n</span><span class="p">)</span>
</code></pre></div>
<p><code>current_user</code>(作家)の注文のうち、現在のレコード自身を除いて <code>id</code> 降順に N 件取るだけ。発行される SQL もざっくり次のような形です。</p>
<div class="highlight"><pre class="highlight sql"><code><span class="k">SELECT</span> <span class="n">sales</span><span class="p">.</span><span class="o">*</span> <span class="k">FROM</span> <span class="n">sales</span>
<span class="k">WHERE</span> <span class="n">sales</span><span class="p">.</span><span class="n">creator_id</span> <span class="o">=</span> <span class="o">?</span> <span class="k">AND</span> <span class="n">sales</span><span class="p">.</span><span class="n">id</span> <span class="o">!=</span> <span class="o">?</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">sales</span><span class="p">.</span><span class="n">id</span> <span class="k">DESC</span>
<span class="k">LIMIT</span> <span class="n">N</span><span class="p">;</span>
</code></pre></div>
<p>一見、<code>creator_id</code> で絞って <code>id</code> 降順に N 件取るだけのシンプルなクエリです。<strong>注文数が少ない作家では即座に返ります。</strong> ところが累計数万件規模の注文を持つような作家では、このクエリが安定してタイムアウトしていました。</p>
<h2 id="原因--order-by-id-desc--limit-n-で-optimizer-が-primary-を選ぶ">原因 — <code>ORDER BY id DESC</code> + <code>LIMIT N</code> で optimizer が PRIMARY を選ぶ</h2>
<p><code>sales</code> テーブルには <code>(creator_id, ordered_at)</code> の複合インデックス <code>index_sales_on_creator_id_ordered_at</code> が張ってあります。<strong><code>(creator_id, id)</code> のインデックスはありません。</strong></p>
<p>実際の作家(sale 約 6.5 万件保有)で EXPLAIN ANALYZE を取った結果が次のとおりです。なお <code>EXPLAIN ANALYZE</code> は通常の <code>EXPLAIN</code> と違って <strong>クエリを実際に実行した上で</strong> 実測値を返すため、本番 DB に対して使う場合は負荷影響が伴います。本記事の計測も、実行タイミングや対象クエリを選ぶなど <strong>影響範囲が最小となるよう注意した上で</strong> 取得しています。</p>
<div class="highlight"><pre class="highlight plaintext"><code>EXPLAIN ANALYZE SELECT sales.* FROM sales
WHERE sales.creator_id = xxxxx AND sales.id != yyyyy
ORDER BY sales.id DESC
LIMIT 10;
-&gt; Limit: 10 row(s) (cost=66636 rows=10) (actual time=6.26..56.6 rows=10 loops=1)
-&gt; Filter: ((sales.creator_id = xxxxx) and (sales.id &lt;&gt; yyyyy)) (cost=66636 rows=34158) (actual time=6.26..56.6 rows=10 loops=1)
-&gt; Index range scan on sales using PRIMARY over (id &lt; yyyyy) OR (yyyyy &lt; id) (reverse)
(cost=66636 rows=23.3e+6) (actual time=0.0213..54.1 rows=27907 loops=1)
</code></pre></div>
<p><code>possible_keys</code> には <code>PRIMARY</code> と <code>index_sales_on_creator_id_ordered_at</code> の両方が挙がっていましたが、optimizer は <strong>PRIMARY 逆順スキャン</strong> を選択しました。<code>ORDER BY id DESC LIMIT 10</code> を満たすのに「PRIMARY を逆順に舐めて、<code>creator_id</code> にヒットした行が 10 件揃ったところで打ち切る」プランを最も低コストと見積もったわけです。</p>
<p>これは MySQL が <code>ORDER BY ... LIMIT</code> の組み合わせに対して行う最適化(書籍『<a href="https://gihyo.jp/book/2024/978-4-297-14184-4">MySQL運用・管理[実践]入門</a>』では <strong>ORDER BY LIMIT 最適化</strong> と呼ばれています)が裏目に出たケースです。同書第 4 章「ロックとクエリ実行計画」では、<code>ORDER BY Population DESC LIMIT 10</code> のような単純なケースについてこう説明されています。</p>
<blockquote>
<p>idx_population によって Population はすでに昇順(暗黙のソート順は ASC)にソートされており、B+Tree 形式をしているためこのインデックスを昇順にたどるのと逆順にたどるのはほぼ等価に可能です(よってインデックスを使って逆順に処理したことを示す Extra: Backward index scan が追加されています)。</p>
<p>——『MySQL運用・管理[実践]入門』p.93</p>
</blockquote>
<p>MySQL 8.0 で導入された <strong>Backward index scan</strong> によって、<code>ORDER BY id DESC</code> は PRIMARY を逆順に辿ればファイルソート無しに満たせます。さらに <code>LIMIT N</code> が付くと「途中で N 件揃った時点で打ち切れる」という早期終了が見込めるため、optimizer の目には PRIMARY 逆順スキャンが極めて魅力的なプランに映る、というわけです。</p>
<p>なぜこれが罠になるかは、<code>sales</code> の PRIMARY が <strong>全作家の注文が時系列にミックスされた 1 本の列</strong> だと考えると見えてきます。「PRIMARY を逆順に少し舐めれば N 件揃う」という見立ては、暗黙のうちに <strong>対象作家の注文行が PRIMARY の最新側に十分密集している</strong> ことを前提にしています。</p>
<ul>
<li>直近で注文を捌いている作家: 最新側の id 帯に自分の注文行が頻繁に現れる → 数千行のスキャンで N 件揃う</li>
<li>直近で注文を出していない作家: 最新側にはほぼ存在せず、自分の注文行が出てくる id 帯まで遡る必要がある → スキャン量が膨大に</li>
</ul>
<p>これは『MySQL運用・管理[実践]入門』が示す ORDER BY LIMIT 最適化の <strong>最悪ケース</strong> に相当します。</p>
<blockquote>
<p>最悪ケースは Continent=’Asia’ を満たす行が確定する前にインデックスフルスキャンが終わるケース(すべて Continent にヒットしない)。</p>
<p>——『MySQL運用・管理[実践]入門』p.94</p>
</blockquote>
<p>書籍は <code>Continent='Asia'</code> でフィルタにヒットせずインデックスを最後まで舐めきるケースを挙げていますが、本記事の事例は <code>creator_id = xxxxx</code> でヒットする行が PRIMARY 末尾に十分量現れない、という述語の差があるだけで構造は同じです。<code>WHERE</code> 列と <code>ORDER BY</code> 列のインデックスが分離している限り、optimizer は filtered の見積もりに基づいて「フィルタは早期に効くだろう」と楽観視し、それが外れた瞬間にスキャン量がテーブル全体まで線形に伸びていきます。</p>
<p>23.3M はあくまで rows 推定の上限値で、上のテスト作家では actual 27,907 行で済んでいます。一方で本番では、最新側に行が少ない作家のケースで <code>max_statement_time</code> を実際に踏み抜いていました。テーブルが伸びるほど worst case のスキャン量も線形に増えていく、という構造になっていたわけです。</p>
<h3 id="limit-を外せば直るのではない">「LIMIT を外せば直る」のではない</h3>
<p>ここで自然な疑問は「じゃあ <code>LIMIT</code> を外せば PRIMARY 誘導は回避できるのでは?」というものです。実際 <code>LIMIT</code> を外すと optimizer の選択は変わります。</p>
<div class="highlight"><pre class="highlight plaintext"><code>EXPLAIN ANALYZE SELECT sales.* FROM sales
WHERE sales.creator_id = xxxxx AND sales.id != yyyyy
ORDER BY sales.id DESC;
-&gt; Sort: sales.id DESC (cost=63195 rows=65024) (actual time=257..270 rows=35353 loops=1)
-&gt; Index lookup on sales using index_sales_on_creator_id_ordered_at (creator_id=xxxxx),
with index condition: (sales.id &lt;&gt; yyyyy) (cost=63195 rows=65024) (actual time=0.0305..209 rows=35353 loops=1)
</code></pre></div>
<p><code>(creator_id, ordered_at)</code> インデックスが採用されて creator スコープに閉じる代わりに、<strong><code>id DESC</code> で並べ直すための filesort(<code>Sort</code> ノード)が必要</strong>になります。実測 270ms。LIMIT 有りでの 56.6ms より遅く、テーブルが伸びれば線形に悪化します。</p>
<p>つまり罠の核心は <code>LIMIT</code> ではなく、<strong><code>WHERE</code> 列と <code>ORDER BY</code> 列が別インデックスにまたがるクエリの組み立て方</strong> そのものです。<code>LIMIT</code> の有無で optimizer は別の悪い選択肢に逃げるだけです。</p>
<ul>
<li><strong>LIMIT N + <code>id DESC</code></strong>: PRIMARY 逆順スキャン誘導 → テーブル全体に対する worst case</li>
<li><strong>LIMIT 無し + <code>id DESC</code></strong>: <code>(creator_id, ordered_at)</code> + filesort → creator の sale 件数に対する線形コスト</li>
</ul>
<p>『MySQL運用・管理[実践]入門』は次のように結論しています。</p>
<blockquote>
<p>なお、WHERE と ORDER BY LIMIT 最適化を同時に満たすインデックスは、INDEX(Continent, Population) でこれが最速になります。</p>
<p>——『MySQL運用・管理[実践]入門』p.95</p>
</blockquote>
<p>本件に当てはめれば <code>(creator_id, id)</code> の複合インデックスを足すのが理論的な最速解で、両方の問題を一気に解消できます。ただし数千万行規模の <code>sales</code> テーブルへの index 追加は運用コストが大きく、なるべく避けたいところです。本記事の修正は <strong>既存の <code>(creator_id, ordered_at)</code> を活かす形にアプリ側のクエリを寄せる</strong> というアプローチを取りました。</p>
<h2 id="修正--order-by-を既存インデックスのソート順に揃える">修正 — <code>ORDER BY</code> を既存インデックスのソート順に揃える</h2>
<p>修正の本丸は <code>ORDER BY id DESC</code> を <code>ORDER BY ordered_at DESC</code> に切り替えることです。これだけで <code>(creator_id, ordered_at)</code> インデックスが <strong>WHERE と ORDER BY の両方</strong> で使えるようになり、optimizer の選択が劇的に変わります。</p>
<div class="highlight"><pre class="highlight sql"><code><span class="k">SELECT</span> <span class="n">sales</span><span class="p">.</span><span class="o">*</span> <span class="k">FROM</span> <span class="n">sales</span>
<span class="k">WHERE</span> <span class="n">sales</span><span class="p">.</span><span class="n">creator_id</span> <span class="o">=</span> <span class="o">?</span> <span class="k">AND</span> <span class="n">sales</span><span class="p">.</span><span class="n">id</span> <span class="o">!=</span> <span class="o">?</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">sales</span><span class="p">.</span><span class="n">ordered_at</span> <span class="k">DESC</span>
<span class="k">LIMIT</span> <span class="n">N</span><span class="p">;</span>
</code></pre></div>
<p>同じ作家で EXPLAIN ANALYZE を取り直したのが次の結果です。</p>
<div class="highlight"><pre class="highlight plaintext"><code>EXPLAIN ANALYZE SELECT sales.* FROM sales
WHERE sales.creator_id = xxxxx AND sales.id != yyyyy
ORDER BY sales.ordered_at DESC
LIMIT 10;
-&gt; Limit: 10 row(s) (cost=63494 rows=10) (actual time=0.0296..0.0863 rows=10 loops=1)
-&gt; Filter: (sales.id &lt;&gt; yyyyy) (cost=63494 rows=32512) (actual time=0.0288..0.0848 rows=10 loops=1)
-&gt; Index lookup on sales using index_sales_on_creator_id_ordered_at (creator_id=xxxxx) (reverse)
(cost=63494 rows=65024) (actual time=0.0277..0.0827 rows=10 loops=1)
</code></pre></div>
<p><code>(creator_id, ordered_at)</code> を逆順に index lookup し、<code>LIMIT 10</code> で <strong>本当に 10 行だけ読む</strong> 計画になりました。filesort も無く、actual rows が 10。実測 0.086ms です。</p>
<p>参考までに、<code>LIMIT</code> を外した場合の EXPLAIN ANALYZE も載せておきます。</p>
<div class="highlight"><pre class="highlight plaintext"><code>EXPLAIN ANALYZE SELECT sales.* FROM sales
WHERE sales.creator_id = xxxxx AND sales.id != yyyyy
ORDER BY sales.ordered_at DESC;
-&gt; Filter: (sales.id &lt;&gt; yyyyy) (cost=60713 rows=32512) (actual time=0.0286..213 rows=35353 loops=1)
-&gt; Index lookup on sales using index_sales_on_creator_id_ordered_at (creator_id=xxxxx) (reverse)
(cost=60713 rows=65024) (actual time=0.0273..209 rows=35353 loops=1)
</code></pre></div>
<p><code>LIMIT</code> を外しても <code>Sort</code> ノードは現れず、<code>(creator_id, ordered_at)</code> を順に舐めるだけのプランです。actual rows は creator スコープの 35,353 行で、実測 213ms。これは「対象 creator の sale を一通り読む」コストそのものであり、旧クエリのようにテーブル全体の 23M 行を舐めにいくのとは性質がまったく違います。</p>
<p>修正前後の違いを 4 通りの実験で見える化すると次のようになります(同じ作家・同じ条件で測定)。</p>
<table>
<thead>
<tr>
<th>Query</th>
<th>採用 key</th>
<th>filesort</th>
<th>actual rows</th>
<th><strong>actual time</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>id DESC</code> + <code>LIMIT 10</code></td>
<td><strong>PRIMARY</strong></td>
<td>no</td>
<td>27,907</td>
<td><strong>56.6 ms</strong></td>
</tr>
<tr>
<td><code>id DESC</code>(LIMIT なし)</td>
<td><code>..._ordered_at</code></td>
<td><strong>YES</strong></td>
<td>35,353</td>
<td>270 ms</td>
</tr>
<tr>
<td><strong><code>ordered_at DESC</code> + <code>LIMIT 10</code></strong></td>
<td><code>..._ordered_at</code></td>
<td>no</td>
<td>10</td>
<td><strong>0.086 ms</strong></td>
</tr>
<tr>
<td><code>ordered_at DESC</code>(LIMIT なし)</td>
<td><code>..._ordered_at</code></td>
<td>no</td>
<td>35,353</td>
<td>213 ms</td>
</tr>
</tbody>
</table>
<p><code>ordered_at DESC</code> 系は <strong>LIMIT 有無に関わらず</strong> filesort 無しで <code>(creator_id, ordered_at)</code> 一本で完結しています。LIMIT 有りの場合は早期打ち切りも効いて <code>actual rows = 10</code>、文字どおり「N 件だけ読む」状態になっています。</p>
<h3 id="補足-id-desc-と-ordered_at-desc-の意味的な差">補足: <code>id DESC</code> と <code>ordered_at DESC</code> の意味的な差</h3>
<p>旧実装は <code>id</code> 降順、新実装は <code>ordered_at</code> 降順なので、厳密には「並び順」の意味が変わります。<code>sales.ordered_at</code> が NULL なレコードや、バックフィルなどで <code>ordered_at</code> と <code>id</code> が逆転するレコードでは、選ばれる注文が変わる可能性があります。</p>
<p>今回のユースケースでは「直近の傾向に近いものが取れていれば十分」だったため許容しましたが、<strong>厳密に <code>id</code> の最大を取りたい場合は単純な置き換えはできない</strong> 点には注意が必要です。<code>(creator_id, id)</code> のインデックスを足すか、<code>(creator_id, ordered_at)</code> の上で取った後に再度 <code>id</code> で並べ替える、といった選択肢があります。</p>
<h2 id="比較サマリ">比較サマリ</h2>
<table>
<thead>
<tr>
<th>項目</th>
<th>旧 (<code>ORDER BY id DESC</code> + <code>LIMIT 10</code>)</th>
<th>新 (<code>ORDER BY ordered_at DESC</code> + <code>LIMIT 10</code>)</th>
</tr>
</thead>
<tbody>
<tr>
<td>採用 index</td>
<td>PRIMARY</td>
<td><code>index_sales_on_creator_id_ordered_at</code></td>
</tr>
<tr>
<td>スキャン推定行数</td>
<td>23,297,383</td>
<td>65,024(<strong>約 358 倍削減</strong>)</td>
</tr>
<tr>
<td>実 scan 行数</td>
<td>27,907</td>
<td>10</td>
</tr>
<tr>
<td>filesort</td>
<td>無し(PRIMARY backward)</td>
<td>無し(index backward)</td>
</tr>
<tr>
<td>実測実行時間</td>
<td>56.6 ms</td>
<td>0.086 ms(<strong>約 658 倍高速化</strong>)</td>
</tr>
<tr>
<td>追加 index</td>
<td>–</td>
<td>–</td>
</tr>
</tbody>
</table>
<p>既存 index の活用に閉じているため、<code>sales</code> テーブルに追加負担は発生せず、巨大テーブルへの DDL を一切避けられました。</p>
<h2 id="学び">学び</h2>
<p>今回の改善から得た学びを 3 つに整理します。</p>
<ul>
<li><strong>罠は LIMIT 単独でも <code>id DESC</code> 単独でもなく、<code>WHERE 列</code> と <code>ORDER BY 列</code> が別インデックスにまたがるクエリの組み立て方</strong>: LIMIT 有り → 別インデックス(PRIMARY)誘導、LIMIT 無し → filesort、どちらにしても遅くなる。fix は <strong><code>ORDER BY</code> を <code>WHERE</code> 列と組になっている既存インデックスのソート順に揃える</strong> こと</li>
<li><strong>EXPLAIN を取るまで「速いはず」は信用しない</strong>: <code>creator_id</code> で絞っているから速いだろう、ではなく、<code>possible_keys</code> と <code>key</code> を見て optimizer の実際の選択を確認する。本件も <code>possible_keys</code> には正解の index が出ていたが、<code>key</code> は PRIMARY だった。<code>EXPLAIN ANALYZE</code> まで取ると actual rows / actual time も見えて、見積もりとの乖離も追える(ただし <code>EXPLAIN ANALYZE</code> はクエリを実際に走らせるため、本番で取るなら影響範囲が最小となるよう注意するのが基本)</li>
<li><strong>巨大テーブルへの index 追加に頼らず、まずクエリ形を既存インデックスに寄せる</strong>: 数千万行規模のテーブルへの index 追加は運用コストが大きい。<code>(creator_id, ordered_at)</code> のような既存複合インデックスを活かす形にアプリ側のクエリを寄せるだけで、追加コストゼロで大幅改善が得られるケースは意外と多い</li>
</ul>
<p>これらの観点は、minne のように長く運用されるサービスで巨大テーブルと付き合う上で、繰り返し効いてくる考え方だと感じています。同じような「特定ユーザーで突然タイムアウトする」現象に出会った方の参考になれば幸いです。</p>
<h2 id="参考">参考</h2>
<ul>
<li>yoku0825・北川健太郎・tom__bo・坂井恵 著『<a href="https://gihyo.jp/book/2024/978-4-297-14184-4">MySQL運用・管理[実践]入門</a>』技術評論社, 2024 — 第 4 章「ロックとクエリ実行計画」が ORDER BY LIMIT 最適化と Backward index scan の理論的背景を丁寧に扱っており、本記事の現象を語彙化する上で大きく参考にしました</li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/order-by-optimization.html">MySQL :: MySQL 8.0 Reference Manual :: 8.2.1.16 ORDER BY Optimization</a> — MySQL 公式リファレンス</li>
</ul>
</description>
</item>
<item>
<title>生成AIの従量課金とどう付き合うか?AIサイトエージェント開発で実践した段階的コスト見積もり</title>
<link>https://tech.pepabo.com/2026/05/01/ai-app-cost-estimate/</link>
<guid>https://tech.pepabo.com/2026/05/01/ai-app-cost-estimate/</guid>
<pubDate>Fri, 01 May 2026 00:00:00 +0900</pubDate>
<description><h2 id="はじめに">はじめに</h2>
<p>こんにちは。ロリポップ・ムームードメイン事業部でエンジニアリングリードをしています <a href="https://x.com/kinosuke01">kinosuke01</a> といいます。</p>
<p>生成AIを組み込んだアプリケーションを事業として提供するとき、避けて通れないテーマが <strong>コスト</strong> です。</p>
<p>事業としてやっている以上、売上・利益・粗利率には当然ながら目標値があります。開発者としても「技術的に動けばOK」ではなく、その数字を前提にしたうえで、お金のことも勘案して設計や実装を進める必要があります。</p>
<p>ところが生成AIをAPI経由で使う場合、消費トークン数による従量課金となるため、事前にどの程度のコストになるのかを見積もるのが一筋縄ではいきません。</p>
<p>とくに生成AIが自律的に判断・分岐するワークフローだと、入力も出力もユーザーの指示内容に大きく依存するため、「このケースで n トークン」と簡単には言い切れません。</p>
<p>本記事では、AIサイトエージェント開発プロジェクトで実施した、<strong>段階的にコスト試算の精度を上げていくアプローチ</strong>を紹介します。完璧な見積もりを最初から作ろうとするのではなく、プロジェクトのフェーズごとに見積もり方法を変えていく話です。</p>
<h2 id="前提ai-サイトエージェントというサービス">前提:AI サイトエージェントというサービス</h2>
<p>私たちが開発している <a href="https://lolipop.jp/ai/site-agent/">AI サイトエージェント</a> は、「カフェのサイトを作りたい」「フリーランス向けのポートフォリオが欲しい」といった自然言語の指示を投げると、ページ構成・デザインテーマ・コンテンツまでを一括で生成してくれる Web サイト制作サービスです。生成したあとも、チャット越しに「トップのキャッチを変えて」「このセクションの写真を差し替えて」と伝えれば、AI が編集を代行してくれます。</p>
<p><img src="/blog/2026/05/01/ai-app-cost-estimate/img01.png" alt="img01" />
<img src="/blog/2026/05/01/ai-app-cost-estimate/img02.png" alt="img02" /></p>
<p>内部のワークフローは、<strong>決定論的なワークフローをベースに、一部のステップで生成AIが自律的に判断・分岐する</strong> 構造になっています。全部を生成AIに任せるのではなく、「ここは構造が決まっている」「ここは自由度を持たせたい」をフェーズごとに使い分けることで、品質と予測可能性を両立させる狙いです。</p>
<p>ざっくり図にすると、このような流れです。</p>
<p><img src="/blog/2026/05/01/ai-app-cost-estimate/flow.png" alt="flow" /></p>
<p>ラベルに「生成AI:」と書かれているブロックが生成AI呼び出しで、それ以外は決定論的な処理です。たとえば <code>PageAndSectionPlanner</code> は「ページ何枚構成にするか」「各ページにどのセクションを置くか」を、ユーザーの指示に応じて柔軟に決めます。一方で、決まったフォーマットの JSON が返ってこないと後続の処理が動かないので、Structured Output を使って構造を縛っています。</p>
<p>このような構造だと、単に「1 回のサイト生成で何トークン?」と問われても、ページ数・セクション数・モデルの揺らぎで大きく変わってきます。見積もりが難しいわけです。</p>
<h2 id="対応方針段階的に精度を上げる">対応方針:段階的に精度を上げる</h2>
<p>難しいのでやらない、というわけにはいきません。かといって最初から正確な数字を出すこともできません。そこでビジネス職と以下について合意しました。</p>
<ul>
<li>従量課金であり、実績に応じてコストが変動すること</li>
<li>開発の進行に合わせて、段階的に試算の精度を上げていくこと</li>
</ul>
<p>具体的には 3 つのフェーズに分けて見積もりを更新していきます。</p>
<table>
<thead>
<tr>
<th>フェーズ</th>
<th>何を使って見積もるか</th>
<th>精度</th>
</tr>
</thead>
<tbody>
<tr>
<td>1. 設計直後</td>
<td>ワークフロー骨組み + 各ステップの想定トークン数</td>
<td>概算</td>
</tr>
<tr>
<td>2. 実装後(検証環境)</td>
<td>実際の呼び出しログから集計した実績値</td>
<td>中精度</td>
</tr>
<tr>
<td>3. ローンチ後</td>
<td>本番の実トラフィックに基づくモニタリング</td>
<td>高精度</td>
</tr>
</tbody>
</table>
<p>以下、それぞれのフェーズで何をやったかを見ていきます。</p>
<h2 id="フェーズ-1設計段階でざっくり見積もる">フェーズ 1:設計段階でざっくり見積もる</h2>
<p>まず最初にやるべきは、ワークフロー全体の骨組みを設計することです。このとき、細部の実装よりも「どのステップで、どんなモデルを、どのくらいのトークン量で呼ぶか」を決めることに集中します。</p>
<p>たとえば <code>PageAndSectionPlanner</code> であれば、ざっくりこんな見積もりになります。</p>
<ul>
<li>モデル:Gemini 2.5 Flash</li>
<li>入力トークン:プロンプト(1,500)+ ユーザー指示(200)= 1,700</li>
<li>出力トークン:3 ページ分の JSON ≒ 800</li>
</ul>
<p>(※ 上記のトークン数は説明用の仮の値です。実際の値はプロンプトの内容や出力サイズによって変わります)</p>
<p>これを全ステップで行い、1 サイト生成あたりの概算を出します。そこに想定利用数(例:月間 n ユーザー × 平均 m 回/人)を掛けて、ざっくりとした月額コストを算出し、ビジネス職に第一報として共有します。</p>
<p>この段階では精度は荒いものの、</p>
<ul>
<li>「桁が合っているか」の感覚を関係者で揃える</li>
<li>「粗利率が破綻するオーダーではないか」の早期チェック</li>
</ul>
<p>という観点で十分に価値があります。桁が想定を超えていれば、そもそもワークフロー設計を見直すべきというシグナルになります。</p>
<h2 id="フェーズ-2実装してから実データで見積もる">フェーズ 2:実装してから実データで見積もる</h2>
<p>2 回目の見積もりに入ります。このフェーズの流れは大きく 3 ステップです。</p>
<ol>
<li>エッジケースは後回しにして、まず正常系が一通り動く状態まで実装する</li>
<li>生成AI呼び出しのトークン数を自動で DB に記録できるようにする</li>
<li>検証環境で実データを溜め、集計結果をもとに再見積もりする</li>
</ol>
<p>順に見ていきます。なお 2 の実装詳細は、記事末尾の「付録:トークン数を DB に記録する仕組み」に切り出しています。</p>
<h3 id="正常系を一通り動かす">正常系を一通り動かす</h3>
<p>このフェーズで大事なのは、<strong>エッジケースの対応は後回しにして、まず「とにかく一通り動く」ものを作ること</strong> です。</p>
<p>ここで言うエッジケース対応とは、たとえば以下のようなものです。</p>
<ul>
<li>生成AIが Structured Output のスキーマを逸脱した JSON を返したときのサニタイズや再試行</li>
<li>タイムアウトやレート制限にかかったときのリトライ制御</li>
<li>出力内容が明らかに破綻している(空文字、文字化け等)ときのフォールバック</li>
</ul>
<p>これらは本番運用では欠かせませんが、最初から詰め始めると、実データが溜まるのがずるずる先延ばしになります。正常系さえ動けば検証環境で大半のケースは実測できるので、まずはそちらを優先します。</p>
<h3 id="トークン数を自動で-db-に記録する">トークン数を自動で DB に記録する</h3>
<p>次に、生成AI呼び出し時の「使用モデル」「入力/出力トークン数」「所要時間」などを自動で DB に記録する仕組みを入れます。<code>PageAndSectionPlanner</code>、<code>ThemeDeterminer</code>、<code>SectionValueGenerator</code> 等のすべてのステップで、呼び出すたびに 1 レコードが残る状態を作ります。</p>
<p>ポイントは、アプリケーションコード側に計測ロジックを足さずに済む形で組み込むことと、プロンプト・レスポンス本文も併せて保存しておくこと(後からプロンプト改善の材料になるため)です。具体的な実装は付録を参照してください。</p>
<h3 id="実績データから見積もる">実績データから見積もる</h3>
<p>ここまで仕込めたら、あとは実データを溜めるフェーズです。検証環境にデプロイし、動作検証がてらサイト生成をひたすら回します。同じ指示文でも、モデルの揺らぎで消費トークン数は変わるため、数を回せば回すほどばらつきが見えてきます。</p>
<p>実績データが溜まったら、stepType ごと・モデルごとの平均トークン数を集計し、それに想定利用数を掛けて月額コストを再計算します。実際の運用では、検証環境のデータに安全係数(今回は 2 倍程度)を掛けておきました。本番では、検証環境では出なかった長いユーザー入力や、エッジケースの追加プロンプトなどが入り込むことを想定しています。</p>
<p>この段階で出てきた数字と粗利率の目標値を突き合わせ、ビジネス職と「このまま進められるか」「料金プランの調整が必要か」を議論します。</p>
<h2 id="フェーズ-3ローンチ後のモニタリング">フェーズ 3:ローンチ後のモニタリング</h2>
<p>ここまで来ると、ローンチして実際のユーザーの利用状況を見るだけです。フェーズ 2 で仕込んだトークン数のログを定期的に集計し、</p>
<ul>
<li>1 サイト生成あたりの平均トークン数は想定どおりか</li>
<li>粗利率の目標値を脅かす動きはないか</li>
<li>特定のステップだけ異常に消費量が大きくなっていないか</li>
</ul>
<p>をモニタリングします。想定から外れた動きがあれば、プロンプトの見直し、モデルの選定変更、もしくは料金プラン側での調整を検討します。</p>
<p>本番の実データという最も精度の高い情報源を持っている状態なので、このフェーズの見積もりは「見積もり」というより「実測値のトラッキング」です。ここに至って初めて、生成AIのコストは見積もりの対象ではなく、<strong>モニタリングし続ける運用指標</strong> に変わります。</p>
<h2 id="まとめ">まとめ</h2>
<p>生成AI のコストは、事前に一発で正確に見積もることは困難です。だからといって「わかりません」では済まないので、<strong>段階的に精度を上げていく</strong> という方針で進めるのが現実的でした。</p>
<ul>
<li><strong>フェーズ 1(設計直後)</strong>:ワークフローの骨組みとざっくりトークン数で桁を合わせる</li>
<li><strong>フェーズ 2(実装後)</strong>:生成AI呼び出しをラップして実データを集め、安全係数を掛けて再見積もり</li>
<li><strong>フェーズ 3(ローンチ後)</strong>:本番データで継続モニタリング、粗利率への影響をチェック</li>
</ul>
<p>このうち効果が大きかったのは、フェーズ 2 で踏んだ次の 2 点です。</p>
<ul>
<li><strong>エッジケース対応を後回しにして、一通り動くものを先に作る</strong>:実データを早く集めるには、検証環境で回せる状態に到達するのが最優先です。</li>
<li><strong>生成AI呼び出しをラッパー化して、全ステップ自動でログ化する</strong>:計測を仕組み化しておけば、フェーズ 2 以降の意思決定が実データベースでできるようになります。</li>
</ul>
<p>どちらか片方だけでは成立しません。動くものがあってもログが取れなければ実績はわからないし、ログの仕組みだけ整えても呼び出されなければデータは溜まらない。この 2 つを揃えることで、実データを武器に意思決定できる状態に早く到達できました。</p>
<p>生成AI を使うプロダクトを作る方にとって、同じ悩みを抱えている方の参考になれば幸いです。</p>
<hr />
<h2 id="付録トークン数を-db-に記録する仕組み">付録:トークン数を DB に記録する仕組み</h2>
<p>フェーズ 2 で触れた「生成AI呼び出しのトークン数を自動で DB に記録する」の実装詳細です。本筋を読み進める上では読み飛ばしても構いません。</p>
<h3 id="どこに永続化するかrdb-を選んだ理由">どこに永続化するか:RDB を選んだ理由</h3>
<p>トークン数の永続化先は、いくつか選択肢がありました。</p>
<ul>
<li><strong>生成AI 向けの Observability SaaS( <a href="https://smith.langchain.com/">LangSmith</a> など)</strong> を使う</li>
<li><strong><a href="https://langfuse.com/">Langfuse</a> の OSS 版</strong> をセルフホストする</li>
<li><strong>自前の RDB テーブル</strong> に保存する</li>
</ul>
<p>最終的には RDB に保存する方針を選びました。理由は以下です。</p>
<ul>
<li>SaaS は便利だが、<strong>ベンダーロックイン</strong> を避けたい</li>
<li>Langfuse OSS は魅力的だが、この時点では<strong>運用対象を増やしたくない</strong></li>
<li>RDB なら既に運用しているので追加コストが相対的に低い</li>
</ul>
<p>将来的に実データが溜まってきたら BigQuery 等のデータウェアハウスに流す、という拡張余地は残しています。「あとで捨てられる」「あとで移行できる」選択肢を選ぶことで、意思決定のコストを下げています。</p>
<h3 id="prisma-スキーマ">Prisma スキーマ</h3>
<p>スキーマは必要最小限。どのステップで、どのモデルを、何トークン使ったかが後から辿れれば十分です。</p>
<div class="highlight"><pre class="highlight plaintext"><code>model AiGenerationLog {
id String @id @default(uuid())
userId String? @map("user_id")
websiteId String? @map("website_id")
stepType String @map("step_type") // "page_and_section_plan" 等
model String @map("model") // "gemini-2.5-flash"
prompt String @db.MediumText @map("prompt")
response String @db.MediumText @map("response")
inputTokens Int? @map("input_tokens")
outputTokens Int? @map("output_tokens")
durationMs Int? @map("duration_ms")
isError Boolean @default(false) @map("is_error")
errorMessage String? @db.Text @map("error_message")
createdAt DateTime @default(now()) @map("created_at")
@@map("ai_generation_log")
}
</code></pre></div>
<p>プロンプトとレスポンスも併せて保存しています。これは、後から「このトークン数のときはこういう出力が返っていた」と振り返って、プロンプトをチューニングする材料にできるためです。</p>
<h3 id="ログの書き込み処理">ログの書き込み処理</h3>
<p>ログの書き込みは、ビジネスロジックに影響させたくありません。そのため、保存失敗はエラーログに残すだけで呼び出し元には伝播させない、という方針にしています。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="c1">// ai-generation-logger.ts(抜粋)</span>
<span class="k">async</span> <span class="nx">saveLog</span><span class="p">(</span><span class="nx">input</span><span class="p">:</span> <span class="nx">AiGenerationLogCreateInput</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span> <span class="p">{</span>
<span class="k">try</span> <span class="p">{</span>
<span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nx">save</span><span class="p">(</span><span class="nx">input</span><span class="p">);</span>
<span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
<span class="nx">logger</span><span class="p">.</span><span class="nx">error</span><span class="p">({</span> <span class="na">err</span><span class="p">:</span> <span class="nx">error</span> <span class="p">},</span> <span class="dl">"</span><span class="s2">Failed to save AI generation log</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="nx">logGeneration</span><span class="p">(</span><span class="nx">params</span><span class="p">:</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">}):</span> <span class="k">void</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">context</span> <span class="o">=</span> <span class="nx">getAiLogContext</span><span class="p">();</span>
<span class="k">this</span><span class="p">.</span><span class="nx">saveLog</span><span class="p">({</span>
<span class="na">userId</span><span class="p">:</span> <span class="nx">context</span><span class="p">.</span><span class="nx">userId</span><span class="p">,</span>
<span class="na">websiteId</span><span class="p">:</span> <span class="nx">context</span><span class="p">.</span><span class="nx">siteId</span><span class="p">,</span>
<span class="na">stepType</span><span class="p">:</span> <span class="nx">params</span><span class="p">.</span><span class="nx">stepType</span><span class="p">,</span>
<span class="na">model</span><span class="p">:</span> <span class="nx">params</span><span class="p">.</span><span class="nx">model</span><span class="p">,</span>
<span class="na">prompt</span><span class="p">:</span> <span class="nx">params</span><span class="p">.</span><span class="nx">prompt</span><span class="p">,</span>
<span class="na">response</span><span class="p">:</span> <span class="nx">params</span><span class="p">.</span><span class="nx">response</span><span class="p">,</span>
<span class="na">inputTokens</span><span class="p">:</span> <span class="nx">params</span><span class="p">.</span><span class="nx">inputTokens</span><span class="p">,</span>
<span class="na">outputTokens</span><span class="p">:</span> <span class="nx">params</span><span class="p">.</span><span class="nx">outputTokens</span><span class="p">,</span>
<span class="na">durationMs</span><span class="p">:</span> <span class="nx">params</span><span class="p">.</span><span class="nx">durationMs</span><span class="p">,</span>
<span class="na">isError</span><span class="p">:</span> <span class="nx">params</span><span class="p">.</span><span class="nx">isError</span><span class="p">,</span>
<span class="na">errorMessage</span><span class="p">:</span> <span class="nx">params</span><span class="p">.</span><span class="nx">errorMessage</span><span class="p">,</span>
<span class="p">}).</span><span class="k">catch</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{});</span>
<span class="p">}</span>
</code></pre></div>
<p>ログの保存が原因でサイト生成が失敗したら本末転倒なので、ここは割り切ります。</p>
<h3 id="生成ai呼び出しをラップする">生成AI呼び出しをラップする</h3>
<p>あとは、生成AIクライアント側から上記のLoggerを呼ぶだけです。モデルの呼び出し箇所に毎回 <code>await insertLog(...)</code> を書くのは現実的ではないので、生成AIクライアントをラップする層を 1 枚挟みます。ラッパー側で <code>usageMetadata</code> からトークン数を取り出し、DB 保存用のLoggerに流します。成功時だけでなくエラー時も記録したいので、try/catch の両方でログ化します。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="c1">// db-logging-model.ts(抜粋)</span>
<span class="k">export</span> <span class="kd">class</span> <span class="nx">DbLoggingGenerativeModel</span> <span class="k">implements</span> <span class="nx">GenerativeModel</span> <span class="p">{</span>
<span class="kd">get</span> <span class="nx">modelName</span><span class="p">():</span> <span class="kr">string</span> <span class="p">{</span>
<span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">inner</span><span class="p">.</span><span class="nx">modelName</span><span class="p">;</span>
<span class="p">}</span>
<span class="kd">constructor</span><span class="p">(</span>
<span class="k">private</span> <span class="k">readonly</span> <span class="nx">inner</span><span class="p">:</span> <span class="nx">GenerativeModel</span><span class="p">,</span>
<span class="k">private</span> <span class="k">readonly</span> <span class="nx">stepType</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span>
<span class="k">private</span> <span class="k">readonly</span> <span class="nx">genLogger</span><span class="p">:</span> <span class="nx">AiGenerationLogger</span><span class="p">,</span>
<span class="p">)</span> <span class="p">{}</span>
<span class="k">async</span> <span class="nx">generateContent</span><span class="p">(</span><span class="nx">prompt</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">startTime</span> <span class="o">=</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">();</span>
<span class="k">try</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">inner</span><span class="p">.</span><span class="nx">generateContent</span><span class="p">(</span><span class="nx">prompt</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">text</span> <span class="o">=</span> <span class="nx">result</span><span class="p">.</span><span class="nx">response</span><span class="p">.</span><span class="nx">text</span><span class="p">();</span>
<span class="k">this</span><span class="p">.</span><span class="nx">genLogger</span><span class="p">.</span><span class="nx">logGeneration</span><span class="p">({</span>
<span class="na">stepType</span><span class="p">:</span> <span class="k">this</span><span class="p">.</span><span class="nx">stepType</span><span class="p">,</span>
<span class="na">model</span><span class="p">:</span> <span class="k">this</span><span class="p">.</span><span class="nx">modelName</span><span class="p">,</span>
<span class="nx">prompt</span><span class="p">,</span>
<span class="na">response</span><span class="p">:</span> <span class="nx">text</span><span class="p">,</span>
<span class="na">inputTokens</span><span class="p">:</span> <span class="nx">result</span><span class="p">.</span><span class="nx">usageMetadata</span><span class="p">?.</span><span class="nx">promptTokenCount</span><span class="p">,</span>
<span class="na">outputTokens</span><span class="p">:</span> <span class="nx">result</span><span class="p">.</span><span class="nx">usageMetadata</span><span class="p">?.</span><span class="nx">candidatesTokenCount</span><span class="p">,</span>
<span class="na">durationMs</span><span class="p">:</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span> <span class="o">-</span> <span class="nx">startTime</span><span class="p">,</span>
<span class="p">});</span>
<span class="k">return</span> <span class="nx">result</span><span class="p">;</span>
<span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
<span class="k">this</span><span class="p">.</span><span class="nx">genLogger</span><span class="p">.</span><span class="nx">logGeneration</span><span class="p">({</span>
<span class="na">stepType</span><span class="p">:</span> <span class="k">this</span><span class="p">.</span><span class="nx">stepType</span><span class="p">,</span>
<span class="na">model</span><span class="p">:</span> <span class="k">this</span><span class="p">.</span><span class="nx">modelName</span><span class="p">,</span>
<span class="nx">prompt</span><span class="p">,</span>
<span class="na">response</span><span class="p">:</span> <span class="dl">""</span><span class="p">,</span>
<span class="na">durationMs</span><span class="p">:</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span> <span class="o">-</span> <span class="nx">startTime</span><span class="p">,</span>
<span class="na">isError</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="na">errorMessage</span><span class="p">:</span> <span class="nx">error</span> <span class="k">instanceof</span> <span class="nb">Error</span> <span class="p">?</span> <span class="nx">error</span><span class="p">.</span><span class="nx">message</span> <span class="p">:</span> <span class="nb">String</span><span class="p">(</span><span class="nx">error</span><span class="p">),</span>
<span class="p">});</span>
<span class="k">throw</span> <span class="nx">error</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
</code></pre></div>
<p>このデコレータを挟むことで、すべてのステップ(<code>PageAndSectionPlanner</code>、<code>ThemeDeterminer</code>、<code>SectionValueGenerator</code> 等)のアプリケーションコード側に特別な計測ロジックを足さずに、全呼び出しのトークン数を DB に記録できます。<code>stepType</code> をコンストラクタで受け取っているのは、「どのステップの呼び出しか」をあとから集計できるようにするためです。</p>
</description>
</item>
<item>
<title>Claude Code Skillでメール障害対応を実施</title>
<link>https://tech.pepabo.com/2026/04/30/claude-code-skills-mail-incident/</link>
<guid>https://tech.pepabo.com/2026/04/30/claude-code-skills-mail-incident/</guid>
<pubDate>Thu, 30 Apr 2026 00:00:00 +0900</pubDate>
<description><p>こんにちは、技術部 技術基盤グループのkmsnです。</p>
<p>GMOペパボが運営するECサイト構築サービス「<a href="https://shop-pro.jp/">カラーミーショップ</a>」で、Outlook/Hotmail/Live宛メールがブロックされる障害が発生しました。この記事では、Claude Code の Skill を活用して障害の初動対応を効率化した事例を紹介します。</p>
<h2 id="結論skill-で障害対応の初動が変わった">結論:Skill で障害対応の初動が変わった</h2>
<p>先に結論をお伝えします。今回の障害対応で最も効果的だったのは、<strong>Claude Code の Skill によって状況把握のスピードが大幅に上がった</strong>ことです。</p>
<p>カラーミーショップのメールサーバーは数十台あります。従来であれば、障害発生時に1台ずつ SSH して <code>sudo postqueue -p</code> を叩き、キューの状態を目視で確認していく必要がありました。台数が多いため状況把握だけで時間がかかり、その間もメールは滞留し続けます。</p>
<p>今回は <code>colorme-mailq</code> Skill を使い、「メールキュー確認して」の一言で全台の状態を一括取得しました。</p>
<table>
<thead>
<tr>
<th>サーバー</th>
<th>active</th>
<th>deferred</th>
<th>合計</th>
</tr>
</thead>
<tbody>
<tr>
<td>server-1</td>
<td>0</td>
<td>3,842</td>
<td>3,842</td>
</tr>
<tr>
<td>server-2</td>
<td>12</td>
<td>0</td>
<td>12</td>
</tr>
</tbody>
</table>
<p>※ 上記は全数十台のうち抜粋</p>
<p>特定サーバーの deferred が突出して積み上がっていることが一目でわかり、対応する方針をすぐに決められました。<strong>従来の手動確認では状況把握に時間を要していた工程が、Skill によって数秒で完了し、対応方針の決定までの時間を大幅に短縮できました。</strong></p>
<p>さらに、この Skill は社内のプライベートリポジトリに登録されているため、自分だけでなくチームの誰でも同じように使えます。個人のスクリプトではなく <strong>チーム共有の Skill として整備しておくことで、次に同様の障害が起きたときにも誰でも素早く初動に入れる</strong>という点が大きな価値です。</p>
<h2 id="何が起きたか">何が起きたか</h2>
<p>カラーミーショップのユーザーが送信したメールが、Outlook/Hotmail/Live宛に届かずブロックされる事象が発生しました。Microsoft(Outlook.com)のような大手プロバイダーは、スパム判定によるブロック時に 5xx 系(主に 550)のエラーを返すことがあります。ただし、Postfix 側の設定や一時的なレスポンスにより deferred キューに滞留するケースもあり、今回はキューの滞留として顕在化しました。</p>
<p>ログを確認したところ、特定のショップドメインからの大量送信が引き金となり、送信を担うサーバーの IP が Microsoft(Outlook.com)側にブロックされたと判断しました。</p>
<h2 id="postfix-transport-で-outlook-宛の配送経路を変更する">Postfix transport で Outlook 宛の配送経路を変更する</h2>
<p>Skill で状況を把握した後、ブロックされたサーバーを迂回する対応を取りました。これには Postfix の <strong>transport テーブル</strong>を使います。</p>
<p>transport テーブルは「特定ドメイン宛の配送を、どの経路で行うか」を定義するファイルです。Outlook 系ドメイン宛のメールを別の経路に切り替える設定を追加しました。</p>
<p>また <code>/etc/postfix/transport</code> を確認すると、以前のインシデント時に手動で投入された送信レート制御の設定が残っていました。今回は別の経路への切り替えで対処するため不要と判断し、削除した上で新しい設定を追加しました。</p>
<h2 id="手動設定を-puppet-でコード化する">手動設定を Puppet でコード化する</h2>
<p>ここで課題に当たりました。対象サーバーの <code>/etc/postfix/transport</code> は <strong>Puppet で管理されておらず、手動設定のみの状態</strong>だったのです。</p>
<p>先ほどのレート制御の設定がまさにその典型で、何のためのものか、誰が投入したのかがすぐに追えない状況でした。手動設定が蓄積すると、こうした「経緯のわからない設定」が増えていきます。</p>
<p>別のメールサーバーでは <code>hieradata/nodes/</code> 配下にノード専用の yaml が存在し、transport 設定が Puppet で管理されていました。一方、対象サーバーにはその yaml が存在しません。チームメンバーにアドバイスをもらいながら、以下の2択で検討しました。</p>
<ul>
<li><strong>A: 対象サーバーのノード yaml だけに書く</strong> → そのサーバーのみに適用。他サーバーへの影響なし</li>
<li><strong>B: ロール共通の yaml に書く</strong> → 全メールサーバーに一律適用</li>
</ul>
<p>既存実装を参考に、今回は <strong>A</strong> を選択。対象サーバー専用のノード yaml を新規作成し、このサーバーとしては初めて transport 設定を Puppet 管理に取り込みました。</p>
<p>チームメンバーのレビューを受けてマージし、<code>--noop</code> での dry-run で差分を確認してから本適用しました。</p>
<div class="highlight"><pre class="highlight shell"><code><span class="nb">sudo </span>puppet agent <span class="nt">--test</span> <span class="nt">--noop</span> <span class="c"># dry-run で変更内容を事前確認</span>
<span class="nb">sudo </span>puppet agent <span class="nt">--test</span> <span class="c"># 本適用</span>
</code></pre></div>
<p>経路の切り替えが正常に動作していることを確認し、復旧しました。</p>
<p>なお、transport による経路変更はあくまで暫定的な緩和措置であり、根本対処ではありません。これを恒久的な対処としてしまうと、迂回先のサーバーにも負荷が偏るなど新たな問題を招く可能性があります。並行して Microsoft への delist 申請や送信元ショップへの対応など、根本原因の解消に向けた対処を実施し、解消後に transport の設定を元に戻すところまでが一連の対応です。</p>
<h2 id="その他の学び">その他の学び</h2>
<p><strong>カスタマーサポート(CS)への連携は「確定情報だけ」を素早く。</strong></p>
<p>技術的な調査の完了を待たず、判明した情報(発生時刻・影響範囲・復旧時刻)をその都度 CS に共有するほうがユーザー影響を最小化できます。調査の過程をすべて流すのではなく、確定した情報だけをタイムリーに伝えるというシンプルな判断が、慣れないうちは意外と難しいと感じました。</p>
<p><strong>手動設定は「なぜ入っているか」が追えなくなる。</strong></p>
<p>今回 Puppet に設定を取り込んだことで、次の担当者が経緯を追いやすくなりました。障害のたびに手動で設定を足していくのではなく、コードに残す習慣が大切だと改めて感じました。</p>
<h2 id="おわりに">おわりに</h2>
<p>今回の対応を通じて、<strong>障害対応の定型作業を Skill として整備しておくことの効果</strong>を実感しました。状況把握の速さは、そのまま復旧の速さにつながります。</p>
<p>この考え方はメール障害に限りません。たとえば Web サーバーのエラーレート急増時にログを集約する、データベースのスロークエリを一覧する、といった「障害発生直後にまず確認すること」は、どの領域にもあるはずです。こうした初動の定型作業を Skill として整備しておくことで、経験の浅いメンバーでも素早く状況把握に入れるようになり、経験豊富なメンバーは情報収集の手間を省いて判断や対応方針の策定に集中できます。</p>
<p>同様の取り組みを検討されている方の参考になれば幸いです。</p>
</description>
</item>
<item>
<title>後付け可能な認証を NextAuth で設計する — ログイン機構が決まらないまま、ログイン前提のプロダクトを作った話</title>
<link>https://tech.pepabo.com/2026/04/24/nextauth-mock-auth-design/</link>
<guid>https://tech.pepabo.com/2026/04/24/nextauth-mock-auth-design/</guid>
<pubDate>Fri, 24 Apr 2026 00:00:00 +0900</pubDate>
<description><h2 id="はじめに">はじめに</h2>
<p>こんにちは。ロリポップ・ムームードメイン事業部でエンジニアリングリードをしています <a href="https://x.com/kinosuke01">kinosuke01</a> といいます。</p>
<p>「この機能はログインしたユーザーのものとして扱いたい」というのは、ほとんどのプロダクトで当たり前の要件となります。ところが、プロダクト本体の開発を進めたいタイミングで、<strong>ログインの仕組みがまだ決まっていない</strong>という状況に直面することがあります。</p>
<p>後から差し替え可能にしておくというのは一つの手です。しかし「あとで差し替え」を甘く見ていると、いざ差し替えるときに思いのほか大がかりな書き換えが発生してしまう場合もあるのではないでしょうか。</p>
<p>この記事では、<a href="https://lolipop.jp/ai/site-agent/">AIサイトエージェント</a> というプロダクトの開発で実際に直面したこの状況と、そこで取った方針について紹介していきます。</p>
<p>要点を先にまとめると、以下の一点になります。</p>
<blockquote>
<p>決まっていない領域を、差し替え可能なレイヤーに封じ込める。そのレイヤーだけを「本物と同じ形の偽物」で置き、他のコードからは本物と区別できない状態で先に作り切る。</p>
</blockquote>
<p>具体的には、<a href="https://authjs.dev/">NextAuth.js</a> を土台にした「本物と同じ形の偽物ログイン」を用意することで、後から本番のログイン機構(OIDC)に NextAuth インスタンスの差し替えだけで移行できるようになりました。以降の節で、この構造を順に分解していきます。</p>
<h2 id="前提aiサイトエージェントとは">前提:AIサイトエージェントとは</h2>
<p>本題に入る前に、舞台となるプロダクトの輪郭を簡単に共有しておきます。</p>
<p><a href="https://lolipop.jp/ai/site-agent/">AIサイトエージェント</a>は、「カフェのサイトを作りたい」「フリーランスのポートフォリオが欲しい」といった自然言語の指示を投げると、ページ構成・デザインテーマ・コンテンツまでを一括で生成してくれる Web サイト制作サービスです。生成したあとも、チャット越しに「トップのキャッチを変えて」「このセクションの写真を差し替えて」と伝えれば、AI が編集を代行してくれます。</p>
<p><img src="/blog/2026/04/24/nextauth-mock-auth-design/img01.png" alt="img01" />
<img src="/blog/2026/04/24/nextauth-mock-auth-design/img02.png" alt="img02" /></p>
<p>このサービスは、<a href="https://lolipop.jp/">ロリポップ!レンタルサーバー</a> と <a href="https://muumuu-domain.com/">ムームードメイン</a> のどちらからも利用できるようになっています。ロリポップのユーザーとムームードメインのユーザー、それぞれが AIサイトエージェントのコンパネに入ってサイトを作れる、というのが本番のユースケースとなります。</p>
<p><img src="/blog/2026/04/24/nextauth-mock-auth-design/oidc.png" alt="ロリポップ/ムームードメインのアカウントでOIDCログインする構成" /></p>
<h3 id="技術スタック">技術スタック</h3>
<p>この記事のコード例を読む前提として、プロダクトの技術スタックにも軽く触れておきます。</p>
<ul>
<li><strong>フレームワーク</strong>: <a href="https://nextjs.org/">Next.js</a>(App Router)</li>
<li><strong>認証</strong>: <a href="https://authjs.dev/">NextAuth.js(Auth.js v5)</a></li>
<li><strong>言語</strong>: TypeScript</li>
</ul>
<p>以降の本文では NextAuth の <code>authorize</code> / <code>jwt</code> / <code>session</code> といったコールバック関数がコード例に登場します。NextAuth に馴染みのない方は、「ログイン処理の各ステップで呼ばれるフック関数」程度のざっくりした理解で読み進めていただければ大丈夫です。</p>
<h2 id="課題の整理ログインが決まっていない中で何を作るか">課題の整理:ログインが決まっていない中で何を作るか</h2>
<p>AIサイトエージェントは、最終的にはロリポップとムームードメインの2つのサービスのアカウントでログインできる形に落ち着きました。しかし、はじめからそう決まっていたわけではなく、アカウント基盤をどうするか・どう認証するかは、ビジネス・技術の両面から議論が続いている状況でした。</p>
<p>一方で、サイトを生成・編集するというプロダクトの中核機能の開発を止めて待つ、という選択肢はありませんでした。Webサイトの生成、ユーザーによる編集、自分のサイトにだけアクセスできる仕組みといった機能は、どれも「ログイン済みユーザーがいる」ことを前提にしています。つまり、<strong>ログイン機構が決まっていないまま、ログイン前提の機能を作り進める必要がある</strong>という状況になっていました。</p>
<p>この状況から、解くべき課題は次の2つに分解できます。</p>
<ol>
<li><strong>今、ログイン前提の機能をどう作るか</strong></li>
<li><strong>本番のログイン機構が決まったとき、どうシームレスに繋ぎ込むか</strong></li>
</ol>
<p>1.だけなら、サーバー側関数で固定の userId を返すような簡易なモックで十分です。しかし 2. を同時に成立させようとすると、それだけでは足りません。本物のログインが乗ったときに、セッションの持ち方もユーザーのテーブル構造もごっそり変わるとなると、結局そのタイミングで広範囲の書き換えが発生してしまいます。</p>
<p>加えて、設計上もうひとつ大きな制約がありました。それが次の <strong>独立開発の要件</strong> です。</p>
<h3 id="開発環境を外部サービスから独立させたい">開発環境を外部サービスから独立させたい</h3>
<p>この時点では本番のログイン機構がまだ決まっていない、というのは先に述べたとおりです。ただし、アカウント基盤の候補として議論されていたロリポップやムームードメインといった既存サービスは、どちらも長く運用されている巨大なコードベースを持つサービスとなります。<strong>仮にこうした既存サービスのアカウント基盤と繋ぎ込むことが決まった場合</strong>、それら本番と同等のログイン基盤をまるごとローカル開発に繋ぎ込むというのは現実的ではありません。セットアップの手間もさることながら、外部依存が増えるほど開発体験は悪くなっていきます。</p>
<p>そのため、本番のログイン機構が最終的に何になっても困らないよう、<strong>認証の外部依存ゼロでプロダクト本体を動かせる</strong> ことも考慮したいと考えていました。</p>
<h3 id="方針">方針</h3>
<p>ここまでを踏まえて、方針は次のように定めました。</p>
<blockquote>
<p>「本物が来たときに差し替えるレイヤー」だけを偽物にする。それ以外は、本番運用で使うものと同じ構造で作り込む。</p>
</blockquote>
<p>具体的には、以下の3点を本物と同じ形で作り込むことにしました。</p>
<ul>
<li><strong>セッション管理の仕組み</strong>: JWT ベースのセッション、コールバックの流れ</li>
<li><strong>ユーザーのテーブル構造</strong>: 外部認証プロバイダーとの紐付けを前提にしたスキーマ</li>
<li><strong>JIT プロビジョニング</strong>: 初回ログイン時にユーザーと所属組織を作成する流れ</li>
</ul>
<p>偽物にするのは「認証のやり方そのもの」だけとなります(以降、この偽物の認証を <strong>モック認証</strong> と呼びます)。これなら、認証のやり方が決まったときに、そこだけ差し替えれば済むようになります。</p>
<h2 id="モック認証に求める振る舞い">モック認証に求める振る舞い</h2>
<p>実装の話に入る前に、このモック認証にどんな挙動をさせたいのかを具体化しておきます。「本物と同じ形」と言っても曖昧ですので、期待する振る舞いをあらかじめ言語化しておくと、以降の実装がなぜそうなっているのかが見えやすくなります。</p>
<p>ここで <code>puid</code>(provider user id の略。プロバイダー側のユーザー識別子に相当する値)という言葉が出てきます。本番では OIDC プロバイダーから渡ってくる <code>sub</code> クレームですが、モックでは開発者が自由に指定できる文字列として扱います。以降の節でも繰り返し登場する語となります。</p>
<p>求める振る舞いは、次のように整理できます。</p>
<p><strong>モック特有の部分</strong>(偽物としての振る舞い)</p>
<ol>
<li><strong>任意のIDでログインできる</strong>: ログイン画面で puid を自由に入力でき、未指定の場合は固定のデフォルトユーザーとしてログインできる</li>
<li><strong>外部サービスへの問い合わせは発生しない</strong>: OIDC の認可エンドポイントや userinfo を呼ばない。入力された puid を信頼してログイン完了とする(前節の独立開発要件に対応)</li>
<li><strong>初回ログイン時にユーザーを自動生成する</strong>: その puid に対応する DB レコードが無ければ、ユーザー・組織・<code>ExternalIdentity</code> を自動で作る(JIT プロビジョニング)</li>
</ol>
<p><strong>本番と揃えたい部分</strong>(アプリから見て本物と同じ形)</p>
<ol>
<li><strong>セッションから得られる情報は本番と同じ</strong>: <code>appUserId</code>, <code>providerUserId</code>, <code>provider</code> がセッションに揃い、<strong>アプリケーションコードから見ると</strong> 本番認証と区別がつかない状態となる</li>
</ol>
<p><strong>開発体験の要件</strong></p>
<ol>
<li><strong>puid を変えればユーザーを切り替えられる</strong>: 権限・所有権など複数ユーザーが絡む検証を、開発中も素直に試せる</li>
</ol>
<p><img src="/blog/2026/04/24/nextauth-mock-auth-design/login-form.png" alt="モック認証のログインフォーム" /></p>
<p class="pager__caption">任意のユーザー名を入力すると、そのユーザーでログインできる</p>
<p>1〜3 が偽物として置く部分、4 がアプリに向けてそろえる部分、5 が開発者が使うときの体験です。では、この3つの層をどう実装していくか、順に見ていきましょう。</p>
<h2 id="土台として-nextauth-を選ぶ">土台として NextAuth を選ぶ</h2>
<p>この設計を支える土台として NextAuth を採用しました。NextAuth は Provider という概念を中心に、Credentials(任意の独自認証)、OAuth、OIDC など複数の認証方式を同じ抽象のもとに扱えるフレームワークです。</p>
<p>「モック認証も一つの Provider として扱い、本番の OIDC 認証も同じく Provider として差し替える」という構造が、そのまま課題にフィットしました。セッション管理やコールバックの組み立て方は Provider に依存しないので、モック時に書いたコールバック処理は OIDC 導入後もそのまま使い回せます。このことが、後述する差し替え時の手数の少なさに直結しました。</p>
<h2 id="テーブル構造は外部連携前提にしておく">テーブル構造は「外部連携前提」にしておく</h2>
<p>ログインがモックであっても、テーブル構造はあとで使う本物のスキーマを想定して設計しました。ポイントは、ユーザー本体(<code>User</code>)と外部認証プロバイダーとの結び付け情報(<code>ExternalIdentity</code>)をテーブル分離し、プロバイダー種別をユニーク制約に含めたところとなります。</p>
<div class="highlight"><pre class="highlight plaintext"><code>model ExternalIdentity {
userId String @unique
provider AuthProvider
providerUserId String
// ... 他のカラム省略
@@unique([provider, providerUserId])
}
enum AuthProvider {
MUUMUU
LOLIPOP
MOCK
}
</code></pre></div>
<p>モック認証もれっきとした「プロバイダーの一つ」として <code>AuthProvider.MOCK</code> を割り当てることで、本番のプロバイダーと同じ経路でユーザーを特定できるようになります。本番のプロバイダーが後から増えても、enum に値を足して <code>ExternalIdentity</code> を作るだけで対応可能です。モックと本番を同じ構造で受け止めるスキーマとなっています。</p>
<h2 id="モック認証を-nextauth-の上に載せる">モック認証を NextAuth の上に載せる</h2>
<p>モック認証は NextAuth の <strong>Credentials Provider</strong> で実装しました。本物の認証ではなく、画面で入力された puid をそのまま通すだけのダミーです。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="c1">// 入力された puid を簡易バリデーションするための正規表現</span>
<span class="kd">const</span> <span class="nx">PUID_PATTERN</span> <span class="o">=</span> <span class="sr">/^</span><span class="se">[</span><span class="sr">a-zA-Z0-9-</span><span class="se">]</span><span class="sr">+$/</span><span class="p">;</span>
<span class="kd">function</span> <span class="nx">createMockAuth</span><span class="p">()</span> <span class="p">{</span>
<span class="k">return</span> <span class="nx">NextAuth</span><span class="p">({</span>
<span class="na">providers</span><span class="p">:</span> <span class="p">[</span>
<span class="nx">Credentials</span><span class="p">({</span>
<span class="na">id</span><span class="p">:</span> <span class="dl">"</span><span class="s2">credentials</span><span class="dl">"</span><span class="p">,</span>
<span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Mock Login</span><span class="dl">"</span><span class="p">,</span>
<span class="na">credentials</span><span class="p">:</span> <span class="p">{</span>
<span class="na">puid</span><span class="p">:</span> <span class="p">{</span> <span class="na">label</span><span class="p">:</span> <span class="dl">"</span><span class="s2">ProviderUserId</span><span class="dl">"</span><span class="p">,</span> <span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">text</span><span class="dl">"</span> <span class="p">},</span>
<span class="p">},</span>
<span class="k">async</span> <span class="nx">authorize</span><span class="p">(</span><span class="nx">credentials</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">puid</span> <span class="o">=</span>
<span class="k">typeof</span> <span class="nx">credentials</span><span class="p">?.</span><span class="nx">puid</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">string</span><span class="dl">"</span> <span class="o">&amp;&amp;</span>
<span class="nx">credentials</span><span class="p">.</span><span class="nx">puid</span><span class="p">.</span><span class="nx">trim</span><span class="p">()</span> <span class="o">!==</span> <span class="dl">""</span>
<span class="p">?</span> <span class="nx">credentials</span><span class="p">.</span><span class="nx">puid</span><span class="p">.</span><span class="nx">trim</span><span class="p">()</span>
<span class="p">:</span> <span class="dl">"</span><span class="s2">mock-user</span><span class="dl">"</span><span class="p">;</span>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">PUID_PATTERN</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">puid</span><span class="p">))</span> <span class="p">{</span>
<span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
<span class="p">}</span>
<span class="c1">// jwt コールバックで user として受け取る値</span>
<span class="k">return</span> <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="nx">puid</span> <span class="p">};</span>
<span class="p">},</span>
<span class="p">}),</span>
<span class="p">],</span>
<span class="na">trustHost</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="na">callbacks</span><span class="p">:</span> <span class="p">{</span>
<span class="k">async</span> <span class="nx">jwt</span><span class="p">({</span> <span class="nx">token</span><span class="p">,</span> <span class="nx">account</span> <span class="p">})</span> <span class="p">{</span>
<span class="k">return</span> <span class="nx">mockJwtCallback</span><span class="p">({</span> <span class="nx">token</span><span class="p">,</span> <span class="nx">account</span> <span class="p">});</span>
<span class="p">},</span>
<span class="k">async</span> <span class="nx">session</span><span class="p">({</span> <span class="nx">session</span><span class="p">,</span> <span class="nx">token</span> <span class="p">})</span> <span class="p">{</span>
<span class="k">return</span> <span class="nx">commonSessionCallback</span><span class="p">({</span> <span class="nx">session</span><span class="p">,</span> <span class="nx">token</span> <span class="p">});</span> <span class="c1">// 本番と共通</span>
<span class="p">},</span>
<span class="p">},</span>
<span class="na">pages</span><span class="p">:</span> <span class="p">{</span> <span class="na">signIn</span><span class="p">:</span> <span class="dl">"</span><span class="s2">/login</span><span class="dl">"</span> <span class="p">},</span>
<span class="p">});</span>
<span class="p">}</span>
</code></pre></div>
<p>コードの中に <code>authorize</code> / <code>jwt</code> / <code>session</code> という3つのコールバックが出てきます。NextAuth に馴染みのない方向けに、それぞれの役割と、<code>mock-user</code> でログインボタンを押したときの呼び出し順を軽く整理しておきます。</p>
<ul>
<li><strong><code>authorize</code></strong>: ログインフォームから送られた値を受け取り、認証の可否を判断するコールバックです(Credentials Provider 特有のもの)。OK ならユーザーを表すオブジェクトを返し、NG なら <code>null</code> を返します。</li>
<li><strong><code>jwt</code></strong>: セッションの元となる JWT を組み立てるコールバックです。<code>authorize</code> が返した情報をベースに、JWT へ積みたい情報(ここでは <code>appUserId</code> や <code>providerUserId</code> など)を追加できます。</li>
<li><strong><code>session</code></strong>: アプリケーション側で <code>auth()</code> から取り出すセッションオブジェクトを組み立てるコールバックです。JWT の中身を、アプリに見せたい形へ整えるのが役割です。</li>
</ul>
<p>ログイン画面で puid に <code>mock-user</code> を入力してログインボタンを押した場合、これらは次の順で呼ばれます。</p>
<ol>
<li><strong><code>authorize({ puid: "mock-user" })</code></strong>: puid を検証し、<code>{ id: "mock-user" }</code> を返す</li>
<li><strong><code>jwt</code></strong>: <code>authorize</code> が返した情報をもとに、JIT プロビジョニングで DB からユーザーを取得(なければ作成)し、<code>appUserId</code> などを JWT に積む</li>
<li><strong><code>session</code></strong>: JWT の値をセッションに詰め替え、アプリから参照できる形に整える</li>
</ol>
<p>この流れを頭に入れておくと、以降のコールバックの中身が読みやすくなります。では、<code>authorize</code> は任意の puid を受け取ってそのまま通すだけなので、キモとなるのは残りの2つです。<code>session</code> コールバックは本番と共通のものを使っており、<code>jwt</code> コールバックもモックと本番で中の処理(後述する JIT プロビジョニングの有無)こそ違うものの、JWT に積む情報の形(<code>providerUserId</code>, <code>appUserId</code>, <code>provider</code>)は揃えてあります。</p>
<h3 id="jit-プロビジョニングで複数ユーザーに対応する">JIT プロビジョニングで複数ユーザーに対応する</h3>
<p>開発中は「ユーザーAとしてログインして確認」「ユーザーBとしてログインして権限を確認」といったシナリオが頻繁に発生します。モックだからといって単一ユーザー固定にしてしまうと、そうした検証がやりにくくなってしまいます。</p>
<p>そこで、JWT コールバックで <strong>JIT(Just-In-Time)プロビジョニング</strong> を行うようにしました。ログイン時に指定された puid がまだ DB に存在しなければ、そのタイミングでユーザーと所属組織を作成します。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="k">async</span> <span class="kd">function</span> <span class="nx">mockJwtCallback</span><span class="p">({</span> <span class="nx">token</span><span class="p">,</span> <span class="nx">account</span> <span class="p">})</span> <span class="p">{</span>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">account</span><span class="p">)</span> <span class="p">{</span>
<span class="k">return</span> <span class="nx">token</span><span class="p">;</span>
<span class="p">}</span>
<span class="kd">const</span> <span class="nx">providerUserId</span> <span class="o">=</span> <span class="nx">token</span><span class="p">.</span><span class="nx">sub</span> <span class="k">as</span> <span class="kr">string</span><span class="p">;</span>
<span class="c1">// (provider, providerUserId) で既存ユーザーを探し、なければ</span>
<span class="c1">// ユーザー・組織・組織メンバー・ExternalIdentity をトランザクション内で一括作成する</span>
<span class="kd">const</span> <span class="nx">appUser</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">userService</span><span class="p">.</span><span class="nx">getOrCreateUserByExternalIdentity</span><span class="p">(</span>
<span class="nx">AuthProvider</span><span class="p">.</span><span class="nx">MOCK</span><span class="p">,</span>
<span class="nx">providerUserId</span><span class="p">,</span>
<span class="p">);</span>
<span class="k">return</span> <span class="p">{</span>
<span class="p">...</span><span class="nx">token</span><span class="p">,</span>
<span class="nx">providerUserId</span><span class="p">,</span>
<span class="na">appUserId</span><span class="p">:</span> <span class="nx">appUser</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span>
<span class="na">provider</span><span class="p">:</span> <span class="nx">AuthProvider</span><span class="p">.</span><span class="nx">MOCK</span><span class="p">,</span>
<span class="p">};</span>
<span class="p">}</span>
</code></pre></div>
<h3 id="セッションコールバックを本番と共通化する">セッションコールバックを本番と共通化する</h3>
<p>さて、この記事のキモとなるのが次の <code>commonSessionCallback</code> です。モックと本番で <strong>完全に同じ関数</strong> を使い回すことで、「セッションから取り出せる値の形」がモードに依らず一定です。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="k">async</span> <span class="kd">function</span> <span class="nx">commonSessionCallback</span><span class="p">({</span> <span class="nx">session</span><span class="p">,</span> <span class="nx">token</span> <span class="p">})</span> <span class="p">{</span>
<span class="c1">// jwt コールバックで積んだ error をそのまま伝搬</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">token</span><span class="p">.</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
<span class="nx">session</span><span class="p">.</span><span class="nx">error</span> <span class="o">=</span> <span class="nx">token</span><span class="p">.</span><span class="nx">error</span> <span class="k">as</span> <span class="kr">string</span><span class="p">;</span>
<span class="k">return</span> <span class="nx">session</span><span class="p">;</span>
<span class="p">}</span>
<span class="c1">// DB 側でユーザーが削除・無効化されていないかを確認</span>
<span class="kd">const</span> <span class="nx">validation</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">validateUserExists</span><span class="p">(</span>
<span class="nx">token</span><span class="p">.</span><span class="nx">appUserId</span> <span class="k">as</span> <span class="kr">string</span> <span class="o">|</span> <span class="kc">undefined</span><span class="p">,</span>
<span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">validation</span><span class="p">.</span><span class="nx">isValid</span><span class="p">)</span> <span class="p">{</span>
<span class="nx">session</span><span class="p">.</span><span class="nx">error</span> <span class="o">=</span> <span class="nx">validation</span><span class="p">.</span><span class="nx">error</span><span class="p">;</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">validation</span><span class="p">.</span><span class="nx">error</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">ActiveUserNotFound</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
<span class="nx">session</span><span class="p">.</span><span class="nx">appUserId</span> <span class="o">=</span> <span class="kc">undefined</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">return</span> <span class="nx">session</span><span class="p">;</span>
<span class="p">}</span>
<span class="nx">session</span><span class="p">.</span><span class="nx">providerUserId</span> <span class="o">=</span> <span class="nx">token</span><span class="p">.</span><span class="nx">providerUserId</span> <span class="k">as</span> <span class="kr">string</span> <span class="o">|</span> <span class="kc">undefined</span><span class="p">;</span>
<span class="nx">session</span><span class="p">.</span><span class="nx">appUserId</span> <span class="o">=</span> <span class="nx">token</span><span class="p">.</span><span class="nx">appUserId</span> <span class="k">as</span> <span class="kr">string</span> <span class="o">|</span> <span class="kc">undefined</span><span class="p">;</span>
<span class="nx">session</span><span class="p">.</span><span class="nx">provider</span> <span class="o">=</span> <span class="nx">token</span><span class="p">.</span><span class="nx">provider</span> <span class="k">as</span> <span class="nx">AuthProvider</span> <span class="o">|</span> <span class="kc">undefined</span><span class="p">;</span>
<span class="k">return</span> <span class="nx">session</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>
<p>中身はほぼ JWT からセッションへの値の詰め替えだけで、モック・本番の差は一切ありません。「ログイン済みユーザーを DB で再確認する」というプロダクト側の要件だけを淡々と満たす形となっています。この関数がモードに依存しない形で書けていることが、後の差し替えコストを最小にしてくれます。</p>
<p>セッション型も同じ形で固定しておきます。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="kr">declare</span> <span class="kr">module</span> <span class="dl">"</span><span class="s2">next-auth</span><span class="dl">"</span> <span class="p">{</span>
<span class="kr">interface</span> <span class="nx">Session</span> <span class="p">{</span>
<span class="nl">providerUserId</span><span class="p">?:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">appUserId</span><span class="p">?:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">provider</span><span class="p">?:</span> <span class="nx">AuthProvider</span><span class="p">;</span>
<span class="nl">error</span><span class="p">?:</span> <span class="kr">string</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">}</span>
</code></pre></div>
<p>アプリケーションのコードは、このセッションから <code>session.appUserId</code> を取り出して使うだけです。<strong>モックか本番かを意識する必要がありません</strong>。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nx">verifyWebsiteOwnership</span><span class="p">(</span><span class="nx">websiteId</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">session</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">auth</span><span class="p">();</span>