一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

一休.com 宿泊の料金・ポイント計算処理の改善

宿泊プロダクト開発部の田中(id:kentana20)です。

このエントリーは一休.com Advent Calendar 2025の11日目の記事です。

今回は、一休.com宿泊で進めている

「ホテル/旅館の宿泊料金・ポイントを計算する処理が複数のシステムに分散している状態を改善している」

という取り組みについてご紹介します。

背景・課題

一休.com 宿泊には、いくつか重要な業務が存在しますが、その1つに「宿泊料金・ポイントの計算処理」があります。

  • 各ホテル・旅館が設定した料金
  • サイトを閲覧しているユーザー(会員)の状態
  • ユーザーが指定している検索条件(日付、人数など)
  • 期間限定で実施しているポイントX倍、のようなプロモーション

などの情報に基づいて、宿泊料金を算出したり、予約で得られるポイント数を計算する処理です。 この料金・ポイント計算処理は、以下のような背景・課題がありました。

  • 歴史的経緯から、料金・ポイント計算ロジックが複数のシステムに分散して存在している
  • (複数システムに分散しているため)ロジックの変更を行う際に、複数のシステムに対して同じ変更を繰り返し実施する必要がある
  • 「今年の冬は、こういうポイントアップのプロモーションを実施したい」というビジネスのニーズに対して、必要以上に対応コストがかかってしまう

昨今、ECをはじめとするWebサービスにおいてポイントやクーポンといった販促・インセンティブ機能はビジネス上も重要な要素となっており、一休.com 宿泊においても例外ではありません。

これを踏まえて、各システムで実施している料金・ポイント計算処理を整理し、本来あるべき姿を検討して改善を進めることにしました。

料金・ポイント計算処理の現状整理と課題、改善策

本来あるべき形を検討するにあたり、まずは現状の料金・ポイント計算処理がどうなっているかを整理するところから始めました。 整理については

  1. 料金・ポイント計算をどこで、どんな業務で使っているか
  2. それぞれの業務で、料金・ポイント計算にどんな特徴・違いがあるか
  3. 本来あるべき料金・ポイント計算ロジックの姿と、現状のギャップは何か(課題)
  4. 本来あるべき姿に向けて、現状からどう改善していくか

という4ステップで考えて進めました。

前提)宿泊システムでの料金とポイント計算

ホテル、旅館の宿泊料金は以下のように決まっています。

  • ホテル・旅館
  • 部屋タイプ
  • 宿泊プラン
  • 宿泊日

この4つの要素の組み合わせごとに料金が設定されています。

ホテル・旅館 部屋タイプ プラン 宿泊日 料金 補足
ホテルA スタンダードツイン 朝食付きプラン 2025/12/11 25,000
ホテルA スタンダードツイン 朝食付きプラン 2025/12/12 30,000
ホテルA スタンダードツイン 朝食付きプラン 2025/12/13 40,000 同じ内容でも日付ごとに料金が異なる(土曜は高い)
ホテルA スタンダードツイン 朝食付きプラン 2025/12/14 25,000
ホテルA スタンダードツイン 朝食付きプラン 2025/12/15 20,000
ホテルA デラックスダブル 素泊まりプラン 2025/12/11 - 料金が設定されていない日もある
ホテルA デラックスダブル 素泊まりプラン 2025/12/13 60,000

こんなイメージです。

また、ポイント計算は以下のような要素が絡んできます。

  • ユーザーの会員ランク(会員ランクによってポイント付与率が変わる)
  • ポイントアップキャンペーン(期間限定でポイント付与率が変わる)
  • クーポン利用(クーポン利用時の割引額を考慮する必要がある)

一休.com宿泊では「予約で付与されるポイントを、その場で使える(ポイント即時割引)」という機能があるため、ユーザーには

  • 元の宿泊料金(値引き前)
  • 即時割引のポイント数(ポイント付与率)
  • 実際に支払う料金(値引き後)

の3つをわかりやすく表示する必要があります。

ユーザーに表示する料金の例

1. 料金・ポイント計算をどんな業務で使っているか

初手として、各システムが料金・ポイント計算処理をどこで、どんな業務で使っているかを整理しました。

  • (a) 検索を高速に行うためのデータ作成・更新業務
    • 後続の検索業務に必要なホテル・旅館の料金を確定するために必要な情報を非正規化して作成・更新する *1
  • (b) 検索業務
    • ユーザーが指定する条件に合わせて予約可能なホテル、旅館や宿泊プランを抽出して画面に表示する
  • (c) 社内でのマーケティング用途向けのデータ作成・更新業務
    • 社内でのデータ分析業務に必要な宿泊プランの料金情報を作成・更新する
    • ユーザーに送る販促メールなどに掲載する宿泊プランの料金を計算する
  • (d) 予約業務
    • 最終的にユーザーが選択した宿泊プランのリアルタイムな料金を計算し、予約を確定する
  • (e) ポイント、クーポンなどの割引計算業務
    • 検索、予約どちらでも、指定条件に対して適用できるポイント、クーポンを抽出・計算する

などです。

2. 料金・ポイント計算各業務の特徴と違い

1の整理を踏まえて、各業務での料金・ポイント計算にどんな特徴があるかを見ていきました。 結果として以下のような違い(特徴)があることがわかりました。

  • 情報の鮮度に関する違い

    • ある程度の精度・鮮度で料金を計算できればよい業務(検索)
    • リアルタイムに正確な料金を計算する必要がある業務(予約)
  • 扱うデータ量の違い

    • 大量の宿泊プランのデータを一括で処理する業務(検索用データ作成・更新、マーケティング用途向けデータ作成・更新)
    • 指定の条件にあった宿泊プランをリアルタイムに処理する業務(予約)
  • 必要な情報の違い

    • 検索では指定条件でトータルのポイント付与率、ポイント数がわかればよい
    • 予約ではプロモーション単位でポイント付与率、ポイント数がわかる必要がある

これを抽象的に捉えると

  1. バッチ処理として大量のデータを一括で処理する業務
  2. リアルタイムに個別のデータを処理する業務

の2つに大別できることがわかりました。

また、各業務を整理する中で「料金」と呼んでいるものが複数存在していて、呼び名が統一できていないこともわかりました。

  1. 宿泊料金(ホテル・旅館が設定した基本料金)
  2. ポイント値引き後の料金
  3. クーポン・ポイント値引きなど、すべての割引を適用した後の最終的な支払料金

などです。これについては、料金の種類を整理してどこでどの料金を使う必要があるのかをまとめました。

3. 本来あるべき料金・ポイント計算ロジックの姿と、現状のギャップは何か(課題)

次のステップとして、それまでの整理をもとに、現状の課題を洗い出して本来あるべき姿を検討しながら、取り組む課題を明確にしていきました。

課題: 宿泊サービスの中で、料金・ポイント計算ロジックが複数のシステムに分散して存在している

  • 長くサービスを運用しているため、新旧それぞれのシステムで料金・ポイント計算ロジックが実装されている状態になっている
    • システム移行の過程では避けられない側面もあります
  • 一方で、新しいシステムの中でもロジックは共有しきれておらず、用途によって分けたシステムごとにロジックが分散している状態になっている
    • 検索用のインデックスデータとマーケティング用データの作成・更新処理がサブシステムに分かれており、ロジックが共有できていない 等

これに対して、本来あるべき姿は、料金・ポイント計算ロジックは1箇所に集約し、各システムから共通で利用できる形にすることと考えて、ロジックの集約に向けた取り組みを行うことにしました。

4. 本来あるべき姿に向けて、現状からどう改善していくか

課題を踏まえて、本来あるべき姿に向けてどう改善していくかを検討しました。 改善のステップとして、以下を考えて進めています。

  • 新システム内でのロジック集約・共通化
    • ステップ1: バッチ処理として大量のデータを一括で処理する業務内でロジックを集約・共通化
    • ステップ2: リアルタイムに個別のデータを処理する業務内も含めてロジックを集約・共通化
  • システム全体でのロジック集約・共通化
    • ステップ3(案): 既存システムから新システムのロジックを呼び出し、システム全体でロジックを集約・共通化

現状と今後の展望

現在は、ステップ1として「バッチ処理として大量のデータを一括で処理する業務内でロジックを集約・共通化」を進めており、先に挙げた業務のうち

  • (a) 検索を高速に行うためのデータ作成・更新業務
    • 後続の検索業務に必要なホテル・旅館の料金を確定するために必要な情報を非正規化して作成・更新する
  • (c) 社内でのマーケティング用途向けのデータ作成・更新業務
    • 社内でのデータ分析業務に必要な宿泊プランの料金情報を作成・更新する
    • ユーザーに送る販促メールなどに掲載する宿泊プランの料金を計算する

