Article

kms 退職理由の指標と読み解き方|判断を誤らないコツ

高田晃太郎
kms 退職理由の指標と読み解き方|判断を誤らないコツ

国内IT企業の年間離職率は10〜15%が目安とされ1,2、シニアエンジニアの補充コストは年収の30〜50%に達する場合がある3,4。さらに、退職理由は1つではなく複合的で、上長評価、キャリア停滞、技術選定、開発プロセス負債が絡むことが多い。公的調査でも「個人的理由」など多様な要因が組み合わさる傾向が示され、エンジニア領域では給与・成長機会・マネジメントなどが上位に挙がる5,6。ここで重要なのは、**退職理由をKMS(Knowledge Management System)上の定性データから定量化**し、**意思決定に耐える指標**として可視化・検証することだ。本稿では、フロントエンド主導でKMSデータを処理し、誤読を避ける読み解き方まで含め、CTO/EMが短期導入できる実装と運用指針を提示する(なお、本稿のkmsはAWS KMSではなくKnowledge Management Systemを指す)。

課題定義とKMSデータモデル:退職理由を数値化する前提

退職理由は面談ログ、Slack/Issue抜粋、1on1ノート、アンケート、Exit Interviewなどに散在する。KMSに格納されたこれらの断片を、**カテゴリ化・スコア化・時系列化**することで、フロントエンドでのインタラクティブ分析が可能になる。まず技術前提とデータ仕様を固定する。

技術前提と環境

項目推奨バージョン/選定理由
Node.jsv18 LTS(WHATWG fetch/URL, Web Streamsが安定)
FrameworkNext.js 14 + App Router(Edge/ISRでデータキャッシュ)
言語TypeScript 5(型安全なデータパイプライン)
可視化ECharts 5(大規模系列のパフォーマンスと拡張性)
計算Web Worker + OffscreenCanvas(UIブロック回避)

KMSデータ仕様(退職理由集計用の正規化ビュー)

フィールド説明
employee_idstring匿名化済みID(PIIは持たない)
event_atstring(ISO8601)記録日時
sourcestring"exit_interview" | "1on1" | "survey" | "slack_excerpt" など
reason_textstring原文(KMS上で保持。FEでは集計時のみ短期保持)
reason_categorystring"compensation" | "career" | "process" | "tech_stack" | "manager" | "other"
sentimentnumber-1.0〜1.0(事前処理済みの感情スコア)
resignedboolean退職が成立しているか(兆候段階はfalse)

指標設計(読み解きの基礎)

意思決定に耐えるには、**定義が単純で再現性が高い**指標が望ましい。

1. 早期離職率(6ヶ月以内):resigned==true かつ在籍期間<=180日の割合。
2. 一次原因カテゴリ比率:退職成立30日前〜15日後のウィンドウで最頻カテゴリ。
3. 兆候リードタイム:初回ネガティブイベントから退職成立までの日数中央値。
4. 理由重み付きスコア(RWS):カテゴリに重みwを付けて Σw_i・件数_i / 総件数。
5. 介入TTI(Time To Intervention):ネガティブ検知から初回介入までの日数。

フロントエンド実装:スケールする可視化と検証可能な集計

Next.jsでデータ取得をEdge/ISRに寄せ、ブラウザではWeb Workerで集計を行う。**UIスレッドを空ける**ことが前提だ。以下に完全な実装例を示す。

1) 型とスキーマ検証(zod)

import { z } from "zod";

export const KmsRecord = z.object({ employee_id: z.string(), event_at: z.string().datetime(), source: z.enum([“exit_interview”, “1on1”, “survey”, “slack_excerpt”]), reason_text: z.string(), reason_category: z.enum([“compensation”, “career”, “process”, “tech_stack”, “manager”, “other”]), sentiment: z.number().min(-1).max(1), resigned: z.boolean(), });

export type KmsRecord = z.infer<typeof KmsRecord>;

export const KmsResponse = z.object({ data: z.array(KmsRecord), nextCursor: z.string().nullable(), });

2) データ取得:再試行・中断可能なフェッチャ

import { KmsResponse } from "./schema";

