[의존성의 방향을 따라 3/5] OpenRewrite와 Claude가 코드를 변환한다


Updater의 두 가지 무기
Planner가 전파 순서를 결정하면, Updater가 각 레포에서 실제 코드 변환을 수행합니다. Updater에게는 두 가지 무기가 있습니다.
- OpenRewrite — 정적 코드 변환 엔진. 규칙 기반으로, 예측 가능하고 재현 가능한 변환을 수행합니다.
- Claude (AI) — 빌드 에러 분석과 수정. recipe만으로 해결되지 않는 예외적인 수정을 담당합니다.
이 둘의 역할 분담이 핵심입니다. OpenRewrite가 먼저 정적 변환을 적용하고, 빌드를 돌립니다. 빌드가 성공하면 끝. 실패하면 Claude가 에러를 분석하고 수정을 시도합니다. 이 루프를 반복합니다.
이 분담은 1화 끝에서 이야기한 "속도와 정확성을 동시에 잡는 구조"의 구체적 형태입니다. 규칙으로 표현할 수 있는 변환 — 50개 레포에 똑같이 적용되어야 하는 부분 — 은 OpenRewrite가 결정론적으로 처리합니다. 동일 입력에 동일 출력이라, 재현 가능하고 품질이 흔들리지 않습니다. 규칙으로 표현하기 어려운 비즈니스 판단은 Claude가 유연하게 처리하되, 그 결과는 빌드가 검증합니다. 사람에게 다 맡기면 느리고, AI에게 다 맡기면 검증되지 않는 변경이 번집니다. Updater는 변환의 대부분을 결정론에 맡기고, 판단이 필요한 좁은 영역만 AI에 넘기며, 양쪽 모두 빌드라는 가드레일 안에 둡니다.
OpenRewrite: 코드를 다루는 코드
OpenRewrite는 소스 코드의 AST(Abstract Syntax Tree)를 분석하고 변환하는 도구입니다. 단순한 텍스트 치환이 아니라, 코드의 구조를 이해한 상태에서 변환합니다.
이 차이가 실무에서 왜 결정적인지 짚고 넘어가겠습니다. sed나 정규식으로 findWorkspace를 일괄 치환하려 들면, 주석 안의 같은 단어, 문자열 리터럴, 우연히 이름이 겹치는 다른 함수까지 함께 건드립니다. 50개 레포에 그런 변환을 풀어놓는다는 건, 50곳에서 제각기 다른 오작동을 감수한다는 뜻입니다. OpenRewrite는 코드를 글자가 아니라 문법 트리로 보기 때문에, "이 타입의, 이 시그니처를 가진 메서드 호출"만 정확히 지목할 수 있습니다. 변환이 결정론적이라는 말의 실체가 바로 이것입니다 — 같은 코드에 몇 번을 돌려도, 어느 레포에 돌려도 같은 곳만 같은 방식으로 바뀝니다.
Recipe의 구조
OpenRewrite의 변환 단위는 "recipe"입니다. recipe는 YAML로 선언하거나 Java/Kotlin으로 프로그래밍할 수 있습니다.
선언적 recipe (YAML)
# rewrite-recipes/src/main/resources/META-INF/rewrite/spring-boot-3.5.10.yml
type: specs.openrewrite.org/v1beta/recipe
name: com.example.SpringBoot3_5_10
displayName: Upgrade to Spring Boot 3.5.10
description: Migrates projects from Spring Boot 3.5.5 to 3.5.10
recipeList:
# 공식 Spring Boot 마이그레이션 recipe
- org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_5
# 내부 전용 의존성 버전 조정
- org.openrewrite.java.dependencies.ChangeDependency:
oldGroupId: com.mysql
oldArtifactId: mysql-connector-j
newVersion: 9.2.0
# FixtureMonkey 호환 버전으로 조정
- org.openrewrite.java.dependencies.UpgradeDependencyVersion:
groupId: com.navercorp.fixturemonkey
artifactId: "*"
newVersion: 1.1.11
# deprecated API 마이그레이션
- org.openrewrite.java.ChangeMethodName:
methodPattern: "org.springframework.boot.web.server.WebServerFactoryCustomizer customize(..)"
newMethodName: customizeWebServerFactory프로그래밍 recipe (Java/Kotlin)
선언적 YAML만으로 표현하기 어려운 변환은 코드로 작성합니다. OpenRewrite는 빌드 스크립트의 메서드 호출을 AST 노드 단위로 방문하는 visitor를 제공하는데, 커스텀 recipe는 이 visitor를 구현합니다. 예를 들어 내부 버전 관리 플러그인의 패밀리 버전 선언을 찾아내, 그 안의 version 파라미터를 새 버전으로 교체하는 식입니다. 텍스트 치환이 아니라 "이 메서드 호출의 이 인자"를 구조적으로 지목해서 바꾸기 때문에, 주석이나 문자열 안의 비슷한 텍스트를 잘못 건드리지 않습니다.
flex에서의 recipe 적용 방식
OpenRewrite는 init script 방식으로 적용합니다. 각 레포의 build.gradle.kts를 수정하지 않고, 빌드 시점에 주입합니다.
// rewrite-init.gradle
initscript {
repositories {
maven { url "https://plugins.gradle.org/m2" }
}
dependencies {
classpath("org.openrewrite:plugin:latest.release")
}
}
gradle.rootProject {
plugins.apply(org.openrewrite.gradle.RewritePlugin)
dependencies {
rewrite("org.openrewrite.recipe:rewrite-spring:latest.release")
rewrite("com.example:rewrite-recipes:0.2.0-SNAPSHOT")
}
}이 init script의 설계가 중요합니다.
- 레포 코드를 수정하지 않음:
build.gradle.kts에 OpenRewrite 플러그인을 추가할 필요가 없습니다. init script로 외부에서 주입합니다. 이건 단순한 편의가 아닙니다. 만약 변환을 위해 각 레포의 빌드 스크립트를 먼저 수정해야 한다면, "변환 도구를 적용하기 위한 변환"이라는 닭과 달걀 문제가 생기고, 그 수정 자체가 또 50개 레포에 전파해야 할 변경이 됩니다. 외부 주입은 이 순환을 끊습니다 — 대상 레포는 자신이 변환당하고 있다는 사실조차 코드에 남기지 않습니다. - 두 가지 recipe 소스: 공식
rewrite-spring(Spring Boot 마이그레이션)과 커스텀rewrite-recipes(내부 전용 변환)를 조합합니다. 공식 recipe가 프레임워크 차원의 표준 마이그레이션을 책임지고, 커스텀 recipe가 "우리 조직에서만 의미 있는" 변환 — 내부 라이브러리 버전 규약, 사내 컨벤션 — 을 얹습니다. 바퀴를 다시 발명하지 않으면서, 조직 고유의 맥락만 추가로 인코딩하는 구조입니다. - 버전 관리: 커스텀 recipe가 Nexus에서 관리되므로, recipe 자체도 버전 관리됩니다. 변환 규칙이 코드처럼 버전을 갖는다는 건, "3월에 적용한 그 변환"을 6개월 뒤에도 토씨 하나 다르지 않게 재현할 수 있다는 뜻입니다.
실행
Updater가 각 레포에서 recipe를 적용하는 명령은 이렇습니다.
# init script를 통해 OpenRewrite 실행
gradle rewriteRun \
--init-script rewrite-init.gradle \
-Drewrite.activeRecipe=com.example.SpringBoot3_5_10rewriteRun 태스크가 모든 소스 파일의 AST를 구축하고, 활성화된 recipe를 순서대로 적용합니다. 변환된 코드는 원본 파일에 직접 덮어씁니다 (in-place modification).
Recipe가 변환하는 것들
Spring Boot 3.5.5에서 3.5.10 업그레이드에서 recipe가 실제로 수행하는 변환 예시를 보겠습니다.
1. 의존성 버전 업데이트
build.gradle.kts의 의존성 버전 선언에서 버전 문자열을 변경합니다. MySQL Connector를 9.1.0에서 9.2.0으로, FixtureMonkey를 1.1.7에서 1.1.11로. recipe는 선언 블록의 해당 항목만 정확히 찾아 버전 문자열을 교체합니다.
2. Deprecated API 마이그레이션
Spring Boot 버전업으로 deprecated된 API 호출을 새 API로 변환합니다.
// Before
@Configuration
class WebConfig : WebMvcConfigurer {
override fun addCorsMappings(registry: CorsRegistry) {
registry.addMapping("/**")
.allowedOrigins("*") // Spring 6.2+에서 deprecated
}
}
// After (recipe 적용 후)
@Configuration
class WebConfig : WebMvcConfigurer {
override fun addCorsMappings(registry: CorsRegistry) {
registry.addMapping("/**")
.allowedOriginPatterns("*") // 새 API로 변환
}
}3. Import 문 변경
패키지가 이동된 클래스의 import를 업데이트합니다.
// Before
import org.springframework.boot.autoconfigure.web.ServerProperties
// After (패키지 이동된 경우)
import org.springframework.boot.web.server.ServerProperties4. 설정 프로퍼티 마이그레이션
application.yml의 deprecated 프로퍼티도 recipe가 변환합니다.
# Before
spring:
datasource:
initialization-mode: always
# After
spring:
sql:
init:
mode: alwaysRecipe만으로 안 되는 것들 — Claude의 역할
recipe는 규칙으로 표현 가능한 변환에 강합니다. 하지만 규칙으로 표현하기 어려운 변환도 있습니다. 정확히는, "무엇을 바꿔야 하는지"는 기계가 알 수 있어도 "어떻게 바꾸는 게 옳은지"가 코드 바깥의 맥락에 달려 있는 경우입니다. 이 지점이 결정론과 판단의 경계선이고, recipe와 Claude의 역할이 갈리는 곳입니다.
사례 1: 비즈니스 로직에 영향받는 타입 변경
API 시그니처가 바뀌면서, 호출 코드의 비즈니스 로직까지 수정이 필요한 경우.
// 라이브러리 API 변경 (backend-commons)
// Before: fun findWorkspace(id: Long): Workspace
// After: fun findWorkspace(id: Long): Workspace? (nullable로 변경)
// 서비스 코드에서의 호출
fun getWorkspaceName(id: Long): String {
val workspace = workspaceClient.findWorkspace(id)
return workspace.name // nullable 반환에 대한 처리가 필요
// 이걸 어떻게 처리할지는 비즈니스 맥락에 따라 다름
// - 예외를 던질 것인가?
// - 기본값을 반환할 것인가?
// - 상위로 nullable을 전파할 것인가?
}recipe는 "nullable 반환에 대해 어떤 비즈니스 결정을 내릴지"를 알 수 없습니다. 세 가지 선택지는 문법적으로 모두 정당하지만, 어느 것이 맞는지는 이 워크스페이스가 없을 때 시스템이 어떻게 동작해야 하는가에 달려 있습니다. 그건 코드의 구조가 아니라 도메인의 약속입니다. 규칙으로 미리 적어둘 수 없는, 매번 따져봐야 하는 판단이라는 뜻입니다. 여기서 Claude가 개입합니다 — 주변 코드의 기존 예외 처리 패턴을 읽고, 가장 그럴듯한 결정을 제안합니다. 다만 제안이 옳다는 보장은 Claude 자신에게 있지 않습니다. 그 보장은 다음에 볼 빌드 가드레일이 책임집니다.
사례 2: 빌드 에러에서 수정안 도출
recipe 적용 후 빌드가 실패하면, Claude가 빌드 로그를 분석합니다.
// 빌드 에러 로그
e: file:///src/main/kotlin/com/example/issue/service/IssueService.kt:42:15
Type mismatch: inferred type is Workspace? but Workspace was expected
e: file:///src/main/kotlin/com/example/thread/service/ThreadService.kt:28:23
Unresolved reference: getDefaultTimezone
(fun was renamed to getDefaultTimeZone in Spring Boot 3.5.8)Claude는 이 에러를 분석하고, 각 파일에 대한 수정을 생성합니다.
// Claude의 수정 - IssueService.kt
fun getWorkspaceName(id: Long): String {
val workspace = workspaceClient.findWorkspace(id)
?: throw WorkspaceNotFoundException(id) // 기존 코드의 예외 패턴을 분석
return workspace.name
}
// Claude의 수정 - ThreadService.kt
val timezone = config.getDefaultTimeZone() // 대소문자 수정AI + 빌드 가드레일 루프
Claude의 수정이 올바른지 어떻게 검증할까요? 본편 1화에서 이야기한 빌드 가드레일이 여기서 작동합니다.

