검색
← 모듈 로드맵

Claude Code로 3,200라인 God Class를 14개 모듈로 분리한 사례

발행 출처 DEV · yureki_lab

클로드 코드사례전문가

나중에 보기좋아요
공유 · 인쇄
후기댓글

TL;DR

아무도 건드리길 두려워하던 OrderService라는 3,200줄짜리 Python 클래스가 있었습니다. 다섯 주 동안 Claude Code를 사용해, 운영 사고 한 번 없이 이 클래스를 14개의 집중된 모듈로 분해했습니다. 핵심은 AI가 코드를 작성하게 두는 것이 아니었습니다. 먼저 특징화 테스트를 만들고, 제가 직접 그린 seam map을 바탕으로, PR 하나당 추출 하나씩 진행한 것이었습니다.

문제

오래 살아남는 코드베이스라면 누구나 하나쯤 생깁니다. 우리에게는 OrderService가 그랬습니다. 가격 계산, 할인, 세금, 재고 예약, 결제 승인, 환불, 이메일 알림, 감사 로그 기록, 그리고 아무도 이유를 기억하지 못하던 — 재무팀용 CSV 내보내기까지 담당하는 단일 클래스였습니다.

제가 마침내 측정해 보니 수치는 이랬습니다.

  • 한 파일에 3,212줄
  • 87개 메서드 , 그중 31개는 5곳 이상에서 호출되는 private 헬퍼
  • 19개 인스턴스 속성 , 그중 다수는 여러 메서드에서 변경됨
  • checkout() 하나만으로 순환 복잡도 41
  • 지난 6개월 동안 전체 pull request의 60% 에서 수정됨

마지막 수치가 진짜 비용이었습니다. 모든 기능이 이 파일에 들어왔기 때문에 모든 PR에서 병합 충돌이 발생했고, 모든 리뷰에는 "주문 코드"를 아는 사람이 필요했습니다. 보통 그 사람은 저였습니다.

이전에도 두 번 직접 분리를 시도한 적이 있었습니다. 두 번 다 이틀쯤 진행하다가 어떤 메서드가 self._cart_snapshot을 조용히 3개의 다른 메서드가 의존하는 방식으로 변경한다는 사실을 발견했고, 결국 전부 되돌렸습니다.

이번에는 AI 코딩 에이전트가 위험한 부분은 제가 통제하면서, 지루한 부분을 맡아줄 수 있는지 보고 싶었습니다.

환경: Claude Code(v2.x CLI), Python 3.12, pytest 8, Django 5.1 앱, 서비스의 대략 92% 라인 커버리지 — 커버리지는 좋게 들리지만, 실제로는 어떤 라인이 실행됐는지 알려줄 뿐 어떤 동작이 검증됐는지는 알려주지 않는다는 점을 깨닫기 전까지는 그렇습니다.

어떻게 해결했나

전체 작업은 네 단계로 끝났습니다.

코드
flowchart LR
    A[Characterization tests] --> B[Seam map]
    B --> C[Extract one module per PR]
    C --> D[Delete the facade]
    C -->|tests red| A

1단계: 현재 동작을 고정하기(이상한 부분까지도)

한 줄도 옮기기 전에 Claude Code에게 characterization tests를 작성하게 했습니다. 이 테스트는 코드가 어떻게 동작해야 하는지 가 아니라, 현재 코드가 실제로 무엇을 하는지 를 기록합니다.

잘 먹힌 프롬프트는 이랬습니다.

코드
For each public method on OrderService, write pytest tests that capture
its CURRENT behavior, including edge cases that look like bugs.
Do not fix anything. If behavior looks wrong, add a comment
"# CHARACTERIZED: looks wrong, preserved on purpose" and assert it anyway.
Use real fixtures from tests/fixtures/, not mocks, wherever possible.

이 "아무것도 고치지 마"라는 문구가 핵심이었습니다. 이 문구가 없으면 에이전트가 "도와주려고" 올바른 동작을 단언했고, 그러면 실제 코드와 맞지 않아 실패했으며, 그다음에는 그걸 고치려 했습니다. 제가 피하고 싶었던 바로 그 나선형이었습니다.

