Timee Product Team Blog

タイミー開発者ブログ

RailsフロントエンドをNext.js(SPA)に移行した〜バックエンド視点での振り返り〜

好きな水風呂の温度は16℃でお馴染み edy69x です。

Timee Advent Calendar 2023 の16日目を担当します。

本記事では今年完遂したUIリニューアル(SPA化)を通してタイミーで実施した工夫や学びを普段バックエンドの開発を担当する私の視点からお伝えします。

先日のイベントでの登壇内容を補完した内容となっています。気になる方は下記資料もご覧ください。

speakerdeck.com

イベントの方はプロジェクト終盤での断捨離やリファクタリングなどがテーマになっていたので本記事ではプロジェクト進行過程全般での知見をシェアしていきます。

プロジェクト概要

まずプロジェクトの概要です。大雑把に言うとフロントエンドの技術基盤を移行しながらUIリニューアルを実施しました。

それだけだと「何のことだ?」となるので前提からご説明します。

タイミーでは単発のアルバイト求人の掲載を行う事業者用のWeb管理画面を提供しています。

リニューアル前はRailsエンジニアがテンプレートエンジンのSlimを活用し、フロントエンド開発も担っていました。Railsで作られたサーバは動的に生成したHTMLをレンダリングする責務も持っていました。いわゆる Server Side Rendering(SSR) です。タイミー創業当初から変わらない技術スタックで構成されていて価値も生み出し続けていました。

しかし、サービス拡大にともない、古くから存在する Model や Controller への継ぎ足しの実装などが生まれ、Railsアプリケーションのバックエンドとフロントエンドの密結合なロジックが増えたり Fat Controller に頭を悩ませられるシーンが発生し始めました。

一方のフロントエンドでは保守性や情報設計(Information Architecture)を始めとして品質向上を狙ったり、jQueryに代わるモダンWebフロント技術の導入、またフロントエンド専任のエンジニアの採用拡大も狙っていました。

上記の理由などからUIリニューアル及びフロントエンドの技術基盤を刷新するプロジェクトがスタートしました。

フロントエンドの技術は下記の構成で、Railsのリポジトリとは別で切り出しました。

  • 言語: TypeScript
  • ライブラリ: React
  • フレームワーク: Next.js
  • APIドキュメント: OpenAPI (Swagger)

Webページ構成やレンダリング手法もこのタイミングから Server Side Rendering(SSR) から Single Page Application(SPA) に変更しました。

いざ始まったUIリニューアル

UIリニューアルを進める上で不確実性を大いに孕んでいたので管理画面全体を一気にビッグバンリリース的に旧バージョンから新バージョンに切り替える方針は見送りました。

方針としてはハレーションを抑えるためにアクセス数の少ない画面から徐々にリプレイスしていき、顧客が最も利用する事業のコア機能を終盤にリプレイスする計画を立てました。

同時にDatadogやGoogleAnalyticsなどの監視・分析ツールを用いて、アクセス数の解析、利用デバイス比率の特定をしたり、売上規模の上位を占める事業者を選定し、移行に際して障壁となるポイントが無いかを探っていきました。

従来デザイン使い続けたい問題

進行する中で発見されたのが「従来のデザインを使い続けたい」というユーザーニーズです。

背景には、既存UI/UXに対してのメンタルモデルが形成されているので変化を忌避してしまったり、画面構成やボタン単位で指定オペレーションを厳密に組んでいるケースなどがありました。

強行突破する方法もあると思いますが、打ち手として新デザインのリリース後も従来の旧UIに切り替えられる仕組みを導入しました。デバイス変更やセッション切れ等で、従来デザインを選択したユーザーが新デザインに切り戻されることを回避したり、データ分析観点で活用するために従来デザインを選択した状態はDBに永続化しました。

プロジェクト終了時には削除する前提だったので明確さやアクセスのし易さを優先しました。フロントエンドは使用状況をAPI経由で取得する方針を採用しています。

[従来のデザインに切り替える] ボタンを配置

この仕組みのお陰でリリース後に利用者に多少影響があっても完全にロールバックすることなく、利用者ごとの判断に委ねることが出来ました。新デザインを許容できる利用者には新デザインを提供し、どうしても従来のバージョンを利用したい方には一定期間の従来バージョン利用を許容する運用をしました。

事前告知による期待値調整も勿論大事だと思うのですが、石橋を叩きすぎずにリリース後の ”動くソフトウェア” を通しての実際のユーザーのフィードバックから次の意思決定に繋げられるアプローチを経験できたのは大きな財産になりました。

画面移行の約80~90%程度が終わる頃には新デザインがマジョリティになったので、上記機能はほぼ役目を終え勇退しました。

影響範囲を限定する話での余談ですが、カナリアリリースの仕組みがあると影響を抑えられた障害などもあったので今思い返すと導入しておけると良かったです(プロジェクト中には追加出来なかったので今後導入していきたい)。

ユースケースロジックを切り出すFormObjectを活用

プロジェクトも佳境に差し掛かった中で、とあるエンドポイントに対応するAPIを実装する工程が最も難を極めました。タイミーのコアドメインに関わる機能を司っている Controller のリプレイスです。元々が Fat Controller だったケースでして既存の仕様理解や根本的なリファクタリングが求められたのを覚えています。

リクエストを受けてからHTMLを返却する一連の処理の中に、データベースへの書き込みや外部APIへの通信、ビジネスロジックに関係する処理など様々な責務を Controller が抱えていました。テスタビリティも低く、RSpecで書いていた controller spec では仕様は網羅されていませんでした。

その際に Controller の責務を一部切り出す方法として FormObject を採用しました。ユースケースに対応したロジックを FormObject クラスに閉じ込め、Controllerはクラスのパブリックメソッドの呼び出しとリクエストの受付、及び適切なレスポンスの返却に特化する様な作りにしました。

FormObject には単に移行するだけではなく「テストピラミッド」も意識し、テストケースの割合が結合テスト < ユニットテスト になるように必要に応じて責務の見直しも行いました。

テストピラミッド

結果的に Fat Controller は消え去り、元々はAPIリクエストの受付からレスポンスまでの過程を通して結合テストでカバーしていたテストケースをユニットテストで再現できるようになりました。Controller は RSpec の request spec にて受け付けるリクエストとレスポンスを明示し、副作用のうち代表的なケースをテストすることで不要に FormObject 側のユニットテストと重複することも回避しました。

Controller の上部に君臨し続けていたTODOコメントを削除できたときは非常に感慨深かったことを今でも覚えています。

古来からタイミーを見守ってきた長老の様なTODOコメント

プロジェクトの中断

これは少しオフトピック的な話ですがプロジェクト進行のメタ的な話として触れておきます。

一般的にプロジェクトのみならず仕事を進める中で優先度が高い別Issueが割り込まれることはありますよね。今回のUIリニューアルプロジェクトでも起きました。

プロジェクトの性質的には「技術改善」をテーマとし基本的に従来機能を踏襲しながらUIリニューアルを進行する前提でした。そのため、割り込み的な機能開発に対してスイッチングコストが発生したり、リニューアルとの優先順位付けが必要になったりと苦労した覚えがあります。移行期間全体が約3年あったうち合計1年ほどはチーム構成やミッションが根本から変化し、別テーマの開発を担当する時期もありました。

