ホワイトヘルスケア - テックブログ

ホワイトヘルスケアは、日本のヘルスケア領域における社会課題に正面から向き合い、現実世界を生きる人々の不安や痛みといった見過ごされるべきでない問題に対して本質的な解決に取り組むことで、持続的な医療システム・社会の実現を目指します。

レセプトデータ分析における注意点

1. 概要

レセプトデータの分析では、データの特性を理解しないまま処理を進めると、思わぬエラーや分析結果の誤りにつながります。本記事では、数多くある注意点の中から代表的な3つの例(結合キーの重複、傷病名コードの先頭ゼロ欠落、ICD-10-2の見落とし)を取り上げ、具体的な対策とコード例を紹介します。

2. はじめに

こんにちは。アナリティクスエンジニアの横田です。
ホワイトヘルスケアでは、リアルワールドデータの一つであるレセプトデータを分析しています。DWHとしてはBigQueryを用い、dbtやPythonを使ったデータの前処理、分析を行っています。 データ分析結果の品質担保は難しい側面があるものの、例えばdbtではデータに対するテストを記述でき、これにより一定の品質担保に貢献できます。
この記事では、私たちがレセプトデータの分析において気をつけている数多くのポイントの中から、特に重要な例をいくつか共有し、同様の課題に取り組む方々の分析品質向上に貢献することを目的とします。

3. 注意点(代表例)

a)結合キーの重複によるレコード重複

データの前処理・分析において、さまざまなテーブルを結合しますが、その結合キーが重複していると意図しない重複行が発生してしまいます。 例えば、レセプトデータの分析では、さまざまなマスタテーブルを用意します(例:医薬品マスタ、疾患マスタ、検査值クレンジングマスタ)。これらは医療専門職の方に作成いただくことも多く、クレンジング用のマスタファイルは継ぎ足し継ぎ足しで作成されがちです。それらを使用する際は、必ずキーカラムがユニークであることを確認しましょう。

対策

  • Python (pandas/polars): merge/joinの際にvalidate引数を必ず指定します。下記の例では、drug_masterのdrug_codeがユニークでない場合、エラーが発生します。
# pandas
result = df_drug.merge(drug_master, on="drug_code", validate="m:1")

# polars
result = df_drug.join(drug_master, on="drug_code", validate="m:1")
  • dbt (SQL): schema.ymlでuniqueテストを定義し、キーカラムのユニーク性を担保します。
# schema.yml
models:
  - name: stg_drug_master
    columns:
      - name: drug_code
        tests:
          - unique
          - not_null
  • CTEの途中はテストできないため、QUALIFYを使って強制的にユニークにするという手もあります。
select *
from {{ ref('drug_master') }}
qualify row_number() over (partition by drug_code order by updated_at desc) = 1

b)傷病名コードの先頭ゼロ欠落

傷病名コードは7桁ですが、先頭が0のコードがあります(例:胃腸炎:0091005)。 CSV形式のマスタファイルを取り扱う際は、特に注意が必要です(医薬品や診療行為のレセプト電算コードでは先頭0がないため、傷病名コード特有の問題です)。

対策

傷病名コードを取り扱う場合は、7桁であることを確認し、必要に応じて0埋めを行います。

# pandas
df['disease_code'] = df['disease_code'].astype(str).str.zfill(7)

# 7桁であることをテスト
assert df['disease_code'].str.len().eq(7).all(), "全ての傷病名コードが7桁である必要があります"

# polars
df = df.with_columns(
    pl.col('disease_code').cast(pl.Utf8).str.zfill(7)
)

# 7桁であることをテスト
assert df.select(pl.col('disease_code').str.len_chars().eq(7)).to_series(0).all(), "全ての傷病名コードが7桁である必要があります"
select
    lpad(cast(disease_code as string), 7, '0') as disease_code
from {{ ref('disease_data') }}

7桁のテスト

# schema.yml
models:
  - name: disease_data_cleaned
    columns:
      - name: disease_code
        tests:
          - dbt_utils.expression_is_true:
              expression: "length(disease_code) = 7"
        description: "傷病名コード"

c)ICD-10-2の見落とし

傷病名の分類でよく使うICD-10ですが、実は1と2があります。ICD-10-1は基礎疾患の分類、ICD-10-2は症状発現の分類になります。 ICD-10-1のみを対象とすると、ICD-10-2に分類される傷病名が抽出できないため、注意が必要です。

対策

ICD-10-1、ICD-10-2両方のカラムを対象に抽出します。マスターテーブルを使ってdisease_codeに変換してから抽出すると便利です。

# pandas
icd_pattern = r'^E1[14]'  # 2型糖尿病関連コードの例(E11, E14)

# マスターから対象のdisease_codeを取得
target_disease_codes = disease_master[
    (disease_master['icd10_1'].str.contains(icd_pattern, na=False)) |
    (disease_master['icd10_2'].str.contains(icd_pattern, na=False))
]['disease_code'].tolist()

# 実データから抽出
df_filtered = df[df['disease_code'].isin(target_disease_codes)]

# polars
# マスターから対象のdisease_codeを取得
target_disease_codes = disease_master.filter(
    (pl.col('icd10_1').str.contains(icd_pattern)) |
    (pl.col('icd10_2').str.contains(icd_pattern))
).select('disease_code').to_series().to_list()

# 実データから抽出
df_filtered = df.filter(pl.col('disease_code').is_in(target_disease_codes))
with

target_disease_codes as 
(
    select distinct disease_code
    from {{ ref('disease_master') }}
    -- 2型糖尿病関連コードの例(E11, E14)
    where 
        regexp_contains(icd10_1, r'^E1[14]') 
        or regexp_contains(icd10_2, r'^E1[14]')
)

select *
from {{ ref('disease_data') }} as d
inner join target_disease_codes as t
    on d.disease_code = t.disease_code