그 결과 오후 한나절 만에 214개의 새 테스트를 만들었습니다. 저는 이를 약 두 시간 검토했고, CHARACTERIZED로 표시된 11개의 사례를 발견했습니다. 실제 버그였고, 그중 하나는 쿠폰 적용 후 기프트카드를 사용하면 할인이 두 번 중복 적용되는 문제였습니다. 저는 이것들을 티켓으로 기록하고 동작은 그대로 고정해 두었습니다. 리팩터링과 버그 수정을 같은 diff에서 하는 건 주말을 날리는 지름길입니다.

2단계: 제가 직접 seam map 그리기

이 단계에서 저는 프로젝트의 가장 중요한 결정을 내렸습니다. 에이전트가 모듈 경계를 정하지 못하게 한 것입니다.

Claude Code에게 클래스 분석은 시켰습니다. 이 프롬프트는 정말 유용한 보고서를 만들어 줬습니다.

코드
List every instance attribute on OrderService. For each one, show which
methods read it and which methods write it. Output a table.
Then group methods into clusters that share writes.

출력은 읽기/쓰기 매트릭스였고, 실제 결합 관계를 보여 줬습니다. 축약하면 이렇습니다.

속성기록하는 곳읽는 곳
_cart_snapshotcheckout , apply_coupon , recalc14 methods
_tax_cachecompute_taxcheckout , refund , export_csv
_payment_intentcapture , refundcheckout , notify

그 다음에는 제가 종이에 경계를 그렸습니다. 가격, 세금, 재고, 결제, 환불, 알림, 감사, 내보내기, 그리고 몇 개의 더 작은 영역 — 총 14개였습니다. 에이전트가 제안한 클러스터 중 일부는 종이 위에서는 아주 그럴듯했지만, 우리 팀이 하나의 개념으로 생각하는 것을 둘로 쪼개거나 서로 다른 이유로 변경되는 것들을 한데 묶을 가능성이 있었습니다.

AI는 무엇이 결합 되어 있는지는 알려줄 수 있습니다. 하지만 6개월 뒤에 팀이 무엇을 이해 하게 될지는 알려줄 수 없습니다.

3단계: PR 하나당 추출 하나, facade는 살아 있게

각 추출은 같은 절차를 따랐고, 저는 이를 세션이 시작될 때 에이전트가 읽는 짧은 지시 파일에 적어 두었습니다.

코드
Extraction rules:
1. Extract exactly ONE module per session. Name given by me.
2. OrderService keeps every public method. It delegates to the new module.
3. No behavior changes. No renames of public methods. No "while I'm here".
4. Run the full characterization suite before and after. Paste both summaries.
5. If any test changes, stop and explain why instead of editing the test.

2번 규칙은 facade pattern 이었고, 전체 작업을 안전하게 만든 핵심이었습니다. 호출자들은 아무 일도 일어나고 있다는 걸 전혀 알아차리지 못했습니다. 세금 로직을 추출한 뒤 OrderService는 이렇게 보였습니다.

코드
class OrderService:
    def __init__(self, order, *, tax_calculator=None):
        self.order = order
        self._tax = tax_calculator or TaxCalculator(order.region)

    def compute_tax(self, lines):
        # Delegates to extracted module; kept for existing callers.
        return self._tax.compute(lines)

그리고 새 모듈은 작고, 단순하며, 독립적으로 테스트할 수 있었습니다.

코드
@dataclass(frozen=True)
class TaxCalculator:
    region: str

    def compute(self, lines: list[LineItem]) -> Decimal:
        rate = RATES.get(self.region, DEFAULT_RATE)
        taxable = sum(l.amount for l in lines if not l.tax_exempt)
        return (taxable * rate).quantize(Decimal("0.01"))

frozen=True와 mutable cache가 없다는 점에 주목하세요. 예전 _tax_cache는 checkout당 약 0.4ms를 절약해 주는 것으로 드러났고, 이를 제거한 것은 제가 허용한 몇 안 되는 의도적 변경 중 하나였습니다. 그것도 별도의 PR로, 벤치마크를 첨부해서 처리했습니다.

