🎉

資格学習サービスの大規模リニューアルを走り切った話

に公開

はじめまして、_minoです!

この記事は、「2025年に最も大きなチャレンジとなったプロジェクト」についてまとめたものです。

かなり分量が多くなってしまいましたが、ざっくり2パートに分かれています。

前半: ユーザーヒアリングや設計まわりの話
後半: 技術選定やパフォーマンス改善、リリースまわりの話

少しでもプロジェクトや開発のお役に立てれば幸いです!

🔥 このプロジェクトについて

弊社では、簿記資格の学習サービス「Funda簿記」を展開しています。
https://lp.boki.funda.jp/

ToBとToCどちらにも提供しており、学生から社会人、学校導入など多くの方々にご利用いただいています。

今回はこのプロダクトの事業成長に向けた、大規模なリニューアルプロジェクトに関する内容をまとめました。

👥 プロジェクトのチーム体制

スタートアップということもあり、少数精鋭の体制で進めました。

プロジェクトオーナー 2名(経営層)
プロジェクトの最終意思決定および方針検討の壁打ち相手

デザイナー 1名
UI/UXなどのデザイン周りを担当

エンジニア 2名(自分 + 業務委託の方)
設計・開発を担当(プロジェクトマネジメントも兼務)

QA・ヘルプメンバー 5名程度
QA(動作テスト)やCMS用のコンテンツ積み込みなどを担当

✍️ 当時抱えていた課題

サービス改善前、プロダクトにはいくつかの課題がありました。

ビジネス的観点

  • UI/UXの問題(ページ動線の複雑化、スマホ未最適化によるPC依存)により、学習機会の損失や滞在時間・エンゲージメントの低下が発生
  • SNSや記事で一部ネガティブなレビューがあり、マーケティング施策によるユーザー増加時にユーザー体験の質を維持できるか懸念があった
  • オンボーディング設計の不足により、機能が認知・活用されていない

技術的観点

  • レガシーコードやスパゲッティコードによる可読性の低下
  • パフォーマンスの最適化不足によるページ表示の遅延
  • ライブラリのバージョン起因の依存関係の問題

他にも論点はありましたが、これらの課題を短期間で改善する必要があり、今回のプロジェクトで両方の観点に取り組みました。

📝 ユーザーヒアリングで見えたこと

課題と改善ポイントの仮説は立てていたものの、それが本当に正しいのか確信が持てませんでした。実際にユーザーがどう使っているのか、どこに不満を感じているのかを把握するため、まず10名ほどにインタビューを実施しました。

対象者集めの工夫と苦労

最初に直面した壁は、インタビュー対象者集めでした。

コミュニティやメールで連絡を取っても、全く集まりませんでした。アプリのファンの方は何人か協力してくださいましたが、それでも足りません。

そこで「Amazonギフト券 500円分」の謝礼をつけたところ、反応が一気に良くなり、すぐに10名ほど集まりました。

これでようやくスタートが切れました。

インサイトマネジメントについて

ユーザーヒアリングは初めてだったので、どう進めればいいかわかりませんでした。
そこで「Centou」というブログを見つけ、読み漁りました。

こちらのブログでは「インサイトマネジメント」という考え方をもとに、単に「ユーザーの声を聞く」だけでなく、得られた情報をどう整理・活用するかまで体系的にまとめられており、ヒアリング設計の軸になりました。

どのようにインサイトを得るか」「ユーザーとの向き合い方」など詳細に書かれているので、かなりおすすめです👇
https://centou.jp/insight-management-roadmap

他にもヒアリングの進め方に関する記事を読み、実践していきました。
https://note.com/pd_m/n/nde0be71c2eae
https://baigie.me/blog-ui/2020/01/16/ux-research-for-business/

ヒアリングから得られたインサイト

事前に立てていた仮説もありましたが、実際に生の声を聞くと、どこが使いづらいのか、何が改善されたら嬉しいのかが山のように出てきました。