結果として生存期間が短い前提で作られた機構が想定より長く存在し続けることになりました。例えば先に述べたデザイン切り替えの仕組みや、Rails, Next.js それぞれのリポジトリが互いに依存する様な暫定的なロジックなどです。

当時の自分はイレギュラーに対してある種「当たり前」くらいで考えていたのですが、水面下ではコードベース上に認知負荷を高める要因が生まれていました。新規参画メンバーから質問が来ることもありましたが、当時は影響を理解したり、言語化することは出来ていませんでした。

今思い返すと内部品質低下に繋がることから、プロジェクト完遂までのリードタイムを短縮したり、テーマが変化する前に最低限の内部品質改善をするように働きかける余地はあったなと感じます。

プロジェクトが順調に進んだり完了することだけが学びではなく、むしろ想定外の中に自分の枠を超えた学びのきっかけがあると気付かされた瞬間でした。

プロジェクト完遂

過程では様々な事があり、ここでは語り切れないのですが3年の歳月を経てプロジェクトは完遂しました。RailsリポジトリからはHTMLレンダリングにまつわるコードやCSSライブラリなど5万行を超えるコードが削除されました。特に1つのPull Requestで1万行を超えるコード削除をした時は非常に爽快でした。

Webpackerやyarnなどにも別れを告げ、外部顧客向けの機能についてはRailsリポジトリはWebAPIの開発に特化する様な存在になっています。GraphQL化を進める様なチャレンジも生まれました。

WebFrontも1名でプロジェクトがスタートしましたが今では技術コミュニティが生まれたり、技術顧問の方が付いていたりと2020年当時と比べてだいぶ拡大しています。いい話ですね。

おわりに

プロジェクトが終われど、フロントエンドにとってはスタートラインに立ったとも言えますし、バックエンド側も保守性が多少増したとは言え、会社の成長を支える面でいうとまだまだ改善余地があります。

会社に興味がある方がいたら、タイミーワーカーで運営が成り立っているTHE 赤提灯という居酒屋が新橋オフィス近くにあるので是非飲みに行きましょう。

一応採用ページも貼っておきます。

product-recruit.timee.co.jp

忘れていましたが、声を大にして改めて言っておきたい。

「タイミーはSPA化やり遂げたぞ!!!!」

タイミーAndroidChapter式LADRを導入してみた

こちらはTimee Advent Calendar 2023シリーズ1の15日目の記事になります。

こんにちは、タイミーでAndroidエンジニアとして働いている @orerus こと村田です。

私は現在タイミーのAndroidChapter(弊社は特定領域のメンバーの集まりのことをChapterと呼称しています)の一員で、喜ばしいことに来年早々にメンバーが大きく増加する予定です・・・!

以前はAndroidChapterのメンバーが全員同じチームで開発を行っていましたが、タイミーのエンジニア組織拡大に伴い全員が異なる開発チーム(弊社ではSquadと呼称しています)に所属する形へと変化しました。その為、各SquadにはAndroidエンジニアが少人数しか所属しておらず、Androidアプリに関するPRレビューについてはSquadを跨いで行うことになります。必然、故も知らないPRレビューが飛んでくる為、PRレビューの負荷が上がり工数的にも心理的にも辛いものとなってしまいました。

そこで今回は、そういった組織のスケールに伴い発生する課題解決の為の施策の一環として、弊社AndroidChatperで導入した独自のLADR(Lightweight Architecture Decision Records)について紹介したいと思います。

また、導入して得た学びや見えてきた改善点についても共有できればと思います。

施策の背景、課題

先述の通り、タイミーでは現在Androidメンバーが全員異なるSquadに所属し、それぞれが異なるプロダクトゴールに向けた機能開発を行っています。

AndroidChapterで毎日10分程度のデイリーMTGは行っていますが、そこでは現在取り組んでいるもの・これから触るコード領域、困りごと等の共有に留まります。(各Squadのデイリースクラムもある為、あえて軽い内容にしてあります)

そのような状態で開発サイクルを回す中で、いくつかの大きな課題が見えてきました。

  • PRレビュー
    • レビューに入る前に、PRが行っている開発内容を把握する為の多大なコンテキストスイッチコストがかかる
    • コンテキストを十分に理解できていない為、仕様や設計の妥当性の判断が困難で表面的なレビューになりがち
    • PRという完成形で初めてレビュー依頼が飛んでくる為、仕様や設計で致命的な問題があった場合に後戻りが難しい(修正コストが高い)
    • PRレビューコストが高い為、PRレビューを行う際にまとまった時間を確保する必要がありレビュー完了までのリードタイムが長くなる
  • 改修/影響調査
    • 昔のコードや自身が触っていないコードについて、開発当初の意思決定根拠や設計意図が分からず、影響調査に時間がかかったり変更妥当性の判断が難しい

そこで、これらの課題を解決するために実験的に導入を始めたのが独自LADRでした。

独自LADRについて

ここでは我がAndroidChapterで定義している独自LADRについて紹介します。なお、ここでいうLADRは弊社AndroidChapter用に独自改変したLADRですが、AndroidChapter内では単に「LADR」と呼称している為、この記事でも以後は単にLADRと記述します。

そんなAndroidChapterで導入している独自LADRですが、株式会社ラクスの鈴木さんが公開されているLADR-templateをベースにさせていただいています。
また、鈴木さんの記事にある通り、LADRの本来の用途はアーキテクチャ選定において「導入しなかった」記録を残す為であると認識していますが、AndroidChapterではLADRの目的や対象をそこから拡大解釈して利用しています。

目的や対象を具体的にどのように拡大解釈しLADRを運用しているかについては、実際に私が作成した「LADRを導入する為のLADR」を以下に添付しますので、こちらをご覧いただければ雰囲気が掴めるかと思います。(「Context - 文脈」「Specification - 仕様、やること、やらないこと」の項目のみご覧いただければ十分です)



ご覧の通り、LADRのドキュメント自体は軽量なものになっている事が分かるかと思います。ここがLightweight形式を採用した最大の理由でして、ハードルを下げることでまずはこのドキュメントが書かれることを一番の狙いとしています。

LADRのサンプル

先ほどお見せしたLADRは機能開発を伴うものではなかった為にいくつか項目を省略してますが、機能開発時のLADRでは必要に応じて詳細な仕様を記述したり、実装時の設計についても触れています。

以下、実際に作成されたLADRのサンプルです。(雰囲気が伝わるようにシンプルなものと重めなものを抜粋しています)


サンプルA. 機能追加のためのLADR



サンプルB. 不具合修正のためのLADR



このようなLADRを作成した後、AndroidChapterにLADRのレビュー依頼をPRとして投げ、そこでLADRに書かれたコンテキストや仕様、設計について開発着手前にレビューを受けるようにしています。

LADR導入の経緯

先述のLADRについての記事を拝見したときに「これだ!」と思い、すぐさま「LADR導入の為のLADR」、および「LADRのテンプレート」を作成し、AndroidメンバーにPRという形で投げつけました。LADRについて解説するより、まず実物を見せ、そこから話をするのが早いと感じた為です。