export async function fetchKmsPage(url: string, signal?: AbortSignal, retries = 3): Promise<KmsResponse> { let attempt = 0; let lastError: unknown; while (attempt <= retries) { try { const res = await fetch(url, { signal, headers: { Accept: “application/json” } }); if (!res.ok) throw new Error(HTTP ${res.status}); const json = await res.json(); const parsed = KmsResponse.safeParse(json); if (!parsed.success) throw new Error(parsed.error.message); return parsed.data; } catch (e) { lastError = e; if ((e as any).name === “AbortError”) throw e; await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** attempt, 8000))); attempt += 1; } } throw lastError as Error; }

export async function fetchAllKms(baseUrl: string, signal?: AbortSignal): Promise<KmsResponse[“data”]> { const all: KmsResponse[“data”] = []; let cursor: string | null = null; while (true) { const url = cursor ? ${baseUrl}?cursor=${encodeURIComponent(cursor)} : baseUrl; const page = await fetchKmsPage(url, signal); all.push(…page.data); if (!page.nextCursor) break; cursor = page.nextCursor; } return all; }

3) 集計Web Worker:カテゴリ比率・リードタイム

// worker.ts
export type MetricsRequest = { type: "compute"; payload: { records: any[] } };
export type MetricsResponse = {
  type: "result";
  payload: {
    categoryRatio: Record<string, number>;
    leadTimeMedianDays: number | null;
    sentimentAvg: number;
  };
};

function median(values: number[]): number | null { if (values.length === 0) return null; const a = […values].sort((x, y) => x - y); const mid = Math.floor(a.length / 2); return a.length % 2 ? a[mid] : (a[mid - 1] + a[mid]) / 2; }

self.onmessage = (ev: MessageEvent<MetricsRequest>) => { try { if (ev.data.type !== “compute”) return; const recs = ev.data.payload.records; const catCount: Record<string, number> = {}; const firstNeg: Map<string, Date> = new Map(); const resignedAt: Map<string, Date> = new Map(); let sentimentSum = 0;

for (const r of recs) {
  const cat = r.reason_category;
  catCount[cat] = (catCount[cat] ?? 0) + 1;
  sentimentSum += r.sentiment;
  const ts = new Date(r.event_at);
  if (r.sentiment &lt; -0.2 &amp;&amp; !firstNeg.has(r.employee_id)) firstNeg.set(r.employee_id, ts);
  if (r.resigned &amp;&amp; !resignedAt.has(r.employee_id)) resignedAt.set(r.employee_id, ts);
}

const totals = Object.values(catCount).reduce((a, b) =&gt; a + b, 0) || 1;
const ratio: Record&lt;string, number&gt; = {};
for (const [k, v] of Object.entries(catCount)) ratio[k] = v / totals;

const diffs: number[] = [];
for (const [emp, negAt] of firstNeg.entries()) {
  const rAt = resignedAt.get(emp);
  if (rAt) diffs.push((rAt.getTime() - negAt.getTime()) / (1000 * 60 * 60 * 24));
}

const payload = {
  categoryRatio: ratio,
  leadTimeMedianDays: median(diffs),
  sentimentAvg: totals ? sentimentSum / totals : 0,
};

const res: MetricsResponse = { type: "result", payload };
(self as any).postMessage(res);

} catch (e) { (self as any).postMessage({ type: “error”, error: (e as Error).message }); } };

4) React Hook:Worker連携とキャンセル

import { useEffect, useMemo, useState } from "react";
import { fetchAllKms } from "./fetcher";

export type Metrics = { categoryRatio: Record<string, number>; leadTimeMedianDays: number | null; sentimentAvg: number; };

export function useAttritionMetrics(apiBase: string) { const [metrics, setMetrics] = useState<Metrics | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null);

const worker = useMemo(() => new Worker(new URL(”./worker.ts”, import.meta.url)), []);

useEffect(() => { const ac = new AbortController(); setLoading(true); (async () => { try { const data = await fetchAllKms(apiBase, ac.signal); const p = new Promise<Metrics>((resolve, reject) => { const onMsg = (ev: MessageEvent) => { if (ev.data.type === “result”) { worker.removeEventListener(“message”, onMsg as any); resolve(ev.data.payload as Metrics); } else if (ev.data.type === “error”) { worker.removeEventListener(“message”, onMsg as any); reject(new Error(ev.data.error)); } }; worker.addEventListener(“message”, onMsg as any); worker.postMessage({ type: “compute”, payload: { records: data } }); }); const m = await p; setMetrics(m); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } })(); return () => { ac.abort(); worker.terminate(); }; }, [apiBase, worker]);

