본문으로 건너뛰기
AICosmus

Where tech meets the everyday — AI, fintech, swimming, and cars.

AICosmus

Where tech meets the everyday — AI, fintech, swimming, and cars.

  • 홈
  • IT기술
    • RAG
    • GRPC
    • Kotlin
    • LLM
    • 금융 IT
    • 에이전트
    • 제로Trust
    • 자동화
  • About
    • Contact
    • Terms of Service
    • Disclaimer
    • Privacy – Policy
  • 홈
  • IT기술
    • RAG
    • GRPC
    • Kotlin
    • LLM
    • 금융 IT
    • 에이전트
    • 제로Trust
    • 자동화
  • About
    • Contact
    • Terms of Service
    • Disclaimer
    • Privacy – Policy
닫기

검색

레거시 코드 AI 리팩터링 안전망 개념
IT기술

[Claude 활용 24회 — AI에게 일을 위임하는 법] 17/24화: 레거시 코드 AI 리팩터링 4단계 — 테스트 0%에서 시작하는 법

By AICosmus
2026년 08월 18일 14 Min Read
0

레거시 코드 AI 리팩터링이라는 말을 처음 들었을 때, 나는 솔직히 흥분했다. 800줄짜리 주문 처리 함수를 AI에게 던지고 “이거 깔끔하게 리팩터링해줘”라고 했다. 5분 뒤 돌아온 코드는 깔끔했다. 놀라울 정도로 깔끔했다. 변수명이 정리되고, 함수가 분리되고, 심지어 타입 힌트까지 붙어 있었다. 나는 감동해서 그대로 머지했다.

3시간 뒤 장애 알림이 울렸다. 환불 처리 로직이 통째로 빠져 있었다. AI가 “정리”하면서 else 분기 하나를 “불필요한 코드”로 판단하고 제거한 것이다. 테스트가 없었으니 아무도 — 나도, AI도 — 그게 필요한 코드인지 몰랐다. 20년차가 이런 실수를 한다. 도구가 강력해질수록 안전장치 없이 쓰면 폭발 반경도 커진다.

이번 회차는 그 장애 이후 6개월 동안 다듬은 방법론이다. 레거시 코드에 AI를 붙이는 법. 핵심은 단 하나 — 고치기 전에 현재 동작을 얼려라.

16회에서 아티팩트로 살아있는 대시보드를 만들었다면, 이번 회부터는 PART 5 — 실전 운영이다. 매일 굴러가는 시스템에서 검증한 것만 쓴다. AI가 코드를 짜주는 시대가 아니라, AI에게 일을 위임하는 시대다. 그리고 레거시 코드야말로 위임의 진짜 시험대다.

레거시 코드 AI 리팩터링 4단계 프로세스

탐색이 먼저다 — 레거시 코드 AI 리팩터링의 출발점

대부분의 개발자가 레거시 코드를 마주하면 바로 고치려 든다. AI를 쓸 때도 마찬가지다. “이 코드 리팩터링해줘.” 이게 첫 번째 실수다.

탐색 우선 전략(Explore-First Strategy)이란, AI에게 코드를 수정하라고 시키기 전에, 먼저 읽고 이해하게 시키는 접근이다. 단순해 보이지만, 이 순서 하나가 결과를 완전히 바꾼다.

왜 AI는 레거시 코드를 잘못 읽는가

AI가 코드를 “이해”하는 방식과 인간이 이해하는 방식은 근본적으로 다르다. AI는 패턴 매칭에 강하다. 변수명, 함수 시그니처, 일반적인 디자인 패턴은 잘 파악한다. 하지만 레거시 코드의 진짜 문제는 패턴이 아닌 곳에 있다.

  • 암묵적 의존성: 전역 변수, 사이드 이펙트, 특정 순서로만 호출되어야 하는 함수들
  • 역사적 맥락: “이 if문은 2019년 결제사 장애 때 추가된 것” — 코드에는 그런 주석이 없다
  • 비즈니스 규칙의 코드화: 환불은 7일 이내인데 주말/공휴일은 제외하고 VIP는 30일 — 이런 규칙이 중첩 if문으로 굳어 있다

AI는 이런 맥락 없이 코드의 표면만 보고 “정리”한다. Michael Feathers가 정의한 레거시 코드의 핵심 특성 — “테스트가 없는 코드” — 이 바로 여기서 치명적이 된다. 검증 수단 없이 변경하면, 뭐가 깨졌는지조차 모른다.

탐색 단계에서 AI에게 시킬 3가지

5회에서 다룬 CLAUDE.md에 레거시 시스템의 암묵지를 넣으라고 했다. 하지만 암묵지를 넣으려면 먼저 암묵지가 뭔지 발굴해야 한다. 탐색 단계가 바로 그 발굴 과정이다.

첫째, 의존성 지도를 그려라.

# Claude Code에서 탐색 에이전트로 의존성 분석
# 11회에서 다룬 서브에이전트 패턴 활용

> 이 프로젝트의 src/booking/ 디렉터리를 분석해줘.
> 각 모듈이 어떤 모듈을 import하는지,
> 외부 API를 호출하는 함수는 어떤 것들인지,
> 전역 상태를 읽거나 쓰는 함수는 어떤 것들인지
> 마크다운 표로 정리해줘.
> 코드를 수정하지 마. 읽기만 해.