4. まとめ

レセプトデータ分析で注意すべき点は数多くありますが、本記事で取り上げた代表的な3つの例は以下の通りです:

  1. 結合キーの重複対策: merge/joinの際は必ずvalidate引数を指定し、dbtではuniqueテストを実装
  2. 傷病名コードの先頭ゼロ欠落対策: zfill()やlpad()で7桁に0埋めし、assertやdbt testsで検証
  3. ICD-10-2の見落とし対策: icd10_1, icd10_2両方のカラムを対象に正規表現で抽出

これらの対策例を参考に、各プロジェクトの特性に応じた品質担保の仕組みを構築することで、信頼性の高い分析結果を得ることができます。

なお、レセプトデータ分析における注意点は上記以外にも多数存在します。継続的にデータ品質の向上に取り組むことが重要です。

5. 採用情報

医療データ分析、ホワイトヘルスケアに興味を持たれた方はこちらへご連絡ください!

hrmos.co

MSWを使ってStorybookにダミーデータを表示しようとしてS3ホスティングでハマった話

はじめに

ホワイトヘルスケア エンジニアの林です。

今回はReact開発で便利なモックサーバーツール「MSW(Mock Service Worker)」を使っていて、StorybookをS3でホスティングしたときに発生した問題とその対処法について共有します。


🧪 背景:開発中はMSWでモックデータを表示

普段のReact開発では、以下のような構成で進めていました。

  • React + Vite でフロントエンドを構築
  • APIのモックには MSW
  • コンポーネントのドキュメントには Storybook
  • Storybookでも実データではなく MSWでモックデータ表示

ローカルでは、storybook とMSWを組み合わせて、MSWで返すダミーデータを表示するようにしていました。具体的には、以下のように preview.js で MSW を有効化していました。

// .storybook/preview.js

import { initialize, mswLoader } from "msw-storybook-addon";

initialize(
  // ...
  // 中略
  // ...
  // mockServiceWorker.jsは/public以下に配置
  serviceWorker: {
    url: "/mockServiceWorker.js",
  },
});

const preview: Preview = {
  parameters: {
    // 一部略
    msw: {
      handlers,
    },
  },
  loaders: [mswLoader],
};

また、handlersは以下のような定義になっていました。

// src/mocks/handlers.ts

export const hadlers = [
  http.get("モックするURL",
    () => {
      return HttpResponse.json({
       xxx: [
          {
            id: 1,
            name: "name1",
          },
          {
            id: 2,
            name: "name2",
          },
        ],
      });
    },
  ),
];

🌐 問題:S3にStorybookをホスティングしたら動かない

社内では、コード管理をGitHubで行なっており、機能変更、取り込みはプルリクエストを用いて行なっています。また、frontendの変更を伴うプルリクエスト時には、StoryBookをAWS S3にホスティングし、UIの変更をブラウザ上から確認できるような設定にしていました。

しかし、以下のようなエラーが発生しMSWが動かない状況になりました。

[MSW] Failed to register a ServiceWorker for scope ('https://s3-region.amazonaws.com/bucket-name/...') with script ('https://s3-region.amazonaws.com/bucket-name/mockServiceWorker.js'): ServiceWorker script does not support cross-origin requests.

🧩 原因:Service Workerのパスがローカルとホスティングで異なっていた

エラーログとしてはCORSでしたが、実際は参照するmockServiceWorker.jsの配置が想定と異なっていることが原因でした。

mockServiceWorker.jsが存在しているはずのURLにブラウザから直接アクセスしたところ、ファイルがなかったことから判明しました。


✅ 対処法:ローカル開発とホスティング時のパスの統一

今回は、StorybookのS3ホスティング時の問題だったので、mockServiceWorker.jsの配置を以下のように実際のパスを参照するように変更しました。

// preview.js

// mockServiceWorker.jsのパスをローカル、ホスティングに関わらず参照できるようにする
const getWorkerPath = () => {
  const pathname = window.location.pathname;
  const base = pathname.replace(/\/[^/]*$/, "/"); // 最後のファイル名を除く
  return `${base}mockServiceWorker.js`;
};

initialize(
  // ...
  // 中略
  // ...
  // mockServiceWorker.jsは/public以下に配置
  serviceWorker: {
    url: getWorkerPath(),  // 変更
  },
});

📝 まとめ

  • MSWはローカル開発では非常に便利
  • Storybookでも簡単にダミーデータを表示できる
  • S3やCloudFrontにホスティングしたときのService Worker制約には注意

採用情報

ホワイトヘルスケアでは、100年先でも国民が質の高い医療を適正負担で受けられる社会の実現を目指しています。 少しでも気になった方は、ぜひお気軽にお問い合わせください!

採用情報詳細

hrmos.co

dbtでレセプトデータをモデリングしてみたら、定石が全然通用しなかった話

はじめに

  • この記事は開発部データチームの石田が医療データ(レセプト)の標準化をdbtで進める中で体験した、理想と現実のギャップを記録したものです
  • 医療系データエンジニアの方や、複雑なドメインでdbtを使ったデータ基盤構築の経験がある方を想定しています
  • レセプト(診療報酬明細書)データの特殊性について触れるので、レセプトデータの構造をある程度知っている方を前提としています

dbtの「理想」と医療データの「現実」のギャップ

最近ではデータ分析基盤にdbtを使うのが定番になっています。少し前まではDataform等のライバルも存在していましたが、コミュニティの盛り上がりや公式ドキュメントの整備具合からみても、同系統のツールならdbt一択という感じがします。

しかし、いざ公式ドキュメントに沿った実装を行おうとすると、なかなか難しいという現実に直面します。