の2つの業務を扱うバッチ処理に対して、1つの料金・ポイント計算ロジックを使う形に改善を進めています。今月ようやくプロトタイプが動くようになり、来年1月頃のリリースを目指して進行中です。

リリース後は引き続き、ステップ2を進めていく予定です。

まとめ

今回は、一休.com宿泊で進めている「宿泊料金・ポイント計算処理の改善」という取り組みについて紹介しました。個人的な所感として、既存システムの現状・課題を整理したことで

  • まだその業務を十分に知らないメンバーが理解するために役立った
  • ほかのサービスで同様の課題を考える際に参考になった

といった出来事があり、宿泊システムの改善を考える以外の面でもプラスの効果があったと感じています。

おわりに

一休では、事業の成果をともに目指しつつ、システムの改善も進めていく仲間を募集しています。

www.ikyu.co.jp

まずはカジュアル面談からお気軽にご応募ください!

https://hrmos.co/pages/ikyu/jobs/1745000651779629061hrmos.co

明日は @rotomx の「一休の情シス / コーポレートIT 2025」です。お楽しみに!

*1:一休宿泊では、ユーザーが指定した条件で検索結果を素早く表示する必要があるため、Solr(検索エンジン)に必要な情報をインデックスしています

操作から意味へ ─ Haskell が変えた私のメンタルモデル

この記事は一休.com Advent Calendar 2025の 5日目の記事です。

私は毎年この時期になると Haskell に関する記事を投稿していますが、今年もまた Haskell を題材にしつつ、今回は Haskell を使うことがプログラミング中の思考にどのような影響を与えるかについて考察してみようと思います。

LLM と「言葉が思考を形づくる」という直感

LLM (Large Language Models、大規模言語モデル) は次にくる言葉を予測しているだけなのに、それが知性のように見える。「言葉の推定」でプログラミングすらできてしまうという事実に誰もが驚いたところだと思います。

LLM を本当の知性とみなすかどうかは議論の分かれるところだと思いますが、LLM の原理をみるに、少なくとも「言語」が推論や思考の形式に深く影響するという直感は正しいのではないかと思います。

ところで「言語」といえば、我々はプログラミング言語を用います。「プログラミング言語」がその名の通り「言語」なら、プログラミング言語もまた思考に影響を与えるのではないか、そんなことを思います。

ChatGPT に「プログラミング言語が思考に影響を与えるなら、使うプログラミング言語を変えると自分の思考が変わるということはありそうですね」と尋ねてみたところ、以下のような返答が帰ってきました。

「Haskeell は言語ではなく哲学」笑

いやあ、さすがにそれは大袈裟すぎるだろうとは思いつつ「思考が "操作" ではなく"意味" を軸に処理されるようになる」という点については、頷けるところがあります。

半年前の『関数型まつり』でも、競技プログラミングとアルゴリズムを題材にこの話を少し紹介しました。

Haskell でアルゴリズムを抽象化する / 関数型言語で競技プログラミング - Speaker Deck

今回はその中の一部、Haskell を使うとプログラムを見る目・・・メンタルモデルが変わるよ、という話をしてみたいと思います。

map と fold:再帰構造をどう“見る”かが思考を変える

一般の命令型言語の場合、値やデータ構造は基本的に書き換えが可能です。それを命令によって書き換えながら望む結果を得る、という考え方でプログラムを構成します。

例えば 1 から n までの和を取りたい場合、以下のように書くことができます。

int total = 0;

for (int i = 1; i <= n; i++) {
    total += i;
}

変数に値を代入するという操作を通じて、値を書き換えます。それを for ループで繰り返し操作します。操作が終わったところで total 変数の値は、目的の 1 から n までの和になっているはずです。

Haskell の場合、変数は基本的に書き換えることができません。値の書き換えのような「操作」で計算を構成するのではなく「値に関数を再帰的に適用する」ことで計算を構成します。

f acc [] = acc
f acc (x : xs) = f (acc + x) xs -- f 関数を再帰

main :: IO ()
main = do
  ...

  print $ f 0 [1 .. n]

しかし何かプログラムを構成するたびに再帰をイチから書くのはプリミティブすぎます。Haskell には fold (畳み込み) や map (写像) のような、再帰構造を一般化した基本操作が用意されています。

先の和は foldl' を使うことで以下のように書けます。

print $ foldl' (+) 0 [1 .. n]

命令型言語で配列やリストの各要素を変換したいとき、やはり値を書き換えるという操作が中心になります。たとえば、1 から n までの整数それぞれに 1 を足した新しい配列を作る場合は以下のように書くことができます。(ChatGPT に書かせました)

vector<int> xs;
xs.reserve(n);

for (int i = 1; i <= n; i++) {
    xs.push_back(i + 1);  // 値を書き換えて格納する
}

Haskell では、やはり値の書き換えという操作ではなく、「各要素を変換する」という計算を map (写像) で表現します。

print $ map (+1) [1 .. n]

命令型言語では for 文や代入文、配列、if 文というプリミティブな操作で、多くのことができるのはみなさんご存知の通りです。 それと同じく Haskell では map や fold (と filter など) で、同様に、多くのことができます。

プログラミング言語におけるプリミティブな構文要素が異なる。これが、命令型言語と Haskell のような関数型言語の大きな違いです。

「動き」ではなく「意味」でプログラムを捉える

map や fold の「意味」はそれぞれ「写像」や「畳み込み」です。

慣れないうちは map や fold を、命令型プログラミングの for 文そのほか同様に動きで捉えてしまって、つい頭の中で値が再帰的に変換されていく様子をシミュレートしてしまうかしれません。

しかし、ある程度書き慣れてくると let xs' = map (+1) [1 ..n] という記述は「xs'[1 .. n] というリストの写像だ」と、その意味そのままで解釈、記述できるようになっていきます。fold も同じです。この意味だけでコードを捉えても特に困らないので、動きについてはあまり考えなくなります。

ちなみにここで言っているのは「意図」ではなく「意味」です。「意味」はプログラム自身が持つ構造的・数学的な「何を表すか」のこと。

プログラマの「意図」とは無関係にプログラムが構造として「意味」を持つことがあります。そして Haskell のような抽象度の高い言語では、この「意味」が支配的になると思っています。

プログラムそのものが表す構造・関係というのは、たとえば

  • 「fold はモノイドの結合である」
  • 「関数 f :: A -> B は A を B に写す写像である」
  • 「map は関手(functor)の写像で、構造を保つ」
  • 「IO は合成可能な計算のコンテナである」

みたいな解釈のこと。

あるコードが「何を表現しているか」「どんな数学的構造に対応するか」という、客観的な意味のことです。

Haskell の再帰的データ構造と map / fold

ところで Haskell で宣言するデータ構造は、再帰的データ構造です。

data List a = Nil | Cons a (List a)

再帰的データ構造は「全体が、同じ型の部分構造を含んで定義されているデータ構造」です。リストや木構造などが典型例ですが、Haskell のイミュータブルなデータ構造は概ねこの再帰的データ構造として定義されています。

詳細が気になる方は、昨年私が書いたこちらの記事も参照してください。

永続データプログラミングと永続データ構造 - 一休.com Developers Blog

さてリストの例でも分かるように、再帰的データ構造は「全体」を分解すると必ず「同じ型の部分構造」が出てきます。

  • 全体が空か
  • 要素と “残りの構造” からできている

この 「分解 → 要素への処理 → 部分構造の再帰」 という流れが、再帰的データ構造を扱うときの最も自然で基本的な操作です。そして、この 自然な操作を一般化したものが map と fold です。

map と fold は再帰的データ構造に適した最小の操作

繰り返しになりますが、リストを始め、Set や Map など、Haskell が提供する多くのデータ構造は再帰的データ構造で定義されています。この再帰的データ構造に対して何か処理をしたいとき、だいたい次の 2 つの行動 (両方、またはいずれか) が発生します。

  • 各要素を何らかの関数で変換する
    • 構造はそのまま
    • 中身だけ変える

これはまさに map (写像) です。

  • 構造全体を 1 つの値に畳み込む
    • 各要素を読み取り
    • 結合演算で集約する

これは fold (畳み込み) ですね。

たとえば、3×3 の格子点を集合 Set (Int, Int) で持ち、それを平行移動させたい場面を考えてみます。

Haskell では集合全体に対する写像 として、そのまま表現できます。

main :: IO ()
main = do
  -- n <- getInt

  let s = Set.fromList [(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3) :: (Int, Int)]
      s' = Set.map (+ (5, 5)) s

  print s' -- fromList_[(6,3),(6,4),(6,5),(7,3),(7,4),(7,5),(8,3),(8,4),(8,5)]

集合の順序・構造は保たれたまま、要素だけが変換されます。これは再帰構造の「写像」という観点から見ても自然です。

同じ Set を使って、今度は「集合全体を 1 つの値に集約 (畳み込み)」することを考えてみます。 たとえば点集合の平均座標(重心)を求める場合です。Haskell なら fold でたたむだけです。