마지막 줄이 핵심이다. “코드를 수정하지 마.” 이 한 문장이 없으면 AI는 읽다가 슬금슬금 고치기 시작한다.

둘째, 데이터 흐름을 추적하라.

> process_order() 함수에서 시작해서,
> 주문 데이터가 어떤 함수들을 거쳐
> 최종적으로 DB에 기록되는지 호출 순서를 나열해줘.
> 각 단계에서 데이터가 어떻게 변형되는지도 포함해줘.
> 읽기 전용. 수정 금지.

셋째, “이상한 코드”를 표시하라.

> src/booking/payment.py를 읽고,
> 일반적인 패턴에서 벗어나는 코드를 찾아줘.
> 예: 하드코딩된 매직 넘버, 주석 처리된 코드 블록,
> 예외를 삼키는 catch, 같은 로직의 반복 등.
> 각각 줄 번호와 함께 왜 이상한지 한 줄로 설명해줘.
> 수정 제안은 하지 마. 목록만 만들어.

이 세 가지 결과물이 쌓이면, 당신은 코드를 한 줄도 고치지 않고도 시스템의 지도를 갖게 된다. 7회에서 다룬 컨텍스트 관리가 여기서 빛을 발한다 — 탐색 결과를 CLAUDE.md에 추가하면 이후 세션에서도 맥락이 유지된다.

대규모 코드베이스의 컨텍스트 전략

10만 줄짜리 코드베이스를 통째로 AI에게 넘기는 건 불가능하다. 컨텍스트 창이 아무리 커도 모든 파일을 한 번에 읽을 수는 없다. 여기서 쓰는 전략이 “관심 영역 축소(Narrowing the Scope)”다.

# 1단계: 디렉터리 수준 탐색 (넓게)
> 이 프로젝트의 전체 디렉터리 구조를 보여줘.
> 각 디렉터리가 어떤 역할인지 한 줄씩만.

# 2단계: 모듈 수준 탐색 (좁혀서)
> src/booking/ 안의 파일 목록과
> 각 파일의 클래스/함수 시그니처만 보여줘. 본문은 필요없어.

# 3단계: 함수 수준 탐색 (집중)
> src/booking/payment.py의 calculate_refund() 함수를
> 호출하는 모든 곳을 찾아줘.
> 그리고 이 함수가 호출하는 모든 함수도 나열해줘.

넓게 시작해서 좁히는 이 패턴은, 인간이 새 코드베이스에 합류했을 때 하는 행동과 같다. AI에게도 같은 순서를 밟게 해야 정확도가 올라간다.

한 가지 더. 대규모 코드베이스에서는 세션을 역할별로 분리하는 게 효과적이다. 한 세션에서 전체를 다루려 하면 컨텍스트가 오염된다(7회에서 다룬 “실패한 시도가 남아 같은 실수를 반복”하는 현상). 대신:

  • 세션 A: 결제 모듈 탐색 + 특성화 테스트
  • 세션 B: 주문 모듈 탐색 + 특성화 테스트
  • 세션 C: 두 모듈 간 통합 테스트

각 세션의 결과(의존성 지도, 발견한 이상 코드 목록)는 CLAUDE.md나 별도 마크다운 파일에 누적하면, 다음 세션이 이전 세션의 발견을 참조할 수 있다.

특성화 테스트 — 현재 동작을 콘크리트로 굳히기

탐색이 끝났으면 다음 단계는 리팩터링이 아니다. 특성화 테스트(Characterization Test)다.

특성화 테스트란, “코드가 이렇게 동작해야 한다”를 검증하는 일반적인 단위 테스트와 다르다. “코드가 지금 이렇게 동작하고 있다”를 기록하는 테스트다. 의도가 맞든 틀리든, 현재 동작을 있는 그대로 캡처한다.

왜 이게 중요한가? 레거시 코드에는 의도된 동작과 의도치 않은 동작이 뒤섞여 있다. 그리고 둘 다 운영 환경에서 “정상”으로 굴러가고 있다. 리팩터링 후에 동작이 달라졌다면 — 그게 의도된 변경인지, 버그를 만든 건지 — 특성화 테스트가 즉시 알려준다.

특성화 테스트의 원리

Michael Feathers의 접근법을 AI 시대에 맞게 바꾸면 이렇다:

  1. 함수를 호출한다 — 실제 운영에서 들어오는 입력과 비슷한 값으로
  2. 현재 출력을 기록한다 — 반환값, 사이드 이펙트, DB 변경, 로그 메시지 전부
  3. 그 출력을 assertion으로 굳힌다 — 이게 “정답”이 된다
  4. 리팩터링 후 테스트를 돌린다 — 깨지면 동작이 바뀐 것

핵심은 3번이다. 현재 출력이 버그라도 상관없다. 일단 굳힌다. 리팩터링과 버그 수정은 별개의 작업이다. 섞으면 폭발한다.

특성화 테스트 분기 분석 다이어그램

AI에게 특성화 테스트를 만들게 하는 지시서

여기서 4회에서 다룬 작업 지시서의 4요소(맥락·제약·완료조건·검증방법)가 다시 등장한다. 레거시 코드의 특성화 테스트를 만들 때, 지시서의 품질이 결과를 결정한다.