dbt公式のBest Practicesを見ると、こんな前提で書かれています:

  • stagingでは軽い変換のみ行う
  • 1つのソーステーブル = 1つのstagingモデル
  • インクリメンタル処理はupdated_atベース
  • モデルの粒度は明確に分離する

dev.classmethod.jp

この処理レイヤーによる整理は秀逸で、データの品質を担保する上で非常に有効です。大概のデータは、これに習って整理することで、データパイプラインの構築時に突き当たる課題を解決することができます。 ところが、レセプトのような複雑な構造を持つ医療データを扱っていると、なかなか難しい部分があります。

dbtベストプラクティスの前提 レセプトデータの現実
構造が統一されている ファイル種別で構造が全然違う
カラム数が一定 同一ファイル内でも行ごとにカラム数が変わる
仕様書が明確 医療制度の知識がないと仕様書すら読めない
データ品質が安定している 仕様通りじゃないことが日常茶飯事
シンプルなJOIN設計 識別単位が複雑に絡み合っている
updated_atでインクリメンタル処理 過去月データが後から修正・再送される

つまり、公式のベストプラクティスを「教科書通り」に適用しようとすると、ことレセプトデータに限って通用しない部分が多いです。

「比較的シンプルな構造向けのベストプラクティス」と「複雑な構造を持つデータへの現実的な対応」では、dbtの使い方が全然違うよね、を最初に理解して次に進んでいただければと思います。

なぜ医療データでは定石が通用しないのか

レセプトデータって何がそんなに厄介なの?

レセプト(診療報酬明細書)データは、CSV形式で提供されるんですが、実態としては「CSV の皮を被った何か」みたいな感じです。 特に厄介なのが以下の点:

  • 医療機関の種類ごとにファイルが4つに分かれている
    • 医科・DPC・歯科・調剤でそれぞれ構造が異なる(医科とDPCは似てるけど、歯科・調剤は別物)
  • 同一ファイル内でも行ごとに構造が変わる
    • MNIRRESYIYなど、データ識別単位(レコード種別)によってカラム数が全然違う
  • 仕様書通りじゃないことが多い
    • データ不備や変則的な構造が存在するのが普通
    • 保険者ごとに微妙に違ったりもする
  • 読み手に高い専門性が求められる
    • 医療知識・制度の理解がないと、そもそも仕様書が読み解けない

この時点で「あ、これはdbtの想定してる世界じゃないな...」って気づくわけです。

参考:

www.phm-jmdc.com

dbtの定石に「従えなかった」具体例と工夫

1. stagingで"軽く整える"どころじゃない問題

通常、dbtではstagingモデルで名前変換・型付け程度を行い、ロジックはintermediateに任せる設計が推奨されています。

従わなかった場合

-- 理想的には staging では軽い処理のみのはずが...
with raw as (
  select * from {{ source('app_db', 'receipt_files') }}
),

-- 実際にはバリデーション処理がガッツリ必要
validated as (
  select
    id as insurer_receipt_file_id,
    receipt_file_id,
    seq_number,
    -- ファイル形式チェック
    case when regexp_contains(file_name, r'\\.csv$') then true else false end as is_valid_format,
    -- 保険者コードの正当性チェック
    case when length(insurer_code) = 8 then insurer_code else null end as validated_insurer_code,
    -- 診療年月の妥当性チェック
    case when regexp_contains(medical_care_date, r'^20[0-9]{2}(0[1-9]|1[0-2])$')
         then medical_care_date else null end as validated_medical_care_date

    -- 以下、延々とCTEが続く.....
  from raw
  where deleted_at is null
    and file_status = 'completed'
)

select * from validated
  • レセプトデータは「仕様通りじゃない」ことが前提なので、stagingの段階で相当なバリデーション処理が必要になります
  • 「後工程でエラーになるより、早い段階で不正データを除外した方が効率的」という判断をしました
  • きれいなデータなら staging → intermediate → mart の責務分離が理想ですが、汚いデータでは「staging でできるだけ整える」方が現実的だと考えています

2. モデルの粒度設計で悩みまくった話

データ識別ごとにstagingモデルを作る正攻法は理想的ですが、現実にはファイル種別×識別単位の組み合わせが100近くになってしまいました。

従わない場合

-- 本来なら SI、IY、TO、CO それぞれ別モデルにするべきだが...
with si_records as (
  select * from {{ source('receipt', 'raw_receipt_data') }}
  where record_type = 'SI'  -- 診療行為
),

iy_records as (
  select * from {{ source('receipt', 'raw_receipt_data') }}
  where record_type = 'IY'  -- 医薬品
),

-- さらに TO(特定機材)、CO(コメント)も...
-- これを100パターン作るのは現実的じゃない

修正後

-- 構造が似ており連携が強い識別単位はまとめて処理
with tekiyo_info as (
  select
    receipt_id,
    record_type,
    procedure_code,
    procedure_name,
    unit_price,
    quantity,
    points
  from {{ source('receipt', 'raw_receipt_data') }}
  where record_type in ('SI', 'IY', 'TO', 'CO')  -- 摘要情報をまとめて処理
    and procedure_code is not null
),

final as (
  select
    receipt_id,
    record_type,
    procedure_code,
    procedure_name,
    coalesce(unit_price * quantity, points) as total_points
  from tekiyo_info
)

select * from final
  • 理想的には1つのレコード種別=1つのモデルですが、品質・コスト両面で現実的ではありませんでした
  • SI(診療行為)、IY(医薬品)、TO(特定機材)、CO(コメント)などの「摘要情報」は構造も類似しており、相互に依存しているため、ひとつのstagingモデルに統合しました
  • 「ベストプラクティスを破る」ことで、設計の複雑さと実行効率のバランスを取る判断をしました

3. インクリメンタルが全然うまくいかない問題

従わない場合

-- 普通のインクリメンタル処理
{{
  config(
    materialized='incremental',
    unique_key='receipt_id'
  )
}}

