레거시 Java 코드를 Java 17/21/25로 마이그레이션하는 AI 기반 CLI를 만들었습니다
요약
레거시 Java 코드를 최신 버전(Java 17/21/25)으로 자동 마이그레이션해주는 AI 기반 CLI 도구인 'java-migrate'를 소개합니다. 정규 표현식 기반의 정적 스캔과 Claude의 LLM 능력을 결합하여 비용 효율적이고 정확한 코드 현대화를 지원합니다.
핵심 포인트
- 정규 표현식 스캐너를 통해 변경이 필요한 파일만 선별하여 AI 비용과 지연 시간 최소화
- 대상 Java 버전에 따라 최적화된 시스템 프롬프트를 구성하여 버전별 문법 적용
- CLI 환경에서 dry-run 모드와 상세한 마이그레이션 노트를 제공하여 안전한 작업 지원
- 익명 클래스, 장황한 null 체크 등 레거시 패턴을 최신 문법으로 자동 변환
레거시 Java 코드를 Java 17/21/25로 마이그레이션하는 AI 기반 CLI를 만들었습니다
엔터프라이즈 Java 환경에서 시간을 보내본 적이 있다면 그 기분을 잘 알 것입니다. 2014년부터 실행되어 온 서비스를 열면 익명 내부 클래스 (anonymous inner classes)의 벽, 장황한 null 체크, new ArrayList를 감싸고 있는 Collections.unmodifiableList, 그리고 실제 로직보다 break 키워드가 더 많은 switch 문들이 당신을 맞이합니다.
각 패턴을 개별적으로 수정하는 데는 30초가 걸립니다. 하지만 300개의 파일이 있는 코드베이스 전체를 본다면, 이는 코드 리뷰를 고려하기 전부터 이미 일주일 분량의 기계적인 작업이 됩니다.
그래서 저는 java-migrate를 만들었습니다. Java 파일을 스캔하고, 레거시 패턴을 감지한 뒤, 이를 Claude에 정밀한 시스템 프롬프트 (system prompt)와 함께 전송하여 현대화된 코드를 받아오는 CLI 도구입니다. 명령어 한 줄로 즉각적인 diff를 확인하며, 예상치 못한 상황도 없습니다.
실제 사용 모습
다음은 마이그레이션 전의 전형적인 레거시 파일입니다:
public class LegacyService {
// getter/setter가 있는 POJO
...
java-migrate LegacyService.java --dry-run --verbose를 실행하면 다음과 같은 diff를 얻을 수 있습니다:
- public static class User {
- private String name;
- private int age;
...
또한 영향을 받은 줄 번호와 함께 모든 변경 사항을 설명하는 마이그레이션 노트가 포함됩니다. --dry-run 옵션을 제거하기 전까지는 아무것도 디스크에 기록되지 않습니다.
아키텍처
이 도구는 각각 단일 책임을 가진 네 개의 모듈로 구성되어 있습니다:
src/java_migrate/
├── cli.py # click CLI — 옵션, 진행률 표시줄, 요약
├── detector.py # 정규 표현식 (regex) 기반 레거시 패턴 스캐너
...
1. 빠른 정적 감지 (깨끗한 파일에 대한 AI 비용 발생 없음)
Claude를 호출하기 전에, 정규 표현식 (regex) 스캐너가 각 파일을 확인하여 알려진 레거시 패턴을 검사합니다:
PATTERNS = [
("instanceof-cast",
re.compile(r"\bif\s*\(\s*\w+\s+instanceof\s+(\w+)\s*\)\s*\{?\s*\n?\s*\w+\s+\w+\s*=\s*\(\1\)")),
...
매칭되는 항목이 없는 파일은 완전히 건너뜁니다. API 호출도, 비용도, 지연 시간 (latency)도 발생하지 않습니다. 일반적인 코드베이스에서는 파일의 40~60%가 이미 깨끗한 상태입니다.
2. 버전 인식 프롬프트 구성
시스템 프롬프트(System prompt)는 --target-version 플래그에 따라 변경됩니다. 각 변환(transformation)에는 이를 지원하는 최소 Java 버전이 태그로 지정됩니다:
_TRANSFORMATIONS = [
(8, "익명 Comparator / Runnable을 위한 람다 표현식 (Lambda expressions)"),
(9, "`Collections.unmodifiable*` 대신 `List.of` / `Map.of` 사용"),
...
Java 11을 대상으로 하나요? Claude에게 record나 패턴 매칭 (pattern matching)을 사용하지 말라고 지시합니다. Java 25를 대상으로 하나요? sealed classes 및 구조화된 동시성 (structured concurrency)을 포함한 전체 목록을 제공합니다.
3. Claude 호출하기
이 도구는 Bearer 토큰과 함께 Bedrock InvokeModel API 형식을 사용합니다:
def _invoke(model: str, payload: dict) -> dict:
url = f"{BEDROCK_BASE_URL}/model/{model}/invoke"
body = json.dumps(payload).encode()
...
시스템 프롬프트는 엄격합니다. 업데이트된 파일만 반환한 다음, 불렛 포인트(bullet points)가 포함된 // === MIGRATION NOTES === 섹션을 추가하도록 합니다. 응답은 해당 마커를 기준으로 분할되어, 노트가 코드와 별도로 저장됩니다.
4. Diff 및 보고서 생성
리포터(reporter)는 Python의 내장 모듈인 difflib을 사용하여 --verbose 모드를 위한 색상이 입혀진 터미널 diff와 마크다운(markdown) 보고서를 위한 유니파이드 diff (unified diff)를 모두 생성합니다:
def unified_diff(self) -> str:
return "".join(difflib.unified_diff(
self.original.splitlines(keepends=True),
...
지원되는 변환 목록
| 레거시 패턴 (Legacy pattern) | 현대적 대체 방식 (Modern replacement) | 최소 Java 버전 |
|---|---|---|
instanceof + 캐스팅 (cast) | 패턴 매칭 (Pattern matching) (instanceof Foo f) | 16 |
| ... |
왜 OpenRewrite를 그냥 사용하지 않나요?
OpenRewrite는 매우 훌륭하며 구조적 리팩터링 (structural refactoring)을 위해 사용해야 합니다. OpenRewrite는 적절한 AST (Abstract Syntax Tree)를 가지고 있으며, 정규 표현식 (regex)이 처리할 수 없는 예외 케이스를 다루고, 방대한 레시피 (recipes) 라이브러리를 보유하고 있습니다.
java-migrate는 두 가지 측면에서 다릅니다:
-
AI 기반 (AI-driven) — 변환 작업이 Claude에 의해 수행됩니다. 이는 고정된 레시피 (recipes)가 처리할 수 없는 문맥 (context)과 뉘앙스를 다룰 수 있음을 의미합니다. POJO를
record로 변환하려면 모든 호출 지점 (call site)을getName()에서name()으로 업데이트해야 하는데, 이는 정규 표현식 (regex) 레시피로는 신뢰성 있게 수행할 수 없는 작업입니다. Claude는 파일 전체를 한 번에 처리합니다. -
설정 제로 (zero-configuration) — Gradle/Maven 플러그인, YAML 설정, 빌드 시스템 통합이 필요하지 않습니다. 어떤 디렉터리든 지정하기만 하면 바로 작동합니다.
이 두 도구는 상호 보완적입니다. 구조적 마이그레이션 (Spring Boot 업그레이드, API 변경, 의존성 업데이트)에는 OpenRewrite를 사용하세요. 그 상위 계층에서 기계적인 Java 언어 현대화 레이어를 적용할 때는 java-migrate를 사용하세요.
설치 및 빠른 시작
# pipx로 영구 설치
brew install pipx && pipx ensurepath
pipx install /path/to/java-migrate
...
지원 대상: 11, 17 (기본값), 21, 25.
제작 과정에서 배운 교훈들
2단계 파이프라인이 비용을 절감합니다. 정규 표현식 (regex) 사전 필터링은 프로젝트 전체에서 내린 최고의 결정이었습니다. 200개의 파일로 구성된 코드베이스에서 100개의 파일이 이미 깨끗한 상태라면, 프롬프트 엔지니어링 (prompt engineering)을 한 줄도 작성하기 전에 API 비용을 절반으로 줄일 수 있습니다.
엄격한 출력 형식은 타협할 수 없는 요소입니다. 첫 번째 버전에서는 단순히 Claude에게 "마이그레이션된 코드를 반환하라"고 요청했습니다. 응답은 일관성이 없었습니다. 때로는 설명 문구가 포함되었고, 때로는 코드 펜스 (fences)가 있었으며, 때로는 아예 없기도 했습니다. // === MIGRATION NOTES ===를 필수 마커로 추가하고 이를 기준으로 분할함으로써 파싱 (parsing)을 결정론적 (deterministic)으로 만들었습니다.
프록시 URL 때문에 두 시간을 허비했습니다. 제 API 엔드포인트는 …/v1이었는데, Anthropic SDK가 자동으로 /v1/messages를 붙여서 …/v1/v1/messages가 되어버렸습니다. 디버그 로그 라인인 Sending HTTP Request: POST …/v1/v1/messages를 확인하자마자 명확해졌지만, 처음에는 한참 동안 엉뚱한 곳을 보고 있었습니다. API 호출을 디버깅할 때는 항상 전체 요청 URL을 출력하세요.
프롬프트에 버전을 제한(Version-gating)하는 것은 생각보다 훨씬 중요합니다. 그렇지 않으면 Claude는 대상이 Java 11임에도 불구하고 가끔 Java 17 기능을 사용하곤 했습니다. 단순히 코드를 작성하는 더 나은 방법을 알고 있었기 때문입니다. 프롬프트에 대상 버전을 명시하고 적용 가능한 변환(transformation) 목록만 나열함으로써 이 문제를 해결했습니다.
향후 계획
추가하고 싶은 몇 가지 기능이 있습니다:
--since-commit플래그 — 특정 git 커밋 이후에 변경된 파일만 마이그레이션하며, 이는 점진적인 도입(incremental adoption)에 유용합니다.- 병렬 파일 처리 (Parallel file processing) — 현재는 파일이 순차적으로 처리되지만, 동시 API 호출(concurrent API calls)을 구현하면 대규모 코드베이스에서 큰 차이를 만들 수 있습니다.
- IntelliJ / VS Code 확장 프로그램 — 파일 우클릭 → Java 21로 마이그레이션
- GitHub Action — PR(Pull Request) 시 실행되어 레거시 패턴을 리뷰 코멘트로 표시
코드베이스는 작고 의도적으로 단순하게 설계되었습니다. 새로운 패턴이나 새로운 변환을 추가하고 싶다면, 말 그대로 두 개의 파일에 두 줄만 추가하면 됩니다.
프로젝트는 GitHub의 [https://github.com/jharoonalishah/java-migrate]에서 확인할 수 있습니다. PR(Pull Request)은 언제나 환영합니다.
최근에 대규모 Java 코드베이스를 마이그레이션한 적이 있나요? 가장 고통스러웠던 부분은 무엇이었나요? 제가 타겟팅한 패턴들이 사람들이 실제로 마주치는 것들과 일치하는지 궁금합니다. 아래에 댓글을 남겨주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기