# 나쁜 지시
> payment.py에 테스트 만들어줘.

# 좋은 지시
> src/booking/payment.py의 calculate_refund() 함수에 대한
> 특성화 테스트를 만들어줘.
>
> [맥락]
> - 이 함수는 테스트가 전혀 없는 레거시 코드야.
> - 함수 시그니처: calculate_refund(order: dict, reason: str) -> dict
> - 이 함수가 호출하는 외부 의존성:
>   - db.get_payment_record(order_id) — DB 조회
>   - api.request_refund(payment_id, amount) — 외부 API 호출
>
> [제약]
> - 외부 의존성(DB, API)은 mock으로 대체해.
> - 현재 동작을 있는 그대로 캡처하는 게 목적이야.
>   "이렇게 동작해야 한다"가 아니라 "이렇게 동작하고 있다"를 테스트해.
> - 코드에 버그가 있어 보여도 테스트에서 버그를 수정하지 마.
>   현재 동작을 assertion으로 굳혀.
>
> [완료조건]
> - 정상 경로 최소 3케이스
> - 예외 경로 최소 2케이스 (잘못된 입력, 외부 API 실패)
> - 경계값 1케이스 (금액 0, 음수 등)
> - 모든 테스트가 현재 코드에서 통과해야 함
>
> [검증방법]
> - pytest -v로 전부 통과 확인
> - pytest --cov=src/booking/payment로 해당 파일 커버리지 확인
> - 커버리지 50% 이상 달성

이 지시서의 핵심은 “버그가 있어 보여도 수정하지 마” 부분이다. AI는 버그를 보면 고치고 싶어 한다. 기본 성향이 그렇다. 명시적으로 막지 않으면 테스트를 만들면서 “이건 버그인 것 같으니 이렇게 고쳐야 합니다”라며 원본 코드를 건드린다. 그러면 특성화 테스트의 의미가 사라진다.

실습 — 테스트 0%에서 레거시 코드 AI로 커버리지 붙이기

실제로 해보자. 가상의 여행 예약 시스템을 예제로 쓴다. 테스트가 하나도 없고, 문서도 없고, 원래 만든 사람은 퇴사한 전형적인 레거시 코드다.

대상 코드: 예약 취소 처리

# src/booking/cancellation.py
# 테스트 없음, 문서 없음, 원작자 퇴사
import datetime
from decimal import Decimal

from .db import get_booking, update_booking_status, get_refund_policy
from .payment import process_refund
from .notification import send_cancel_email, send_cancel_sms


def cancel_booking(booking_id, reason, requested_by, force=False):
    booking = get_booking(booking_id)
    if not booking:
        return {"success": False, "error": "booking_not_found"}

    if booking["status"] in ("cancelled", "completed", "no_show"):
        return {"success": False, "error": "invalid_status",
                "current_status": booking["status"]}

    checkin = datetime.datetime.strptime(
        booking["checkin_date"], "%Y-%m-%d"
    )
    now = datetime.datetime.now()
    days_until = (checkin - now).days

    policy = get_refund_policy(booking["hotel_id"])
    refund_pct = Decimal("0")

    if force and requested_by == "admin":
        refund_pct = Decimal("100")
    elif days_until >= policy["free_cancel_days"]:
        refund_pct = Decimal("100")
    elif days_until >= policy.get("partial_cancel_days", 0):
        refund_pct = policy.get("partial_refund_pct", Decimal("50"))
    elif days_until >= 0:
        refund_pct = Decimal("0")
    else:
        # 체크인 날짜가 지남 — 하지만 no_show가 아닌 케이스
        # 2021-03 hotfix: 해외 시차 문제로 체크인 당일 새벽에
        # 취소 요청이 들어오는 경우를 위해 추가
        if days_until >= -1:
            refund_pct = Decimal("0")
        else:
            return {"success": False, "error": "past_checkin"}

    total = Decimal(str(booking["total_amount"]))
    refund_amount = total * refund_pct / Decimal("100")

    if refund_amount > 0:
        refund_result = process_refund(
            booking["payment_id"], refund_amount
        )
        if not refund_result["success"]:
            return {"success": False, "error": "refund_failed",
                    "detail": refund_result.get("error")}

    update_booking_status(booking_id, "cancelled", {
        "reason": reason,
        "cancelled_by": requested_by,
        "refund_amount": str(refund_amount),
        "refund_pct": str(refund_pct),
    })

    # 알림
    try:
        send_cancel_email(booking["guest_email"], booking_id,
                          str(refund_amount))
        if booking.get("guest_phone"):
            send_cancel_sms(booking["guest_phone"], booking_id)
    except Exception:
        pass  # 알림 실패는 무시 — 2020-08 장애 이후 추가

    return {
        "success": True,
        "refund_amount": str(refund_amount),
        "refund_pct": str(refund_pct),
    }

이 코드를 보면 역사가 보인다. 2020년 8월 장애 이후 추가된 except: pass, 2021년 3월 핫픽스로 추가된 시차 처리. 코드에 묻혀 있는 비즈니스 결정들이다. 이걸 AI가 “정리”하면서 날리면 같은 장애가 재현된다.

