-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathfeed.xml
More file actions
1302 lines (920 loc) · 161 KB
/
feed.xml
File metadata and controls
1302 lines (920 loc) · 161 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"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Pepabo Tech Portal</title>
<id>https://tech.pepabo.com/</id>
<link href="https://tech.pepabo.com/"/>
<link href="https://tech.pepabo.com/feed.xml" rel="self"/>
<updated>2026-04-17T00:00:00+09:00</updated>
<author>
<name>GMO Pepabo, Inc.</name>
</author>
<entry>
<title>プロンプトのtypoをCIで弾く ── TypeScriptの型でAIへの指示を守る</title>
<link rel="alternate" href="https://tech.pepabo.com/2026/04/17/typescript-type-safe-prompt/"/>
<id>https://tech.pepabo.com/2026/04/17/typescript-type-safe-prompt/</id>
<published>2026-04-17T00:00:00+09:00</published>
<updated>2026-04-23T00:53:59+00:00</updated>
<author>
<name>kinosuke01</name>
</author>
<content type="html"><h2 id="はじめに">はじめに</h2>
<p>こんにちは。ロリポップ・ムームードメイン事業部でエンジニアリングリードをしています <a href="https://x.com/kinosuke01">kinosuke01</a> といいます。先日ゴジラ-0.0のティザー映像が解禁されましたね。公開が楽しみです。</p>
<p>さて、GMOペパボでは、ユーザーとの対話をもとにWebサイトをまるごと自動生成する<a href="https://lolipop.jp/ai/site-agent/">AIサイトエージェント</a>を提供しています。ユーザーが「カフェのサイトを作りたい」「コーポレートサイトがほしい」と伝えるだけで、ページ構成からデザインテーマ、コンテンツまでを一括で生成し、すぐに公開できるWebサイトを作り上げます。</p>
<p><img src="/blog/2026/04/17/typescript-type-safe-prompt/img01.png" alt="img01" />
<img src="/blog/2026/04/17/typescript-type-safe-prompt/img02.png" alt="img02" /></p>
<p>この仕組みの裏側では、Webサイトの構造をすべて<strong>JSON</strong>で表現しています。生成AIに対して「このJSONを埋めてください」と指示し、Structured Outputでフォーマットを固定することで、安定した出力を得ています。</p>
<p>しかし、JSONの構造を固定するだけでは不十分でした。各プロパティに<strong>何を入れるべきか</strong>を正しく伝えるために、システムプロンプトでフィールドごとの説明を与えています。ここで問題になったのが、<strong>プロンプトに書いたプロパティ名と実際のコードの型定義がズレるリスク</strong>です。</p>
<p>この記事では、TypeScriptの型システムを活用してプロンプトの正しさをコンパイル時に保証する仕組みを紹介します。</p>
<h2 id="サイト生成の仕組み">サイト生成の仕組み</h2>
<h3 id="jsonでwebサイトを表現する">JSONでWebサイトを表現する</h3>
<p>私たちのシステムでは、Webサイトを「セクション」の組み合わせで表現しています。ヒーローセクション、特徴紹介セクション、料金表セクションなど、複数のセクションを用意しており、それぞれがJSON構造を持ちます。</p>
<p>たとえば、ヒーローセクションはこのような構造です。</p>
<div class="highlight"><pre class="highlight json"><code><span class="p">{</span><span class="w">
</span><span class="nl">"component"</span><span class="p">:</span><span class="w"> </span><span class="s2">"HeroSection"</span><span class="p">,</span><span class="w">
</span><span class="nl">"props"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"headline"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"想いを、かたちに。"</span><span class="w"> </span><span class="p">},</span><span class="w">
</span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"あなたの「やりたい」を実現するために"</span><span class="w"> </span><span class="p">},</span><span class="w">
</span><span class="nl">"primaryButton"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"label"</span><span class="p">:</span><span class="w"> </span><span class="s2">"お問い合わせ"</span><span class="p">,</span><span class="w"> </span><span class="nl">"href"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/contact"</span><span class="w"> </span><span class="p">},</span><span class="w">
</span><span class="nl">"layout"</span><span class="p">:</span><span class="w"> </span><span class="s2">"split-content-image"</span><span class="p">,</span><span class="w">
</span><span class="nl">"image"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"imageId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hero-1"</span><span class="p">,</span><span class="w"> </span><span class="nl">"alt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"メインビジュアル"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div>
<h3 id="3つのエージェントによる段階的な生成">3つのエージェントによる段階的な生成</h3>
<p>サイト生成は、1回のAPI呼び出しで完結するわけではありません。大きく3つの生成ステップに分けて処理を進めます。</p>
<div class="highlight"><pre class="highlight plaintext"><code>ユーザーのヒアリング情報
│
▼
┌──────────────────────┐
│ PageAndSectionPlanner│ ← ページ構成とセクション選定
└─────────┬────────────┘
▼
┌─────────────────────┐
│ ThemeDeterminer │ ← カラー・フォントの決定
└─────────┬───────────┘
▼
┌──────────────────────┐
│ SectionValueGenerator│ ← 各セクションのコンテンツ生成
└─────────┬────────────┘
▼
Webサイト完成
</code></pre></div>
<h3 id="structured-outputでフォーマットを固定する">Structured Outputでフォーマットを固定する</h3>
<p>各ステップの出力は、Zodスキーマで定義した構造に従います。ZodスキーマをJSON Schemaに変換し、Gemini APIの<code>responseSchema</code>に渡すことで、AIの出力フォーマットを固定しています。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="c1">// Zodスキーマを定義</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">themeConfigSchema</span> <span class="o">=</span> <span class="nx">themeValuesSchema</span><span class="p">.</span><span class="nx">extend</span><span class="p">({</span>
<span class="na">themeId</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="kr">string</span><span class="p">().</span><span class="nx">max</span><span class="p">(</span><span class="nx">MAX_ID</span><span class="p">),</span>
<span class="na">fontId</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="kr">enum</span><span class="p">(</span><span class="nx">FONT_IDS</span><span class="p">).</span><span class="nx">optional</span><span class="p">(),</span>
<span class="p">});</span>
<span class="c1">// JSON Schemaに変換してStructured Outputに渡す</span>
<span class="kd">const</span> <span class="nx">themeConfigResponseSchema</span> <span class="o">=</span> <span class="nx">zodToResponseSchema</span><span class="p">(</span><span class="nx">themeConfigSchema</span><span class="p">);</span>
<span class="c1">// AIにリクエスト</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">model</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="p">{</span>
<span class="na">responseSchema</span><span class="p">:</span> <span class="nx">themeConfigResponseSchema</span><span class="p">,</span>
<span class="p">});</span>
</code></pre></div>
<p>これにより、AIが自由なフォーマットでJSONを返してしまう問題は解消されます。</p>
<h3 id="システムプロンプトで意味を伝える">システムプロンプトで「意味」を伝える</h3>
<p>構造を固定しただけでは、AIは各フィールドに何を入れるべきか判断できません。そこで、システムプロンプトでフィールドごとの説明を与えています。</p>
<p>たとえばセクションのコンテンツを生成する際、こんなプロンプトを組み立てます。</p>
<div class="highlight"><pre class="highlight plaintext"><code>## セクション1: HeroSection
### フィールド説明
- layout: レイアウトパターン。split-content-image: コンテンツ左・画像右(全幅)、...
- headline.text: ページ全体のキャッチコピー。事業の価値を一言で伝える短いフレーズ(10〜20文字)
- title.text: キャッチコピーを補足するサブメッセージ(15〜30文字)
- primaryButton.label: メインのCTAボタンのラベル(2〜8文字)。例: 「お問い合わせ」「詳しく見る」
- primaryButton.href: ボタンのリンク先。サイト内ページへの相対パス(例: /about, /contact)
</code></pre></div>
<p><code>headline.text</code> や <code>primaryButton.label</code> といったドットパスでプロパティの位置を指定し、AIに「ここには何文字くらいの、どんなテキストを入れてほしい」と具体的に伝えています。</p>
<h2 id="課題プロンプトとコードの乖離リスク">課題:プロンプトとコードの乖離リスク</h2>
<p>ここまでの仕組みはうまく動いていました。しかし、開発を進める中で不安が生まれます。</p>
<h3 id="プロパティ名が変わったらどうなる">プロパティ名が変わったらどうなる?</h3>
<p>フィールド説明のドットパス(<code>headline.text</code> や <code>primaryButton.label</code>)は、セクションのProps型と対応しています。もしリファクタリングで <code>headline</code> を <code>mainHeading</code> に変えたとき、フィールド説明のパスも更新しなければなりません。</p>
<p>しかし、フィールド説明がただの文字列であれば、Props型の変更に追従できているかはコードを目視で確認するしかありません。</p>
<h3 id="typoは静かに品質を劣化させる">typoは静かに品質を劣化させる</h3>
<p><code>headline.text</code> を <code>headling.text</code> とtypoしたらどうなるでしょうか。プログラムはエラーを出しません。プロンプトの中に誤ったパスが紛れ込むだけです。AIはそのパスに対応するフィールドを見つけられず、適切なコンテンツを生成できなくなります。</p>
<p>結果として、ある日突然「ヒーローセクションのキャッチコピーがなぜか空っぽのサイトが生成された」といった不具合が起きる可能性があります。Structured Outputで構造は正しいのに、中身の品質が落ちる。原因の特定が難しい厄介な問題です。</p>
<h2 id="解決策typescriptの型でプロンプトを縛る">解決策:TypeScriptの型でプロンプトを縛る</h2>
<h3 id="アプローチの全体像">アプローチの全体像</h3>
<p>まず、私たちが取ったアプローチの全体像を示します。</p>
<p>セクションごとに「フィールド説明オブジェクト」を定義し、それをプロンプト組み立て時に埋め込む構成にしました。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="kd">const</span> <span class="nx">fieldDescs</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">layout</span><span class="p">:</span> <span class="dl">"</span><span class="s2">レイアウトパターン。split-content-image: コンテンツ左・画像右...</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">headline.text</span><span class="dl">"</span><span class="p">:</span>
<span class="dl">"</span><span class="s2">ページ全体のキャッチコピー。事業の価値を一言で伝える短いフレーズ(10〜20文字)</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">title.text</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">キャッチコピーを補足するサブメッセージ(15〜30文字)</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">primaryButton.label</span><span class="dl">"</span><span class="p">:</span>
<span class="dl">"</span><span class="s2">メインのCTAボタンのラベル(2〜8文字)。例: 「お問い合わせ」「詳しく見る」</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">primaryButton.href</span><span class="dl">"</span><span class="p">:</span>
<span class="dl">"</span><span class="s2">ボタンのリンク先。サイト内ページへの相対パス(例: /about, /contact)</span><span class="dl">"</span><span class="p">,</span>
<span class="p">};</span>
</code></pre></div>
<p>ドットパスをキー、AIへの説明を値とするシンプルなオブジェクトです。プロンプト組み立て時にこのオブジェクトを展開し、箇条書き形式でプロンプトに埋め込みます。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="kd">function</span> <span class="nx">formatSectionFieldDescs</span><span class="p">(</span><span class="nx">descs</span><span class="p">:</span> <span class="nb">Record</span><span class="o">&lt;</span><span class="kr">string</span><span class="p">,</span> <span class="kr">string</span><span class="o">&gt;</span><span class="p">):</span> <span class="kr">string</span> <span class="p">{</span>
<span class="k">return</span> <span class="nb">Object</span><span class="p">.</span><span class="nx">entries</span><span class="p">(</span><span class="nx">descs</span><span class="p">)</span>
<span class="p">.</span><span class="nx">map</span><span class="p">(([</span><span class="nx">path</span><span class="p">,</span> <span class="nx">desc</span><span class="p">])</span> <span class="o">=&gt;</span> <span class="s2">` - </span><span class="p">${</span><span class="nx">path</span><span class="p">}</span><span class="s2">: </span><span class="p">${</span><span class="nx">desc</span><span class="p">}</span><span class="s2">`</span><span class="p">)</span>
<span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="dl">"</span><span class="se">\n</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div>
<p>ここまでは特別なことはしていません。ポイントはこの次です。</p>
<p>このオブジェクトのキーに<strong>TypeScriptの型制約</strong>を加えることで、typoやプロパティ名の変更に対してコンパイルエラーで気づけるようにしました。</p>
<h3 id="satisfiesで定義を検証する">satisfiesで定義を検証する</h3>
<p>具体的に見ていきましょう。各セクションのメタデータ定義で <code>satisfies</code> を使って型制約をかけます。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">heroSectionMeta</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">HeroSection</span><span class="dl">"</span><span class="p">,</span>
<span class="c1">// ...</span>
<span class="na">fieldDescs</span><span class="p">:</span> <span class="p">{</span>
<span class="na">layout</span><span class="p">:</span> <span class="nx">buildLayoutFieldDesc</span><span class="p">(</span><span class="nx">layoutDescs</span><span class="p">),</span>
<span class="dl">"</span><span class="s2">headline.text</span><span class="dl">"</span><span class="p">:</span>
<span class="dl">"</span><span class="s2">ページ全体のキャッチコピー。事業の価値を一言で伝える短いフレーズ(10〜20文字)</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">title.text</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">キャッチコピーを補足するサブメッセージ(15〜30文字)</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">primaryButton.label</span><span class="dl">"</span><span class="p">:</span>
<span class="dl">"</span><span class="s2">メインのCTAボタンのラベル(2〜8文字)。例: 「お問い合わせ」「詳しく見る」</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">primaryButton.href</span><span class="dl">"</span><span class="p">:</span>
<span class="dl">"</span><span class="s2">ボタンのリンク先。サイト内ページへの相対パス(例: /about, /contact)</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">secondaryButton.label</span><span class="dl">"</span><span class="p">:</span>
<span class="dl">"</span><span class="s2">サブのCTAボタンのラベル(2〜8文字)。例: 「詳しく見る」「サービス一覧」</span><span class="dl">"</span><span class="p">,</span>
<span class="p">}</span> <span class="nx">satisfies</span> <span class="nx">ValidFieldDescs</span><span class="o">&lt;</span><span class="nx">HeroSectionProps</span><span class="o">&gt;</span><span class="p">,</span> <span class="c1">// ← ここ</span>
<span class="p">}</span> <span class="k">as</span> <span class="kd">const</span><span class="p">;</span>
</code></pre></div>
<p>末尾の <code>satisfies ValidFieldDescs&lt;HeroSectionProps&gt;</code> が効いています。もし <code>"headline.text"</code> を <code>"headling.text"</code> とtypoしたら、<strong>コンパイルエラー</strong>になります。</p>
<div class="highlight"><pre class="highlight plaintext"><code>// typoするとコンパイルエラー
"headling.text": "ページ全体のキャッチコピー..."
// ~~~~~~~~~~~~~~
// Type '"headling.text"' is not assignable to type 'FieldDescPaths&lt;HeroSectionProps&gt;'
</code></pre></div>
<p>Props型のプロパティ名を変更した場合も同様です。<code>headline</code> を <code>mainHeading</code> にリネームすれば、<code>"headline.text"</code> は無効なパスになり、コンパイラが教えてくれます。</p>
<h3 id="validfielddescsの仕組み">ValidFieldDescsの仕組み</h3>
<p>では <code>ValidFieldDescs</code> がどのように機能しているかを見てみましょう。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="k">export</span> <span class="kd">type</span> <span class="nx">ValidFieldDescs</span><span class="o">&lt;</span><span class="nx">Props</span><span class="o">&gt;</span> <span class="o">=</span> <span class="nb">Partial</span><span class="o">&lt;</span>
<span class="nb">Record</span><span class="o">&lt;</span><span class="nx">FieldDescPaths</span><span class="o">&lt;</span><span class="nx">Props</span><span class="o">&gt;</span><span class="p">,</span> <span class="kr">string</span><span class="o">&gt;</span>
<span class="o">&gt;</span><span class="p">;</span>
</code></pre></div>
<p><code>FieldDescPaths&lt;Props&gt;</code> がProps型から「有効なドットパス」のunion型を自動生成する部分です。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="k">export</span> <span class="kd">type</span> <span class="nx">FieldDescPaths</span><span class="o">&lt;</span><span class="nx">T</span><span class="p">,</span> <span class="nx">D</span> <span class="kd">extends</span> <span class="kr">number</span> <span class="o">=</span> <span class="mi">4</span><span class="o">&gt;</span> <span class="o">=</span>
<span class="nx">D</span> <span class="kd">extends</span> <span class="mi">0</span>
<span class="p">?</span> <span class="nx">never</span>
<span class="p">:</span> <span class="nx">T</span> <span class="kd">extends</span> <span class="nx">object</span>
<span class="p">?</span> <span class="nx">IsIndexSignature</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span> <span class="kd">extends</span> <span class="kc">true</span>
<span class="p">?</span> <span class="nx">never</span>
<span class="p">:</span> <span class="p">{</span>
<span class="p">[</span><span class="nx">K</span> <span class="k">in</span> <span class="kr">keyof</span> <span class="nx">NonNullish</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span> <span class="o">&amp;</span> <span class="kr">string</span><span class="p">]:</span>
<span class="o">|</span> <span class="nx">K</span>
<span class="o">|</span> <span class="p">(</span><span class="nx">NonNullish</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">[</span><span class="nx">K</span><span class="p">]</span> <span class="kd">extends</span> <span class="nb">Array</span><span class="o">&lt;</span><span class="nx">infer</span> <span class="nx">E</span><span class="o">&gt;</span>
<span class="p">?</span> <span class="nx">E</span> <span class="kd">extends</span> <span class="nx">object</span>
<span class="p">?</span> <span class="s2">`</span><span class="p">${</span><span class="nx">K</span><span class="p">}</span><span class="s2">[]`</span> <span class="o">|</span> <span class="s2">`</span><span class="p">${</span><span class="nx">K</span><span class="p">}</span><span class="s2">[].</span><span class="p">${</span><span class="nx">FieldDescPaths</span><span class="o">&lt;</span><span class="nx">E</span><span class="p">,</span> <span class="nx">Prev</span><span class="p">[</span><span class="nx">D</span><span class="p">]</span><span class="o">&gt;</span><span class="p">}</span><span class="s2">`</span>
<span class="p">:</span> <span class="s2">`</span><span class="p">${</span><span class="nx">K</span><span class="p">}</span><span class="s2">[]`</span>
<span class="p">:</span> <span class="nx">NonNullish</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">[</span><span class="nx">K</span><span class="p">]</span> <span class="kd">extends</span> <span class="nx">object</span>
<span class="p">?</span> <span class="s2">`</span><span class="p">${</span><span class="nx">K</span><span class="p">}</span><span class="s2">.</span><span class="p">${</span><span class="nx">FieldDescPaths</span><span class="o">&lt;</span><span class="nx">NonNullish</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">[</span><span class="nx">K</span><span class="p">],</span> <span class="nx">Prev</span><span class="p">[</span><span class="nx">D</span><span class="p">]</span><span class="o">&gt;</span><span class="p">}</span><span class="s2">`</span>
<span class="p">:</span> <span class="nx">never</span><span class="p">);</span>
<span class="p">}[</span><span class="kr">keyof</span> <span class="nx">NonNullish</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span> <span class="o">&amp;</span> <span class="kr">string</span><span class="p">]</span>
<span class="p">:</span> <span class="nx">never</span><span class="p">;</span>
</code></pre></div>
<p>一見複雑ですが、やっていることはシンプルです。Props型を再帰的に走査し、有効なドットパスをすべてunion型として列挙します。</p>
<ul>
<li><strong>トップレベル</strong>: <code>"layout"</code></li>
<li><strong>ネストしたオブジェクト</strong>: <code>"headline.text"</code>, <code>"primaryButton.label"</code></li>
<li><strong>配列の要素</strong>: <code>"orderList.items[].title.text"</code></li>
</ul>
<p>たとえば <code>HeroSectionProps</code> に適用すると、<code>"layout" | "headline" | "headline.text" | "title" | "title.text" | "primaryButton" | "primaryButton.label" | "primaryButton.href" | ...</code> のようなunion型が得られます。このunion型に含まれないキーを <code>fieldDescs</code> に書くと、<code>satisfies</code> によりコンパイルエラーになるというわけです。</p>
<h3 id="テーマプロンプトでも同じアプローチ">テーマプロンプトでも同じアプローチ</h3>
<p>テーマ決定のプロンプトでも、カラーパスの定数を型で縛っています。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="kd">type</span> <span class="nx">ColorsInferred</span> <span class="o">=</span> <span class="nx">z</span><span class="p">.</span><span class="nx">infer</span><span class="o">&lt;</span><span class="k">typeof</span> <span class="nx">colorsSchema</span><span class="o">&gt;</span><span class="p">;</span>
<span class="cm">/** "group.field" 形式のパス型。colorsSchemaと一致しない場合コンパイルエラー */</span>
<span class="kd">type</span> <span class="nx">ColorPath</span><span class="o">&lt;</span><span class="nx">G</span> <span class="kd">extends</span> <span class="kr">keyof</span> <span class="nx">ColorsInferred</span><span class="o">&gt;</span> <span class="o">=</span>
<span class="s2">`</span><span class="p">${</span><span class="nx">G</span><span class="p">}</span><span class="s2">.</span><span class="p">${</span><span class="kr">string</span> <span class="o">&amp;</span> <span class="kr">keyof</span> <span class="nx">ColorsInferred</span><span class="p">[</span><span class="nx">G</span><span class="p">]}</span><span class="s2">`</span><span class="p">;</span>
<span class="cm">/** スキーマと連動した型安全なパス定数 */</span>
<span class="kd">const</span> <span class="nx">BG_PRIMARY_PATH</span><span class="p">:</span> <span class="nx">ColorPath</span><span class="o">&lt;</span><span class="dl">"</span><span class="s2">background</span><span class="dl">"</span><span class="o">&gt;</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">background.primary</span><span class="dl">"</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">TYPOGRAPHY_CONTRAST_PATH</span><span class="p">:</span> <span class="nx">ColorPath</span><span class="o">&lt;</span><span class="dl">"</span><span class="s2">typography</span><span class="dl">"</span><span class="o">&gt;</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">typography.contrastText</span><span class="dl">"</span><span class="p">;</span>
</code></pre></div>
<p>これらの定数はシステムプロンプトに埋め込まれます。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="kd">const</span> <span class="nx">themePrompt</span> <span class="o">=</span> <span class="s2">`
...
- {TYPOGRAPHY_CONTRAST_KEY} と {BG_PRIMARY_KEY} は必ず高コントラストを保ってください
...
`</span><span class="p">;</span>
<span class="c1">// プロンプト組み立て時に定数を埋め込む</span>
<span class="k">return</span> <span class="nx">themePrompt</span>
<span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/{TYPOGRAPHY_CONTRAST_KEY}/g</span><span class="p">,</span> <span class="nx">TYPOGRAPHY_CONTRAST_PATH</span><span class="p">)</span>
<span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/{BG_PRIMARY_KEY}/g</span><span class="p">,</span> <span class="nx">BG_PRIMARY_PATH</span><span class="p">);</span>
</code></pre></div>
<p>もし <code>colorsSchema</code> から <code>contrastText</code> が削除されたり名前が変わったりすれば、<code>TYPOGRAPHY_CONTRAST_PATH</code> の定義でコンパイルエラーが発生します。プロンプトに存在しないプロパティ名が紛れ込む余地がありません。</p>
<h3 id="フィールド説明の型安全性も">フィールド説明の型安全性も</h3>
<p>テーマのカラーフィールド説明にも、同じ考え方を適用しています。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="cm">/** palette 各フィールドの説明(AI プロンプト用) */</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">paletteFieldDescs</span><span class="p">:</span> <span class="nb">Record</span><span class="o">&lt;</span>
<span class="kr">keyof</span> <span class="nx">z</span><span class="p">.</span><span class="nx">infer</span><span class="o">&lt;</span><span class="k">typeof</span> <span class="nx">paletteSchema</span><span class="o">&gt;</span><span class="p">,</span>
<span class="kr">string</span>
<span class="o">&gt;</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">primary</span><span class="p">:</span> <span class="dl">"</span><span class="s2">パレットの主役となる色(全体の起点)</span><span class="dl">"</span><span class="p">,</span>
<span class="na">light</span><span class="p">:</span> <span class="dl">"</span><span class="s2">背景とprimaryを繋ぐ低彩度カラー</span><span class="dl">"</span><span class="p">,</span>
<span class="na">deep</span><span class="p">:</span> <span class="dl">"</span><span class="s2">primary のダークトーン(引き締め)</span><span class="dl">"</span><span class="p">,</span>
<span class="p">};</span>
</code></pre></div>
<p><code>Record&lt;keyof z.infer&lt;typeof paletteSchema&gt;, string&gt;</code> により、<code>paletteSchema</code> のすべてのフィールドに対して説明を書くことが強制されます。フィールドを追加したのに説明を書き忘れたらコンパイルエラー、存在しないフィールドの説明を書いてもコンパイルエラーです。</p>
<h2 id="まとめ">まとめ</h2>
<p>生成AIを活用したシステムでは、プロンプトの品質がそのままサービスの品質に直結します。しかしプロンプトは往々にしてただの文字列として扱われ、コードとの整合性は開発者の注意力に依存しがちです。</p>
<p>私たちはTypeScriptの型システムを使って、<strong>プロンプトに埋め込むフィールド名やパスをコードの型定義と連動させる</strong>ことで、この問題を解決しました。特別なツールやライブラリは必要ありません。<code>satisfies</code>、Template Literal Types、<code>Record&lt;keyof T, string&gt;</code> といったTypeScriptの標準的な機能だけで実現できます。</p>
<p>この仕組みの良いところは、<strong>CIの型チェック(<code>tsc --noEmit</code>)で自然にカバーされる</strong>点です。プロンプト専用のテストを書く必要はなく、普段の開発フローの中で、プロパティ名の変更やtypoが自動的に検出されます。</p>
<p>プロンプトもコードの一部です。型で守れるものは、型で守りましょう。</p>
</content>
</entry>
<entry>
<title>flaky testの原因は、無関係なファイルの1行にあった</title>
<link rel="alternate" href="https://tech.pepabo.com/2026/04/15/flaky-test-investigation/"/>
<id>https://tech.pepabo.com/2026/04/15/flaky-test-investigation/</id>
<published>2026-04-15T00:00:00+09:00</published>
<updated>2026-04-23T00:53:59+00:00</updated>
<author>
<name>aruma</name>
</author>
<content type="html"><p>SUZURI Webアプリケーションエンジニアのarumaです。<a href="/2026/04/14/flaky-tests/">昨日の記事</a>では、SUZURIで遭遇したflaky testの事例をいくつかご紹介しました。本記事では、その中でも特に原因の特定が難しかった1件を深掘りします。</p>
<ol id="markdown-toc">
<li><a href="#発生していた現象" id="markdown-toc-発生していた現象">発生していた現象</a></li>
<li><a href="#手がかり1-自前のメソッドが呼ばれていない" id="markdown-toc-手がかり1-自前のメソッドが呼ばれていない">手がかり1: 自前のメソッドが呼ばれていない</a></li>
<li><a href="#手がかり2-フレームワークに同名メソッドが追加されていた" id="markdown-toc-手がかり2-フレームワークに同名メソッドが追加されていた">手がかり2: フレームワークに同名メソッドが追加されていた</a></li>
<li><a href="#手がかり3-ancestorsチェーンの順序が変わっている" id="markdown-toc-手がかり3-ancestorsチェーンの順序が変わっている">手がかり3: ancestorsチェーンの順序が変わっている</a></li>
<li><a href="#手がかり4-トップレベルでのinclude" id="markdown-toc-手がかり4-トップレベルでのinclude">手がかり4: トップレベルでのinclude</a></li>
<li><a href="#真相" id="markdown-toc-真相">真相</a></li>
<li><a href="#おわりに" id="markdown-toc-おわりに">おわりに</a></li>
</ol>
<h2 id="発生していた現象">発生していた現象</h2>
<p><code>ApplicationHelper</code> に定義された画像表示用のヘルパーメソッド <code>picture_tag</code> のテストが、時々failしていました。</p>
<p>fail時のログを確認すると、<code>picture_tag</code> が期待と異なるHTMLを生成していました。</p>
<p>CIの実行ログからpass時とfail時それぞれのseed値を確認し、ローカルで同じseedを指定して実行してみると、seed値に応じてpassとfailが再現しました。</p>
<h2 id="手がかり1-自前のメソッドが呼ばれていない">手がかり1: 自前のメソッドが呼ばれていない</h2>
<p>試しに <code>ApplicationHelper#picture_tag</code> の中身に <code>raise</code> を加えてそれぞれのseedで実行してみると、</p>
<ul>
<li>passしていたseedで実行 → 例外が発生</li>
<li>failしていたseedで実行 → 例外は発生せず、同じようにfail</li>
</ul>
<p>という結果になりました。つまり、failするseedでは、テスト対象 <code>ApplicationHelper#picture_tag</code> がそもそも呼ばれていないということです。では、代わりに何が呼ばれているのでしょうか?</p>
<h2 id="手がかり2-フレームワークに同名メソッドが追加されていた">手がかり2: フレームワークに同名メソッドが追加されていた</h2>
<p><code>picture_tag</code> で検索してみると、Rails 7.1で <code>ActionView::Helpers::AssetTagHelper</code> に同名の <code>picture_tag</code> メソッドが<a href="https://guides.rubyonrails.org/7_1_release_notes.html#action-view-notable-changes">新規追加されていた</a>ことがわかりました。<code>ApplicationHelper#picture_tag</code> とは異なる動作をするメソッドです。</p>
<p>fail時の出力は、この <code>AssetTagHelper#picture_tag</code> の動作と一致していました。failするseedでは、<code>ApplicationHelper#picture_tag</code> ではなく <code>AssetTagHelper#picture_tag</code> が呼ばれていたようです。</p>
<p>Rubyのメソッド解決は、ancestorsチェーンを前から順にたどり、最初に見つかったメソッドを呼び出す仕組みです。通常、<code>ApplicationHelper</code> は <code>ActionView::Helpers::AssetTagHelper</code> よりもancestorsチェーンの前方に位置するため、フレームワーク側に同名のメソッドが追加されても <code>ApplicationHelper</code> の方が優先されるはずです。</p>
<p>ところが今回は、seed値によって呼ばれるメソッドが変わっています。ancestorsチェーンを実際に確認してみることにしました。</p>
<h2 id="手がかり3-ancestorsチェーンの順序が変わっている">手がかり3: ancestorsチェーンの順序が変わっている</h2>
<p>pass時とfail時、それぞれのseedでテスト対象のオブジェクトのancestorsチェーンを出力して比較してみると、次のようになっていました。</p>
<p><strong>pass時</strong></p>
<div class="highlight"><pre class="highlight plaintext"><code>...
29: ApplicationHelper
...
77: ActionView::Helpers::AssetTagHelper
...
</code></pre></div>
<p><strong>fail時</strong></p>
<div class="highlight"><pre class="highlight plaintext"><code>...
76: ActionView::Helpers::AssetTagHelper
...
95: ApplicationHelper
...
</code></pre></div>
<p>pass時には <code>ApplicationHelper</code> が <code>AssetTagHelper</code> より前にありますが、fail時には順序が逆転していました。つまり、failするseedでは <code>AssetTagHelper#picture_tag</code> が先に見つかり、そちらが呼ばれていたのです。</p>
<h2 id="手がかり4-トップレベルでのinclude">手がかり4: トップレベルでのinclude</h2>
<p>ancestorsチェーンが変わる原因を突き止めるため、<code>ApplicationHelper</code> に <code>included</code> フックを仕込みました。モジュールがどこかに <code>include</code> されるたびに、include先とそのコールスタックを出力するものです。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="k">module</span> <span class="nn">ApplicationHelper</span>
<span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">included</span><span class="p">(</span><span class="n">base</span><span class="p">)</span>
<span class="nb">puts</span> <span class="s2">"ApplicationHelper included into: </span><span class="si">#{</span><span class="n">base</span><span class="si">}</span><span class="s2">"</span>
<span class="nb">puts</span> <span class="nb">caller</span><span class="p">.</span><span class="nf">first</span><span class="p">(</span><span class="mi">3</span><span class="p">).</span><span class="nf">join</span><span class="p">(</span><span class="s2">"</span><span class="se">\n</span><span class="s2">"</span><span class="p">)</span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
<p>failするseedで実行してみると、次の出力が得られました。</p>
<div class="highlight"><pre class="highlight plaintext"><code>ApplicationHelper included into: Object
spec/graphql/queries/some_query_spec.rb:2:in 'include'
spec/graphql/queries/some_query_spec.rb:2:in '&lt;main&gt;'
</code></pre></div>
<p><code>ApplicationHelper</code> が <strong><code>Object</code> クラスに</strong> includeされています。該当のspecファイルを見てみると、以下のようなコードでした。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="nb">require</span> <span class="s1">'spec_helper'</span>
<span class="kp">include</span> <span class="no">ApplicationHelper</span>
<span class="n">describe</span> <span class="s1">'SomeQuery'</span> <span class="k">do</span>
<span class="c1"># ...</span>
<span class="k">end</span>
</code></pre></div>
<p><code>include</code> が <code>describe</code> ブロックの外、つまりトップレベルで実行されています。トップレベルで <code>include</code> を実行すると、<code>Object</code> クラスに対しての <code>include</code> となります。<code>Object</code> はほぼ全てのRubyクラスの祖先なので、ここにモジュールが追加されると全クラスのancestorsチェーンに影響します。</p>
<h2 id="真相">真相</h2>
<p>今回の事象は、3つの要因が重なって発生していました。</p>
<ol>
<li>Rails 7.1で追加された <code>AssetTagHelper#picture_tag</code> と、自前の <code>ApplicationHelper#picture_tag</code> が名前衝突していた。どちらが呼ばれるかはancestorsチェーンの順序で決まる(通常は自前のメソッドが優先される)</li>
<li>あるspecファイルがトップレベルで(つまり <code>Object</code> へ) <code>include ApplicationHelper</code> を実行していた</li>
<li>RSpecのテスト実行順序はseed値によって毎回変わる</li>
</ol>
<p>つまり、seed値次第で、テスト実行の流れは次のどちらかになります。</p>
<p>問題のspecが<strong>先に</strong>実行される場合:</p>
<ol>
<li>テスト実行開始</li>
<li>問題のspecが読み込まれ、<code>ApplicationHelper</code> が <code>Object</code> にincludeされる</li>
<li>ヘルパーのspecが実行される。<code>ApplicationHelper</code> はすでにinclude済みのため、テストコンテキストへのincludeが無視され、<code>AssetTagHelper#picture_tag</code> が優先される → <strong>fail</strong></li>
</ol>
<p>問題のspecが<strong>後に</strong>実行される場合:</p>
<ol>
<li>テスト実行開始</li>
<li>ヘルパーのspecが実行される。<code>ApplicationHelper</code> が通常通りテストコンテキストにincludeされ、<code>ApplicationHelper#picture_tag</code> が優先される → <strong>pass</strong></li>
<li>問題のspecが読み込まれ、<code>ApplicationHelper</code> が <code>Object</code> にincludeされる(しかし結果に影響しない)</li>
</ol>
<p>なお、”先か後か” の二択ならfailの確率は50%となりますが、SUZURIのCIではテストを複数プロセスに分散して実行しているため、実際の発生頻度は数%程度となっていたのでした。</p>
<p>原因さえわかれば対策はシンプルで、トップレベルでの <code>include</code> をやめるだけでした。<br />
同種の問題を防ぐには、トップレベルでの <code>include</code> を禁止するlint規則が有効です。</p>
<h2 id="おわりに">おわりに</h2>
<p>この問題は、今回のflaky test対処の中で原因特定に最も時間がかかったケースでした。どの要因も単体では問題になりませんが、3つが重なることで原因の見えにくいflaky testになっていました。</p>
<p>他のflaky testの事例は<a href="/2026/04/14/flaky-tests/">前回の記事</a>で紹介しています。</p>
</content>
</entry>
<entry>
<title>シニアエンジニア所信表明 — レバレッジと横断性で事業やサービスが向かうべき先に未来を作る</title>
<link rel="alternate" href="https://tech.pepabo.com/2026/04/15/senior-engineer-haruotsu/"/>
<id>https://tech.pepabo.com/2026/04/15/senior-engineer-haruotsu/</id>
<published>2026-04-15T00:00:00+09:00</published>
<updated>2026-04-23T00:53:59+00:00</updated>
<author>
<name>haruotsu</name>
</author>
<content type="html"><p>こんにちは!ロリポップ・ムームードメイン事業部ムームードメイングループのはるおつ(<a href="https://x.com/haruotsu_hy">@haruotsu_hy</a>)です。2026年4月1日にシニアエンジニアになりました。このエントリーでは、シニアエンジニアとしてこれから何をしていくのかを、ここに至るまでの経緯とあわせて書きます。</p>
<h2 id="所信表明">所信表明</h2>
<p>シニアエンジニアとして、<strong>レバレッジの効く開発と横断的な行動を武器に、目の前の課題を技術構造ごと解き、事業やサービスが向かうべき先に未来を作る。その成果を全社・業界を動かすレベルまで広げていきます。</strong></p>
<p>レバレッジの効く開発とは、一つの技術投資が時間軸・組織軸・技術軸で複数の価値を生む開発です。個別の問題を個別に解くのではなく、構造的に解く。目の前のタスクの背後にある構造を変えることで、まだ見えていない未来の課題まで同時に解決する。</p>
<p>横断的な行動とは、価値を届けるために自分から境界を越えに行くということです。技術領域も、組織も、職域も関係ない。価値があるなら自分のチームに閉じさせず、どこまでも持っていく。境界を一つ越えるたびに、同じ投資の効果が倍になっていく。</p>
<p>レバレッジで一つの投資の価値を最大化し、横断性でその価値の届く範囲を最大化する。この掛け算で、事業が向かうべき方向に対して技術の力で道を作る。それが私のエンジニアリングです。</p>
<h2 id="ペパボのエンジニア職位制度">ペパボのエンジニア職位制度</h2>
<p>GMOペパボのエンジニア職位制度は立候補制です。「作り上げる力」「先を見通す力」「影響を広げる力」の3軸で評価され、基準を超えた人が立候補資料を提出して昇格の判断を受けます(<a href="https://tech.pepabo.com/engineers/">詳細</a>)。</p>
<p>判断は「<strong>追認制</strong>」、つまりすでに実質その等級として振る舞えている人だけが昇格します。<br />
私の立候補資料も「以上で、私がシニアエンジニアに相応しい人材であるということは十分であろう。」という結論とともに締めくくりました。</p>
<p><img src="/blog/2026/04/15/senior-engineer-haruotsu/images/position-map.png" alt="エンジニア職位制度" /></p>
<h2 id="これまで">これまで</h2>
<p>私は2024年4月、<a href="https://recruit.group.gmo/710program/">新卒年収710万プログラム</a>のエンジニアとしてペパボに入社しました。<a href="https://www.onecareer.jp/articles/2754">プログラムの導入背景</a>にあるように、尖った技術力や専門性を幹にしながら複数の枝を生やし、すべてを自分ごと化しながら高いモチベーションと覚悟でチャレンジしていくことが求められる採用枠です。</p>
<p>その期待に応えるべく、ペパボの「<a href="https://speakerdeck.com/kentaro/the-secret-of-leadership-and-followership">やっていき・のっていき</a>」文化の「いいじゃん!」に背中を押されながら、同期や先輩パートナーとともに、速度感をもってありとあらゆることに挑戦し時には職種を超えて社内外で注目される活動を重ねてきたように思います。</p>
<p>しかし、2025年8月に行った1度目の立候補では、シニアエンジニアになることは叶いませんでした。</p>
<blockquote>
<p>「キャッチアップ力と行動量によって仕事が速いことは素晴らしい」「課題の粒度が小さい」「専門性が浅い」「レバレッジの影響範囲が狭い」「ペパボやサービスが haruotsu の専門性によってどう変わっていくのかという視点で課題に取り組み、上長やチームと同期し、意思決定を行なっていくことを期待」(抜粋)</p>
</blockquote>
<p>与えられたタスクを速を出しながらやっていき・のっていきを発揮しながら薙ぎ倒していくと同時に、シニアエンジニアに求められていたのは、部門の目標達成に関わる構造的課題を自分で定義し、自分の専門性で解き、その成果を事業部や会社レベルで動かしていく。この一連のサイクルを自分の手で回し切ることでした。</p>
<p>それ以降降ってくるタスクひとつひとつに対して、事業のボトルネックや構造的な課題はどこにあるか、当たり前を常に疑い、開発にレバレッジを組み込める余地はないか。</p>
<p>そしてそれを個人の思いつきで突き進めるのではなく、事業が向かうべき方向、ムームードメインでいえば「ドメインを入り口としたサービス展開」を軸に、未解決の専門領域の課題を自分で定義し、最速最高で実行し続けました。
その過程では、エンジニアチームが描きたい未来、自分が目指す「最高」が他メンバーの「最高」と干渉しないか、チーム・設計・拡張性の観点で最適かを常にすり合わせました。
それに加えて「なぜ今これをやるのか」「どのような価値を出しながら進めていくか」を言葉にし、エンジニアが目指す姿とマネージャを含めたビジネス側が求める価値のキャリブレーションを続けました。</p>
<p>結果として、私が引いた設計や事業価値の整理は、以降の施策を進める際の判断軸の一つとしてチームに残り、他メンバーの意思決定の土台にもなっていきました。
自分の動きが、自分一人の成果にとどまらず、組織の共通資産として機能し始めた手応えがあります。</p>
<p>これをひとつのサイクルとして回し続けることで、言葉として持っていた「レバレッジ」と「横断性」を実践の規模で体現していきました。</p>
<p>そうして臨んだ2度目の立候補で、以下のようなフィードバックをいただきました。</p>
<blockquote>
<p>長らく手がつけられなかった大きな事業課題に対し、臆することなく解決可能なサイズへと切り分け、<strong>圧倒的なコミット力で薙ぎ倒していく姿勢</strong>を高く評価します。アウトプットの量のみならず、その質においても <strong>「最高・最速」を両立</strong>させており、シニアエンジニアに相応しい模範を示しました。特に、歴史的経緯から困難であったシステムに対し、<strong>長期的な拡張性を担保した基盤を短期間で組み込んだ功績</strong>は大きいです。<strong>「負の遺産の解消と未来の創出」が同時に実現</strong>されたことは、4等級に求められる<strong>専門性と推進力の証左</strong>です。(抜粋)</p>
</blockquote>
<p>過去の負債を片付けるために作ったものが、そのまま未来の武器になる。一つの投資で過去と未来の両方を動かす。これがレバレッジと横断性の掛け算であり、大切にしていることそのものを認めていただけたと受け止めています。</p>
<h2 id="これから">これから</h2>
<p>てこの支点が深いところにあるほど、動かせるものは大きくなります。フィードバックでは、以下のような期待もいただいています。</p>
<blockquote>
<p>事業の中核をなす専門技術への理解をさらに深め、社内での実践に留まらず、該当領域の技術コミュニティへの積極的な参加や発信にも取り組んでほしいです。一般的なWebエンジニアの枠を超え、ペパボの事業ドメインにおける第一人者としての専門性を身につけることで、会社に、そして業界にさらなる大きなインパクトを与えるリーダーへと成長していく姿を期待しています。 (抜粋)</p>
</blockquote>
<p>今いる場所でその中核技術となるのはドメインやDNSです。インターネットの根幹をなすこの領域にまずは深く潜ります。<br />
また、ドメイン領域に限らず、どこにいても、その事業の中核をなす技術に深く潜っていきます。</p>
<p>それがレバレッジの支点を深くすることになると考えています。</p>
<p>加えて、AIによって開発の時間軸が圧縮され続けているいま、この変化にただ適応するのではなく、自分自身、そしてペパボが業界において先んじてインパクトを起こしていきたいと考えています。</p>
<p>事業ドメインの深化も新しい時代への対応も、レバレッジと横断性を武器に最高・最速を両立させていきます。</p>
</content>
</entry>
<entry>
<title>SUZURIで遭遇したflaky testの事例集</title>
<link rel="alternate" href="https://tech.pepabo.com/2026/04/14/flaky-tests/"/>
<id>https://tech.pepabo.com/2026/04/14/flaky-tests/</id>
<published>2026-04-14T00:00:00+09:00</published>
<updated>2026-04-23T00:53:59+00:00</updated>
<author>
<name>aruma</name>
</author>
<content type="html"><p>SUZURI Webアプリケーションエンジニアのarumaです。SUZURIにはRSpecによるテストコードが多数ありますが、一時期flaky testが増えてCIが不安定になってしまったことがありました。まとめて調査・対処した際の記録から、いくつかの事例をピックアップしてご紹介します。</p>
<p>なお、記事中のコードは説明のために簡略化したものです。</p>
<ol id="markdown-toc">
<li><a href="#事例1-2026年になると失敗するテスト" id="markdown-toc-事例1-2026年になると失敗するテスト">事例1: 2026年になると失敗するテスト</a></li>
<li><a href="#事例2-深夜0時ちょうどにのみ失敗するテスト" id="markdown-toc-事例2-深夜0時ちょうどにのみ失敗するテスト">事例2: 深夜0時ちょうどにのみ失敗するテスト</a></li>
<li><a href="#事例3-保証のない順序に依存していたテスト" id="markdown-toc-事例3-保証のない順序に依存していたテスト">事例3: 保証のない順序に依存していたテスト</a></li>
<li><a href="#事例4-不正なテストデータが作られていたテスト" id="markdown-toc-事例4-不正なテストデータが作られていたテスト">事例4: 不正なテストデータが作られていたテスト</a></li>
<li><a href="#事例5-偶然の一致で失敗するテスト" id="markdown-toc-事例5-偶然の一致で失敗するテスト">事例5: 偶然の一致で失敗するテスト</a></li>
<li><a href="#番外編-そもそも実行されていなかったテスト" id="markdown-toc-番外編-そもそも実行されていなかったテスト">番外編: そもそも実行されていなかったテスト</a></li>
<li><a href="#おわりに" id="markdown-toc-おわりに">おわりに</a></li>
</ol>
<h2 id="事例1-2026年になると失敗するテスト">事例1: 2026年になると失敗するテスト</h2>
<p>これは厳密にはflaky testというより「ある時点から必ず失敗するようになったテスト」ですが、テストの書き方に共通する教訓があるので紹介します。</p>
<p>クレジットカード決済に関するテストのセットアップ内で、カードの有効期限がハードコードされていました。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">let</span><span class="p">(</span><span class="ss">:credit_card</span><span class="p">)</span> <span class="k">do</span>
<span class="n">create</span><span class="p">(</span>
<span class="ss">:credit_card</span><span class="p">,</span>
<span class="ss">expire_year: </span><span class="mi">2025</span><span class="p">,</span>
<span class="ss">expire_month: </span><span class="mi">12</span>
<span class="p">)</span>
<span class="k">end</span>
</code></pre></div>
<p>このコードが書かれたのは2020年。5年間は問題なく動いていましたが、2026年を迎えた途端に有効期限切れの無効なカードとなり、テストの検証対象とは無関係な理由でエラーが発生するようになってしまいました。</p>
<p>対処として、有効期限が常に未来の日付となるよう、現在の日付から算出する形に変更しました。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">let</span><span class="p">(</span><span class="ss">:credit_card</span><span class="p">)</span> <span class="k">do</span>
<span class="n">create</span><span class="p">(</span>
<span class="ss">:credit_card</span><span class="p">,</span>
<span class="ss">expire_year: </span><span class="no">Time</span><span class="p">.</span><span class="nf">zone</span><span class="p">.</span><span class="nf">today</span><span class="p">.</span><span class="nf">year</span> <span class="o">+</span> <span class="mi">1</span><span class="p">,</span>
<span class="ss">expire_month: </span><span class="mi">12</span>
<span class="p">)</span>
<span class="k">end</span>
</code></pre></div>
<p>似た話として、<code>travel_to</code> で固定した時刻がSQLの <code>now()</code> には反映されないために、テストとDBで時刻がずれていたケースもありました。<code>travel_to</code> はRubyプロセス内の時刻を差し替えるだけなので、DBの <code>now()</code> には効きません。これも月をまたいで初めて顕在化しました。</p>
<p>時間の経過でテストの前提が崩れないようにすることが大切です。</p>
<h2 id="事例2-深夜0時ちょうどにのみ失敗するテスト">事例2: 深夜0時ちょうどにのみ失敗するテスト</h2>
<p>夜間に自動作成されるPRだけなぜか時々CIが落ちる、ということがありました。落ちていたのはセールの適用判定に関するテストで、調査するとテストデータの時刻指定方法が原因でした。</p>
<p>セールには開始時刻と終了時刻があります。factoryのデフォルトでは、開始時刻が「今日の00:00:00」に設定されていました。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">factory</span> <span class="ss">:time_discount</span> <span class="k">do</span>
<span class="n">start_time</span> <span class="p">{</span> <span class="no">Time</span><span class="p">.</span><span class="nf">current</span><span class="p">.</span><span class="nf">beginning_of_day</span> <span class="p">}</span>
<span class="n">end_time</span> <span class="p">{</span> <span class="mi">1</span><span class="p">.</span><span class="nf">hour</span><span class="p">.</span><span class="nf">from_now</span> <span class="p">}</span>
<span class="k">end</span>
</code></pre></div>
<p>そして、終了済みセールのテストでは、終了時刻のみを過去日時に差し替えていました。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">let</span><span class="p">(</span><span class="ss">:time_discount</span><span class="p">)</span> <span class="k">do</span>
<span class="n">create</span><span class="p">(</span>
<span class="ss">:time_discount</span><span class="p">,</span>
<span class="ss">end_time: </span><span class="mi">2</span><span class="p">.</span><span class="nf">minutes</span><span class="p">.</span><span class="nf">ago</span>
<span class="p">)</span>
<span class="k">end</span>
</code></pre></div>
<p>ほとんどの時間帯では問題になりませんが、深夜00:00〜00:02の間にテストが実行されると</p>
<ul>
<li>開始時刻: <code>Time.current.beginning_of_day</code> → 今日の00:00:00</li>
<li>終了時刻: <code>2.minutes.ago</code> → 昨日の23:58頃</li>
</ul>
<p>と、終了時刻が開始時刻より前になり、セットアップ段階でバリデーションエラーが発生する状態になっていました。</p>
<p>対処として、時刻の逆転が起きないよう、開始時刻も明示的に指定しました。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">let</span><span class="p">(</span><span class="ss">:time_discount</span><span class="p">)</span> <span class="k">do</span>
<span class="n">create</span><span class="p">(</span>
<span class="ss">:time_discount</span><span class="p">,</span>
<span class="ss">start_time: </span><span class="mi">1</span><span class="p">.</span><span class="nf">hour</span><span class="p">.</span><span class="nf">ago</span><span class="p">,</span>
<span class="ss">end_time: </span><span class="mi">2</span><span class="p">.</span><span class="nf">minutes</span><span class="p">.</span><span class="nf">ago</span>
<span class="p">)</span>
<span class="k">end</span>
</code></pre></div>
<p>パラメータの一部を上書きする際、他のパラメータとの整合性は見落としがちなポイントです。</p>
<h2 id="事例3-保証のない順序に依存していたテスト">事例3: 保証のない順序に依存していたテスト</h2>
<p>コレクションを返すAPIのテストで、特定の要素が含まれることを検証する際に <code>.first</code> で先頭要素だけをチェックしていました。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">result</span> <span class="o">=</span> <span class="n">api_response</span><span class="p">.</span><span class="nf">items</span>
<span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="nf">first</span><span class="p">).</span><span class="nf">to</span> <span class="n">have_attributes</span><span class="p">(</span><span class="ss">color: </span><span class="s2">"white"</span><span class="p">)</span>
</code></pre></div>
<p>しかし、このAPIは返却順序を保証しておらず、<code>first</code> で取得される要素が実行ごとに変わりうる状態になっていました。</p>
<p>対処として、「先頭が一致すること」ではなく「目的の要素が含まれること」に修正しました。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">result</span> <span class="o">=</span> <span class="n">api_response</span><span class="p">.</span><span class="nf">items</span>
<span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">).</span><span class="nf">to</span> <span class="kp">include</span><span class="p">(</span><span class="n">an_object_having_attributes</span><span class="p">(</span><span class="ss">color: </span><span class="s2">"white"</span><span class="p">))</span>
</code></pre></div>
<p>順序保証のないコレクションに対して <code>first</code> で検証するのは、flaky testの典型的な原因です。</p>
<h2 id="事例4-不正なテストデータが作られていたテスト">事例4: 不正なテストデータが作られていたテスト</h2>
<p>あるモデルが1対多の関連を持ち、そのうち1件を <code>primary: true</code> で代表データとして扱う構造がありました。代表データは <code>has_one</code> で取得できるようになっています。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">has_one</span> <span class="ss">:primary_record</span><span class="p">,</span> <span class="o">-&gt;</span> <span class="p">{</span> <span class="n">where</span><span class="p">(</span><span class="ss">primary: </span><span class="kp">true</span><span class="p">)</span> <span class="p">},</span> <span class="ss">class_name: </span><span class="s2">"Record"</span>
</code></pre></div>
<p>factoryのデフォルトが <code>primary: true</code> であり、2件作成すると両方がprimaryで作成されていました。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">let</span><span class="p">(</span><span class="ss">:parent</span><span class="p">)</span> <span class="p">{</span> <span class="n">create</span><span class="p">(</span><span class="ss">:parent</span><span class="p">)</span> <span class="p">}</span>
<span class="n">let!</span><span class="p">(</span><span class="ss">:record_a</span><span class="p">)</span> <span class="p">{</span> <span class="n">create</span><span class="p">(</span><span class="ss">:record</span><span class="p">,</span> <span class="ss">parent: </span><span class="n">parent</span><span class="p">)</span> <span class="p">}</span> <span class="c1"># primary: true</span>
<span class="n">let!</span><span class="p">(</span><span class="ss">:record_b</span><span class="p">)</span> <span class="p">{</span> <span class="n">create</span><span class="p">(</span><span class="ss">:record</span><span class="p">,</span> <span class="ss">parent: </span><span class="n">parent</span><span class="p">)</span> <span class="p">}</span> <span class="c1"># primary: true</span>
<span class="n">it</span> <span class="s2">"正しいURLが組み立てられること"</span> <span class="k">do</span>
<span class="n">expect</span><span class="p">(</span><span class="n">parent</span><span class="p">.</span><span class="nf">page_url</span><span class="p">).</span><span class="nf">to</span> <span class="n">eq</span> <span class="s2">"https://example.com/records/</span><span class="si">#{</span><span class="n">record_a</span><span class="p">.</span><span class="nf">id</span><span class="si">}</span><span class="s2">"</span>
<span class="k">end</span>
</code></pre></div>
<p>その結果、内部で <code>primary_record</code> を参照していた、URLを組み立てるメソッドのテストがflakyになっていました。<code>has_one</code> は該当レコードが複数あってもエラーにはならず、いずれか1件を返すため、<code>record_b</code> が返された場合にテストが落ちていました。</p>
<p>2件目の作成時に <code>primary: false</code> を指定することで解消しました。</p>
<p>テストデータのセットアップでは、実環境では起こりえないようなデータを作ってしまわないよう注意が必要です。</p>
<h2 id="事例5-偶然の一致で失敗するテスト">事例5: 偶然の一致で失敗するテスト</h2>
<p>SUZURIでは、Tシャツなどの物理商品に加えて、スマホ用壁紙画像などのデジタルコンテンツを販売することもできます。これらを横断的に取得するAPIがあり、そのテストで問題が発生しました。</p>
<p>APIのレスポンスには順序保証がなく、かつ各エントリの属性を個別に検証する必要があったため、IDで目的のレコードを探していました。イメージとしては以下のようなコードです。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">let</span><span class="p">(</span><span class="ss">:product</span><span class="p">)</span> <span class="p">{</span> <span class="n">create</span><span class="p">(</span><span class="ss">:product</span><span class="p">)</span> <span class="p">}</span>
<span class="n">let</span><span class="p">(</span><span class="ss">:digital_product</span><span class="p">)</span> <span class="p">{</span> <span class="n">create</span><span class="p">(</span><span class="ss">:digital_product</span><span class="p">)</span> <span class="p">}</span>
<span class="n">before</span> <span class="k">do</span>
<span class="n">register</span><span class="p">(</span><span class="n">product</span><span class="p">)</span>
<span class="n">register</span><span class="p">(</span><span class="n">digital_product</span><span class="p">)</span>
<span class="k">end</span>
<span class="n">it</span> <span class="k">do</span>
<span class="n">entries</span> <span class="o">=</span> <span class="n">api_response</span><span class="p">[</span><span class="s2">"entries"</span><span class="p">]</span>
<span class="n">product_entry</span> <span class="o">=</span> <span class="n">entries</span><span class="p">.</span><span class="nf">find</span> <span class="p">{</span> <span class="o">|</span><span class="n">entry</span><span class="o">|</span> <span class="n">entry</span><span class="p">[</span><span class="s2">"id"</span><span class="p">]</span> <span class="o">==</span> <span class="n">product</span><span class="p">.</span><span class="nf">id</span><span class="p">.</span><span class="nf">to_s</span> <span class="p">}</span>
<span class="n">digital_product_entry</span> <span class="o">=</span> <span class="n">entries</span><span class="p">.</span><span class="nf">find</span> <span class="p">{</span> <span class="o">|</span><span class="n">entry</span><span class="o">|</span> <span class="n">entry</span><span class="p">[</span><span class="s2">"id"</span><span class="p">]</span> <span class="o">==</span> <span class="n">digital_product</span><span class="p">.</span><span class="nf">id</span><span class="p">.</span><span class="nf">to_s</span> <span class="p">}</span>
<span class="c1"># product専用の検証</span>
<span class="n">verify_product_attributes</span><span class="p">(</span><span class="n">product_entry</span><span class="p">)</span>
<span class="c1"># digital_product専用の検証</span>
<span class="n">verify_digital_product_attributes</span><span class="p">(</span><span class="n">digital_product_entry</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div>
<p><code>product</code> と <code>digital_product</code> は別テーブルであり、IDの採番は独立しています。テストの実行時に両者にたまたま同じIDが振られると、<code>find</code> が誤った方にマッチしてしまい、属性の検証で失敗するという状況でした。</p>
<p>対処として、IDに加えて型情報も含めて取得するようにしました。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">product_entry</span> <span class="o">=</span> <span class="n">entries</span><span class="p">.</span><span class="nf">find</span> <span class="p">{</span> <span class="o">|</span><span class="n">entry</span><span class="o">|</span> <span class="n">entry</span><span class="p">[</span><span class="s2">"type"</span><span class="p">]</span> <span class="o">==</span> <span class="s2">"Product"</span> <span class="o">&amp;&amp;</span> <span class="n">entry</span><span class="p">[</span><span class="s2">"id"</span><span class="p">]</span> <span class="o">==</span> <span class="n">product</span><span class="p">.</span><span class="nf">id</span><span class="p">.</span><span class="nf">to_s</span> <span class="p">}</span>
<span class="n">digital_product_entry</span> <span class="o">=</span> <span class="n">entries</span><span class="p">.</span><span class="nf">find</span> <span class="p">{</span> <span class="o">|</span><span class="n">entry</span><span class="o">|</span> <span class="n">entry</span><span class="p">[</span><span class="s2">"type"</span><span class="p">]</span> <span class="o">==</span> <span class="s2">"DigitalProduct"</span> <span class="o">&amp;&amp;</span> <span class="n">entry</span><span class="p">[</span><span class="s2">"id"</span><span class="p">]</span> <span class="o">==</span> <span class="n">digital_product</span><span class="p">.</span><span class="nf">id</span><span class="p">.</span><span class="nf">to_s</span> <span class="p">}</span>
</code></pre></div>
<p>IDがユニークである範囲を意識する必要があります。</p>
<h2 id="番外編-そもそも実行されていなかったテスト">番外編: そもそも実行されていなかったテスト</h2>
<p>flaky test調査の過程で発見した、「そもそも実行されていなかったテスト」の話です。</p>
<p>有効なテストコードが書かれたあるテストファイルの名前が <code>_spec.rb</code> で終わっておらず、RSpecの実行対象に含まれていませんでした。</p>
<p>再発防止として、テストが記述されたRubyファイルが <code>_spec.rb</code> で終わっていることをチェックする仕組みを導入しました。</p>
<h2 id="おわりに">おわりに</h2>
<p>どの事例も、原因がわかってしまえば対処は簡単でした。手間がかかるのは、原因を突き止めるところです。今回、flaky testをまとめて対処できたのは、遭遇するたびにClaude Code等のAIにバックグラウンドで調査を任せていたことが大きいです。従来であれば「再実行したら通ったからいいか」と見過ごしがちだったものも、メインの作業と並行して対処を進められるようになりました。</p>
<p>本記事で紹介した事例は比較的シンプルなものですが、中には複数の要因が重なり、原因の特定に大きく手間取ったケースもありました。その話は<a href="/2026/04/15/flaky-test-investigation/">明日の記事</a>で詳しく紹介します。</p>
</content>
</entry>
<entry>
<title>「技術で事業の成長曲線を変える」── 事業成長を軸に働くエンジニアがGMOペパボを選んだ理由</title>
<link rel="alternate" href="https://tech.pepabo.com/2026/04/13/engineer-entry-article/"/>
<id>https://tech.pepabo.com/2026/04/13/engineer-entry-article/</id>
<published>2026-04-13T00:00:00+09:00</published>
<updated>2026-04-23T00:53:59+00:00</updated>
<author>
<name>tokai</name>
</author>
<content type="html"><p>2026年3月、SREチームに新しいメンバーが加わりました。事業会社で約5年間、バックエンド開発からアーキテクチャ設計、開発リードまで幅広く経験してきたtokaiさん。技術力だけでなく「事業と数字に向き合う姿勢」を自身の核に据えるエンジニアがなぜ次の活躍の場としてGMOペパボを選んだのか、そのキャリア観はどのように形成されたのか。これまでの歩みとともにお話を伺いました。</p>
<hr />
<h2 id="偶然から始まったエンジニア人生">偶然から始まったエンジニア人生</h2>
<p>──エンジニアを目指そうと思ったきっかけを教えてください</p>
<p>大学4年のとき、1年間休学して海外でインターンをしていました。オフショア企業で日本人のPMと現地エンジニアをつなぐブリッジのような役割を担っていたのですが、そこで初めてコードに触れたのがきっかけです。
もともと親族が建築会社を経営していたこともあり「ものづくり」への漠然とした憧れはずっとありました。プログラミングはそこに通じる感覚があって、一気にのめり込んでいきました。競技プログラミングにも触れていて、問題を解いていくゲーム性も相まって、どんどん面白くなっていった記憶があります。</p>
<h2 id="小さな組織で全部やる">小さな組織で、全部やる</h2>
<p>──新卒の際、どのような軸(基準)で会社を選ばれたのですか?</p>
<p>正直に言うと、当時業界軸ではあまり考えておらず、当時は事業ドメインへの関心もほとんどなくて、使っている技術スタックが面白そうだな、くらいの感覚で企業を見ていました。ただ「エンジニア組織が小さいところで、何でもやれる環境がいい」という軸は明確にありました。内定をいくつかいただいた中で、エンジニアが最も少ない会社を選んでいます。
実際、入社した企業は、当時エンジニアは20人に満たず、私は新卒エンジニアの第1期生でした。バックエンド担当として入りましたが、インフラもやればフロントも触る。必要なら事業部との折衝まで――とにかく自走するしかない環境でした。
最初の3年間は正直、自身の力不足もあって事業への手触り感はほとんどなかったです。ただ、とにかくいろんなことをやらせてもらえました。振り返れば、あの環境が今のエンジニアとしての土台になっています。</p>
<h2 id="転機になった事業の早期撤退">転機になった「事業の早期撤退」</h2>
<p>──新卒として入社した企業には5年近く在籍されたと思うのですが、その中で印象的なエピソードはありますか?</p>
<p>2年目に新規プロジェクトのアーキテクチャ設計からアプリケーション開発までゼロベースで任せてもらいました。途中から私がPMを兼務することになり、3年目には正式にPMへ職種を転身しました。何とかリリースまでこぎ着けたものの、運用開始からわずか4ヶ月で早期撤退という結果になりました。
投資対効果が低いと判断されたのが直接の原因です。何より辛かったのは、ユーザーや事業部の期待に応えられなかったこと、そして事業にも何のインパクトも残せなかったことでした。様々な機会をいただいた中でのこの結果は、私にとって非常に大きな挫折となりました。</p>
<h2 id="技術が好きだからこそ事業を見る">「技術が好きだからこそ、事業を見る」</h2>
<p>──その後、考え方はどう変わりましたか?</p>
<p>4年目に新しいプロジェクトへ異動したタイミングで、ちょうど入社してきたPMの方から事業と数字の大切さを徹底的に叩き込まれました。それがターニングポイントです。
誤解されがちですが、事業を見るというのは技術への追求を手放すことではないんです。やりたい技術を会社で実現するためには、意思決定者の承認が要る。承認を得るには投資対効果を示さなければならない。つまり事業目標や数字にきちんとヒットする提案ができてはじめて、チームがやりたい技術にも投資してもらえる。私はそういう環境を作るのがシニアエンジニアの役割だと思っています。
技術も事業も、どっちもやりたい。だからこそ事業の視点が必要なんです。</p>
<p><img src="/blog/2026/04/13/engineer-entry-article/images/DSC2148.jpg" alt="インタビュー中のtokaiさん" /></p>
<h2 id="想定外の転職活動">想定外の転職活動</h2>
<p>──転職しようと考えたきっかけ、そして転職活動はどのように進められましたか?</p>
<p>入社して4,5年経ったときに、プロダクト開発そのものにはやりがいを感じていたものの、自身の技術が事業や数字にどれだけインパクトを与えられているのか――その実感をより強く得られる環境に身を置きたいと考えるようになったのが転職のきっかけでした。
そのため、今回の転職活動の軸は2つでした。</p>
<ol>
<li>「技術力が事業成長や数字に直接つながる実感を得られる環境か」</li>
<li>「これまでの経験がその会社にとって価値になるか」</li>
</ol>
<p>私はお客さんではないので、これまでの経験を活かしてしっかりと成果を出せるかどうかも重要な判断基準でした。
ありがたいことに多くの企業様からお声がけいただいたのですが、前職の最終月が丸々有休だったこともあり、1社ずつ会社のサイト・IR情報・テックブログを読み込んでじっくり検討させていただきました。特にこだわったのは、「結果や成果を定量的に把握しようとしているか」どうかでした。</p>
<h2 id="ペパボを選んだ理由">ペパボを選んだ理由</h2>
<p>──最終的にペパボに決めた理由は何ですか?</p>
<p>選考プロセスでの対応スピードが圧倒的に速かったことは印象に残っています。とにかくレスポンスが早く、それだけでもこの会社は候補者に向き合ってくれていると感じました。
加えて、面談の中で「事業や数字を大切にするエンジニアが欲しい」という話があり、私の軸と一致したのも大きかったです。内定前には配属チーム以外の事業部CTOの方ともお話しする機会をいただき、組織全体の解像度が上がったことで最終的な決断に至りました。</p>
<h2 id="入社後のギャップ">入社後のギャップ</h2>
<p>──実際に入社してみていかがですか?また、今後やっていきたいことがあれば教えてください。</p>
<p>転職前とのギャップはまったくありません。これまで10年、20年と継続してきたシステムに携わったことは無かったので、この規模感やこれまで関わられてきた事業部、エンジニアのみなさまには素直にリスペクトを持っています。</p>
<p>SREとしての私の役割は、技術横断で信頼性と経済合理性のバランスを取り、無駄の削減やインフラコストの最適化を通じて利益率を向上させることです。つまり、事業の土台を技術で強くしていくことであり、そこに大きなやりがいを感じています。同じ部門のパートナーやサービスを運営する事業部のパートナーともコミュニケーションをスムーズにとることができています。
また「このチームにあまりいなかったタイプ」と言っていただいているので、事業と数字の目線、そしてそこへの推進力をチームやエンジニア組織全体に広げていくことも、私に期待されている役割だと捉えていますし、どんどんアウトプットしてやっていきたいと思っています。</p>
<p><img src="/blog/2026/04/13/engineer-entry-article/images/DSC2194.jpg" alt="tokaiさん" /></p>
</content>
</entry>
<entry>
<title>シニアエンジニア所信表明 — AIを使うから、AIに使ってもらうへ</title>
<link rel="alternate" href="https://tech.pepabo.com/2026/04/10/senior-engineer-watasan/"/>
<id>https://tech.pepabo.com/2026/04/10/senior-engineer-watasan/</id>
<published>2026-04-10T00:00:00+09:00</published>
<updated>2026-04-23T00:53:59+00:00</updated>
<author>
<name>watasan</name>
</author>
<content type="html"><p>SUZURI・minne事業部の渡辺(<a href="https://x.com/ae14watanabe">@ae14watanabe</a>)です。2026年4月にシニアエンジニアとなりました。自己紹介を兼ねてこれまでの自分の取り組みを簡単に紹介します。その上で、これから何をしていくのかを述べ、所信表明とします。</p>
<h2 id="これまでサービスがaiを使う">これまで:サービスがAIを使う</h2>
<p>私は自身の専門領域を「AIが関わるサービスの開発」とし、これまで<a href="https://suzuri.jp">SUZURI byGMOペパボ</a>において、AI(LLMをはじめとする機械学習モデル)を導入した機能の開発を複数行ってきました。具体的な取り組みについては<a href="https://speakerdeck.com/ae14watanabe/weve-got-to-make-this-web-service-better-with-ai-trials-and-errors-at-suzuri-by-gmo-pepabo">こちらの資料</a>にまとめていますが、一例を挙げると、商品の規約違反チェックをAIで自動化するプロジェクトをリードしてきました。</p>
<p>AIを導入した機能開発と、そうでない開発の根本的な違いは、不確実性の有無にあります。従来のソフトウェアは書いた通りに動きます。バグがなければ期待通りの結果を決定的に返します。一方でAIは、入力のわずかな違いで出力が変わりうる、複雑な判断を求められるほど完璧な結果は期待できない、といった性質があり、不確実性が常につきまといます。この不確実性をハンドリングすることが自分の仕事だと考えています。</p>
<p>ハンドリングとしてやってきたことは大きく2つあります。1つ目はAIへの入力を設計し、評価し、改善するサイクルを回すことです。規約違反チェックの例でいえば、違反ジャンルを網羅したデータセットを構築してAIの判断を定量評価しました。さらに、CSチームが現場で見つけた誤判定のフィードバックも踏まえて改善サイクルを回してきました。2つ目は、AIの誤りを許容できるフローの構築です。AIは間違える前提で、ユーザ・サービス・運営者それぞれのレイヤーで誤りを吸収できる仕組みを設計してきました。</p>
<h2 id="これからaiにサービスを使ってもらう">これから:AIにサービスを使ってもらう</h2>
<p>しかし最近、自分が「AIが関わるサービス」をかなり狭く捉えていたのではないか、と思うようになりました。</p>
<p>自分がやってきたのは「サービスがAIを使うこと」でした。構造としては「ユーザ ↔ サービス ↔ AI」——サービスがAIを呼び出し、その結果をサービスが受け取ってユーザに届ける形です。</p>
<p><img src="/blog/2026/04/10/senior-engineer-watasan/user-service-ai.jpeg" alt="ユーザ ↔ サービス ↔ AI" /></p>
<p>一方で、もうひとつの構造があります。「ユーザ ↔ AI ↔ サービス」——AIエージェントがMCPやCLIを通じてサービスをツールとして使う形です。</p>
<p><img src="/blog/2026/04/10/senior-engineer-watasan/user-ai-service.jpeg" alt="ユーザ ↔ AI ↔ サービス" /></p>
<p>たとえば今、Claude Codeのようなコーディングエージェントは、CLIやMCPを通じてさまざまなサービスを日常的に使っています。こういったエージェントにサービスを使ってもらうことも、「AIが関わるサービスの開発」です。エージェントが単一のタスクにとどまらず複数のサービスを組み合わせて作業を遂行するようになりつつある今、こちらの構造の重要性はより増していくと考えています。</p>
<p>前者と後者の構造では、不確実性のハンドリングの手段が変わります。前者ではAIの出力をサービス側で受け取れるため、前述の誤りを含む想定でのフロー構築など、出力側のハンドリングを行うことができました。これに対して後者では、AIの出力はユーザに向かい、サービス側からは見えません。出力を制御できないなら、入力の設計がハンドリングのほぼ全てになります。</p>
<p>だからこそ、ツールとしてのレスポンスやスキーマの設計が重要となります。エージェントに実際にツールを使わせ、どう振る舞うかを観測し、改善していく。SUZURI byGMOペパボでやってきたことと地続きですが、まだ誰も正解を知らない領域だと感じています。これまでやってきたことをより洗練させてやっていかねばと思っています。</p>
<p>ペパボでは、<a href="https://pepabo.com/news/press/202603091300/">カラーミーショップのAIコネクター</a>や<a href="https://pepabo.com/news/information/202603311100/">ムームードメインのMCPサーバー</a>など、既存サービスにおけるこの「ユーザ ↔ AI ↔ サービス」構造への取り組みがすでに始まっています。これらは既存サービスの延長線上にある取り組みで、もちろん重要です。しかしこれからは、この構造をネイティブとする新しいサービスに挑戦することで、既存サービスの延長線上では届かない価値を届けられると考えています。そこを、自分が先頭で実践していく。これがシニアエンジニアとしての自分の所信です。</p>
</content>
</entry>
<entry>
<title>シニアエンジニア所信表明 - 不変条件を見極めて、システムを組み替える</title>
<link rel="alternate" href="https://tech.pepabo.com/2026/04/10/senior-furudono/"/>
<id>https://tech.pepabo.com/2026/04/10/senior-furudono/</id>
<published>2026-04-10T00:00:00+09:00</published>
<updated>2026-04-23T00:53:59+00:00</updated>
<author>
<name>donokun</name>
</author>
<content type="html"><p>EC事業部の古殿(<a href="https://x.com/furudono2">@furudono2</a>)です。
ペパボでシニアエンジニアに昇格しました。この記事では、私がどのような関心を持ち、今後どのように取り組んでいくかを紹介します。</p>
<h2 id="これまで">これまで</h2>
<p>学生の頃はプログラミング言語の意味論を研究していました。どのようなプログラミング言語の定義をすると、どのような不変条件がプログラムに生まれ、プログラミングを頑強にするかを議論してきました。この頃の関心と今の仕事には共通するものがあると考えています。どちらも、あるシステムの上で期待される営みを見定め、それがうまく進むための制約を設計する仕事です。私はこの種の問題を解くことに楽しさと価値を感じています。</p>
<p>2023年に入社してからは、この関心に沿った仕事をしてきました。新規サービスの立ち上げ、サービス間で共通するID・決済機能の基盤設計、大規模メールリレーシステムの移設など、いずれも特定のレイヤに閉じず横断的に課題を捉える仕事です。議論の対象は技術の話からそれを用いたソフトウェアや、ソフトウェアを組み合わせて提供するサービスへと広がってきました。</p>
<h2 id="所信">所信</h2>
<p>EC事業部のシステムには、長年の積み重ねによる制約がいくつかあります。たとえばサービスを支える複数のロール(管理画面とAPI)の責務境界が歴史的に曖昧になっていたり、古い文字コードへの依存が広範に残っていたりします。こうした制約は、提供できる機能の幅や日々の開発速度に影響を与えています。</p>
<p>これらが今日まで解きほぐされずに残っているのには理由があると考えています。何が本当に守らなければならない制約なのかを見抜くことは難しく、見抜いた上でそれを実際のコードやデータに反映させるには膨大な労力が要るからです。過去に取り組んだ人がいなかったのではなく、取り組むコストが割に合いづらい性質の課題だったのだと考えています。</p>
<p>生成AIエージェントの登場によって、広範囲にわたるコードやデータの変更、あるいは依存関係や情報の欠落を洗い出す調査といった、かつて膨大な手作業を要した仕事を、現実的なコストで進められるようになってきたのではないかと考えています。もっとも、AIを使えば何でも解けるわけではありません。システムに何が本当に守らなければならない不変条件があるのかは、まだ人間が見抜く必要があるように感じています。そして、その不変条件を満たし続けるようにシステムの変更を組み立てていく仕事があってはじめて、AIの力を安全に借りられるようになります。制約が曖昧なまま大きな力を走らせても、壊れ方が速くなるだけです。</p>
<p>私はこの「不変条件を見極め、それを守りながらシステムを組み替える」仕事を進めて、機能と開発ワークフローの双方を改善します。</p>
<h2 id="ペパボのエンジニア職位制度">ペパボのエンジニア職位制度</h2>
<p>ペパボではシニアエンジニアを定めるにあたって「エンジニア職位制度」が運用されており、以下のようにホームページで紹介されています。</p>
<blockquote>
<p>半期ごとの評価制度と同時期に行われ、エンジニア自身の立候補と面談を経て、通過すると4等級以上の専門職に昇格します。シニア・プリンシパルエンジニア、プリンシパルエンジニア、シニアエンジニアの3つの職位があり、対象の職位のエンジニアは各サービスや会社全体の技術的な問題解決、技術力の底上げ、事業価値の創出にコミットする業務を行います。</p>
<p>— <a href="https://tech.pepabo.com/engineers/">ペパボのエンジニア</a></p>
</blockquote>
<p>どのような人がシニアなどの専門職にふさわしいかは、定性的に定められた基準の他に、過去にどのような振る舞いをした人が専門職として認められてきたかをもとに判断されます。今回私はシニアエンジニアとして立候補し、面談を経て昇格しました。</p>
<p>この記事が、ペパボの職位制度を知っていただくための一つの例として役立てば幸いです。</p>
</content>
</entry>
<entry>
<title>minneリワードを支えるマルチクラウドアーキテクチャ</title>
<link rel="alternate" href="https://tech.pepabo.com/2026/04/10/minne-mission-reward-architecture/"/>
<id>https://tech.pepabo.com/2026/04/10/minne-mission-reward-architecture/</id>
<published>2026-04-10T00:00:00+09:00</published>
<updated>2026-04-23T00:53:59+00:00</updated>
<author>
<name>kazu</name>
</author>
<content type="html"><h2 id="はじめに">はじめに</h2>
<p>minneでは、アプリでミッションを達成したり、広告を見ることでminneコインが無料で貯まる機能「minneリワード」をリリースしました。
minneリワードには昨年リリースされた作家・ブランドへの応援ミッションの他に、「アプリを起動する」「注目の特集をチェック」といったデイリーミッション、応援・購入回数に応じた累積ミッションが用意されています。ミッションを達成し動画広告を視聴すると、minneコインが付与される仕組みです。</p>
<blockquote class="twitter-tweet" data-media-max-width="560"><p lang="ja" dir="ltr">【予告】お買い物がちょっとお得になる「minneリワード」が3月に登場✨<br /><br />アプリでミッションを達成し、広告を見るとコインがもらえます🎁<br />貯めたコインは、ポイントに交換してお買い物に使えます!… <a href="https://t.co/xp955ifPQ9">pic.twitter.com/xp955ifPQ9</a></p>&mdash; minne byGMOペパボ(ミンネ) (@minnecom) <a href="https://twitter.com/minnecom/status/2024680460190617737?ref_src=twsrc%5Etfw">February 20, 2026</a></blockquote>
<script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
<p>本記事では、マルチクラウド構成でのゲーミフィケーション機能を実現するにあたっての設計判断と、実際に運用する上で直面した課題・解決策を紹介します。</p>
<ol id="markdown-toc">
<li><a href="#はじめに" id="markdown-toc-はじめに">はじめに</a></li>
<li><a href="#minneリワードの全体像" id="markdown-toc-minneリワードの全体像">minneリワードの全体像</a></li>
<li><a href="#クライアント起点の実績集計を採用しなかった理由" id="markdown-toc-クライアント起点の実績集計を採用しなかった理由">クライアント起点の実績集計を採用しなかった理由</a></li>
<li><a href="#なぜマルチクラウドになったのか" id="markdown-toc-なぜマルチクラウドになったのか">なぜマルチクラウドになったのか</a></li>
<li><a href="#設計判断のポイント" id="markdown-toc-設計判断のポイント">設計判断のポイント</a> <ol>
<li><a href="#1-firestoreのドキュメント構造ユーザー起点-vs-日付起点" id="markdown-toc-1-firestoreのドキュメント構造ユーザー起点-vs-日付起点">1. Firestoreのドキュメント構造:ユーザー起点 vs 日付起点</a></li>
<li><a href="#2-dynamodb-gsiシャーディングによるホットパーティション回避" id="markdown-toc-2-dynamodb-gsiシャーディングによるホットパーティション回避">2. DynamoDB GSIシャーディングによるホットパーティション回避</a></li>
<li><a href="#3-sqsを介した非同期書き込み" id="markdown-toc-3-sqsを介した非同期書き込み">3. SQSを介した非同期書き込み</a></li>
<li><a href="#4-2フェーズコイン付与による不正防止" id="markdown-toc-4-2フェーズコイン付与による不正防止">4. 2フェーズコイン付与による不正防止</a></li>
<li><a href="#5-graphqlにおけるインターフェース分離パターン" id="markdown-toc-5-graphqlにおけるインターフェース分離パターン">5. GraphQLにおけるインターフェース分離パターン</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="minneリワードの全体像">minneリワードの全体像</h2>
<p>minneリワードは、大きく二種類のミッションで構成されています。</p>
<ul>
<li><strong>デイリーミッション</strong>: 毎日リセットされ、「アプリを起動する」「注目の特集をチェック」といったタスクをこなすと達成されるミッション</li>
<li><strong>累積ミッション</strong>: 「作品を10回購入する」「応援スタンプを10回送る」のように、長期的な利用で達成されるミッション</li>
</ul>
<p>ミッション達成後にユーザーが動画広告を視聴すると、minneコインが付与されます。このコインはminneでのお買い物に利用できるminneポイントに変換することができます。</p>
<p>全体の処理は大きく4つのフェーズに分かれます。</p>
<ol>
<li><strong>ミッション進捗の記録</strong>: ユーザーの行動ログをGC側で非同期にFirestoreへ記録</li>
<li><strong>ミッション一覧の表示</strong>: GraphQLクエリでFirestoreから進捗を取得しクライアントに返却</li>
<li><strong>報酬の受け取り</strong>: 動画広告視聴後、AWS SQS経由でDynamoDBに達成の記録を作成</li>
<li><strong>コインの確定</strong>: バッチで広告配信元の視聴データと突合し、minneコインを付与</li>
</ol>
<p><img src="/blog/2026/04/10/minne-mission-reward-architecture/images/sequence.png" alt="minneリワードの処理フロー" /></p>
<h2 id="クライアント起点の実績集計を採用しなかった理由">クライアント起点の実績集計を採用しなかった理由</h2>
<p>当初、ミッションの進捗記録はクライアント(iOS/Android)起点で行う設計を検討していました。具体的には、ミッション達成条件を満たす行動が発生したタイミングで、クライアントからGraphQL APIを呼び出して実績をインクリメントする方式です。</p>
<p>しかし、この方式では<strong>ミッションを新たに追加するたびに、そのミッションの達成導線となる画面にAPIコールを仕込むクライアント側の改修が必要</strong>になります。例えば「特集から作品を見る」ミッションを追加するなら特集画面に、「新着作品を確認する」ミッションを追加するなら新着一覧画面に、それぞれAPI呼び出しのコードを追加してアプリをリリースしなければなりません。</p>
<p>minneリワードは今後もミッション種別を柔軟に追加・変更していきたい機能です。ミッションの追加のたびにクライアントの改修とアプリリリースが必要になる構成では、その機動性が大きく損なわれてしまいます。</p>
<p>この課題を踏まえて、<strong>実績集計をバックエンド側に閉じる</strong>方針に転換しました。それが次に説明するマルチクラウド構成の採用につながっています。</p>
<h2 id="なぜマルチクラウドになったのか">なぜマルチクラウドになったのか</h2>
<p>minneのメインアプリケーションはAWS上のRuby on Railsで動作していますが、ミッション報酬機能ではGoogle Cloud(以下、GC)のFirestoreやCloud Functionsも組み合わせたマルチクラウド構成を採用しました。</p>
<p>minneではユーザーの行動ログを <code>Railsアプリケーション → fluentd → fluentd(aggregator) → Cloud Pub/Sub</code> のパイプラインで収集しています。このCloud Pub/Subに対してBigQueryへの蓄積用とCloud Functions用の2つのサブスクリプションを設定し、Cloud Functionsでミッション進捗をFirestoreに書き込む構成にしたことで、<strong>メインアプリケーションへの変更をほぼゼロ</strong>にしながらデイリーミッションの進捗記録を実現しました。</p>
<div class="highlight"><pre class="highlight plaintext"><code>[ユーザー行動]
↓
Railsアプリケーション → fluentd → fluentd(aggregator) → Cloud Pub/Sub → BigQuery
↓ (subscription)
Cloud Functions
↓
Firestore
(デイリーミッション進捗)
</code></pre></div>
<p>この設計には2つの大きなメリットがあります。まず、既存のRailsアプリケーションにミッション進捗記録のためのコードを追加する必要がなく、新機能のリリースが既存機能の安定性に影響を与えるリスクを最小限に抑えられます。さらに重要なのは、<strong>新しいミッションを追加する際にiOS/Androidのクライアント側の改修がほとんど不要</strong>になる点です。ミッションの実績集計はバックエンド側で完結するため、ミッション種別の追加はサーバーサイドの設定変更で対応でき、クライアントアプリのリリースサイクルに縛られません。</p>
<h2 id="設計判断のポイント">設計判断のポイント</h2>
<h3 id="1-firestoreのドキュメント構造ユーザー起点-vs-日付起点">1. Firestoreのドキュメント構造:ユーザー起点 vs 日付起点</h3>
<p>デイリーミッションの進捗をFirestoreに格納する際、2つの構造を検討しました。</p>
<p><strong>候補A:ユーザー起点(採用)</strong></p>
<div class="highlight"><pre class="highlight plaintext"><code>missions/{user_id}/dailyProgress/{YYYYMMDD}
</code></pre></div>
<p><strong>候補B:日付起点(不採用)</strong></p>
<div class="highlight"><pre class="highlight plaintext"><code>dailyMissions/{date}/{user_id}
</code></pre></div>
<p>候補Bは日別の集計に便利ですが、<strong>同じ日付ドキュメント配下に全ユーザーの書き込みが集中</strong>します。Firestoreには<a href="https://firebase.google.com/docs/firestore/best-practices?hl=ja#index_exemptions">書き込み数の制限</a>があり、キャンペーン時などにDAUが跳ね上がるとボトルネックになり得ます。候補Aのユーザー起点であれば、各ユーザーのドキュメントへの書き込みが自然に分散されるため、こちらを採用しました。</p>
<h3 id="2-dynamodb-gsiシャーディングによるホットパーティション回避">2. DynamoDB GSIシャーディングによるホットパーティション回避</h3>
<p>ミッションの達成状態はDynamoDBに格納しています。DynamoDBではパーティションキーを指定して効率的にデータを取得するQueryが基本であり、パーティションキーの設計がパフォーマンスを左右します。DynamoDBの基礎的な設計の考え方については、以前のテックブログ記事「<a href="https://developers.gmo.jp/technology/77858/">DynamoDB設計で痛い目にあった話 – RDB脳から抜け出すための実践ガイド</a>」で紹介していますので、あわせてご覧ください。</p>
<p>今回、「特定日にミッションを達成したユーザー一覧」を取得するためのGSI(Global Secondary Index)で、ホットパーティション問題が発生し得ます。</p>
<p>例えば、GSIのパーティションキーを <code>acquired_at_date</code>(日付)にすると、全ユーザーの書き込みがその日の1パーティションに集中してしまいます。</p>
<p>これを回避するため、パーティションキーに<strong>シャードサフィックス</strong>を付与しました。</p>
<div class="highlight"><pre class="highlight plaintext"><code>パーティションキー: {YYYY-MM-DD}#{user_id % 10}
</code></pre></div>
<p><code>user_id % 10</code> で10個のシャードに分散させることで、1パーティションあたりの書き込み負荷を1/10に軽減しています。読み取り時は10シャード分をScatterGatherで取得する必要がありますが、日次バッチ処理でしか使わないため許容範囲です。</p>
<h3 id="3-sqsを介した非同期書き込み">3. SQSを介した非同期書き込み</h3>
<p>DynamoDBへの書き込みは、Railsアプリケーションから直接行うのではなく、<strong>SQSキューを経由してLambdaで処理</strong>する構成にしました。</p>
<div class="highlight"><pre class="highlight plaintext"><code>[Rails] → SQS → Lambda → DynamoDB
</code></pre></div>
<p>これは既存の応援スタンプ機能で実績のあるパターンを踏襲したものです。メリットは以下のとおりです。</p>
<ul>
<li>Railsのリクエスト処理とDynamoDBの書き込みを分離し、レイテンシのスパイクを防ぐ</li>
<li>DynamoDBのスロットリングが発生してもSQSがバッファとして機能し、リトライが自動で行われる</li>
<li>ミッション達成時のトラフィックスパイクからメインアプリケーションを保護する</li>
</ul>
<h3 id="4-2フェーズコイン付与による不正防止">4. 2フェーズコイン付与による不正防止</h3>
<p>コインの付与は、即時に仮付与→翌日に本付与という2フェーズで行っています。</p>
<div class="highlight"><pre class="highlight plaintext"><code>[ミッション達成 + 動画広告視聴]
↓
DynamoDB に PENDING 状態で記録(仮付与)
↓ (翌日 18:00 JST)
広告配信元から取得した広告視聴レポート(CSV)と突合
↓
一致 → コイン付与(本付与)
不一致 → 付与をスキップ
</code></pre></div>
<p>動画広告の視聴完了をクライアント側の報告だけで信用すると、不正にコインを取得される可能性があります。広告プラットフォーム側が独立に生成するCSVレポートと突合することで、広告が実際に視聴されたことを第三者データで検証しています。</p>
<p>この突合処理はAWS Step Functionsでオーケストレーションしており、CSV取得→突合→コイン付与という一連のバッチ処理を管理しています。</p>
<h3 id="5-graphqlにおけるインターフェース分離パターン">5. GraphQLにおけるインターフェース分離パターン</h3>
<p>ミッション情報のGraphQLスキーマでは、認証状態に応じて返すフィールドを変えるために<code>Interface + Type</code>の分離パターンを採用しました。</p>