PR上でLADRの狙いや、他ドキュメントとの棲み分けについてなどの多少のラリーはありましたが、好意的に受け入れられすんなりと実験導入が始まりました。

運用を初めて2ヶ月程度ですが、現在以下の18個のLADRが作成されています。



導入後の成果と学び、および見えてきた改善点

AndroidChapterメンバーからのFB

まずは2ヶ月運用してきて、AndroidChapterのメンバーはどう感じているのかを探るためにアンケートを実施してみました。
アンケート結果について、AndroidChapterメンバーの中でLADR作成経験済み、作成機会がまだ無い状態の2人の回答を紹介します。

「ありのままを記事に書きたいので率直に書いてね」とお願いをしておいたので、きっちりと本音が書かれているかと思います・・・!
※なお、アンケートの設問はChatGPT先生にご協力願いました

その結果がこちらです。

まずは分かりやすく数値で評価してもらいました。



一番右の私の回答分はバイアスがかかりまくっているのでスルーして、他メンバーの評価は以下のような感じです。

  • 満足度はどちらも7と「やや良い」点数
  • プロセスの効率化や課題解決度合いの感じ方はLADR作成機会があったメンバーは全体的に高め、機会が無かったメンバーは低め
    • Bの方が「LADR導入によって解決された課題の効果度」の項目の3点について、「まだ自身で作成したことがないために自分事感が薄い」とのFBをもらっています

次に、フリーテキストで回答してもらったものを原文ママで掲載します。
※2人分の回答をマージしています

まずは導入による成果についての質問についてです。



こちらにより以下のことが言えそうです

  • A、Bの両者ともに「コンテキスト共有」については効果を明確に感じている
  • LADRを作成する機会があったメンバーは仕様/思考の整理に効果を感じている
  • LADRを作成する機会がなかったメンバーもPRレビューの効率化には効果を感じている
    • ただし、自身でLADR作成を行っていないと課題解決の実感が薄い

次に改善点についての質問です。



こちらにより以下のことが言えそうです

  • LADRの制度自体の課題点はまだ発生していない
  • 一方、ドキュメントの参照のしやすさ、ドキュメント作成のオンボーディングといったLADRの活用方法についての改善点が挙がっている
    • この点は当初から想像していた懸念点であった為、今後の明確な改善点だといえる
  • 組織がスケールしてもコンテキスト共有に関する課題解決の効果が見込めそう

自身の振り返り

また、私自身が感じているLADRのメリットデメリットも軽く挙げておきます。

メリット

  • 何にせよ意思決定のログが残るようになった
    • メンテを保証できない仕様書にするより、意思決定のログと割り切って扱っている
  • PR作成時のコンテキスト共有が楽になったように感じた
    • 「実装→ドキュメントを書く」が「ドキュメントを書く→実装」と順序が変わったのが大きいのかもしれない
  • 設計段階の指摘や懸念点の伝達が事前にできるようになった
  • 実装者以外のメンバーのコンテキストの理解度合いが深くなったように感じた

デメリット

  • LADRの作成の工数がかかる
    • 以前もPR本文に一定のコンテキストを書いていたので、劇的に工数が増加した訳では無い
  • LADRのPR、機能開発のPRと、レビューが必要となるPRの数が増える

学びと改善点のまとめ

以上から、学びと改善点について乱暴にまとめると以下のようなことが言えるかと思います。

  • 課題解決に一定の効果は確実に出ており、デメリットを上回るメリットがあるといえる
  • LADRドキュメントの参照性に課題がある
  • LADRドキュメントを作成するまでのハードルがまだ感じられる
    • オンボーディング時に1度作成してもらう等のケアが検討余地としてありそう

当初抱えていた課題は解決できたのか

最後に、当初挙げていた課題がどの程度解決できたかの所感をまとめます。

  • 課題1: レビューに入る前に、PRが行っている開発内容を把握するためのコンテキストスイッチコストがかかる
    • 解決状況: ✅ 完全に解決
  • 課題2: コンテキストを十分に理解できず、仕様や設計の妥当性判断が困難
    • 解決状況: ✅ 完全に解決
  • 課題3: PRという完成形で初めてレビュー依頼が飛んでくる為、仕様や設計で致命的な問題があった場合の修正コストが高い
    • 解決状況: 🤔 様子見
    • 現状では問題なし。設計難易度の高い開発での対応を今後注視
  • 課題4: PRレビューコストが高く、レビュー完了までのリードタイムが長い
    • 解決状況: 🟢 一定の解決
    • PRレビューコスト減少、ラリーの回数も減少
  • 課題5: 昔のコードや自身が触っていないコードの開発意図が分かりづらい
    • 解決状況: 🟢 様子見、ただし解決の兆しあり
    • まだ運用期間が短く見返す機会が少ないが、LADRのルールを見直す際に当初作成したLADRドキュメントが役立った

結論として、全体的には解決に向かっていると感じています。ただし、組織のスケールアップに対する対応として導入した施策であるため、今後も組織がスケールした際の変化に注視しつつ、より良い形へと進化をさせ続けていく所存です。

dbt jobの分割方針について考えてみた

Timeeのカレンダー | Advent Calendar 2023 - Qiitaの12月13日分の記事です。

はじめに

こんにちは
okodoooooonです

dbtユーザーの皆さん。dbtモデルのbuild、どうやって分割して実行してますか?
何かしらの方針に従って分割をすることなく、毎回全件ビルドをするような運用方針だと使い勝手が悪かったりするんじゃないかなあと思います。

現在進行中のdbtのもろもろの環境をいい感じにするプロジェクトの中で、Jobの分割実行について考える機会があったので、現状考えている設計と思考を公開します!(弊社は一般的なレイヤー設計に従っている方だと思うのでJob構成の参考にしやすいと思います)

この辺をテーマに語られていることってあんまりないなあと思ったので、ファーストペンギンとして衆目に晒すことで、いい感じのフィードバックをもらえたらなーと思ってます。

弊社のデータ基盤全体のデザインについて把握してからの方が読みやすいと思うので、そちらをご覧になりたい方はこちらの記事からご覧ください。

これまでの弊社のデータ更新周りについて

これまでの弊社のdbt Job設計の概念図を作るとしたらこんな感じになります。

  • 走っているクエリがフォルダ単位/モデルファイル単位などバラバラな粒度で実行されている
  • リネージュを意識したJobの走らせ方になっておらず更新頻度の噛み合わせが悪い
  • 明確なルールがないので意図しないタイミングで同じモデルがビルドされたりする
  • 明確なルールがないのでビルドから漏れているモデルがひっそり存在している

dbtコマンドで示すとこんな具合のカオス感

dbt build --select models/path_to_model_dir1/model1-1.sql
dbt build --select resource_name
dbt build --select models/path_to_model_dir2/model2-1.sql
dbt build --select models/path_to_model_dir3
....

改善のための試行錯誤 v1

  • レイヤーごとにtagを付与して以下のようなビルドコマンドが走らせられるようにしました。
dbt build --select tag:staging_layer
dbt build --select tag:dwh_layer
dbt build --select tag:dm_layer
  • 更新頻度tagの概念を設計して、以下のようなビルドコマンドを走らせられる構成を検討しました。