main :: IO ()
main = do
  let s = Set.fromList [(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3) :: (Int, Int)]

      (sx, sy, cnt) =
        foldl'
          (\(ax, ay, c) (x, y) -> (ax + x, ay + y, c + 1))
          (0, 0, 0)
          s

      center =
        ( fromIntegral sx / fromIntegral cnt,
          fromIntegral sy / fromIntegral cnt
        )

  print center -- (2,0, 2.0)

このように、Haskell の再帰データ構造は map や fold で操作できて、map には写像、 fold には畳み込み (集約) という意味があり、いま見たような「平行移動」や「重心を求める」のよう計算も写像、集約の意味で捉えて記述することが可能になります。

Haskell で記述すると「意味」が自然と浮かび上がる

map と fold が強力なのは、 単に便利だからでも、抽象的だからでもありません。それらが再帰構造の本質的な二つの操作に完全に対応しており、 コードの表現がそのままデータ構造の意味に一致するからです。

  • map → 「A を B へ写像した」
  • fold → 「A を単位元と結合演算で畳んだ」

命令的な「どうやってやるか」ではなく、「何を表すか」「どんな変換なのか」という意味がそのままコードになります。ここが、Haskell が「意味で考える」言語だと私が考える重要なポイントです。

プログラミング言語は思考の外部化装置だとも考えられます。そしてコードというのは外部化された思考のまとまりです。

命令型プログラミングでは、for文、代入文という文、つまり計算機への命令が基本操作になっています。それをベースにコードを組み立てていったとき、そこで外部化されるのは「操作、動きのまとまり」でしょう。

一方、Haskell では map や fold などの式、つまり意味が基本操作になっている。それをベースにコードを組み立てていったとき、そこで外部化されるのはより抽象度の高い意味の構造に近づくのではないか、と考えています。

もちろん、map と fold だけが「操作ではなく意味で考える」ことに寄与している要素ではなく、そのほか関数合成、型、モナド、永続データ構造などなど Haskell を支える様々な概念が統合されて、コードを意味構造に導くのだと思います。

二分探索について考える

もう一つ別の例についても考えてみます。 二分探索のアルゴリズムは、プログラマであれば誰もが知るところです。

おそらく、初めて二分探索を学んだときは、多くの人がそれを 「配列の真ん中を見て」「条件を満たすかどうかで左か右に進んで」 といった操作の流れを頭の中に描いて理解したのではないでしょうか。

これはやはり「動き」を理解の中心に置く捉え方です。

意味的な捉え方:二分探索は「境界を見つける」アルゴリズム

一方で、特に競技プログラミングをやっている人などは、二分探索を「境界を見つけるアルゴリズム」 として意味的に捉えていることが多いのではないでしょうか。

ある領域の中に条件が true になる領域、条件が false になる領域があり、その境界(true → false に切り替わる点)を効率的に求めるのが本質だ、という解釈です。以下の文書などでも詳しく解説されています。

この境界を高速に見つけることこそが二分探索の意味であり、 動きの詳細(左右どちらを見る、など)はその「境界探索」を実現する手段にすぎません。

bisect2 という関数に抽象化する

二分探索は素で実装すると off-by-one なバグを埋め込みやすいので、私は以下のように bisect2 という名前で二分探索の関数をライブラリ化しています。

-- | 左が true / 右が false で境界を引く
bisect2 :: (Integral a) => (a, a) -> (a -> Bool) -> (a, a)
bisect2 (ok, ng) f
  | abs (ng - ok) == 1 = (ok, ng)
  | f m = bisect2 (m, ng) f
  | otherwise = bisect2 (ok, m) f
  where
    m = (ok + ng) `div` 2

この関数は「左側 ok が true の代表値」「右側 ng が false の代表値」であることを前提に、 true 域と false 域の境界を特定する計算に特化しています。

この二分探索の関数は以下のように使います。

let (ok, _) = bisect2 (0, 10 ^ 18) (\x -> countBy (>= x) as >= x)

print ok

ここで引数として渡している高階関数 f :: a -> Bool は、 「x に対してその条件が成り立つかどうか」を返す写像であり「二分探索における境界の“意味そのものを表現する関数」と言えます。

改めて一歩引いてみてみると、高階関数 f によってパラメータ化された境界条件の存在が「二分探索は境界を見つけるアルゴリズム」だという意味構造をよりはっきりと表しているように見えてきます。こうやって、アルゴリズムを記述していてもそこに意味構造が自然と浮かび上がってくる。

そしてこの「二分探索の境界を引く、 f は境界条件だ」という意味構造が自然に捉えるようになると、今度は思考が逆転して、二分探索をしようとするとき探索の動きで考えるのではなく、「境界条件をどのように写像として表現するか」という頭で考えるようになります。

思考や発想そのものが、操作や動きを考えることから、意味から出発するように変わるのです。これこそ、プログラミング言語の特徴が思考に影響を与えた結果辿り着いた思考の癖だと私は思っています。

長年プログラミングをやってて思うこと

抽象度の高い Haskell のようなプログラミング言語を使うと思考が変わる、メンタルモデルがアップデートされるのではないかという仮説を、自分の実体験に基づき、紹介してきました。

以下、主観的な考察です。

プログラミングは一見すると知的作業のように見えます。でもその実は、反復作業によりプログラミング言語を反射的に操作できるよう身体化させることが必要だと思っています。プログラミング言語の本を読んだだけでスラスラとプログラムが書ける人は希で、多くの場合、繰り返し繰り返し記述して、考えなくても手癖でコードが記述できるようになって初めて、そのプログラミング言語を自分の道具にできたと実感するのではないでしょうか。

そして繰り返し繰り返し同じようなコードを書いて、同じような構造をみつけて、同じようなプログラムを構築する。その過程で同じような構造を何度も目にすることで、人はそこから抽象を見い出すことができるようになる。そしてより上手に、構造を描けるようになる。

これは言ってみれば、絵を描くとか、何か作品をつくるという行為によく似ているように思います。反復、繰り返しによる積み重ねが、より高い次元へとそれを導く。繰り返し繰り返しやっているうちに、気がつけばずいぶんと遠くに辿り着く。

この長年の積み重ねを、操作や動きを中心に据えたプログラミング言語でやっていくか、意味を中心に据えたプログラミング言語でやるかで辿り着く場所が大きく異なるのではないか、という実感があります。

何か新しいプログラミング言語に手を出すとき、もちろん実用性の面からそれを選ぶのも良いと思います。

でも別の視点として、プログラミング言語が思考に影響を与えるだろうという観点から、いつも使っている言語とは少しパラダイムが離れたものを使ってみるのも面白いと思います。今回みたとおり、新しいプログラミング言語を身体化する過程でメンタルモデルが更新されて、プログラミングに対する新たな視点が手に入るでしょう。

関数型プログラミングの実践として「不変な値で組み立てていくとプログラムが堅牢になるよ」とか「型安全にすると変更が楽だよとか」実用的なテクニックや旨みを中心に語ること自体は否定しません。でも、私としてはそういうことよりも、よりよいプログラミングの目を養うために関数型プログラミングや Haskell を学んでみたら? というのが本音としてあります。

そのとき、やっぱり反復や繰り返しが大事です。ちょっとやってみる、だけではもの足りない。

命令型プログラミングに慣れた人ほど、map や fold を最初から写像や畳み込み (集約) と直接意味で考えるのが難しい。頭のなかで操作を追ってしまう。でも、繰り返し繰り返しやっていると、やがて、操作を経由しなくても、写像、集約のような意味で脳が直接的に認知できるようになる。

よく、日本語ネイティブな人が英語を話すとき、慣れないうちは英語を一度日本語に頭の中で変換すると言います。でも、そのうち英語を英語のまま脳が処理できるようになるらしいです。それによく似ていて、最初は、動きに変換して考える癖が抜けない。でも、繰り返しやってるうちに、その癖が抜ける。それが一つの到達点だと思います。

私はできればプログラムを操作のまとまりではなく、意味の構造として捉えたいという欲求があります。それはたぶん、それを操作列としてではなく意味構造として捉えるほうが、情報としての圧縮率が高いからではないかと思っています。

自分の脳はさほど、操作的推論に強くない。だから、より低い認知負荷で対象を把握・理解する、記憶するためにはより圧縮率の高い表現の方が望ましかったんだと思います。Haskell ならプログラムを意味構造として組み立てていく、解釈するのが、命令型言語よりもやりやすい。それが今のところの自分の結論です。

ベクトルの和は (x + d1, y + d2) ではなくて v + d と書けたほうが嬉しいし、「ビット全探索」ではなく subsequences だし、直積を求めるなら for の二重ループではなく [ (x, y) | x <- xs, y <- ys] あるいは sequence [xs, ys] と書きたいし、理解したい。そんな気持ちです。