そこから優先度が高くインパクトの強いものを絞り、改善案のメリット・デメリット、リスク、期限内に終わるかを洗い出して、プロダクトオーナーにも相談して、最終的に3つの機能改善に決めました。

ユーザーの声がモチベーションになった

実際にユーザーの方と話してみると、熱量を持って使ってくださっている方が大多数でした。なかには「合格後も継続して使いたい」と言ってくださる方もいて、とても嬉しかったです。

普段は聞けない生の声に触れ、「なんとしても完成させたい!」という気持ちが強くなりました。改めてメンバーと認識を揃え、ゴールを再設定して進めていきました。

🎨 UI/UXリサーチの進め方

ヒアリングで得たインサイトをもとに、どんなUI/UXにするかを検討しました。その参考として、さまざまな学習サービスやデザイン系サイトを調査しました。

他サービスの調査

同じ学習サービスなので、有名どころの「Progate」「侍テラコヤ」「Udemy」「Duolingo」などを実際に使ってみて、どこが優れているのかをリサーチしました。

そこから「この機能は取り入れたい」「このUIはアレンジしたい」といった議論をデザイナーの方とほぼ毎日重ねながら、Figmaでプロトタイプを作っていきました。

参考にしたツール・リソース

デザイナーの方に教えてもらった以下のサイトも参考にしながら、有名サービスの画面設計や動線をリサーチしました。デザインの参考にかなりおすすめです!
https://mobbin.com/
https://www.ui-pocket.com/mobile/apps
https://jp.pinterest.com/

デザインが完成

プロジェクトが始まって1ヶ月半が経ち、デザイナーの方からFigmaでデザイン案が共有されました。

どの画面もかなり改善されていて、元のバージョンとは見違えるほど素晴らしい仕上がりでした。
初めて見たときに「おーー!」と声が出た記憶があります(笑)

社内メンバーやユーザーにも見てもらい、フィードバックを受けて微調整を加え、ついにデザインが完成しました。

そして開発サイドにバトンが渡ってきました🔥

📚 技術選定とアップグレード方針

4年前ぐらいから動いているプロダクトということもあり、技術スタックやバージョンがかなり古い状態でした。
今回のリニューアルでは、スケジュールも加味して全てを刷新するのではなく、既存のアーキテクチャを維持しつつ、段階的にボトルネックを解消する方針としました。

技術スタック

技術スタックは以下の構成になっています。

  • フロントエンド / バックエンド: Next.js
  • スタイリング: Chakra UI
  • 認証: Auth0
  • DB: Supabase
  • ORM: Drizzle ORM
  • バリデーション: Zod
  • 状態管理: Zustand
  • ホスティング: Vercel
  • CMS: microCMS
  • 画像: Cloud Storage + Cloud CDN
  • テスト: Vitest

アップグレード内容

Next.jsの設計見直しとライブラリのバージョンアップをメインに進めました。

Next.js: Pages Router v12 → App Router v14
Server Components やキャッシュの観点で App Router が優位と判断しました。
また、コミュニティの動向からも App Router が主流になると感じ、このタイミングで移行を決定しました。当時v15系はリリース直後で破壊的変更が多かったため、v14を選択しました。

Chakra UI: v1 → v2
v1 は App Router に対応しておらず、React の依存関係からもアップグレードが必須でした。
新しいデザインシステムやメソッドも追加されており、開発体験の向上につながりました。

なお、v3 は設計や概念が刷新されており App Router とも相性が良さそうだったので採用したかったのですが、着手時点では未リリースだったため見送りました。
https://chakra-ui.com/

v2とv3の比較も公式で出ていたので気になる方はぜひ👇
https://chakra-ui.com/blog/chakra-v2-vs-v3-a-detailed-comparison

Auth0: v2 → v4
v2はApp Routerに対応しておらず、対応しているv3以降へのアップグレードが必須でした。
https://auth0.com/blog/auth0-stable-support-for-nextjs-app-router/