dbt build --select tag:hourly_00 # 毎時00分にビルドするものに付与するタグ
dbt build --select tag:daily_09 # 毎日9時にビルドするものに付与するタグ

これらの設計をしてみて、レイヤー単位でJobを走らせることを可能にできたのと、更新頻度をdbt上で管理可能な形にはできました。

しかし、ユーザーのデータ更新ニーズに基づいたJob設計になっていないことから、ユーザーニーズベースでのJob管理方針の検討を進めました。

データ活用ユーザーのデータ更新ニーズの整理

弊社のレイヤー設計とデータ活用ユーザーのアクセス範囲は以下のような形になります。

  • アナリストはステージング層以降の層に対するアクセス権を保持していますが、現状アナリスト業務はモデリング済みテーブルではなく、3NFテーブルに対するクエリを作成する作業が支配的です(モデリング済みテーブルの拡充とその布教をもっと頑張りたい)。
  • データ組織外の社内ユーザーはデータ組織が作成したアウトプットを通して分析基盤のデータを活用できるような状態になっています。
    • ※弊社内ではデータ組織以外のユーザーにBigQueryのクエリ環境を開放しておらず、LookerやLookerStudioなどのアウトプットを経由して社内データにアクセスする状態にしています。

それぞれの層ごとの現在顕在化しているデータ更新ニーズを整理すると以下のようになります。

  • アナリストと野良アウトプットによる「ステージング層のなるはや更新ニーズ」
    • アナリストのアドホッククエリ
      • 分析要件は予測ができず、要件次第では最新のデータが必要となるため、なるべく早く更新されることが期待されます。
    • マートから作成されずステージングテーブルから作られるアウトプット
      • 更新頻度ニーズのすり合わせなく作られて提供されるものも多く、要件次第では最新のデータが必要となるため、なるべく早く更新されることが期待されます。
  • データ活用ユーザーによる「アウトプット更新ニーズ」
    • データ活用ユーザーに提供するデータマート経由のアウトプットは更新頻度を擦り合わせられているものだけになっている(今はまだ数が少ないだけだが)ため、hourly,daily,weekly,monthlyなどの頻度ごとの更新設計が可能となっています。
    • 例えば弊社のよく使われるLooker環境はhourlyの頻度で更新が要求されています。

単純なジョブ設計をしてみる

単純なジョブ実行案① ステージングなるはや更新プラン

ソースシステムごとのデータ輸送完了後にそのソースシステムを参照しているモデルを全てビルドするプラン

  • コマンド例
    • salesforceのデータ輸送完了後のコマンド:
      • dbt build --select tag:salesforce_source+
      • dbt build --select models/staging/salesforce+
    • アプリケーションDBのデータ輸送完了後のコマンド:
      • dbt build --select tag:app_source+
      • dbt build --select models/staging/app_db+
  • 実装方法
    • airflowなど何かしらのオーケストレーションツールで転送完了をトリガーに下流一括ビルドのコマンドを実行する必要がある。

この場合の更新ジョブを概念図に表すと以下のようなものになると思います(上流から下流へのビルド時のデータリネージュを黄色の三角形で表現しています)。 メリデメをまとめると以下のようになるかと思います

  • メリット
    • 下流が全てビルドされるのでビルド漏れが発生しない。
    • 常にモデルの鮮度が最新になる = アナリストのstaging最新化ニーズは満たせている
  • デメリット
    • 更新がそこまで不要なモデルに対してまでビルドが実行される。
    • BigQueryのクエリ料金が高額になる

単純なジョブ実行案② ユーザーニーズに合わせるプラン

ユーザーのデータ更新ニーズに合わせて更新頻度をアウトプットごとに設定。更新ニーズの頻度ごとに最新化されるようなプラン

  • コマンド例
    • 1時間おきにビルドされて欲しいアウトプットを更新するコマンド: dbt build --select +tag:hourly
    • 1日おきにビルドされて欲しいアウトプットを更新するコマンド: dbt build --select +tag:daily

この場合の更新ジョブを概念図に表すと以下のようなものになります(下流から上流へのビルド時のデータリネージュを赤色の三角形で表現しています)。 メリデメをまとめると以下のようになります。

  • メリット:アウトプットの更新頻度が最適化される。
  • デメリット
    • stagingのモデル作成の重複が発生する。
    • 出力ニーズが存在しないstagingテーブルなどがビルド対象から漏れてしまう

ではジョブ分割をどのように設計するか

  • ステージングなるはや更新に合わせてリネージュの上流から実行すると下流で無駄なビルドが発生してしまう。
  • ユーザーニーズベースでリネージュの下流から実行すると上流で無駄なビルドが発生してしまう。

この二つの問題の折衷案を考える必要があると考えました。

現状のジョブ実行案

更新ジョブの概念図は以下のようなものになります(上流から下流へのビルド時のデータリネージュを黄色の三角形、下流から上流へのビルド時のデータリネージュを赤色の三角形で表現しています)。

  • ソースデータ ~ ステージング層のビルド
    • ソースデータが連携されたらステージング層までを下流に向かってビルド
    • コマンド: dbt build --select tags:source_a+ --exclude tags:dwh_layer tags:dm_layer
  • DWH層~アウトプットの間のビルド
    • ステークホルダーと定めた更新頻度に合わせて上流に向かってビルド
    • コマンド: dbt build —select +tags:daily_00_00 —exclude tags:staging_layer tags:datalake_layer

こうすることで、ステージング層までは常に可能な限り最新化されつつ、アウトプットは要件ごとに更新頻度が最適化された状態となります。

ただ、DWH層やデータマート層に多少のビルドの重複は発生してしまいますが、そこは許容しています。

本当はこうしたい案

アナリストがモデリング済みのDWH層を使うことが常態化するような世界観になってくると、上図のように「レイク~DWHまでは最新鮮度」「それ以降はユーザーニーズベースで更新」って流れがいいのかなと思っています。

また source_status:fresher+などのstate管理をうまく使って、更新があったものだけをビルドするような方式を模索していきたいです。

おわりに

dbtの環境リプレイスとともにこのJob設計も実戦投入しようと考えているので、想定していなかったデメリットが発覚したり、改善点が見つかったら改善していこうと思います!

弊社データ基盤でもストリーミングデータが取り扱えるようになったので、そのデータの使用が本格化すると ストリーミング✖️バッチ のJob構成などを考える必要があり、まだまだ俺たちの戦いはこれからだ。と思います

ていうかみなさんどうやって分割して実行してるの!!!教えてほしすぎる

We’re Hiring

タイミーのデータ統括部はやることがまだまだいっぱいで仲間を募集しています!興味のある募集があればこちらから是非是非ご応募ください。

product-recruit.timee.co.jp

ML基盤を再構築したはなし

こんにちは、タイミーのデータ統括部、DRE(Data Reliability Engineer)グループ & DS(Data Science)グループ所属の筑紫です。

DSグループではML基盤の構築・運用保守を担当しています。

本記事では、ML基盤を再構築した話を紹介したいと思います。

Timee Advent Calendar2023のシリーズ 2の12月13日分の記事です。

経緯と課題

DSグループでは、様々なプロジェクトのML基盤を構築しています。