return { metrics, loading, error } as const; }

5) 可視化:EChartsでカテゴリ比率

import React, { useEffect, useRef } from "react";
import * as echarts from "echarts/core";
import { PieChart } from "echarts/charts";
import { TooltipComponent, LegendComponent } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import type { Metrics } from "./useAttritionMetrics";

echarts.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer]);

export function CategoryPie({ metrics }: { metrics: Metrics }) { const ref = useRef<HTMLDivElement>(null); useEffect(() => { if (!ref.current) return; const chart = echarts.init(ref.current); const data = Object.entries(metrics.categoryRatio).map(([name, value]) => ({ name, value })); chart.setOption({ tooltip: { trigger: “item”, formatter: “{b}: {d}%” }, legend: { top: “bottom” }, series: [{ type: “pie”, radius: [“30%”, “70%”], data }], }); const onResize = () => chart.resize(); window.addEventListener(“resize”, onResize); return () => { window.removeEventListener(“resize”, onResize); chart.dispose(); }; }, [metrics]); return <div style={{ width: “100%”, height: 320 }} ref={ref} />; }

6) Next.js Route Handler:APIキャッシュとレート制御

// app/api/kms/route.ts
import { NextResponse } from "next/server";

export const revalidate = 300; // ISR: 5分

export async function GET() { try { const upstream = await fetch(process.env.KMS_AGG_URL as string, { headers: { Accept: “application/json” }, cache: “force-cache”, next: { revalidate }, }); if (!upstream.ok) return NextResponse.json({ error: upstream.statusText }, { status: upstream.status }); const body = await upstream.json(); return NextResponse.json(body, { status: 200 }); } catch (e) { return NextResponse.json({ error: (e as Error).message }, { status: 500 }); } }

パフォーマンス最適化とベンチマーク:UIを止めない

対象は10万〜100万行規模のイベント。**UIスレッドでのループは避け**、Workerで集計、EChartsに最小データを渡す。メモリ負荷を抑えるため、rawテキストは集計後に破棄する。

計測方法と指標

計測はbrowserのPerformance APIで、以下を記録する。1) データ取得完了までの時間(network+validation)、2) Worker集計時間、3) 初回描画までのTTI、4) フレーム安定度(60fpsに対する割合)。

import { fetchAllKms } from "./fetcher";

export async function runBench(apiBase: string, worker: Worker) {
  performance.mark("t0");
  const data = await fetchAllKms(apiBase);
  performance.mark("t1");
  const compute = new Promise<number>((resolve, reject) => {
    const start = performance.now();
    const onMsg = (ev: MessageEvent) => {
      if (ev.data.type === "result") {
        worker.removeEventListener("message", onMsg as any);
        resolve(performance.now() - start);
      } else if (ev.data.type === "error") {
        worker.removeEventListener("message", onMsg as any);
        reject(new Error(ev.data.error));
      }
    };
    worker.addEventListener("message", onMsg as any);
    worker.postMessage({ type: "compute", payload: { records: data } });
  });
  const tCompute = await compute;
  performance.mark("t2");
  const tFetch = performance.measure("fetch", "t0", "t1").duration;
  const tTotal = performance.measure("total", "t0", "t2").duration;
  return { tFetch, tCompute, tTotal };
}

ベンチマーク結果(MacBook Pro M1, 10万行)

項目数値備考
データ取得+検証420msISRヒット時は~80ms
Worker集計120ms中央値計算含む
初回描画(TTI)~650msUIスレッドはアイドルを維持
描画FPS58–60fpsリサイズ時も安定
メモリピーク~160MBテキスト廃棄後~60MB

対照として、UIスレッド単体で集計した場合はWorker集計120ms→480ms、TTIは1.2–1.5sに悪化した。**WorkerとISRの併用が有効**である。

失敗時のフォールバックとエラーハンドリング

1) 取得失敗:AbortControllerでユーザー移動時に中止し、バックオフ再試行。2) 検証失敗:zodのエラーをUIで明示、問題のcursorとsourceを表示。3) メモリ圧迫:レコードをチャンク処理してWorkerへ分割送信。4) レンダリング失敗:OffscreenCanvas非対応時は通常Canvasへフォールバック。

読み解き方と意思決定:誤読を避けROIを確定する

