본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 28. 20:07

Claude로 100개의 함수를 리팩터링했습니다. CI는 통과했지만, 운영 환경의 7개 지점에서 성능이 저하되었습니다.

요약

Claude Code를 이용해 100개의 Python 함수를 리팩터링했으나, CI를 통과했음에도 운영 환경에서 7개 함수의 성능이 저하되는 문제가 발생했습니다. 단위 테스트와 변이 테스트가 놓친 성능 저하 원인을 분석하고, AI 리팩터링 시 적용해야 할 체크리스트를 제안합니다.

핵심 포인트

  • CI는 기능적 동일성만 검증할 뿐 성능 저하를 감지하지 못함
  • Claude Code의 리팩터링 결과가 단위 테스트를 통과해도 성능 이슈가 발생할 수 있음
  • AI 코드 변경 시 부하 환경을 고려한 별도의 검증 프로세스 필요

저는 제가 관리하는 Python 서비스 전반에 걸쳐 100개의 함수를 리팩터링하도록 Claude Code에 요청했습니다. Claude는 두 번의 패스(pass)를 통해 작업을 완료했습니다. 두 번 모두 CI(지속적 통합)는 통과(green)했습니다. PR(Pull Request) 설명이 너무 깔끔해서 금요일에 배포하는 것이 미안하게 느껴질 정도였습니다.

2주 후, 한 엔드포인트의 p95(95번째 백분위수)가 180ms에서 240ms로 변했다는 온콜(on-call) 호출을 받았습니다. 저는 이진 탐색(bisecting)을 시작했습니다. 탐색 결과는 리팩터링 PR에 도달했습니다. 저는 리팩터링 PR을 읽기 시작했습니다. 100개의 함수 중 7개가 운영 환경에서 더 느려졌습니다. CI는 이를 전혀 알아채지 못했습니다. 왜냐하면 CI는 "느려짐"을 측정하는 것이 아니라, "동일한 값을 반환하는지"를 측정하기 때문입니다.

이 포스트는 그 7개의 느려진 함수들이 어떤 공통점을 가졌는지, 왜 변이 테스트(mutation tests)와 단위 테스트(unit tests)가 모두 이를 놓쳤는지, 그리고 이제 제가 Claude나 그 어떤 AI가 부하가 걸리는 환경에 배포될 코드를 리팩터링하도록 허용하기 전에 실행하는 4가지 체크리스트에 대해 다룹니다.

Timeline: 100 functions refactored on day 0, CI green, on-call paged on day 14 with 7 functions slower in production

이 사례가 일반화될 수 있는지 판단하기 위한 설정

코드베이스: 약 18,000줄의 비즈니스 로직을 가진 Python 3.12 서비스로, FastAPI가 에지(edge)에 있고, Postgres에는 asyncpg를 사용하며, Redis 캐시와 모든 요청에서 실행되는 CPU 집약적(CPU-bound) 스코어링 모듈이 포함되어 있습니다. 100개의 함수는 엄선된 배치였습니다. 소형에서 중형 규모이며, 가능한 한 순수 함수(pure function)이고, 모두 단위 테스트를 갖추고 있었습니다. 저는 Claude Code에 표준적인 정리 작업을 적용하도록 요청했습니다: 조기 반환(early returns), 매직 넘버(magic numbers)에 대한 변수 추출, 루프가 한 가지 일만 수행하는 곳에 컴프리헨션(comprehensions) 적용, 임시 튜플(ad hoc tuples)에 대한 데이터 클래스(dataclass) 변환 등입니다.

저는 범위를 신중하게 결정했습니다. 재작성(rewrites)은 없었습니다. 아키텍처 변경도 없었습니다. "하는 김에 이것도" 식의 재배선(rewiring)도 없었습니다. 50개씩 두 번의 배치로 나누어 각각 별도의 PR로 배포했으며, 각 배치는 8코어 러너(runner)에서 자체적인 CI를 실행했습니다. 단위 테스트는 통과했습니다. mutmut을 이용한 변이 테스트(mutation testing) 결과도 깨끗했습니다. 리팩터링된 모듈의 킬 레이트(Kill rate)는 78%에서 81%로 상승했습니다. 제가 가진 모든 신호에 따르면, 코드는 동일하며 약간 더 나아진 상태였습니다.

그것은 정확히 2주 뒤 금요일에 (사고를 쳐서) 페이지 2면에 실리게 만드는 종류의 자신감입니다.

느려진 7개 함수의 공통점

느려진 7개의 함수를 나란히 놓고 읽어보았을 때, 세 가지 패턴이 나타났습니다. 그 중 어느 것도 명백하지 않았습니다. 모두 CI (지속적 통합)가 구조적으로 잡아낼 수 없는 종류의 것들이었습니다.

Three patterns inside the seven slow functions — comprehensions traversed twice, early return defeated lru_cache, dataclass broke asyncpg fast path

