every Tech Blog

株式会社エブリーのTech Blogです。

SQLとLLMを用いた食トレンド予測

はじめに

1ヶ月間株式会社エブリーでデータサイエンティストとしてインターンをしている中村です。

私が配属された「デリッシュリサーチ」チームでは、デリッシュキッチンの膨大な検索ログデータを抽出・加工して、メーカー・小売の意思決定を支援しています。

本インターンでは、アプリ内の検索データから未来の「食トレンドワード」の予測に挑戦しました。

開発背景

食品業界では新商品の企画から販売までに時間がかかるため、企画の段階で「販売時期のトレンド」を正確に予測することがビジネスの成否を大きく左右します。

一般的に、トレンドが本格化するまでには、感度の高い層の検索行動などに「先行指標」が現れます。
そこで私たちは、「デリッシュキッチン」の膨大な検索ログデータにこのトレンドの”予兆”が現れるのではないか、という仮説を立てました。

今回のインターンでは、この仮説に基づきデータドリブンに未来のトレンドを予測するという課題に挑みました。

トレンド予測のパイプライン

今回作成したコードは月に1度実行され、前月までの検索データの推移をもとにトレンド予測を行います。
大きな流れとして、「1. SQLによる候補の抽出」と「2. LLMによる絞り込み」という2つのステップで構成しました。

1. SQLによる絞り込み

まず、SQLクエリを用いて、検索データ全体からトレンドの兆候を示す可能性のあるワードを絞り込みます。
ここでの目的は、再現率(Recall)を重視し、ポテンシャルのある単語を可能な限り拾い上げることです。

当初、仮説ベースでクエリを設計しましたが期待したような出力は得られませんでした。
そこで、過去のトレンドワードのデータを分析し、ブーム発生前の共通パターンを特定する帰納的アプローチに切り替えました。

過去のトレンドワードの流行のきっかけと推移を調査し、トレンド候補として取得したい時期を設定し各ワードがその時期に結果に含まれるようクエリを設計しました。

分析の結果、これらのワードには以下のような2つの特徴が共通することが判明しました。

  • 検索数が少ない:流行前は世間的に認知が低いため検索数が一般的な料理ワードと比較して少ない傾向にありました。
  • 検索頻度スコアの最大を更新:流行の兆しが見られているタイミングでアプリ内でも検索頻度スコアが過去最高を更新していたことが判明しました。
    (注) 検索頻度スコア:全検索ワード1000回あたりの特定のワードの検索回数

過去トレンド例:せいろ

過去トレンドの例として、せいろのトレンド推移を紹介します。
せいろは2024年9月にレシピ本が出版されたことをきっかけにブームとなり、デリッシュキッチン内でも急上昇を見せています。

しかし、トレンド化する予兆が全くなかったわけではありません。
2023年6月以前はほとんど検索されていなかったものの、インフルエンサーの投稿などから注目が集まり2023年7月~2024年1月の多くの月で検索頻度が過去最高を更新しています。

このように多くの過去トレンドワードでは大流行する前に先述した2つの特徴を持つトレンド化の予兆を示す時期があることが判明し、十分クエリで絞り込み可能と考えました。

先述した2つの条件をクエリに落とし込み候補を約1500件まで絞り込みました。

次に、この結果を分析したところ「バレンタイン」や「秋刀魚」といった季節性要因で検索が増加したワードが多数含まれていました。
これらはトレンドと異なるため周期的なパターンを検出するロジックを作成し、これらを除外する処理を追加しました。
この処理によってデータは約900件にまで絞り込めました。

後述するLLMでの絞り込みではデータ数に比例したコストがかかるため、絞り込んだ全てのワードを使うことはできません。
そこで昨年からの検索数の増加量を基準に並び替えを行い上位100件を"トレンドワード候補"として使用しました。

2. LLMによる絞り込み

クエリによる絞り込みでは正解の単語を確実に取得することを重視しているため、中にはデリッシュキッチンのSNS経由など他の要因で検索が増加した単語が含まれています。
そこで、各候補ワードの定性的な評価を行うため、LLMを用いた分類ステップを導入しました。

LLMには、Web検索機能を用いて各単語の背景(定義、メディアでの扱われ方、SNSでの話題性など)を調査させた上で、以下の5つのトレンドタイプに分類するタスクを実行させます。
この中でfuture(high)に分類されたワードを、最終的に使用します。

  • past : 過去に流行したもの
  • ongoing : 現在流行しているもの
  • future(low) : 今後流行する可能性があるが、現時点では限定的
  • future(high) : 流行の兆しがあり、今後大きなインパクトが期待されるもの
  • stable : 一過性の流行ではなく、社会に定着しているもの

最後に出力用にデータの整形を行います。

データや分析を提供する目的は、企業の意思決定支援です。
そのためには、単に単語リストを提供するだけでは不十分であり、そのワードの定義や分類の根拠を説明する必要があります。

先ほどの分類ステップでLLMには分類結果と同時に、その判断に至った具体的な理由や背景情報をテキストで生成させています。
その説明を入力にLLMに要約を作成させ、表示用の説明文としました。

ここでは具体例を掲載することはできませんが、韓国ブームや健康志向といったマクロな社会潮流と一致する単語を複数抽出できており、本手法の有効性を確認できました。

技術的な工夫

非同期処理の活用

LLMによる分類ステップでは、100件の候補ワードを処理する必要がありました。
当初、APIリクエストを同期的に逐一実行していたため、1ワードあたり約3分、全体で約5時間を要し開発イテレーションの大きなボトルネックとなっていました。
これでは、プロンプトチューニングを行う上でも実際の実行でも問題となります。そこでPythonの非同期処理を用いて並列でリクエストを送信しました。

ただし、OpenAI APIにはレート制限が存在します。
短時間にリクエストが集中するとエラーが返されるため、リトライ処理の実装が不可欠です。
今回はtenacityライブラリを活用し、リクエスト失敗時に最大6回まで再試行するロジックを組み込み、処理の安定性を確保しました。

これらの対応により、全体の処理時間を大幅に短縮でき、プロンプトチューニングや本番実行を短時間で行えるようになりました。

@retry(wait=wait_random_exponential(min=1, max=60), stop=stop_after_attempt(6))
async def call_gpt(search_word: str, prompt_template: str, schema: dict,date_formatted: str,recipe_master_attention:str, model_name: str = "gpt-5-mini-2025-08-07") -> tuple:
    try:
        prompt = prompt_template.format(research_word=search_word,date_formatted=date_formatted,recipe_master_attention=recipe_master_attention)
        response = await client.responses.create(
            model=model_name,
            tools = [{
                "type": "web_search",
                "user_location":{
                    "type": "approximate",
                    "country": "JP",
                    "city": "Tokyo",
                    "region": "Tokyo"
                },
            }],
            input=[
                {"role": "system", "content": "あなたは、食のトレンドを専門とするリサーチャーです。"},
                {"role": "user", "content": prompt},
            ],
            text = schema
        )
        res_dict = json.loads(response.output_text)
        res_dict["search_word"] = search_word
        return res_dict, response.usage
    except Exception as e:
        print(f"❌ Failed to analyze word: {search_word}, Error: {e}")
        raise e

分類根拠の説明

OpenAI APIは構造化出力をサポートしており、指定したスキーマでレスポンスを受け取ることができます。
これを利用して、トレンドタイプに加えてその分類の根拠もテキスト形式で出力させています。

