
「수년이 걸릴 마이그레이션이 수 주 만에 끝난다」를 47만 행의 유적에서 확인하다 (후편)
요약
대규모 레거시 코드(47만 행)를 JDK 21로 마이그레이션하는 과정에서 자동화 도구의 성능과 정확성을 검증한 사례입니다. 도구를 통해 비즈니스 로직의 결함과 유물을 식별하고, 테스트 코드를 생성하여 마이그레이션의 안정성을 확보하는 워크플로우를 보여줍니다.
핵심 포인트
- 47만 행의 코드 중 약 23만 행을 1시간 만에 JDK 21로 통과시킴
- 자동화 커맨드(assess, extract-rules, transform)의 유기적 연쇄를 통한 부채 식별
- 비즈니스 규칙 추출 및 결함(NPE, 이자 계산 오류 등)의 구조적 포착
- 수기 작업보다 정교한 테스트 생성 및 등가성 증명 방식 확인
TL;DR
- 전편의 preflight가 남긴 가장 중요한 gap은 「사라진 사내 Nexus」였다. 후편에서는 우선,
gap을 메우는 경로(벽을 벗겨내어 빌드를 통과시키러 가는 것)를 실측했다. 벽은 8개 층이 있었고, 1시간 만에 47만 행의 49.7%(233,747행)까지 통과했다. 비즈니스 로직의 심장부인appdomain(14.3만 행)을 포함하여 - 가장 중요한 숫자는 이것이다.
약 23만 행을 현행 JDK 21로 통과시키기까지, 코드 자체가 거부된 것은 import 실질 2줄(에러 4건)뿐이었다. 벽의 정체는 거의 모두 「사라진 공급원」이었으며, 코드는 15년이 지나도 컴파일러에게 거의 투명했다 - 남은 절반을 막고 있는 것은, 공급원이 죽은 Pentaho 라이브러리 14개와, JDK 21의 클래스 파일을 읽지 못하는 2011년제 AspectJ 플러그인 2개다.
/modernize-assess를 실행하여 정답 확인 표 10개 항목을 채점했더니 ○2 △2 ×6이었다. 비즈니스 사양의 유물은 직접적으로 수집하지 않는다. 하지만 표에 없는 새로운 부채를 file:line 포함하여 6건 발굴했으며, 그중 2건(여신 규칙의 페일 오픈(fail-open), 버그 티켓명의 영구 수정 코드)은 표의 어떤 것보다 무거웠다. 유물 그 자체를 지목하지는 않지만, 유물이 있는 방은 정확히 지목했다 -
/modernize-map은 「web.xml이 unused 하위」인 함정을 피하고, 반대로 struts-config에 등록되어 있음에도 실체가 없는 「유령 액션(ghost action)」을 3개 발굴했다. 이중 UI 스택 마이그레이션이 도중에 힘이 다한 흔적이다 -
/modernize-extract-rules를 3가지 렌즈로 실행하여 42개 규칙을 추출했다. 정답 확인 표의 비즈니스 규칙 유물 8개를 8/8 포착하여, 전건이 이루어졌다. 전편에서 내가 우려했던 「사고를 공식적인 비즈니스 규칙으로서 무비판적으로 작성해 버리는」 문제는 Suspected defect 란에 preserve/fix 판단과 함께 분류되었기에 발생하지 않았다 — 커맨드 설계가 유래에 대한 의심을 구조적으로 받아내고 있었다 - 덤으로 표에 없는 결함도 나왔다. 쌍둥이 이자 생성기가 size()>1과 size()>2에서 어긋나, 2분할 대출은 이자 타입에 따라 첫 회 이자가 일할 계산되거나 되지 않거나 한다 (실물로 확인) -
판정 자료로서 예측했던 equals의 NPE 계약 위반은, extract-rules가 아니라 assess가 잡아내는 층이었다. 비즈니스 사양의 결함은 extract-rules, 코드 품질의 결함은 assess로, 두 커맨드가 층을 깔끔하게 분담하고 있었다 -
/modernize-transform의 특성 테스트 생성만을 Money 모듈에 적용했더니 58개의 테스트를 생성했다. double 경로의 실질적인 피해를 (잡아냈다). 게다가 「망가진 값을 active로 assert + 있어야 할 값을 1234567.8899999998976으로 동결하고, 제4회 수기 골든(golden)과 끝 13자리까지 완전 일치하도록 @Disabled 처리」하는 이중 구조로, 수기보다 구조화되어 있었다 -
예측이 빗나갔다 — 공식은 기록 트레이스(record trace)에 빠지지 않았다. 검증 1에서 Money를 실행 가능하게 만들었기에, 공식은 더 강력한 live-legacy-execution 증명을 선택할 수 있었다. 벽을 벗겨낸 부수적인 결과가 등가성 증명을 격상시켰다 -
4개의 커맨드가 연쇄되어 있었다. assess가 방을 좁히고 → extract-rules가 유물을 의도/사고로 분류하고 → transform이 사고를 동결 테스트로 떨어뜨린다. 제3·4회의 수작업 「발굴 → 동결」이 커맨드 연쇄에 대응했다.
전편의 줄거리
전편에서 확정된 사실만을 재게시한다.
- 마이그레이션 키트의 3가지 전제(심판, 구조 보존, 룰북의 작성자)는 이 유적에서는 3개 모두 무너진다. 다만 code-modernization 플러그인이 3가지 모두에 수용처를 가지고 있다.
/modernize-preflight를 47만 행에 흘린 판정은 Not ready 0건이었다. 「빌드가 통과되지 않더라도 블록하지 않는다」는 선언은 실물로 지켜졌다 - 「빌드가 통과되지 않음」의 내역은, JDK 핀(pin) 총 30곳과, **조직과 함께 사라진 사내 Nexus(층상 구조)**로 분해되었다. 8%(39,112행)는 현행 JDK에서 그대로 통과했다 - 분석 도구 5개를 도입하여 다시 측정한 결과, 유적의 총량은 996,334행(Bun의 이식과 거의 비슷한 규모)이었으며, 독해가 필요한 함수는 CCN 15 이상의 152개(0.46%)로 압축되었다.
preflight가 내놓은 「가장 중요한 수정 사항」은 하나였다 — 사라진 사내 Nexus의 의존성을 어디서 조달할지 확정하라, 였다. 후편은 여기서부터 시작한다.
검증 1: 벽을 벗겨낼 것인가, 우회할 것인가
preflight 규칙에 따르면, 레거시 측 툴체인 (toolchain)이 빨간색(에러 상태)인 상태에서도 transform은 Ready-with-gaps로 진행할 수 있다 (동등성이 기록 트레이스 (record trace)로 폴백 (fallback)된다). 즉, 경로는 두 가지가 있다.
경로(a) 벽을 벗겨낸다: 의존성 (dependency) 계층을 채워 빌드 (build)를 끝까지 통과시킨다. 동등성 증명은 가장 강력한 '신구 동시 실행'을 사용할 수 있다 -
경로(b) 우회한다: 빌드는 포기하고, 기록 트레이스 (record trace) / 골든 마스터 (golden master)로 동등성을 증명한다
일본 SIer 레거시에서 어느 쪽이 주류가 될지는 경로(a)의 비용에 의해 결정된다. 그래서 우선 (a)를 실측했다.
경로(a): 벽을 벗겨냈더니 8개 층이 있었다
전편에서 판명되었던 벽은 2개 층이었다 (자체 제작 플러그인 mifos-pseudoloc-gen → Central에 승격되지 않은 spring-mobile-device:1.0.0.M3). 실제로 벗겨내기 시작하니 8개 층이 있었다. 전부 작성한다. 이 단조로움 자체가 계측 결과다.
| # | 벽 | 종류 | 조치 |
|---|---|---|---|
| 1 | JDK 핀 1.6 총 30곳 | 환경의 시차 | sed로 8로 치환 |
| 2 | 자체 제작 Maven 플러그인 mifos-pseudoloc-gen (6 pom) | 사라진 공급원 (자체 제작품) | 참조 자체를 제거. 의사 로컬라이즈 (pseudo-localize) 생성기로, 컴파일에는 불필요하다고 판단 |
| 3 | 사내 Nexus maven.mifos.org URL | 사라진 공급원 (리포지토리 (repository)) | 살아있는 URL로 교체 |
| 4 | spring-mobile-device:1.0.0.M3 (Central 미승격) | 사라진 공급원 (마일스톤 (milestone) 버전) | 현존하는 근연 버전 1.0.0.RELEASE로 교체 |
| 5 | struts:struts-legacy:1.0 (Central에는 1.1만 존재) | 사라진 공급원 (버전 누락) | 1.1로 교체 |
| 6 | strutstest:strutstest:2.1.4 (Central에 부재) | 사라진 공급원 (완전 소멸) | test 스코프 (scope)이므로 선언 자체를 제거 |
| 7 | javax.annotation 패키지 부재 | JDK 진화에 따른 API 삭제 | javax.annotation-api:1.3.2를 의존성에 추가 |
| 8 | BIRT jar 14개 org.mifos.birtlibs:* | 유적의 자기 빌딩 (self-vendoring) | 벽이 아니었음 (후술) |
보충 설명을 3가지 하겠다.
층 4는 한 번 실패했다. M3의 정식 공급원이었던 Spring의 마일스톤 리포지토리 (repo.spring.io/milestone)는 이제 익명 액세스에 503을 반환한다. 사내 Nexus뿐만 아니라, 대형 벤더의 리포지토리도 15년이면 죽는다. 근연 버전 1.0.0.RELEASE로의 교체로 해결했지만, 이는 API 호환성에 대한 도박이다. 사용처를 확인하니 5개 클래스 (Device, DeviceUtils, SitePreference 계열)였고, 도박은 성공했다. userInterface는 이 교체를 통해 컴파일 단계까지 도달했다.
층 7이 이 실험에서 가장 중요한 수치를 냈다. 여기까지의 벽은 전부 '의존성이 어디에 있는가'의 문제였으며, 코드에는 일절 손을 대지 않았다. 층 7에서 처음으로 코드 자체가 현행 JDK에 의해 거부되었다. 그 내역은 — javax.annotation의 import에 기한 에러 4건 (실질적으로 1종류). JDK 11에서 표준 라이브러리에서 삭제된 패키지로, 의존성을 하나 추가하면 사라진다. 즉, 여기까지 통과시킨 약 23만 행의 Java 코드 중, 15년 치의 JDK 진화에 걸린 것은 실질적으로 import 2줄이었다. 1.6 시대의 Java 코드는 현대의 컴파일러에게 거의 투명하다.
층 8은 벽조차 아니었다. reporting이 요구하는 org.mifos.birtlibs:* 14개는, 전편 Check 4에서 '소스 없는 jar 140개'라고 셌던 바로 그 jar 군으로, 리포지토리 내에 동봉되어 있었다. reporting-libs라는 pom만 있는 모듈이, install 페이즈 (phase)에서 그것들을 로컬 리포지토리로 자기 설치(self-install)하도록 설계되어 있다. mvn compile로는 영원히 보이지 않으며, mvn install -pl reporting-libs
한 번 통과시키면 해결된다. 유적은 사라진 사내 Nexus에 의존할 수 없는 날을 위한 우회로를 스스로 가지고 있었다. 참고로 jar 중 하나는 coreapi_MIFOS-5119_MIFOS-5118.jar라는 이름으로, 수동 패치(手パッチ)한 JIRA 티켓 번호가 파일명에 새겨져 있다. 20년 전 누군가의 노고가 그대로 화석이 되어 있다.
도달점: 50%에서 멈췄다. 멈춰 세운 벽은 2종류
| 단계 | 통과 모듈 | 통과 행수 | 비율 |
|---|---|---|---|
| 순수 상태 (전편) | 0/24 | 0 | 0% |
| ... | 233,747 | 49.7% |
업무 로직의 심장부인 appdomain (142,936행)이 통과된 것이 크다. 제4회에서 특성 테스트(特性テスト)를 작성한 LoanBO와 AccountBO도 이제 현행 JDK 21에서 컴파일이 통과된다. 남은 절반을 막고 있는 것은 두 가지다.
- Pentaho 라이브러리 14개 (
pentaho-library:libbase:1.2.8등). 공급원이었던 Pentaho의 공개 리포지토리(Public Repository)는 죽어 있고, Central에도 없으며, BIRT와 달리 동봉되어 있지도 않다.reporting과 그에 의존하는application이후가 전멸한다. - aspectj-maven-plugin 1.4 (2011년 제작).
rest의 AOP 위빙(Weaving)에 사용되지만, 이 연대의 AspectJ 컴파일러는 JDK 21의 클래스 파일(java.lang.Enum조차)을 읽을 수 없다. 층 7이 '코드와 JDK의 시차'였다면, 이것은 '빌드 플러그인과 JDK의 시차'로, 플러그인 측의 세대교체가 필요하다.
작업량 총괄. 8개 층에서 사용한 것은 sed 몇 회, pom 편집 4종, mvn install 1회 — 전부 합쳐서 1시간 분량의 작업이다. 사라진 사내 Nexus는 '전체를 블록하는 가장 중요한 gap'(전편 preflight의 판정)이었지만, 벗겨내 보니 대부분의 층은 10분급이었고, 정말로 메울 수 없는 것은 Pentaho와 AspectJ 두 가지였다. preflight를 읽는 법(❌의 개수가 아니라 종류)이 여기서도 유효하다.
경로(b): 우회는 검증 4에서 자연스럽게 모습을 드러냈다
경로(b)(기록 트레이스(Record Trace)로의 폴백(Fallback))는 독립된 실험으로서 돌리지 않았다. 돌릴 필요도 없이, 검증 4에서 공식 test-engineer가 분기 조건 그 자체를 언급했기 때문이다—'레거시가 컴파일되면 live-legacy-execution, 되지 않으면 기록 트레이스'. 이 유적에서는 벗겨낸 49.7%(Money 포함)가 (a)의 대상, 벗겨내지 못한 남은 절반(reporting/Pentaho 계열)이 (b)의 대상이라는 선이 그어졌다. 같은 유적 안에 (a)와 (b)가 동시에 존재한다. 어느 한쪽을 선택하는 문제가 아니라, 모듈별로 빌드가 통과되는지에 따라 자동으로 분류된다. 일본의 SIer 레거시에서도 전부를 경로 (a)로 통과시킬 필요는 없다—통과할 수 있는 모듈은 강력한 증명, 통과할 수 없는 모듈은 기록 트레이스라고 단정 지을 수 있다. preflight가 애초에 그렇게 설계되어 있었다.
검증 2: 공식 발굴 vs 자작 발굴
제1회에서 자작 슬래시 명령어가 이 유적에서 파헤쳐낸 유물은, 제2회에서 '정답 확인표 10항목'으로 확정되어 있다. 회계 분개(Accounting Journal)의 HashSet 반복 순서, hashCode()의 ID 대용(MIFOS-4517), 0.09 이하의 완납 사례 없음, CEILING 반올림—이 표를 이번에는 공식 명령어의 채점에 사용한다.
/modernize-assess와 /modernize-map을 같은 유적에 흘려보냈다. assess는 Step 1-7을 절차대로 실행하며, Step 3의 3개 서브 에이전트(구조 맵, 기술적 부채, 보안 감사)를 병렬로 실행했다. map은 지시대로 일회성 추출 스크립트(extract_topology.py)를 작성하여, topology.json과 대화형 뷰어 TOPOLOGY.html을 생성했다. 실행 방식은 전편 preflight와 마찬가지로 슬래시 기동이 아닌 명령어 정의에 따른 수동 실행이다.
assess는 정답 확인표를 직접 맞히지는 않는다. 하지만 서식지는 맞혔다
먼저 정답 확인표 10항목을 assess의 ASSESSMENT.md(특히 Technical Debt 상위 10)가 지목할 수 있었는지를 기준으로 채점한다.
| 정답 확인표의 유물 (제2회에서 확정) | assess의 언급 |
|---|---|
| 회계 분개(Accounting Journal)의 계정 과목이 HashSet 반복 순서에 따라 결정됨 | △ 신(God) 오브젝트로서 LoanBO의 HashSet 수수료 저장 → 스케줄이 해시 순서에 의존한다고 부채 #10에서 지적. 회계 분개로의 파급까지는 도달하지 못함 |
| ... | |
| ○2, △2, ×6. 예고한 대로, assess는 「업무 사양에 각인된 결함」을 직접적으로 잡아내지는 못한다. 표의 10개 항목은 「이 임계값은 의도인가 사고인가」라는 업무 지식을 요하는 유물로, 정적 분석(Static Analysis)의 설계 사상(인벤토리화)과는 층위가 다르다. 제2회의 Fable 5(○7)에도 훨씬 못 미친다. |
하지만 여기서 단순한 패배로 판정하면 오판하게 된다. assess는 정답 확인표에는 없는 새로운 부채를 file:line(파일:라인) 정보와 함께 6건 가져왔다. 게다가 그중 2건은 정답 확인표의 그 어떤 것보다 실무적으로 무겁다.
여신 규칙의 페일 오픈 (Fail-open) (LoanOfferingBO.java:1015 외 3곳). 대출 적격 범위(Eligibility Range) 선택이 빈 catch(Exception e){}로 둘러싸여 있어, 실패하면 묵묵히 「마지막으로 정의된 범위」를 반환한다. 그것이 최대 금액이라면, 예외가 발생할 때마다 여신 한도가 최대치로 변해버린다. -
버그 티켓명을 명명한 영구 수리 코드 (LoanBO.java:4122의 applyMifos5722Fix()). 과거에 데이터 손상 버그가 발생했을 때, 그 자리에서 데이터 수술(Data Surgery)로 막아버려 수술 코드가 운영 메서드로 남게 된 것이다. 제3회·제4회에서 추적했던 「결함이 사양으로 정착하는」 과정의 화석화된 실례다.
제2회의 채점 기준(정답 확인표를 몇 할이나 맞히는가)으로 보면 assess는 약하다. 하지만 제5회에서 내가 「표 밖에 무엇을 쌓는가」로 축을 옮겨야 한다고 썼던 그 새로운 축으로 보면, assess는 표 밖을 6건 파헤쳤다. 단발적인 Fable 5 발굴과는 상보적이며, 경쟁 관계가 아니다.
그리고 전편의 예측(발굴 대상은 복잡도로 압축할 수 있다) — 복잡도 랭킹이 유물의 서식지를 가리킬 수 있는가 — 는 적중했다. 부채 상위 항목은 모두 LoanBO / LoanScheduleEntity / AccountApplyPaymentAction에 집중되어 있으며, 이는 전편의 lizard 상위 항목(대출·저축 검증기)과 동일한 계층이다. 정답 확인표의 유물 대부분도 이곳에 거주한다. assess는 유물 그 자체를 지목하지는 않지만, 유물이 있는 방은 정확히 지목했다. 발굴(제1회의 자체 제작 프롬프트) 전에 방을 좁히는 도구로서 assess는 유효하다.
map은 함정에 빠지지 않고, 오히려 유령을 3체 찾아냈다
전편에서 예고한 함정 — web.xml이 reporting/src/unused/에만 있다는 점 — 에 대해, map은 올바르게 대처했다. 추출 스크립트는 struts-config 8개 파일에서 74개의 액션(Action)을, Spring MVC 어노테이션에서 112개의 컨트롤러(Controller)를 엔트리 포인트(Entry Point)로 찾아냈으며, web.xml에 대해서는 「진짜는 unused 하위의 1장뿐이며, 실제 구동은 프로그램 구성에 따름」이라고 observations에 사실로서 기록했다. 배포 기술서(Deployment Descriptor)를 맹신하여 「엔트리 포인트 제로」라고 판단하거나, unused의 web.xml을 믿고 잘못된 엔트리를 세우는 실수도 하지 않았다.
찾아낸 74개의 액션이 전편에서 grep으로 찾은 Struts Action 74개와 일치한 것도 좋은 상호 검증이 되었다.
그뿐만 아니라 map은 지시하지 않은 부산물을 내놓았다. struts-config에 등록되어 있음에도 클래스가 존재하지 않는 「유령 액션」을 3체 찾아낸 것이다.
/acceptedPaymentTypeAction (other-struts-config.xml:285) → 대응하는 Struts 클래스가 소스 어디에도 없음. 기능은 Spring MVC 버전(AcceptedPaymentTypesController)으로 이전되었으나, Struts 측의 매핑만 남아 있어 요청 시 에러가 발생하는 상태 -
moratorium의 폼 빈(Form Bean) → org.mifos.application.moratorium 패키지가 애초에 존재하지 않음 -
권한 폼 빈의 타이포 복제 (RolesPermissionActionForm vs 올바른 RolesPermissionsActionForm)
이것은 이중 UI 스택 이행의 도중에 힘이 다한 흔적이다. Struts → Spring MVC 이행이 기능별로 진행되면서, Spring 측을 만든 후에 Struts 측의 설정을 지우는 것을 잊어버린 것이다. map 정의가 "grep만으로는 디스패치(dispatch) 대상 전체를 dead로 오판정한다"라고 경고했던 것과는 반대로, 설정에는 등록되어 있지만 실체가 없는 엔트리라는, 정적 분석(static analysis)으로만 찾아낼 수 있는 불일치를 포착했다. 이행 설계상 이는 매우 중요한데, strangler-fig 패턴으로 나머지를 교체할 때, 이 3가지 유형은 "이미 교체되었으나 대장에서 삭제되지 않은 것"으로 구분하여 먼저 처리해야 한다.
요약하자면, 검증 2의 채점 결과는 다음과 같다. assess는 정답 확인용 표(answer key)에는 약하지만 표 외부의 정보에는 강하며, map은 함정을 피하면서 이행이 완료되지 않은 흔적을 파헤쳐냈다. 둘 다 "공식 커맨드(official command)가 실재하는 레거시에서 의미 있는 출력을 내놓는다"는 것을 입증했다. 생성물(ASSESSMENT.md / topology.json / TOPOLOGY.html / .mmd 3종)은 모두 analysis/mifos/에 남아 있다.
검증 3: extract-rules는 「사양화된 결함」을 어떻게 작성하는가
이 연재의 중심 테마는 결함이 사양(specification)으로 정착된 부분의 처리다. /modernize-extract-rules는 업무 규칙(business rule)을 Given/When/Then 형식의 Rule Card로 작성한다. 여기에 구조적인 위험성이 존재한다.
"잔액 0.09 이하는 완납으로 간주한다"를 Rule Card에 작성하는 순간, 그것은 단수 면제라는 정식 업무 규칙처럼 보이게 된다. 실제로는 누군가의 임시방편일 수도 있다. Rule Card의 서식은 그 유래를 묻지 않는다.
즉, extract-rules의 시험대는 규칙을 찾아낼 수 있느냐가 아니라, 찾아낸 규칙에 「이것은 의도인가, 사고인가」를 판단할 재료를 덧붙일 수 있는가이다.
판정 연습: CCN 45의 equals를 인간이 먼저 읽다
실행 전에 판정 측의 실력을 확인한다는 의미에서, 전편에서 CCN 45라고 지칭했던 BranchReportStaffSummaryBO::equals를 인간(나)이 읽어보았다. 전편에서는 "사양화된 결함의 전형적인 서식지"라고 썼다. 절반은 틀렸고, 절반은 맞았다.
틀린 절반. 내용은 IDE가 생성한 정형 코드(boilerplate)였다. 필드별로 null 체크를 하며 비교하는, Eclipse가 뱉어내는 형태 그대로의 기계적인 코드다. CCN 45라는 숫자는 "45개의 판단이 얽힌 복잡한 업무 로직"이 아니라 "필드가 많을 뿐인 정형 코드"를 가리키고 있었다. 복잡도의 숫자는 읽어야 할 곳을 좁혀주지만, 읽은 결과 무엇이 나올지까지는 알려주지 않는다.
맞은 절반. 정형 코드임에도 불구하고 결함이 있다.
public boolean equals(Object obj) {
if (this == obj) {
return true;
...
null 체크가 없다. Eclipse의 생성 코드에는 본래 if (obj == null) return false;가 들어간다. 누군가 지웠는지, 생성 후에 손을 댔는지는 알 수 없으나, 결과적으로 equals(null)은 false를 반환하지 않고 NullPointerException을 던진다. Object.equals의 계약(contract) 위반이며, 이 객체를 List.contains나 remove에 통과시키는 순간 밟게 되는 지뢰다.
이는 extract-rules에 대한 구체적인 질문이 된다. 이 equals를 Rule Card로 옮길 때, 「null과는 같지 않다」라고 쓸 것인가, 「null을 전달하면 오류가 발생한다」라고 쓸 것인가. 전자는 계약으로부터 추측한 이상향이고, 후자는 실제 동작이다. 특성 테스트(characterization test)의 유파(제4회)라면 망설임 없이 후자를 쓸 것이다. 공식 커맨드가 어느 쪽을 작성하는지는, 도구의 사상이 가장 솔직하게 드러나는 장면이라고 생각한다.
실행: 3개의 렌즈로 42개의 규칙을 추출하다
Method B(계산·검증·상태의 3개 렌즈를 병렬 서브 에이전트로 실행하는 방식)로 실행했다. 추출한 것은 42개의 규칙(P0가 21개)이다. 결과물 BUSINESS_RULES.md / DATA_OBJECTS.md를 analysis/mifos/에 생성했다.
먼저 솔직한 주석을 하나 남기겠다. 3개의 렌즈(lens) 프롬프트에는 정답 확인표(answer key)에 대응하는 관점을 힌트로 심어두었다(예: "일수 기준은 360/365 고정인가, 윤년을 제외하는가" 등). 따라서 "제로 지식(zero-knowledge) 상태에서 전부 발굴해냈다"는 검증은 아니다. 제2회 Fable 5 발굴(표를 주지 않고 수행한 ○7)과는 경기장이 다르다. 여기서 측정하는 것은 포착률이 아니라, 수집한 규칙을 『올바른 사양(correct specification)』, 『보존해야 할(preserve) 사고』, 『수정해야 할(fix) 결함』으로 분류할 수 있는가—전편에서 내가 우려했던 바로 그 지점이다.
채점: 정답 확인표의 업무 규칙은 8/8, 전건이 올바르게 분류되었다
업무 규칙에 해당하는 8개의 유물(주석 처리된 87행과 매몰된 구형 구현 2개는 데드 코드(dead code)로서 "규칙"이 아니기에 대상에서 제외)을, extract-rules는 전부 Rule Card로 만들었다. 본론은 그 다음이다.
| 정답 확인표의 유물 | Rule Card |
|---|---|
Suspected defect 란의 분류 | |
| 최종 반올림이 CEILING(항상 올림) | RULE-C02 |
| 잔액 0.09 이하를 완납 처리 | RULE-C10 |
| ... | Fix. data-integrity risk |
| 권한이 동일 ID(142)를 공유 | RULE-V06 |
| 회계 분개가 HashSet 반복 순서에 따라 결정됨 | RULE-L08 |
| 쌍둥이 클래스에서 첫 이자 발생 조건이 서로 다름 | RULE-L13 |
8/8 포착, 8건 전부가 Suspected defect 란에 preserve/fix 판단과 함께 분류되었으며, 7건에는 SME 질문이 덧붙여졌다. 전편에서 내가 썼던 우려—"『잔액 0.09 이하를 완납 처리』를 Rule Card에 옮겨 적는 순간, 잔액 면제라는 공식적인 업무 규칙처럼 보여버린다"—는
빗나갔다. RULE-C10은 실제로 "여기서 실제로 돈이 상각되므로, 의도인지 SME에게 확인하라"고 적고 있다. Rule Card의 서식은 유래를 불문할 것이라는 게 나의 예상이었으나, 커맨드(command)의 설계는
Suspected defect
라는 란을 하나 가짐으로써, 유래에 대한 의구심을 구조적으로 받아내고 있었다. CEILING 처리(RULE-C02)가 상징적이다. 이는 제3회에서 "결함이 아니라 사양일지도 모른다"라고 내가 보류했던 항목이지만, extract-rules는 "보존해야 함(preserve)”이라고 판단한 뒤에 “단, 말없이 HALF_UP으로 바꾸면 모든 대출의 총액이 변한다”라는 이행 시의 경고까지 덧붙였다. preserve/fix라는 이분법 사이에 "preserve이지만 주의 요망"이라는 계조(gradation)가 존재한다. 이는 인간 결정권자가 작성하는 주석과 거의 동일한 입도(granularity)다.
예상치 못한 부가가치: 정답 확인표에 없는 것을 2개 발굴하다
힌트로 제공한 8개의 유물 외에도, extractor는 표에 없는 결함을 찾아냈다. 두 가지를 꼽겠다.
이자 엔진이 두 개 존재하며, 기준이 서로 다르다. 고정 스케줄 생성 측(flat/declining 계산기)은 360/365 고정 일수를 사용하여 윤일을 고려하지 않는 반면, 재계산·변동 분할 측(ScheduleCalculatorAdaptor)은 실제 달력 일수를 사용한다. 동일한 대출이라도 어느 경로로 재계산되느냐에 따라 다른 이자가 발생한다. 이는 정답 확인표의 유물 8(360/365 고정)보다 더 깊은 곳에 있던 불일치였다.
쌍둥이 제너레이터의 off-by-one (RULE-L13). 이는 유물 4 "쌍둥이 클래스에서 첫 이자 발생 조건이 서로 다름"에 대응하지만, extractor가 특정한 내용이 날카롭다. 표준 EMI 버전은 dates.size() > 1을, 균등 원금 버전은 scheduledDates.size() > 2를 기준으로 첫 일할 계산을 발동한다. 실제 코드로 확인했다.
DecliningBalancePrincipalWithInterestGenerator.java:33: if(dates.size() > 1){
DecliningBalanceEqualPrincipalWithInterestGenerator.java:36: if(scheduledDates.size() > 2){
정확히 2분할된 대출은 이자 유형이 EMI인지 EPI인지에 따라 첫 이자가 일할 계산되거나 되지 않는다. 동일한 feature(M5193)·동일한 의도의 복사 붙여넣기로 인해, 가드(guard) 조건만 >1에서 >2로 어긋났다.
extractor는 이를 "거의 확실한 복사 붙여넣기 버그, 단 정확한 임계값은 SME에게 확인 필요(아마도 둘 다 >1일 것)"라고 분류했다. 결함의 특정뿐만 아니라, 수정 방법의 후보와 이를 단정 짓지 않는 판단 유보까지 작성하고 있다.
판정 연습과 대조하기
실행 전의 "판정 연습"에서, 나는 미리 CCN 45의 equals를 읽고, null 체크 누락으로 NPE(NullPointerException)를 발생시키는 계약 위반을 발견했다. 그때 세웠던 "어느 쪽을 작성할 것인가"에 대한 답은 어느 쪽도 아니었다. extract-rules는 이 equals를 업무 규칙(business rule)으로는 포착하지 않았다—이것은 옳다. equals()의 계약 위반은 "업무 규칙"이 아니라 "코드 품질 결함"이며, 이는 assess의 Technical Debt #10이 포착하는 계층이다. extract-rules와 assess는 포착하는 계층을 깔끔하게 분담하고 있었다. 업무 사양의 결함은 extract-rules가 Suspected defect로, 코드 품질의 결함은 assess가 부채(debt)로 분류한다. 인간이 제3회·제4회에서 수작업으로 나누었던 두 종류의 결함을, 두 개의 명령어가 나누어 포착했다.
검증 4: 공식 특성 테스트 vs 제4회의 수기 작성
/modernize-transform은 공정에 특성 테스트(characterization test) 생성을 내장하고 있다. 제4회에서 내가 직접 작성한 특성 테스트(공개 리포지토리, 실제 6개 클래스 + 스텁 9개, 골든 마스터 포함)와 동일한 모듈을 대상으로, 공식 도구가 생성하는 테스트와의 중첩도를 측정한다.
/modernize-transform
AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기