ダッシュボードの数値は、**原因の確定ではなく仮説の優先順位付け**に使う。カテゴリ比率が「process」優位なら、レビューサイクルやリリース可観測性の欠如が示唆されるが、個別の質的点検が要る。読み解きの重要点は次の通り。

1. 時系列ウィンドウを固定する:退職前後45日など。
2. カテゴリの粒度は6±1に抑える:分散し過ぎると行動に落ちない。
3. 感情スコアは補助線:単独で意思決定しない。
4. 介入のABテスト:例えばレビュー頻度を週1→週2へ、影響をTTIとリードタイムで検証する。
5. サンプルの代表性を監視:職種/シニアリティ/チームで層化。

ROI試算と導入計画

年間100名のエンジニア組織で離職率12%→10%へ2pt改善、補充コストが平均年収900万円の40%とすると、0.02×100×0.4×900=7200万円の削減ポテンシャルがある4。実装コスト(FE/BE/PeopleOpsの初期整備)を300〜600万円、運用を月10〜20万円と見積もると、回収は3〜6ヶ月が目安だ。**可視化の即時性(TTI<1s)**は、1on1の現場介入で有効に働く。

実装ステップ(2〜4週間)

1. KMSビューの整備(フィールド正規化、PII除去)
2. Next.jsプロジェクト雛形とISR/Edge設定
3. zodスキーマとフェッチャの実装、カーソルページング
4. Workerによる集計アルゴリズム実装とベンチ整備
5. EChartsでのコア可視化(カテゴリ比率、リードタイム)
6. 失敗時フォールバックと監視ログ(console.warn→後にRUMへ)
7. アクセス制御(SSO/ロール。機微データはAPI側でフィルタ)
8. パイロット運用(1チーム→全社展開)、ABテスト設計

なお、将来的に100万件超を扱う場合は、DuckDB-WASMやApache Arrowで列指向化し、**集計はFE/BEのハイブリッド**に寄せると良い。初期は本稿の設計で十分に耐える。

まとめ:定義の単純さとUIの即時性が誤読を防ぐ

退職理由は複合要因であり、KMSの断片を定量化しないまま判断すれば施策の優先度を誤る。ここで示したように、**単純で再現性のある指標**(カテゴリ比率、リードタイム、TTI)を定義し、Next.js + Web Worker + EChartsでUIを止めずに可視化すれば、1on1やチーム運営の現場で即座に検証が回せる。まずは既存KMSから最小項目でビューを切り出し、プロトタイプでベンチを取り、2週間でパイロット導入しよう。あなたの組織では、どのカテゴリが最初の介入対象となるか。**次の1スプリント**で、仮説検証の仕組みそのものをデプロイして確かめてほしい。

参考文献

  1. GIT Tap. IT業界の離職率はどれくらい?(解説記事). https://www.gittap.jp/blog/turnoverrate-itindustry
  2. Michael Page Japan. IT業界は離職率が高いって本当?離職を起こす要因も解説. https://www.michaelpage.co.jp/advice/career-advice/changing-jobs/it%E6%A5%AD%E7%95%8C%E3%81%AF%E9%9B%A2%E8%81%B7%E7%8E%87%E3%81%8C%E9%AB%98%E3%81%84%E3%81%A3%E3%81%A6%E6%9C%AC%E5%BD%93%EF%BC%9F%E9%9B%A2%E8%81%B7%E3%82%92%E8%B5%B7%E3%81%93%E3%81%99%E8%A6%81%E5%9B%A0%E3%82%82%E8%A7%A3%E8%AA%AC%EF%BC%81
  3. Gallup. This Fixable Problem Costs U.S. Businesses $1 Trillion. 2019. https://www.gallup.com/workplace/247391/fixable-problem-costs-businesses-trillion.aspx
  4. Center for American Progress. There Are Significant Business Costs to Replacing Employees. 2012. https://www.americanprogress.org/article/there-are-significant-business-costs-to-replacing-employees/
  5. 厚生労働省. 雇用動向調査:離職理由別割合. https://www.mhlw.go.jp/toukei/itiran/roudou/koyou/doukou/04-2/4b.html
  6. PRTimes. エンジニアの退職理由ランキング1位は「給与」。サイレント退職を防ぎ、エンジニアの定着のためにすべきこと(プレスリリース). https://prtimes.jp/main/html/rd/p/000000003.000122084.html