패턴 1: 두 번 순회하는 컴프리헨션 (comprehensions). 7개 중 4개는 Claude가 리스트 컴프리헨션 (list comprehension)으로 압축한 루프였습니다. 컴프리헨션 자체는 정확했습니다. 하지만 Claude가 가독성을 위해 술어 (predicate)와 투영 (projection)을 분리했기 때문에, 입력값을 두 번 훑고 있었습니다 (한 번은 필터링을 위해, 한 번은 매핑을 위해). 기존 루프는 ifcontinue를 사용하여 한 번의 패스 (pass)로 두 작업을 모두 수행했습니다. 요청당 한 번 실행되는 50개의 항목 리스트에서 그 차이는 1.4ms였습니다. 핫 패스 (hot path)에서 요청 전체에 걸쳐 곱해졌을 때, p95 기준으로 약 12ms의 차이가 발생했습니다.

만약 제가 이전 코드와 새 코드를 한 줄씩 읽었다면 코드 리뷰 (code review)에서 잡아냈을 것입니다. 하지만 Diff (차이점)가 교과서적인 "컴프리헨션 추출" 정리처럼 보였고 테스트도 통과했기 때문에 그렇게 하지 않았습니다.

패턴 2: 캐시를 무력화하는 조기 반환 (early returns). 7개 중 2개는 외부 함수에 @functools.lru_cache를 사용했습니다. Claude는 캐시 조회 전에 잘못된 입력에 대해 None을 반환하는 가드 절 (guard clause)을 추가했습니다. 의도는 방어적인 것이었습니다. 잘못된 입력에 대해 빠르게 실패 (fail fast)하는 것이었죠. 하지만 결과적으로 함수가 이제 메모이제이션 (memoization)되지 않는 경로를 통해 반환되면서, 전체 유효 입력 경로에 대해 캐시가 채워지는 것이 중단되었습니다. 해당 함수의 히트 레이트 (hit rate)는 91%에서 6%로 떨어졌습니다. 함수 자체는 빨랐습니다. 하지만 85포인트나 떨어진 히트 레이트는 빠르지 않았습니다.

이것은 유닛 테스트 (unit test)로는 잡아낼 수 없습니다. 부하 테스트 (load test)나 운영 환경에서, 혹은 "이 함수의 계약 (contract)뿐만 아니라 시스템 내에서 이 함수의 역할은 무엇인가?"라는 질문을 던지며 함수를 읽음으로써 잡아낼 수 있습니다.

패턴 3: asyncpg의 패스트 패스 (fast path)를 깨뜨린 dataclass 변환. 한 함수는 기존에 asyncpg가 행 디코더 (row decoder)로 직접 언패킹 (unpack)할 수 있는 튜플 (tuple)을 반환했습니다. Claude는 이 튜플을 동일한 필드를 가진 dataclass로 변환했는데, 이는 구조적으로 더 깔끔하고 의미론적으로도 동일합니다. 하지만 이로 인해 행당 추가적인 할당 (allocation)과 __init__ 호출이 강제되었습니다. 요청당 800개의 행이 있고 초당 30개의 요청이 발생하는 환경에서, 이는 p95 기준으로 약 8ms의 지연을 추가했습니다.

이 사례는 제가 가장 좋아하는 사례입니다. 왜냐하면 "리팩터링은 옳지만, 리팩터링은 틀렸다"라는 문장을 가장 깔끔하게 보여주는 예시이기 때문입니다. 코드는 더 읽기 좋아졌지만, 시스템은 더 느려졌습니다.

CI와 변이 테스트 (mutation testing)가 모두 'Yes'라고 답한 이유

이 부분에 대해 한 단락을 할애하고 싶습니다. 왜냐하면 제가 이 사실을 내면화하는 데 시간이 꽤 걸렸기 때문입니다.

단위 테스트 (Unit tests)는 함수가 동일한 입력에 대해 동일한 값을 반환하는지 검증합니다. 단위 테스트는 함수가 대략적으로 동일한 시간 내에, 대략적으로 동일한 할당 패턴을 유지하며, 대략적으로 동일한 락 (locks)을 보유한 채로 동일한 값을 반환하는지는 검증하지 않습니다. 변이 테스트 (Mutation testing)는 코드의 로직이 변경되었을 때 테스트가 이를 감지할 수 있는지 검증합니다. 하지만 변이 테스트의 변이 생성기 (mutators)에는 "데이터 구조 교체"가 포함되어 있지 않기 때문에, "이 함수가 이제 튜플을 언패킹하는 대신 행당 dataclass를 할당한다"라는 변화는 감지하지 못할 것입니다.

다시 말해, 제 CI 파이프라인에 있던 모든 도구는 "이 코드가 정확한가?"라는 질문에 답하고 있었습니다. 그중 어느 것도 "이 코드가 충분히 빠른가?"라는 질문에는 답하지 않았습니다. 그 간극이 바로 Claude의 리팩터링이 미친 영향이었습니다. 코드 정리 (cleanups)는 정확했습니다. 다만 실제 트래픽 하에서만 나타나는 방식으로 더 느려졌을 뿐입니다.

