800===Dev Docs and License/Clean Code

파이썬 코드 리팩토링 마스터 가이드 - Part 3: 코드 테스트와 유지보수성 향상 🧪

블로글러 2025. 1. 7. 21:30

들어가며 🎯

Part 3에서는 코드의 테스트 용이성과 유지보수성을 높이는 핵심 리팩토링 기법들을 자세히 살펴보겠습니다.

1. 단위 테스트를 위한 리팩토링 🔍

1.1 의존성 주입 패턴 적용

from abc import ABC, abstractmethod
from typing import List, Optional
import datetime

# 개선 전
class UserService:
    def get_active_users(self):
        db = Database()  # 직접 의존성 생성 - 테스트 어려움
        return db.query("SELECT * FROM users WHERE is_active = 1")

# 개선 후
class DatabaseInterface(ABC):
    """데이터베이스 작업을 추상화하는 인터페이스입니다."""
    @abstractmethod
    def query(self, sql: str) -> List[dict]:
        pass

class UserService:
    """사용자 관련 비즈니스 로직을 처리하는 서비스 클래스입니다."""

    def __init__(self, database: DatabaseInterface):
        self.database = database  # 의존성 주입

    def get_active_users(self) -> List[dict]:
        """활성 사용자 목록을 조회합니다."""
        return self.database.query("SELECT * FROM users WHERE is_active = 1")

# 테스트 코드
class MockDatabase(DatabaseInterface):
    """테스트를 위한 Mock 데이터베이스입니다."""

    def query(self, sql: str) -> List[dict]:
        # 테스트용 더미 데이터 반환
        return [
            {"id": 1, "name": "User1", "is_active": 1},
            {"id": 2, "name": "User2", "is_active": 1}
        ]

def test_get_active_users():
    """사용자 서비스 테스트."""
    mock_db = MockDatabase()
    service = UserService(mock_db)
    users = service.get_active_users()
    assert len(users) == 2
    assert users[0]['name'] == "User1"

1.2 테스트 용이한 클래스 설계

from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import List, Optional

@dataclass
class OrderItem:
    """주문 항목을 나타내는 데이터 클래스입니다."""
    product_id: str
    quantity: int
    price: float

class Order:
    """주문을 처리하는 클래스입니다."""

    def __init__(self, 
                 order_id: str,
                 items: List[OrderItem],
                 created_at: Optional[datetime] = None):
        self.order_id = order_id
        self.items = items
        self.created_at = created_at or datetime.now()
        self.status = "CREATED"

    @property
    def total_amount(self) -> float:
        """주문 총액을 계산합니다."""
        return sum(item.price * item.quantity for item in self.items)

    @property
    def is_valid(self) -> bool:
        """주문의 유효성을 검사합니다."""
        return bool(self.items) and all(
            item.quantity > 0 and item.price > 0 
            for item in self.items
        )

    def can_be_modified(self) -> bool:
        """주문 수정 가능 여부를 확인합니다."""
        return (
            self.status == "CREATED" and 
            datetime.now() - self.created_at < timedelta(hours=24)
        )

# 테스트 코드
def test_order_validation():
    """주문 유효성 검사 테스트."""
    # 유효한 주문 테스트
    valid_items = [
        OrderItem("P1", 2, 1000),
        OrderItem("P2", 1, 500)
    ]
    order = Order("O1", valid_items)
    assert order.is_valid
    assert order.total_amount == 2500

    # 무효한 주문 테스트
    invalid_items = [
        OrderItem("P1", 0, 1000),  # 수량 0
        OrderItem("P2", 1, -500)   # 음수 가격
    ]
    invalid_order = Order("O2", invalid_items)
    assert not invalid_order.is_valid

2. 에러 처리와 로깅 개선 🚨

2.1 커스텀 예외 클래스

from typing import Optional
import logging

class OrderError(Exception):
    """주문 관련 기본 예외 클래스입니다."""
    pass

class InvalidOrderError(OrderError):
    """유효하지 않은 주문 예외입니다."""
    pass

class OrderProcessingError(OrderError):
    """주문 처리 중 발생한 예외입니다."""
    def __init__(self, message: str, order_id: Optional[str] = None):
        super().__init__(message)
        self.order_id = order_id

class OrderProcessor:
    """주문 처리를 담당하는 클래스입니다."""

    def __init__(self):
        self.logger = logging.getLogger(__name__)

    def process_order(self, order: Order):
        """주문을 처리합니다."""
        try:
            if not order.is_valid:
                raise InvalidOrderError("유효하지 않은 주문입니다")

            self._validate_stock(order)
            self._process_payment(order)
            self._update_inventory(order)

        except InvalidOrderError as e:
            self.logger.warning(
                f"주문 유효성 검사 실패 - Order ID: {order.order_id}", 
                exc_info=True
            )
            raise

        except Exception as e:
            self.logger.error(
                f"주문 처리 실패 - Order ID: {order.order_id}", 
                exc_info=True
            )
            raise OrderProcessingError(
                f"주문 처리 중 오류 발생: {str(e)}", 
                order.order_id
            )

