every Tech Blog

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

リテールハブ小売アプリのプレビュー機能を改善した話

はじめに

はじめまして。2025年の8月から1ヶ月間、株式会社エブリーのインターンシップに参加していた山本陽右と申します。配属は、国内最大級のレシピ動画メディア「デリッシュキッチン」の知見を活かし、リテールメディアのプラットフォーマーを目指す「リテールハブ」事業部の「小売アプリ」開発です。今回、小売アプリの機能改善に取り組みました。

経緯

学部、大学院と建築学専攻である私がプログラミングに興味を持ったきっかけは、卒業論文でカビの成長モデルをJuliaで実装したことでした。それがきっかけで、建設業界だけでなくソフトウェア業界にも興味を持って就職活動していました。そこで縁あって未経験ながらエブリーのインターンに参加し、学業と両立しながら事前学習を進めました。

背景・課題

リテールハブ事業部では、小売店の収益拡大やファン創出のために、

  • デジタルサイネージを用いた「ストアDX
  • ネットスーパー
  • 小売アプリ

の3つの事業を展開しています。私は今回、小売アプリの開発に携わりました。 小売アプリにはお客様に送信するチラシやPOPを編集する「お知らせ」機能があります。これがお客様の端末でどう見えるかプレビューする機能があるのですが、これの使い勝手があまりよくありませんでした。 具体的には、

  • 編集中にプレビューボタンを押しても、編集中の内容が反映されない
  • プレビュー画面(/preview)から戻ると、編集内容が保持されない

これらの課題を解決し、ユーザー体験を向上させることに注力しました。

改善計画

改善案

まず、要件の整理からやらせていただきました。手探りでしたが、アドバイスもいただき、最終的には上記のように2つの課題に切り分けて別々に対処することにしました。 当初は、pinia(Vueで、ローカルの情報をグローバル変数として保持できる機能)を使って、ローカルで編集中の内容を保持・反映することで対応しようと思い、提案書を作りましたが、「プレビューは本番と同じ形式 = 一度データベースに保存し、それを取得・表示するからこそ意味がある」とレビューをいただき、バックエンドの改修も作業に含めることにしました。

技術スタック

上記のような理由から、データベースから改修する必要がありました。 小売アプリではフロントエンドはVue、バックエンドはLaravelを使用しています。そのため、Laravelのマイグレーションを活用しました。現状のテーブルに要素を付け足すのではなく、(変更履歴やバージョン管理など)今後の拡張性も考慮して公開されている記事用のデータベース"articles"とは別に、下書き保存用のデータベース"article_drafts"を作成することにしました。また、2つ目の課題にはpiniaを使用し、手早く実装しようと考えました。

実装方針

  • 新規APIの作成(下書きの保存)
  • webルートの拡張(既存ロジックの拡張)
    • 既存のプレビュー機能は埋め込みhtmlで実装しており、それを活用しながらデータの取得先を下書きDBに繋ぎかえた
  • piniaによる編集内容の復元

書いたコード

バックエンド

既存のコードに則って、コントローラー、サービス、リポジトリの3層構造+リクエスト(認証、バリデーション)で実装しました。まず、この設計思想を理解するのに時間がかかりました。理解を深めるためにphpの歴史を調べながら、実際にコードに触れて学んでいきました。

<?php
// === コントローラーレイヤー ===
// HTTPリクエストの受け取り、認証・権限チェック、レスポンスの返却を担当
public function storeDraft(DraftRequest $draftRequest): JsonResponse
{
    // リクエストデータのバリデーション済みデータを取得
    $validated = $draftRequest->validated();

    // (権限周りの処理)

    // サービス層に処理を委譲し、下書きデータを保存
    $draft = $this->articleService->storeDraft($validated, $staff);

    // JSONレスポンスとして保存された下書きデータを返す
    return response()->json([
        'article' => $draft
    ]);
}