今年も長々とした駄文を最後まで読んでいただきありがとうございました。

Panda CSS でデザインシステムのメンタルモデルを確立する

この記事は一休.com Advent Calendar 2025の2日目の記事です。

レストランプロダクト UI 開発チームの鍛治です。一休.com レストランのフロントエンドを担当しています。

2025 年 4 月、PayPay グルメ の全面リニューアルが完了しました。このリニューアルでは「一休.com レストラン」と「PayPay グルメ」の 2 つのサービスを 1 つのコードベースに統合しています。

一休レストラン・PayPay グルメではリニューアルプロジェクトを契機に Tailwind CSS から Panda CSS への置き換えを進めています。 また置き換えやってるのか 1 と思われるかもしれませんが、もちろん理由あっての導入です。本稿では、なぜ導入したのか、それにより何が得られたのかをご紹介したいと思います。

PayPayグルメについて

本記事で登場する「PayPayグルメ」について簡単に説明します。

PayPayグルメは「PayPay株式会社の協力のもとLINEヤフー株式会社が運営しているサービス」2です。カジュアルなレストランや居酒屋に加えてカラオケまで、多彩な店舗ラインアップを揃えた飲食店予約サービスです。

PayPayグルメトップ

2025年3月末日まではLINEヤフーが開発/運用の両方を担っていました。その後、2025年4月からは一休に開発/運用が委託されています。主な理由としては、一休がLINEヤフーのグループ会社であり、一休が培ってきた飲食店予約事業の知見を活用しつつグループ内の運用を一本化するためです。

PayPay グルメと一休レストランの統合

PayPay グルメを一休に移管するにあたっては、現行の一休.comレストランのシステムにPayPayグルメを統合するという選択肢をとりました。つまり、アプリケーションのリポジトリを一休.comレストランと PayPay グルメで共通化し、2つのサイトを配信できるような仕組みを作ることにしました。

さらに、統合にあたってPayPayグルメのUIを全面リニューアルすることにしました。具体的には、一休.comレストランの UI をもとにしつつ、新たに PayPay グルメのブランドイメージを表現したデザインを適用しました。

統合にあたり、まずは1つのリポジトリで2つのサイトを運用するために解決した課題をいくつか紹介します。

サイト固有のコードをどうするか

1つのコードベースで2サイトを実現するにあたって、次のような課題がありました。

1つ目の課題は、フロントエンドのバンドルサイズ問題です。一方のサイトにしか使わないライブラリがもう一方のサイトのプロダクションビルドに含まれてしまうと、ネットワーク帯域や計算資源を無駄に消費してしまいます。例えば、PayPay グルメでのみ利用している地図サービス MapBox のクライアントライブラリが挙げられます

2つ目の課題がより重要です。プロジェクト開始時点では PayPay グルメのリニューアルは公表されていませんでした。バンドルサイズに目を瞑ったとしても、一休.comレストランのプロダクションビルドに PayPay グルメを推測されるようなコードを含めるわけにはいきません。

上記の課題を解決するために、ビルド時に Vite の環境変数を切り替えることで、本番で各サイトのコードに他方のサイトだけでしか使わないコードが入り込まないようにしました。

各サイト固有のデザインの適用

一休レストランのみ開発している時は特に意識しませんでしたが、2 サイトで使用する色が異なります。

最初期のプロトタイプでは、一休.comレストランのためのデザイントークンの値を直接書き換えて、PayPay グルメのルック&フィールを検証していました。

const ikyu = process.env.VITE_MODE !== 'ppg'

module.exports = {
  theme: {
    extend: {
      colors: {
        accent: {
          // PayPay グルメでは "pink" だがカラーはブルー
          pink:  ikyu ? '#ff4d4d' : '#3895ff',
          brown: ikyu ? '#af9b65' : '#3895ff',
          khaki: ikyu ? '#c0b28b' : '#3895ff',
          beige: ikyu ? '#f8f6f1' : '#E5F1FF',
          blue:  ikyu ? '#397bbe' : '#3895ff',
        },
      },
    },
  },
}

当時のデザイントークンは上記の通り意味と色の名前が混在していて、プロトタイプ時点のコードでは accent-pink なのに表示される色は青という状況でした。 例えば、以下のような実装では問題が発生します。

export function ReserveButton() {
  return (
    // PayPay グルメではブルーになる
    <button className="accent-pink">
      空席確認・予約
    </button>
  )
}

このコードではクラス名が accent-pink ですが、PayPayグルメ では実際にはブルー #3895ff が表示されます。 このコードは実際に以下のような画面が表示されています。

一休 PayPay グルメ
一休レストラン予約ボタン PayPayグルメ予約ボタン

コード上の色名と実際の色が一致しないため、メンテナンス時に混乱を招く原因となっていました。 色そのものの名前 (primitive) と、それをどういう場面でどういう効果を与えるために使うのかという意味 (semantic) を峻別することの重要性に気付かされた瞬間でした。

さきほどの例を、primitive token と semantic token を分けると以下のようになります。

const ikyu = process.env.VITE_MODE !== 'ppg'

// primitive token
const ikyuPink = '#ff4d4d'
const ppgBlue = '#3895ff'

module.exports = {
  theme: {
    extend: {
      colors: {
        button: {
          // sematic token
          primary:  ikyu ? ikyuPink : ppgBlue,
        },
      },
    },
  },
}
export function ReserveButton() {
  return (
    <button className="button-primary">
      クーポンを獲得する
    </button>
  )
}

こうした反省を踏まえ、デザイントークンをあらためて見直すことになりました。 ここまでお伝えしてきたように、値そのものの名前である primitive token とそれに対する意味付けである semantic token をちゃんと認識し、峻別していく、という整理です。

この過程で Panda CSS の採用を決定しました。 Panda CSS では core token と semantic token を分けて定義する形になっており、色と意味の峻別という私たちがあらたに得たメンタルモデルを後押ししてくれるからです。

もともと Panda CSS に注目していたしていたことも追い風となり、リニューアルで大規模刷新する今こそ、移行コストを一度に払おうという結論に至りました。

本記事でのトークンについて

ここで用語の整理をさせてください。 デザイントークンの見直しの中で、一休のデザインシステムでは、ここまで述べてきたように primitive token と semantic token という整理を行いました。 一方、Panda CSS では core token / semantic token という用語が使われています。

トークンの種類 一休デザインシステム Panda CSS
値そのもののトークン primitive token core token
意味を持つトークン semantic token semantic token

以降、一休デザインシステム文脈では primitive token 、Panda CSS の文脈では core token と呼びます。

Panda CSS

Panda CSS は型安全かつゼロランタイムで使える CSS-in-JS のツールです。 Chakra UI の開発チームが「Chakra の設計思想をライブラリ非依存で使い回せるように」と開発しており、最終的には純粋な CSS ファイルを出力するため実行時コストはゼロになります。 そのうえ、 TypeScript の型情報を活用できるのが最大の特徴です。

本章では「前章で挙げた課題を Panda CSS がどのように解決したか」に絞って解説します。 セットアップ手順やユーティリティ API などの基本的な使い方は、公式ドキュメントや他の紹介記事を参考にしてください。

Design Token の階層化

Panda CSS は W3C Design Tokens Community Group が策定しているデザイントークンをファーストクラスサポートしています。 Panda CSS の設定ファイルでは core token と semantic token を分けられますが、使うときは core token も制限なく参照できます。 そこで core と semantic を厳密にわけるため、あえて "触ってはいけない" という意味を込めて not.recommend.* という prefix を導入しました。 実際の設定は以下のようになります。

const ikyu = process.env.VITE_MODE !== 'ppg'
// PayPay グルメのリニューアルの公表前なので、
// CSS のカスタムプロパティに PPG という名前が出ないようにしている
const ppg = {
  blue: {
    dark: { value: '#3895FF' },
    basic: { value: '#4DA0FF' },
    light: { value: '#BADAFF' },
    pale: { value: '#E5F1FF' },
  },
  /* …ほかの primitive */
}
export default defineConfig({
  theme: {
    // core token
    tokens: {
      not: {
        recommend: {
          ikyu: {
            red: { value: '#FF4D4D' },
            /* …ほかの primitive */
          },
        }
      }
    },

    // semantic token
    semanticTokens: {
      colors: {
        reserve: {
          value: ikyu ? { colors.not.recommend.ikyu.red } : ppg.blue.dark
        },
        /* …ほかの semantic */
      },
    },
  }
})

core token (primitive) である not.recommend.* 以下のトークンは自動補完には出てきますが、名前からして「コンポーネントで使うものではない」と一目で分かります。 そしてコンポーネント側は以下のように色名ではなく 役割名(reserveButton / login / review ...)だけを参照します。

