はじめに
GMOコネクトの星です。
「このシステム、動いてるけど中身がブラックボックス……手を入れるのが怖い😇」という状況、ご経験はないでしょうか。
社内製フレームワーク(以下「独自FW」)で構築された管理系Webアプリを、Spring Boot 4.0 + Java 17環境へ移行しました。この独自FWはオンプレミスの専用サーバに強く依存していてクラウド環境では動きません。「そのまま移植」の選択肢はなく、完全な置き換えです。
頼りにしたのはAIエージェント(Claude Code)ですが、AIに丸投げすれば終わりとはいきません。うまく使うには「型」が必要で、試行錯誤の末に**「調査・変換・検証」の三段構えのプロセス**にたどり着きました。
移行中に踏んだ地雷も含めて、現場の実録として残します。
先にまとめ
三位一体プロセスの概要:
| フェーズ | やること | AIの使い方 |
|---|---|---|
| 調査 | pom.xml・設定ファイルから依存関係を把握 | 「何が変わるか」を分類させる |
| 変換 | 独自フレームワーク→Spring標準のコード変換 | パターンを適用させる(作らせない) |
| 検証 | 手順書通りにゼロから再構築して差分確認 | 再現性を物理的に担保する |
主な変換パターン(9種):
| カテゴリ | Before | After |
|---|---|---|
| DB接続 |
domain.selectOne() (独自FW) |
repository.findByLoginIdAndDeletedAtIsNull() (JPA) |
| DI |
@ApplicationScoped + @Inject (CDI) |
@Service + @Autowired (Spring) |
| Webフレームワーク |
@Path + @GET + Viewable (JAX-RS) |
@Controller + @GetMapping + Model (Spring MVC) |
| フィルター登録 | web.xml |
FilterRegistrationBean (SecurityConfig.java) |
| 認証I/F |
validate(Credentials, WebContext) (pac4j 3.x) |
validate(Credentials, WebContext, SessionStore) (pac4j 5.x) |
| CSRF管理 |
CsrfTokenGeneratorAuthorizer (Authorizer) |
CsrfTokenGeneratorMatcher (Matcher) |
| セッション操作 |
J2EContextキャスト + HttpSession 直接操作 |
sessionStore.set(context, key, value) |
| Hex変換 |
DatatypeConverter.printHexBinary() (JAXB) |
HexFormat.of().formatHex() (Java 17標準) |
| サーブレットAPI | javax.servlet.* |
jakarta.servlet.* |
バージョン変更:
| ライブラリ | Before | After |
|---|---|---|
| pac4j-core | 3.5.0 | 5.7.2 |
| pac4j-http | 3.5.0 | 5.7.2 |
| j2e-pac4j | 4.1.0 | jee-pac4j:6.0.0(廃止・後継へ) |
| javax.servlet-api | 4.0.1 | jakarta.servlet-api:6.0.0 |
| commons-fileupload | 1.4 | 削除(Spring自動解析で代替) |
【調査】AIで5分でわかる依存関係の全貌
移行で最初に詰まるのは「何がどこに依存しているか」の把握です。社内製フレームワークは、その名前でGoogle検索しても何も出てきません。
ここでAIエージェントに投げた最初のプロンプトがこれです。
このプロジェクトのpom.xmlと主要なConfigファイルを読んで、
以下を整理してください:
1. 独自フレームワーク固有の依存(社内製FW等)
2. Spring Bootへの移行で必ず変更が必要になるライブラリ
3. そのまま使えるライブラリ
4. 変換パターンを「機械的な置換」と「ロジックの書き換え」に分類
10分後には次の分類が返ってきました。
機械的な置換(AIが一括処理できる):
-
javax.*→jakarta.*のパッケージ名変更(全ファイル共通) -
@Inject→@Autowired、@ApplicationScoped→@Service
ロジックの書き換え(ファイルごとに判断が必要):
- 独自FW Clientの呼び出し → Spring Data JPA Repository
- JAX-RSのエンドポイント定義 → Spring MVCのController
- pac4j 3.x → 5.x のAPIシグネチャ変更
この分類が重要です。「機械的な置換」を先に済ませてから「ロジックの書き換え」に集中する、という順序立てができます。規模感もつかめ、「全体で何日かかりそうか」の見通しを立てられました。
【変換1】DB接続:独自FW Client → Spring Data JPA
独自FWのDB接続は、Domainクラスを介した独自APIでした。
// Before: 独自FW Client
AppAccountDomain domain = new AppAccountDomain();
domain.setLoginId(credentials.getUsername());
Map<String, Object> map = domain.selectOne(); // 独自FW固有
// After: Spring Data JPA
Optional<AppAccount> account =
appAccountRepository.findByLoginIdAndDeletedAtIsNull(
credentials.getUsername()
);
見た目はシンプルですが、JPAへの変換で考える点が2つあります。
Spring Data JPAの命名規則への適合
独自FWのDomainクラスは「setLoginId()してselectOne()する」という手続き型でした。JPAのRepositoryはメソッド名が検索条件になる宣言型です。
public interface AppAccountRepository extends JpaRepository<AppAccount, Integer> {
// メソッド名から自動でSQL生成される
Optional<AppAccount> findByLoginIdAndDeletedAtIsNull(String loginId);
Optional<AppAccount> findByMailAddressAndIdNotAndDeletedAtIsNull(String email, int id);
}
deletedAt IS NULL(論理削除チェック)のような条件を、元コードのどこで処理していたかを追う作業がポイントです。独自FWのDomainクラス内に隠れていることが多く、AIに「このDomainクラスが内部でどんなWHERE句を生成しているか推測して」と聞くと、類似パターンから解釈してくれます(ただし推測なので要確認)。
楽観ロックは手動実装が必要
独自FWは楽観ロックを自動でハンドリングしていました。JPA移行後はそのサポートがなくなり、手動チェックが必要になります。
// Before: 独自FW が OptimisticConcurrencyException を自動スロー
domain.setLockVersion(oldLockVersion);
domain.update();
// After: バージョンを手動チェックしてからsave
AppRole entity = appRoleRepository.findById(roleId).orElseThrow();
if (entity.getLockVersion() != Integer.parseInt(form.getLockVersion())) {
throw new AppException("E_APP_00_0002", "更新失敗");
}
entity.setLockVersion(entity.getLockVersion() + 1);
appRoleRepository.save(entity);
これはAIが自動で変換してくれない部分です。独自FW固有の例外型が消えたタイミングで「ここに楽観ロックが隠れていた」と気づけるかどうかが勘所です。
【変換2】認証:pac4j 3.x → 5.x の地雷を踏まずに進む
今回の移行で最も変更量が多かったのが pac4j の認証まわりです。
APIシグネチャの一覧変更
pac4j 5.x でインターフェースが大きく変わりました。
| 項目 | Before (3.x) | After (5.x) |
|---|---|---|
| Authenticator 型引数 | Authenticator<UsernamePasswordCredentials> |
Authenticator(ジェネリクス廃止) |
| validate() 引数 | (Credentials, WebContext) |
(Credentials, WebContext, SessionStore) |
| ProfileAuthorizer 型引数 | ProfileAuthorizer<CommonProfile> |
ProfileAuthorizer(ジェネリクス廃止) |
| isAuthorized() 引数 | (WebContext, List<Profile>) |
(WebContext, SessionStore, List<UserProfile>) |
| Pac4jConstants パッケージ | org.pac4j.core.context |
org.pac4j.core.util |
validate()の引数が2つから3つに増えた変更は、コンパイルエラーとして即座に検出できます。が、ProfileAuthorizerのジェネリクス廃止は「CommonProfile→UserProfileへの型変更」も伴うため、型を追って直すことになります。
CsrfAuthorizer が消えた話
最もはまったのがCSRF管理です。CsrfAuthorizerを探してもpac4j 5.xには存在しません。
役割が分離されました。
| pac4j 3.x | pac4j 5.x | 役割 |
|---|---|---|
CsrfTokenGeneratorAuthorizer |
CsrfTokenGeneratorMatcher |
トークン生成 |
CsrfAuthorizer |
CsrfAuthorizer(継続) |
トークン検証 |
SecurityConfigの設定も変わります。
// Before (web.xml): authorizers にまとめて書いていた
// authorizers="csrfToken,mustBeAnon"
// After (SecurityConfig.java): 生成は matchers、検証は authorizers に分離
SecurityFilter filter = new SecurityFilter(config, "mustBeAnon");
filter.setMatchers("csrfToken"); // ← 生成をMatcherで担当
filter.setAuthorizers("mustBeAuth"); // ← 検証はAuthorizer側
config.addMatcher("csrfToken", new CsrfTokenGeneratorMatcher(...)) という登録も必要です。ドキュメントを読んだだけでは気づきにくく、既存プロジェクトの移行済みコードを参照して判明しました。
セッション操作はSessionStore経由に統一
pac4j 5.xではJ2EContextクラスが廃止されています。セッションへの書き込みはすべてSessionStore経由になります。
// Before: J2EContext にキャストして直接操作
((J2EContext) context).getRequest().getSession(true).setAttribute(key, value);
// After: SessionStore 経由(JEE環境では内部でHttpSessionに委譲)
sessionStore.set(context, key, value);
ただし、フレームワーク外から呼ばれるstaticメソッドではSessionStoreインスタンスを受け取れません。validate()内でsessionStore.set()した値はJEE環境ではHttpSessionに保存されるので、static側ではHttpSessionから直接取得できます。
// static メソッドでのセッション読み取り
HttpSession session = request.getSession(false);
return (session == null) ? null : (String) session.getAttribute(SESSION_KEY_USER_ID);
【変換3】フレームワーク:JAX-RS → Spring MVC
Webフレームワークの置き換えは、変換パターンが明確なので最も機械的に進められます。
// Before: JAX-RS
@Path("/login/")
@Produces(TEXT_HTML)
public class LoginResource {
@GET
public Viewable index() {
Map<String, Object> model = new HashMap<>();
model.put("callbackUrl", callbackUrl);
return new Viewable("/login/index", model);
}
}
// After: Spring MVC
@Controller
@RequestMapping("/login/")
public class LoginResource {
@Autowired private Config config;
@GetMapping
public String index(Model model) {
model.addAttribute("callbackUrl", callbackUrl);
return "/login/index";
}
}
フィルターチェーンもweb.xmlからFilterRegistrationBeanに移行します。
@Configuration
public class SecurityConfig {
@Bean
public FilterRegistrationBean<LoggerFilter> loggerFilter() {
FilterRegistrationBean<LoggerFilter> bean =
new FilterRegistrationBean<>(new LoggerFilter());
bean.setOrder(1);
bean.addUrlPatterns("/*");
return bean;
}
// ... 以降、orderを2〜7でフィルターを登録
}
web.xmlに書かれたフィルターの順序がsetOrder()の数値に対応します。既存のweb.xmlをAIに読み込ませて「これをFilterRegistrationBeanに変換して」と頼むと、変換テーブルをそのままコードに落としてくれます。
commons-fileupload の撤廃
javax.servlet依存のcommons-fileuploadは削除しました。Spring Bootが自動でマルチパートを解析してくれるので、MultipartFileで直接受け取れます。
// Before: commons-fileupload で手動パース
ServletFileUpload upload = new ServletFileUpload(new DiskFileItemFactory());
List<FileItem> items = upload.parseRequest(request);
// After: Spring Boot が自動解析済み
MultipartFile file = form.getUploadFile();
file.transferTo(new File(uploadDir));
【検証】ゼロベース検証:手順書を物理的に試す
AIが生成したコードを「動きそう」と目視確認するだけでは不十分です。手順書に書いた通りに本当に動くかを確認する方法として「ゼロベース検証」を取り入れました。
手順:
- 移行済みコードを別ブランチに退避
- 元のコードに戻す
- 手順書(移行記録の
.mdファイル)に書いた変更を、一から手で実施 - 退避した移行済みコードとdiffを取る
差分が0なら手順書が完全だということです。差分がある場合、それは「手順書に書き忘れた変更」か「移行時に判断した内容が記録されていない」ことを意味します。
この「再現性テスト」の目的は、コードの正しさではなく手順書の正しさを確認することです。移行作業では往々にして「なんとなく直したら動いた」という変更が紛れ込みます。それが記録されていないと、次に同じ作業をする人が詰まります。
実際に今回の検証で発見したのが、社内共通ライブラリJAR内のクラスを本プロジェクトへコピーした際の変更(jakarta.interceptorへの置換、HexFormatへの書き換え)が移行記録に抜けていた点でした。
【番外】泥臭いトラブル集
ハマり1: AIがクラスを勝手に作った
二段階認証クラスの移行中、参照先クラス(ExternalUtil)が見つからないとAIが判断し、新規クラスを作成してしまいました。
当然ながら、作ったクラスの中身は推測と補完の産物です。実際には別モジュールに存在するクラスでした。
この教訓からルールを作りました:
参照先クラスが存在しない場合、AIは作成せず報告のみ行う。
プロンプトに明示します。
参照先クラスが見つからない場合、クラスを新規作成しないでください。
代わりに「XXXクラスが見つかりません」と報告してください。
レガシー移行では「クラスが存在しない=消えた」ではなく「まだ移行されていない」ことがほとんどです。AIはコンパイルを通そうとして補完しようとするので、この制約は必須です。
ハマり2: VSCode の Java Language Server がMavenの変更を認識しない
pom.xmlにライブラリを追加しても、VSCode上でimportエラーが消えない状況がありました。
解決策は以下の順番で試します。
- VSCodeのコマンドパレットから「Java: Clean Java Language Server Workspace」を実行
- それでも消えない場合、
mvn clean compileをターミナルで実行してエラーが実際にあるか確認 - Mavenのエラーが出ない場合、VSCodeの表示上の問題なのでウィンドウのリロード(
Developer: Reload Window)
mvn compile が通ればビルド上は問題ありません。VSCodeの表示エラーはエディタ側のキャッシュ問題であることが多いので、まずMavenを正として切り分けてください。
ハマり3: JAXB の HexFormat 置き換えで精度確認を忘れない
// Before: javax.xml.bind.DatatypeConverter(Java 11以降削除)
String hex = DatatypeConverter.printHexBinary(bytes);
// After: java.util.HexFormat(Java 17標準)
String hex = HexFormat.of().formatHex(bytes);
DatatypeConverter.printHexBinary()は大文字16進数を返しますが、HexFormat.of().formatHex()はデフォルトで小文字です。大文字が必要な場合は末尾に.toUpperCase()を繋いで変換してください。DBに保存されている既存データの形式との一致を事前に確認してください。
ハマり4: 移行時に既存バグを発見
CustomAuthenticatorの移行中に、元コードにStringの参照比較バグを発見しました。
// 旧コード(バグ): == はオブジェクト参照の比較
(adminAccountId == "1") ? "1" : "0"
// 修正: 文字列の内容比較には equals()
adminAccountId.equals("1") ? "1" : "0"
JVMのString intern化が偶然機能している間は動いてしまいますが、DBから取得したStringは基本的にintern化されません。AIがコードを精読する過程でこのバグが浮かび上がりました。移行作業はコードレビューの機会でもあります。
まとめ
- 調査フェーズで**「機械的な置換」と「ロジックの書き換え」を分類**することで、AIに任せる範囲と人が判断する範囲が明確になります
- pac4j 5.xの最大の変化はCSRF管理の役割分離(Authorizer → Matcher)とSessionStore引数の追加。バージョン間差異はAIが混乱しやすい部分なので、公式Changelogを手元に置いておくのが確実です
- レガシー移行では**「作成ではなく報告」のプロンプト制約**が必須。クラスが見つからない=まだ移行されていない、と読み替えてください
- ゼロベース検証は、目視確認では気づけない「記録されていない変更」を炙り出す有効な手段です
AIエージェントは「何かを作る」だけでなく「何かを把握する」ツールとして強力です。ブラックボックスの解析から始まる移行作業でこそ、その使いどころがあります。
最後に、GMOコネクトではサービス開発支援や技術支援をはじめ、幅広い支援を行っておりますので、何かありましたらお気軽にお問合せください。