ジャンプTOON Web アプリケーションの全体像〜採用技術と開発方針〜

目次

  1. はじめに
  2. Next.js × GraphQL のサーバー間通信
  3. Fastly でのコンテンツ配信とキャッシュ方針
  4. vanilla-extract の採用とスタイリングシステム
  5. Chromatic による UI テスト / UI レビュー
  6. Datadog でのサービスモニタリング
  7. ジャンプTOON の CI/CD
  8. リリース戦略とブランチ運用の工夫
  9. おわりに

はじめに

ジャンプTOON の Web 版(以降、ジャンプTOON Web)の開発を担当している2024年度新卒入社の鏑木 俊樹(かぶらき としき) @tosssssy_ です。

5 月にサービスを開始した「ジャンプTOON」は、オリジナル縦読みマンガ作品や人気作品のタテカラー版を連載する、ジャンプグループ発の新サービスです。

ジャンプTOON Web では Next.js App Router (v14.2)を採用して開発をしており、開発中に得た知見などを前半と後半 2 つの記事で発信します。前半(本記事)は「ジャンプTOON Web アプリケーションの全体像〜採用技術と開発方針〜」、後半は「ジャンプTOON Next.js App Router の活用〜得られた恩恵と課題〜」です。

前半の本記事では、ジャンプTOON Web の全体像として、採用技術や開発方針を中心にご紹介します。(2024年8月時点)

Next.js × GraphQL のサーバー間通信

ジャンプTOON では、クライアント・バックエンド間の通信手段として GraphQL を採用しています。

これにより、クライアントの UI に沿った柔軟なデータ取得の実現や、サーバーへのリクエスト回数の削減が容易になります。

ジャンプTOON Web では、 Next.js サーバーに GraphQL サーバーとの通信の責務を持たせる方針にしています。データ取得は React Server Components (以降 RSC)で行い、ミューテーション系の処理は Server Actions で行っています。そのため、GraphQL サーバーへのリクエストは全て Next.js サーバーに集約されています。

サーバー間通信のイメージ
サーバー間通信のイメージ

React Server Components でのデータ取得

データ取得の際の GraphQL クライアントとしては urql を採用しています。

RSC でデータ取得する方針では、従来の GraphQL クライアントの責務が減ります。

  • 状態管理 → React の Suspense が担当し、データ取得中はサスペンド状態で表現します。
  • キャッシュ管理 → Next.js のキャッシュ機構(fetch の拡張)が担当します。

そのため、GraphQL クライアントの責務としては、「Node.js で動作する http リクエスト機能」「fetch オプションをリクエスト毎に設定できる機能」があれば十分と判断し、軽量で拡張性の高い urql を採用しました。

GraphQL クライアントの責務が減り、よりシンプルなデータ取得が行えることは RSC でデータ取得する大きなメリットの一つであると感じています。

実際にデータ取得する際は、GraphQL の Fragment Colocation を活用し、ページ毎に 1 つのクエリを組み立てています。

