본문 바로가기
개발 언어/Python

8강: 타입 힌트와 정적 분석 - 파이썬 고급편

by 주호파파 2025. 5. 29.
728x90
반응형

 

8강: 코드를 더 안전하고 명확하게 - 파이썬 타입 힌트 도입

개요 및 중요성

파이썬은 동적 타입 언어이지만, Python 3.5부터 도입된 타입 힌트를 통해 정적 타입 검사의 이점을 누릴 수 있습니다. 타입 힌트는 코드의 가독성을 향상시키고, 개발 단계에서 오류를 조기에 발견하며, IDE의 자동완성과 리팩토링 기능을 강화합니다.

핵심 포인트: 타입 힌트는 런타임에 영향을 주지 않으며, 개발자를 위한 문서화 및 도구 지원 역할을 합니다.

타입 힌트 없는 코드 vs 있는 코드

먼저 타입 힌트가 없는 코드와 있는 코드의 차이점을 비교해보겠습니다.

타입 힌트가 없는 코드

# 타입 힌트 없는 함수 - 무엇을 받고 무엇을 반환하는지 불분명
def process_data(data, multiplier, include_negative):
    result = []
    for item in data:
        processed = item * multiplier
        if include_negative or processed >= 0:
            result.append(processed)
    return result

def calculate_statistics(numbers):
    total = sum(numbers)
    count = len(numbers)
    average = total / count
    return total, count, average

# 사용할 때 어떤 타입을 전달해야 하는지 명확하지 않음
data = [1, 2, -3, 4, -5]
result1 = process_data(data, 1.5, False)
result2 = calculate_statistics(result1)

타입 힌트가 있는 코드

from typing import List, Tuple, Union

# 타입 힌트가 있는 함수 - 매개변수와 반환값의 타입이 명확
def process_data(
    data: List[Union[int, float]], 
    multiplier: float, 
    include_negative: bool
) -> List[float]:
    """데이터를 처리하여 필터링된 결과를 반환합니다."""
    result: List[float] = []
    for item in data:
        processed: float = item * multiplier
        if include_negative or processed >= 0:
            result.append(processed)
    return result

def calculate_statistics(numbers: List[float]) -> Tuple[float, int, float]:
    """숫자 리스트의 통계를 계산합니다."""
    total: float = sum(numbers)
    count: int = len(numbers)
    average: float = total / count
    return total, count, average

# 사용법이 명확해짐
data: List[int] = [1, 2, -3, 4, -5]
result1: List[float] = process_data(data, 1.5, False)
result2: Tuple[float, int, float] = calculate_statistics(result1)

기본 타입 힌트 문법

파이썬의 기본 타입들과 typing 모듈의 활용법을 알아보겠습니다.

from typing import List, Dict, Set, Tuple, Optional, Union, Any, Callable

# 기본 타입
def basic_types_example():
    # 기본 타입들
    name: str = "김철수"
    age: int = 25
    height: float = 175.5
    is_student: bool = True
    
    # 컬렉션 타입
    numbers: List[int] = [1, 2, 3, 4, 5]
    scores: Dict[str, int] = {"수학": 90, "영어": 85, "과학": 92}
    unique_ids: Set[int] = {101, 102, 103}
    coordinates: Tuple[float, float] = (37.5665, 126.9780)
    
    # Optional (None 가능)
    middle_name: Optional[str] = None  # str 또는 None
    
    # Union (여러 타입 중 하나)
    user_id: Union[int, str] = "user123"  # int 또는 str
    
    # Any (모든 타입 허용 - 가급적 사용 지양)
    dynamic_data: Any = {"key": "value"}
    
    return name, age, height, is_student

# 함수 타입 힌트
def greet_user(name: str, age: int) -> str:
    return f"안녕하세요, {age}세 {name}님!"

# 함수를 매개변수로 받는 경우
def apply_operation(numbers: List[int], operation: Callable[[int], int]) -> List[int]:
    return [operation(num) for num in numbers]

# 사용 예시
def square(x: int) -> int:
    return x ** 2

result: List[int] = apply_operation([1, 2, 3, 4, 5], square)
print(f"제곱 결과: {result}")

클래스와 메서드의 타입 힌트

from typing import List, Optional, ClassVar
from datetime import datetime