// === サービスレイヤー ===
// ビジネスロジックの実装、トランザクション管理、DTOの作成を担当
public function storeDraft(array $validated, Staff $staff): array
{
    // データベーストランザクションを開始(データの整合性を保証)
    DB::beginTransaction();
    try {
        // バリデーション済みデータをDTO(Data Transfer Object)に変換
        // DTOはレイヤー間でのデータ受け渡しを構造化・安全に行うためのオブジェクト
        $createDraftDto = new CreateDraftDto(
          // (省略)
        );

        // リポジトリ層に処理を委譲し、下書きデータを保存
        $articleDraft = $this->articleRepository->storeDraft($createDraftDto);

        // トランザクションをコミット(変更を確定)
        DB::commit();

        // 保存されたデータを配列形式で返す
        return $articleDraft->toArray();
    } catch (\Exception $e) {
        // エラー発生時はロールバック(変更を破棄)
        DB::rollBack();
        throw $e;
    }
}

// === リポジトリレイヤー ===
// データベース操作を担当し、モデルのCRUD操作を抽象化
public function storeDraft(CreateDraftDto $createDraftDto): ArticleDraft
{
    // article_idとstaff_idの組み合わせでレコードを検索
    // 見つかれば既存レコードを更新、なければ新規作成(Upsert操作)
    // これにより、同じ記事の同じスタッフによる下書きは常に1件のみ保持される
    $articleDraft = ArticleDraft::updateOrCreate(
      // (省略)
    );

    // 保存されたArticleDraftモデルインスタンスを返す
    return $articleDraft;
}

困ったこと

バックエンドの実装を終え、当初の方針でフロントエンドとの繋ぎ込みを進め、8割ほど実装が完了した段階で、設計時に考慮しきれていなかった新たな課題が複数見えてきました。 具体的には、

  • 直リンク問題: プレビュー用のURLに直接アクセスされた場合の挙動が保証できない。
  • ブラウザバック時の挙動がブラウザによって異なる可能性:piniaで保持していた編集内容が消えてしまうケースがある。

これらは技術的には解決可能な問題ではありますが、コードが膨れ上がってしまいます。インターン期間中にも終わらなくなってしまいます。そこで一度立ち止まってアプローチそのものを見直すことにしました。そして、これらの問題の根本原因は「プレビューのためにページを遷移している」ことにあると考え、プレビュー処理をページ遷移からモーダル表示に変更するという解決策にたどり着きました。仕様の変更なのでデザインの修正は必要になりますが、モーダルだとそれ専用のページがあるわけではない = 画面遷移しないので、直リンクの対策も、ブラウザバックの挙動も気にする必要がなくなります。

フロントエンド

以上から、仕様を変更してモーダルで実装することにしました。

<iframe
    :src="createApiUrl(`/web/articles-drafts/${articleId}`)"
    class="preview-iframe"
    frameborder="0"
></iframe>

ページ遷移をモーダルに変更しましたが、内部処理は同じで、埋め込みhtmlのiframeを使用しています。基本的なロジックは改善前から完成していたので、モーダルへの移行は比較的簡単でした。

const handlePreview = async () => {
  isLoading.value = true
  //下書き保存APIの呼び出し
  await storeDraft(articleId, currentValuesForPreview())
  isLoading.value = false
  //モーダルを開く
  showPreviewModal.value = true
}

これはプレビューボタンを押した時に呼び出される関数なのですが、フロントではこの実装が一番気に入っています。リーダブルコードの原則に則り、見通しよく書けたと思っています。最終的にはシンプルになりましたが、仮実装でハードコードしてしまったり、苦労して書き上げたものが無意味になったりと、かなり回り道しました。ですが、汚くても一通りコードを書いて自分で理解することで、削って整えることができるのだとも思いました。このバランス感覚は今後の課題にしたいと思います。