각 추출 PR은 평균적으로 약 180줄이 변경되었고, 그 덕분에 리뷰 시간이 한 시간에서 15분으로 줄었습니다. 동료들이 실제로 리뷰할 수 있게 되었고, 그건 코드 자체만큼이나 중요했습니다.

무용담: 숨겨진 공유 상태

6번째 추출 대상은 환불 로직이었습니다. 에이전트가 메서드를 옮겼고, 위임 구조도 깔끔해 보였으며, 214개의 특성화 테스트가 모두 통과했습니다.

그런데 스테이징에서 문제가 터졌습니다. 쿠폰이 적용된 주문의 부분 환불이 할인 전 금액으로 처리되고 있었던 겁니다.

무슨 일이 있었냐면, 기존 refund()는 self._cart_snapshot을 읽었는데, 이 값은 같은 요청 안에서 앞서 apply_coupon()이 설정한 것이었습니다. 뷰가 같은 OrderService 인스턴스에서 두 메서드를 모두 호출했기 때문입니다. 새 RefundProcessor는 자체적으로 새 스냅샷을 가져왔고, 이는 데이터베이스를 바탕으로 만들어졌습니다. 그런데 해당 트랜잭션은 아직 커밋되지 않았기 때문에 쿠폰 적용이 반영되지 않은 상태였습니다.

내 특성화 테스트 중 어느 것도 같은 인스턴스에서 apply_coupon()과 refund()를 연달아 호출하지 않았습니다. 각 테스트는 새로운 서비스를 생성했습니다. 테스트는 메서드 자체를 고정했을 뿐, 호출 순서는 고정하지 않았습니다.

이 문제는 원인을 이해하고 나니 10분 만에 고칠 수 있었습니다. 환불 모듈이 다시 스냅샷을 만들지 않도록, 스냅샷을 명시적으로 넘기면 됐습니다. 하지만 교훈이 제대로 체감되기까지는 더 오래 걸렸습니다. 그래서 나는 다시 돌아가 Claude Code에게 1단계에서 했어야 할 일을 시켰습니다:

코드
Search the codebase for every call site that invokes two or more
OrderService methods on the same instance. List the method sequences.
Then write one characterization test per unique sequence.

그 결과 뷰, Celery 작업, 관리 명령 전반에서 서로 다른 23개의 시퀀스가 발견되었습니다. 그중 세 개는 나중에 추출 과정에서 깨졌을 공유 상태를 실제로 건드리고 있었습니다. 그 테스트들 덕분에, 노트북을 떠나기 전에 두 가지 문제를 더 잡아냈습니다.

4단계: 파사드 삭제

14개 모듈이 모두 생기고 나니 OrderService는 약 260줄짜리 순수 위임 코드만 남았습니다. 나는 에이전트에게 새 모듈들을 직접 사용하도록 호출 지점을 옮기게 했습니다. 이건 가장 기계적인 작업이었고, AI의 속도가 가장 크게 빛난 구간이기도 했습니다. 에이전트는 오후 한나절에 걸쳐 142개의 호출 지점을 38개 파일에서 업데이트했는데, 내가 각 배치를 리뷰할 수 있도록 한 번에 약 20개씩 묶어서 커밋했습니다.

그다음 OrderService를 삭제했습니다. 기분이 좋았습니다. 🚀

5주 후 최종 집계:

  • 14개 모듈, 가장 큰 것은 410줄, 중앙값은 190줄
  • checkout() 복잡도: 41 → 9
  • 특성화 테스트: 214 → 237(시퀀스 테스트 포함)
  • 나중에 별도 PR에서 수정된 동결 버그 11개 기록
  • 주문 코드의 머지 충돌: "대부분의 주"에서 대략 한 달에 한 번 수준으로 감소
  • 리팩터링으로 인한 운영 장애: 0건

얻은 교훈

1. 특성화 테스트가 진짜 산출물이다. 💡 리팩터링은 거의 부산물에 가까웠습니다. 동작이 고정되자 모듈 추출은 부담이 거의 없는 작업이 됐습니다. 이 글에서 딱 하나만 가져간다면 이것입니다: 에이전트가 아무것도 바꾸기 전에 현재 동작을 먼저 고정하게 하고, 발견한 문제를 고치지 말라고 명시적으로 지시하세요.