class BankAccount:
    # 클래스 변수 타입 힌트
    bank_name: ClassVar[str] = "파이썬 은행"
    
    def __init__(self, owner: str, initial_balance: float = 0.0) -> None:
        self.owner: str = owner
        self.balance: float = initial_balance
        self.transaction_history: List[Tuple[datetime, str, float]] = []
    
    def deposit(self, amount: float) -> None:
        """예금"""
        if amount <= 0:
            raise ValueError("예금액은 0보다 커야 합니다")
        
        self.balance += amount
        self.transaction_history.append(
            (datetime.now(), "입금", amount)
        )
    
    def withdraw(self, amount: float) -> bool:
        """출금 - 성공 여부 반환"""
        if amount <= 0:
            return False
        
        if self.balance >= amount:
            self.balance -= amount
            self.transaction_history.append(
                (datetime.now(), "출금", -amount)
            )
            return True
        return False
    
    def get_balance(self) -> float:
        """잔액 조회"""
        return self.balance
    
    def get_owner(self) -> str:
        """계좌주 이름 반환"""
        return self.owner
    
    def find_large_transactions(self, threshold: float) -> List[Tuple[datetime, str, float]]:
        """임계값 이상의 거래 내역 찾기"""
        return [
            transaction for transaction in self.transaction_history
            if abs(transaction[2]) >= threshold
        ]

# 사용 예시
account: BankAccount = BankAccount("김철수", 1000.0)
account.deposit(500.0)
success: bool = account.withdraw(200.0)
balance: float = account.get_balance()
large_transactions = account.find_large_transactions(300.0)

고급 타입 힌트

더 복잡한 상황에서 사용할 수 있는 고급 타입 힌트 기능들을 살펴보겠습니다.

from typing import TypeVar, Generic, Protocol, Literal, Final
from abc import ABC, abstractmethod

# TypeVar - 제네릭 타입 변수
T = TypeVar('T')
U = TypeVar('U')

class Stack(Generic[T]):
    """제네릭 스택 클래스"""
    def __init__(self) -> None:
        self._items: List[T] = []
    
    def push(self, item: T) -> None:
        self._items.append(item)
    
    def pop(self) -> Optional[T]:
        if self._items:
            return self._items.pop()
        return None
    
    def peek(self) -> Optional[T]:
        if self._items:
            return self._items[-1]
        return None
    
    def is_empty(self) -> bool:
        return len(self._items) == 0

# Protocol - 덕 타이핑을 위한 프로토콜
class Drawable(Protocol):
    def draw(self) -> str: ...

class Circle:
    def __init__(self, radius: float) -> None:
        self.radius = radius
    
    def draw(self) -> str:
        return f"원 (반지름: {self.radius})"

class Rectangle:
    def __init__(self, width: float, height: float) -> None:
        self.width = width
        self.height = height
    
    def draw(self) -> str:
        return f"사각형 ({self.width} x {self.height})"

def render_shape(shape: Drawable) -> str:
    """Drawable 프로토콜을 구현한 모든 객체를 렌더링"""
    return shape.draw()

# Literal - 리터럴 값으로 타입 제한
Color = Literal["red", "green", "blue"]
def set_color(color: Color) -> None:
    print(f"색상을 {color}로 설정합니다.")

# Final - 변경 불가능한 상수
MAX_USERS: Final[int] = 1000
API_VERSION: Final[str] = "1.0.0"

# 사용 예시
int_stack: Stack[int] = Stack()
int_stack.push(1)
int_stack.push(2)
value: Optional[int] = int_stack.pop()

string_stack: Stack[str] = Stack()
string_stack.push("hello")
string_stack.push("world")

circle = Circle(5.0)
rectangle = Rectangle(10.0, 20.0)
shapes: List[Drawable] = [circle, rectangle]

for shape in shapes:
    print(render_shape(shape))

set_color("red")    # 정상
# set_color("yellow")  # 타입 체커에서 오류 (yellow는 허용되지 않음)

Mypy를 이용한 정적 타입 검사

Mypy는 파이썬 코드의 타입 힌트를 검사하는 정적 분석 도구입니다. 런타임 전에 타입 관련 오류를 발견할 수 있습니다.

타입 오류가 있는 코드 예시

# type_error_example.py
def add_numbers(a: int, b: int) -> int:
    return a + b

def process_user_data(name: str, age: int, scores: List[float]) -> Dict[str, float]:
    return {
        "average_score": sum(scores) / len(scores),
        "name_length": len(name),
        "age": age
    }

# 의도적인 타입 오류들
result1 = add_numbers("5", 10)          # 문자열을 int 자리에
result2 = add_numbers(5, 10.5)          # float을 int 자리에

user_data = process_user_data(
    123,                                 # int를 str 자리에
    "25",                               # str을 int 자리에
    [85, "90", 92.5]                    # 리스트에 str 포함
)

# 반환값 타입 오류
def get_user_name() -> str:
    return None                         # None을 str 자리에

# 리스트 인덱싱 타입 오류
def get_first_item(items: List[str]) -> str:
    return items[0]                     # 빈 리스트일 수 있음