分類根拠を出力させることでLLMの推論の過程を理解することができ、プロンプトチューニングが効率化されるだけでなく、その内容を要約してクライアント向けの説明文を生成することも可能になりました。

今後の課題

今回の分析である程度期待した精度の出力を得ることに成功しましたが、予測精度と提供価値をさらに高めるために、2つの改善点が考えられます。

LLMは検索データを考慮していない

現状、LLMによる分類のステップでは、プロンプトにアプリ内の検索数の推移を含めていません。
LLMに検索増という事実だけでなく生のデータを与えることで、検索増の要因の考察の精度が上昇することが期待されます。

過去の予測を考慮した出力

クライアントである企業にとっての価値は、「まだ見ぬトレンドの種」をいち早く知ることです。
その点で、過去に提示した単語が数ヶ月後に再び表示されると、「新しい発見がない」という印象を与えかねません。
現在のクエリでは、一度候補に入ると3ヶ月間は必ず"トレンド候補"となります。この期間が適切であるかは考慮する必要があります。
対策として、一度予測として提示した単語をフィルタリングするといった出力制御ロジックを組み込むことで、常に新鮮で多様な「未来のヒント」を提供できるようになると考えています。

まとめ

アプリ内の検索データをもとに、SQLを用いた定量的な候補抽出とLLMを用いた定性的な評価によって食トレンドを予測する機能を実装しました。
今回の実装は、LLMのweb検索機能を使用しているため、過去データでの性能検証ができません。
現在の予測が正しいかは数ヶ月後になってみないとわかりません。
予測には海外で流行している料理などもあり、今後日本で話題となることを期待しています。

今回のインターンでは、丁寧なコードレビューや毎日のフィードバックを元に、開発を改善しひとつの機能を実装することができました。
膨大なデータから価値を創造した体験を経て、データサイエンティストとして働く上での解像度が劇的に高まりました。
特に整備されたデータ基盤のもと試行錯誤を繰り返したことで、技術的に成長し、普段の勉強や研究では得られないような業務上の知識を多く得られました。

SwiftUIのBinding型の裏側 ~ PropertyWrapperとDynamicMemberLookup + KeyPath ~

はじめに

こんにちは。デリッシュキッチン開発部でiOSエンジニアをしている谷口恭一です。

デリッシュキッチンでは新規画面のUI実装は主にSwiftUIを使用していて、@State、@Publishedなどを使って状態管理の仕組みを学びながら日々実践しています。 SwiftUIの状態管理に関連する言語機能は便利な機能ですが、使い方を誤ってコンパイルエラーになったときに全く意味がわからないというような状況になることがあります。 そこで、これらの言語仕様を調査してみようと考えました。特に@Bindingがどのように動作しているのかを調査したので、それを解説します。

目次

  1. Bindingとは
  2. PropertyWrapper
  3. DynamicMemberLookup + KeyPath
  4. Bindingの詳細な動作
  5. まとめ

Bindingとは

まずSwiftUIでよく見るBindingについて簡単に説明します。Appleのドキュメントには以下のような例があります。

struct PlayButton: View { 
    @Binding var isPlaying: Bool 
    
    var body: some View { 
        Button(isPlaying ? "Pause" : "Play") { 
            isPlaying.toggle() 
        } 
    } 
}

struct PlayerView: View { 
    var episode: Episode 
    @State private var isPlaying: Bool = false 
    
    var body: some View { 
        VStack { 
            Text(episode.title) 
                .foregroundStyle(isPlaying ? .primary : .secondary)
            PlayButton(isPlaying: $isPlaying) // Pass a binding. 
        } 
    } 
}

/// 以下structとPreviewは自分で定義

struct Episode {
    let title: String
}

#Preview {
    PlayerView(episode: .init(title: "エピソード1"))
}

https://developer.apple.com/documentation/swiftui/binding

このように親ViewであるPlayerViewでは@StateでisPlayingを定義して、この値の状態の変更によって画面を再描画できるようにしています。 子ViewであるPlayButtonではisPlayingを@Bindingで定義することにより、子Viewでの値の変更でも親Viewが更新されるようにしています。

isPlaying = false isPlaying = true

一見すると、@Stateと同様、@Bindingを付与した変数も変更時にUI更新が行われるような気がします。つまり、@Bindingとは、@StateのようなSwiftUIのView再描画用機能の1つであると思われます。 しかし、@Bindingはそのような機能を提供していません。最終的に、その理由を理解することをゴールとして解説していきます。

AppleのドキュメントによるとBindingは以下のように定義されています。

@frozen @propertyWrapper @dynamicMemberLookup 
struct Binding<Value>

ここから読み取れることとして、

  • Binding型は、ある型をラップするための型であるということ
  • frozen: 構造体が将来変更されないということ
  • propertyWrapper、dynamicMemberLookupという機能が付与されているということ

実際に実装を確認すると以下のようになっています。

@frozen @propertyWrapper @dynamicMemberLookup public struct Binding<Value> {
    public var wrappedValue: Value { get nonmutating set }
    
    public var projectedValue: Binding<Value> { get }
    
    public init(projectedValue: Binding<Value>)
    
    public subscript<Subject>(dynamicMember keyPath: WritableKeyPath<Value, Subject>) -> Binding<Subject> { get }
}

まずはこれらにどのような意味があり、背景の言語仕様がどのようなものであるかについて1つ1つ解説していきます。

PropertyWrapper

まず、Binding型に付与されているものとして@propertyWrapperがあります。 swift.orgのドキュメントによるとPropertyWrapperとは、ある型のラッパーを作ったときに、そのプロパティ自体の定義と、値の保存方法の管理を分離する機能だと示されています。

A property wrapper adds a layer of separation between code that manages how a property is stored and the code that defines a property.

https://docs.swift.org/swift-book/documentation/the-swift-programming-language/properties/

つまり、以下の例の場合、Bool型というのがプロパティ自体の定義ですが、これに加えて値の保存方法を指定された方法で管理できますよ、という仕組みのことです。

@Binding var isPlaying: Bool 

PropertyWrapperはwrappedValueという計算プロパティを実装する必要があります。この計算プロパティ内で実装者は値の保存方法、更新時の処理などを追加することができます。 また、任意でprojectedValueという計算プロパティを実装すると、この値には$という記号で簡易アクセスする機能が付与されます。

以下の例では、@Wrapper var a: Int = 1と定義すると、$aと書くとa.projectedValueと同等の意味になり、projectedValue: 1というStringを返すというような動作になります。

@propertyWrapper struct Wrapper<Value> {     
    private var value: Value          
    
    init(wrappedValue: Value) {         
        self.value = wrappedValue     
    }          
    
    var wrappedValue: Value {         
        get { value }         
        set { value = newValue }     
    }          
    
    var projectedValue: String {         
        return "projectedValue: \(value)"     
    } 
}

$の簡易アクセサはSwiftUIでよく見る、

PlayButton(isPlaying: $isPlaying)

このような書き方の正体です。

また、PropertyWrapperには、アンダースコア(_)プレフィックスを使うことでWrapper自体にアクセスできる機能もあります。 例えば、@Wrapper var a: Int = 1と定義した場合:

  • awrappedValueInt型の値そのもの)にアクセス
  • $aprojectedValue(この例ではString)にアクセス
  • _a → Wrapper自体(Wrapper<Int>)にアクセス