Typescript: v4.8 → v5.7
App Router との互換性の観点から、v5 以降へのアップグレードが必要でした。また satisfies も使えるようになるため、当時の最新バージョンを選択しました。
https://typescriptbook.jp/reference/values-types-variables/satisfies

Drizzle ORM: v0.28 → v0.38
依存関係の制約はなかったものの、利用できる関数や機能が増えていたため、このタイミングで更新しました。

ディレクトリ構成

当時、App Routerの知見があまりなかったこともあり、色々とアレンジしたり、先輩の構成を参考にしたりして、以下のモノリシックな構成にしました。

├── app/
│   ├── api/                    # APIエンドポイント
│   └── (authentication)/
│       ├── page.tsx            # 画面コンポーネント
│       ├── layout.tsx          # レイアウト
│       ├── action.ts           # Server Actions
│       ├── query.ts            # データ取得
│       └── _components/        # 画面固有コンポーネント
├── components/                 # 共通UIコンポーネント
├── features/                   # 機能別モジュール
├── db/                         # DB関連(マイグレーション等)
├── utils/                      # ユーティリティ関数
├── lib/                        # ライブラリ設定・初期化
├── types/                      # 型定義
├── constants/                  # 定数
└── ...

(authentication) のRoute Group配下には、画面ごとの actions.ts(更新処理)、query.ts(取得処理)、_components(画面固有コンポーネント)を配置しています。

責務を明確に分離しつつ、関連ファイルを近くにまとめることで開発時のフォルダ間の行き来を減らすことが狙いでした。

取得処理(GET)は基本的にServer Componentで行い、更新処理(POST, PATCH, DELETE)はServer Actionsに移行しました。

また、可読性の観点からもPages Routerで使用していた /api を、App Routerの /api に移行しました。

📈 パフォーマンス改善の話

サービス内で最も課題があったページの表示速度を改善するため、以下の6つの改善に取り組みました。

1. サーバーコンポーネントの活用

App Routerへの移行によりサーバーコンポーネントが利用可能になったため、可能な限りサーバーコンポーネントを活用する方針としました。

実装の管理にはContainer/Presenterパターンを採用しています。

_components/
├── XxxContainer.tsx   # データ取得・ロジック(Server Component)
└── XxxPresenter.tsx   # 表示・UI(Client Component)

https://zenn.dev/buyselltech/articles/9460c75b7cd8d1

ただし、Chakra UI v2はクライアントコンポーネントとして動作するため、管理は若干複雑になります。

Chakra UI側に"use client"が内蔵されているため、サーバーコンポーネント内でも利用自体は可能ですが、レンダリングはクライアントで行われます。
https://blog.stin.ink/articles/chakraui-is-still-client-components

2. データフェッチ設計の見直し

従来はPages Routerの getServerSidePropsSWR を用いてAPIフェッチを行っていました。

しかし、スマホの隙間時間でのアクセスが多く、低速なネットワーク環境(満員電車など)では表示速度に影響が出てしまうため、クライアント側の通信回数を減らすため、サーバーコンポーネント内で直接クエリを実行する設計に変更しました。

query.ts
export const getFetchData = async () => { 
  return await db.select().from(users)
}
Container.tsx
export const ServerComponent = async () => {
  const fetchData = await getFetchData()
  return {/* ... */}
}

また、依存関係のないクエリについては並列フェッチで、同一ページで必要なデータを同時に取得するようにしました。

Container.tsx
const [fetchData1, fetchData2] = await Promise.all([
  getFetchData1(),
  getFetchData2()
])

https://nextjs.org/docs/app/getting-started/fetching-data#parallel-data-fetching

ただし、並列実行は同時DB接続数が増えるため、高負荷時に接続数上限に達するリスクがあります。
DBリソースの状況に応じて適用範囲を判断してください。

3. SuspenseによるStreamingの導入

データ取得が完了するまで画面が空白のままだとユーザー体験が悪化するため、Suspenseを用いたStreamingを導入しました。

これにより、準備できたコンポーネントから段階的に表示することで、ユーザーの「待たされている感」を軽減するようにしました。

page.tsx
import { Suspense } from 'react'