当初は、1つのCloud Composerの上に、全てのpipelineを載せて運用していました。

以下に当初の構成のイメージ図を示します。

ただ、このML基盤についてはいくつか課題がありました。

本番環境しかない

本番稼働しているpipelineが複数ある中、環境が本番環境しかありませんでした。

Cloud Composerも本番のインスタンス1つしかなく、例えばCloud Composerのimageのupdateが必要な際もupdateして既存本番に影響がないか検証できる環境がない、などの問題が発生しており、保守に耐えられない状況でした。

サービスレベルの異なるpipelineが同じワークフロー上に乗っている

当時ML基盤の予測結果を社外に提供する取り組みも開始している中で、社内利用のML pipelineと社外利用のML pipelineが同じワークフローツール(Cloud Composer)上に乗っている状況でした。

ML基盤でもその結果を社内で使用するものと社外で使用するものでは、サービスレベルが変わってきます。

異なるサービスレベルを持つ複数のpipelineが一つのワークフローに乗っている状況では、あるpipelineの挙動が他のpipelineにも影響を及ぼし、サービスレベルに影響を与える可能性があるため、運用上好ましくありませんでした。

インフラ反映までのプロセスが長い

GoogleCloud環境はTerraformで管理していますが、そのリポジトリはDREグループ管理になっており、DSグループがリソースを作成する際に、以下の手順を踏む必要がありました。

  1. DSグループで修正のPullRequest(以降PR)を作成
  2. DREグループにレビューを依頼
  3. DREグループのレビュー通過後にマージ & デプロイされるので、反映結果を確認

このような手順を踏むので、

  • 検証のために、試しにリソースを作成したり変更したりする際に、都度PRを作成し、レビュー依頼しないといけない
  • 他チームのレビューを挟むので、GoogleCloudのリソースを作成するまで、リードタイムがかかりがち

という問題があり、DX(Developer eXperience)に影響を与えていました。

ソリューション

まず本番環境しかない問題については、開発環境を作り、Cloud Composerを本番環境、開発環境それぞれに配置することにしました。

それに合わせてGoogleCloudのprojectも分けることにしました。

1つのproject上に本番環境と開発環境を構成するよりも、分けた方が事故が起こりにくく、Terraform上の管理もしやすかったためです。

また、サービスレベルの異なるpipelineが同じワークフローに載っている問題について、サービスレベルが大きく異なるユースケースごとにCloud Composerを分けることにしました。

コストとのトレードオフになりますが、運用・保守のしやすさを考えると、社内で使うユースケースと社外などConsumerなどに提供するユースケースでCloud Composerを分けた方が良いという判断からこの構成にしました。

結果、下図のような構成になりました。

その上で、上記環境のTerraformのコードをDRE管理のリポジトリと分けて、DSグループ管理の別リポジトリで管理する方針としました。

また、ブランチ戦略を整理して、featureブランチにpushする度に開発環境にデプロイされることにしました。

これにより、デプロイ都度レビューを挟まないといけないフローが、featureブランチにpushするだけでデプロイされるようになり、検証までのリードタイムを改善できたと思ってます。

結果

最終的に、数ヶ月かけてこの新環境の構築とpipelineの移行を行いました。

結果、上記の3つの課題については、解消することができたと思ってます。

特に、開発環境ができたことと、DSグループ管理のTerraformリポジトリができたこと、featureブランチのpushでデプロイできるようになったことから、インフラの検証のしやすさは格段に上がったと思っており、DSグループ内のDX向上に寄与できたのではと思っております。

We’re Hiring

タイミーのデータ統括部では、データサイエンティスト、DRE、Data Analystなど、様々な職種のメンバーを募集してます!

https://hrmos.co/pages/timee/jobs

カジュアル面談からでも対応できますので、少しでも気になった方は申し込み頂けると嬉しいです!

今後の機能開発を快適にするために検索機能をリファクタリングした

こちらはTimee Advent Calendar 2023の13日目の記事です。

タイミーでバックエンドエンジニアをしている @Juju_62q です。

記事内でワーカーさんや事業者さんに関して敬称を省略させていただきます。

タイミーは雇用者である事業者に求人を投稿してもらい、労働者であるワーカーが求人を選ぶという形でマッチングを実現しています。ワーカーが求人を選ぶためにはなんらかの形でワーカーが自分にあった求人を見つけられる必要があります。検索はワーカーが求人を見つけるために最もよく使われる経路です。今回はそんな検索機能において今後の開発をスムーズにするためのリファクタリングを実施した話を紹介します。

続きを読む

dbtに関連する運用の自動化

この記事は Timee Advent Calendar 2023 シリーズ 3 の12日目の記事です。

qiita.com

はじめに

DREグループでデータエンジニアをやっている西山です。

今回は、データ転送まわりの運用自動化について書きます。

タイミーのアプリログが分析できる状態になるまでのリードタイムが長く、効果検証や意思決定に遅れが出ていた問題に対して、dbtに関連する運用を自動化することで改善しました。

タイミーでのアプリログの転送について

タイミーではS3に貯まったサーバーログを定期的にデータ基盤(GCPのBigQuery)へ転送しており、ログがLake層へ追加されるとdbt(data build tool)によって型変換等の加工処理がなされてStaging層へと転送されます。

アプリの機能追加によって新しいログテーブルが追加された場合、dbtの処理追加が必要になるため、GitHub Actionsで定期的にLake層とStaging層でログテーブルの差異をチェックし、Lake層に新しく追加されたテーブルがあれば処理追加を促す通知をslackへ送信していました。

通知を確認したら、以下の対応を行います。

  1. 新しく追加されたテーブルのスキーマを確認
  2. dbtでmodelを作成

    1. 他のログテーブルのmodelをコピー
    2. カラム名やキャスト処理を修正
     {{
         config(
             alias={{ テーブル名を書く }}
         )
     }}
     {% set column_lists = [ {{ カラム名を書く }}] %}
     {{ production_logs_template({{ テーブル名を書く }}, column_lists) }}
     , result_record AS (
         SELECT
             {{ カラム名を書く }},
             dre_transfer_at,
             ROW_NUMBER() OVER (PARTITION BY {{ カラム名を書く }}) AS row_number
         FROM 
             records 
     ), stg_record AS (
         SELECT
             {{ 各カラムの変換処理を書く }},
         FROM
             result_record
         WHERE
             row_number = 1
     )
     SELECT
         *,
         {{ set_record_exported_at(column_lists) }} AS dre__record_exported_at,
     FROM
         stg_record
    
  3. チーム内レビュー

  4. dbtのジョブを実行してStging層にもテーブルを追加

基本的には通知を確認次第、上記の対応を行う方針でしたが、他に優先度の高い障害対応や他チームからの依頼があると後回しになってしまうことも多く、1週間以内にStaging層へ新規ログテーブルを転送する取り決めとなっていました。

しかし、これだとログが分析できるようになるまでのリードタイムが長く、プロダクト開発チームの効果検証や意思決定に遅れが出てしまいます。

そこで、今回はこのリードタイムの短縮を目指すことにします。

実装案

アプリのログに関しては、dbtで実装する加工処理の内容がほぼ決まりきっているため、modelの追加が自動化できそうです。