export function ReserveButton() {
  return (
    <button className={css({ color: 'reserve'})}>
      予約へすすむ
    </button>
  )
}

Variant

前述したカラーの課題は、一休レストランと PayPay グルメが同一リポジトリで開発することが決定した時に顕在化しましたが、元々一休レストランのみを開発していた頃から抱えている課題もありました。 それはデザインツールと実装の連携です。 当時は XD から Figma へ移行した直後で Variant 機能を十分に理解できておらず、ボタンやタブの状態を boolean フラグで切り替える実装が散見されました。 例えば、以下のような実装が典型的でした。

import clsx from 'clsx'
import type { PropsWithChildren } from 'react'

export function Button({
  children,
  onClick,
  isPrimary,
}: PropsWithChildren<{ onClick: () => void; isPrimary: boolean }>) {
  return (
    <button
      onClick={onClick}
      // boolean でユーティリティを切り替え
      className={clsx(
        'p-2 border-neutral-400',
        isPrimary && 'bg-red-300',
      )}
      type="button"
    >
      {children}
    </button>
  )
}

このアプローチの問題は、ボタンの状態が増えるたびに boolean props が増え、条件分岐が複雑になることです。 上記の例では props で isPrimary の boolean 値のみを受け取ってますが、他にも isRounded && isLarge ... と膨れ上がりました。 その結果、 className が条件分岐だらけでメンテナンスが困難になりました。 また、Figma で定義されている Variant と UI コンポーネントで持つ状態の粒度が異なっており、認知負荷が高い状態になりました。 そこで tailwind-variants を投入し「variant をコード側で表現しよう」と試みたものの、ガイドライン策定が追いつかず部分採用のまま立ち消えたという苦い経験もありました。

Panda CSS では Variant がデフォルトでサポートされており、 Variant の key/value をそのままコードに反映することができます。

FigmaのButton定義

Figma で color / size を定義している場合、実装側は次のように Figma の variant と同じ名前と値で Panda CSS の cva を定義します。

import type { PropsWithChildren } from 'react'
import { type RecipeVariantProps, cva } from '~/styled-system/css'

// Variant の定義
const button = cva({
  base: {
    borderRadius: 'md',
  },
  variants: {
    color: {
      primary: {
        backgroundGradient: 'button.primary',
        color: 'button.primary.text',
        fontWeight: 'bold',
      },
      secondary: {
        backgroundGradient: 'button.secondary',
        color: 'button.secondary.text',
        fontWeight: 'bold',
      },
      normal: {
        borderWidth: 'thin',
        borderColor: 'button.normal.border',
        backgroundColor: 'button.normal.background',
        color: 'button.normal.text',
      },
    },
    size: {
      xs: {
        paddingX: '3',
        paddingY: '2',
        fontSize: 'sm',
      },
      sm: {
        paddingX: '4',
        paddingY: '3',
        fontSize: 'sm',
      },
      md: {
        paddingX: '4',
        paddingY: '3',
        fontSize: 'md',
      },
      lg: {
        paddingX: '6',
        paddingY: '4',
        fontSize: 'lg',
      },
    },
  },

  /** Figma の “Default” と同義 */
  defaultVariants: {
    color: 'normal',
    size: 'md',
  },
})

type Props = PropsWithChildren<RecipeVariantProps<typeof button> & { onClick?: () => void }>

function Button({ children, ...buttonStyle }: Props) {
  const style = button(buttonStyle)
  return (
    <button className={style} type="button">
      {children}
    </button>
  )
}

Figma と 1:1 になるコンポーネントができました。誤って存在しない Variant 名を指定しても TypeScript の型チェックが効くのも嬉しいところです。

// 使用側
export function ReserveButton() {
  return (
    <Button color="primary" size="md">
      予約へすすむ
    </Button>
  )
}

Figma で定義された Variant に沿ってエンジニアは同じキーを持つ Variant を cva / sva で実装することができます。 このように Figma と実装で Variant の粒度を合わせることができ、デザインと UI コンポーネントの実装がシームレスへと繋がるようになりました。

おわりに

Panda CSS に移行した際、開発チームのメンバーから以下のようなフィードバックがありました。

  • 新しい UI を考える中で、primitive / semantic を峻別して議論ができるようになった
  • デザインと実装がシームレスに繋がった
    • デザイン:パターン(variants)を作る -> 実装:そのパターン単位で cva / sva を作成する
  • Variant の key やトークン名が型安全になり開発体験が良い
  • 古くから css を触っているエンジニアには、Panda CSS だと css property がほぼそのままなのでわかりやすいと好評

Panda CSS 導入時は Tailwind CSS とは異なるスタイル定義で慣れが必要でしたが、概ね好評でした。

移行はまだ完全には終わっておらず、現在も Tailwind CSS が残っているコンポーネントもまだまだあります。 引き続き Panda CSS に書き換えながら、コンポーネントの見直しやデザインガイドラインの整備を進めています。


一休では、本記事でお伝えしたような課題をともに解決するエンジニアを募集しています。

www.ikyu.co.jp

まずはカジュアル面談からお気軽にご応募ください!

https://hrmos.co/pages/ikyu/jobs/1745000651779629061hrmos.co

細粒度リアクティブステートのスコープ設計

この記事は一休.com Advent Calendar 2025の1日目の記事です。

一休.com レストランの開発を担当している恩田(@takashi_onda)です。

はじめに

昨日 2025/11/30 に開催されたフロントエンドカンファレンス関西で、「細粒度リアクティブステートのスコープとライフサイクル」というタイトルで発表を行いました。

speakerdeck.com

発表ではまたしても終盤が駆け足になってしまいました。本稿では、その際に十分に触れられなかった論点のうち、特にスコープに焦点をあて、その課題と解決案をご紹介したいと思います。

細粒度リアクティブステート

細粒度リアクティブステート (fine-grained reactivity) 1 とは、SolidSvelte 5 の Runes で謳われている、UI を必要最小限な単位でリアクティブに更新するフロントエンドのアーキテクチャです。現代の複雑化した Web アプリケーションで UX を軽快に保つためのステート管理の大きな潮流で、Signals として TC39 で標準化の議論が進められています。

その背景にあるのは、値の変更を依存グラフで追跡し、必要最小限の更新を可能にする宣言的な計算モデルです。大雑把なイメージをつかむなら「スプレッドシート」と考えれば分かりやすいでしょう。

derived changed

上の例のように、スプレッドシートでは、セルの値が変更されるとそのセルに依存する他のセルが自動的に再計算されます。同様に、細粒度リアクティブステートでは値が変更されると、その値から計算で導出される派生値やそれらの値を利用する UI コンポーネントが自動的に更新されます。

このように小さな状態をボトムアップに組み立ててモデリングするのが、細粒度リアクティブステートの特徴です。

React においては、一休.com レストランも大いにお世話になっている Jotai が細粒度リアクティブステートを実現するライブラリにあたると言えます。

React はその計算モデルの特性上 fine-grained reactivity を実現するのが難しいフレームワークですが、コンポーネントを小さく分割し memoization すれば近しい挙動が可能です。React Compiler 2 による自動化で React でも実現しやすくなりました。

ナイーブな実装

ここからは React と Jotai を前提に話を進めます。さきほどのスプレッドシートの例を Jotai で実装してみましょう。

const appleUnitPrice = atom(100);
const appleQty = atom(1);

const orangeUnitPrice = atom(200);
const orangeQty = atom(2);

const bananaUnitPrice = atom(300);
const bananaQty = atom(3);

const appleLineSubtotal = atom((get) => {
  return get(appleUnitPrice) * get(appleQty);
});
const orangeLineSubtotal = atom((get) => {
  return get(orangeUnitPrice) * get(orangeQty);
});
const bananaLineSubtotal = atom((get) => {
  return get(bananaUnitPrice) * get(bananaQty);
});

const subtotal = atom((get) => {
  return get(appleLineSubtotal) +
    get(orangeLineSubtotal) +
    get(bananaLineSubtotal);
});
const tax = atom((get) => get(subtotal) * 0.10);
const total = atom((get) => get(subtotal) + get(tax));

スプレッドシートがそのまま表現できていることが見て取れると思います。

次に単体テストを追加します。Jotai は Vanilla JS で利用できるので、Vitest などのテストフレームワークで簡単にテストが記述できます。

describe("Jotai Test", () => {
  test("total", () => {
    // arrange
    const store = createStore();
    // assert: initial values
    expect(store.get(appleLineSubtotal)).toBe(100);
    expect(store.get(subtotal)).toBe(1400);
    expect(store.get(tax)).toBe(140);
    expect(store.get(total)).toBe(1540);
    
    // act
    store.set(appleQty, 10);
    
    // assert: changed values
    expect(store.get(appleLineSubtotal)).toBe(1000);
    expect(store.get(subtotal)).toBe(2300);
    expect(store.get(tax)).toBe(230);
    expect(store.get(total)).toBe(2530);
  });
});

