파이썬 – 클래스를 사용하지 않기

보통 객체 지향 언어를 설명할 때, 객체 지향의 기본적인 개념으로 클래스를 언급하는 경우가 많습니다. 그리고 자바나 C++ 등의 언어에서도 어떤 현실의 문제를 해결할 때, 그에 맞는 클래스부터 설계하는 방식으로 접근하기도 하지요. 파이썬 역시 객체지향 컨셉이 주가 되는 언어이며, 당연하게도 사용자가 직접 원하는 클래스를 정의하여 사용할 수 있습니다. 실제로 파이썬의 모든 것이 객체이기도 하지요.

그럼에도 불구하고 몇몇 특수한 경우를 제외하면 사실 파이썬에서 클래스를 직접 만들어서 사용하는 방식을 그리 권장하고 싶지는 않습니다.

기본적으로 파이썬에서 임의의 객체는 실행 시점에 동적으로 속성을 제어할 수 있습니다. 예를 들어 내부 구조를 알 수 없는 커스텀 클래스로부터 생성된 객체 obj가 있다고 하면, 이 객체는 그 클래스가 foo 라는 속성을 정의했든 하지 않았든 상관없이 속성 foo를 설정할 수 있습니다.

class Foo:
  pass

>>> obj = Foo()
>>> getattr(obj, 'foo', None)
# None

>>> obj.foo = 4.7
>>> obj.foo
# 4.7

실제로 클래스에서 정의된 속성은 그 내부에 일종의 사전으로 정의됩니다. obj.foo 와 같은 문법은 실제로는 내장함수 getattr(obj, 'foo')와 비슷하게 동작하며, 실제로는 매직 메소드 중 하나인 __getattr__()을 호출합니다. 그리고 이 구현은 기본적으로 객체 내부에 숨겨진 사전으로부터 속성의 이름을 키로 주어 그 값을 액세스합니다. (만약 이때 KeyError가 발생한다면 접근할 수 없는 속성을 액세스하려는 시도이므로 AttributeError 예외를 일으키게 됩니다.)

만약 클래스에서 정의한 속성 외에 다른 속성을 가질 수 없게 만든다면 모를까, 속성을 한정할 수 없는 이상 클래스를 사용해서 데이터를 저장하는 것은 추가적인 오버헤드를 유발할 뿐, 별다른 이익을 얻지 못하는 방법으로 그리 권장할만한 방법이 아닙니다.

예를 들어 어떤 학생들의 성적을 처리하기 위해서 Student 라는 클래스를 다음과 같이 정의했다고 하겠습니다.

class Student:
  def __init__(self, math, science, art):
    self.math = math
    self.science = science
    self.art = art

그리고 일련의 학생들의 수학 성적의 평균을 구하기 위한 함수를 다음과 같이 정의해봅니다.

def avg_math(students: [Student]) -> float:
  if student:
    return sum(x.math for x in students) / len(student)
  raise ValueError("students cannot be empty")

이때, Student 라는 클래스는 단순히 특정한 학생의 과목별 성적을 저장하는 용도로 사용될 뿐, 그 이상의 의미를 가지지는 않습니다. 파이썬에는 이런 경우에 사용될 수 있는 대체 타입이 얼마든지 있고, Student라는 클래스없이도 충분히 표현될 수 있습니다. 예를 들어 다음과 같이 사전을 이용하는 방법이 있습니다.

a_score = { 'name': 'Tommy',
            'math' : 80.0,
            'science': 78.0,
            'art' : 91.3 }

모든 학생 한 명의 성적이 하나의 사전으로 표현되고, 모든 사전이 같은 키를 사용하여 개별 학생의 점수를 다룬다면, 수학 과목의 평균을 구하기 위한 함수는 다음과 같이 작성해도 잘 동작할 것입니다.

from typing import Dict, Any

def avg_math(students: Dict[str, Any]) -> float:
  if students:
    return sum(x['math'] for x in students) / len(students)
  return ValueError("students cannot be empty")

# 모든 학생의 math 필드가 존재하는 것이 보장되지 않는다면
# x.get('math', 0) 과 같이 기본값처리를 할 수 있습니다.

튜플이나 리스트를 사용하는 것도 좋은 방법입니다. 특히 튜플은 그 자체로 불변인 타입이며, 내용을 변경하거나 원소를 추가/삭제할 수 없기 때문에 해당 기능을 구현하기 위한 추가적인 오버헤드가 없으며 그만큼 더 가볍습니다.

사전으로 각 과목의 점수를 구분하기 위해 과목이름으로 된 문자열을 키로 사용할 수 있었던 반면, 튜플을 사용한다면 각 과목의 점수는 ‘순서’로 구분해야 합니다. 역시나 마찬가지로 (수학, 과학, 미술)의 순서대로 데이터를 준비하기만 한다면 평균점수를 구하기 위한 함수는 크게 달라지지 않을 겁니다.