이 루프에서 중요한 것은, Claude가 자유롭게 수정하되, 빌드가 검증한다는 것입니다. 잘못된 수정은 빌드가 잡아냅니다. Convention Plugin이 의존성 방향을 강제하고, 테스트 스위트가 비즈니스 로직을 검증합니다. Claude는 이 가드레일 안에서만 동작합니다.
Recipe 작성의 실제
flex 규모에서 recipe를 작성할 때의 실무적 고려 사항을 정리합니다.
1. 테스트 가능한 recipe
OpenRewrite recipe도 테스트할 수 있습니다. recipe가 의도한 대로 변환하는지, 변환하지 말아야 할 코드를 건드리지 않는지 검증합니다.
// Recipe 테스트 예시
@Test
void migratesMySQLConnectorVersion() {
rewriteRun(
spec -> spec.recipe(new MigrateDependencyVersions()),
// language=kotlin
kotlin(
"""
dependencies {
implementation("com.mysql:mysql-connector-j:9.1.0")
}
""",
"""
dependencies {
implementation("com.mysql:mysql-connector-j:9.2.0")
}
"""
)
);
}2. Idempotent recipe
recipe는 여러 번 실행해도 같은 결과를 내야 합니다. 이미 변환된 코드에 다시 적용해도 추가 변경이 없어야 합니다.
# 좋은 recipe: 조건부 적용
- org.openrewrite.java.dependencies.UpgradeDependencyVersion:
groupId: com.mysql
artifactId: mysql-connector-j
newVersion: 9.2.0
# 이미 9.2.0 이상이면 변환하지 않음 (OpenRewrite 내장 로직)3. Composition 가능한 recipe
작은 recipe를 조합해서 큰 마이그레이션을 구성합니다.
# 개별 recipe들을 조합
type: specs.openrewrite.org/v1beta/recipe
name: com.example.SpringBoot3_5_10
recipeList:
- com.example.UpgradeMySQLConnector # MySQL 커넥터 업그레이드
- com.example.UpgradeFixtureMonkey # FixtureMonkey 호환 버전
- com.example.MigrateDeprecatedApis # deprecated API 마이그레이션
- com.example.UpdateFamilyVersions # 내부 라이브러리 버전 갱신각 recipe는 독립적으로 테스트 가능하고, 다른 마이그레이션에서도 재사용 가능합니다.
파이오니어의 경험이 인코딩되는 과정
1화에서 "파이오니어의 경험이 슬랙 메시지가 아니라 recipe로 인코딩된다"고 했습니다. 그 과정을 구체적으로 보겠습니다.
1. 파이오니어가 첫 번째 레포에서 버전업 시도
2. MySQL 커넥션 릭 발견 -> MySQL Connector 9.2.0으로 수정
3. 이 수정을 recipe로 작성: com.example.UpgradeMySQLConnector
4. FixtureMonkey 호환성 이슈 발견 -> 1.1.11로 수정
5. 이 수정을 recipe로 작성: com.example.UpgradeFixtureMonkey
6. 두 recipe를 조합하여 com.example.SpringBoot3_5_10 생성
7. 커스텀 recipe 레포에 커밋, Nexus에 퍼블리시
8. Updater가 나머지 49개 레포에 이 recipe를 적용슬랙 메시지와 recipe의 차이는 이렇습니다.
| 속성 | 슬랙 메시지 | OpenRewrite Recipe |
|---|---|---|
| 실행 가능성 | 사람이 읽고 해석 | 기계가 자동 실행 |
| 재현 가능성 | 맥락에 따라 해석이 다름 | 동일 입력, 동일 출력 |
| 테스트 가능성 | 불가능 | JUnit으로 검증 가능 |
| 버전 관리 | 메시지 히스토리에 묻힘 | Git으로 이력 추적 |
| Idempotency | 두 번 적용하면 문제 발생 가능 | 여러 번 적용해도 안전 |
| 조합 가능성 | 문서를 합쳐야 함 | recipe를 compose |
recipe는 실행 가능한 문서입니다. 파이오니어의 경험이 코드가 되고, 그 코드가 50개 레포에 동일하게 적용됩니다. 1화의 슬랙 로그에서, 가이드가 세 번 바뀌는 동안 누구는 첫 버전으로 작업을 끝내버렸던 그 혼란을 떠올려 보면 차이가 분명합니다. recipe는 "다시 받아서 다시 적용"하면 그만이고, 이미 적용된 곳에 다시 돌려도 idempotent하니 안전합니다. 사람이 메시지를 읽고 손으로 옮기는 단계가 통째로 사라지는 것 — 이것이 Updater의 핵심 가치입니다.
여기까지가 "코드를 어떻게 바꾸는가"였습니다. 하지만 바뀐 코드는 아직 각 레포의 로컬 브랜치에 머물러 있을 뿐입니다. 50개의 변환 결과를 실제로 각 레포에 반영하고, 의존 순서를 지키며 머지까지 끌고 가는 일이 남았습니다. 다음 화의 Distributer가 그 마지막 구간을 맡습니다.
다음 화 — PR을 전파하는 Distributer. Updater가 코드를 변환하면, Distributer가 PR을 생성하고 Wave 기반으로 전파합니다. CI 상태 추적과 auto-merge 정책, 그리고 실패 시 에스컬레이션 기준을 살펴봅니다.
🚀플렉스팀 채용페이지 바로가기☕flex Private Talk 신청하기
