[ python ] 02. 파이썬스러운(pythonic) 코드_(4)

박찬영·2024년 3월 26일

파이썬 클린 코드

목록 보기
7/19
post-thumbnail

클린 코드

파이썬스러운 코드

시퀀스 만들기

객체에 __iter__() 메서드를 정의하지 않았지만, 반복하기를 원하는 경우를 생각해볼 수 있다.

iter() 함수는 객체에 __iter__() 정의 여부 확인 -> __getitem__ 여부 확인 -> TypeError 발생

시퀀스는 __len____getitem__을 구현하고 첫 번째 인덱스 0부터 시작하여 포함된 요소를 한 번에 하나씩 차례로 가져올 수 있어야 한다. 즉 __getitem__을 올바르게 구현하여 이러한 인덱싱이 가능하도록 주의를 기울여야 한다. 그렇지 않으면 반복이 작동하지 않게 된다.

이전에 배운 이터러블을 사용하면 메모리를 적게 사용하지만, n번째 요소를 얻기 위한 시간복잡도는 O(n)이다. 하지만 시퀀스로 구현하면 더 많은 메모리가 사용되지만 특정 요소를 가져오기 위한 인덱싱의 시간복잡도는 O(1)로 상수에 가능하다.

이전 포스팅에서 이터러블 에제 코드를 다음과 같이 바꿀 수 있다.

시퀀스 예제 코드

from datetime import date
from datetime import timedelta

class DateRangeSequence:
  def __init__(self, start_date, end_date):
    self.start_date = start_date
    self.end_date = end_date
    self._range = self._create_range()

  def _create_range(self):
    days = []
    current_day = self.start_date
    while current_day < self.end_date:
      days.append(current_day)
      current_day += timedelta(days=1)

    return days

  def __getitem__(self, day_no):
    return self._range[day_no]

  def __len__(self):
    return len(self._range)

s1 = DateRangeSequence(date(2022,1,1), date(2022,1,5))
for day in s1:
  print(day)

이터러블과 시퀀스의 구현 중 어느 것을 사용할지 결정할 때 메모리와 CPU 사이의 트레이드오프를 계산해볼 수 있다. 일반적으로 이터레이션이 더 좋은 선택이지만 모든 경우의 요건을 염두에 두어야 한다.

컨테이너 객체

컨테이너는 __contains__ 메서드를 구현한 객체로 __contains__ 메서드는 일반적으로 Boolean 값을 반환한다. 이 메서드는 파이썬에서 in 키워드가 발견될 때 호출된다.

예를 들어 다음 코드를 보자

element in container

이 코드를 파이썬은 다음과 같이 해석한다.

container.__contains__(element)

이 메서드를 잘 사용하면 코드의 가독성이 정말 높아지고 파이썬스러운 코드가 된다.

2차원 게임 지도에서 특정 위치에 표시를 해야하는 문제를 예로 살펴보자

컨테이너 객체 예제 코드

일반 코드

def mark_coordinate(grid, coord):
  if 0 <= coord.x < grid.width and 0 <= coord.y < grid.height:
    grid[coord] = MARKED 

여기서 첫 번째 if문은 상당히 난해해 보인다. 위치를 지정하기 전에 매번 if문을 통해 grid 영역 안에 있는지 검사해야한다. grid 객체 스스로 특정 좌표가 자신의 영역 안에 포함되었는지를 판단할 수 있으면 좋을 것이다.
이를 객체 지향 설계와 매직 메서드를 사용하여, Grid의 경계선을 나타내는 새로운 추상화 클래스 Boundaries를 만들어 해결해보자

class Boundaries:
  def __init__(self, width, height):
    self.width = width
    self.height = height
  def __contains__(self, coord):
    x, y = coord
    return 0 <= x < self.width and 0 <= y < self.height


class Grid:
  def __init__(self, width, height):
    self.width = width
    self.height = height
    # 컴포지션 
    self.limits = Boundaries(width, height)

  def __contains__(self, coord):
    return coord in self.limits

def mark_coordinate(grid, coord):
  if coord in grid:
    grid[coord] = MARKED