SwiftUIでは通常、structで実装されたViewはイニシャライザを省略できるため、_を使う機会は少ないですが、カスタムイニシャライザを実装する際などに使用します。

では、@Bindingというプロパティラッパーが提供する「保存方法」とは何でしょうか? Appleのドキュメントによると、

A property wrapper type that can read and write a value owned by a source of truth.

つまり、「A Source of Truthな値を読み書きできる」という保存方法です。

「A Source of Truth」はエンジニアならお馴染みの「Single Source of Truth」と同じ概念です。 「Single Source of Truth(SSoT)」とは、単一の信頼できる情報源という意味です。 最初の例で言うと、isPlayingという情報は2種類あります

  • PlayerViewのisPlaying
  • PlayButtonのisPlaying

このとき、信頼できる情報源はもちろん親ViewであるPlayerViewのisPlayingです。(親ViewのisPlayingがどのように単一の信頼できる情報源を提供しているかについては後述します。)

PlayButtonで管理しているisPlayingは常に必ず親ViewのisPlayingと同じでなければなりません。そうでないと、同じ画面に2種類の状態が混在して、どちらが正しく再生状態を表しているかわからなくなってしまいます。

そこで、親ViewのPlayerViewの情報源を単一の信頼できる情報源として、それを参照し、いつでも子ViewのisPlayingが親Viewのものと同じ状態であり続ける機能が欲しくなると思います。

しかし、SwiftUIのViewはstructであり、その中で定義されたプロパティは値型です。 よって、子Viewに渡されるisPlayingの実態は、初期化時に作成された親ViewのisPlayingのコピーであり、親Viewのプロパティとは別のメモリ領域に格納されます。 したがって、通常の方法では親Viewのプロパティを直接参照したり値を更新することはできません。

そこでBindingという機能を付与することによって、このような値を読み書きできるようにしています。

Bniding型が読み書きしている親ViewのisPlayingは @Stateを付けることによって、「単一の信頼できる情報源」を提供しています。

@StateのPropertyWrapperの「保存方法の管理」とは何かをAppleのドキュメントから確認すると、

A property wrapper type that can read and write a value managed by SwiftUI. Use state as the single source of truth for a given value type that you store in a view hierarchy. SwiftUI manages the property’s storage. When the value changes, SwiftUI updates the parts of the view hierarchy that depend on the value.

https://developer.apple.com/documentation/swiftui/state

つまり、

  • View階層内でSwiftUIが管理している、Single Source of Truthな値を読み書きできる
  • 値が変更されるとSwiftUIはその値に依存するView階層の箇所を更新する

という保存方法であるということがわかります。

上図のように、View内で@Stateで宣言された値は、単にスタックメモリに保存されるわけではなく、SwiftUIが提供する特別な保存領域で管理されるというわけです。@Stateは読み書きできることに加えて、値の変更時に依存するViewを再計算するという機能も備わっています。 そして、そのような値を子Viewから読み書きするためにBindingを提供する必要があったという背景です。

以下にState型の実装を示しました。ここから、State型のpropertyWrapperのprojectedValueはBinding<Value>であることがわかります。

@frozen @propertyWrapper public struct State<Value> : DynamicProperty {
    public var wrappedValue: Value { get nonmutating set }
    
    public var projectedValue: Binding<Value> { get }
}

よって、

@State private var isPlaying: Bool = false 
...
PlayButton(isPlaying: $isPlaying)

というように@Stateが付与された値(State<Bool>)に$をつけてアクセスしたときは、Binding<Bool>が返されるという挙動になり、PlayButtonの@Binding var isPlaying: Boolで定義されたBinding<Bool>型に値を渡せることに納得がいくかと思います。

このように、State型、Binding型におけるPropertyWrapperは、SwiftUIの階層的にViewを構築していくという設計思想を実現するための最重要機能であることがわかると思います。

DynamicMemberLookup + KeyPath

次に、Bindingの定義に付与されていた@dynamicMemberLookupについて解説します。swift.orgのドキュメントによると、

Apply this attribute to a class, structure, enumeration, or protocol to enable members to be looked up by name at runtime. The type must implement a subscript(dynamicMember:) subscript.

つまり、DynamicMemberLookupとは実行時にメンバーを名前で検索できるようにする機能です。メンバーとは、その型に紐づくプロパティやメソッドなどです。

https://docs.swift.org/swift-book/documentation/the-swift-programming-language/attributes/

@dynamicMemberLookup struct DynamicStruct {
    subscript(dynamicMember member: String) -> String {
        return "\(member) was accessed"
    }
}

let obj = DynamicStruct()
print(obj.someProperty) // "someProperty was accessed"
print(obj.anyName) // "anyName was accessed"

上の例では、objは全くメンバーを持っていませんが、somePropertyanyNameにアクセスすることができます。アクセス時の動作はsubscriptで実装することができます。アクセスできるメンバーは実行時に動的に決定されるので、型安全性は失われます。

検索には任意の型を用いることができ、Bindingではこの型としてKeyPathという型を使用しています。KeyPathとはプロパティ自体を変数として使用できる機能です。以下の例のように、\.titleというように書くと、メンバー自体を変数にできます。

struct Recipe {
    var title: String
}

var recipe1 = Recipe(title: "レシピ1")

// 読み取り
let keyPath: KeyPath<Recipe, String> = \.title

// 書き込み
let writableKeyPath: WritableKeyPath<Recipe, String> = \.title
recipe1[keyPath: writableKeyPath] = "ハンバーグ"
print(recipe1.title) // ハンバーグ

KeyPathをダイナミックメンバーの検索時の型として用いる例は以下のようになります

@dynamicMemberLookup struct Wrapper<T> {
    let value: T

    subscript<U>(dynamicMember keyPath: KeyPath<T, U>) -> U {
        return value[keyPath: keyPath]
    }
}

let wrapper = Wrapper(value: Recipe(title: "レシピ1"))

print(wrapper.title) // "レシピ1"

Wrapperという型はvalueというプロパティしか持っていませんが、titleというプロパティに直接アクセスすることができています。また、KeyPathはアクセスするときに存在するプロパティかどうかをコンパイル時にチェックするので型安全にdynamicMemberLookupを使用することができます。 つまり、最初の例のようにDynamicMemberとしてStringを受け取っていたときと違って、somePropertyなどの存在しないメンバーにアクセスしようとするとコンパイルエラーになります。

ここで、Bindingの定義を再度見てみます。

@frozen @propertyWrapper @dynamicMemberLookup public struct Binding<Value> {
    public var wrappedValue: Value { get nonmutating set }
    
    public var projectedValue: Binding<Value> { get }
    
    public init(projectedValue: Binding<Value>)
    
    public subscript<Subject>(dynamicMember keyPath: WritableKeyPath<Value, Subject>) -> Binding<Subject> { get }
}

ここのsubscript()を見ると、Bindingは保持するValue型の任意のメンバー(Subject型)に直接アクセスすることができて、アクセスした結果、アクセスしたメンバーのBinding(Binding<Subject>)が取得できるということを表しています。

Bindingの詳細な動作