(例)

  • 末尾にidが付くカラムはINT型へキャスト
  • 末尾にatが付くカラムはJSTのDATETIME型へキャスト

これまで新しいログテーブルを検知していたGitHub Actionsのツール内で、dbtのmodel追加のPRを生成することにします。

実装してみた

大まかにやったこととしては以下の通りです。

  • GitHub AppsによるAPIリクエストの準備
  • pythonで書かれている監視ツールの修正
    • dbtのmodel(sqlファイル)とtest(ymlファイル)の生成処理追加
    • GitHubへのリクエスト処理追加
    • slackへの通知内容修正

GitHub AppsによるAPIリクエストの準備

今回はログテーブルの監視ツールとdbtプロジェクトが格納されているリポジトリが異なったため、GitHub Appsを作成してAPI操作ができるようにします。

  1. 以下の手順に沿ってAppを作成 https://docs.github.com/ja/apps/creating-github-apps/registering-a-github-app/registering-a-github-app
  2. Appの秘密鍵を取得 https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps
  3. 認証に必要なINSTALLATION_IDを生成 https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation#generating-an-installation-access-token
  4. 3.で生成したINSTALLATION_IDを使ってinstallation access tokenを発行 今回は監視ツールに以下のクラスを追加しました。
import json
import os
from datetime import datetime, timedelta

import jwt
import requests

class GitHubAppTokenManager:
    def __init__(self) -> None:
        self.GITHUB_APP_ID = os.environ.get("APP_ID")
        self.GITHUB_APP_PRIVATE_KEY = os.environ.get("APP_PRIVATE_KEY")
        self.GITHUB_APP_INSTALLATION_ID = os.environ.get("APP_INSTALLATION_ID")

    def _generate_jwt(self) -> str:
        PEM = (
            self.GITHUB_APP_PRIVATE_KEY.replace("\\n", "\n")
            if self.GITHUB_APP_PRIVATE_KEY is not None
            else None
        )
        utcnow = datetime.utcnow()
        alg = "RS256"
        payload = {
            "typ": "JWT",
            "alg": alg,
            "iat": utcnow,
            "exp": utcnow + timedelta(seconds=60),
            "iss": self.GITHUB_APP_ID,
        }
        return jwt.encode(payload, PEM, algorithm=alg)

    def _generate_jwt_headers(self) -> dict:
        jwt_token = self._generate_jwt()
        return {
            "Authorization": f"Bearer {jwt_token}",
            "Accept": "application/vnd.github.machine-man-preview+json",
        }

    def _fetch_access_token(self) -> str:
        url = f"https://api.github.com/app/installations/{self.GITHUB_APP_INSTALLATION_ID}/access_tokens"
        response = requests.post(url, headers=self._generate_jwt_headers())
        response.raise_for_status()
        return json.loads(response.text).get("token")

    def generate_token_header(self) -> dict:
        token = self._fetch_access_token()
        return {
            "Authorization": f"token {token}",
            "Accept": "application/vnd.github.inertia-preview+json",
        }

ここまでできればAPIのリクエスト準備完了です。

dbtのmodelとtestの生成処理追加

GitHub APIをリクエストする前にコミット対象となるファイル生成の処理を追加します。

まずはmodelのsqlファイルとtestのymlファイルのテンプレートを用意します。

↓modelのテンプレートです。

dbtで使っているJinjaに反応して意図したところで値が置き換わらなくなるので、一部エスケープしています。

やっぱり多少見づらくなるので他に良い方法はないだろうか・・・

{{ '{{' }}
    config(
        alias='{{ table_name }}',
    )
{{ '}}' }}
{{ '{%' }} set column_lists = {{ column_list }} {{ '%}' }}
{{ '{{' }} production_logs_template('{{ table_name }}', column_lists) {{ '}}' }}
, result_record AS (
    SELECT 
        {{ select_list }},
        dre_transfer_at,
        ROW_NUMBER() OVER (PARTITION BY {{ partition_column_list }}) AS row_number
    FROM 
        records 
), stg_record AS (
    SELECT 
        {{ jst_converted_select_list }},
        dre_transfer_at,
        {{ '{{' }} jst_now() {{ '}}' }} AS dre_delivered_at,
    FROM
        result_record
    WHERE
        row_number = 1
)
SELECT
    *,
    {{ '{{' }} set_record_exported_at(column_lists) {{ '}}' }} AS dre__record_exported_at,
FROM
    stg_record

↓testのテンプレートです。ログの一意性を担保するテストを追加します。

version: 2
models:
  - name: TABLE_NAME
    tests:
      - dbt_utils.unique_combination_of_columns:
          combination_of_columns: COLUMN_LISTS

次に以下のクラスを追加します。

BQから追加対象テーブルのカラム名を取得してgenerate_dbt_file_info関数に渡すことで、先ほど作成したテンプレートを元にファイルが生成されます。

import json
import os
import re
import tempfile
from typing import Any, Dict, List, Tuple

from jinja2 import Environment, FileSystemLoader
from ruamel.yaml import YAML

class DBTModelFileGenerator:
    def _convert_column_with_at(self, column_name: str) -> str:
        return f"TIMESTAMP({column_name})"

    def _convert_column_with_id(self, column_name: str) -> str:
        return f"CAST({column_name} AS INT)"

    def _convert_utc_to_jst(self, column_name: str) -> str:
        return f"{{{{ timestamp_utc2datetime_jst('{column_name}') }}}}"

    def _generate_select_list(self, column_list: List[str]) -> str:
        converted_columns = []

        for col in column_list:
            if col.endswith("_at"):
                converted_columns.append(
                    f"{self._convert_column_with_at(col)} as {col}"
                )
            elif re.search(r"(_id$|^id$)", col):
                converted_columns.append(
                    f"{self._convert_column_with_id(col)} as {col}"
                )
            else:
                converted_columns.append(col)

        return ",\n        ".join(converted_columns)

    def _generate_jst_converted_select_list(self, column_list: List[str]) -> str:
        converted_list = []
        for col in column_list:
            if col.endswith("_at"):
                converted_list.append(f"{self._convert_utc_to_jst(col)} as {col}")
            else:
                converted_list.append(col)
        return ",\n        ".join(converted_list)

    def _generate_staging_sql(self, table_name: str, column_list: List[str]) -> str:
        select_list = self._generate_select_list(column_list)
        partition_column_list = ", ".join(column_list)
        jst_converted_select_list = self._generate_jst_converted_select_list(
            column_list
        )

        # テンプレートのロード
        file_loader = FileSystemLoader("テンプレートファイルのパス")
        env = Environment(loader=file_loader)
        template = env.get_template("template_stg_production_logs.sql")

        # テンプレートのレンダリング
        sql = template.render(
            table_name=table_name,
            column_list=column_list,
            select_list=select_list,
            partition_column_list=partition_column_list,
            jst_converted_select_list=jst_converted_select_list,
        )

        # tempdirにファイルを保存
        output_path = os.path.join(
            tempfile.gettempdir(), f"{table_name}_stg_production_logs.sql"
        )
        with open(output_path, "w") as tmp:
            tmp.write(sql)

        return output_path

    def _generate_staging_test(self, table_name: str, column_list: List[str]) -> str:
        yaml = YAML()
        yaml.indent(sequence=4, offset=2)

        with open(
            "テンプレートファイルのパス",
            "r",
        ) as template_file:
            template_content = yaml.load(template_file)

        template_content["models"][0]["name"] = f"{table_name}_stg_production_logs"
        test_definition = template_content["models"][0]["tests"][0]
        test_definition["dbt_utils.unique_combination_of_columns"][
            "combination_of_columns"
        ] = column_list

        output_path = os.path.join(
            tempfile.gettempdir(), f"{table_name}_stg_production_logs.yml"
        )
        with open(output_path, "w") as tmp:
            yaml.dump(template_content, tmp)

        return output_path

    def generate_dbt_file_info(
        self, new_table_and_column_names: List[Dict[str, Any]]
    ) -> List[Tuple[str, str, str]]:
        files = []

        for record in new_table_and_column_names:
            table_name = record["table_name"]
            record_dict = json.loads(record["record"])
            column_list = list(record_dict.keys())

            sql_file_path = self._generate_staging_sql(table_name, column_list)
            test_file_path = self._generate_staging_test(table_name, column_list)

            files.append(
                (
                    f"追加先のdbt modelのパス/{table_name}_stg_production_logs.sql",
                    sql_file_path,
                    f"Add staging SQL for {table_name}",
                )
            )
            files.append(
                (
                    f"追加先のdbt testのパス/{table_name}_stg_production_logs.yml",
                    test_file_path,
                    f"Add staging test for {table_name}",
                )
            )

        return files