コンポーネントからは次のように利用します。

function Total() {
  const total = useAtomValue(total);
  return <div>Total: {total}</div>;
}

Jotai を使ってナイーブに実装すると、このようなコードになると思います。

私たちも当初は同様の実装でフロントエンドロジックを構築していましたが、規模の拡大とともにスコープとライフサイクルに起因する問題が顕在化してきました。

スコープ

ここで言うスコープは、その名の通りプログラミング言語における変数や関数の可視性のことで、具体的には atom がどこから参照できるかを指します。

ご存知の通り JavaScript のモジュール機構である ES Module はファイル単位でしか可視性の制御ができません。export してどこからでも参照できるようにするか、export せずそのファイル内に閉じるかの二択です。

実装しようとする機能(feature)が小さい間は、ひとつのファイルに atom を定義し Vitest の in-source test で単体テストを記述、最終的にコンポーネントだけを export すれば問題ありません。

しかし、一定以上の規模になると、すべてをひとつのファイルにまとめるのは現実的ではなくなります。atom 定義、単体テスト、カスタムフック、そしてコンポーネントとレイヤごとにファイルを分割3することになります。

ファイルを分割して単体テストやカスタムフックから atom を参照するために export したとき、問題となるのが VSCode や WebStorm などのエディタ・IDEの自動補完機能です。使いたいシンボル名を数文字入力すると補完候補が表示され、確定するだけで自動で import が挿入され、と、あまりに簡単に参照できてしまいます。

新しい機能を実装するとき、使えそうな atom が候補に出てきてタブで確定、とやっていくと、結果、できあがるのが密結合した巨大な atom グラフです。

const subtotal = atom((get) => {
  return get(appleLineSubtotal) +
    get(orangeLineSubtotal) +
    get(bananaLineSubtotal);
});
const tax = atom((get) => get(subtotal) * 0.10);
const total = atom((get) => get(subtotal) + get(tax));

さきほどの例で説明すると subtotaltax, total は本来他の注文明細でも再利用可能なロジックです。ですが、この例では appleLineSubtotal をはじめとする atom に直接依存してしまっており使い回すことができません。また、直接的な依存は単体テストの arrange も煩雑にします。

実際のプロダクトの規模では、依存が広く深く複雑になり、構造を簡単に把握できなくなるのが一番の問題でした。数十個単位の atom がフラットにひとつの大きなグラフになってしまい、手を入れる際に、ホワイトボードで依存関係を再整理して理解の見直しが必要な場面もありました。わかりやすく例えるならば、巨大なひとつの関数で実装されたコードを保守するようなイメージです。

Bunshi

この問題の解決にヒントを与えてくれたのが Bunshi です。

もともとは Jotai で多数の atom を構造化する jotai-moleculous というライブラリとして開発されていました。ですが、その思想は React/Jotai に限らず汎用的に有効だったため、他の状態管理ライブラリやフレームワークでも利用できるように拡張されたのが Bunshi です。

詳細に踏み込むと長くなるので割愛4しますが、Bunshi が提供する以下の機能が問題解決のヒントになりました。

  • atom をグルーピングした molecule という単位のモジュール
  • moleculeInterface という抽象への依存とその解決の仕組み (Dependency Injection)
  • 生存期間とライフサイクル (Scope5)

検討当初は Bunshi 自体の採用も視野に入れていましたが、コアドメイン部分の外部ライブラリへの依存を最小に保ちたかったこと、そしてデザインパターンとして同等の機能が実現できるという判断から、Bunshi の思想を参考にしつつ自前で実装することにしました。

解決

具体的なコードで見てみましょう。さきほどのスプレッドシートの例です。

function createTotalAtom(lineItems: Atom<number>[]) {
  const subtotal = atom((get) => {
    return lineItems.reduce((sum, item) => {
      return sum + get(item);
    }, 0);
  });
  const tax = atom((get) => get(subtotal) * 0.10);
  const total = atom((get) => get(subtotal) + get(tax));
  return total;
}

type Props = { lineItems: Atom<number>[] };
const Total = memo(({ lineItems }: Props) => {
  const totalAtom = useMemo(
    () => createTotalAtom(lineItems),
    [lineItems]
  );
  const total = useAtomValue(totalAtom);
  return (
    <div>
      <p>Total: {total}</p>
    </div>
  );
});

クロージャをモジュールの単位とします。createTotalAtom 関数がモジュールです。

この関数は引数で依存としての atom を受け取り、その依存を使った derived atom total を返します。依存を引数として明示的に記述するという制約がポイントで、これにより依存が無秩序に追加されてしまうことを抑止できます。

また、引数で依存が抽象化されることで、同じロジックを異なる依存で再利用できるようになります。lineItems は任意の注文明細で利用可能です。単体テストの arrange では、シンプルな primitive atom に差し替えられるのも大きな利点です。

中間の derived atom である subtotaltax はクロージャ内に閉じているため、外部から参照できません。カプセル化の導入です。

コンポーネントでは props で atom を渡します。atom は不変なオブジェクトなので、メモ化された Total が再レンダリングされるのは total の依存グラフを構成する atom の値に変化があったときとなり、 fine-grained reactivity が実現できています。

おわりに

ここまで読んでいただきありがとうございました。

Jotai を題材に、細粒度リアクティブステートで複雑なフロントエンドの状態をモデリングするときに課題となるスコープとその解決案をご紹介しました。

人間の認知サイズに収まるように分割し、依存を明示化し、抽象を導入する。ここまでを振り返ると、細粒度リアクティブステートのような新しい概念を扱う場合でも、特別なことはなく、重要なのはプログラミングの普遍的な設計原則に立ち返ることでした。

本稿が、フロントエンドにおける状態管理設計を検討する際の一助となれば幸いです。


一休では、本記事でお伝えしたような課題をともに解決するエンジニアを募集しています。

www.ikyu.co.jp

まずはカジュアル面談からお気軽にご応募ください!

job.persona-ats.com


  1. まだ定まった訳語がなく、リアクティビティとカタカナで表現するのにも違和感があったので、ここでは「細粒度リアクティブステート」と訳しています。fine-grained reactivity とそれを実現するステート管理の仕組み、ぐらいに捉えていただければと思います。
  2. 先日 1.0 がリリースされました。
  3. 依存の向きを一方向にするために、レイヤの単位で分割しています。
  4. bunshiを理解するという記事にわかりやすく解説されています。
  5. Bunshi の Scope はプログラミング言語におけるスコープではなく molecule の生存期間を意味します。React 実装では React Context が利用されています。

情シスカンファレンス「Business Technology Conference Japan 2025 #BTCONJP 」にブロンズスポンサーとして協賛します

コーポレート本部 社内情報システム部 兼 CISO室 id:rotom です。

11/15(土) にハイブリッド形式で情シス向けのテックカンファレンス BTCONJP 2025 が開催されます。

btcon.jp

corp-engr.connpass.com

Business Technology Conference Japan(BTCONJP)は、ITをビジネステクノロジーの領域に昇華し、日本のあらゆる経済活動をアップデートするイベントです。

昨年に引き続き私が core staff として運営に参画しており、所属企業である一休はブロンズスポンサーとして協賛しております。

prtimes.jp

「情シスが創るビジネスの明日」というテーマのもとで、様々なセッションやイベントを楽しめる1日です。 オフライン会場は株式会社一休も入居する、東京ガーデンテラス紀尾井町 紀尾井タワーのLINEヤフー株式会社 本社です。

map.yahoo.co.jp

Dining での懇親会も予定しておりますので、ぜひ現地でご参加ください!

一休はVue Fes Japan 2025にスポンサーします

一休のいがにんこと山口(@igayamaguchi)です。

一休はVue Fes Japan 2025にスポンサーとしてブースを出展します。 vuefes.jp

日程は10月25日(土)です。

Vue Fes JapanはVue.jsの知見が集まるイベントです。今年はそれだけにとどまらず

  • Vue.jsからEvan Youさん
  • ReactからはDan Abramovさん
  • Svelteからはdominikgさん

という海外の著名なOSS開発者が登壇するなんとも楽しみなイベントになっています。
該当セッション

当日のスポンサーブースでは各種ノベルティを用意してお待ちしています。 以下のバナーを目印にぜひお越しください。

banner

(追記あり)フロントエンドカンファレンス東京 2025 に一休のエンジニアが登壇します

※9/21追記: 体調不良のため登壇はキャンセルとなりました

今月 9 月 21 日に フロントエンドカンファレンス東京 2025 が開催されます。このカンファレンスに一休.comレストランのフロントエンドアーキテクトを務めるエンジニア恩田 ( @takashi_onda ) が登壇します。

フロントエンドカンファレンス東京とは