1단계 — 탐색 에이전트로 코드 지도 만들기

# Claude Code 터미널에서 실행

> src/booking/cancellation.py의 cancel_booking() 함수를 분석해줘.
>
> 다음을 마크다운 표로 정리해:
> 1. 이 함수의 모든 분기(branch) — 조건과 결과를 행별로
> 2. 외부 의존성 목록 — 함수명, 역할, 실패 시 동작
> 3. 사이드 이펙트 — DB 변경, 외부 호출, 알림 등
> 4. 코드 내 주석에서 발견되는 역사적 맥락
>
> 코드를 수정하지 마. 분석만 해.

AI가 돌아올 결과는 대략 이런 형태다:

## 분기 분석

| # | 조건 | 결과 |
|---|------|------|
| 1 | booking이 None | error: booking_not_found |
| 2 | status가 cancelled/completed/no_show | error: invalid_status |
| 3 | force=True AND requested_by="admin" | 100% 환불 |
| 4 | days_until >= free_cancel_days | 100% 환불 |
| 5 | days_until >= partial_cancel_days | 부분 환불 (정책 비율) |
| 6 | days_until >= 0 | 0% 환불 |
| 7 | days_until >= -1 | 0% 환불 (시차 hotfix) |
| 8 | days_until < -1 | error: past_checkin |

## 외부 의존성

| 함수 | 역할 | 실패 시 |
|------|------|---------|
| get_booking() | DB 조회 | None 반환 → 분기 1 |
| get_refund_policy() | 정책 조회 | (미처리 — 예외 전파) |
| process_refund() | 결제 환불 | success=False → 중단 |
| update_booking_status() | DB 갱신 | (미처리 — 예외 전파) |
| send_cancel_email() | 이메일 | except: pass |
| send_cancel_sms() | SMS | except: pass |

## 역사적 맥락
- 2020-08: 알림 실패 시 전체 취소가 롤백되는 장애 → except pass 추가
- 2021-03: 해외 시차로 체크인 당일 새벽 취소 불가 → days_until >= -1 분기 추가

이 지도가 있으면, 이제 어떤 테스트를 만들어야 하는지 명확해진다. 분기가 8개니까 최소 8개의 테스트 케이스가 필요하고, 외부 의존성 6개를 전부 mock해야 한다.

2단계 — 특성화 테스트 생성

# 이제 AI에게 테스트를 만들게 한다

> 위 분석을 바탕으로 cancel_booking()의 특성화 테스트를 만들어줘.
>
> [규칙]
> - 프레임워크: pytest + unittest.mock
> - 파일: tests/test_cancellation_characterization.py
> - 모든 외부 의존성은 mock으로 대체
> - 현재 동작을 있는 그대로 assertion으로 캡처
>   (버그가 보여도 고치지 마)
> - datetime.now()도 mock해서 테스트를 결정적(deterministic)으로
> - 분기 분석의 8개 경로를 모두 커버
> - 추가로: process_refund 실패 케이스, 알림 실패 케이스
>
> [출력 형식]
> - 테스트 함수 이름은 test_cancel_분기번호_설명
> - 각 테스트 위에 주석으로 어떤 분기를 검증하는지 명시

AI가 생성할 특성화 테스트의 핵심 부분:

# tests/test_cancellation_characterization.py
"""
특성화 테스트 — cancel_booking()의 현재 동작을 기록.
이 테스트는 '올바른 동작'이 아니라 '현재 동작'을 검증한다.
리팩터링 후 이 테스트가 깨지면 동작이 변경된 것이다.
"""
import datetime
from decimal import Decimal
from unittest.mock import MagicMock, patch

import pytest

from booking.cancellation import cancel_booking


@pytest.fixture
def mock_booking():
    """기본 예약 데이터 — 정상 상태, 체크인 30일 전"""
    return {
        "booking_id": "BK-001",
        "status": "confirmed",
        "checkin_date": "2026-09-20",
        "hotel_id": "HTL-100",
        "total_amount": "150000",
        "payment_id": "PAY-001",
        "guest_email": "[email protected]",
        "guest_phone": "010-1234-5678",
    }


@pytest.fixture
def mock_policy():
    """기본 환불 정책"""
    return {
        "free_cancel_days": 7,
        "partial_cancel_days": 3,
        "partial_refund_pct": Decimal("50"),
    }


@pytest.fixture
def deps(mock_booking, mock_policy):
    """외부 의존성을 모두 mock으로 교체"""
    with (
        patch("booking.cancellation.get_booking",
              return_value=mock_booking) as m_get,
        patch("booking.cancellation.get_refund_policy",
              return_value=mock_policy) as m_policy,
        patch("booking.cancellation.process_refund",
              return_value={"success": True}) as m_refund,
        patch("booking.cancellation.update_booking_status") as m_update,
        patch("booking.cancellation.send_cancel_email") as m_email,
        patch("booking.cancellation.send_cancel_sms") as m_sms,
        patch("booking.cancellation.datetime") as m_dt,
    ):
        m_dt.datetime.now.return_value = datetime.datetime(2026, 8, 18)
        m_dt.datetime.strptime = datetime.datetime.strptime
        yield {
            "get_booking": m_get,
            "get_refund_policy": m_policy,
            "process_refund": m_refund,
            "update_booking_status": m_update,
            "send_cancel_email": m_email,
            "send_cancel_sms": m_sms,
            "datetime": m_dt,
        }