select * from {{ ref('stg_receipt') }}
{% if is_incremental() %}
  where updated_at > (select max(updated_at) from {{ this }})
{% endif %}

修正後

-- レセプト特有の事情に対応
{{
  config(
    materialized='incremental',
    unique_key=['invoice_year_month', 'receipt_id'],
    incremental_strategy='insert_overwrite'
  )
}}

select
  invoice_year_month,
  receipt_id,
  medical_institution_code,
  patient_id,
  total_points
from {{ ref('stg_receipt') }}
{% if is_incremental() %}
  where invoice_year_month >= '{{ var("target_month") }}'
{% endif %}
  • レセプトでは毎月、過去の月データが再送・修正されることがあります
  • 「最終更新日時」でのインクリメンタル処理では、「3月分が6月に再送される」といったケースに対応できません
  • そこで、invoice_year_month + receipt_idをキーに全件削除・再生成する構成にしました
  • 実行時間は少し増えますが、データの整合性を確保できる設計になっています

現実的な工夫と"ゆるやかな妥協"

中間テーブルの materialized 戦略

-- models/intermediate/receipt/receipt_detail_flattened.sql
{{ config(materialized='table') }}  -- 再利用するので table に

select
  receipt_id,
  procedure_code,
  procedure_date,
  unit,
  point
from {{ ref('stg_receipt_details') }}
where procedure_code is not null
  • 中間テーブルのmaterializedはviewephemeralにするのが理想ですが、レセプトデータの場合はそれだとパフォーマンスが悪くなるため、tableにしました
  • 再利用する中間ロジックはmaterialized='table'にし、BigQueryの分散処理に乗せることでコストを抑えました
  • Pythonで複雑な処理を書くこともできますが、実行環境のリソース管理やデータサイズのばらつき(保険者による)を考えると非現実的でした

最後に

レセプトのパイプラインを構築して改めて「ドメインの特性を理解すること」の大切さを感じました。 dbtの知見が詰まったベストプラクティスは強力ですが、医療データのような複雑なドメインでは、それを形式的に守るのは難しいこともあります。 ただし、以下の設計思想そのものは、むしろ医療データのような難しい領域でこそ活きると感じました:

  1. モデルの責務を明確に分ける

    • stagingの役割が重くなっても、「データ検証」という明確な責務を持たせる
    • 無理に軽量化するより、各段階で何をするかを明確にする方が重要
  2. 定義を統一し、再利用可能にする

    • マクロやテスト定義で、医療制度特有のバリデーションロジックを標準化
    • 「保険者コード8桁チェック」「診療年月の妥当性確認」などを共通化
  3. 依存関係を可視化して、影響範囲を把握する

    • レセプトの複雑な識別単位でも、lineageグラフで上流・下流の関係が一目瞭然
    • データ修正時の影響範囲が事前に分かるため、安全にメンテナンスできる
  4. テストによる品質担保

    • 「仕様通りじゃない」前提だからこそ、自動テストでデータ品質をチェック
    • severity: warnで処理を止めずに問題を可視化する柔軟性

このような状況を前にすると、ベストプラクティスに従わず「オレオレ設計」で切り抜けたくなるのですが、結局のところ「なぜそのプラクティスが存在するのか」を理解した上で、自分たちのドメインに適応させることが大切だと思いました。 医療データのような複雑な領域で格闘している方の参考になれば幸いです。


採用情報

もし似たような課題でお困りの方がいらっしゃれば、ぜひコメントやSNSでお気軽にお声がけください。一緒に解決策を考えましょう!

hrmos.co

AWS IAM RoleとGoogleサービスアカウントのWIP連携によるキーレス認証

こんにちは、開発チームの菅野です。

弊社ではアプリケーションにAWS、データ基盤にGoogle Gloud BigQueryを利用していて、 両者でデータをやり取りする場合に認証が必要になります。

その際、AWS IAM Role とサービスアカウントの間でWorkload Identity Poolによる連携を行い、 恒常的なキーを利用することなくセキュアに認証を行っていきたいので、その取り組みを紹介していきます。

前提

  • ECS上で稼働するRailsアプリケーション
  • RDSとBigQueryの間でデータのやりとりをしたい
  • Terraformによる構成で管理

キーレス認証の重要性

まず長期的なキーを作成することなく認証を行いたいという前提があります。 もし恒常的なキーをアプリケーションに設定した場合、このキーが長期的な侵害リスクを抱えてしまうことになります。

タイトルの通り、IAM UserではなくIAM Roleを利用しているのもそのためです。 ベストプラクティスに従い、RailsからはアタッチされたECSタスクロールが sts:AssumeRole することによってAWSの各種リソースにアクセスが可能になります。

docs.aws.amazon.com

同様に、AWSGoogle Cloudの連携についてもキーレスでセキュアなアクセスを志向したいので、 Workload Identity Pool (WIP)連携を使う方向で取り組んでいきます。

cloud.google.com

実装

直接関係のないリソースについては割愛しますが、 Terraformの構成については以下のようになります。

AWS側の設定

まずはAWS ECSタスクロールに必要な権限を付与します:

resource "aws_iam_role" "ecs_task_role" {
  name               = "${var.name_snake}_${var.env}_fargate_ecs_task_role"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_role.json
}

data "aws_iam_policy_document" "ecs_task_role" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type = "Service"
      identifiers = [
        "ecs-tasks.amazonaws.com",
        # その他必要なサービス
      ]
    }
  }
}

resource "aws_iam_role_policy" "role_inline" {
  # 必要な権限を付与するポリシー
}

Google Cloud側の設定

Google Cloudのサービスアカウントを作成し、必要な権限を付与します:

resource "google_service_account" "app_user" {
  account_id   = "app-user"
  display_name = "app-user"
}