GitHubへのリクエスト処理追加

以下のクラスを追加し、create_pr関数に前段で生成したファイルの情報を渡すことでPRが生成されます。

認証にはAPIのリクエスト準備で発行したinstallation access tokenを使います。

import base64
import json
import re
from typing import Any, Dict, List, Tuple

import jwt
import requests

from deleted_logs.adapter.output.github_app_token_manager import GitHubAppTokenManager

class GitHubPRCreator:
    def __init__(self, session_start_time: str) -> None:
        self.OWNER = "XXX"
        self.REPO = "XXX"
        self.BASE_BRANCH_NAME = "main"
        numeric_only_session_start_time = re.sub(r"\D", "", session_start_time)
        self.NEW_BRANCH_NAME = f"feature/add_new_table_of_production_logs_to_staging_{numeric_only_session_start_time}"
        self.github_app_token_manager = GitHubAppTokenManager()

    def _is_branch_present(self) -> bool:
        headers = self.github_app_token_manager.generate_token_header()
        branch_url = f"https://api.github.com/repos/{self.OWNER}/{self.REPO}/git/ref/heads/{self.NEW_BRANCH_NAME}"
        response = requests.get(branch_url, headers=headers)

        if response.status_code == 200:
            return True
        elif response.status_code == 404:
            return False
        else:
            response.raise_for_status()
            return False

    def _create_branch(self) -> None:
        if self._is_branch_present():
            return

        headers = self.github_app_token_manager.generate_token_header()

        # ベースブランチのSHAを取得
        base_ref_url = f"https://api.github.com/repos/{self.OWNER}/{self.REPO}/git/ref/heads/{self.BASE_BRANCH_NAME}"
        response = requests.get(base_ref_url, headers=headers)
        response.raise_for_status()
        sha = response.json()["object"]["sha"]

        # ブランチ作成
        branch_ref_url = (
            f"https://api.github.com/repos/{self.OWNER}/{self.REPO}/git/refs"
        )
        data = {"ref": f"refs/heads/{self.NEW_BRANCH_NAME}", "sha": sha}
        response = requests.post(branch_ref_url, headers=headers, json=data)
        response.raise_for_status()

    def _upload_file_to_repository(
        self, git_file_path: str, local_file_path: str, message: str
    ) -> None:
        headers = self.github_app_token_manager.generate_token_header()

        with open(local_file_path, "r") as file:
            content = file.read()

        encoded_content = base64.b64encode(content.encode()).decode()

        url = f"https://api.github.com/repos/{self.OWNER}/{self.REPO}/contents/{git_file_path}"

        # 同様のファイルが既に存在するか確認
        response = requests.get(url, headers=headers)
        sha = None
        if response.status_code == 200:
            sha = response.json()["sha"]

        data = {
            "message": message,
            "content": encoded_content,
            "branch": self.NEW_BRANCH_NAME,
        }
        if sha:
            data["sha"] = sha

        response = requests.put(url, headers=headers, json=data)
        response.raise_for_status()

    def _push_files(self, files: List[Tuple[str, str, str]]) -> None:
        self._create_branch()

        for git_file_path, local_file_path, message in files:
            self._upload_file_to_repository(git_file_path, local_file_path, message)

    def _generate_pr_title(self, table_names: List[str]) -> str:
        return f"production_logs新規テーブル({','.join(table_names)})追加"

    def _generate_pr_body(self, table_and_column_names: List[Dict[str, Any]]) -> str:
        header = "このPRはdeleted_logsによって自動生成されたものです。\n\n以下のテーブルのステージング処理を追加しています:\n"
        lines = []
        for record in table_and_column_names:
            table_name = record["table_name"]
            record_dict = json.loads(record["record"])
            columns = ", ".join(record_dict.keys())
            lines.append(f"**{table_name}**\nColumns: {columns}\n")
        return header + "\n".join(lines)

    def create_pr(
        self,
        table_names: List[str],
        column_names: List[Dict[str, Any]],
        files: List[Tuple[str, str, str]],
    ) -> str:
        self._push_files(files)

        headers = self.github_app_token_manager.generate_token_header()
        url = f"https://api.github.com/repos/{self.OWNER}/{self.REPO}/pulls"

        title = self._generate_pr_title(table_names)
        body = self._generate_pr_body(column_names)

        data = {
            "title": title,
            "body": body,
            "head": self.NEW_BRANCH_NAME,
            "base": self.BASE_BRANCH_NAME,
        }

        response = requests.post(url, headers=headers, json=data)
        response.raise_for_status()

        return response.json()["html_url"]

こんな感じのPRが生成されました。

※テストで作成したものなのでクローズしてます。

slackへの通知内容の修正

前段で生成したPRのURLを通知メッセージに追加します。

↓テストで飛ばした通知はこんな感じです。

ここで通知されたPRのレビューとマージを行うことで、Staging層のdbt modelが作られるようになりました。

まとめ

今回dbtのmodel生成を自動化したことで、2つの効果が得られました。

1つ目は、トイルの削減です。

これまで以下のフローで対応していましたが、

既存のdbt modelをコピペしてPR作成 → レビュー依頼 → マージ

自動化したことで、

PRを確認してマージ

だけででよくなりました。

元々大した工数はかかっていなかったものの、繰り返し発生する価値のない定型作業を減らすことができました。

そして2つ目は、Staging層へ新規ログテーブルが反映されるまでのラグ短縮です。

自動化したことで、他の対応でひっ迫している際に後回しにされることがなくなり、

これまではログ出力し始めてから分析可能になるまで最長7日かかっていたところ、導入後は大体1日以内で分析可能な状態になりました。