이 코드는 컴포지션 패턴을 사용해 Grid의 일부 기능을 Boundaries 클래스에 위임했다. 두 객체는 매우 응집력이 있으며, 최소한의 로직을 가지고 있다.

컴포지션? : 컴포지션은 다른 클래스의 일부 메서드를 사용하고 싶지만, 상속은 하고 싶지 않을 경우 사용한다. 사용방법은 클래스 이름을 직접 명시하여 사용하거나 클래스 내부에 사용하고 싶은 메서드를 직접 명시할 수 있다.

객체의 동적인 속성

__getattr__ 매직 메서드를 사용하면 객체가 속성에 접근하는 방법을 제어할 수 있다. 우리가 <myobject>.<myattribute> 형태로 객체의 속성에 접근하려고 하면 파이썬은 객체 인스턴스의 속성 정보를 가지고 있는 __dict__ 사전에서 <myattribute>가 있는지 검색한다. 만약 해당 속성이 있는 경우 검색된 속성 객체에 대해서 __getattribute__ 메서드를 호출한다. 객체에 찾고 있는 속성이 없는 경우, 조회하려는 속성(myattribute)의 이름을 파라미터로 하여 __getattr__ 메서드를 호출한다. 이를 호출하면 존재하지 않는 속성을 호출하려고 했을 때의 행동을 제어할 수 있다.
심지어 __dict__ 변수를 사용하면 객체의 인스턴스에 새로운 속성을 동적으로 추가할 수도 있다.

__getattr__ 메서드 예제

class DynamicAttributes:
  def __init__(self, attribute):
    self.attribute = attribute
  
  def __getattr__(self, attr):
    if attr.startswith("fallback_"):
      name = attr.replace("fallback_","")
      return f"[Fallback resolved] {name}"
    raise AttributeError(f"{self.__class__.__name__}에는 {attr} 속성이 없음.")


첫 번째 호출 예제는 객체에 정상적으로 존재하는 속성에 접근하여 그 값을 그대로 반환한 것이다.
두 번째 호출 예제는 객체에 존재하지 않는 fallback_test라는 속성에 접근하려고 했다. 이 경우 attr="fallback_test" 파라미터와 함께 __getattr__ 메서드가 호출된다. 위 코드에서 __getattr__ 메서드는 어떤 속성에 접근하려고 했었는지를 나타내는 오류 문자열을 반환한다.
세 번째 호출 예제는 fallback_new라는 새로운 속성을 추가하는 예제이다. 이 코드는 dyn.fallback_new = "new value" 처럼 작성한 것과 동일하다. 여기서는 __dict__ 메서드를 통해 직접 인스턴스의 속성을 추가했기 때문에 __getattr__ 메서드가 호출되지 않는다는 것에 유의한다.
마지막 예제는 __getattr__ 메서드는 존재하지 않는 속성에 접근하려고 할 때 호출된다. 그리고 그 존재하지 않는 속성의 이름이 fallback_으로 시작하는 경우에는 단순히 그런 속성이 없다고 안내하는 문자열을 반환한다. 그러나 나머지 이름에 대해서는 AttributeError 오류를 발생한다. 이렇게 오류를 발생시키면 에러 메시지에 어떤 속성이 없었는지 알려줌으로써 일관성을 유지할 수 있을 뿐만 아니라, getattr 함수에서도 활용할 수 있다. getattr 함수는 이렇게 오류가 발생하는 경우 세 번째 파라미터에서 지정한 기본값을 사용한다.

__getattr__처럼 동적으로 변하는 속성에 대한 메서드를 작성하는 경우, 존재하지 않는 속성에 접근하려고 하면 AttributeError를 발생시키도록 하자

__getattr__ 매직 메서드는 많은 상황에서 유용하게 쓰일 수 있다. 한 예로 다른 객체에 대한 proxy 역할을 수행할 수 있다. 예를 들어, 컴포지션을 통해 기존 객체의 위에서 동작하는 wrapper 객체를 만든다고 생각해보자. 이런 경우 기존 객체에서 가져오려는 메서드를 wrapper 객체에 그대로 중복해서 복사하는 대신에, __getattr__ 메서드로 내부적으로 같은 이름의 메서드를 호출하도록 하면 쉽게 위임이 가능하다.