resource "google_project_iam_member" "bigquery_job_user" {
  project = var.project_id
  role    = "roles/bigquery.jobUser"
  member  = "serviceAccount:${google_service_account.app_user.email}"
}

# 他の必要な権限

Workload Identity Poolの設定

AWSGoogle Cloudで連携するためのWorkload Identity Poolを設定します: ここでは大元になるAWSとのWIPと、それを利用するProviderの構成が必要になります。

これにより、AWSが信頼できるアクセス元とみなされ、 GoogleのサービスアカウントとECSタスクロールとの接続が許可されます。

resource "google_iam_workload_identity_pool" "aws_pool" {
  workload_identity_pool_id = "aws-pool"
}

resource "google_iam_workload_identity_pool_provider" "aws_app_provider" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.aws_pool.workload_identity_pool_id
  workload_identity_pool_provider_id = "aws-app-provider"
  
  aws {
    account_id = var.aws_account_id
  }
}

resource "google_service_account_iam_binding" "workload_identity_binding" {
  service_account_id = google_service_account.app_user.name
  role               = "roles/iam.workloadIdentityUser"
  members = [
    "principalSet://iam.googleapis.com/projects/${var.project_number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.aws_pool.workload_identity_pool_id}/attribute.aws_role/arn:aws:sts::${var.aws_account_id}:assumed-role/${aws_iam_role.ecs_task_role.name}/*"
  ]
}

アプリケーションへの設定

次にアプリケーションへの設定手順です。 通常のサービスアカウントのキーとは違い、認証に関する構成情報のJSONをアプリケーションに設定していきます。 (認証情報ではなく、あくまで認証に関する構成情報なのでこれはgit管理に乗せることが可能です)

構成情報はコンソール上からダウンロードするか、以下のようにgcloudで作成します。

gcloud iam workload-identity-pools create-cred-config \
  "projects/[PROJECT_NUMBER]/locations/global/workloadIdentityPools/[WIP_ID]/providers/[PROVIDER_ID]" \
  --service-account="[SERVICE_ACCOUNT_EMAIL]" \
  --aws
  --output-file="sample.json"

あとは通常のサービスアカウントキーのjsonファイルと同様に、このjsonをアプリケーションの認証設定に適用すれば準備は完了です。

これでAWS ECS上で稼働するRailsアプリケーションから、ECSタスクロールを通じてAWSの各種リソースに、 またECSタスクロールからサービスアカウントを通じてGoogle Cloudの各種リソースに対しても恒常的なキーを介さずにアクセスができるようになりました。

余談

このようにクラウド上でのWIP連携によるAWS - Google間のアクセスを行ってきましたが、 実はローカル環境での疎通がうまくいっていません。

ローカル環境ではもともと awslabs/amazon-ecs-local-container-endpoints を利用して タスクロールの検証やクラウドリソースとの疎通を行っていましたが、WIP連携の認証構成情報とうまく噛み合わず、 また両クラウドRuby SDKの内部的な実装のギャップも相まって技術的にまだ解決できていない課題となってます。

github.com

こちらはもしかしたら ecs-local-container-endpoints ではなく IAM Roles Anywhare を利用した方がスムーズかもしれません。

aws.amazon.com

【入社エントリー】医療データ分析×地方在住フルリモート×育児 を目指してWHCにジョインしました!

はじめに

はじめまして。開発部データチームの徳本と申します。 2025年4月よりホワイトヘルスケア(以下、WHC)にジョインし、福岡県からフルリモートで勤務しています。

本記事は、入社から1ヶ月が経過したタイミングで執筆した入社エントリとなります。

前回の更新から約1年ぶりとなるテックブログですが、そんな節目にふさわしい(?)記事となっていれば嬉しいです。

写真は福岡県糸島市の浜辺です。自然に囲まれ、家族でのんびり暮らしています。

【プロフィール】
看護系の学部・大学院を卒業後、新卒で産業保健師として従事。健康診断やストレスチェックの企画・運営、従業員への保健指導・健康教育などを担当。
約4年後、興味のあったエンジニア職へ未経験でキャリアチェンジ。データエンジニアとして、主にデータ基盤の構築やETL処理の開発に従事。
エンジニアとして2社を経験した後、2025年4月よりWHCに入社。医療分野におけるデータ分析エンジニアとして新たなキャリアをスタート。
妻と2歳の息子と3人暮らし。現在は福岡県に在住。

なぜWHCに入社したのか

入社を決めた理由は、大きく以下の3つです。

ヘルスケアの未来を作るという社会的意義

少子高齢化や人口減少、働き手の不足など、日本の医療制度は将来に向けてさまざまな課題を抱えています。 WHCは「医療費の適正化 ✕ 患者の医療参画」というアプローチで、こうした課題に真正面から取り組んでいます。

将来に対して漠然とした不安を抱えていた私にとって、こうした取り組みに貢献できることに大きな魅力を感じ、入社を強く意識するきっかけとなりました。

ベンチャー気質ながらも落ち着いた雰囲気

社員数は40名ほど(入社当時)と少数精鋭で、ベンチャーらしいスピード感のある環境を想像していましたが、採用面接で話したメンバーの雰囲気がとても穏やかで親しみやすく、良い意味でギャップを感じました。

また、30〜40代が中心の落ち着いた年齢層もあり、自分自身にフィットする職場だと感じた点も決め手のひとつです。

リモートを前提とした柔軟な働き方

弊社 横田のエントリ記事でも紹介されていますが、WHCではリモート前提の働き方を実現するための工夫が随所に見られます。

地方在住かつ子育て中の私にとって、柔軟な働き方が可能な点は非常に重要でした。 面接でもその旨を率直に伝えたところ、制度や運用の詳細について丁寧な説明を受け、不安なく入社を決めることができました。

入社後の印象