def avg_math3(students):
  if students:
    # 각 학생의 수학점수를 [0]번째 요소로 참조한다는 차이만 존재합니다.
    return sum(x[0] for x in students) / len(students)
  raise ValueError('Stduents cannot be empty')

namedtuple

클래스 혹은 사전을 사용했을 때 공통적으로 할 수 없는 기능은, 해당 객체를 변경불가능하게 만들 수 없다는 것입니다. (새로운 속성의 추가나 삭제와는 별개로) 현실 세계의 많은 문제에서는 개별 데이터를 변경해 나가기보다는 만들어진 자료를 묶어내고 특정한 기준에 따라 분류하고 합계나 평균등의 계산을 수행하는 경우가 많습니다. 이런 경우라면 개별 개체의 내부값을 일일이 변경하는 데에는 관심이 없는 경우가 많기 때문에 가변성을 위한 오버헤드를 제거하는 것은 좋은 접근이며, 튜플을 사용하는 것이 현명한 선택지일 수 있습니다.

다만 튜플을 사용할 때, 특정한 필드의 순서를 기억하고 있는 것이 도움이 될텐데, 그렇지 않다면 필드 이름으로 각각의 필드를 접근하는 방법이 있다면 좋겠습니다.

이 때 사용할 수 있는 타입이 바로 namedtuple입니다. namedtuplecollections 패키지에 정의되어 있으며, 개별 필드를 인덱스 및 속성이름으로 접근할 수 있는 튜플의 서브타입니다. namedtuple 함수는 타입 이름과 필드 이름의 목록을 인자로 받아서 새로운 이름이있는 튜플 타입을 생성할 수 있습니다. 이렇게 생성된 튜플 타입의 생성자를 사용해서 테이터를 관리할 수 있습니다. 다음은 namedtuple 타입을 사용해서 각 학생의 시험 성적을 저장하는 Score 타입을 정의하고 사용하는 예입니다.

from collections import namedtuple

Score = namedtuple('Score', ['math', 'science', 'art'])
a = Score(**a_score)
# Score(math=80.0, science=78.0, art=91.3)

print(a[0])   # 80.0
print(a.math) # 80.0

a[0] = 100 # !! Error - Score는 튜플이므로 값을 변경할 수 없습니다.

namedtuple로부터 생성된 하위 타입의 인스턴스는 키워드인자를 통해서 생성할 수 있으므로 간단하게 사전으로부터 (혹은 키/값의 연속열들로부터) 생성할 수 있으며, 튜플과 같이 정수 인덱스를 통해서 개별 필드에 접근할 수 있습니다. 즉 이미 만들어진 사전이 있거나, 사전을 생성할 수 있는 데이터셋이 있다면 얼마든지 사용할 수 있다는 뜻입니다. 뿐만아니라 클래스로 정의한 것처럼 . 연산자를 통해서 속성이름을 적용해서 값을 참조하는 것도 가능하기 때문에 편리하게 사용할 수 있습니다.

클래스를 사용하는 것이 좋을 때

많은 경우에 데이터를 저장하기 위한 용도로 클래스를 사용하는 것은 그리 권장할만한 것이 아니라고 했습니다. 이미 튜플이나 사전과 같은 기본 타입으로 충분히 이를 대체할 수 있기 때문입니다. 그럼에도 불구하고 클래스를 사용할 필요가 있는 것은 어떤 경우일까요?

꼭 정답은 아니지만 다음과 같은 경우가 있을 수 있습니다.

  1. 저장되어 있는 데이터에 기반한 계산을 자주해야 하는 경우 (Computed property 같은 기능)
  2. 해당 타입끼리 기본 연산을 제공하고 싶은 경우

첫번째 경우는 다른 프로퍼티들로부터 어떤 값을 계산해내어야 하거나 특정한 관계를 추적해야 하는 경우가 해당되는데, 보통 이는 메소드로 작성을 하게 됩니다. 이런 경우들은 사실 외부 함수로 대체할 수 있기 때문에 클래스를 사용해야하는 강한 이유가 되기 어렵습니다.

실질적으로 메소드라고 하는 것이 다른 객체지향 언어처럼 해당 인스턴스에 강하게 바인딩되는 것도 아닙니다. 파이썬에서는 메소드처럼 생긴 함수가 호출될 때, 암묵적으로 해당 객체를 첫번째 인자로 넘겨주어, 그 객체를 메소드 내에서 참조할 수 있게 합니다. 그것이 모든 클래스의 메소드 정의에서 self 가 첫 인자로 관례로 들어가는 이유입니다.

두번째 경우는 클래스를 활용할만한 강한 이유에 해당합니다.  다음의 예는 2차원 좌표계상의 한 점을 나타내는 Point2D 클래스입니다. 이 클래스는 단순히 두 축의 좌표쌍을 저장하는 용도이지만, “약간의” 무언가가 더 필요합니다.