또 다른 예는 동적으로 계산되는 속성이 필요한 경우이다. 중복 코드가 많이 발생하거나, 보일러플레이트 코드가 많은 경우 __getattr__ 메서드가 좋은 선택일 수 있다.

보일러플레이트 코드? : 수정 없이 매번 사용하는 코드

__getattr__ 메서드를 남용하면 코드의 가독성이 떨어지므로 필요한 경우를 잘 판단하여 사용해야한다. 명시적으로 선언하지 않은 속성이 많아지면 코드를 이해하기가 어려워진다. 이 메서드를 사용할 때에는 코드의 간결성과 유지관리 비용의 트레이드오프를 고려해야한다.

호출형 객체(callable)

함수처럼 동작하는 객체를 만들면 편리한 경우가 있다. 객체를 일반 함수처럼 호출하면 __call__ 매직 메서드가 호출된다. 이때 객체 호출 시 사용된 모든 파라미터는 __call__ 메서드에 그대로 전달된다.

호출 가능한 형태의 함수가 아니라 객체를 사용하는 가장 큰 장점은 객체에 상태를 저장할 수 있기 때문에 호출이 일어날 때 알맞은 정보를 저장하고 나중에 활용할 수 있다는 점이다. 즉, 어떤 기능을 호출할 때마다 관리해야 하는 상태가 있다면 따로 상태를 관리하는 함수를 만드는 것보다 호출형 객체를 사용하는 것이 훨씬 편리하다

파이썬은 object(*args, **kwargs) 같은 구문으로 생성한 객체를 object.__call__(*args, **kwargs) 형태로 변환한다. 이 메서드는 객체를 파라미터가 있는 함수처럼 사용하거나 정보를 기억하는 함수처럼 사용할 경우 유용하다.

__call__ 메서드 사용 예시

동일한 파라미터 값으로 몇 번이나 호출되었는지를 카운트하는 예시 코드이다.

from collections import defaultdict

class CallCount:
  def __init__(self):
    self._counts = defaultdict(int)

  def __call__(self,argument):
    self._counts[argument] += 1
    return self._counts[argument]

cc = CallCount()

호출 결과를 확인하면 다음과 같다.

매직 메서드 요약

사용 예 매직 메서드 비고
obj[key]
obj[i:j]
obj[i:j:k]
__getitem__(key) 첨자형(subscriptable)객체
with obj: ... __enter__/__exit__ 컨텍스트 관리자
for i in obj:... __iter__/__next__
__len__/__getitem__
이터러블 객체
시퀀스
obj.attribute __getattr__ 동적 속성 조회
obj(*args, **kwargs) __call__(*args,**kwargs) 호출형(callable) 객체

이러한 매직 메서드를 올바르게 구현하고 같이 구현해야 하는 조합이 무엇인지 확인하는 가장 좋은 방법은 collections.abs 모듈에서 정의된 추상 클래스를 상속하는 것이다. 이 모듈에 포함된 인터페이스는 구현해야만 하는 메서드 목록을 제공하므로, 올바르게 동작하는 클래스를 쉽게 만들 수 있도록 도와준다.

지금까지 파이썬의 독특한 문법을 활용한

  • 컨텍스트 관리자
  • 호출형 객체
  • 사용자 정의 시퀀스
    등의 기능들을 살펴볼 수 있었다.

파이썬에서 유의할 점

언어의 주요 기능을 이해하는 것 이외에도 흔히 발생하는 잠재적인 문제를 피할 수 있는 관용적인 코드를 작성하는 것도 중요하다. 여기에서는 방어코드를 작성하지 않으면 오랜 시간 디버깅하는데 고생할 수 있는 일반적인 이슈들을 공부한다.

작업 중인 코드에서 이러한 코드를 발견하면 제안된 방식으로 리팩토링을 해보자

변경 가능한(mutable) 파라미터의 기본 값

변경 가능한 객체를 함수의 기본 인자로 사용하면 안 된다. 이렇게 사용하면 기대와 다른 결과를 얻을 수 있다.

