Python Dataclass 개념 및 장점 - 좋은 코드를 위한 습관

# 목적

업무를 하면서 Dataclass를 컨벤션 중 하나로 사용하고 있었는데, 기존 개발자 분들께서 Dataclass를 왜 사용하셨는지를 이해하는 과정이 필요했다.

 

이번 포스팅에서 Dataclass의 개념과 장점에 대해서 공부한 내용을 정리하고 공유하고자 한다.

 

현재 Python 3.7 환경을 사용하고 있어, 3.7 버전을 기준으로 정리했다.


# Dataclasses 패키지  

Dataclass는 Dataclasses 모듈에서 제공하는 기능이다.

 

Dataclasses 모듈은 3.7 버전에서 공식적으로 추가되고 3.12 버전에서도 관리되는 패키지이다.

 

Dataclasses 모듈은 Dataclass 데코레이터 및 함수를 제공하여, 클래스 선언 시 __init__, __repr__, __eq__ 등의 메소드를 클래스에 자동으로 추가해준다.

 

이를 통해 별도로 해당 메소드들을 오버라이딩하지 않는다면,  클래스 작성 시 간단하게 멤버 변수만 선언해도 __init__, __repr__, __eq__ 메소드를 사용할 수 있다.

@dataclass
class C:
    ...

@dataclass()
class C:
    ...

@dataclass(init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False, ...)
class C:
   ...

위와 같은 방식으로 dataclass 데코레이터를 사용하여 특수 메소드를 클래스 C에 추가하여 사용할 수 있다.

위 세가지 선언은 동일한 클래스를 생성한다.

 

세 번째 선언 방식에서 dataclass 데코레이터에 정의된 매개변수와 기본 값을 확인할 수 있다.

from dataclasses import dataclass

@dataclass
class InventoryItem:
    """Class for keeping track of an item in inventory."""
    name: str
    unit_price: float
    quantity_on_hand: int = 0

    def total_cost(self) -> float:
        return self.unit_price * self.quantity_on_hand

위와 같이 InventoryItem 클래스를 Dataclass 데코레이터와 함께 선언하게 된다면, InventoryItem 클래스에는 아래의 생성자가 추가되고 사용된다.

def __init__(self, name: str, unit_price: float, quantity_on_hand: int = 0):
    self.name = name
    self.unit_price = unit_price
    self.quantity_on_hand = quantity_on_hand

굉장히 매력적이라고 생각되는 점은 멤버 변수에서 사용했던 Type Annotaion을 생성자에서 자동으로 사용이 가능하단 것이다.

 

협업과 유지보수를 위해 클래스를 작성하는 과정에서 클래스 멤버 변수의 타입 힌트를 그대로 사용하게 됨으로써, 동적 타입 언어인 파이썬을 보완하고 생산성을 높이고 잘못된 변수명을 사용하는 것을 사전에 방지할 수 있다.


dataclass()의 매개변수 

dataclass 데코레이터의 매개변수가 갖는 의미를 살펴본다.

init - 기본값: True

True일 경우 __init__() 메소드가 생성된다. 

클래스에서 __init__() 메소드를 정의했으면 이 매개변수는 무시된다.

repr - 기본값: True

True일 경우 __repr__() 메소드가 생성된다. 생성된 repr 문자열은 클래스 이름과 각 필드의 이름과 동일한 값을 갖는다.

각 필드는 클래스에 정의된 순서대로 표시된다. repr에서 제외하도록 표시된 필드는 포함되지 않는다.

클래스에서 __repr__() 메소드를 정의했으면 이 매개변수는 무시된다.

eq - 기본값: True

True일 경우 __eq__() 메소드가 생성된다.

이 메소드는 클래스의 필드들을 튜플인 것처럼 순서대로 비교하여 인스턴스 간의 일치 연산을 사용할 수 있다. 비교되는 두 인스턴스는 같은 타입이어야한다.

클래스에서 __eq__() 메소드를 정의했으면 이 매개변수는 무시된다.

order - 기본값: False

True일 경우 __lt__(), __le__(), __gt__(), __ge__() 메서드가 생성된다.

이 메소드들은 클래스의 필드들을 튜플인 것처럼 순서대로 비교한다. 비교되는 두 인스턴스는 같은 형이어야한다.

`order` 가 참이고 `eq` 가 거짓이면 ValueError를 발생시킨다.

unsafe_hash - 기본값: False