export default function Page() {
  return (
    <Suspense fallback={<Loading />}>
      <ServerComponent />
    </Suspense>
  )
}

https://nextjs.org/docs/app/getting-started/fetching-data#with-suspense

また、TTFB(Time To First Byte:最初の1バイトが届くまでの時間)FCP(First Contentful Paint:最初のコンテンツが表示されるまでの時間) といったパフォーマンス指標の改善が期待できます。

4. キャッシュの活用

実験的な機能ではありますが、unstable_cacheを用いてサーバーコンポーネント内の重いクエリ処理をキャッシュするようにしました。

import { unstable_cache } from 'next/cache'

const getCachedData = unstable_cache(
  async () => {
    return await db.select().from(contents)
  },
  ['cache-key'],        // キャッシュを識別するキー
  {
    tags: ['cache-tag'], // revalidateTagで無効化する際に使用
    revalidate: 60,      // 60秒後に再検証
  }
)

https://nextjs.org/docs/app/api-reference/functions/unstable_cache

すべてのデータをキャッシュするのではなく、以下のように使い分けています。

  • キャッシュしない: ユーザーごとの学習ログなど、頻繁に更新されるデータ
  • キャッシュする: パブリックな学習コンテンツやユーザープロフィールなど、更新頻度の低いデータ

5. Prefetchの活用

ページ数が多く、ユーザーのページ遷移も頻繁なため、待機時間を減らしスムーズな体験を提供することを目指しました。

App Routerの<Link>コンポーネントは、デフォルトでビューポート内のリンクを自動的にprefetchします。これにより、クリック後の待ち時間が短縮されます。

import Link from 'next/link'

export default function Navigation() {
  return (
      <Link href="/dashboard">
        ダッシュボード
      </Link>
  )
}

https://nextjs.org/docs/app/guides/prefetching

ただし、リンクが大量にある画面(無限スクロールのリストなど)では、過剰なprefetchがリソース消費につながる可能性があるので、その場合はprefetch={false}で無効化するか、onMouseEnterを使用してホバー時のみprefetchするよう制限することも可能です。
https://nextjs.org/docs/app/guides/prefetching#preventing-too-many-prefetches

6. クエリカラムの見直し

従来は全カラムを取得していたり、使用しないカラムまで取得していたため、すべてのクエリで必要なカラムのみを明示的に指定するよう変更しました。

const getUser = async (id: string) => {
  const rows = await db
    .select({
      name: users.name,
      email: users.email,
    })
    .from(users)
    .where(eq(users.id, id))
  return rows[0]
}

特にTEXT型やJSON型の大きなカラムを含むテーブルや、複数テーブルをJOINしているクエリでは、データ転送量とメモリ使用量の削減に効果がありました。

ただし、カラムを厳密に絞りすぎると戻り値の型定義が複雑になるトレードオフがあります。

型管理を簡潔にするため、以下のようにクエリ関数の戻り値から型を推論する方法を採用しました。

type User = NonNullable<Awaited<ReturnType<typeof getUser>>>

📗 テスト・QA体制の構築

比較的大きなリプレイスだったため、バグを最小限に抑えてリリースするために、1ヶ月ほどのテスト期間を設けました。社内メンバーと業務委託の方にご協力いただき、テストを実施しました。

テスト協力者の確保

別件で弊社と関わりのある業務委託の方に声をかけ、想定工数と時間単価を提示した上で、準委任契約を締結しました。

契約周りは今まで携わる機会がなかったので勉強になりました。
https://biz.moneyforward.com/contract/basic/13962/

テスト項目の管理方法

テスト担当者のほとんどが非エンジニアだったこともあり、Notionでテストシートを作成し、できるだけ細かく管理しました。

以下は一部抜粋ですが、「概要」「画面カテゴリー」「デバイス」「システムケース(正常系、異常系)」「誰が確認済みか」「試験に合格済みか」「どれくらいの品質か(合格した回数)」など項目を詳細に設定しました。
テストシートの項目例