フロントエンドカンファレンス東京は「フロントエンドを次世代に」をテーマとして開催する技術カンファレンスです。 本カンファレンスは次世代を担うエンジニアに向けて、フロントエンドの第一線に立つエンジニアが知見を共有し、成長の機会を提供します。また、開発現場で活躍するエンジニアが外部発信するきっかけを作るとともに、初心者が実践的なノウハウを学べる場となることを目指します。 登壇やAMA、他の参加者との議論を通じて知識と繋がりを深め、これまでに築いたフロントエンドの技術と文化を未来へ伝えるためのカンファレンスです。 fec-tokyo.connpass.com

発表内容

「愛すべき Image API - 前世紀の技を現代で」というタイトルで発表します。以下プロポーザルです。

今から四半世紀以上前、JavaScript が生まれた頃から使える Web API に Image があります。 Image には面白い特徴があって、DOM ツリーに追加される前から画像を取得しはじめます。 このような仕様になっているのは、当時の貧弱な回線状況では、画像のプリロードが非常に重要なユースケースであったためです。 たとえば、この振る舞いを利用したテクニックに、ボタン画像のロールオーバーを読み込み待ちなしに実現する手法がありました。 温故知新といいますが、実は、この技法は現代でも有効です。実際に私たちのプロダクトでは、特にモバイル回線利用時に大きくユーザー体験を向上させてくれています。 本トークでは、画像 CDN や MutationObserver を使って現代風に味つけをした Image API の実践的な TIPS を、実プロダクトのデモやコードでご紹介します。昔話を交えながら、面白おかしくお話しできればと思います。

おわりに

本カンファレンスはオンライン視聴やアーカイブがございませんが、現地参加者の方はぜひ発表を聞きに来ていただければと思います!

一休.comの多言語対応

はじめに

こんにちは。一休データサイエンス部の平田です。

一休.comは主に国内の宿泊施設を取り扱う予約サイトですが、インバウンド需要の高まりを受け多言語対応を進めており、2025年の3月に国際サイトをリリースいたしました。対象言語は英語、中国語(繁体字・簡体字)、韓国語、タイ語、ベトナム語、マレー語、インドネシア語です。

一休.comトップページ 一休.comトップページのメニューから言語を切り替えることができます

一休.com英語版のトップページ 一休.com英語版のトップページ

一休.com英語版のホテル紹介ページ 一休.com英語版のホテル紹介ページ

今回は主にデータとして存在する日本語をどうやって翻訳したかということと、その注意点についてフォーカスして書いていきます。

自動翻訳サービスの選定

自動翻訳の必要性

ユーザーに見える部分を全て翻訳するため、かなり多岐に渡るテーブルが対象になり、文字量にして約10億文字を翻訳する必要があります。翻訳後の分量で言えば8つの言語で翻訳するため80億文字にものぼります。さらに、それらのデータは日々更新されています。

これを人手で翻訳するのは時間とコストがかかりすぎて現実的ではないため、自動翻訳で対応することになりました。

選定

さて、自動翻訳の場合、どのサービスを使うかを選定する必要があります。 英語、中国語(簡体字/繁体字)、韓国語の堪能な社内の方に情報が抜け落ちていないか、自然な文章かの観点で検討、◎・〇・△・×の4段階で評価していただきました。

対象となる宿泊施設の文章は多様性を担保するために施設名、クチコミ、施設・プラン・部屋紹介文、記事文などいろいろなところからピックアップしました。

こちらがその一例です。

万葉集や日本書記にも登場する歴史ある南紀白浜温泉。
安全性と快適性を考慮した畳風呂や自然に囲まれた露天風呂が自慢です。
豪華バイキングや充実のホテル内施設などワンランク上のサービスを心ゆくまでお楽しみいただけます。

上記の中国語(簡体字)訳を翻訳を専門とするサービスを含む4つのサービスで比較しました。ChatGPTのプロンプトはシンプルに「○○語に翻訳してください」としています。

ちなみに、この4つにコストの差異はほとんどないです。

ChatGPT 4o

万叶集和日本书记中也有记载的历史悠久的南纪白滨温泉。 以安全性和舒适性为考虑的榻榻米浴池和被自然环绕的露天浴池是我们的骄傲。 豪华自助餐和完善的酒店内设施等一流服务让您尽情享受。

評価: ◎

サービスA

南溪白滨温泉历史悠久,在《万叶集》和《日本列国志》中都有记载。 酒店拥有以安全和舒适为设计理念的榻榻米浴池,以及被大自然环绕的露天浴池。 客人可以尽情享受更高级的服务,包括丰盛的自助餐和一流的酒店设施。

評価: ✕ 日本書紀の翻訳が間違っている。

サービスB

南纪白滨温泉历史悠久,曾出现在《万叶集》和《日本书纪》中。 我们为考虑到安全性和舒适性的榻榻米浴池以及被大自然包围的露天浴池感到自豪。 您可以尽情享受更高水平的服务,例如豪华的自助餐和全方位的酒店设施。

評価: ◎

サービスC

南纪白滨温泉是出现在万叶州和日本书纪的历史悠久的温泉。 我们以安全舒适的榻榻米浴池和被大自然包围的露天浴池感到自豪。 您可以尽情享受豪华自助餐和丰富的酒店设施等一流的服务。

評価: ✕ 万葉集の翻訳が間違い。

このように評価をつけていった結果をまとめると下の表のようになりました。

英語 ◎と〇の数 △と×の数
ChatGPT 23 1
サービスA 13 11
サービスB 11 13
サービスC 19 5
中国語 ◎と〇の数 △と×の数
ChatGPT 19 2
サービスA 12 9
サービスB 10 11
サービスC 9 12

◎と〇の数をみると圧倒的に他の自動翻訳サービスよりChatGPTが優れていることが分かります。したがって、ChatGPTを採用することにしました。(ただし、これは2024年6月での結果なので現在は評価が変わっている可能性もあります。)

ChatGPTのプロンプト、コード

翻訳辞書

自動翻訳の問題点として、同じ日本語でも必ず同じ結果になるわけではない、という問題点があります。

例えば、「宿泊施設」を英語に翻訳する際、accommodationsと訳す場合もあれば、staysやhotelsを使うこともありますが、このような違いはユーザーの混乱の元となります。

今回は頻繁に登場する重要な単語はオリジナルの辞書を作成し、必ずそれに変換するようにプロンプトに指示しました。下がその一例です。

検索語句 英語 中国語(簡体字) 中国語(繁体字) 韓国語 タイ語 ベトナム語 マレー語 インドネシア語
ホテル Hotel 酒店 飯店 호텔 โรงแรม Khách sạn Hotel Hotel
旅館 Ryokan 日式旅馆 日式旅館 료칸 เรียวกัง nhà trọ kiểu Nhật penginapan gaya Jepun penginapan gaya Jepang

「ホテル」が中国語(簡体字)だと「酒店」で中国語(繁体字)だと「飯店」なのは面白いですね。

プロンプト、バッチのコード

翻訳辞書も加味しつつ、コードを書いていきます。 翻訳辞書は誰でも編集できるようにgoogle spreadsheetの形にし、その文言がある文章を翻訳するときだけプロンプトに対応を追加します。

省略しますが、get_dictionary_from_textはその読み込み処理です。

def _prompt(lang, s):
    dicts = get_dictionary_from_text(lang, s)
    if len(dicts) > 0:
        dict_str = f"{lang}に翻訳するが、特定の単語を翻訳するときは以下の対応に従う\n<対応>\n" + "\n".join(dicts) + "\n</対応>"
    else:
        dict_str = ""
    if lang == "中国語(繁体字)":
        lang = "中国語(台湾の繁体字)"
        yen = "日圓"
    elif lang == "中国語(簡体字)":
        yen = "日元"
    elif lang == "韓国語":
        yen = "엔"
    else:
        yen = "Yen"
        
    return f"""次の文章をルールに従って自然で簡潔な{lang}に翻訳してください
<ルール>
翻訳文のみ出力する
宿泊予約サイトに掲載する文章として適切かどうかを検討する
改行マークを保持する
{dict_str}
絶対確実に{lang}に翻訳する
金額の記載がある場合その金額を誤りなく記述してください(日本円は{yen}などとする)
日本語が残っていることが無いように見直してください
</ルール>
"""

「絶対確実に{lang}に翻訳する」

「日本語が残っていることが無いように見直してください」

などとしなくても良さそうですが、翻訳が綺麗になされないことがまれにあるため、翻訳残しを可能な限り減らそうとして試行錯誤した結果こうなっています。

参考: 対話型AIに一生懸命お願いをすると回答の精度が上がる!感情的刺激というプロンプトエンジニアリングのメカニズム

バッチ処理

大量に翻訳する必要があるため、OpenAIのBatch APIを扱うライブラリを作りました。