False일 경우 eq와 frozen의 설정에 따라 __hash__() 메소드가 생성되고, True일 경우 __hash__() 메소드를 생성한다. True일 경우 frozen과 eq의 설정에 따라 불변성이 보장되지 않을 수 있다.

__hash__()는 내장 hash()를 사용하며, 딕셔너리와 집합 같은 해시 컬렉션에 객체가 추가될 때 사용된다. __hash__() 메소드가 존재한다는 것은 클래스의 인스턴스가 불변이라는 것을 의미한다.

기본적으로 dataclass는 안전하지 않다면 __hash__() 메소드를 묵시적으로 추가하지 않는다. 기존에 명시적으로 정의된 __hash__() 메소드를 추가하거나 변경하지 않는다. 

이 매개변수는 설명이 복잡해서 간단히 요약한다.
- hash 함수를 사용하는 상황은 빠르게 무언가를 찾기 위함이다. 따라서 Hash 값은 Unique해야하는데, 동일한 인스턴스가 여러개 존재하거나 비교 연산이 불가능한 상황에선 hash 함수를 사용하기에 적절하지 않은 상황이다.
- 즉, 객체의 해시 값이 변하지 않도록 보장하기 위해 객체는 immutable해야하는데, 이를 유연하게 사용할 수 있도록 상황에 따라 객체의 해시값을 반환할 수 있게 설계되었다.

frozen - 기본값: False

True일 경우 필드에 값을 대입하면 예외를 발생시킨다. 이는 읽기 전용 인스턴스처럼 생성한 인스턴스를 불변하게 유지한다.

만약, 이 값이 참일 때, __setattr__() 또는 __delattr__() 메소드가 클래스에 정의되어 있을 경우 TypeError를 발생한다.

 

3.10버전 이후로는 match_args, kw_only 등의 매개변수가 추가되었다.


Dataclass의 필드

Dataclass 데코레이터를 사용하여 클래스를 정의할 때 멤버 변수는 Python 문법대로 사용하면 된다.

@dataclass
class C:
	a: int
	b: int = 0

C 클래스는 다음과 같은 생성자를 갖는데 멤버 변수 선언 순서대로 매개변수가 작성되기 때문에,

클래스의 멤버 변수 선언 시 Python 매개변수 기본값 지정 문법을 따라야한다.

def __init__(self, a: int, b: int = 0):

 

일반적인 사용에서는 다른 기능은 필요하지 않지만, 필드별로 추가 정보가 필요한 경우 사용할 수 있는 데이터 클래스의 기능이 존재한다. 
추가 정보에 대한 필요성을 충족시키기 위해, 기본 필드 값을 제공된 field() 함수 호출로 변경할 수 있다.

default: 선언될 경우, 이 필드의 기본값이 된다. field 호출 자체가 기본값을 지정하는 위치에 사용되기 때문에 필요하다.
default_factory: 선언될 경우, 이 필드의 기본 값이 필요할 때 호출되는 인자가 없는 Callable 타입이어야 한다. 여러 용도 중에서 가변 기본값을 갖는 필드를 지정할 때 사용될 수 있다. 

default와 default_factory를 함께 지정할 수는 없다.
init: 참일 경우, 이 필드는 생성된 __init__() 메소드의 매개변수에 포함된다.
repr: 참일 경우, 이 필드는 생성된 __repr__() 메소드의 매개변수에 포함된다.
compare: 참일 경우, 이 필드는 생성된 비교 메소드의 매개변수에 포함된다.
hash: 이는 bool 또는 None일 수 있다. 참일 경우, 이 필드는 생성된 __hash__() 메소드에 포함된다. None이면, compare의 값을 사용한다. 필드가 비교에 사용되면 해시 처리 과정에서 고려되어야 한다. 이 값을 None 이외의 값으로 설정하는 것은 권장되지 않는다.
metadata: 매핑이나 None이 될 수 있다. None은 빈 딕셔너리로 취급된다. 이 값는 MappingProxyType()으로 감싸져서 읽기 전용으로 만들어지고, Field 객체에 노출된다. 데이터 클래스에서는 전혀 사용되지 않으며, 제 삼자 확장 매커니즘으로 제공된다.

 

필드의 기본 값이 field() 호출로 지정되면, 이 필드의 클래스 속성은 할당한 default 값으로 대체된다. 

@dataclass
class C:
    mylist: List[int] = field(default_factory=list)

c = C()
c.mylist += [1, 2, 3]

C 클래스의 mylist 멤버 변수를 리스트로 지정하고 사용할 수 있게 된다.

객체에는 빈 리스트가 할당되어 있다.