以上の説明によって、Bindingがどんな言語仕様を用いているかがわかりました。ここで、最初のPlayerの例に戻りたいと思います。 以下のコード例では、PlayerViewにおいて、最初の例とデータの持ち方を少し変更しました。 具体的には、isPlayingを直接定義するのではなく、PlayStateという構造体を使って再生状態を管理するようにしました。

struct PlayButton: View {
    @Binding var isPlaying: Bool
    
    // ここでは、前述のPropertyWrapperの`_`プレフィックスを使って、Wrapper自体(`Binding<Bool>`)を直接代入しています。
    //通常、SwiftUIが自動的にinitを生成するため、このコードは省略可能です。
    init(isPlaying: Binding<Bool>) {
        self._isPlaying = isPlaying
    }
    
    var body: some View {
        Button(isPlaying ? "Pause" : "Play") {
            isPlaying.toggle()
        }
    }
}

struct PlayerView: View {
    var episode: Episode
    @State private var playState: PlayState = .init(isPlaying: false)
    
    var body: some View {
        VStack {
            Text(episode.title)
                .foregroundStyle(playState.isPlaying ? .primary : .secondary)
            PlayButton(isPlaying: $playState.isPlaying) // Pass a binding.
        }
    }
}

struct Episode {
    let title: String
}

struct PlayState {
    var isPlaying: Bool
}

#Preview {
    PlayerView(episode: .init(title: "エピソード1"))
}

ここで

PlayButton(isPlaying: $playState.isPlaying)

というようにアクセスしている部分は

PlayButton(isPlaying: playState.projectedValue.isPlaying)

と解釈できます。 playState.projectedValueBinding<PlayState>型です。Binding型には当然isPlayingというメンバーは存在しません。しかし、Binding<Value>のdynamicMemberLookupのsubscriptが返却する型はBinding<Subject>であることから、Binding<PlayState>.isPlayingBinding<Bool>になります。 よって、子ViewのPlayButtonのイニシャライザに渡すことができます。

次にPlayButtonの

Button(isPlaying ? "Pause" : "Play") { 
    isPlaying.toggle() 
} 

この部分で、isPlayingの値の変更は、Bindingが参照する単一の信頼できる情報源である親ViewのisPlayingを変更します。この変数は@Stateで定義されているため、SwiftUIが管理する保存領域が変更され、この値に依存しているUIが更新されるという仕組みになっています。

今回の例では、@Binding自体はこのような特殊な値の保存管理方法をする@Stateな変数を読み書きできるという能力を持っていることがわかります。

逆に@Binding自体はSwiftUIのViewに作用してViewを更新したりする能力は持っていないことに注意する必要があります。@Stateと違って、@BindingはSwiftUIとは一切関係ない機能であると捉えることもできると思います。

まとめ

  • SwiftUIのBinding型はPropertyWrapper、DynamicMemberLookup + KeyPathという言語機能が使われている。
  • PropertyWrapperは値の定義と値の「保存方法の管理」を分離する機能である。
  • DynamicMemberLookupは動的にメンバーにアクセスできる仕組みで、KeyPathをダイナミックメンバーに用いると、型安全にメンバーにアクセスできる。
  • Binding型は「Single Source of Truthな値を読み書きできる」機能が付与されていて、SwiftUIの@Stateなどで定義された値を読み書きするために主に使われている。しかしBinding自体は、SwiftUIのViewに作用する機能ではない。

SwiftUIは非常に記述量が少なく、簡単に階層的な状態管理を実装することができますが、裏側の仕組みとしてものすごく複雑で面白い言語仕様が使われていることに気がつきました。Swiftのコミュニティと言語仕様に感謝ですね。

仕様駆動開発ツールcc-sddを実務で使ってみた

はじめに

こんにちは、開発1部でソフトウェアエンジニアをしている新谷です。 今回は、AIエージェントで仕様駆動開発を実現する国産ツール「cc-sdd」を実務で約1ヶ月使用してみたので紹介します。

cc-sddとは

cc-sddは、仕様駆動開発をAIエージェントで実現する国産ツールです。

github.com

2025年10月10日時点では、以下のAIツールに対応しています。

  • Claude Code
  • Cursor IDE
  • Gemini CLI
  • Qwen Code

本記事の事例では、Claude Codeを使用しています。

cc-sddは、3つのファイルを順次承認制で作成していく仕組みになっています。

  • requirements.md: 要件定義フェーズで使用するファイル。受け入れ基準がEARS記法で書かれます
  • design.md: 技術設計書として使用するファイル。実装の詳細な設計を記述します
  • task.md: 実装可能な単位にタスクを分解したファイルです

各ファイルの具体的な内容については、後述の実務事例で紹介します。

仕様駆動開発を実務で導入したいモチベーション

端的に言うと、設計書を書けばシステムが完成するという開発フローが、チームでの開発生産性を大きく向上させると考えているからです。

見込める開発効率

具体的には、以下のような開発効率の向上が見込めると考えています。

  • コードを書くときにはAIとやり取りしなくていいので、その間別タスクが可能
  • design.mdを作るときにチームで設計が問題ないか認識を合わせられる
  • 事前に設計などを共有できているため、コードレビューの負担が軽減される

課題

一方で、以下のような課題もあると考えています。

  • 結局実装しないと分からない部分もあるのではないか
  • design.mdをどの粒度まで作り込むべきか判断が難しい

実務で取り組んだ事例

実際に2つのAPI開発でcc-sddを使用してみました。

前提条件

  • チーム全員が使っているわけではなく、私だけが使用
  • バックエンドのAPI開発での適用
  • まだ1ヶ月程度の使用期間
  • 既存サービスに新しいAPIを追加する実装
  • 最低限のCLAUDE.mdは作成済み
  • 事前にsteeringファイルは作成済み

事例1: アプリ内のプッシュ通知設定API

要件の概要

  • アプリにプッシュ通知のon/offボタンを作成
  • 現在の状態を取得するAPIと、状態を変更するAPIを実装

作成されたファイルの例

requirements.md(一部)

### Requirement 1: 通知設定の永続化
**User Story:** アプリのユーザーとして、プッシュ通知の受信設定を保存し、その設定が永続的に維持されることを期待する

#### Acceptance Criteria

1. WHEN ユーザーが初めてアプリを利用開始する THEN システムはプッシュ通知設定をデフォルトで「有効」として初期化 SHALL
2. IF データベースにユーザーの通知設定が存在しない THEN システムはデフォルト値として「有効」を返す SHALL
3. WHEN ユーザーが通知設定を変更する THEN システムはその設定をデータベースに即座に永続化 SHALL
4. IF データベース保存に失敗した THEN システムはエラーレスポンスを返し、設定は変更されない SHALL
5. WHERE 同一ユーザーが複数デバイスを使用している THE SYSTEM SHALL 全デバイスで統一された通知設定を適用

design.md(一部)

## コンポーネントと インターフェース

### バックエンドサービス & メソッドシグネチャ

#### NotificationSettingService

type NotificationSettingService struct {
    repo domainRepository.UserNotificationSettingRepository
}

// GetUserNotificationSetting ユーザーの通知設定を取得
func (s *NotificationSettingService) GetUserNotificationSetting(ctx context.Context, userID int64) (*domainModel.UserNotificationSetting, error)

// UpdateUserNotificationSetting ユーザーの通知設定を更新
func (s *NotificationSettingService) UpdateUserNotificationSetting(ctx context.Context, userID int64, enabled bool) (*domainModel.UserNotificationSetting, error)