不具合があった場合は別ページで「概要」「再現手順」「スクリーンショット・画面収録・ログ」を細かく記載していただき、チャットにメンションを投げる運用にしました。
不具合報告の例
テストシートの作成にはかなり時間がかかりましたが、この段階で多くの不具合を解消でき、改めてテストの重要性を実感しました。

🧭 リリース準備・当日の動き方

テスト期間を経て、プロジェクトオーナーからリリースOKの判断をもらい、リリース計画を進めました。

事前告知

Xコミュニティでの告知と、ユーザーへのメンテナンス・リリース通知をGASで行いました。
GASで一斉送信する場合は上限があるため、複数回に分けて送信しました。
https://zenn.dev/gas/articles/7b8bf039ff0186

SendGridResend などの通知プロバイダーを使っても良かったかなとも思ってます。

事前準備

リリース計画書を細かく作成しました。
当日のタイムラインと作業内容、手順書、完了後の動作確認手順、問題発生時の対応とロールバック手順など、当たり前のことも含めてすべて言語化しました。

https://note.com/yohei30hobby/n/nc96c7fb1996c

この作業はかなり大事だと思っていて、いくら準備をしても何かしら問題は起こりうるので、実際に起こったときに焦らないための予防策として、事前準備は欠かせないと思っています。

リリース時に意識したこと

リリース計画書の用意はもちろんですが、一番大事なのは「みんなでリリース作業を見守ること」だと思います。

過去に夜中に一人でリリースして苦い思いをした経験があったので、今回は何人かに起きていてもらえるようお願いしました。一人いるだけで心理的安全性が段違いです。その代わり、メンバーには進捗を適宜共有することが大事です。

また、リリース計画以外のことは絶対にしないと決めていました。 予定より早く終わったからといって次のステップに進めたり、追加で関係のないコミットを含めたりはしない。

小さなアクションが大きなインシデントにつながる可能性があると、経験上思っています。

リリース後に不具合発生

本番ブランチにマージして反映を確認したところ、一部のケースで画面が表示されない不具合を発見しました。(さすがに焦りました😅)

幸いメンテナンス時間内だったので、落ち着いて原因を特定し、急いで修正PRを作成して反映し直しました。残り時間を起きているメンバー全員で触ってもらい問題ないことを確認しました。

そして…

無事にリリースが完了しました!🎉

これだけの変更ファイルを一気にマージするのは神経を使いますね...
その日は全く眠れなかったです(笑)

リリース完了を祝う画像

🚨 リリース後の運用・監視体制

リリース後1〜2週間は、エラーや不具合が発生していないか監視画面を常にチェックしていました。
New Relic、Vercelのログ、Auth0のログ、Supabaseのログを使って監視していました。

オブザーバビリティをもう少し改善しておけばよかったなと思いつつ、一旦は地道にログを見に行くようにしました。怪しいログがあれば、すぐにタスクを作成して調査・修正しました。

テストの甲斐もあり、緊急の不具合は発生せず、細かい修正のみで済みました。

2週間ほど経って、ようやくひと段落しました🍵

🏃‍♂️ プロジェクトを終えて

プロジェクトを振り返ると、かなり濃い期間だったと思っています。

エンジニアリングの挑戦はもちろん、いつもと違う領域での挑戦、少人数でのチームワークなど、今まで経験したプロジェクトの中でも上位に入るぐらい、全力を出して走り切れたんじゃないかと思います。

既存ユーザーからリニューアルに対する多くのポジティブなコメントをいただき、そして「合格報告」もいただき、必死にやって良かったと思いました。

今回触れなかった部分や葛藤、苦労話などもありますが、無事に終えることができました!

👀 おわり

最後まで読んでくださり、ありがとうございました!☺️
この記事を通して、少しでもお役に立てば幸いです!

個人ブログでも「技術選定に関すること」や「最新技術の分析・深掘り」など学びや知見を発信しています。もしご興味のある方はこちらからご確認いただけますと幸いです!
https://techbuild.app/blog

Discussion