제게는 CI 스위트 (CI suite)가 있었고, 결과는 통과 (green)였습니다. 함수들은 그저 더 느려졌을 뿐입니다. CI는 "더 느려짐"을 측정하지 않습니다.

제가 현재 실행하는 네 가지 체크리스트

그 사건 이후, 저는 리팩터링 흐름에 네 가지 체크리스트를 구축했습니다. 세 가지는 자동화되어 있고, 네 번째는 10분간의 정독입니다. 제가 이것들을 공유하는 이유는 이번 분기에 나온 모든 "AI에게 코드 리팩터링을 맡기세요"라는 게시물들을 읽어보았지만, 성능 검증 (performance verification)을 언급하는 글은 단 하나도 없었기 때문입니다.

체크 1: 리팩터링 전 베이스라인 벤치마크 (baseline benchmark). 저는 실제 운영 환경과 유사한 트레이스 (trace)를 기록하여 상위 20개 엔드포인트에 대해 pyinstrument를 실행하고 보고서를 저장합니다. 이 보고서는 핫 패스 (hot path) 상의 모든 함수에 대해 p50, p95 및 할당 횟수 (allocation count)를 명시합니다. 리팩터링 전에는 어떤 함수가 중요한지 알고 있어야 합니다. 이 베이스라인이 없다면 "이 함수가 느려졌다"라고 말할 수 없습니다. 단지 "서비스가 느려진 것 같다"라고만 말할 수 있을 뿐이며, 그것이 바로 제가 이 자리에 오게 된 근본적인 이유였습니다.

체크 2: 리팩터링 후 동일한 벤치마크 및 차이점 (diff) 분석. 동일한 트레이스와 동일한 스크립트를 사용하여 두 보고서의 차이점을 비교합니다. 자체 실행 시간 (self-time) 기준 상위 50개 함수 중 어느 하나라도 5% 이상의 드리프트 (drift)가 발생하면 경고 (flag)입니다. 차단 (block)이 아니라 경고입니다. 조사를 시작해야 합니다.

체크 3: 부하 형태의 소크 테스트 (load-shaped soak). 리팩터링된 빌드를 대상으로 피크 운영 부하의 80% 수준에서 10분 동안 locust를 실행하며 캐시 히트율 (cache hit rates), 할당률 (allocation rates), 그리고 DB 연결 획득 시간 (DB connection acquisition time)을 관찰합니다. 이것이 바로 lru_cache 회귀 (regression)를 잡아냈을 방법입니다. 5분간의 소크 테스트 동안 히트율이 91%에서 6%로 급락하는 것은 매우 명확하게 드러납니다. 유닛 테스트 (unit tests)에서는 영원히 조용히 숨어있을 문제입니다.

체크 4: "내가 요청한 구조적 변경 vs. 실제로 이루어진 구조적 변경"에 대한 디프 (diff) 검토. 디프를 열어 변경된 모든 함수를 찾고 한 가지 질문을 던집니다: "이 변경이 데이터 구조 (data structure), 반복 패턴 (iteration pattern), 캐시 경계 (cache boundary), 또는 락 획득 (lock acquisition)에 영향을 주었는가?" 만약 그렇다면, 이를 별도의 리스트에 추가하여 정독 (slow read)합니다. 정독에는 함수 100개당 약 10분이 소요됩니다. 이 과정이 제 문제 7개 중 5개를 잡아냈을 것입니다.

이제 저는 AI 리팩터링을 주니어 엔지니어의 PR (Pull Request)처럼 취급합니다. 스타일은 신뢰하되 실체는 검증하며, 핫 패스 (hot path)에 영향을 주었다면 부하 테스트 (load test) 없이는 절대 머지 (merge)하지 않습니다. 가혹하게 들릴 수도 있습니다. 하지만 이는 제가 인간 기여자에게 요구하는 것과 동일한 기준입니다. 차이점이 있다면, 인간 기여자에게는 "왜 이것을 변경했나요?"라고 물어보고 이유를 들을 수 있다는 점입니다. Claude의 경우, 구조적으로 깔끔한 디프 (diff)와 텅 빈 코멘트 창만을 받게 됩니다.

내가 하지 않는 것

저는 리팩터링을 위해 Claude를 사용하는 것을 피하지 않습니다. 7번의 성능 저하 (regression) 이후, 저는 4단계 검증 프로세스 (four-check flow)를 적용하여 240개의 리팩터링을 추가로 배포했으며, 그 이후로는 운영 환경에서의 성능 저하가 발생하지 않았습니다. 이 프로세스는 함수 50개 배치당 약 20분이 소요됩니다. 이는 수 주에 걸친 이분 탐색 (bisecting) 작업과, 제 파트너의 생일 저녁 식사 중인 금요일 오후 7시 42분에 날아온 한 페이지 분량의 이슈와 맞바꾼 20분입니다.

또한 저는 더 이상 작업 중에

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0