# --- 분기 1: 예약 없음 ---
class TestBranch1BookingNotFound:
    def test_returns_error_when_booking_not_found(self, deps):
        deps["get_booking"].return_value = None
        result = cancel_booking("NONEXIST", "test", "user")
        assert result == {"success": False, "error": "booking_not_found"}

    def test_does_not_call_refund_when_booking_not_found(self, deps):
        deps["get_booking"].return_value = None
        cancel_booking("NONEXIST", "test", "user")
        deps["process_refund"].assert_not_called()


# --- 분기 2: 잘못된 상태 ---
class TestBranch2InvalidStatus:
    @pytest.mark.parametrize("status", ["cancelled", "completed", "no_show"])
    def test_returns_error_for_terminal_status(
        self, deps, mock_booking, status
    ):
        mock_booking["status"] = status
        result = cancel_booking("BK-001", "test", "user")
        assert result["success"] is False
        assert result["error"] == "invalid_status"
        assert result["current_status"] == status


# --- 분기 3: 관리자 강제 취소 → 100% 환불 ---
class TestBranch3AdminForce:
    def test_admin_force_gives_full_refund(self, deps):
        result = cancel_booking(
            "BK-001", "admin_request", "admin", force=True
        )
        assert result["success"] is True
        assert result["refund_amount"] == "150000"
        assert result["refund_pct"] == "100"

    def test_non_admin_force_does_not_give_full_refund(self, deps):
        """force=True지만 requested_by가 admin이 아니면
        일반 정책을 따름 — 현재 동작 기록"""
        result = cancel_booking(
            "BK-001", "test", "user", force=True
        )
        # 체크인 33일 전 (8/18 → 9/20) → free_cancel_days(7) 이상
        # → 100% 환불 (분기 4로 진입)
        assert result["success"] is True
        assert result["refund_pct"] == "100"


# --- 분기 4: 무료 취소 기간 내 ---
class TestBranch4FreeCancellation:
    def test_full_refund_within_free_period(self, deps):
        # 체크인 33일 전 → free_cancel_days(7) 이상
        result = cancel_booking("BK-001", "changed_plan", "user")
        assert result["success"] is True
        assert result["refund_amount"] == "150000"
        assert result["refund_pct"] == "100"


# --- 분기 5: 부분 환불 기간 ---
class TestBranch5PartialRefund:
    def test_partial_refund_within_partial_period(self, deps, mock_booking):
        # 체크인 5일 전으로 설정 (free 7일 미만, partial 3일 이상)
        mock_booking["checkin_date"] = "2026-08-23"
        result = cancel_booking("BK-001", "changed_plan", "user")
        assert result["success"] is True
        assert result["refund_amount"] == "75000"  # 150000 * 50%
        assert result["refund_pct"] == "50"


# --- 분기 6: 취소 불가 기간 (환불 0%) ---
class TestBranch6NoRefund:
    def test_zero_refund_close_to_checkin(self, deps, mock_booking):
        # 체크인 1일 전
        mock_booking["checkin_date"] = "2026-08-19"
        result = cancel_booking("BK-001", "emergency", "user")
        assert result["success"] is True
        assert result["refund_amount"] == "0"
        assert result["refund_pct"] == "0"

    def test_no_refund_call_when_amount_is_zero(self, deps, mock_booking):
        mock_booking["checkin_date"] = "2026-08-19"
        cancel_booking("BK-001", "emergency", "user")
        deps["process_refund"].assert_not_called()


# --- 분기 7: 시차 hotfix (체크인 당일~하루 뒤) ---
class TestBranch7TimezoneHotfix:
    def test_allows_cancel_one_day_past_checkin(self, deps, mock_booking):
        """2021-03 hotfix: 해외 시차 문제 — 체크인 당일 새벽 취소 허용"""
        mock_booking["checkin_date"] = "2026-08-17"  # 어제
        result = cancel_booking("BK-001", "timezone_issue", "user")
        assert result["success"] is True
        assert result["refund_amount"] == "0"


# --- 분기 8: 체크인 날짜 경과 ---
class TestBranch8PastCheckin:
    def test_rejects_cancel_two_days_past_checkin(self, deps, mock_booking):
        mock_booking["checkin_date"] = "2026-08-15"  # 3일 전
        result = cancel_booking("BK-001", "late", "user")
        assert result == {"success": False, "error": "past_checkin"}


# --- 환불 처리 실패 ---
class TestRefundFailure:
    def test_returns_error_when_refund_fails(self, deps):
        deps["process_refund"].return_value = {
            "success": False, "error": "gateway_timeout"
        }
        result = cancel_booking("BK-001", "test", "user")
        assert result["success"] is False
        assert result["error"] == "refund_failed"
        assert result["detail"] == "gateway_timeout"

    def test_does_not_update_status_when_refund_fails(self, deps):
        deps["process_refund"].return_value = {
            "success": False, "error": "gateway_timeout"
        }
        cancel_booking("BK-001", "test", "user")
        deps["update_booking_status"].assert_not_called()