ここからは、入社から1ヶ月経った時点での率直な感想です。

1️⃣多様なバックグラウンドを持つメンバーとの協働

WHCでは、ヘルスケア業界経験者・未経験者問わず、さまざまなバックグラウンドを持つメンバーが在籍しています。

それぞれが専門性を活かしながら、プロジェクトの成功に向けて連携する姿はとても刺激的です。 エンジニアとして技術に偏りがちな視野を広げ、多角的な視点で物事に取り組める環境に魅力を感じています。

2️⃣テキストコミュニケーション文化の成熟

リモートワークを前提とするWHCでは、Slackを中心としたテキストベースのコミュニケーションが根付いています。

進捗共有はもちろん、雑談用チャンネルも活発で、部署や職種を越えて気軽にやりとりができる環境が整っています。 地方に住む私にとって、こうした“つながり”を感じられる場はとても貴重に感じています。

勤務開始/終了を報告するチャネルもあります。沢山リアクションがもらえて朝からHappyになれます。

3️⃣フレキシブルな就業時間

WHCではフルフレックス制度(コアタイムなし/AM5:00〜PM10:00)を導入しています。

早朝に始業して早めに業務を終えたり、家庭の都合に合わせて一時中抜けしたりと、柔軟な働き方が可能です。 私自身も保育園の送迎や夕食準備を担うことが多いため、非常に助かっています。

前職はフル出社だったため、家族との時間はもちろん、趣味や学習の時間も確保できるようになり、日々の生活にゆとりが生まれました。

さいごに

最後まで読んでいただき、ありがとうございました! 拙い文章ではありますが、「やりがいがあって、柔軟に働ける環境だな」と感じていただけたら嬉しいです。

これからも自分自身の成長を重ねながら、WHCの一員として日本の医療の未来に貢献していきたいと思います。

採用情報

WHCにご興味をお持ちいただけた方は、ぜひ以下からご覧ください。 一緒に働ける日を楽しみにしています! hrmos.co

Railsでお手軽全文検索 - search_copとMySQLの全文検索機能を活用

概要

RailsActiveRecordを拡張するsearch_copという検索用のgemと、MySQL全文検索機能を組み合わせることで、シンプルな全文検索を実装できます。

動作環境

はじめに

こんにちは、開発チームの丸尾です。今回は、Railsで簡単に全文検索を実装する方法について紹介します。

search_copは、シンプルな記述で複数のキーワードを使った検索を可能にするgemですが、実はMySQL全文検索機能にも対応しています。 今回は、実際にsearch_copとMySQL全文検索を組み合わせて使ってみた結果をお伝えしつつ、その設定方法と使い方についても解説していきます。

MySQL全文検索のモードについて

MySQL全文検索のモードは以下の三つあります。

  • IN NATURAL LANGUAGE MODE
  • IN BOOLEAN MODE
  • WITH QUERY EXPANSION

search_copを使った全文検索機能はIN BOOLEAN MODEを使った検索になります。それぞれのモードについての説明はここでは割愛します。
詳細は以下をご確認下さい。
MySQL :: MySQL 8.0 リファレンスマニュアル :: 12.10 全文検索関数

インストールと設定

search_copのインストール

Gemfileにsearch_copを追加し、bundle installを実行します。

gem 'search_cop'

モデルの作成

bin/rails generate model Book title:string content:text
  • db/migrate/xxxxx_create_books.rb
class CreateBooks < ActiveRecord::Migration[7.0]
  def change
    create_table :books do |t|
      t.string :title
      t.text :content

      t.timestamps
    end
  end
end

FULLTEXT INDEXを作成

bin/rails generate migration AddFulltextIndexToBooks
  • db/migrate/xxxxx_add_fulltext_index_to_books.rb
class AddFulltextIndexToBooks < ActiveRecord::Migration[7.0]
  def up
    execute("ALTER TABLE books ADD FULLTEXT INDEX `index_books_on_fulltext` (`title`, `content`) WITH PARSER ngram")
  end

  def down
    execute("ALTER TABLE books DROP INDEX `index_books_on_fulltext`")
  end
end

WITH PARSER ngram オプションを使用することで、文字列をn文字単位で分割してインデックスを作成します(デフォルトは2文字です)。 日本語のようにスペースで単語が区切られていない言語に対して全文検索を効果的に行うためには、ngram全文パーサーが必須です。

参考: MySQL :: MySQL 8.0 リファレンスマニュアル :: 12.10.8 ngram 全文パーサー

migrate実行

bin/rails db:migrate

FULLTEXT INDEXの作成を確認

mysql> SHOW INDEX FROM books \G
*************************** 1. row ***************************
        Table: books
   Non_unique: 0
     Key_name: PRIMARY
 Seq_in_index: 1
  Column_name: id
    Collation: A
  Cardinality: 0
     Sub_part: NULL
       Packed: NULL
         Null:
   Index_type: BTREE
      Comment:
Index_comment:
      Visible: YES
   Expression: NULL
*************************** 2. row ***************************
        Table: books
   Non_unique: 1
     Key_name: index_books_on_fulltext
 Seq_in_index: 1
  Column_name: title
    Collation: NULL
  Cardinality: 0
     Sub_part: NULL
       Packed: NULL
         Null: YES
   Index_type: FULLTEXT
      Comment:
Index_comment:
      Visible: YES
   Expression: NULL
*************************** 3. row ***************************
        Table: books
   Non_unique: 1
     Key_name: index_books_on_fulltext
 Seq_in_index: 2
  Column_name: content
    Collation: NULL
  Cardinality: 0
     Sub_part: NULL
       Packed: NULL
         Null: YES
   Index_type: FULLTEXT
      Comment:
Index_comment:
      Visible: YES
   Expression: NULL
3 rows in set (0.01 sec)

正しくFULLTEXT INDEXが作成されました。

