파이썬에서 데이터 클래스로 데이터 가공 하기
파이썬에서 데이터 클래스로 데이터 가공 하기

시작 하며
안녕하세요. 파이썬과 함께 데이터를 주무르고 있는 주니어 개발자 입니다.
최근 사내에서 진행 하는 프로젝트에서 MongoDB를 조회 하여 데이터를 변환 하거나, API 응답 값을 변환 하는 작업을 하고 있습니다.
이런 데이터는 당연하게 딕셔너리와 JSON을 많이 떠올리고 딕셔너리로 관리하는 방식을 많이 선택 합니다.
파이썬이 주는 장점을 살려 어떻게 데이터를 가독성 좋게 관리할 수 있을지 사용 예제를 통해 경험을 공유합니다.
이 글을 읽는 예상 독자는 파이썬에서 데이터 클래스를 처음 사용 하거나 서비스 계층에 몰려있는 코드를 분리 하고 싶은 파이썬 개발자 입니다.
데이터 클래스는 무엇일까?
“이 모듈은
__init__()나__repr__()과 같은 생성된 특수 메서드 를 사용자 정의 클래스에 자동으로 추가하는 데코레이터와 함수를 제공합니다. 원래 PEP 557 에 설명되어 있습니다.” — Python 3.7.17 Document
쉽게 생각 해 보면 기본적으로 클래스 내부에 속성 값만 설정 한다면 매직 메서드를 자동으로 생성 하여 보일러 플레이트 코드를 줄일 수 있는 장점이 있습니다.
또한, 코드 내부 구조에서 NamedTuple을 이용 하기 때문에 굳이 TypeDict를 활용한 TypeHints 방식을 이용하지 않아도 Dataclass에서 정의 된 Type을 이용 할 수도 있습니다.
example:
from dataclasses import dataclass
@dataclass
class TestData:
var_a: str
var_b: str
if __name__ == "__main__":
test_data: TestData = TestData("var_a123", "var_b123")
print(f"test data = {test_data}")
""" output
test data = TestData(var_a='var_a123', var_b='var_b123')
"""
데이터 클래스 본격적으로 다뤄보기
개인적으로 비즈니스 로직에 대한 도메인 계층을 모델링 하여 데이터 클래스에서 관리 하는 것을 선호합니다.
우선, 데이터 클래스가 다양한 도메인을 어우르는 DTO의 역할을 하기 위해 인터페이스로 설계 하여 반복되는 로직을 상속 시키는 것이 아닌 조합 방식을 이용 합니다.
상속에 대한 개인적인 의견은 반복되는 로직을 줄일 수 있지만 객체가 어떠한 행동을 숨겼는지 알기 어렵고, 구현 하고 있는 객체의 수정이 일어났을 때 부모 클래스에서 변경 사항이 일어나기 때문에 선호하지 않는 방식입니다.
데이터 클래스 인터페이스 만들기
이러한 인터페이스를 생성 할 때 디렉터리 구조를 항상 고민을 하고 있습니다.
그럼에도 가장 사용하기 편리 했던 방법은 공통 모듈에서 “base” 라는 디렉터리 내에 존재 했을 때 의미를 해석하기 수월 했습니다.
src/shared/base/data_class.py
from abc import ABC, abstractmethod
class DataClass(ABC):
@abstractmethod
def to_dict(self):
pass
@abstractmethod
def _set_attrs(self):
pass
위 인터페이스가 제공 하는 메서드는 추후에 다른 도메인 객체가 구현해야 할 메서드지만 간단하게 살펴보자면 메서드가 의도하는 바는 이렇습니다.
- to_dict: 데이터 클래스의 속성 값들을 딕셔너리로 반환
- set_attrs: 데이터 클래스에 정의된 속성 값만 할당
구현체에서 메서드 오버라이딩을 하면서 내용을 더 자세하게 풀어보겠습니다.
데이터 클래스 구현체 만들기
비즈니스 로직을 풀어 낼 도메인 객체를 추출 하여 사용합니다.
데이터 모델링에 의해 사용자는 아래와 같은 데이터를 가져야 한다고 가정 해봅시다.
- 이름
- 이메일
- 전화번호
src/domain/users/user.py
from dataclasses import dataclass
@dataclass
class User:
name: str
phone_number: str
email: str
인터페이스를 활용 하여 앞으로 들어올 데이터에서 사용자가 갖고 있는 속성에만 적용 되는 메서드와 데이터를 반환 할 수 있는 메서드를 제공 해봅시다.
from dataclasses import dataclass, asdict, fields
from shared.base.data_class import DataClass
@dataclass
class User(DataClass):
name: str
phone_number: str
email: str
def __init__(self, **kwargs):
self._set_attrs(**kwargs)
def to_dict(self, exclude=None):
return_data = asdict(self)
if exclude:
return {key: value for key, value in return_data.items() if key not in exclude}
return return_data
def _set_attrs(self, **kwargs):
attrs = self.__annotations__.keys()
default_values = {f.name: f.default for fin fields(self)}
for key in attrs:
setattr(self, key, kwargs.get(key, default_values.get(key, "")))
다시 메서드가 갖고 있는 의미를 실행 결과와 함께 살펴보겠습니다.
to_dict: 데이터 클래스의 속성 값들을 딕셔너리로 반환
user = User(
name="jhlee",
phone_number="01012341234",
email="tmjhlee@gmail.com",
)
user_data = user.to_dict()
print(f"user dataclass = {user}, type: {type(user)}")
print(f"user_data = {user_data}, type: {type(user_data)}")
"""output
user dataclass = User(name='jhlee', phone_number='01012341234', email='tmjhlee@gmail.com'), type: <class '__main__.User'>
user_data = {'name': 'jhlee', 'phone_number': '01012341234', 'email': 'tmjhlee@gmail.com'}, type: <class 'dict'>
"""
set_attrs: 데이터 클래스에 정의된 속성 값만 할당
user = User(
name="jhlee",
phone_number="01012341234",
email="tmjhlee@gmail.com",
password="strongEncrypt",
)
user_data = user.to_dict()
print(f"user dataclass = {user}, type: {type(user)}")
print(f"user_data = {user_data}, type: {type(user_data)}")
"""output
user dataclass = User(name='jhlee', phone_number='01012341234', email='tmjhlee@gmail.com'), type: <class '__main__.User'>
user_data = {'name': 'jhlee', 'phone_number': '01012341234', 'email': 'tmjhlee@gmail.com'}, type: <class 'dict'
"""
“set_attrs”에 의해 다른 데이터가 입력이 되더라도 온전히 정의 된 속성 값에만 값이 할당 되는 것을 확인할 수 있습니다.
“set_attrs”를 이용하지 않고 데이터 정의 형태보다 더 많은 데이터가 존재 했을 때 값을 입력 하는 경우 마주할 수 있는 에러 내용 입니다.
password=”strongEncrypt”
TypeError: init() got an unexpected keyword argument ‘password’
그렇다면 반대로, 값이 없는 상황이라면 어떻게 해야할까요?
데이터 클래스의 기본 값 설정하기
데이터 클래스에서 기본 값을 사용 하는 방법이 두 가지 존재 합니다.
예제 코드를 살펴보겠습니다.
from dataclasses import dataclass, asdict, field, fields
from shared.base.data_class import DataClass
@dataclass
class User(DataClass):
name: str = "jhlee"
phone_number: str = "01012341234"
email: str = "tmjhlee@gmail.com"
password: str = field(default="")
def __init__(self, **kwargs):
self._set_attrs(**kwargs)
def to_dict(self, exclude=None):
return_data = asdict(self)
if exclude:
return {
key: value
for key, value in return_data.items()
if key not in exclude
}
return return_data
def _set_attrs(self, **kwargs):
attrs = self.__annotations__.keys()
default_values = {f.name: f.default for f in fields(self)}
for key in attrs:
setattr(self, key, kwargs.get(key, default_values.get(key)))
if __name__ == "__main__":
user = User()
user_data = user.to_dict()
print(f"user dataclass = {user}, type: {type(user)}")
print(f"user_data = {user_data}, type: {type(user_data)}")
"""output
user dataclass = User(name='jhlee', phone_number='01012341234', email='tmjhlee@gmail.com', password=''), type: <class '__main__.User'>
user_data = {'name': 'jhlee', 'phone_number': '01012341234', 'email': 'tmjhlee@gmail.com', 'password': ''}, type: <class 'dict'>
"""
첫 번째는 변수 할당 식으로 선언 하는 방식입니다.
- 타입 힌트를 의무적으로 검사하지 않는 이상 타입과 변수의 값이 달라도 인스턴스화 되는 데 문제는 발생 하지 않습니다.
- 데이터클래스 속성 값의 타입을 지정 하지 않으면 “필드”로 인식하지 않습니다.
두 번째는 “field” 메서드를 이용 하여 선언 하는 방식입니다.
- field에서 기본 값과 기본 자료를 설정 할 수 있습니다. 그에 반면 “metadata”를 이용 하여 추가적인 정보를 제공 할 수도 있습니다.
“field란?”
데이터 클래스의 속성 값을 NamedTuple 형식으로 관리 하고 있습니다. 정의 된 속성에 접근 하여 선언 된 값을 가져올 수 있으며 아래와 같은 속성을 갖고 있습니다.
- name: 속성 이름
- type: 변수 지정 타입
- default: 기본 값
- default_factory: 기본 값이 객체 일 때, 기본적으로 MISSING_TYPE을 사용 하며 대부분 list, dict와 같은 자료를 기본 값으로 사용 할 때 쓰입니다. 그 외에 lambda식을 이용 하여 커스텀 할 수도 있습니다
- 기타 상태 값들(init, hash…)
Field의 메타데이터 속성 활용하기
데이터 클래스의 속성 선언 방식 중 Field를 이용 하면 메타데이터를 활용 할 수 있습니다.
저 같은 경우 보통 외부에서 입력 받는 데이터의 명칭과 데이터 모델링된 명칭이 서로 다른 경우 네이밍을 변경 시키기 위해 사용 합니다.
아래 예시 코드는 외부 API 응답 데이터가 카멜 케이스로 이루어져 있다고 가정합니다.
파이썬에서 데이터를 가공 한 뒤 결과 값이 정해진 형식과 다르게 저장 되는 것을 방지 하기 위해 메타데이터를 활용하는 예시 입니다.
from dataclasses import dataclass, asdict, fields
from shared.base.data_class import DataClass
@dataclass
class User(DataClass):
userName: str = field(default="", metadata={"rename": "user_name"})
phoneNumber: str = field(default="", metadata={"rename": "phone_number"})
emailAddress: str = field(default="", metadata={"rename": "email_address"})
userAge: int = field(default=0, metadata={"rename": "user_age"})
def __init__(self, **kwargs):
self._set_attrs(**kwargs)
def to_dict(self):
attrs = {}
for _field in fields(self):
key: str = _field.metadata.get("rename") or _field.name
attrs[key]: str = getattr(self, _field.name, None) or _field.default
return attrs
def _set_attrs(self, **kwargs):
attrs = self.__annotations__.keys()
default_values = {f.name: f.default for fin fields(self)}
for key in attrs:
setattr(self, key, kwargs.get(key, default_values.get(key, "")))
if __name__ == "__main__":
api_response_data = {
"userName": "jhlee",
"phoneNumber": "01012341234",
"emailAddress": "tmjhlee@gmail.com",
"userAge": "99",
}
user = User.from_dict(**api_response_data)
print(user.to_dict())
"""output
{'user_name': 'jhlee', 'phone_number': '01012341234', 'email_address': 'tmjhlee@gmail.com', 'user_age': '99'}
"""
fields는 NamedTuple로 된 데이터이기 때문에 각 필드에 접근이 가능 합니다.
그렇기 때문에 꼭 딕셔너리 타입이 아니어도 되며 다양한 변수를 활용 할 수 있습니다.
데이터 클래스의 기본 값과 외부 데이터를 같이 사용하기
해당 방식을 가장 많이 사용한다고 생각 합니다. 변하지 않는 값이 있는 반면, 외부에서 받는 데이터를 같이 저장 해야 하는 경우가 많은 서비스 계층에서 이뤄지기 때문이죠.
from dataclasses import dataclass, asdict, field, fields
from shared.base.data_class import DataClass
@dataclass
class User(DataClass):
name: int = "jhlee"
phone_number: str = "01012341234"
email: str = "tmjhlee@gmail.com"
password: str = field(default="")
def __init__(self, **kwargs):
self._set_attrs(**kwargs)
def to_dict(self, exclude=None):
return_data = asdict(self)
if exclude:
return {
key: value
for key, value in return_data.items()
if key not in exclude
}
return return_data
def _set_attrs(self, **kwargs):
attrs = self.__annotations__.keys()
default_values = {f.name: f.default for f in fields(self)}
for key in attrs:
setattr(self, key, kwargs.get(key, default_values.get(key)))
if __name__ == "__main__":
user = User(password="very@Strong")
user_data = user.to_dict()
print(f"user dataclass = {user}, type: {type(user)}")
print(f"user_data = {user_data}, type: {type(user_data)}")
"""output
user dataclass = User(name='jhlee', phone_number='01012341234', email='tmjhlee@gmail.com', password='very@Strong'), type: <class '__main__.User'>
user_data = {'name': 'jhlee', 'phone_number': '01012341234', 'email': 'tmjhlee@gmail.com', 'password': 'very@Strong'}, type: <class 'dict'>
"""
이렇게 특정 데이터는 외부에서 주입 받고, 내부에서 기본 값을 설정하여 객체를 관리 할 수 있습니다.
데이터 클래스가 갖는 이점은 이 것 뿐만 아닙니다. 객체지향적 코드에서 객체의 상태를 변경 시키기 위해 직접 접근하지 않고 메시지를 던지는 방식을 많이 얘기 하곤 합니다.
만약, 유저 데이터에서 나이를 추가 하고 유저의 나이가 성인인지 검사 하는 로직이 있다고 가정 하고 코드를 만들어봅시다.
from dataclasses import dataclass, asdict, field, fields
from shared.base.data_class import DataClass
@dataclass
class User(DataClass):
name: int = "jhlee"
phone_number: str = "01012341234"
email: str = "tmjhlee@gmail.com"
age: int = 0
def __init__(self, **kwargs):
self._set_attrs(**kwargs)
def is_adult(self):
return self.age > 19
def to_dict(self, exclude=None):
return_data = asdict(self)
if exclude:
return {
key: value
for key, value in return_data.items()
if key not in exclude
}
return return_data
def _set_attrs(self, **kwargs):
attrs = self.__annotations__.keys()
default_values = {f.name: f.default for f in fields(self)}
for key in attrs:
setattr(self, key, kwargs.get(key, default_values.get(key)))
if __name__ == "__main__":
user = User(age=20)
print(user.is_adult()) # result: True
이렇게 객체의 상태를 묻는 메시지를 관리 하기도 편리 합니다.
가독성 향상 시키기
마지막으로 서비스 계층에서 데이터 변환 작업을 하기 위해 JSON 데이터를 관리 할 때 “**kwargs” 를 적극적으로 사용합니다.
이 키워드 아규먼트를 DTO 인자에 넣거나 생성자 팩터리 메서드 패턴(from_dict)과 같은 메서드의 인자로 제공 하여 데이터 변환을 하게 되면 나름가독성 좋은 코드를 만들어 낼 수 있다고 생각 합니다.
from dataclasses import dataclass, asdict, field, fields
from shared.base.data_class import DataClass
class ExampleRepository:
def insert_user(self, user_dto: User):
# data 저장 로직 예시
self.save(user.to_dict())
@dataclass
class User(DataClass):
name: int = ""
phone_number: str = ""
email: str = ""
age: int = 0
def __init__(self, **kwargs):
self._set_attrs(**kwargs)
@classmethod
def from_dict(cls, **kwargs):
# 데이터 추가 가공 로직 위치
return cls(**kwargs)
def to_dict(self, exclude=None) -> Dict[str, Union[str, int]:
return_data: Dict[str, Union[str, int] = asdict(self)
if exclude:
return {
key: value
for key, value in return_data.items()
if key not in exclude
}
return return_data
def _set_attrs(self, **kwargs):
attrs: List[str] = self.__annotations__.keys()
default_values: Dict[str, Union[str, int] = {f.name: f.default for f in fields(self)}
for key in attrs:
setattr(self, key, kwargs.get(key, default_values.get(key)))
if __name__ == "__main__":
repository: ExampleRepository = ExampleRepository()
api_response_data: Dict[str, str] = {
"name": "jhlee",
"phone_number": "01012341234",
"email": "tmjhlee@gmail.com",
"age": "99",
"password": "aes256encryption",
"not_important_values": "1234",
# another many values...
}
user: User = User.from_dict(**api_response_data)
repository.insert_user(user)
마치며
외부에서 들어온 데이터를 내부에서 변환 할 때 데이터 클래스 객체를 이용 하면 서비스 계층에서 복잡한 비즈니스 로직을 캡슐화 할 수 있는 장점이 있습니다.
파이썬은 *(args), **(kwargs)를 이용 하면 메서드 시그니처에 맞게 데이터를 Unpacking 하도록 유도할 수 있습니다.
그렇기 때문에 JSON 데이터를 가공 할 때 DTO 클래스를 사용 하면 편리함을 얻을 수 있다고 생각합니다.
감사합니다.