result = get_first_item([])            # 런타임 에러 가능

Mypy 설정 예시

# mypy.ini 설정 파일 예시
[mypy]
# 기본 설정
python_version = 3.8
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True

# 엄격한 검사
strict_optional = True
warn_no_return = True
warn_unreachable = True

# 특정 모듈에 대한 설정
[mypy-requests.*]
ignore_missing_imports = True

Mypy 명령어를 통한 타입 검사:

# 터미널에서 실행
$ pip install mypy
$ mypy your_file.py

# 예상 출력
error: Argument 1 to "add_numbers" has incompatible type "str"; expected "int"
error: Argument 2 to "add_numbers" has incompatible type "float"; expected "int"
error: Argument 1 to "process_user_data" has incompatible type "int"; expected "str"

실습: 타입 안전한 데이터 처리 시스템

타입 힌트를 활용하여 안전하고 명확한 데이터 처리 시스템을 구축해보겠습니다.

from typing import List, Dict, Optional, Union, TypeVar, Generic, Protocol
from dataclasses import dataclass
from datetime import datetime
from enum import Enum

# 사용자 정의 타입들
class Priority(Enum):
    LOW = "low"
    MEDIUM = "medium" 
    HIGH = "high"
    CRITICAL = "critical"

class Status(Enum):
    PENDING = "pending"
    IN_PROGRESS = "in_progress"
    COMPLETED = "completed"
    CANCELLED = "cancelled"

@dataclass
class Task:
    id: int
    title: str
    description: str
    priority: Priority
    status: Status
    created_at: datetime
    due_date: Optional[datetime] = None
    assigned_to: Optional[str] = None
    estimated_hours: Optional[float] = None

# 프로토콜 정의
class TaskFilter(Protocol):
    def matches(self, task: Task) -> bool: ...

class PriorityFilter:
    def __init__(self, min_priority: Priority) -> None:
        self.priority_order = {
            Priority.LOW: 1,
            Priority.MEDIUM: 2,
            Priority.HIGH: 3,
            Priority.CRITICAL: 4
        }
        self.min_level = self.priority_order[min_priority]
    
    def matches(self, task: Task) -> bool:
        return self.priority_order[task.priority] >= self.min_level

class StatusFilter:
    def __init__(self, allowed_statuses: List[Status]) -> None:
        self.allowed_statuses = set(allowed_statuses)
    
    def matches(self, task: Task) -> bool:
        return task.status in self.allowed_statuses

class AssigneeFilter:
    def __init__(self, assignee: str) -> None:
        self.assignee = assignee
    
    def matches(self, task: Task) -> bool:
        return task.assigned_to == self.assignee

# 제네릭 타입 변수
T = TypeVar('T')

class TaskManager(Generic[T]):
    def __init__(self) -> None:
        self._tasks: List[Task] = []
        self._next_id: int = 1
    
    def add_task(
        self,
        title: str,
        description: str,
        priority: Priority,
        due_date: Optional[datetime] = None,
        assigned_to: Optional[str] = None,
        estimated_hours: Optional[float] = None
    ) -> Task:
        """새 작업 추가"""
        task = Task(
            id=self._next_id,
            title=title,
            description=description,
            priority=priority,
            status=Status.PENDING,
            created_at=datetime.now(),
            due_date=due_date,
            assigned_to=assigned_to,
            estimated_hours=estimated_hours
        )
        self._tasks.append(task)
        self._next_id += 1
        return task
    
    def get_task_by_id(self, task_id: int) -> Optional[Task]:
        """ID로 작업 찾기"""
        for task in self._tasks:
            if task.id == task_id:
                return task
        return None
    
    def update_task_status(self, task_id: int, status: Status) -> bool:
        """작업 상태 업데이트"""
        task = self.get_task_by_id(task_id)
        if task:
            task.status = status
            return True
        return False
    
    def filter_tasks(self, task_filter: TaskFilter) -> List[Task]:
        """필터를 사용하여 작업 검색"""
        return [task for task in self._tasks if task_filter.matches(task)]
    
    def get_tasks_by_priority(self, priority: Priority) -> List[Task]:
        """우선순위별 작업 조회"""
        priority_filter = PriorityFilter(priority)
        return self.filter_tasks(priority_filter)
    
    def get_tasks_by_assignee(self, assignee: str) -> List[Task]:
        """담당자별 작업 조회"""
        assignee_filter = AssigneeFilter(assignee)
        return self.filter_tasks(assignee_filter)
    
    def get_active_tasks(self) -> List[Task]:
        """진행 중인 작업 조회"""
        status_filter = StatusFilter([Status.PENDING, Status.IN_PROGRESS])
        return self.filter_tasks(status_filter)
    
    def get_task_statistics(self) -> Dict[str, Union[int, float]]:
        """작업 통계"""
        total_tasks = len(self._tasks)
        completed_tasks = len([t for t in self._tasks if t.status == Status.COMPLETED])
        
        estimated_hours = [
            t.estimated_hours for t in self._tasks 
            if t.estimated_hours is not None
        ]
        
        avg_estimated_hours = (
            sum(estimated_hours) / len(estimated_hours)
            if estimated_hours else 0.0
        )
        
        priority_counts: Dict[Priority, int] = {}
        for priority in Priority:
            priority_counts[priority] = len([
                t for t in self._tasks if t.priority == priority
            ])
        
        return {
            "total_tasks": total_tasks,
            "completed_tasks": completed_tasks,
            "completion_rate": completed_tasks / total_tasks if total_tasks > 0 else 0.0,
            "average_estimated_hours": avg_estimated_hours,
            "high_priority_tasks": priority_counts[Priority.HIGH] + priority_counts[Priority.CRITICAL],
        }