検索対象モデルにsearch_copの設定を記載

  • app/models/book.rb
class Book < ApplicationRecord
  include SearchCop

  search_scope :search do
    attributes :all => [:title, :content]

    options :all, :type => :fulltext, :default => true
  end
end

このようにsearch_scopeのオプションを使って、検索対象のカラムを指定します。

search_copを使ってみる

それでは実際に検索してみましょう。今回は以下のようなテストデータ(架空の本)を用意しました。

Book.all.select(:id, :title, :content)
  Book Load (1.7ms)  SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` /* loading for pp */ LIMIT 11
=>
[#<Book:0x0000ffffab805560 id: 1, title: "月光のソナタ", content: "作家ほげさんによる、月明かりの下で繰り広げられるピアニストの挑戦と恋の物語。">,
 #<Book:0x0000ffffaac73e90 id: 2, title: "夢見る機械", content: "作家ふがさんによる、未来都市を舞台にした革新的な技術と人間の感情が交錯するサイエンスフィクション。">,
 #<Book:0x0000ffffaac73df0 id: 3, title: "静かなる波", content: "作家ぴよさんによる、海辺の小さな村を舞台にした老船乗りの過去と現在を織り交ぜた物語。">,
 #<Book:0x0000ffffaac73c10 id: 4, title: "風に乗る言葉", content: "作家ほげさんによる、旅人の詩人が各地を巡りながら出会う人々との交流を描いた物語。">,
 #<Book:0x0000ffffaac73990 id: 5, title: "時を超える橋", content: "作家ほげさんによる、時代を超えた恋愛を描いたロマンス小説。">]

モデルのsearchメソッドを使って簡単に全文検索できます。

Book.search("ほげ").select(:id, :title, :content)
  Book Load (1.1ms)  SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` WHERE (MATCH(`books`.`title`, `books`.`content`) AGAINST('ほげ' IN BOOLEAN MODE)) /* loading for pp */ LIMIT 11