// 下書き保存用のAPIを呼び出す関数
const storeDraft = async (id: number, values: ArticleFormValues) => {
    // ガード節
    if (id === INVALID_ARTICLE_ID || !values) {
      return false
    }

    // CSRFトークンを取得(セキュリティ対策)
    const csrfToken = await getCsrfToken()
    // APIエンドポイントURLを生成
    const url = `${createApiUrl('/articles-drafts')}/${id}`

    // useFetchを使ってAPIリクエストを実行
    await useFetch(url, createCsrfFetchOptions(csrfToken), {
      // リクエスト前に実行
      beforeFetch({ options }) {
        errors.value = undefined
        options.body = JSON.stringify(convertToAPIValuesForDraft(id, values))
        return { options }
      },
      // エラー発生時の処理
      onFetchError: (ctx) => handleAPIError(ctx, errors),
      // レスポンス受信後の処理
      afterFetch(ctx) {
        const {
          data: { article: apiArticle },
        } = ctx.data
        article.value = convertToClientArticle(apiArticle)
        return ctx
      },
    })
      .post()  // POSTリクエストを送信
      .json()  // JSONレスポンスとして処理

    // エラーがなければtrue、発生していればfalseを返す
    return !errors.value
  }

既存のコードを利用しつつ、storeDraft(下書きの保存・更新)APIは新規で作りました。 1つ目のコードブロックではstoreDraftをカプセル化していますが、内部でも適宜、下位問題を切り出して見通しをよくする努力をしました。 言われてみれば当たり前なのですが、変数や関数の名前を「それが何をしているか、何を表しているか」をわかりやすいものにするだけで、可読性がかなり高まることは、とても学びになりました。

改善結果

改善前後の比較

改善前

変更前タイトル
改善前のプレビュー機能
変更後のタイトル

プレビューに編集内容が反映されておらず、編集画面から戻ると編集内容も消えてしまう(画像はタイトルのみですが、本文も同様)

改善後

改善後のプレビュー機能

変更後本文

編集内容が保持・反映されており、要件が満たされていることがわかリます。

実装コスト削減とUX向上

ページ遷移をモーダルに変更した結果、

  • piniaによる編集内容の保持が不要になった
  • アクセス方法が制限されるため、直リンクやブラウザバックへの対策が不要になった
  • モーダル枠外の任意の場所をクリックしても編集に戻れるようになった
    • これまでは「編集に戻る」ボタンと、ブラウザバックでしかプレビューを終了できなかった

というメリットが生まれ、要件を満たしながら、実装コストを下げつつUXも向上させるという、一石二鳥の実装ができました!

学んだことと振り返り

モダンな技術スタック:要件の整理からVue、Laravel、データベースまで、幅広く勉強させていただきました。もう少し事前学習ができていればインフラまで踏み込めたのが少々心残りです。

初めてのチーム開発経験:個人開発と違って、全員が同じ方向性を向いて、同じ目的意識を持って開発を進めるためには意見のすり合わせが不可欠だと感じました。また、会議の初めにゴールを明確にするなど、会議のための会議にならない工夫を実践することもできました。

仕事の姿勢:納期があり、制約があり、その中で優先順位をつけて良いものを届けるという、就業型インターンならではの経験ができました。慣れていないから余計に目先の実装に追われがちで、システム全体やそれを使うクライアントにまで意識を向けることが難しかったですが、今後も大事にしていきたい経験です。

ソフトウェアエンジニアに求められる思考:期間中に読んだ「リーダブルコード」だけでなく、社員さんの話し方、考え方から少しずつ吸収しようと心がけていました。インプットだけでなく学んだ知識の実践までできたので、自らの血肉になった実感があります。

おわりに

学業や学会の準備をしながらのインターンで、非常に忙しかったですが、充実した1ヶ月間でした。要件を満たして実装完了までできて、とても嬉しいです。

最後になりましたが、小売アプリの皆さんには大変お世話になりました。とてもよくしていただきました。この場で御礼申し上げます。