レガシー刷新事例:20年前のシステムを最新Webアプリに再構築
既存システムの保守にIT予算の60〜80%が費やされているという指摘は複数の調査で繰り返し示されてきました¹。稼働年数が20年を超える基幹系が少なくない一方で、ビジネス側は週次どころか日次の機能提供を求めています。レガシー刷新は「やるべき」ではなく、競争力維持のための「生存戦略」です。本稿では、Windows時代のモノリシックな業務アプリを止めずに移し替え、SPA(単一ページアプリケーション)+APIの最新Webアプリへ再構築する典型パターンを、意思決定の軸からコード、観測、ベンチマーク、そしてROIまで、実装者の視点で整理します。
前提条件として、移行元の代表例はWindows Server 2008上の.NET FrameworkとOracleの組み合わせで、画面はサーバーサイドレンダリング、バッチはWindowsタスクに依存しているケースです。移行先の一例はコンテナ基盤上のTypeScript/NestJSとReact、PostgreSQL、Redis、OpenTelemetryで構成し、可能な限りクラウドのマネージドサービスを用いる構成です。全面リライトではなく、境界を切り出し段階的に差し替える方針が現実的です。
レガシー刷新の全体像と意思決定の軸
刷新の最初の一歩は技術選定ではありません。現行機能の棚卸しをビジネス能力(どの価値を誰に届けるか)の単位で可視化し、価値創出に直結する領域と規制・会計の制約が強い領域を分けるところから始めます。変更頻度、障害インパクト、依存関係、データ鮮度といった運用指標で優先順位を付け、リスクの高い心臓部にはアンチコラプション層(旧仕様の歪みを新設計へ持ち込まないための翻訳層)を挟みながら周辺から切り出すのが安全です。
モダナイゼーションは設計思想の刷新でもあります。トランザクションの強整合性と最終整合性(遅延を許しつつ整合させる)の使い分け、同期と非同期の境界、そしてUIとドメインの分離が意思決定の基準になります。すべてをマイクロサービスに分解するのではなく、まずはモジュラーモノリス(単一デプロイだが境界が明確なモジュール構成)で境界を固め、組織の認知負荷に合わせて粒度を調整するほうが再現性が高いという報告が一般的です⁶。
移行は止められないビジネスと同居します。そこでストラングラーパターンでルーティングを制御し、新旧を並走させつつ、機能トグルでリスクを可視化します²³。さらに、開始時点で観測性(メトリクス・ログ・トレースの可視化)を先行実装し、レイテンシ、エラー率、スループットのしきい値を合意してから切り替えに入ると、抽象論に陥りがちな議論が具体に落ちます。ここで用いるSLO(Service Level Objective: 目標水準)とパーセンタイル指標(p50/95/99など、一定割合のリクエストが収まる遅延値)の定義が鍵です⁷⁸。
プロキシで境界を切る:段階移行の要
既存のURL空間を保ったまま特定ドメインだけ新実装へ振り向けるために、L7のリバースプロキシをフロントに立てます。以下はNginxで注文系だけを新APIへ転送する例です。ステートフルなセッションはクッキーを暗号化してBFFへ渡し、認証はIdP(アイデンティティプロバイダ)で統一します。
# nginx.conf
server {
listen 443 ssl;
server_name legacy.example.com;
location /orders/ {
proxy_pass https://new-api.example.com/orders/;
proxy_set_header X-Request-Id $request_id;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
location / {
proxy_pass https://legacy-app.example.com/;
}
}
ビジネス境界を支えるBFF:APIの収斂点
クライアント直結のBFF(Backends for Frontends)はスキーマの安定化装置です。新旧の差分を吸収し、UIの進化速度を落とさないようにします。BFFパターン自体は、複数デバイス/チャネルを前提にAPIを最適化する設計手法として整理されています⁴⁵。NestJSでの最小実装は次の通りで、ここに入力検証、サーキットブレーカー、キャッシュ、分散トレーシングを足していきます。
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger } from '@nestjs/common';
import * as pino from 'pino';
import * as pinoHttp from 'pino-http';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
async function bootstrap() {
const otel = new NodeSDK({
instrumentations: [getNodeAutoInstrumentations()],
});
await otel.start();
const app = await NestFactory.create(AppModule, {
bufferLogs: true,
});
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
app.use(pinoHttp({ logger }));
app.enableShutdownHooks();
await app.listen(process.env.PORT || 3000);
Logger.log('BFF started');
}
bootstrap().catch((e) => {
// フェイルファストと明示ログ
// eslint-disable-next-line no-console
console.error('Fatal error on bootstrap', e);
process.exit(1);
});
技術アーキテクチャとデータ移行:壊さずに進める
最大のリスクはデータです。スキーマの正規化不足、エンコーディングの混在、暗黙のビジネスルールが列名に溶け込みがちです。先に新スキーマを設計して移行用パイプラインを作り、読み取りはレプリカで二重化、書き込みは新旧双方で記録して差分検証を行うと、安全に段階移行できます。バッチはイベント駆動へ寄せ、夜間一括処理のボトルネックを解消しながら、会計や監査の締め処理はトランザクション境界を守る設計にします。
アプリケーション層はドメインの言葉を第一言語にします。コントローラは薄く、ユースケースはアプリケーションサービスへ置き、永続化はリポジトリで隠蔽します。NestJSとPrismaを用いた例では、入力検証、ドメインエラー、インフラエラーを分けて扱うことで、障害時のユーザー体験を守れます。
// src/orders/orders.controller.ts
import { Controller, Get, Param, HttpException, HttpStatus } from '@nestjs/common';
import { OrdersService } from './orders.service';
@Controller('orders')
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Get(':id')
async findOne(@Param('id') id: string) {
try {
const order = await this.orders.getById(id);
if (!order) {
throw new HttpException('NotFound', HttpStatus.NOT_FOUND);
}
return order;
} catch (e: any) {
if (e.code === 'P2025') {
throw new HttpException('NotFound', HttpStatus.NOT_FOUND);
}
throw new HttpException('InternalError', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
// src/orders/orders.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class OrdersService {
private prisma = new PrismaClient();
async getById(id: string) {
return this.prisma.order.findUnique({ where: { id } });
}
}
データ移行はダウンタイムを許さない前提で、事前同期(バックフィル)と切替時のデルタ適用を組み合わせます。FlywayなどのDDLツールを使う場合でも、移行スクリプトにはリトライ耐性とロギングを必ず持たせます。下はシンプルなスキーマ移行の例です。
-- V20250110__create_orders.sql
CREATE TABLE IF NOT EXISTS orders (
id VARCHAR(36) PRIMARY KEY,
customer_id VARCHAR(36) NOT NULL,
status VARCHAR(24) NOT NULL,
total_amount NUMERIC(12,2) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_orders_customer ON orders(customer_id);
フロントエンドの再構築:段階差し替えとUXの両立
UIは新旧の混在が最も見えやすい領域です。React 18のサスペンス(非同期UIの待機制御)とBFFのキャッシュを組み合わせ、体感速度を損なわずに段階的に移行します。アクセシビリティとパフォーマンスは初期から測定対象へ入れ、Core Web Vitalsで閾値を決めると合意が取りやすくなります。
// src/components/OrderDetail.tsx
import React, { Suspense, useEffect, useState } from 'react';
export function OrderDetail({ id }: { id: string }) {
const [data, setData] = useState<any | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
fetch(`/api/orders/${id}`)
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('Failed'))))
.then((json) => !cancelled && setData(json))
.catch((e) => !cancelled && setError(e));
return () => {
cancelled = true;
};
}, [id]);
if (error) return <p role="alert">読み込みに失敗しました</p>;
if (!data) return <p aria-busy="true">読み込み中...</p>;
return (
<Suspense fallback={<p>準備中...</p>}>
<article>
<h1>注文 {data.id}</h1>
<p>金額: {data.total_amount} 円</p>
</article>
</Suspense>
);
}
運用・観測・品質:切替の条件を数字で握る
切替の可否は勘ではなくSLOで判断します。p50/95/99レイテンシ、エラー率、スループット、リソース効率を可視化し、旧系と新系を同一負荷で比較します⁷。OpenTelemetryでの分散トレーシングとメトリクス収集は初日から仕込み、スパン属性にビジネスキー(注文IDなど)を持たせると、原因特定がアプリ層で迅速になります⁸。Redisによる読み取りキャッシュと機能トグルのオンオフで、段階的な最適化も可能です。
// src/cache/redis.ts
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
export async function cacheGet(key: string) {
return redis.get(key);
}
export async function cacheSet(key: string, value: string, ttlSec = 60) {
await redis.set(key, value, 'EX', ttlSec);
}
新旧比較のためにHTTPレイヤで同一シナリオを流し、たとえば「p95レイテンシが一定期間で20〜30%短縮」を切替条件とする方法が現場では扱いやすいでしょう⁷。k6での負荷スクリプトは次の通りです。しきい値は業務要件とSLOに合わせて設計します。
// scripts/loadtest.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 100,
duration: '2m',
};
export default function () {
const res = http.get(`${__ENV.TARGET}/api/orders/123`);
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(0.2);
}
可観測性は開発体験にも効きます。失敗を前提にしたリトライ、タイムアウト、サーキットブレーカーのポリシーをコード化し、例外の意味付けを統一するだけで、復旧時間は短縮されます。トグルはまず環境変数の分岐から始め、必要に応じて外部のフラグ管理へ移行すれば十分です。
// src/toggles.ts
import * as process from 'process';
export const isNewOrderFlowEnabled = () => process.env.NEW_ORDER_FLOW === '1';
効果とビジネスインパクト:TCOとリードタイム
段階移行は、初期の一部ドメインから開始して中核領域へ拡大し、最終的に旧系の書き込みを停止して新系へ一本化するのが定石です。規模や制約により期間は前後しますが、数四半期〜数年のスパンでの移行が一般的です。DORA指標(デプロイ頻度、変更のリードタイム、MTTRなど)で見れば、週次から日次へのデプロイ頻度の引き上げや、平均復旧時間の数十%改善が報告されることも珍しくありません。
インフラ費は並走期間に一時的な増加を伴いますが、運用最適化と機能提供速度の向上により、中期的にはTCOの削減や投資対効果の改善に結びつくケースがあります。特にレガシー維持コストが高止まりしている組織では、保守削減と新規機能の売上寄与を含めたROIがプラスに転じやすい、という業界一般の見立てもあります¹。人材育成面では、型安全な言語と統合的な観測によりオンボーディング時間や品質ゲート通過までの反復が減る傾向があり、ADR(アーキテクチャ決定記録)で知見を残す運用は属人化の抑止に有効です。
実装ディテールと落とし穴:現場で効いた手当て
移行中は「同じ振る舞い」が正義です。新実装がどれほど美しくても、旧仕様と不一致ならバグです。スキーマやAPIの互換性を守るために、BFFで古いフィールドを埋める層を設け、後方互換のない変更はトグルの下に隠します。タイムゾーンや通貨計算、丸め規則、禁則文字といった暗黙知は、テストデータとプロパティベーステストで露呈させ、仕様化してから変換すると安全です。ログは相関IDを強制し、外形監視のアラート閾値は「ノイズが出ない最小限」に保ち、オンコールの負担を避けます。
最後に、段階移行の出口戦略を忘れないこと。古い画面やAPIは切替が完了したらルートごと削除し、プロキシ設定からも消していきます。借金は返す前提で積み上げ、返済の見取り図はロードマップに常に明記しておきます。
// 典型的なHTTPクライアントの耐障害化例(簡略)
import fetch from 'node-fetch';
type Result<T> = { ok: true; value: T } | { ok: false; error: Error };
export async function getWithRetry(url: string, tries = 3, timeoutMs = 1500): Promise<Result<any>> {
for (let i = 0; i < tries; i++) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
clearTimeout(id);
if (res.ok) return { ok: true, value: await res.json() };
} catch (e: any) {
if (i === tries - 1) return { ok: false, error: e };
}
await new Promise((r) => setTimeout(r, 100 * (i + 1)));
}
return { ok: false, error: new Error('Unknown') };
}
実装の背景や詳細パターンは、関連する知見をまとめた記事群とも相互参照できるようにしています。ストラングラ―の設計上の勘所はストラングラーパターン実戦、観測性の立ち上げはOpenTelemetry入門、境界づけと分解の判断はモジュラーモノリス先行戦略、旧仕様の扱いはレガシー仕様のリスク管理、安全な切替は機能トグル運用に整理しています。
まとめ:小さく始め、数字で進め、捨てる勇気を
二十年選手のシステムを最新Webに移す作業は、技術的にも組織的にも容易ではありません。しかし、境界を定めて小さく始め、観測で現在地を測り、数字で合意しながら進めれば、リスクは管理できます。価値の大きい領域から段階的に切り出し、互換性を保ちながら速度を上げ、不要になったものは計画的に捨てる。この基本に忠実であるほど、成果は再現可能になります。次にあなたのチームが取るべき一歩は、最も変更頻度の高い機能の入口にプロキシのスイッチを置き、計測を有効にすることかもしれません。どの境界からなら安全に切り出せるか、そして切替の合図となる数字は何か。その問いに答える準備が整ったら、刷新は既に始まっています。
参考文献
- ZDNET. Staying on legacy systems ends up costing IT more. https://www.zdnet.com/article/staying-on-legacy-systems-ends-up-costing-it-more/
- Microsoft Azure Architecture Center. Strangler fig パターン. https://learn.microsoft.com/ja-jp/azure/architecture/patterns/strangler-fig
- AWS Prescriptive Guidance. Strangler fig pattern. https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/strangler-fig.html
- Microsoft Azure Architecture Center. Backends for Frontends. https://learn.microsoft.com/ja-jp/azure/architecture/patterns/backends-for-frontends
- AWS Mobile Blog. Backends for Frontends pattern. https://aws.amazon.com/blogs/mobile/backends-for-frontends-pattern/
- ThoughtWorks. Modular Monoliths – a better way to build software. https://www.thoughtworks.com/en-gb/insights/blog/microservices/modular-monolith-better-way-build-software
- Google SRE Book. Service Level Objectives (SLOs). https://sre.google/sre-book/service-level-objectives/
- OpenTelemetry. Observability primer. https://opentelemetry.io/docs/concepts/observability-primer/