2.2 컨텍스트 매니저 활용

from contextlib import contextmanager
import time
from typing import Generator

@contextmanager
def operation_logger(operation_name: str) -> Generator:
    """작업 시간과 결과를 로깅하는 컨텍스트 매니저입니다."""
    logger = logging.getLogger(__name__)
    start_time = time.time()

    try:
        logger.info(f"{operation_name} 시작")
        yield
        elapsed = time.time() - start_time
        logger.info(f"{operation_name} 완료 (소요시간: {elapsed:.2f}초)")

    except Exception as e:
        elapsed = time.time() - start_time
        logger.error(
            f"{operation_name} 실패 "
            f"(소요시간: {elapsed:.2f}초): {str(e)}"
        )
        raise

class InventoryManager:
    """재고 관리를 담당하는 클래스입니다."""

    def update_stock(self, order: Order):
        """주문에 따라 재고를 업데이트합니다."""
        with operation_logger("재고 업데이트"):
            for item in order.items:
                self._update_item_stock(item)

3. 코드 문서화와 타입 힌트 📚

3.1 문서화 예시

from typing import Dict, List, Optional, Union
import json

class APIClient:
    """REST API와 통신하는 클라이언트 클래스입니다.

    이 클래스는 외부 API와의 통신을 담당하며, 재시도 로직과
    에러 처리를 포함합니다.

    Attributes:
        base_url: API 기본 URL
        timeout: 요청 타임아웃 시간 (초)
        max_retries: 최대 재시도 횟수

    Example:
        >>> client = APIClient("https://api.example.com")
        >>> response = client.get("/users/1")
        >>> print(response['name'])
    """

    def __init__(self, 
                 base_url: str, 
                 timeout: int = 30, 
                 max_retries: int = 3):
        self.base_url = base_url
        self.timeout = timeout
        self.max_retries = max_retries

    def get(self, 
            endpoint: str, 
            params: Optional[Dict] = None) -> Dict:
        """API GET 요청을 수행합니다.

        Args:
            endpoint: API 엔드포인트 경로
            params: URL 쿼리 파라미터

        Returns:
            API 응답 데이터를 딕셔너리로 반환

        Raises:
            APIError: API 호출 실패시 발생
            ValueError: 잘못된 인자 전달시 발생
        """
        url = f"{self.base_url}{endpoint}"
        return self._request("GET", url, params=params)

3.2 타입 힌트와 Validation

from typing import TypeVar, Generic
from pydantic import BaseModel, validator
from datetime import datetime

T = TypeVar('T')

class Page(BaseModel, Generic[T]):
    """페이지네이션 결과를 담는 제네릭 클래스입니다."""
    items: List[T]
    total: int
    page: int
    size: int

class UserCreate(BaseModel):
    """사용자 생성 요청 모델입니다."""
    username: str
    email: str
    birth_date: datetime

    @validator('username')
    def username_must_be_valid(cls, v: str) -> str:
        """사용자명 유효성을 검사합니다."""
        if len(v) < 3:
            raise ValueError("사용자명은 3자 이상이어야 합니다")
        if not v.isalnum():
            raise ValueError("사용자명은 영문과 숫자만 가능합니다")
        return v

    @validator('email')
    def email_must_be_valid(cls, v: str) -> str:
        """이메일 형식을 검사합니다."""
        if '@' not in v:
            raise ValueError("올바른 이메일 형식이 아닙니다")
        return v

class UserService:
    """사용자 관련 비즈니스 로직을 처리하는 서비스입니다."""

    def get_users(self, 
                 page: int = 1, 
                 size: int = 10) -> Page[UserCreate]:
        """사용자 목록을 페이지네이션하여 조회합니다."""
        users = self._fetch_users(page, size)
        total = self._count_users()

        return Page(
            items=users,
            total=total,
            page=page,
            size=size
        )

정리 🎁

Part 3에서는 코드의 품질을 높이는 세 가지 핵심 영역을 다루었습니다:

  1. 단위 테스트 용이성

    • 의존성 주입을 통한 테스트 용이한 구조
    • 명확한 클래스 설계로 테스트 케이스 작성 용이성 확보
  2. 에러 처리와 로깅

    • 체계적인 예외 처리 구조
    • 컨텍스트 매니저를 활용한 리소스 관리
  3. 문서화와 타입 시스템

    • 명확한 문서화로 코드 이해도 향상
    • 타입 힌트를 통한 안정성 확보

이러한 기법들을 적절히 조합하여 적용하면, 더 안정적이고 유지보수가 용이한 코드를 작성할 수 있습니다.


References:

  1. Python Testing with pytest (Brian Okken) - Chapter 3, 5
  2. Clean Code in Python (Mariano Anaya) - Chapter 4, 7
  3. Python Type Hints Documentation (https://docs.python.org/3/library/typing.html)
  4. pytest Documentation (https://docs.pytest.org/)
  5. Python Logging Cookbook (https://docs.python.org/3/howto/logging-cookbook.html)
728x90