task.md(一部)

## パート1: 通知設定API機能

### フェーズ1: データモデルとマイグレーション

- [x] 1. データベーススキーマとマイグレーションの作成
  - db/migrations/配下に新しいマイグレーションファイルを作成
  - user_notification_settingsテーブルのCREATE文を実装
  - インデックス設計(user_id, enabled)を含める
  - ロールバック用のDROP文も実装
  - _要件: REQ-1, REQ-6_

cc-sddの適用結果

  • design.mdは、チームへの共有も含めて3日ほどかけて作成・修正しました
  • task.mdは少し修正した程度で済みました
  • 実装後にコードの大きな修正は不要でした

事例2: ミッション達成計算API

要件の概要

  • ゲームでよくあるアクションによって実績が解除される機能
  • 事前に決めたミッションをユーザーが達成しているかどうかを判定
  • ミッションは複数あり、入れ替わりや制限期間はなし

作成されたファイルの例

requirements.md(一部)

### Requirement 1: ミッション管理機能
**Objective:** 管理者として、ミッションの定義と管理を柔軟に行いたい、将来的な拡張が容易にできるようにするため

#### Acceptance Criteria

1. WHEN システムが起動される THEN ミッションサービス SHALL サーバー設定から全てのミッション定義を読み込む
2. IF 新しいミッション定義がサーバー設定に追加される THEN ミッションサービス SHALL アプリケーション再起動なしに新ミッションを有効化する
3. WHERE ミッション定義が存在する THE ミッションサービス SHALL 以下の情報を管理する:ミッションID、名称、達成条件、表示順序
4. WHEN ミッション定義が不正な形式で設定される THEN ミッションサービス SHALL エラーログを出力し、該当ミッションを無効化する

design.md(一部)

### Domain Layer

#### Mission

**Responsibility & Boundaries**
- **Primary Responsibility**: ミッション定義とその達成条件を管理
- **Domain Boundary**: ミッションドメイン
- **Data Ownership**: ミッションのメタデータと達成条件
- **Transaction Boundary**: 読み取り専用(設定ファイルから)

**Dependencies**
- **Inbound**: MissionService
- **Outbound**: なし
- **External**: なし

**Contract Definition**

type Mission struct {
    ID               string          `json:"id"`
    Title            string          `json:"title"`
    RequiredCount    int             `json:"required_count"`
}

task.md(一部)

## ミッション機能Phase1 実装タスク

- [x] 1. データベースとドメインモデルの基盤構築
- [x] 1.1 ユーザーミッション達成記録テーブルの作成
  - user_mission_completionsテーブルのマイグレーションファイル作成
  - user_id, mission_id を複合主キーとして定義
  - created_atインデックスの追加
  - ロールバック用のダウンマイグレーション作成
  - _Requirements: 1.3, 7.1, 8.1_

cc-sddの適用結果

  • design.mdは5日ほどかけて作成・修正しました
  • task.mdは少し修正した程度でした
  • 実装に関しては、コードの責務や書き方などが不適切で大きく修正が必要でした

うまくいかなかった原因

  • ロジックが複雑だったにもかかわらず、design.mdに詳細を記載できていなかった
  • CLAUDE.mdの記載が不足していた
    • ロジックをどこに配置すべきか、責務の定義が不明確
    • テストの書き方の指針が不足
  • task.mdのレビューが不十分だった

design.md作成について

今回初めてdesign.mdを作成しましたが、思った以上に時間がかかりました。 原因としては、以下のようなものがあると考えています。

  • 自分の設計力不足
  • どの粒度で記載すべきかの判断に迷った
  • 別タスクとの並列作業によるスイッチングコスト
  • チームへの設計共有時に発生するレビュー時間

まとめ

cc-sddを約1ヶ月実務で使用してみて、以下のことがわかりました。

  • 簡単な新規のAPI追加実装であれば、CLAUDE.mdを適切に書いておくことで修正不要で実装できる可能性がある
  • 複雑なロジックを持つAPIや既存APIの改修については、まだチューニングが必要
  • design.mdにどこまでの粒度で記載すべきか、まだ明確な基準が定まっていない
  • design.mdの作成には3〜5日かかっており、設計スキルやツールへの習熟が必要

仕様駆動開発は、設計とレビューだけで実装が完了する世界を作れる可能性があると考えています。 今後も試行錯誤を重ねながら、開発速度を爆速にできるよう取り組んでいきたいと思います。

Google Ad Manager REST API と BigQuery の連携によるレポート自動化システムの構築

はじめに

こんにちは、トモニテで開発を担当している吉田です。 デジタル広告の運用において、広告パフォーマンスの分析とレポート作成は重要な業務の一つです。しかし、弊社では手動でレポートを作成しており、営業活動に集中する時間を削ってしまう課題がありました。

本記事では、Google Ad Manager(GAM)の REST API と BigQuery を連携させ、レポート作成を自動化するシステムの構築事例について、紹介します。

背景:セールスレポート作成の課題

ビジネス課題

セールスチームが Google Ad Manager の広告レポートを手動で作成する際、以下の課題に直面していました。

  • レポート作成工数の嵩み: 現状 30 分〜1 時間程度の工数が発生
  • データ抽出の複雑さ: GAM から直接データを取得する手間
  • 営業活動時間の減少: レポート作成に時間を取られ、営業活動に集中できない

期待される成果

レポート作成の自動化により、以下の成果を期待しました。

  • 工数削減: レポート作成時間の短縮
  • 営業活動の強化: レポート作成時間を営業活動に充て、売上貢献の向上
  • データ活用の効率化: BigQuery での SQL による柔軟なデータ抽出

技術選定:REST API の採用

既存システムの課題

社内の別サービスでは、Google Ad Manager の SOAP API を使用していました。しかし、以下の理由から REST API(現在 Beta 版)で実装することを決定しました。

項目 SOAP API REST API
実装の複雑さ XML ベースで複雑 JSON ベースでシンプル
エラーハンドリング 複雑な XML パースが必要 標準的な HTTP ステータスコード
デバッグの容易さ XML ログの可読性が低い JSON ログで直感的
メンテナンス性 古い技術スタック モダンな技術スタック
ドキュメント 限定的 豊富で分かりやすい

REST API の選択理由

  1. 開発効率の向上: JSON ベースのシンプルな実装
  2. 保守性の向上: モダンな技術スタックによる将来性
  3. エラー処理の簡素化: 標準的な HTTP レスポンスの活用
  4. チーム開発の効率化: より直感的な API 設計

注意: Google Ad Manager REST API は現在 Beta 版のため、本番環境での使用には注意が必要です。API の仕様変更や制限事項について、公式ドキュメントを定期的に確認することをお勧めします。

システムアーキテクチャ

全体構成

  1. Cloud Run: メイン処理コンテナ
    • GAM REST API を呼び出してレポートデータを取得
    • 取得したデータを BigQuery に格納
  2. GAM REST API: 広告データの提供
  3. BigQuery: データの保存と分析

データフロー

  1. 実行開始: Cloud Run が HTTP リクエストまたはイベントで実行
  2. 日付抽出: リクエストから対象日付を取得
  3. レポート生成: GAM API を使用してレポートデータを取得
  4. BigQuery 挿入: 取得したデータを BigQuery に保存

GAM REST API の実装詳細

API クライアントの初期化

from google.ads import admanager_v1

