- 에이전트가 작성한 17,155줄 규모의 Rust 데이터 접근 계층을 단계적으로 리팩터링하자, 같은 기능 변경에 필요한 입력 토큰이 159,564개에서 27,360개로 83% 감소함
- 전체 코드량은 거의 그대로였지만 관련 코드를 응집도 높은 파일로 분리하면서, 에이전트가 변경에 필요한 최소 파일 집합만 읽을 수 있게 됨
- 입력 토큰은 가장 큰 파일이 충분히 작아지기 전까지 크게 줄지 않았으며, 최종적으로 데이터 계층을 19개 Rust 파일로 나누고 최대 파일 크기를 17,155줄에서 3,695줄로 줄임
- 출력 토큰과 기능 구현량은 거의 변하지 않았고 Claude도 적절한 리팩터링을 스스로 선택하거나 안정적으로 수행하지 못해, 계획과 실행에 사람의 적극적인 지침이 필요했음
- Sonnet 5의 입력 가격 $3/MTok 기준으로 변경 1회당 절감액은 약 $0.397에 불과하지만, 이후 데이터 접근 계층을 수정할 때마다 비용이 반복적으로 줄어들 가능성을 확인함
에이전트가 만든 17,155줄짜리 파일
- 업무 지원용 애플리케이션은 동적 갱신·조회가 가능한 웹 UI, 모달과 자동 저장, 외부 시스템 연동, 머신러닝과 텍스트 분석, 백그라운드 작업, 자동 배포 환경을 갖춤
- 전체 약 15만 줄 가운데 Rust가 약 12만 줄이고 나머지는 TypeScript와 Terraform이며, 대부분 Claude Code와 일부 Cursor를 이용해 에이전트가 작성함
- 개발자는 흥미로 가끔 살펴본 경우를 제외하면 코드를 읽거나 검토하지 않았음
- 데이터 접근 계층은 모든 읽기·쓰기 쿼리에서 같은 HTTP 요청 설정과 JSON 인코딩·디코딩을 반복하면서 6,000줄 이상으로 성장했고, 결국 하나의 Rust 파일이 17,155줄에 도달함
- 이 모듈에는 중복 제거와 내부 언어가 없고 함수 추출이 제한적이며 클래스 추출도 거의 없었지만, 보존할 인터페이스와 명확한 경계가 있어 리팩터링 실험에 적합했음
동일한 변경을 반복한 측정 방식
- 목표는 현재 리팩터링에 토큰을 투자해 향후 기능 변경의 토큰 소비를 낮출 수 있는지 확인하는 것이었음
- 에이전트는 이전 작업에서 학습하지 않으므로, 매 단계마다 새 하위 에이전트에게 정확히 같은 변경을 요청해 학습 효과가 섞이지 않도록 함
- 실험은 다음 순서로 진행함
- 엄격한 리팩터링 원칙에 따라 전체 계획을 작성함
- 하나의 프롬프트로 대표 변경을 정의함
- 하위 에이전트가 변경을 수행하고 토큰 소비량을 보고하게 해 기준값을 측정함
- 변경 결과를 폐기한 뒤 리팩터링 한 단계를 적용함
- 같은 변경을 다시 수행하고 결과를 폐기하는 과정을 반복함
- 단계별 토큰 비용, 실행 시간, 코드 줄 수를 기록함
- Claude가 실시간 토큰 수를 신뢰성 있게 제공하지 못해 송수신 문자 수를 보고하게 하고, tiktoken으로 문자 수를 4로 나눠 토큰을 근사함
단계별 측정 결과
- 기준 상태에서는 데이터 접근 계층과 최대 파일이 모두 17,155줄, 전체 Rust 코드는 50,359줄이었고 대표 변경에 입력 159,564토큰, 출력 1,705토큰, 342초가 필요했음
- 15단계 이후 데이터 접근 계층은 16,608줄, 최대 파일은 3,695줄, 전체 Rust 코드는 49,812줄이었으며 입력 27,360토큰, 출력 2,113토큰, 실행 시간은 454초였음
- 중간 단계에서는 최대 파일이 작아질수록 입력 토큰도 함께 감소함
- 7단계
queries.rs추출 후 최대 파일은 15,670줄, 입력은 151,850토큰으로 줄어듦 - 8단계
traits.rs추출 후 최대 파일은 13,845줄, 입력은 132,558토큰이 됨 - 12단계
store/분리 후 최대 파일은 9,269줄, 입력은 104,080토큰까지 감소함 - 마지막
store/분리 후 최대 파일은 3,695줄, 입력은 27,360토큰으로 급감함
- 7단계
- 최종 데이터 접근 계층은 19개 Rust 파일로 구성됐고, 가장 큰 파일은 테스트 라이브러리가 됨
- 추가 리팩터링에서도 같은 방식을 해당 테스트 파일에 적용할 수 있음
입력 토큰이 83% 줄어든 이유
- 같은 작업의 입력 토큰은 159,564개에서 27,360개로 줄어 132,204토큰을 절약함
- 데이터 접근 계층의 전체 코드량은 거의 변하지 않았으므로, 읽을 코드 자체가 줄어든 결과는 아님
- 에이전트가 작업에 필요한 최소 파일 집합을 식별해 점점 더 작은 코드 영역만 읽었으며, Claude Code의 사고 출력과 파일 읽기 요약에서도 이를 확인할 수 있었음
- 파일을 임의로 잘게 나누기만 하면 관련 코드를 찾으려고 여러 파일을 읽어야 하므로 같은 효과를 얻기 어려움
- 가장 큰 감소는 마지막 분리에서 발생했지만, 앞선 단계에서 중복을 추출하고 반복되는 핵심 구조를 만든 덕분에 파일 분리가 가능했음
- 이 순서는 비용 절감을 목표로 사전에 설계한 것이 아니라, 로컬 중복을 먼저 제거하고 공통 핵심이 드러난 뒤 작은 파일로 분해하는 일반적인 리팩터링 과정에서 나옴
출력 토큰과 금전적 효과
- 대표 변경을 작성할 때 생성된 출력 토큰은 거의 변하지 않아, 리팩터링이 실제 변경 크기까지 줄이지는 못함
- 출력 토큰 가격은 입력 토큰의 5배였지만 절대량은 훨씬 적었음
- Sonnet 5 입력 가격을 $3/MTok으로 계산하면 변경 1회당 절감액은 약 39.7센트임
- 디버깅이나 더 복잡한 기능, 코드베이스 전체 리팩터링에서도 절감이 누적되는지와 리팩터링 자체 비용이 얼마인지는 확인되지 않음
- 출력 토큰까지 줄이는 리팩터링이 가능한지도 불분명하며, 단순한 대표 변경에서는 비결정적 코드 생성의 잡음이 구조 변화에 따른 차이를 가렸음
Claude와 진행한 리팩터링
- Claude는 코드를 보고 적용할 리팩터링을 스스로 선택하지 못했으며, 실제 결과는 프롬프트가 직접 지시한 작업에 대응함
- 개발 하네스에 명시적인 리팩터링 단계가 있었지만, Claude는 이를 통해 17,155줄짜리 파일을 개선하지 않았음
- 계획 수립 과정에서 Claude Code는 함수 추출을 첫 단계로 찾은 반면, Claude.ai는 전체 클라이언트 클래스 추출까지 식별함
- 기계적인 변경에는
grep과sed를 사용하는 Python 스크립트를 활용했지만, 스크립트가 들여쓰기 때문에 자주 혼란을 겪음 - 가장 가치가 컸던 저장소 파일 분리는 첫 시도에서 누락돼 후속 단계로 다시 적용했으며, 이 때문에 측정 결과의 단계 수와 부록의 계획 단계가 일치하지 않음
- 전체 실험에는 약 8시간이 걸렸고 대부분 무인으로 진행됨
- 6시간 40분 뒤 누락된 단계를 발견해 한 차례 개입함
- 느린 호텔 Wi-Fi보다 지나치게 커진 Cargo 임시 빌드 캐시가 테스트 실행을 크게 늦춘 원인이었음
대표 변경 프롬프트
- 각 하위 에이전트는 코드베이스와 아키텍처 문서만 제공받고 동일한
ItemWatchStore비동기 공개 트레이트를 구현함 - 트레이트는 다음 세 메서드를 포함함
watch_item(&self, item_id: &str, user_id: &str) -> Result<()>unwatch_item(&self, item_id: &str, user_id: &str) -> Result<()>watched_items_for_user(&self, user_id: &str) -> Result<Vec<String>>
- watch 정보는 Firestore의
item_watches컬렉션에itemId,userId,createdAt필드로 저장함 - 별도의 Rust 레코드 구조체 없이 item ID의
Vec<String>을 반환함 FakeStore에는 메모리 내Vec<(String, String)>필드를 추가하고,FirestoreStore에는 기존 HTTP 패턴을 그대로 사용해 구현함- 응답 마지막에는 읽은 파일과 문자 수, 응답 문자 수를 JSON으로 출력하게 했으며 코드는 커밋하지 않도록 지시함
적용한 리팩터링 계획
-
1단계 —
FirestoreClient클래스 추출- 도메인 쿼리 조정과 Firestore HTTP 전송 책임을 분리함
reqwest::Client,project_id,MetadataAuth, URL·인증 헤더 처리를 새 구조체로 옮김- 계획상
FirestoreStore구현에서 약 1,200줄을 줄이고 클라이언트에 약 120줄을 추가함
-
2단계 —
extract_doc_id,new_link함수 추출- 20개 문서 파서의 ID 추출과 62회 반복된
Link생성을 공통화함 - 약 500줄 절감을 예상함
- 20개 문서 파서의 ID 추출과 62회 반복된
-
3단계 — 링크 쿼리 파이프라인 함수 추출
- 약 15곳의 쿼리 결과 수집과 약 8곳의 단일 대상 ID 조회 패턴을 공통화함
- 약 200줄 절감을 예상함
-
4단계 —
FakeStoreInner링크 조건 함수 추출- 약 15개 메서드에 반복된
inner.links.iter()변형을 두 메서드로 분리함 - 약 120줄 절감을 예상함
- 약 15개 메서드에 반복된
-
5단계 — Firestore 값 생성 함수 도입
- 128회 이상 반복된 문자열·타임스탬프 등의
json!표현을 네 개의 함수 호출로 교체함 - 여러 줄 매크로를 한 줄 호출로 바꿔 약 80줄 절감을 예상함
- 128회 이상 반복된 문자열·타임스탬프 등의
-
6단계 —
FieldsBuilder추출- 약 20개 인코더의 필드 맵 생성 패턴을 빌더로 통합함
- 약 40줄짜리 인코더를 약 12줄로 줄여 총 500~600줄 절감을 예상함
-
7단계 —
queries.rs분리- 32개
LinkQuery상수와 관련 타입을 별도 모듈로 이동함 - 기존 호출부를 변경하지 않고
mod.rs에서 약 800줄을 줄임
- 32개
-
8단계 —
traits.rs분리- 17개 공개 트레이트와 관련 오류 타입을 이동하고 다시 내보냄
mod.rs에서 약 1,900줄을 줄이지만 새 파일도 약 1,900줄이 됨
-
9단계 —
traits/도메인별 분리- 트레이트를
planning.rs,content.rs,people.rs,system.rs로 나눔 - 정의와 호출부를 바꾸지 않고 파일별 크기를 약 300~650줄로 제한함
- 트레이트를
-
10단계 —
codec.rs분리- 문서 인코더·디코더, 파서,
FieldsBuilder, 값 생성 함수를 이동함 - 6단계 이후 약 400~500줄짜리 모듈이 되며
mod.rs에서 약 500줄을 줄임
- 문서 인코더·디코더, 파서,
-
11단계 —
fake_store.rs분리FakeStore,FakeStoreInner, 18개 트레이트 구현을 이동함mod.rs에서 약 4,700줄을 줄임
-
12단계 —
FirestoreStore구현 분리store/mod.rs에는 구조체, 생성자,FirestoreClient,MetadataAuth를 두고 트레이트 구현은 도메인별 파일로 나눔- 약 10,000줄짜리 파일을 각각 120~650줄인 10개 파일로 바꾸고
mod.rs는 약 100줄짜리 재내보내기 파일로 만듦
-
13단계 — 테스트를 대상 모듈과 함께 배치
- 테스트 코드는 바꾸지 않고 각 구현 파일 아래로 이동함
mod.rs에서 약 2,000줄을 줄이고 각 파일에는 관련 테스트 200~700줄이 추가됨
한계와 후속 실험
- 리팩터링 계획 작성과 실행에 든 토큰을 별도로 세지 않아 리팩터링 투자 비용을 정확히 계산할 수 없음
- 해당 시간대 전체 사용량을 기준으로 한 상한은 500만 토큰이지만, 여기에는 계획을 두 번 작성한 작업, 실험 및 대표 변경 설계, 기타 작업이 모두 포함됨
- 실험 대상은 아직 그린필드 단계이고 한 개발자가 구축·관리하는 하나의 대규모 애플리케이션이므로 결과를 일반화할 수 없음
- 후속 작업으로 정확한 리팩터링 토큰 측정, 더 복잡한 변경, 더 넓은 범위의 리팩터링, 지속적 리팩터링, 접근 방식별 상대 가치 비교가 필요함
- 이번 실험은 리팩터링으로 얻는 시간·금전적 가치와 리팩터링 자체 비용을 함께 측정할 수 있는 출발점이 됨