トップページ (https://jumptoon.com/)

トップページ


const TopPageQuery = graphql(`
  query TopPageData($hasSession: Boolean!) {
    topComics {
      ...TopComics
    }

    rankings {
      id
      name
      comics(first: 5) {
        ...ComicItemList
      }
    }

    feeds {
      ...FeedListItem
    }

    banners {
      ...BannerItem
    }

    userCoin @include(if: $hasSession) {
      ...UserCoin
    }
  }
`);

GraphQL の Fragment Colocation の活用や設計方針などについては、後半記事「ジャンプTOON Next.js App Router の活用〜得られた恩恵と課題〜」をご覧ください。

Server Actions でのミューテーション

ミューテーション系の処理やリアルタイム性が求められる一部のデータ取得処理は Server Actions で行っています。

ユーザーのコメント投稿処理の例


"use server"

const CreateCommentMutation = graphql(`
  mutation CreateComment(
    $input: CreateCommentInput!
  ) {
    createComment(input: $input) {
      inputError {
        text
      }
    }
  }
`);

export const createComment = async (
  variables: CreateCommentMutationVariables
) => {
  const result = toApiResult(
    await getClient().mutation(CreateCommentMutation, variables)
  );

  if (!result.errors) {
    revalidateTag(revalidateTags.commentList);
  }

  return result;
};

GraphQL のミューテーションを上部で定義し、 urql から生成した getClient に渡しています。

Server Actions の返り値はシリアライズ可能で無ければいけないので、 toApiResult でシリアライズが可能な値に整形しています。

ユーザーが作品にコメントした際はデータをリフレッシュする必要があるため、revalidateTag を発火してキャッシュを破棄しています。

上記は一例ですが、おおよそこのような流れでミューテーション処理を実装しています。

Fastly でのコンテンツ配信とキャッシュ方針

ジャンプTOON では、 CDN として Fastly を採用しており、 CDN キャッシュを活用してコンテンツ配信を行っています。本セクションでのキャッシュは基本的にこの Fastly の CDN キャッシュを指します。

ここではジャンプTOON Web のページ毎のキャッシュと静的アセットファイルのキャッシュ方針についてまとめます。

ページ毎のキャッシュ

ジャンプTOON Web のユーザーは、ログイン済みと未ログインの状態に分かれます。

未ログインの時は全てのページのレンダリング結果(主に HTML + RSC ペイロード)をキャッシュし、ログイン済みの時は基本的にキャッシュした上で動的な部分は SPA でリアルタイムに更新するのが理想です。コンテンツの読み込み速度や運用コストを考慮すると、できる限りキャッシュ効率を高めることが重要です。

ただ、それらを実現することは難しいのが現状です。なぜなら、データ取得を RSC に寄せたことで、 RSC 内でログイン情報(Cookie)を取得し、ログイン済みなら GraphQL サーバーへのリクエストヘッダー部分に Cookie を含める必要があるからです。Cookie を取得する際に Next.js の Dynamic Functions である cookies 関数を実行する必要があるため、ログイン状態に関わらずページ全体が動的レンダリングになってしまいます。よって、レンダリング結果のキャッシュができません。

その問題に対して、ジャンプTOON Web では未ログイン時の時に限りミドルウェアで Cache-Control ヘッダーにpublic, max-age=0, s-maxage=60, stale-while-revalidate=60, stale-if-error=86400 を設定し、 60 秒間オリジンにリクエストが飛ばないようにしています。ただ、ハック的な実装になってしまうためできる限り Next.js に準拠した方法でこれを実現することが望ましいです。

最後に理想と現状のレンダリング結果のキャッシュ状況をまとめると以下のようになります。

理想 現状
未ログイン時 全てのページをキャッシュする 全てのページをキャッシュしているが、ミドルウェアで制御しているので望ましくない
ログイン時 基本的にキャッシュした上で動的な部分は SPA でリアルタイムに更新する 全てのページがキャッシュされていない

静的アセットファイルのキャッシュ

静的アセットファイルもキャッシュ効率を高める工夫をしています。

Next.js では public ディレクトリから静的ファイルが配信できる機能がありますが、 デフォルトでCache-Control ヘッダーにpublic, max-age=0 が設定されており、キャッシュを上手く活用することができません

そのため、ジャンプTOON Web では public ディレクトリでの静的ファイル配信はほとんど使用せず、 Fastly を利用して https://static.jumptoon.com/* から配信しています。そのドメイン配下のファイルのCache-Control ヘッダーには public, max-age=31536000, immutable が設定されており、キャッシュを破棄しない想定になっています。また、キャッシュバスティング対応(画像が更新された場合キャッシュを無視して最新のものを読み込ませる対応)はファイル名にハッシュ値を付与することで行っています。

まとめると、ページ毎のキャッシュはデプロイの度に破棄されるのに対して、静的アセットファイルのキャッシュは基本破棄されることはありません。この工夫によりキャッシュ効率を高めています。

なお、上記で取り上げた静的アセットファイルとは、主に Web のソースコードに含まれる Local Image を指しています。 API から取得できる画像データや、漫画本体の画像データなどについては本記事では取り上げません。

vanilla-extract の採用とスタイリングシステム

スタイリングには vanilla-extract を採用しています。

選定背景

CSS ライブラリには無数の選択肢がありますが、SSR と TypeScript をサポートしている CSS in JS について分析されている andreipfeiffer/css-in-js を参考に、以下の点を重要視しました。

  • SSR サポートと Next.js との統合
  • TypeScript サポート
  • コード補完を備えた優れた DX
  • 軽量
  • 包括的なドキュメント
  • 直感的な API と簡単な学習曲線

候補としては Tailwind CSSCSS ModulesLinaria 等が挙がりましたが、最も高いレベルで上記の点を満たしていた vanilla-extract を採用しました。

vanilla-extract は TypeScript で型安全に記述できる CSS in JS で、ゼロランタイムであることが特徴です。スタイルは .css.tsファイルの中に JavaScript オブジェクトとして記述し、ビルド時には静的な CSS ファイルとして出力されます。TypeScript で記述する CSS Modules のようなイメージで、エディタの補完や型チェックの恩恵を最大限受けられるため、個人的にかなり開発体験が良いと感じています。

制限付き Style Props の実現

コンポーネントの呼び出し時にスタイルを Props に渡して上書きしたいユースケースはよくあると思います。 例えば Chakra UI では Style Props というスタイリングの手段を提供しています。これにより、直感的かつ効率的なスタイリングを実現できます。

Chakra UI 公式サイト Style Props から引用


import { Box } from "@chakra-ui/react"

// m={2} refers to the value of `theme.space[2]`
<Box m={2}>Tomato</Box>

// You can also use custom values
<Box maxW="960px" mx="auto" />

// sets margin `8px` on all viewports and `12px` from the first breakpoint and up
<Box m={[2, 3]} />

ジャンプTOON Web でも Style Props を採用し、さらに Props に渡せる値を厳格に制限にするために Sprinkles を採用しました。

Sprinkles は vanilla-extract のための CSS フレームワークであり、独自のユーティリティクラスを定義し、スタイリングに制限を付けることができます。また、ビルド時に静的にユーティリティクラスを生成することにより、全体の CSS のサイズが削減できることも大きなメリットです。

Sprinkles の CSS 出力イメージ(Sprinkles 公式サイトから引用)

.sprinkles_display_none_mobile__i8ksq0 {
  display: none;
}
.sprinkles_display_flex_mobile__i8ksq3 {
  display: flex;
}
.sprinkles_display_block_mobile__i8ksq6 {
  display: block;
}
.sprinkles_display_inline_mobile__i8ksq9 {
  display: inline;
}
.sprinkles_flexDirection_row_mobile__i8ksqc {
  flex-direction: row;
}

ジャンプTOON Web では、Sprinkles にデザイントークンやテーマを統合し、それを Style Props としてコンポーネントに渡せるようにしています。Sprinkles に設定した値以外は全て型エラーになるため安全です。

以下はジャンプTOON Web のソースコードの一部と Box コンポーネントの呼び出し例です。

sprinkles.css.ts

const vars = {/* デザイントークン */}
export const sprinklesProperties = defineProperties({
  conditions,
  properties: withImportant({
    // Layout
    display: ["none", "block", "inline", "inline-block", "flex", "inline-flex", "grid", "inline-grid", "contents"],
    position: ["static", "relative", "absolute", "fixed", "sticky"],
    top: vars.space,
    right: vars.space,
    bottom: vars.space,
    left: vars.space,
    zIndex: vars.zIndices,
    // Spacing
    paddingTop: vars.space,
    paddingRight: vars.space,
    paddingBottom: vars.space,
    paddingLeft: vars.space,
    marginTop: vars.space,
    marginRight: vars.space,
    marginBottom: vars.space,
    marginLeft: vars.space,
    ...
  }),
  shorthands: {
    // Spacing
    p: ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"],
    px: ["paddingLeft", "paddingRight"],
    py: ["paddingTop", "paddingBottom"],
    ...
  },
});

export const sprinkles = createSprinkles(sprinklesProperties);
export type Sprinkles = Parameters[0];
box.tsx

import {
  ComponentPropsWithRef,
  ElementType,
  ForwardedRef,
  forwardRef,
  PropsWithRef,
  ReactNode,
} from "react";
import { Overwrite } from "~/lib/types";
import { sprinkles, Sprinkles } from "~/styles/sprinkles.css";
import { cx, extractSprinkleProps } from "~/styles/utils";

const DEFAULT_ELEMENT = "div" satisfies ElementType;

type Props<E extends ElementType> = Overwrite<
  E extends keyof JSX.IntrinsicElements
    ? PropsWithRef<JSX.IntrinsicElements[E]>
    : ComponentPropsWithRef<E>,
 { as?: E } & Sprinkles // Style Props
>;

export const Box: <E extends ElementType = typeof DEFAULT_ELEMENT>(
  props: Props<E>
) => ReactNode = forwardRef(function Box<
  E extends ElementType = typeof DEFAULT_ELEMENT
>({ as, className, ...props }: Props<E>, ref: ForwardedRef<Element>) {
  const Element: ElementType = as ?? DEFAULT_ELEMENT;
  const [sprinkleProps, rest] = extractSprinkleProps(sprinkles, props);
  const classes = cx(sprinkles(sprinkleProps), className);

  return <Element ref={ref} className={classes} {...rest} />;
});

Box コンポーネントの呼び出し例


// 型安全に値を渡すことができ、 Sprinkles で定義していない値は型エラーになる
<Box marginTop={4}>Tomato</Box>

// Sprinkles で定義したショートハンドが使用可能
<Box maxW="3/5" mx="auto" />

// レスポンシブ対応
<Box p={{ base: 4, md: 8, lg: 12 }} />

このようにして制限付き Style Props を実現しています。

基本的にスタイリングは.css.tsに記述し、呼び出し側で指定したい marginpaddingmaxWidth などは Style Props 経由で渡すことで、保守性を保ちつつ効率的な開発をすることができています。

Chromatic による UI テスト / UI レビュー

UI テスト / UI レビューには Storybook 及び Chromatic を採用しています。 Chromatic とは Storybook のホスティング機能に加え、 Storybook で管理している UI コンポーネントの UI テスト(VRT)や UI レビューができるツールです。 これを CI に組み込むことで、 PR に紐づいた Storybook が自動でビルド & デプロイされ、さらに main ブランチとの UI のスナップショットの差分まで効率的にレビューをすることができます。

ジャンプTOON Web では、 UI 修正がある PR は Chromatic で変更分を必ずレビュー / 承認してからマージするフローになっており、効率的にレビューをしつつ、 UI のデグレを防ぐ工夫をしています。実際のレビュー画面は以下です。

Chromatic で UI 差分をレビューしている様子
Chromatic で UI 差分をレビューしている様子

試しにコインを購入するボタンの横幅を 2 倍にして押しやすくする変更をしてみました。上記の画像を見ると、左側には main ブランチの UI のスナップショットが表示されており、右側には 作成した PR のブランチの UI のスナップショットと、その差分が緑色で表示されています。どの箇所に差分が出ているかは一目瞭然で、 PR で行われた変更による影響範囲を一瞬で把握することができます。

また、他にも Chromatic にはコメント機能や Figma との連携機能などがあり、職種を超えて UI に関してのコミュニケーションを取る手段として活用することもできます。ジャンプTOON Web ではまだこの機能を使用できていませんが、将来的には活用していきたいと考えています。

Datadog でのサービスモニタリング

ジャンプTOON ではサービス全体のモニタリングを行うためのツールとして Datadog を採用しています。

Datadog とは、サービスの監視や運用管理を行うための SaaS 型ツールであり、APM(アプリケーションパフォーマンス管理)、ログ分析、リソース管理など、運用監視に必要な機能が包括的に提供されています。また、750以上のインテグレーションが存在し、システム、アプリケーション、サービスまで横断的に監視することができます。その機能の豊富さから社内でも導入事例が多く、ジャンプTOON でも採用に至りました。

ジャンプTOON Web では、アプリケーション内部で発生する通信のログ監視や、Fastly のキャッシュヒット率の管理などに Datadog を活用しています。Datadog は非常に多機能で様々な活用方法がありますが、中でもジャンプTOON Web で利用しているユニークな機能について紹介します。

まずはこちらをご覧ください。

Datadog のセッションリプレイ機能を利用している様子

※こちらは社内ステージング環境でのセッションであり、本番環境に影響は一切ありません。

Datadog にはセッションリプレイ機能があり、ユーザーの Web ブラウジング体験をキャプチャし、それを再生することができます。

上記の動画をみると、 CSS がうまく読み込めていない影響で画面のデザインが大きく崩壊してしまっていてユーザーが混乱する様子が記録されています。 エンジニアからするとかなりショッキングな動画です。

上記はかなり極端な例ですが、このように実際にユーザーが体験している画面と操作をみることで、ユーザーの Web ブラウジング体験を知ることができ、エラーが起きた際の影響範囲やユーザーのフラストレーションが溜まっている UI などをいち早く特定することができます。また、エラー分析以外にも、ユーザーの普段の漫画閲覧の様子などから新しい発見ができるのではないかと可能性を感じています。

ジャンプTOON の CI/CD

ジャンプTOON では、 CI/CD に GitHub Actions を採用しています。また、ジャンプTOON の App、 Server、 Web の GitHub リポジトリで共通で使用しているワークフローを管理するために、Repository RulesetsReusing Workflows を活用しています。

共通のワークフローには、 PR のタイトルやコミットメッセージのチェック、ラベルの管理、 Release Drafter によるリリースのノートの作成プロセスの自動化などが含まれており、 ジャンプTOON 全体で統一することで効率化を図っています。

リリース戦略とブランチ運用の工夫

ジャンプTOON Web では「ジャンプTOON の CI/CD」のセクションで述べた共通ワークフローに加え、開発に必要な各環境への自動デプロイや、リリース時のフローにおいても GitHub Actions を活用しています。本セクションではサービスのリリース戦略に合わせたブランチ運用やリリースフローについてご紹介します。

リリースの種類

ジャンプTOON では、常に複数の機能開発が並行に行われており、それらを安全にリリースするために各リリースごとに QA テストを実施して品質保証をしています。

ジャンプTOON Web では、リリースを下記のように分けています。

  • 定期リリース
    • 2 週間に1 回行う。
  • 機能リリース
    • 比較的大きめな機能開発が該当し、リリース日が決まっているもの。
  • 緊急リリース
    • 本番環境のバグなどを緊急で修正したもの。

これらのリリースを踏まえて開発するには、main ブランチに紐づく開発環境、PR 単位でのプレビュー環境に加え、リリース単位での QA 環境が必要であると判断し、それに沿ってブランチ運用ルールを制定しました。

ブランチ運用

先にブランチ運用の全体像を示します。
ブランチ運用の全体像

特徴的な部分としては、一般的な dev / stg ブランチが存在せず、main ブランチのみの運用になっている部分です。 main ブランチにマージできるものは QA 済みの qa ブランチ(qa- から始まるブランチ)に限定することで main ブランチが常にリリースが可能な状態になっています。

qa ブランチはリリース単位で作成されるブランチです。上述した定期リリース、機能リリース、緊急リリースごとに作成されます。 qa ブランチにはプレビュー環境の他に QA 環境が用意されており、 main ブランチにマージする前に動作検証や QA テストを依頼するのがルールになっています。

その他のブランチは qa ブランチに向けて作成され、命名は自由です。 qa ブランチは git push できないルールが設定されているので、実際に開発してレビューを受けるのはこのブランチになります。

上記のようなブランチ運用ルールを定め、 QA 環境を活用しながらリリースまでの開発を行っています。QA 環境があることで、並行している機能開発やその他の開発でリリース事故が起こりにくくなると感じています。

リリースフロー

main ブランチが常にリリース可能なことで、リリースフローはとても単純になります。GitHub Actions で高度に自動化されており、基本ワークフローの発火と確認作業が主になります。

main ブランチを本番環境にリリースする手順は以下の流れになっています。

  1. プレリリース用のワークフローを発火する
    • Release Drafter により Draft 状態のリリースノートが作成されます
    • 本番用の Docker コンテナが Cloud Run 上にプッシュされます(デプロイはされない)
  2. Draft 状態のリリースノートを latest release としてパブリッシュする
    • 今回のリリースの差分を見て担当者が確認をします
  3. GitHub Actions ページ上でリリース担当者以外からの承認を得る
    • 承認を得たら自動でデプロイされます。リリース完了です 🎉

おわりに

ここまでお読み頂きありがとうございます。

ジャンプTOON Web は開発当初から Next.js の App Router の機能をフル活用する想定で技術選定をし、開発を行ってきました。開発中には紆余曲折あったものの、大きな事故もなく無事に 5 月にリリースをすることができました。

ただ、 ページ毎のキャッシュができない問題をはじめ、まだまだ課題も残っているのが現状です。

本記事では採用技術や開発方針についてお伝えしましたが、後半記事では Next.js に焦点を当て、得られた恩恵や課題をまとめています。 App Router を意識したディレクトリ構成についても触れています。ぜひお読みいただけると嬉しいです!

後半記事「ジャンプTOON Next.js App Router の活用〜得られた恩恵と課題〜

 

ジャンプTOON 関連記事