# GAM クライアントの初期化
client = admanager_v1.ReportServiceClient()

レポート定義の作成

GAM REST API では、レポートの構造を詳細に定義する必要があります。

def create_report_definition(target_date: date, dimensions: list, metrics: list) -> admanager_v1.Report:
    """GAMレポートの定義を作成"""
    report = admanager_v1.Report()

    # ディメンションとメトリクスの設定
    report.report_definition.dimensions = dimensions
    report.report_definition.metrics = metrics
    report.report_definition.report_type = admanager_v1.types.Report.ReportType.HISTORICAL

    # フィルター条件の設定(特定のプレフィックスから始まるアドユニットのみを対象にする)
    report.report_definition.filters = [
        admanager_v1.types.Report.Filter(
            field_filter=admanager_v1.types.Report.Filter.FieldFilter(
                field=admanager_v1.types.Report.Field(
                    dimension=admanager_v1.types.Report.Dimension.AD_UNIT_NAME
                ),
                operation=admanager_v1.types.Report.Filter.Operation.MATCHES,
                values=[
                    admanager_v1.types.Report.Value(string_value="PREFIX_.*")
                ]
            )
        )
    ]

    # 日付範囲の設定
    report.report_definition.date_range.fixed = admanager_v1.types.Report.DateRange.FixedDateRange(
        start_date=date_pb2.Date(
            year=target_date.year,
            month=target_date.month,
            day=target_date.day
        ),
        end_date=date_pb2.Date(
            year=target_date.year,
            month=target_date.month,
            day=target_date.day
        )
    )

    return report

レポートの実行とデータ取得

def create_and_run_report(client: admanager_v1.ReportServiceClient, report: admanager_v1.Report) -> str:
    """GAMレポートを作成して実行"""
    # レポート作成
    request = admanager_v1.CreateReportRequest(
        parent=f"networks/{NETWORK_ID}",
        report=report,
    )
    create_response = client.create_report(request=request)
    report_id = create_response.report_id

    # レポート実行
    run_request = admanager_v1.RunReportRequest(
        name=f"networks/{NETWORK_ID}/reports/{report_id}"
    )
    operation = client.run_report(request=run_request)
    run_result = operation.result()

    return run_result.report_result

データの抽出と変換

GAM API から取得したデータを Pandas DataFrame に変換する処理です。

def extract_dimension_value(dim_value) -> any:
    """ディメンション値を抽出"""
    if dim_value.string_value:
        return dim_value.string_value
    elif dim_value.int_value:
        return dim_value.int_value
    elif dim_value.double_value:
        return dim_value.double_value
    # その他の型も同様に処理
    else:
        return None

def extract_metric_value(primary_value) -> any:
    """メトリクス値を抽出"""
    if primary_value.int_value:
        return int(primary_value.int_value)
    elif primary_value.double_value:
        return primary_value.double_value
    else:
        return None

def fetch_report_data(client: admanager_v1.ReportServiceClient, report_result_name: str, column_names: list[str]) -> pd.DataFrame:
    """レポートデータを取得してDataFrameに変換"""
    fetch_request = admanager_v1.FetchReportResultRowsRequest(
        name=report_result_name
    )

    rows_response = client.fetch_report_result_rows(request=fetch_request)

    rows_list = []
    for row in rows_response:
        row_data = []

        # ディメンション値を取得
        for dim_value in row.dimension_values:
            row_data.append(extract_dimension_value(dim_value))

        # メトリクス値を取得
        for metric_group in row.metric_value_groups:
            for primary_value in metric_group.primary_values:
                row_data.append(extract_metric_value(primary_value))

        rows_list.append(row_data)

    df = pd.DataFrame(rows_list, columns=column_names)
    return df

BigQuery との連携設計

スキーマ設計の考え方

BigQuery へのデータ保存では、以下の設計思想を採用しました。

  1. 日付別テーブル分割: パフォーマンスとコスト最適化
  2. 型安全性の確保: 適切なデータ型の設定
  3. 効率的なクエリ: 分析に適したスキーマ設計

スキーマ定義

以下のスキーマ定義は一例です。実際のプロジェクトでは、ビジネス要件や分析ニーズに応じて適切なカラム名とデータ型を設定してください。

# ディメンションのスキーマ
DIMENSION_SCHEMA = [
    bigquery.SchemaField("date", "INTEGER"),
    bigquery.SchemaField("advertiser_name", "STRING"),
    bigquery.SchemaField("advertiser_id", "INTEGER"),
    bigquery.SchemaField("order_name", "STRING"),
    bigquery.SchemaField("order_id", "INTEGER"),
    bigquery.SchemaField("line_item_type", "STRING"),
    bigquery.SchemaField("line_item_name", "STRING"),
    bigquery.SchemaField("line_item_id", "INTEGER"),
    bigquery.SchemaField("ad_unit", "STRING"),
    bigquery.SchemaField("ad_unit_id", "INTEGER"),
    bigquery.SchemaField("demand_channel_name", "STRING"),
    bigquery.SchemaField("creative_name", "STRING"),
    bigquery.SchemaField("creative_id", "INTEGER"),
]

# メトリクスのスキーマ
METRICS_SCHEMA = [
    bigquery.SchemaField("total_impressions", "INTEGER"),
    bigquery.SchemaField("total_clicks", "INTEGER"),
    bigquery.SchemaField("total_ctr", "FLOAT"),
]

BigQuery へのデータ挿入

def insert_df_to_bigquery(df: pd.DataFrame, target_date: date, bigquery_schema: list[bigquery.SchemaField], table_name: str):
    """Pandas DataFrameをBigQueryに挿入"""
    client = bigquery.Client()

    job_config = bigquery.LoadJobConfig(
        schema=bigquery_schema,
        write_disposition=bigquery.WriteDisposition.WRITE_TRUNCATE,
    )

    date_str = target_date.strftime('%Y%m%d')
    table_id = f"{PROJECT_ID}.{DATASET}.{table_name}_{date_str}"

    job = client.load_table_from_dataframe(df, table_id, job_config=job_config)
    job.result()

Cloud Run の実装

メイン処理の実装

@cloud_event
def main(cloud_event: CloudEvent) -> None:
    """Cloud Run のエントリーポイント"""
    try:
        # 対象日付を抽出
        target_date = extract_target_date(cloud_event)

        # GAM レポートデータを取得
        df = get_gam_report_data(dimensions, metrics, column_names, target_date)

        # BigQuery に挿入
        insert_df_to_bigquery(df, target_date, schema, table_name)

        print("レポート処理が完了しました")

    except Exception as e:
        print(f"処理でエラーが発生しました: {e}")
        raise e

運用面での工夫

Cloud Run のデプロイと実行

Cloud Run のデプロイは gcloud コマンドで行い、以下の設定で実行されます。

  • Region: asia-northeast1
  • Runtime: Python 3.13
  • Memory: 512MB
  • Trigger: HTTP

手動実行のためのコマンド

運用効率を向上させるため、以下のような手動実行用のコマンドを作成しました。

  • 単独日付指定: 特定の日付のレポートを生成
  • 範囲指定: 開始日から終了日までの期間でレポートを一括生成

これらのコマンドにより、スケジュール実行以外にも必要に応じて柔軟にレポートを生成できるようになっています。

システムの実行方式