# --- 알림 실패 (2020-08 장애 후 추가된 except pass) ---
class TestNotificationFailure:
    def test_succeeds_even_when_email_fails(self, deps):
        """2020-08 장애 교훈: 알림 실패가 취소를 롤백하면 안 됨"""
        deps["send_cancel_email"].side_effect = Exception("SMTP down")
        result = cancel_booking("BK-001", "test", "user")
        assert result["success"] is True

    def test_succeeds_even_when_sms_fails(self, deps):
        deps["send_cancel_sms"].side_effect = Exception("SMS provider error")
        result = cancel_booking("BK-001", "test", "user")
        assert result["success"] is True


# --- 사이드 이펙트 검증 ---
class TestSideEffects:
    def test_update_status_called_with_correct_args(self, deps):
        cancel_booking("BK-001", "changed_plan", "user")
        deps["update_booking_status"].assert_called_once()
        args = deps["update_booking_status"].call_args
        assert args[0][0] == "BK-001"
        assert args[0][1] == "cancelled"
        meta = args[0][2]
        assert meta["reason"] == "changed_plan"
        assert meta["cancelled_by"] == "user"

    def test_email_sent_to_guest(self, deps, mock_booking):
        cancel_booking("BK-001", "test", "user")
        deps["send_cancel_email"].assert_called_once_with(
            "[email protected]", "BK-001", "150000"
        )

    def test_sms_sent_when_phone_exists(self, deps, mock_booking):
        cancel_booking("BK-001", "test", "user")
        deps["send_cancel_sms"].assert_called_once_with(
            "010-1234-5678", "BK-001"
        )

    def test_sms_not_sent_when_no_phone(self, deps, mock_booking):
        del mock_booking["guest_phone"]
        cancel_booking("BK-001", "test", "user")
        deps["send_cancel_sms"].assert_not_called()

3단계 — 테스트 실행과 커버리지 확인

# 터미널에서 실행
pytest tests/test_cancellation_characterization.py -v

# 결과 예시
tests/...::TestBranch1BookingNotFound::test_returns_error... PASSED
tests/...::TestBranch1BookingNotFound::test_does_not_call... PASSED
tests/...::TestBranch2InvalidStatus::test_returns_error[cancelled] PASSED
tests/...::TestBranch2InvalidStatus::test_returns_error[completed] PASSED
tests/...::TestBranch2InvalidStatus::test_returns_error[no_show] PASSED
tests/...::TestBranch3AdminForce::test_admin_force_gives_full... PASSED
...
=================== 18 passed in 0.34s ===================

# 커버리지 확인
pytest tests/test_cancellation_characterization.py \
    --cov=src/booking/cancellation \
    --cov-report=term-missing

# 결과 예시
Name                              Stmts   Miss  Cover   Missing
---------------------------------------------------------------
src/booking/cancellation.py          42      2    95%   (줄 번호)
---------------------------------------------------------------

18개 테스트, 95% 커버리지. 이 숫자가 중요한 게 아니다. 중요한 건 이제 코드를 건드려도 뭐가 깨졌는지 즉시 알 수 있다는 것이다. 8회에서 다룬 체크포인트와 결합하면 더 강력하다 — 특성화 테스트가 깨지면 체크포인트로 되돌리고 다른 접근을 시도할 수 있다.

4단계 — 안전한 리팩터링 순서

특성화 테스트가 깔렸으면 이제 리팩터링을 시작할 수 있다. 하지만 순서가 있다. 한 번에 다 고치려 하면 안 된다.

# 리팩터링 지시서 — 순서가 핵심

> cancel_booking() 함수를 리팩터링해줘.
> 반드시 아래 순서를 따르고, 각 단계마다
> tests/test_cancellation_characterization.py가 전부 통과하는지 확인해.
>
> [순서]
> 1단계: 이름 정리 — 변수명·함수명만 개선. 로직 변경 없음.
>        → 테스트 실행 → 전부 통과 확인
>
> 2단계: 추출 — 긴 함수를 작은 함수로 분리. 로직 변경 없음.
>        - calculate_refund_percentage() 추출
>        - notify_guest() 추출
>        → 테스트 실행 → 전부 통과 확인
>
> 3단계: 구조 개선 — 조건문 정리, 얼리 리턴, 가드 절.
>        → 테스트 실행 → 전부 통과 확인
>
> 4단계: (선택) 타입 힌트 추가 — dict 대신 dataclass/TypedDict.
>        → 테스트 실행 → 전부 통과 확인
>
> [절대 금지]
> - 한 단계에서 두 종류의 변경을 동시에 하지 마
> - except: pass를 제거하지 마 (비즈니스 결정임)
> - days_until >= -1 분기를 제거하지 마 (2021 hotfix임)
> - 각 단계를 별도 커밋으로 만들어

이 순서의 원칙은 간단하다. 동작을 바꾸지 않는 변경부터 한다. 이름 정리와 함수 추출은 동작을 바꾸지 않는다(정확히는, 바꾸지 않아야 한다). 그래서 먼저 한다. 구조 개선은 동작을 바꿀 위험이 있으므로 나중에 한다. 그리고 매 단계마다 테스트를 돌린다.