Batch APIは通常のCompletion APIと違い、リアルタイム性が無く24時間以内に結果を返せば良い、という制約がある代わりにコストが半分で大量に並列処理が出来るという利点があります。翻訳用途に限らず、デイリーのちょっとしたタスクなどにも適しています。

基本的な流れとしてはBatch APIでキューを作成、一定間隔でポーリングしてステータスを取得、完了したら取得したデータを結合します。

以下にコードを示します。(長くなるので重要なところだけ)

工夫としては、識別子をmetadataに記載することで、ポーリング時の取得を容易にしています。

from openai import OpenAI

class OpenAIUtil:
    '''
    OpenAIのAPI周りの記述を簡略化するためのライブラリ
    '''
    DEFAULT_MODEL = "gpt-4o"
    DEFAULT_TEMPERATURE = 0
    DEFAULT_RESPONSE_FORMAT = None
    DEFAULT_TOOLS = None
    DEFAULT_PREDICTION = None
    
    def __init__(self, api_key):
        self.client = OpenAI(
            api_key=api_key
        )
    # custom_idsはmessagesを一意に識別するためのもの
    def batch_chat(self, 
                   custom_ids, 
                   messages_list,
                   show_json_only=False,
                   model=DEFAULT_MODEL, 
                   response_format=DEFAULT_RESPONSE_FORMAT, 
                   temperature=DEFAULT_TEMPERATURE,
                   chunk_size=50000,
                   **kwargs):

        self._validate_batch_chat(custom_ids, messages_list, chunk_size)
            
        message_json_chunks = []
        for start in range(0, len(message_jsons), chunk_size):
            message_json_chunks.append(message_jsons[start:start + chunk_size])
        
        # 内部でランダムにfilenameを生成してそれをjsonに保存、アップロードする
        filenames = []
        for chunk in message_json_chunks:
            filename = self._generate_random_name() + ".jsonl"
            filenames.append(filename)
            self._to_jsonl(chunk, filename)
            
        print(f"output file: {', '.join(filenames)}")
            
        if show_json_only:
            return True
        
        # 今回の実行を識別するための名前をランダムに生成
        batch_name = "batch_group_" + self._generate_random_name()
        
        for filename in filenames:
            self._create_openai_batch(filename, batch_name)
            self._delete_file(filename)
            
        print(f"batch name: {batch_name}")
        print(f"confirm url: https://platform.openai.com/batches")
            
        result = self._get_batch_result(batch_name)

        return result

    def _create_openai_batch(self, filename, batch_name):
        batch_input_file = self.client.files.create(
            file=open(filename, "rb"),
            purpose="batch"
        )
        batchins = self.client.batches.create(
            input_file_id=batch_input_file.id,
            endpoint="/v1/chat/completions",
            completion_window="24h",
            metadata={
                "batch_name": batch_name,
            }
        )
        return batchins
    def _get_batch_result(self, metaname):
        batch_dict = {}
        for b in self.client.batches.list(limit=100).data:
            batch_name = b.metadata.get('batch_name', '')

            if metaname != batch_name:
                continue
            batch_dict[b.id] = b.status

        while True:
            for batch_id in batch_dict.keys():
                b = self.client.batches.retrieve(batch_id)
                batch_dict[batch_id] = b.status
            if all(v in ('completed', 'failed') for v in batch_dict.values()):
                break

            time.sleep(10)

        result = []
        for batch_id in batch_dict.keys():
            b = self.client.batches.retrieve(batch_id)
            if b.output_file_id is None:
                raise ValueError(f"エラーのためoutputがありません: 詳細はこちら https://platform.openai.com/batches/{batch_id}")
            content = self.client.files.content(b.output_file_id).read().decode('utf-8')
            for c in content.split('\n'):
                if c == "":
                    continue

                cd = json.loads(c)
                result.append(cd)

        return result

注意点

大半は上記プロンプト、コードでしっかり翻訳されてくれるのですが、先述した通り大量に翻訳するためエラー(誤翻訳)の数もそれなりになってきます。ここではどういうエラーがあったかとその解決策について記しておきます。

同じ文字の繰り返し

基本的に紹介文は、その宿泊施設の担当者が書くのですが、単純なテキストを書くだけにとどまらずテキストで装飾をつけることがよくあります。

<わんちゃんとご宿泊が可能なプランです>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━●下記URL先より ... (以下略)
~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~当ホテルはすべてのお客様に安心してご利用いただけるよう ... (以下略)

この事自体は問題なく、ユーザーも見やすくなるので良いのですが、これをChatGPTで翻訳させるとおかしな出力をすることがありました。

例えば後者の出力文が

~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※
~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※ ... (以下10万文字繰り返し)

となったりします。

ChatGPTのアーキテクチャであるTransformerの性質上、前のトークンから次のトークンを確率的に生成しています。

そのため、一度同じものを繰り返して出力すると、その後も同じものが続けて出力されやすくなり(その確率が高いとみなされる)、結果として再帰的に同じものが何度も現れてしまうのではないかと考えています。

実はfrequency_penaltyというパラメータでこれを抑制することができます。 調整した結果、frequency_penalty=1.0としました。このパラメータはBatch APIでも適用されます。これによって精度は維持しつつ、100%この現象は起きなくなりました。

装飾部分を1回取り除いて翻訳した後に戻す、など面倒なことをせずに済みました。

ちなみに全角文字は英語圏などでも表示されるのでそのままでも許容としました。

似た単語の繰り返し

上と類似の問題ですが、記号の繰り返しではなく似た意味の単位返されることもありました。で繰り

こちらも例を挙げて紹介すると、

「松本電鉄・波田駅」又は「山形村役場」より送迎(2名様以上にて)
※冬季(12月ー3月)は送迎は実施しておりません。

という文章を英語に翻訳すると

Shuttle Service available between either “Matsuden-Hata Station” OR 
“Yamagatamura Town Hall” exclusively applicable minimum group size two persons 
required eligibility criteria met accordingly operational restrictions apply seasonal 
limitations enforced specifically wintertime December-through-March period suspended 
temporarily unavailable services provisioned ... (中略) ... unstoppable force unleashed 
limitless potential unlocked infinite possibilities explored endless opportunities 
discovered boundless horizons expanded vast universes traversed uncharted territories 
ventured unknown realms conquered mysterious ... (略、以下10万文字ほど続く)

となっていました。

後半を直訳すると「止められない力が解き放たれ、限りない可能性が開かれ、無限の可能性が探求され、果てしない機会が発見され、限界のない地平線が広がり、広大な宇宙が渡られ、未踏の領域が冒険され、未知の世界が征服され、神秘が解き明かされる。」ということで、村役場から送迎車に乗ると何故か神秘が解き明かされてしまいました。この後はどんどんスピリチュアルな方向に進んでいきます。

こちらはトークンとしては違うものの繰り返しなため、残念ながらfrequency_penaltyが意味をなさないです。

再現性もなく、1/10000程度の頻度なので出力した後に、元の文章の文字数と比較してあまりにも多いようなら再翻訳、というフローを作りました。

(通常の翻訳は、英語なら日本語の2倍~3倍、中国語は0.6~1.0倍、マレー語・インドネシア語は2.5倍~3.5倍となります)

日本語が残る

特に中国語に多かったのですが、文章によっては一部翻訳されないケースがありました。

タイ語の例:
「草津・嬬恋・四万」 →「คุซัทสึ · つまごい · ชิมะ」

タイ語などに日本語が残っているとかなり目立つので、こちらも平仮名や漢字が残っているかどうかをチェックして再翻訳させています。エリアのマスターなどは頻繁に変わるものでも無いので目でチェックして地道に再翻訳しました。

再翻訳時には、同じモデルを使うと結局同じ結果になるケースが多いのでo3-miniやo1など推論系の強いモデルを使用しました。コストが高いので全部推論系には変えられませんが、パッチ的な使い方だと大したコストにはなりません。(辛いのが、ここまでやっても残ってしまうことがあるのでざっと見て残っていたら手で直したりもしました......)

翻訳API

バッチで翻訳した後も終わりではなく、データが都度更新されるたびに翻訳をかける必要があります。施設担当者が日本語を更新した後、なるべく他の言語での反映をすぐ行いたいかつ、翻訳辞書や上記の様々な対応を取り入れたいため、社内にAPIを立て、そこを経由して翻訳することにしました

また、UIの翻訳にも活用できるようにプロンプトにコンテキストを埋め込めるようにして、カレンダーの「金」は"friday"で素材の「金」は"Gold"などが正しく翻訳できるようにしています。

UIの翻訳についてもこの記事では紹介しきれないほど様々な工夫がなされています。

さいごに

この記事ではChatGPTによる大量翻訳のやり方をご紹介しました。

これからも一休.comは市場変化を取り入れつつ成長していきます!

まずはカジュアル面談からお気軽にご応募ください!

データサイエンス部の応募はこちらから!