파이썬은 여러 가지 방법으로 인자를 받도록 함수를 정의할 수 있으며 사용자도 여러 가지 방법으로 인자를 제공할 수 있다.
- 모든 인자가 값에 의해 전달 된다.
- 변형 가능한 객체를 전달하고 함수에서 값을 변경하면 함수 반환 시 실제 값이 변경되는 부작용이 생길 수 있다.
def function(arg):
arg += " in function"
print(arg)
immutable = "hello"
function(immutable)
immutable
'hello in function'
'hello'
mutable = list("hello")
function(mutable)
mutable
['h', 'e', 'l', 'l', 'o', ' ', 'i', 'n', ' ', 'f', 'u', 'n', 'c', 't', 'i', 'o', 'n']
['h', 'e', 'l', 'l', 'o', ' ', 'i', 'n', ' ', 'f', 'u', 'n', 'c', 't', 'i', 'o', 'n']
결과 설명
- string 객체는 불변형 타입이므로 function 내부에서 새로운 객체를 만들어서 지역 변수인 arg에 다시 할당하여 문제가 되지 않는다.
- list 객체 같은 변형 객체를 전달하면 연산자는 원래 리스트에 대한 참조를 보유하고 있는 변수를 통해 값을 수정하므로 실제 값을 변경할 수 있다.
이렇게 변경하는 방식은 가급적 피하는 것이 좋다.
가변 인자를 사용하려면 해당 인자를 패킹할 변수의 이름 앞에 *를 사용한다.
def f(first, second, third):
print(first)
print(second)
print(third)
l = [1, 2, 3]
f(*l)
1
2
3
패킹 기법의 장점
- 다른 방향으로도 동작한다.
a, b, c = [1, 2, 3]
print(a, b, c)
1 2 3
- 부분적인 언패킹도 가능하다.
- 언패킹하는 순서는 제한이 없다.
- 언패킹할 부분이 없다면 결과는 비어있다.
def show(e, rest):
print("요소: {0} - 나머지: {1}".format(e, rest))
first, *rest = [1, 2, 3, 4, 5]
show(first, rest)
요소: 1 - 나머지: [2, 3, 4, 5]
*rest, last = range(6)
show(last, rest)
요소: 5 - 나머지: [0, 1, 2, 3, 4]
first, *middle, last = range(6)
print(first, middle, last, sep=' - ')
0 - [1, 2, 3, 4] - 5
first, last, *empty = (1, 2)
print(first, last, empty, sep=' - ')
1 - 2 - []
변수 언패킹의 가장 좋은 사용 예는 반복
- 일련의 요소를 반복해야 하고 각 요소가 차례로 있다면 각 요소를 반복할 때 언패킹하는 것이 좋다.
USER = [(i, f"first_name_{i} ", "last_name_{i} ") for i in range(1_000)]
class User:
def __init__(self, user_id, first_name, last_name):
self.user_id = user_id
self.first_name = first_name
self.last_name = last_name
def bad_users_from_rows(dbrows) -> list:
"""DB 레코드에서 사용자를 생성하는 파이썬스럽지 않은 잘못된 사용 예"""
return [User(row[0], row[1], row[2]) for row in dbrows]
def users_from_rows(dbrows) -> list:
"""DB 레코드에서 사용자 생성"""
return [
User(user_id, first_name, last_name)
for (user_id, first_name, last_name) in dbrows
]
**를 키워드 인자에 사용할 수 있다.
딕셔너리에 **를 사용하여 함수에 전달하면 파라미터의 이름으로 키를 사용하고, 파라미터의 값으로 딕셔너리의 값을 사용한다.
function(**{"key": "value"})
function(key="value")
둘은 서로 동일한 의미를 갖는다.
반대로 **로 시작하는 파라미터를 함수에 사용하면 키워드 제공 인자들이 사전으로 패킹된다.
def function(**kwargs):
print(kwargs)
function(key="value")
{'key': 'value'}
너무 많은 인자를 사용하는 함수나 메소드의 해결할 방법
- 구체화
- 전달하는 모든 인자를 포함하는 새로운 객체 생성
- 가변 인자나 키워드 인자 같은 특정 기능을 사용하여 동적 서명을 가진 함수를 만든다.
- 파이썬스럽지만 남용은 금물
- 매우 동적이어서 유지보수가 어려움
- 함수 서명의 인수가 많을수록 호출자 함수와 밀접하게 결합될 가능성이 커진다.
- 함수가 보다 일반적인 인터페이스를 제공하고 더 높은 수준의 추상화로 작업할 수 있다면 코드 재사용성이 높아진다.
- 클래스의 __init__ 메소드를 포함한 모든 종류의 함수와 객체 메소드에 적용된다.
많은 파라미터를 사용하는 함수의 리팩토링 방법
- 공통 객체에 파라미터 대부분이 포함된 경우
- 가장 쉬움
- 아래의 경우 그냥 request를 파라미터로 전달하면 해결
- 함수는 전달받은 객체를 변경해서는 안 된다.
track_request(request.headers, request.ip_addr, request.request_id)
- 파라미터 그룹핑
- 컨테이너처럼 하나의 객체에 파라미터를 담는다.
- 추상화
- 함수의 서명을 변경하여 다양한 인자를 허용
- 최후의 수단
- 인자가 많은데 *args 또느 **kwargs를 사용하면 더 이해하기 어려울 수도 있다.
*args 또느 **kwargs 사용 시
- 장점
- 함수가 융통성 있고 적응력이 좋다.
- 단점
- 서명을 잃어버린다.
- 가독성을 거의 상실한다.
- 매우 좋은 docstring을 만들어야 정확한 동작을 알 수 있다.
좋은 소프트웨어 디자인
- 소프트웨어 엔지니어링의 우수 사례를 따름
- 언어의 기능이 제공하는 대부분의 장점 활용
- 모듈, 클래스 또는 함수를 변경하면 수정한 컴포넌트가 외부 세계에 영향을 미치지 않아야 한다.
- 불가능하다고 해도 가능한 한 영향을 최소화하려고 시도해야 한다.
- 소프트웨어의 런타임 구조 측면에서 직교성은 변경을 내부 문제로 만드는 것이라고 할 수 있다.
예시
def calculate_price(base_price: float, tax: float, discount: float) -> float:
return (base_price * (1 + tax)) * (1 - discount)
def show_price(price: float) -> str:
return "$ {0:,.2f}".format(price)
def str_final_price(base_price: float, tax: float, discount: float, fmt_function=str) -> str:
return fmt_function(calculate_price(base_price, tax, discount))
- 위쪽의 두 함수는 독립성을 가져 하나를 변경해도 다른 하나는 변경되지 않는다.
- 마지막 함수는 아무것도 전달하지 않으면 문자열 변환을 기본 표현 함수로 사용하고 사용자 정의 함수를 전달하면 해당 함수를 사용해 문자열을 포맷한다.
- 그러나 show_price의 변경 사항은 calculate_price에 영향을 미치지 않는다.
- 어느 함수를 변경해도 나머지 함수가 그대로라서 편하게 변경할 수 있다.
str_final_price(10, 0.2, 0.5)
'6.0'
str_final_price(1000, 0.2, 0.1, fmt_function=show_price)
'$ 1,080.00'
코드의 두 부분이 독립적이라는 것은 다른 부분에 영향을 주지 않고 변경할 수 있다는 뜻
- 이는 변경된 부분의 단위 테스트가 나머지 단위 테스트와도 독립적이라는 뜻
- 코드를 구조화하는 방법은 팀의 작업 효율성과 유지 보수성에 영향을 미친다.
- 여러 정의가 들어있는 큰 파일을 만드는 것은 좋지 않다.
- 유사한 컴포넌트끼리 정리하여 구조화해야 한다.
대용량 파일을 작은 파일로 나누기
- 코드의 여러 부분이 해당 파일의 정의에 종속되어 있어도 전체적인 호환성을 유지하면서 패키지로 나눌 수 있다.
- __init__.py 파일을 가진 새 디렉토리를 만드는 것으로 해결 가능하다.
- 이러면 파이썬 패키지가 만들어지고, 이 파일과 함께 특정 정의를 포함하는 여러 파일을 생성한다.
- 이때는 각각의 기준에 맞춰 보다 적은 클래스와 함수를 갖게 된다.
- 그 다음 __init__.py 파일에 다른 파일에 있던 모든 정의를 가져옴으로써 호환성도 보장할 수 있다.
- 뿐만 아니라 이러한 정의는 모듈의 __all__ 변수에 익스포트 가능하도록 표시할 수도 있다.
위 방법의 장점
- 각 파일을 탐색하고 검색이 쉽다.
- 모듈을 임포트할 때 구문을 분석하고 메모리에 로드할 객체가 줄어든다.
- 의존성이 줄었기 때문에 더 적은 모듈만 가져오면 된다.
- 프로젝트를 위한 컨벤션을 갖는데 도움이 된다.
from myproject.constants import CONNECTION_TIMEOUT
이렇게 프로젝트에서 사용할 상수 값을 저장할 특정한 파일을 만들고 임포트하면 끝이다.
정보를 중앙화하는 것의 장점
- 코드 재사용 쉬움
- 실수로 인한 중복 회피
SOLID 원칙
- S: 단일 책임 원칙
- O: 개방/폐쇄의 원칙
- L: 리스코프 치환 원칙
- I: 인터페이스 분리 원칙
- D: 의존성 역전 원칙
- 소프트웨어 컴포넌트가 단 하나의 책임을 져야한다는 원칙
- 클래스가 유일한 책임이 있다는 것은 하나의 구체적인 일을 담당한다는 것을 의미
SRP를 준수하지 않은 디자인
class SystmemMonitor:
def load_activity(self):
"""소스에서 처리할 이벤트를 가져오기"""
def identify_events(self):
"""가져온 데이터를 파싱하여 도메인 객체 이벤트로 변환"""
def stream_events(self):
"""파싱한 이벤트를 외부 에이전트로 전송"""
문제점
- 독립적인 동작을 하는 메소드를 하나의 인터페이스에 정의했다는 것
이 예제에서 각 메소드는 클래스의 책임을 대표하고, 각각의 책임마다 수정 사유가 발생한다.
외부 요소에 의한 영향을 최소화 하고 싶을 때는 보다 작고 응집력 있는 추상화를 하면 된다.
모든 메소드를 다른 클래스로 분리하여 각 클래스마다 단일 책임을 갖게한다.
솔루션 수정
- 각자의 책임을 가진 여러 객체로 분할
- 객체들과 협력하여 동일한 기능 수행
- 각각의 객체들은 특정한 기능을 캡슐화
결과
AlertSystem - run()
- ActivityReader - load()
- SystemMonitor - identify_event()
- Output - stream()
각 클래스가 딱 하나의 메소드를 가져야 한다는 것은 아니다.
처리해야 할 로직이 같은 경우 여러 메소드를 가질 수 있다.
- 모듈이 개방되어 있으면서도 폐쇄되어야 한다는 원칙
- 클래스를 디자인할 때는 유지보수가 쉽도록 로직을 캡슐화하여 확장에는 개방, 수정에는 폐쇄되도록 한다.
예제
- 다른 시스템에서 발생하는 이벤트를 분류하는 기능을 가지고 있음
- 각 컴포넌트는 수집한 데이터를 기반으로 어떤 타입의 이벤트인지 정확히 분류 해야 함
- 단순함을 위해 데이터는 딕셔너리 형태로 저장되어 있고 로그나 쿼리 등의 방법으로 이미 데이터를 수집했다고 가정
- 이 데이터를 기반으로 고유한 계층구조를 가진 다른 이벤트로 분류
class Event:
def __init__(self, raw_data):
self.raw_data = raw_data
class UnknownEvent(Event):
"""데이터만으로 식별할 수 없는 이벤트"""
class LoginEvent(Event):
"""로그인 사용자에 의한 이벤트"""
class LogoutEvent(Event):
"""로그아웃 사용자에 의한 이벤트"""
class SystemMonitor:
"""시스템에서 발생한 이벤트 분류"""
def __init__(self, event_data):
self.event_data = event_data
def identify_event(self):
if (
self.event_data["before"]["session"] == 0
and self.event_data["after"]["session"] == 1
):
return LoginEvent(self.event_data)
elif (
self.event_data["before"]["session"] == 0
and self.event_data["after"]["session"] == 1
):
return LogoutEvent(self.event_data)
return UnknownEvent(self.event_data)
다음과 같이 동작
l1 = SystemMonitor({"before": {"session": 0}, "after": {"session": 1}})
print(l1.identify_event().__class__.__name__)
LoginEvent
이 디자인의 문제점
- 이벤트 유형을 결정하는 논리가 일체형으로 중앙 집중화 됨
- 지원하려는 이벤트가 늘어날수록 메소드도 커져 결국 매우 큰 메소드가 될 수 있다.
- 수정을 위해 닫히지 않음
- 새로운 유형의 이벤트를 시스템에 추가할 때마다 메소드를 수정해야 함
위 예제의 문제점
- SystemMonitor 클래스가 분류하려는 구체 클래스와 직접 상호 작용한다는 점
- 추상화 필요
대안
- SystemMonitor 클래스를 추상적인 이벤트와 협력하도록 변경
- 이벤트에 대응하는 개별 로직은 각 이벤트 클래스에 위임
- 각각의 이벤트에 다형성을 가진 새로운 메소드 추가
- 전달되는 데이터가 해당 클래스의 타입과 일치하는지 판단하는 역할
- 기존 분류 로직을 수정하여 이 메소드를 사용해 전체 이벤트를 돌면서 검사하도록 한다.
class Event:
def __init__(self, raw_data):
self.raw_data = raw_data
@staticmethod
def meets_condition(event_data: dict):
return False
class UnknownEvent(Event):
"""데이터만으로 식별할 수 없는 이벤트"""
class LoginEvent(Event):
@staticmethod
def meets_condition(event_data: dict):
return (
event_data["before"]["session"] == 0
and event_data["after"]["session"] == 1
)
class LogoutEvent(Event):
@staticmethod
def meets_condition(event_data: dict):
return (
event_data["before"]["session"] == 1
and event_data["after"]["session"] == 0
)
class SystemMonitor:
"""시스템에서 발생한 이벤트 분류"""
def __init__(self, event_data):
self.event_data = event_data
def identify_event(self):
for event_cls in Event.__subclasses__():
try:
if event_cls.meets_condition(self.event_data):
return event_cls(self.event_data)
except KeyError:
continue
return UnknownEvent(self.event_data)
상호 작용이 추상화를 통해 이뤄지고 있음
분류 메소드는 이제 특정 이벤트 타입 대신에 일반적인 인터페이스를 따르는 제네릭 이벤트와 동작한다.
이 인터페이스를 따르는 제네릭들은 모두 meets_condition 메소드를 구현하여 다형성을 보장한다.
__subclasses__() 메소드를 사용해 이벤트 유형을 찾는 것에 주목
- 새로운 유형의 이벤트를 지원하려면 단지 Event 클래스를 상속 받고, meets_condition() 메소드를 구현하기만 하면 된다.
확장 가능함을 증명
- 사용자 트랜잭션에 대응하는 이벤트 추가 지원
class Event:
def __init__(self, raw_data):
self.raw_data = raw_data
@staticmethod
def meets_condition(event_data: dict):
return False
class UnknownEvent(Event):
"""데이터만으로 식별할 수 없는 이벤트"""
class LoginEvent(Event):
@staticmethod
def meets_condition(event_data: dict):
return (
event_data["before"]["session"] == 0
and event_data["after"]["session"] == 1
)
class LogoutEvent(Event):
@staticmethod
def meets_condition(event_data: dict):
return (
event_data["before"]["session"] == 1
and event_data["after"]["session"] == 0
)
class TransactionEvent(Event):
"""시스템에서 발생한 트랜잭션 이벤트"""
@staticmethod
def meets_condition(event_data: dict):
return event_data["after"].get("transaction") is not None
class SystemMonitor:
"""시스템에서 발생한 이벤트 분류"""
def __init__(self, event_data):
self.event_data = event_data
def identify_event(self):
for event_cls in Event.__subclasses__():
try:
if event_cls.meets_condition(self.event_data):
return event_cls(self.event_data)
except KeyError:
continue
return UnknownEvent(self.event_data)
TransactionEvent라는 새로운 클래스를 추가하는 것만으로 기존 코드가 잘 동작한다.
identify_event() 메소드는 전혀 수정하지 않은 것으로 이 메소드가 새로운 유형의 이벤트에 대해서 폐쇄되어 있다고 할 수 있다.
반대로 Event 클래스는 필요할 때마다 새로운 유형의 이벤트를 추가할 수 있는 것으로 이벤트는 새로운 타입의 확장에 대해 개방되어 있다고 할 수 있다.
- 이 원칙은 다형성의 효과적인 사용과 밀접하게 관련되어 있다.
- 다형성을 따르는 형태의 계약을 만들고 모델을 쉽게 확장할 수 있는 일반적인 구조로 디자인 하는 것
- 유지보수성에 대한 문제를 해결
- 추상화에 대해서 적절한 폐쇄가 필요