=>
[#<Book:0x0000ffffab8e6ec0 id: 1, title: "月光のソナタ", content: "作家ほげさんによる、月明かりの下で繰り広げられるピアニストの挑戦と恋の物語。">,
 #<Book:0x0000ffffab8e6e20 id: 4, title: "風に乗る言葉", content: "作家ほげさんによる、旅人の詩人が各地を巡りながら出会う人々との交流を描いた物語。">,
 #<Book:0x0000ffffab8e6d80 id: 5, title: "時を超える橋", content: "作家ほげさんによる、時代を超えた恋愛を描いたロマンス小説。">]
Book.search("ほげ 風に乗る").select(:id, :title, :content)
  Book Load (2.2ms)  SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` WHERE ((MATCH(`books`.`title`, `books`.`content`) AGAINST('+ほげ +風に乗る' IN BOOLEAN MODE))) /* loading for pp */ LIMIT 11
=> [#<Book:0x0000ffffab8e6240 id: 4, title: "風に乗る言葉", content: "作家ほげさんによる、旅人の詩人が各地を巡りながら出会う人々との交流を描いた物語。">]
Book.search("ロマンス小説").select(:id, :title, :content)
  Book Load (1.5ms)  SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` WHERE (MATCH(`books`.`title`, `books`.`content`) AGAINST('ロマンス小説' IN BOOLEAN MODE)) /* loading for pp */ LIMIT 11
=> [#<Book:0x0000ffffaca5e248 id: 5, title: "時を超える橋", content: "作家ほげさんによる、時代を超えた恋愛を描いたロマンス小説。">]

sqlを見ると全文検索が正しく動いてそうですね。

このように、search_copを使えば、簡単な記述でMySQL全文検索機能が利用できて、高速な検索も可能になります。

また、ついでに全文検索を使用しない通常のlike検索との速度比較をしてみましょう。
データが少ないと差が分かりにくいので追加で10万件入れてみます。

Book.count
  Book Count (4.2ms)  SELECT COUNT(*) FROM `books`
=> 100005

以下は全文検索を使用した検索結果です。クエリ実行時間: 0.9ms
先の5件のデータの時と速度はあまり変わらないですね。(むしろ少し早くなってる!?

Book.search("ほげ 風に乗る").select(:id, :title, :content)
  Book Load (0.9ms)  SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` WHERE ((MATCH(`books`.`title`, `books`.`content`) AGAINST('+ほげ +風に乗る' IN BOOLEAN MODE))) /* loading for pp */ LIMIT 11
=> [#<Book:0x0000ffffab81de30 id: 4, title: "風に乗る言葉", content: "作家ほげさんによる、旅人の詩人が各地を巡りながら出会う人々との交流を描いた物語。詩と自然の美しさを通じて人生の哲学を問う作品です。">]

以下は全文検索を使用しない場合の検索結果です。クエリ実行時間: 57.5ms
全文検索を使わない通常の検索では、TEXT型のカラムに対してインデックスを使った高速な検索ができません。そのため、大量のデータを検索する際には速度が大幅に低下します。

Book.search("ほげ 風に乗る").select(:id, :title, :content)
  Book Load (57.5ms)  SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` WHERE ((((`books`.`title` IS NOT NULL AND `books`.`title` LIKE '%ほげ%' ESCAPE '\\') OR (`books`.`content` IS NOT NULL AND `books`.`content` LIKE '%ほげ%' ESCAPE '\\')) AND ((`books`.`title` IS NOT NULL AND `books`.`title` LIKE '%風に乗る%' ESCAPE '\\') OR (`books`.`content` IS NOT NULL AND `books`.`content` LIKE '%風に乗る%' ESCAPE '\\')))) /* loading for pp */ LIMIT 11
=> [#<Book:0x0000ffffab934a30 id: 4, title: "風に乗る言葉", content: "作家ほげさんによる、旅人の詩人が各地を巡りながら出会う人々との交流を描いた物語。詩と自然の美しさを通じて人生の哲学を問う作品です。">]

その差50倍以上!

まとめ

いかがでしたでしょうか。search_copと、MySQL全文検索機能を活用することで、elasticsearch等の専用の検索エンジンを導入せず開発・運用コストを抑え、シンプルなアーキテクチャを維持できます。

また、複雑なSQLクエリを直接記述する必要がなく、DSLを使って直感的に検索条件を指定できるのも大きなメリットです。

参考リンク

採用情報

Ruby on Rails を使った Whitehealthcare に興味のある方はこちらから

whitehealthcare.co.jp

【入社エントリー】地方在住でフルリモート勤務

はじめに

はじめまして、開発チームの横田です。

2024年4月にホワイトヘルスケアに入社し、1ヶ月が経ちました。
私は関西在住で、フルリモートで勤務させていただいております。

今日は、私が入社前に不安に感じていた、

  • ホワイトヘルスケアの開発チームってどんな人がいるの?
  • フルリモートでコミュニケーションはどのように取っているの?

といった部分を入社エントリーとして記載いたします。

開発チームのメンバー

2024年5月時点で、開発チームは総勢9名在籍しており、フロントエンド・バックエンド・インフラ・データ分析と様々な専門分野のエンジニアで構成されています。
かくいう私も、エンジニアというよりはデータサイエンティストとして、医療系のデータ分析などを経験してきました。
経歴としても、大手・ベンチャーフリーランス経験者など様々です。ヘルスケア業界がはじめての方もいらっしゃいます。
少数精鋭で、エンジニア歴10年以上の30代〜40代のベテランメンバーで構成されていることが特徴です。
半数はここ1年以内の採用のため、組織として急拡大しているフェーズになります。

また、実は9名中地方在住者が3名います(九州地方・中国地方・関西地方)。
関東圏のメンバーもリモートであるためそこまで困ることはありませんが、開発チームや会社全体としてのコミュニケーションの取り方について、私が良いなと感じたことを中心に、続いて記載します。

コミュニケーションの取り方

弊社では、基本的にSlackを使ったコミュニケーションを取っています。
その中でも良いなと感じたことは以下です。

原則パブリックチャンネル

人事情報のような個人情報等以外、原則パブリックチャンネルしか作成できないため、基本的にどのチャンネルも覗くことができます。他部門や同僚の状況も把握しやすく、リモートワークにおいて助かります。

リアクションがよくもらえる

通常の投稿や会議などで、スタンプやレスなどリアクションがよくもらえる文化です。リアクションがもらえることが後述の心理的安全性に繋がり、コミュニケーション活性化に大きく寄与していると考えております。いつも瞬く間にスタンプを入れてくれる職人に感謝です。

心理的安全性が確保されている

テキストコミュニケーション能力が高い方が多く、!や絵文字・スタンプなど適度に入れたコミュニケーションを取ってくれるため、リモートにおける心理的安全性の確保に繋がっていると感じています。もちろん、強要ルールがあるわけではなく、自主的なものになります。

社内会議はハドルミーティング

社内会議は基本的に、Slackのいずれかのチャンネルのハドルミーティングを使用しています。ハドルミーティングの良いところは、スレッドができて会議においてレスしたことが後から見返すことができる点です。議事録を書くほどでもないような社内会議でも決定事項とTo doだけスレッドに記載しておけば、後から見返すことができるのは便利だなと感じました。もちろん、スレッドに残るので会議非参加者でも情報のキャッチアップができることも利点です。
また、どのチャンネルで誰と誰が会議をしているかSlack上で表示されるため、出社時に近いような雰囲気になります。

timesチャンネル

非エンジニアの方含めて、何人か個人のtimesチャンネルを作成し好きにつぶやいています。改まって雑談したいわけではないけど、出社しているときのように自然と雑談に繋がるのは良いなと思います。ここでも意外とリアクションがもらえるので、皆さんよく見ているなと感じます。

大部屋・小部屋チャンネル

大部屋と呼ばれる全社的なチャンネル、小部屋と呼ばれる部門レベルのチャンネルがあり、誰かと話したいときは該当チャンネルのハドルミーティングに参加しておけば、見つけた誰かが入ってきてお話できるようになっています。最初のうちはできるだけ入って人間関係を作っていくなど活用できます。

役員クラスとの1on1

1ヶ月に1度役員クラスの方との1on1が設定されており、経営層と会話させていただく機会が設けられています。(※組織拡大に伴い変更される可能性はありますが現状です。)

対面での全社集会

3ヶ月に1度全社集会があり、地方在住者は出張扱いとして参加できるため、対面でお会いすることもできます。宿泊もOKなので、その後の懇親会も参加できます。出張・懇親会ともに強制ではないです。

全社的なコミュニケーションで私が良いなと感じたことは以上です。
コミュニケーションの枠組みから外れるため詳細は省きますが、他にもドキュメントやオンボーディング動画がNotionに綺麗にまとめられているため、フルリモートでも入社後のキャッチアップがスムーズに行えました。

開発チームとしては、週1回の進捗報告会・モブプロ・雑談会が設定されています。
雑談会は、週に1度決められた時間にSlackの大部屋チャンネルのハドルミーティングに開発チーム全員で入り、他部署の人と交流する時間です。リモートだからこそ、コミュニケーションを取るための工夫がなされています。

まとめ

いかがでしょうか。少しでもホワイトヘルスケア、開発チームの雰囲気を掴んでいただけたら幸いです。
まだまだ入社して1ヶ月ですが、ルールや文化・皆さんのコミュニケーション能力の高さに、地方からのフルリモートでも特に困らずに働くことができています。

採用情報

ホワイトヘルスケアに興味を持たれた方はこちらへ! hrmos.co