dataclasses.fields()

데이터 클래스의 필드들을 정의하는 Field 객체의 튜플들을 반환한다. 데이터 클래스나 데이터 클래스의 인스턴스를 매개변수로 받을 수 있다.
데이터 클래스나 인스턴스를 전달하지 않으면 TypeError를 반환한다.

from dataclasses import fields

...

print(fields(c))
print(fields(C))

dataclaess.asdict()

데이터 클래스 인스턴스를 딕셔너리로 변환한다. 팩토리 함수 dict_factory를 사용한다.
각 데이터 클래스는 각 필드를 name: value 쌍을 갖는 딕셔너리로 변환된다.

데이터 클래스, 딕셔너리, 리스트 및 튜플들을 재귀적으로 변환된다.

from dataclasses import dataclass, asdict
from typing import List

@dataclass
class Point:
     x: int
     y: int

@dataclass
class C:
     mylist: List[Point]

p = Point(10, 20)
print(asdict(p))

c = C([Point(0, 0), Point(10, 4)])
print(asdict(c))

인스턴스가 데이터 클래스에 속하지 않은 경우 Type Error를 반환한다.


make_dataclass()

클래스 선언 없이 데이터 클래스를 생성할 수 있다.
권장되는 방법이 아니기 때문에 아래의 예시만 확인한다.

C = make_dataclass('C',
                   [('x', int),
                     'y',
                    ('z', int, field(default=5))],
                   namespace={'add_one': lambda self: self.x + 1})

위 C 클래스는 아래와 동일하다.

@dataclass
class C:
    x: int
    y: 'typing.Any'
    z: int = 5

    def add_one(self):
        return self.x + 1

# 정리

Python의 기본 모듈 중 dataclasses 모듈에 대해 정리하며 왜 기존 개발자분들이 dataclass를 사용한 이유를 확인할 수 있었다.

 

dataclass를 사용함으로써 클래스를 선언할 때 간단하게 생성, 표현, 비교 등의 메소드를 추가할 수 있다. 이를 통해 간결한 코드 작성이 가능해져 생산성을 높일 수 있다.


# 회고: 좋은 코드를 위한 습관

(간만의 회고)

 

입사하기 이전에는 아무 생각없이 사용해왔었는데, 코드 리뷰를 받으면서 내가 작성한 코드는 협업과 유지보수를 고려하지 않았다는 것을 알게되었다.

 

Key: Value 형식의 변수를 사용할 때, Dictionary 타입이 아닌 객체를 통한 변수 사용을 권장받았고, 특히 dataclass를 적용해서 사용하는 것을 추천하셨다.

 

IDE에서 기본적으로 제공하는 Linting 기능을 같이 강조해주셨는데, Linting에서 Error 메시지 없이 코드만 작성해도 좋은 코드가 작성된다고 말씀해주셨다. (Variable & Return Type Annotation을 작성하는 것은 당연한 것)

 

사실 코드를 작성할 때 VSCode나 Pycharm에서 노란줄로 경고를 뱉어줘도 크게 신경쓰지 않았었는데, 이 점 또한 보완할 수 있는 계기가 되었다.

 

단순히 Dictionary 타입으로 변수를 사용하면, 어떤 값을 담고 있는지 파악하기 위해선 코드를 일일히 파악할 수 밖에 없다.

 

따라서, 코드를 작성하면서 변수 명을 잘못 쓰거나 타입을 잘못 지정하는 실수를 범할 가능성이 높아진다.

 

이러한 Dictionary 변수를 클래스의 멤버 변수로 선언하여 사용하게 되면 똑똑한 IDE는 이 변수의 타입과 변수명을 기억하고 추천해주기 때문에 앞서 말한 실수가 발생할 확률이 없어진다.

 

이를 지킨 코드와 지키지 않은 코드의 차이는 사소할 수 있지만, 이러한 간극이 좋은 코드와 나쁜 코드를 결정할 수 있는 요인 중 하나라고 생각된다.

 

5월달은 별도의 일정 때문에 별도로 공부도 못했었는데, 책도 좀 읽으면서 좋은 개발자가 되기 위한 기초를 쌓아가는 시간을 가져보려고 한다.

 

최근 개발바닥 유튜브 채널을 많이 보고 있는데, 여러가지 좋은 관점과 내용들을 방구석에서 들을 수 있다는 것에 감사함을 느끼고 있다.

 

Airflow랑 Java 공부도 시작해야하는데 시간을 잘 써봐야지.. 

728x90
반응형