システムは Cloud Run として実装されており、様々な実行パターンに対応できます。例えば、以下のような方法があります。

  • 手動実行: HTTP トリガーによる直接実行
  • スケジュール実行: Cloud Scheduler による定期実行
  • イベント駆動: Pub/Sub や Eventarc を経由した実行

ブログ内で言及はしていませんが、弊社では Cloud Scheduler から Pub/Sub トピックを起動し、サブスクリプションを通じて Cloud Run を定期実行する仕組みを構築しています。この仕組みにより、毎日決まった時間にレポートデータが自動的に更新され、手動作業を大幅に削減できています。

実装で得られた知見

1. GAM REST API の特徴

メリット:

  • JSON ベースで直感的な実装
  • 豊富なドキュメントとサンプルコード
  • 標準的な HTTP エラーハンドリング

注意点:

  • レポート実行は非同期処理のため、完了待ちが必要
  • 大量データの場合はページネーションが必要
  • レート制限に注意が必要

2. BigQuery との連携

最適化のポイント:

  • 日付別テーブル分割によるクエリ性能向上
  • 適切なスキーマ設計によるストレージコスト削減
  • WRITE_TRUNCATE モードによる冪等性の確保

成果と今後の展望

期待される成果

  1. 工数削減: レポート作成時間の短縮(現状 30 分〜1 時間)
  2. 営業活動の強化: レポート作成時間を営業活動に充て、売上貢献の向上
  3. データ活用の効率化: BigQuery での SQL による柔軟なデータ抽出

まとめ

Google Ad Manager REST API と BigQuery の連携により、セールスレポート作成の自動化を実現しました。

このシステムにより、セールスチームが営業活動により多くの時間を割けるようになり、結果として売上の向上に貢献することが期待されます。

同様の課題を抱えている組織の参考になれば幸いです。

参考

developers.google.com

googleapis.dev

googleapis.dev

cloud.google.com

インターンでデリッシュキッチンの新機能開発に取り組みました

1. はじめに

こんにちは、everyで1ヶ月間のインターンシップに参加させていただいた宮田です。本記事では、デリッシュキッチンの新機能開発に携わった経験と、そこで得られた学びを紹介します。 現在、デリッシュキッチンの既存仕様に対して、ユーザー体験を向上させるための新しい機能開発を進めています。今回のタスクでは、ユーザーをグループ化する新機能のバックエンドAPI実装を担当しました。

2. プロジェクト全体像と技術スタック

デリッシュキッチンサーバーの概要は下の図のようになっています。ダッシュボード側ではユーザー情報の管理・監視を行い、モバイルアプリ側ではデータベースからリモートキャッシュにセットした情報をユーザー管理画面に表示します。詳細はDELISH KITCHENのシステムアーキテクチャ で説明しています。今回は、ユーザーをグループ化するAPIとそのグループに招待するコード作成・取得機能を中心としたAPIを実装しました。

技術スタック

  • バックエンド: Go (Echo)
  • データベース: MySQL
  • リモートキャッシュ: Redis

3. デリッシュキッチンサーバー・バックエンド実装

デリッシュキッチンのバックエンドはクリーンアーキテクチャで構成されています。クリーンアーキテクチャとは、ビジネスロジックを外部のフレームワークやツールから切り離すことで、保守性・拡張性を高める設計手法です。主にrepository、infrastructure、service、handler、routerの5つの階層を用いています。最近では、多くの企業で標準的に採用されているようですが、私は今回が初めての経験だったため、概念の理解やコード分割に苦戦しました。

infrastructure・repository

infrastructureは、外部システムとの接続やデータの永続化を担当する層です。repositoryは、データアクセスロジックを抽象化し、ビジネスロジックからデータベースの実装詳細を隠蔽する役割を持ちます。この2つによって、データベース操作の詳細をビジネスロジックから分離し、テスタビリティと保守性を向上させています。

今回のグループ機能実装では、グループの作成・招待コード生成・招待コード取得のためのリポジトリインターフェースを定義し、MySQL用の実装を作成しました。

// 招待コード作成のリポジトリ実装例
func (r *InvitationCodeRepository) CreateTx(ctx context.Context, tx dbr.SessionRunner, m *model.InvitationCode) (*model.InvitationCode, error) {
    result, err := tx.InsertInto(r.getTable()).
        Columns("group_id", "invitation_code", "expires_at", "is_active").
        Record(m).
        Exec()
    if err != nil {
        return nil, e.Wrap(err, "couldn't create invitation code")
    }

    id, err := result.LastInsertId()
    if err != nil {
        return nil, e.Wrap(err, "couldn't get last insert id")
    }

    m.ID = id
    return m, nil
}

データベースへのINSERT操作をトランザクション内で実行しています。招待コード作成ではグループとの関連(group_id)と状態管理(is_activeexpires_at)を含めたレコードを作成しています。LastInsertId()で生成されたIDを取得してモデルに設定し、エラー処理はpkg/errorsパッケージでラップして詳細な情報を保持しています。


service

serviceは、ビジネスロジックを実装する層で、repositoryを通じて取得したデータに対して業務要件を満たす処理を行います。複数のrepositoryを組み合わせて複雑な処理を実現し、トランザクション管理も担当します。

今回の実装では、グループへの招待コード自動生成や、招待コード取得時のアクセス権限チェックなどのビジネスルールを実装しました。特に招待コードは、セキュリティを考慮してランダム文字列生成と有効期限設定を行っています。

// 招待コード生成のサービス実装例
func (s *InvitationCodeServiceImpl) CreateInvitationCode(ctx context.Context) (*model.InvitationCode, error) {
    // トランザクション開始
    session := db.GetSession("t3")
    tx, err := session.Begin()
    if err != nil {
        return nil, e.Wrap(err, "failed to begin transaction")
    }
    defer tx.RollbackUnlessCommitted()

    // 既存の招待コードを無効化
    _, err = s.invitationCodeRepo.DeactivateByGroupIDTx(ctx, tx, group.ID)
    if err != nil {
        return nil, e.Wrap(err, "failed to deactivate existing invitation codes")
    }

    // セキュアなランダム文字列生成
    code, err := random.GenerateInvitationCode()
    if err != nil {
        return nil, e.Wrap(err, "failed to generate invitation code")
    }

    // 24時間の有効期限設定
    expiresAt := time.Now().Add(24 * time.Hour)

    newInvitationCode := model.NewInvitationCode(group.ID, code, expiresAt)
    createdInvitationCode, err := s.invitationCodeRepo.CreateTx(ctx, tx, newInvitationCode)
    if err != nil {
        return nil, e.Wrap(err, "failed to create invitation code")
    }

    if err := tx.Commit(); err != nil {
        return nil, e.Wrap(err, "failed to commit transaction")
    }

    return createdInvitationCode, nil
}

serviceレイヤーでは、複数のリポジトリを組み合わせたビジネスロジックを実装しています。招待コード生成では、トランザクション管理下で既存コードの無効化と新規コード生成を一貫して行い、ACID特性を保証しています。また、セキュリティ面では24時間の有効期限設定やランダム文字列生成を行い、システムの安全性を確保しています。


handler・router

handlerは、HTTPリクエストを受け取り、リクエストデータの検証、serviceの呼び出し、レスポンスの組み立てを行う層です。routerは、URLパスとHTTPメソッドに基づいて適切なhandlerにリクエストを振り分ける役割を担います。この2つで、外部からのAPIリクエストを適切に処理し、JSONレスポンスを返すWebAPIを実現しています。

