こんにちは、映像サービスプロダクト本部の浜田(@narirow)です。
GYAO!では最近トップページの大規模な変更が行われました。本記事では映像サービスのバックオフィスを含む大規模な構成変更と、その成果として得られたスケーラビリティ・ページの表示速度の向上についてをお話しします。
GYAO!のトップページの特徴
映像サービスであるGYAO!のトップページは、豊富なラインアップの中から作品を厳選して掲載しています。有名作品をただ並べるだけではなく、レコメンデーションやターゲティングの技術を使って、閲覧者の趣向にあった作品を一覧しています。大量の画像が表示されていることに加え、縦に長いページ構成となっています。
課題と解決のアプローチ
このページを運用するにあたり、「APIの拡張性の低下」「表示パフォーマンス(Core Web Vitals数値)の悪化」が課題にあがりました。ここでは、その2点の課題をどういったアプローチで解決したかをご説明します。
1. APIの拡張性の低下
これまでは以下のように、BFF(Backends For Frontends *1)を使用したアーキテクチャを採用していました。クライアント側で必要となるデータ構造への変換をBFFが担い、各バックエンドサービスのデータを一カ所で統合する構成です。当初は順調に稼働していましたが、サービスの成長に合わせてBFFの責務が肥大化し、拡張性に課題が生まれました。
iOSアプリ/Androidアプリ/TVアプリ/Web/モバイルWebなど、多くのクライアントの表示要件すべてを担うモノリシックな構成となったAPI。この改修には時間がかかり、幾重にもデータ取得が走る構成ゆえ、複雑な最適化を必要としました。
BFFアーキテクチャ
APIの拡張性の解決:GraphQLの採用とマイクロサービスアーキテクチャ
この課題に対して、これまでのBFFを用いたRestAPIのアーキテクチャから、「GraphQL Gatewayを起点に各マイクロサービスアーキテクチャに接続する構成」に変更を行いました。
GraphQL Gatewayアーキテクチャ
今回採用したアーキテクチャの利点として、大きく以下の2点が挙げられます。
- (1)GraphQL Schemaを共通言語として、各マイクロサービスをシームレスに接続できる点
- (2)クライアントそれぞれの要件に応じた柔軟なクエリを発行できる点
(1)では、疎結合な複数のマイクロサービスが「Gateway」を起点にして接続されるアーキテクチャを採用しました。Gatewayサーバーは、各マイクロサービスでそれぞれ使用しているGraphQL Schemaを登録できる構成にしてあり、クライアントからのリクエストを透過的にマイクロサービスに接続する玄関口のような役割を担います。
すべてのサービス間でSchemaを共通言語として開発が開始されるため、API全体の不整合がおきにくく、チーム別の組織に合わせた柔軟な開発が出来るようになりました。
(2)は、GraphQL自体の特徴でもありますが、クライアントそれぞれの要件に応じた柔軟なデータ取得が可能となりました。RestAPIではすべてのデバイスごとに出し分ける責務をBFFが担っていましたが、GraphQLのクエリによって、その責務をクライアント側に分散させることが可能です。
さらにこのような大規模な構成変更に段階的かつ安全に移行するために、ReverseProxyを使用してパスごとに段階移行していく設計とし、問題が発生した場合はすぐに切り戻しができる体制を構築しました。
幸いにも現在まで切り戻しが必要なインシデントは発生しておりません。
この構成にしてからAPI拡張性が容易になり、機能追加のたびに発生する大量のデバイス確認の手間が減ったことで、デプロイ数が大きく向上しました。
2. 表示パフォーマンス(Core Web Vitals数値)の悪化
もう一つの課題は、ページの初期描画までの時間が長いことです。ページのレスポンスには800ms程度かかり、描画までを含めるとさらに多くの時間を要します。
表示パフォーマンス改善を行っていくにあたっては、以前から継続的に追っているSpeed Index(*2)の数値と、Googleが提唱するCore Web Vitals(*3)を主な参考数値としました。
Core Web Vitalsとは、ユーザーエクスペリエンスに影響する重要な表示パフォーマンス課題を数値化したものです。Core Web Vitalsの基準は状況に応じて毎年アップデートされていくことが案内されていますが、2020年時点の基準では、主要なコンテンツの表示時間、スクロールやクリックなどのユーザーの入力への反応性、表示レイアウトの安定性、の3つがメトリクスとして定義されています。
メトリクス名 | 概要 |
---|---|
LCP(Largest Contentful Paint) | 主要なコンテンツの読み込み時間 |
FID(First Input Delay) | ユーザーの入力からの応答までの時間 |
CLS(Cumulative Layout Shift) | 表示レイアウトの安定性 |
この指標を基準にすると、GYAO!のトップページではLCP(主要なコンテンツの読み込み時間)と、CLS(表示レイアウトの安定性)の数値がボトルネックとなっていることがわかりました。
表示パフォーマンスの悪化によって、ユーザーが映像に辿り着く前にページを離脱してしまう一因となっていたと考えられます。
これに対して、私たちは以下5点の改善に取り組みました。
- MessageQueueを使用したデータの非正規化による読み込み速度の高速化
- GraphQLを使用して初期画面に必要な情報のみを表示する
- WebPの採用による画像サイズの削減と遅延読み込み
- preloadによる初期画面に必要な画像の事前読み込み
- 高さ確保による広告レイアウトシフト改善と、不要なアニメーションの削除
LCPの改善1:MessageQueueを使用したデータの非正規化による読み込み速度の高速化
トップページに掲載される作品はそれぞれ配信期間を持っており、配信が終了した作品を並べてしまうことにならないよう、リクエストの段階で事前にチェックがかけられています。これまでこの処理には、BFF層でRedisを使用した多重のキャッシュ処理によってチューニングを行っていましたが、速度に限界値がありました。
これらを解決するために、各マイクロサービスでは、作品の情報更新が行われたタイミングでメッセージキューにイベントを発行し、そのイベントに応じてデータを非正規化する構成をとりました。データを非正規化する構成をとることで、更新に多少の遅延は発生するものの、各マイクロサービスがメタ情報の取得のために毎回リクエストを行う必要がなくなります。
これにより、データの読み込み速度を大幅に高速化できました。
MessageQueueによるデータの非正規化
LCPの改善2:GraphQLを使用して初期画面に必要な情報のみ表示する
LCP数値の悪化の主要原因は、ページ全体を表示するためのデータをまるごと受け取って描画する構成をとっていたことです。この構成のために、サーバー側での大量のコンテンツ処理と、クライアント側での肥大化したHTMLの表示処理が必要となります。
これらを解決するに当たり、「逐次読み込みで初期画面に必要なデータのみ読み込む構成」「Next.jsを使用したSPAの構成」に変更を行いました。
以前の構成は、ページ全体のリクエストを一度に取得する構成でしたが、GraphQLの柔軟なクエリによって、初期画面に必要な要素しかクエリを行わなわず、次の画面領域で必要なデータを逐次読み込むという構成が可能になりました。
実際のGraphQLをみてみると、初期画面で必要な分だけクエリしていることがわかります。次の画面で必要な分はまた別のクエリで取得します。
// 初期画面で必要なモジュールだけ取得し、スクロールすると別のクエリを発行
query CollectionTopPage($device: DeviceType!, $categoryId: ID!) {
page: categoryCollectionPage(deviceType: $device, id: $categoryId) {
pageTracking {
ultPage
}
featuredModule {
...FeaturedModule
}
....
}
alert: alertItem(deviceType: $device) {
...AlertItem
}
}
各モジュールでクリックしてから起動するような機能も同様に、クリックしてはじめてリクエストを生成します。この処理にはNext.jsのdynamic importを活用しました。
また、ページ上で必要でも、初期レンダリングに関係しない処理は requestIdleCallbackを使用して積極的に遅延読み込みを行います。
これらの改善によって、ページの初期画面の構成に必要なデータを約1/5に削減でき、APIの処理時間とDOM構築の時間を圧倒的に短縮することが可能となりました。
LCPの改善3:WebPの採用による画像サイズの削減と遅延読み込み
トップページには大量の画像を掲載しており、画像のファイルサイズは無視できないパフォーマンス課題の一つです。今回は画像の遅延読み込みを実現するともに、WebP形式(圧縮率の高い画像フォーマット)の採用を行いました。
上記の課題解決にあたって、各アプリケーションで共通で使用できる遅延読み込み用の画像表示ライブラリを作成しました。
このライブラリでは、画像表示のレイアウトシフト(後述)を抑制するとともに、画像が画面内に入ってからロードする遅延読み込み処理をクロスプラットフォームで実現します。
また、ブラウザがWebPに対応している場合は、このライブラリ内でWebP用の画像URLに変換を行います。画像をサーブするバックエンド側では、リクエストパラメータに応じて画像のファイル形式(WebP形式の画像かJPEGの画像か)を送信する構成をとっています。
これらの結果、全体で約20%程度のファイルサイズ削減につながりました。
LCPの改善4:preloadによる初期画面に必要な画像の事前読み込み
トップページでLCPに該当する最も大きな領域を占める要素は、トップページ上部の巨大な画像(ジャンボトロン画像)が該当します。LCPの数値改善には、この画像をなるべく早い段階で読み込むことが重要です。
今回は、Next.jsのnext/head
を使用して、ライブラリにpreloadのオプションを追加することで対応を行いました。ページのヘッダに<link rel="preload">
を挿入してコンテンツを事前に読み込むことで、LCPの値を改善できます。
{preload && (
<Head>
<link rel="preload" as="image" href={href} key={href} />
</Head>
)}
CLSの改善:高さ確保による広告レイアウトシフト改善と、不要なアニメーションの削除
CLS数値の悪化については、ページ全体でレイアウトシフトが発生していたことによります。レイアウトシフトとは、あとから読み込まれたモジュールによってページがガタついて表示されてしまう現象です。
レイアウトシフトの要因の一つとして、高さが固定できない遅延読み込み要素が挙げられます。その中でも、広告は高さが可変であらかじめ余白を確保しておくことが難しい場合があります。今回は幸いなことに、デバイス種別によって高さが固定されている広告であったため、デスクトップ環境とモバイル環境の場合で分岐を行って高さを確保しておくことにより、CLSとして換算される値を限りなく0にすることができました。
また、ユーザーのWeb Vitalsの値を計測した結果、広告以外でもトップページで多くのCLSが検知されていることがわかりました。
当初原因特定は難航しましたが、以下のようなブックマークレットを使用して、レイアウトシフトが発生している要素を特定しました。
javascript:try{new PerformanceObserver(e=>{for(const o of e.getEntries())console.log(o)}).observe({type:"layout-shift",buffered:!0})}catch(e){}
マウスホバー時にCLSが計測される様子
原因は、CSSのtransitionを用いてマウスホバー時に拡大アニメーションを行っていた処理が、意図せずCLSとして検知されていることでした。
CLSの値はユーザーが閲覧している間は積算されるため、小さなレイアウトシフトも積もり積もると結果として大きな数値悪化として算出されてしまいます。
調査をすすめたところ、アニメーションを行う要素に対して明示的に初期状態を宣言しないとアニメーション時にレイアウトシフトが発生することがわかりました。この場合、アニメーションする要素に対して、transform: scale(1.0)
の初期状態のスタイルを適用しておいてから、ホバー時のスタイルを適用することでレイアウトシフトの現象が改善します。
サービスに導入する際は最終的にデザイナーと相談し、ホバー時のアニメーション処理を外すことで対応を行いました。
結果
多くの構成変更を行ってきましたが、全体としてどの程度改善されたでしょうか。
結論からいうと、各パフォーマンスの指標を大幅に改善を行うことができました。
サーバーからの応答速度が約600ms向上
まずはページ全体を描画する構成をやめたことで、サーバーからの応答速度は平均で600ms高速化しました。
指標 | 以前の平均 | 刷新後の平均 | 結果 |
---|---|---|---|
サーバーからのレスポンス(TTFB) | 930ms | 330ms | 約600ms高速化 |
SpeedIndexが46%向上
ページの読み込みの指標として追っていたSpeedIndexは約46%向上しました。
指標 | 以前の平均 | 刷新後の平均 | 結果 |
---|---|---|---|
SpeedIndex | 約2302 | 約1207 | 約46%改善 |
Core Web Vitalsの数値向上
パフォーマンス計測ツールであるLighthouseで計測したDesktop画面のパフォーマンス値は、刷新前は約70前後が平均的な数値でしたが、90前後の数値となって改善しています。
メトリクス | 以前のスコア(平均) | 現在のスコア(平均) |
---|---|---|
First Contentful Paint | 2.3s | 1.2 s |
Largest Contentful Paint | 3.1s | 1.0 s |
Time to Interactive | 1.1s | 1.3 s |
Total Blocking Time | 60ms | 80 ms |
Cumulative Layout Shift | 0 | 0 |
(測定環境からの定点観測数値のため、あくまで参考値です。)
Search Console(Chrome UX Report)のスコア向上
また、レイアウトシフト(CLS)の改善を続けた結果、ChromeUXレポートで、Desktop環境で計測される不良なページはほぼなくすことができました。
さらなる改善に向けて
一方で、上記の結果からパフォーマンス観点のさらなる改善点もわかってきています。
モバイル端末の応答性を改善する
Lighthouseの計測で、TTI(Time to Interactive)とTBT(Total Blocking Time *4)の値が通常にくらべて高いことがわかりました。
この値が高くなると、主に処理パフォーマンスの低いモバイル端末のユーザーの操作性が課題となり得ます。
@next/bundle-analyzerを使用したモジュールサイズの分析と、Lighthouseによる計測で、ApolloClientがもっとも大きな影響を与えていることがわかっています。このライブラリは、GraphQLのクライントライブラリとして使用しているものです。
ApolloClientのバージョン3は柔軟で拡張性の高いキャッシュシステムを備えていますが、私たちのシンプルな用途では機能面で冗長な部分が存在します。今後の計画の中で、既にバックオフィスで使用しているアプリケーションでは実績のある、swrとgraphql-requestを用いた構成に変更し、バンドルサイズを縮小することを検討しています。
逐次読み込みにおけるレイアウトシフトを改善する
逐次読み込みにおいて、ページの最下部にあるフッターがレイアウトシフトとして計測されるようになりました。フッター要素が画面内に表示された直後に、コンテンツの読み込みが発生し、下方向に大きくずれてしまうためです。
こちらは逐次読み込みの完了後にフッターを表示する構成に変更することで、改善ができると考えています。
おわりに
GYAO!では長年の課題となっていたトップページで、GraphQL/Next.jsをメインに据えたアーキテクチャ刷新を行い、以前と比較して大きくページの表示パフォーマンスを向上することができました。
これら知見を生かして、トップページ以外のページでも高速化し快適なサービスを提供できるよう、引き続き改善を続けていく予定です。
- *1 BFFとは、Backends For Frontendsの略で、UIに表示するためのロジックを担うを統括して担うアーキテクチャを指します。詳細は以下をご確認ください。
- *2 Speed Indexの詳細は以下をご確認ください。
- Speed Index (web.dev)(外部サイト)
- *3 Core Web Vitalsの詳細は以下をご確認ください。
- *4 TTI(Time to Interactive)は、ページが操作可能になるタイミングを数値化した指標、TBT(Total Blocking Time)はメインスレッドの応答が阻害された時間を数値化した指標を指します。詳細は以下をご確認ください。
- Time to Interactive (web.dev)(外部サイト)
- Total Blocking Time (web.dev)(外部サイト)
こちらの記事のご感想を聞かせください。
- 学びがある
- わかりやすい
- 新しい視点
ご感想ありがとうございました