個人的には、タイミーにジョインするまでDevOps的なことがやれていなかったこともあり、運用は地味でつまらないイメージがあったのですが(すみません)、こうやって改善がまわせると運用も面白いなと最近思うようになりました。

We’re Hiring

DREグループではまだまだやっていきたいことがたくさんあるのですが、まだまだ手が足りておらず、ともに働くメンバーを募集しています!!

product-recruit.timee.co.jp

定例会議の議事録をNotion DBで構造化して、いい感じにした話

この記事は "Timee Advent Calendar 2023" の11日目の記事です。

qiita.com

こんにちは、タイミーのデータ統括部データサイエンス(以下DS)グループ所属の菊地です。 今回は、定例会議の議事録をNotion DBで構造化して、いい感じにした話を紹介したいと思います!

前提

  • タイミーでは社内ドキュメントツールとしてNotionを採用しており、私が担当しているプロジェクトで週1回開催される定例では、議事録をNotion DBとして管理しています。当初は以下のような定例議事録用テンプレートを作成して運用していました。
  • 定例の内容としては、プロジェクト進行上同期的に議論すべきアジェンダを定例出席者が持ち寄って議論し、決定事項とToDoを記載していくような内容となっています。

旧定例議事録テンプレート

上記の定例議事録で感じていた課題

上記の定例議事録用テンプレートから定例の議事録を作成して運用していた際に、以下のような課題を感じていました。

アジェンダ」パート

  • アジェンダが多いと一覧性が悪く、優先度の高いアジェンダを先に話すなどの判断がしづらい
  • アジェンダに関する議論が複数回にわたる際に、議論内容が複数ページに渡り、情報が追いにくい
  • アジェンダが多く、その回の定例で話せず持ち越しになった場合に、次回の議事録に転記する必要がある

「決定事項」パート

  • 何のアジェンダについて、どんな意思決定が行われたのかが追いにくい
  • ある決定事項がどこに書かれていたかを調べたい際に、決定事項が書かれた会の議事録のページを探し出す必要がある

「ToDo」パート

  • 前回の定例で決まったToDoを確認するために、前回の議事録を参照or次回の議事録に転記する必要がある
  • 進行状況が分からない
  • 期限が不明瞭

やったこと

上記で感じていた課題を解消するために、下記のように「構成要素」と「構成要素間の関係」をNotion DBで整理(構造化)し、合わせてNotion DBテンプレートページの作成を行いました。

アジェンダ」パート

  • アジェンダを管理するNotion DB(Agendas)を作成し、議事録ページからはLinkded viewで参照する

「決定事項」パート

  • 決定事項を管理するNotion DB(Decisions)を作成し、議事録ページからはLinkded viewで参照する
  • Agendas DBとrelationsを持たせる。

「ToDo」パート

  • プロジェクトのタスク管理で使用しているNotion DB(ここでは「Tasks」とします)をそのまま流用し、議事録ページからはLinkded viewで参照する
    • 議事録から参照する際は、定例で作成されたToDoタスクだと判別できるようにtagをつけてフィルターを設定して絞り込む
  • Agendas DBとリレーションを持たせる

Notion DBのリレーション

Notion DBのリレーションは以下のような構成になっています。

  • Meetings: 議事録を管理するNotion DBで、Agendas、Decisions、TasksをLinked Viewで参照する
  • Agendas: 会議のアジェンダを管理するNotion DB
  • Decisions: 会議の決定事項を管理するNotion DB
  • Tasks: プロジェクトのタスクを管理するNotion DB
erDiagram
    Meetings
    Agendas ||--o{ Tasks: "1つのAgendaは0以上のTaskを持つ"
    Agendas ||--o{ Decisions: "1つのAgendaは0以上のDecisionsを持つ"
  

Notion DBテンプレートページ

定例議事録と、定例議事録内の各アジェンダのNotion DBテンプレートページを以下のように作成しました。

「Meetings」DBの定例議事録テンプレート

  • 「ToDo」パートの「Tasks」は、「ステータス」が「完了」以外を表示するようにフィルタを設定
  • アジェンダ」パートの「Agendas」は、「解決済み」が「チェックなし」のみを表示するようにフィルタを設定
  • 「決定事項」パートの「Decisions」は、決定事項が定例会議実施日のみに作成されるという想定の元、「作成日時」のフィルタ条件として、定例実施日に設定する運用にする

新定例議事録テンプレート

「Agendas」DBのアジェンダテンプレート

  • 「ToDo」パートの「Tasks」は、フィルタ条件「Agendas」をテンプレートページに設定しておくことで、テンプレートから作成されたページが自動で設定されるように
  • 「決定事項」パートの「Decisions」は、フィルタ条件「Agendas」をテンプレートページに設定しておくことで、テンプレートから作成されたページが自動で設定されるように
    アジェンダテンプレート

結果

定例議事録ページで感じていた課題は、議事録の「構成要素」と「構成要素間の関係」をNotion DBで整理(構造化)することで、以下のように解消することができました。

アジェンダ」パート

  • アジェンダの一覧性が悪く、優先度の高いアジェンダを先に話すなどの判断がしづらい
    • アジェンダがDBのページとして表現されることで、一覧として表示することができ、一覧性が向上
    • → DBは一覧の並び替えができるので、優先度の高いアジェンダを上に持ってくることが可能になった
  • アジェンダに関する議論が複数回にわたる際に、議論内容が複数ページに渡り、情報が追いにくい
  • アジェンダが多く、その回の定例で話せず持ち越しになった場合に、次回の議事録に転記する必要がある
    • → 解決していないアジェンダは自動的に次回に持ち越されるようになり、転記が不要になった

「決定事項」パート

  • 何のアジェンダについて、どんな意思決定が行われたのかが追いにくい
    • アジェンダに紐づけて決定事項をDBとして管理することで、意思決定が追いやすくなった
  • ある決定事項がどこに書かれていたかを調べたい際に、決定事項が書かれた会の議事録のページを探し出す必要がある
    • → 決定事項はDBにまとまっていて、アジェンダも紐づいているので、情報の検索性が向上し、素早く決定事項にたどり着けるようになった

「ToDo」パート

  • 前回の定例で決まったToDoを確認するために、前回の議事録を参照or次回の議事録に転記する必要がある
    • → ToDoはLinked viewとして次回定例に引き継がれるので、前回の議事録を参照する必要性も、次回の議事録に転記する必要性もなくなった
  • 進行状況が分からない
    • → DBとして表現することで進行状況をpropertyとして表現することが可能になり、進行状況を追えるようになった
  • 期限が不明瞭
    • → DBとして表現することで、期限をpropertyとして表現することが可能になり、期限が明確になった

まとめ

今回は、定例会議の議事録をNotion DBで構造化して、いい感じにした話を紹介しました。

タイミーではNotionをフルに活用しており、私が所属しているデータ統括部DSグループでも、スクラム運用や開発チケット管理など、さまざまな場面でNotionを活用して業務を効率化しています。

今後も定期的に、Notionの活用について発信していきたいと思います!

We’re Hiring!

タイミーのデータ統括部では、ともに働くメンバーを募集しています!!

現在募集中のポジションはこちらです!

「話を聞きたい」と思われた方は、是非一度カジュアル面談でお話ししましょう!