# 사용 예시
def demo_task_manager() -> None:
    """작업 관리 시스템 데모"""
    manager: TaskManager[Task] = TaskManager()
    
    # 작업 추가
    task1 = manager.add_task(
        "데이터베이스 설계",
        "제품 데이터베이스 스키마 설계",
        Priority.HIGH,
        assigned_to="김개발",
        estimated_hours=8.0
    )
    
    task2 = manager.add_task(
        "UI 디자인",
        "사용자 인터페이스 목업 제작",
        Priority.MEDIUM,
        assigned_to="박디자인",
        estimated_hours=12.0
    )
    
    task3 = manager.add_task(
        "성능 테스트",
        "시스템 부하 테스트 실행",
        Priority.CRITICAL,
        assigned_to="김개발",
        estimated_hours=4.0
    )
    
    # 작업 상태 업데이트
    manager.update_task_status(task1.id, Status.IN_PROGRESS)
    manager.update_task_status(task3.id, Status.COMPLETED)
    
    # 다양한 검색 및 필터링
    print("=== 김개발 담당 작업 ===")
    kim_tasks: List[Task] = manager.get_tasks_by_assignee("김개발")
    for task in kim_tasks:
        print(f"- {task.title} ({task.status.value}, {task.priority.value})")
    
    print("\n=== HIGH 우선순위 이상 작업 ===")
    high_priority_tasks: List[Task] = manager.get_tasks_by_priority(Priority.HIGH)
    for task in high_priority_tasks:
        print(f"- {task.title} (우선순위: {task.priority.value})")
    
    print("\n=== 진행 중인 작업 ===")
    active_tasks: List[Task] = manager.get_active_tasks()
    for task in active_tasks:
        print(f"- {task.title} ({task.status.value})")
    
    print("\n=== 작업 통계 ===")
    stats: Dict[str, Union[int, float]] = manager.get_task_statistics()
    print(f"전체 작업 수: {stats['total_tasks']}")
    print(f"완료된 작업 수: {stats['completed_tasks']}")
    print(f"완료율: {stats['completion_rate']:.1%}")
    print(f"평균 예상 시간: {stats['average_estimated_hours']:.1f}시간")
    print(f"높은 우선순위 작업 수: {stats['high_priority_tasks']}")

if __name__ == "__main__":
    demo_task_manager()

코드 실행 결과

=== 김개발 담당 작업 ===
- 데이터베이스 설계 (in_progress, high)
- 성능 테스트 (completed, critical)

=== HIGH 우선순위 이상 작업 ===
- 데이터베이스 설계 (우선순위: high)
- 성능 테스트 (우선순위: critical)

=== 진행 중인 작업 ===
- 데이터베이스 설계 (in_progress)
- UI 디자인 (pending)

=== 작업 통계 ===
전체 작업 수: 3
완료된 작업 수: 1
완료율: 33.3%
평균 예상 시간: 8.0시간
높은 우선순위 작업 수: 2

마무리

타입 힌트는 파이썬 코드의 안전성과 가독성을 크게 향상시킵니다. 기본 타입 힌트부터 시작하여 Generic, Protocol 등 고급 기능까지 활용하면 더욱 견고한 코드를 작성할 수 있습니다. Mypy와 같은 정적 분석 도구를 함께 사용하면 개발 단계에서 오류를 조기에 발견할 수 있어 프로덕션 환경에서의 버그를 크게 줄일 수 있습니다.

다음 강의에서는 파이썬의 디스크립터 프로토콜과 property를 통한 속성 접근 제어 방법을 학습하겠습니다.

728x90
반응형