6회에서 다룬 훅을 여기서 활용할 수도 있다. 커밋 전에 자동으로 특성화 테스트를 돌리는 pre-commit 훅을 걸어두면, AI가 실수로 동작을 바꾼 채 커밋하는 걸 막을 수 있다.

AI가 만드는 ‘동작하는 척’ 코드 — 함정 3가지

레거시 코드에 AI를 붙이면서 가장 위험했던 순간들을 정리한다. 세 가지 모두 “코드가 동작하는 것처럼 보이지만 실제로는 다른 동작을 하는” 경우다.

함정 1: 예외 처리를 “개선”하면서 동작을 바꾸는 것

# 원본 — 의도적인 except: pass
try:
    send_cancel_email(email, booking_id, amount)
except Exception:
    pass  # 알림 실패는 무시

# AI가 "개선"한 버전
try:
    send_cancel_email(email, booking_id, amount)
except SMTPException as e:
    logger.warning(f"Email failed: {e}")
except Exception as e:
    raise CancellationError(f"Notification failed: {e}")

깔끔해 보인다. 하지만 동작이 완전히 달라졌다. 원본은 모든 예외를 무시한다. AI 버전은 SMTPException이 아닌 예외가 나면 전체 취소를 실패시킨다. 2020년 8월 장애가 정확히 이 패턴이었다.

방어법: 특성화 테스트의 TestNotificationFailure 클래스가 이걸 잡는다. Exception("SMTP down")을 던졌을 때 여전히 success=True인지 확인하므로, AI가 except 구조를 바꾸면 즉시 테스트가 깨진다.

함정 2: 경계값을 “정리”하면서 날리는 것

# 원본 — 시차 hotfix
elif days_until >= 0:
    refund_pct = Decimal("0")
else:
    if days_until >= -1:
        refund_pct = Decimal("0")
    else:
        return {"success": False, "error": "past_checkin"}

# AI가 "단순화"한 버전
elif days_until >= -1:
    refund_pct = Decimal("0")
else:
    return {"success": False, "error": "past_checkin"}

수학적으로는 같은 결과다. AI의 리팩터링이 “올바르다”고 할 수도 있다. 하지만 원본의 구조는 의도적이다. days_until >= 0(정상)과 days_until >= -1(시차 예외)을 분리해서, 나중에 시차 예외를 제거하거나 조건을 바꿀 때 한 줄만 건드리면 되게 설계돼 있다. AI가 합쳐버리면 이 의도가 사라진다.

이 경우 특성화 테스트가 통과하기 때문에 더 위험하다. 동작은 같지만 유지보수성이 나빠진 것이다. 방어법: 리팩터링 지시서에 “이 분기를 합치지 마”를 명시하는 수밖에 없다. 탐색 단계에서 발견한 “역사적 맥락”이 여기서 쓰인다.

함정 3: mock이 현실을 반영하지 못하는 것

# AI가 만든 mock
mock_policy = {
    "free_cancel_days": 7,
    "partial_cancel_days": 3,
    "partial_refund_pct": Decimal("50"),
}

# 하지만 실제 운영 DB의 일부 호텔은 이런 정책을 가지고 있다
actual_policy_hotel_205 = {
    "free_cancel_days": 0,  # 무료 취소 없음
    # partial_cancel_days 키 자체가 없음!
}

partial_cancel_days 키가 없을 때 코드가 어떻게 동작하는가? 원본에서 policy.get("partial_cancel_days", 0)으로 처리하고 있지만, AI가 만든 mock에는 항상 이 키가 있다. 실제 운영 데이터의 다양성을 반영하지 못하는 mock은, 테스트가 통과하지만 운영에서 깨지는 가장 흔한 원인이다.

방어법: 특성화 테스트를 만들 때, 운영 DB에서 실제 정책 데이터의 스키마 변동을 샘플링해서 테스트 케이스에 반영하라. AI에게 이렇게 지시한다:

> get_refund_policy()가 반환하는 실제 데이터를 보여줘.
> 특히 선택적(optional) 키가 있는 경우,
> 그 키가 없는 상태에서도 cancel_booking()이
> 에러 없이 동작하는지 확인하는 테스트를 추가해줘.

세 함정의 공통점이 있다. AI는 코드의 “표면적 구조”에는 강하지만, “왜 이렇게 만들었는가”에는 약하다. 탐색 단계에서 역사적 맥락을 발굴하고, 특성화 테스트에서 현재 동작을 굳히고, 리팩터링 지시서에서 “건드리지 마”를 명시하는 삼중 방어가 필요한 이유다.

이번 회차의 수익화 지점

“레거시 코드에 테스트 붙이기”는 거의 모든 개발 조직이 하고 싶지만 손이 못 가는 작업이다. 이번 회차의 방법론을 패키지화하면 실제로 팔 수 있는 형태가 나온다.

레거시 코드 특성화 테스트 생성 서비스. 고객사의 레거시 모듈을 넘겨받아, 탐색 → 분기 분석 → 특성화 테스트 생성 → 커버리지 리포트를 산출물로 전달한다. 9회에서 만든 Skill로 템플릿화하면, 분석부터 테스트 생성까지 AI가 80%를 처리한다. 인간 리뷰어는 비즈니스 맥락 검증에만 집중하면 된다. 실제로 커버리지 0%인 모듈 하나에 특성화 테스트를 붙이는 작업을 반나절 만에 끝내는 게 가능하다. 이전이라면 일주일은 잡았을 작업이다. 그 시간 차이가 곧 가격이 된다.