class Point2D:
  def __init__(self, x, y):
    self.x = x
    self.y = y
  def __add__(self, ot):
    return Point2D(self.x + ot.x, self.y + ot.y)
  def __sub__(self, ot):
    return Point2D(self.x - ot.x, self.y - ot.y)
  def __mul__(self, a):
    assert isinstance(a, int) or isinstance(a, float)
    return Point2D(self.x * a, self.y * a)
  def __abs__(self):
    return (self.x**2 + self.y**2) ** 0.5
  def __eq__(self, ot):
    return self.x == ot.x and self.y == ot.y

이 예제에서는 몇 가지 특별한 기능을 지원하도록 예약된 메소드를 구현했습니다. Point2D 클래스의 인스턴스는 +, – 연산자를 통해서 서로 더하거나 뺄 수 있고, * 연산자를 통해서 스칼라값을 곱할 수 있습니다. 그외에 == 기호로 서로 같은지를 판단하거나 abs() 함수를 통해 절대값(원점으로부터의 거리)을 알아낼 수도 있습니다. 이러한 것들은 모두 일반함수나 일반메소드를 통해서 구현 가능한 것들이지만, 만약 해당 클래스의 성격이 프로젝트에 있어서 매우 기본적인 레벨의 데이터 타입과 같은 것이라면, 몇 가지 약속된 메소드를 사용했을 때에는 여러 상황에서 아주 편리하게 사용될 수 있습니다.

그외에도 몇 가지 메소드와 값들이 서로 묶여 있으면 좋은 경우라면 클래스를 사용할 수 있는 좋은 경우가 될 수 있겠습니다. 사실 그러한 케이스를 정하는 건 언제나 상황을 가장 잘 알고 있는 본인의 몫이겠죠.

클래스 속성을 슬롯으로 한정하기

자주 쓰이지는 않는 기법이지만, 기억해두면 좋은 팁이 하나 있습니다. 클래스의 속성이 동적으로 추가될 일이 없다면 __slots__ 속성을 사용해서 클래스에서 사용될 데이터 슬롯을 고정하는 방법입니다.

이 속성이 사용된 파이썬 클래스는 내부적으로 동적인 속성 추가를 배제하고 최적화된 용량과 속성 참조 방법을 사용합니다. 따라서 수만~수십만개의 인스턴스를 만들어서 사용할 때 메모리 스루풋이 이를 사용하지 않은 경우에 대비해서 월등히 줄어들게 됩니다.

class LimitedScore:
  __slots__ = ['math', 'science', 'art']
  def __init__(self, math, science, art):
    self.math = math
    self.science = sicence
    self.art = art

l = LimitedScores(80, 90, 95)
l.history = 20 # 추가적인 속성을 정의할 수 없습니다.
# AttributeError!

요약

  • 기본적인 데이터 레코드를 다룰 때에는 사전이나 튜플 혹은 namedtuple의 사용을 우선적으로 고려한다.
  • 연산자 적용이나 별도의 접근자 커스터마이징이 요구될 때 데이터 클래스를 작성한다.
  • 데이터클래스 작성시에 __slots__ 속성을 지정하면 메모리 사용량 및 성능 면에서 유리하다.

보너스

데이터클래스 데코레이터

어떤 데이터를 필드별로 저장하는 목적으로 만들어지는 데이터클래스를 정의하는 일은 사실 상당히 번거롭습니다. 사용해야할 필드를 모두 초기화 메소드인 __init__에 정의해야하기 때문입니다. 파이썬 3.7에서부터는 이러한 귀찮은 작업을 도와줄 dataclass 데코레이터가 소개됩니다. dataclass 데코레이터는 dataclasses 모듈에 정의되어 있습니다.

데이터 클래스를 정의하기 위해서는 클래스속성처럼 클래스의 최상위에 해당 프로퍼티의 이름과 타입을 지정합니다. 그러면 자동으로 해당 속성들을 기입받도록 __init__메소드를 자동으로 생성해주는 효과를 얻습니다. @dataclass 데코레이터는 실제로는 __init__외에 __repr__, __eq__ 등의 몇 가지 기본적인 내부 메소드들도 자동으로 만들어줍니다.

위의 Student 클래스는 데이터클래스 데코레이터를 사용해서 다음과 같이 정의할 수 있습니다. 참고로 @dataclass 를 적용하는 것과 __slots__ 설정을 사용하는 것은 별개이므로, 슬롯제한을 두려면 이는 직접 명시해야 합니다.

from dataclasses import dataclass

@dataclass
class Student:
  __slots__ = ('math', 'science', 'art')
  math: float
  science: float
  art: float = 0    # 디폴트값을 명시할 수 있음