今回は、グループ作成・招待コード生成・招待コード取得の3つのエンドポイントを実装しました。各エンドポイントでは、リクエストパラメータのバリデーション、認証チェック、エラーハンドリングを適切に行っています。

// 招待コード作成のハンドラー実装例
func (h *InvitationCodeHandlerImpl) CreateInvitationCode(c echo.Context) error {
    user := h.userAuth.GetUser(c)
    if user == nil {
        return types.ErrNotAuthorized
    }

    invitationCodeModel, err := h.invitationCodeService.CreateInvitationCode(dctx.NewUserContext(c))
    if err != nil {
        return err
    }

    invitationCodeResponse := response.NewInvitationCode(invitationCodeModel)
    return JSONHTTPSuccessHandlerAsMap("invitation_code", invitationCodeResponse, c)
}

handlerレイヤーでは、HTTPリクエストを受け取ってserviceレイヤーに処理を委譲し、適切なJSONレスポンスを返しています。全てのエンドポイントで共通して認証チェック(userAuth.GetUser())を実行し、未認証の場合はErrNotAuthorizedエラーを返しています。また、dctx.NewUserContext() でユーザー情報をコンテキストに埋め込み、service層でユーザー固有の処理ができるようにしています。レスポンス生成では、統一的なフォーマット(JSONHTTPSuccessHandlerAsMap)を使用してクライアントに一貫した形式でデータを返すよう設計されています。


4. インターンシップを通じて学んだこと

GoとTypeScriptの比較

今回初めてGoを使用して開発を行ったため、書き方や仕様を把握するのが大変でした。普段はTypeScriptを使用することが多いのですが、Goを触ったことで以下のような気づきを得ました。

型の違い

TypeScriptでは柔軟で表現力が高いのに対し、Goはシンプルで設計の曖昧さを許さないという違いがあります。ポインタやスライス設計を意識せざるを得ない点は新鮮でした。

非同期処理とcontext

TypeScriptはPromise/async-awaitが主流ですが、Goはcontext.Contextで処理のライフサイクルを統一的に管理できます。これは信頼性を高める強力な仕組みだと実感しました。

テスト文化

Jestでの振る舞いテストが中心のTypeScriptに比べて、Goは層ごとの責務を意識してモックを徹底的に利用します。特に、データベースに直接触らずにテストするという設計方針は強く印象に残りました。

実装について

アーキテクチャ設計とパフォーマンス

今回の実装では、プロジェクトのコーディング規約に従った型設計の重要性を学びました。例えば、スライス型の設計では[]Typeではなく[]*Typeを使用することで、パフォーマンス向上とコードベース全体の一貫性を保つことができます。また、クリーンアーキテクチャにおける依存関係の管理では、定義されていない方法での依存が発生しないよう、各層の責務を明確に分離することが重要でした。

トランザクション管理とエラーハンドリング

データベース操作では、単体の関数とトランザクション版の関数を分離し、前者は後者を呼び出すだけにしてメインロジックは後者に集約する設計パターンを学びました。エラー処理では、==ではなくerrors.Is()を使用した適切な比較や、typesパッケージで定義された標準エラーの活用により、一貫性のあるエラーハンドリングを実現できました。

コード効率性とパフォーマンス最適化

実装時には、早期returnの活用やfor-rangeでの要素検索における標準パッケージslicesの使用など、効率的なロジック設計を心がけました。また、無駄なDBアクセスを避けるためのロジック設計や、ORMのLoadOneメソッドを適切に使用することで、パフォーマンスの向上を図りました。

エラー処理の考え方

TypeScriptのtry-catchに比べ、Goは戻り値で明示的にerrorを返すため、どこで失敗する可能性があるかが明確に見えます。特に、infrastructure層でwrapしたエラーをservice層で再度wrapするかどうかの判断や、エラーの発生源を意識したスタックトレース設計の重要性を学びました。

コーディング規約と命名規則

変数名とコメントの適切性

実装時には、変数名がデータベースのカラム名や既存のプロジェクト慣習に則っているかを常に確認することの重要性を学びました。また、コードを読めば分かる内容についてはコメントを書かず、本当に必要な説明のみをコメントとして残すことで、コードの可読性を向上させることができました。

関数の命名と設計

新しい機能を作成する際には「Add」ではなく「Create」を使用するなど、既存のコードベースの命名規則に従うことの重要性を実感しました。また、使用されていないinterfaceや関数定義は削除し、コードベースをクリーンに保つことも大切だと学びました。

テスト実装

モックの活用とテスト設計

単体テストでは実データベースを使用せず、下位のservice/repositoryにはモックを使用することで、テストの独立性と実行速度を確保できました。テストケース作成時には「このテストで何が検証できているのか」を常に意識し、冗長なテストを避けることの重要性を学びました。

並列テストとテストケース設計

DBアクセスを行わないテストではt.Parallel()を使用した並列化を必ず行い、テスト実行時間を短縮しました。また、全てのエラーパターンを網羅的にテストケースに含め、特にgomock.Any()ではなく具体的な型での検証を行うことで、より堅牢なテストを実現できました。

プルリクエストとレビュー文化

プルリクエスト作成時の配慮

PR作成時には、将来のタスクで使用予定の実装でも、今回のPRに関係ない部分はレビューの邪魔になるため除外することの重要性を学びました。また、テストが落ちている状態でPRを作成しないよう、事前にテストを実行して通った状態にしておくことも基本的なマナーだと感じました。

レビュー可能なPRの作成

プロジェクトに関わっていないレビュアーでもレビューできるよう、PRのdescriptionには初見では分からない情報や背景を丁寧に記載することの大切さを実感しました。これにより、チーム全体での知識共有とコードの品質向上に貢献できます。

レビュー文化

レビューの返ってくるスピードの早さに驚きました。レビューをしないと他の人の作業を止めてしまう、また、人のコードを客観的に見ることで自分も勉強になるから優先的にレビューを行うという考え方が非常に良いと思い、ぜひ自分も真似していきたいと感じました。モックの生成コマンドをMakefileに追加するなど、チーム開発での協調性を意識した細かい配慮も重要だと学びました。

5. まとめ

1ヶ月間のインターンシップを通して、デリッシュキッチンのグループ機能という大事な新機能実装を任せていただいて非常に貴重な体験となりました。普段行っているWeb開発では体験できないテストやCI/CDの自動化ツールであったり、リリース作業などを体験させていただけました。これまで概念として知っていたデータベースのインデックスやトランザクションなど、実際に自分の知識を初めてコードに反映することができてよかったです。また、細かくレビューしていただいたことで、商用としてのより良い実装だけでなく、社内の実装ルールやPR作成時に気をつけなければいけないことなど、自分の中に今までなかった様々なことを学ばせていただきました。今回のインターンシップ参加を通して、従業員として業務をこなしたことによる新しい発見や成長を得ることができ、自分がこれから勉強するべきことなども見つけることができました。また、どれだけ既存コードが理解できていなくてわからない状態でも、実装や開発は非常に楽しいなと常に思っていたので、改めて自分が開発が好きだということを再確認できてよかったです。これからは、今回の実装で学んだことやレビューいただいた内容を元に、どんどん成長して、より良いエンジニアになっていきたいです。