12회에서 다룬 플러그인 형태로 만들면 더 확장된다 — 탐색 에이전트 + 테스트 생성 Skill + 커버리지 리포트 아티팩트(16회)를 하나의 플러그인으로 묶어 팀 단위로 배포할 수 있다.


다음 회 예고: AI 코딩 에이전트의 도입 효과를 Microsoft Research가 수만 명 규모로 측정했다. PR은 24% 늘었지만, 비용은 연간 수백만 달러. 18회에서는 레거시 코드 AI 리팩터링을 포함한 AI 도구 전체의 비용 관리 전략을 다룬다 — 토큰 상한, 예산 설계, 그리고 “6개월 뒤 예산 삭감”을 피하는 법.

관련 회차: 5회 CLAUDE.md로 레거시 암묵지 정리하기 | 8회 체크포인트로 과감한 리팩터링하기


📚 시리즈: Claude 활용 24회 — AI에게 일을 위임하는 법 (총 24화 중 17화)
◀ 이전 16화  (다음 차수는 아직 게시되지 않았습니다)

자주 묻는 질문

레거시 코드를 AI로 리팩터링할 때 가장 흔한 실수는 무엇인가요?

가장 흔한 실수는 탐색 없이 바로 “이 코드 리팩터링해줘”라고 지시하는 것입니다. AI는 코드의 표면적 패턴만 보고 정리하기 때문에, 암묵적 의존성이나 역사적 맥락이 담긴 코드를 불필요하다고 판단해 제거할 수 있습니다. 테스트가 없는 상태에서 이렇게 하면 장애가 발생해도 뭐가 깨졌는지조차 파악할 수 없습니다.


Tags:

AI 코딩Claude CodeClaude 활용 24회 — AI에게 일을 위임하는 법-17화레거시 코드 리팩터링연재:Claude 활용 24회 — AI에게 일을 위임하는 법테스트 자동화특성화 테스트
작성자

AICosmus

Follow Me
다른 기사
Claude 아티팩트 대시보드 일러스트
Previous

[Claude 활용 24회 — AI에게 일을 위임하는 법] 16/24화: Claude 아티팩트 활용 4단계 — 살아있는 대시보드 만들기

opencode 멀티 에이전트 시스템 협업 구조
Next

[opencode 시즌 2 심화 — 나만의 도메인 특화 에이전트 만들기] 11/12화: opencode 멀티 에이전트 시스템 5가지 협업 패턴 2026

댓글 없음! 첫 댓글을 남겨보세요.

답글 남기기 응답 취소

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

최신 글

  • 디지털자산 뉴스 4선 — 2026년 9월 12일, 입법과 인프라가 동시에 다음 단계로 넘어가다
  • AI 트렌드 뉴스 5선 — 2026년 9월 11일, 자본과 규제가 같은 속도로 달린다
  • 디지털자산 뉴스 4선 — 2026년 9월 10일, 은행과 빅테크가 같은 날 스테이블코인 인프라를 가동하다
  • AI 트렌드 뉴스 4선 — 2026년 9월 둘째 주, 수학 난제부터 반도체 현장까지 AI가 증명을 시작했다
  • 디지털자산 뉴스 4선 — 2026년 9월 8일, $320M 해킹과 CBDC 실거래가 같은 주에 터지다

최신 댓글

  1. 디지털자산 뉴스 4선 — 2026년 9월 10일, 은행과 빅테크가 같은 날 스테이블코인 인프라를 가동하다의 디지털자산 뉴스 4선 — 2026년 9월 12일, 입법과 인프라가 동시에 다음 단계로 넘어가다 - AICosmus
  2. AI 트렌드 뉴스 5선 — 2026년 9월 11일, 자본과 규제가 같은 속도로 달린다의 디지털자산 뉴스 4선 — 2026년 9월 12일, 입법과 인프라가 동시에 다음 단계로 넘어가다 - AICosmus
  3. AI 트렌드 뉴스 4선 — 2026년 9월 둘째 주, 수학 난제부터 반도체 현장까지 AI가 증명을 시작했다의 AI 트렌드 뉴스 5선 — 2026년 9월 11일, 자본과 규제가 같은 속도로 달린다 - AICosmus
  4. 디지털자산 뉴스 4선 — 2026년 9월 8일, $320M 해킹과 CBDC 실거래가 같은 주에 터지다의 디지털자산 뉴스 4선 — 2026년 9월 10일, 은행과 빅테크가 같은 날 스테이블코인 인프라를 가동하다 - AICosmus
  5. 디지털자산 뉴스 4선 — 2026년 9월 10일, 은행과 빅테크가 같은 날 스테이블코인 인프라를 가동하다의 AI 트렌드 뉴스 5선 — 2026년 9월 11일, 자본과 규제가 같은 속도로 달린다 - AICosmus
  • About
  • Contact
  • Disclaimer
  • Privacy - Policy
  • Terms of Service
Copyright 2026 — AICosmus. All rights reserved. Blogsy WordPress Theme