2. 메서드뿐 아니라 시퀀스도 테스트하라. 거대한 클래스는 공유 인스턴스에서의 호출 순서에 결합을 숨깁니다. 메서드 단위 테스트만으로는 전혀 잡아내지 못합니다. 여러 메서드를 연속 호출하는 지점을 grep으로 찾는 것이 첫 단계여야지, 스테이징이 깨진 뒤에 배우는 일이 되어서는 안 됩니다.

3. 결합 분석은 에이전트에게 맡기고, 경계는 사람이 정하라. 읽기/쓰기 매트릭스는 이 프로젝트 전체에서 가장 유용한 산출물 중 하나였습니다. 하지만 제안된 클러스터는 그렇지 않았습니다. 모듈 경계는 기술적 결정인 동시에 팀 커뮤니케이션의 결정이며, 그 부분은 여전히 당신의 몫입니다.

4. 작은 PR은 안전성만이 아니라 사람을 위한 기능이다. ⚠️ AI는 기꺼이 3,000줄짜리 diff를 만들어냅니다. 하지만 아무도 그걸 리뷰할 수는 없습니다. 에이전트를 한 세션에 한 번의 추출로 제한하니 모든 PR을 리뷰 가능하게 유지할 수 있었고, 동료들도 계속 참여할 수 있었습니다. 그렇게 해서 새 구조에 대한 지식이 실제로 퍼졌습니다.

5. 규칙은 매번 프롬프트에 넣지 말고 한 번만 문서화하라. 다섯 가지 규칙이 담긴 지침 파일은 어떤 영리한 문구보다도 에이전트를 올바른 방향으로 유지하는 데 큰 도움이 됐습니다. "지금 하는 김에(while I'm here)"식 변경 금지라는 한 줄만으로도 거절될 PR이 아마 수십 개는 줄었을 겁니다.

다음 단계

같은 코드베이스에는 1,500줄이 넘는 클래스가 두 개 더 있습니다. 하나는 보고 서비스이고, 다른 하나는 사용자 권한 모듈입니다. 같은 방식으로 다시 진행하겠지만, 이번에는 무엇보다 먼저 시퀀스 스캔 프롬프트부터 시작할 생각입니다.

또한 큰 파일을 건드리는 모든 PR마다 CI에서 에이전트가 읽기/쓰기 매트릭스를 생성하게 하는 실험도 하고 있습니다. 새 메서드가 세 개의 다른 클러스터가 읽는 속성에 쓰기를 시작한다면, 나중에 또 다른 3,200줄짜리 괴물이 되기 전에 일찍 경고할 만한 신호입니다.

마무리

God class에는 영웅적인 재작성은 필요하지 않습니다. 필요한 것은 고정된 동작, 사람이 정한 경계, 그리고 아주 작고 지루하지만 검토 가능한 단계들입니다. 그리고 바로 그런 작업이 AI 코딩 에이전트가 아주 잘하는 일입니다. 단, 당신이 계속 핸들을 쥐고 있어야 합니다.

당신만의 괴물 같은 클래스가 있다면, 어떻게 다루고 있는지 꼭 듣고 싶습니다 — 댓글에 남겨 주세요. 👇

Claude Code와 AI 코딩 에이전트를 실제로 지저분한 코드베이스에서 다루는 더 많은 작업 기록을 보려면 여기 Dev.to에서 저를 팔로우하세요. 그리고 아직 리팩터링에 Claude Code를 써 보지 않았다면, 가장 무서운 파일에 위의 특성화 테스트 프롬프트부터 적용해 보세요 — 당신이 할 수 있는 가장 안전한 첫걸음입니다.

출처

  • 원문: https://dev.to/yureki_lab/how-i-split-a-3200-line-god-class-into-14-modules-with-claude-code-m62 (2026-10-14 · yureki_lab)

자료 변경 기록을 불러오는 중입니다.

다음 자료를 불러오는 중입니다.

후기

아직 후기가 없습니다

직접 적용한 결과와 주의할 점을 공유하는 공간입니다.

로그인하고 후기 쓰기

참고 자료 · 도구와 릴리스 노트

댓글