잘못된 예시

def wrong_user_display(user_metadata: dict={"name":"John","age":30}):
  name = user_metadata.pop("name")
  age = user_metadata.pop("age")

  return f"{name} ({age})"

위 함수는 변경 가능한 기본 값을 사용한 것 외에도 함수 본문에서 수정 가능한 객체의 값을 직접 수정하여 부작용이 발생한다.
실제로 이 함수는 인자를 사용하지 않고 호출한 경우 처음에만 정상 작동한다. 그 다음부터는 명시적으로 user_metadata를 지정해야하고 그렇지 않으면 KeyError가 발생한다.

이유는 함수 정의에서 user_metadata의 기본값으로 딕셔너리를 사용했는데, 실제로 이 딕셔너리는 한번만 생성된다. 파이썬 인터프리터는 함수의 시그니처에서 딕셔너리를 기본값으로 하는 코드를 발견하면 해당 파라미터에 딕셔너리를 생성하여 할당한다. 이렇게 딕셔너리는 딱 한번만 생성되며 프로그램이 종료될 때까지 모든 객체의 인스턴스는 같은 기본 값을 참조한다. 또한 이 예제에서는 프로그램이 종료될 때까지 공유될 초기값용 딕셔너리에 대해서 pop 메서드로 "name" 과 "age" 정보를 제거해버렸다.

수정 예시

def user_display(user_metadata: dict = None):
  user_metadata = user_metadata or {"name":"John","age":30}
  name = user_metadata.pop("name")
  age = user_metadata.pop("age")

  return f"{name} ({age})"

기본 초기 값으로 None을 사용하고 함수 본문에서 기본 값을 할당하면 된다. 각 함수는 자체 스코프와 생명주기를 가지므로 None이 나타날 때마다 user_metadata를 사전에 할당한다.

내장(built-in)타입 확장

리스트, 문자열, 딕셔너리 같은 내장 타입을 확장하는 올바른 방법은 collections 모듈을 사용하는 것이다.
예를 들어 dict를 직접 상속 받아서 새로운 클래스를 만들다 보면 예상하지 못한 결과를 얻을 수 있다.

예시

특정 위치의 아이템에 접근하려고 하면 접두어와 함께 해당 위치의 값을 문자열로 반환하는 사용자 정의 리스트를 생각해보자. 다음과 같이 구현하면 오류가 숨어있다.

class BadList(list):
  def __getitem__(self,index):
    value = super().__getitem__(index)
    if index % 2 == 0:
      prefix = "짝수"
    else:
      prefix = "홀수"
    return f"[{prefix}] {value}"

BadList로 만든 숫자형 리스트의 값을 join으로 합치려고 하면 문제가 발생한다.

이유는 파이썬 내부에서 스스로 연관된 부분을 모두 찾아서 업데이트 해주지 않기 때문이다. 예를 들어 사전의 key값을 가져오는 방식을 약간 수정하고 싶어서 __getitem__메서드를 재정의 했다고 할 때, 아이템을 조회하는 모든 곳에서 나의 코드가 잘 반영이 되는 것을 확인해야만 한다.

따라서 collectionsUserList 를 상속 받도록 수정해야한다.

from collections import UserList
class goodList(UserList):
  def __getitem__(self,index):
    value = super().__getitem__(index)
    if index % 2 == 0:
      prefix = "짝수"
    else:
      prefix = "홀수"
    return f"[{prefix}] {value}"

마치며

파이썬의 다양한 메서드와 프로토콜 그리고 내부 동장 원리에 대해 공부했다. 파이썬스러운 관용적인 코드를 작성하는 가장 좋은 방법은 관용구를 따르는 것뿐만이 아니라 파이썬이 제공하는 모든 기능을 최대한 활용하는 것이다. 즉 매직 메서드, 컨텍스트 관리자, 또는 컴프리헨션, 할당 표현식을 사용하여 보다 간결한 코드를 작성함으로 보다 유지 보수하기 쉬운 코드를 작성하는 것이 중요하다.

profile
안녕하세요 박찬영입니다.

0개의 댓글