메타데이터를 메모리에 할당하고 주입하기

안녕하세요. 저는 현재 데이터 관리 업무를 하고 있는 주니어 개발자입니다.

이번에 퓨어 파이썬으로 실시간 데이터 수신, 데이터 가공을 진행 할 때 데이터 수신부와 데이터 가공을 분리하고자 하는 요구사항을 적용 하면서 고민 해 보았던 내용입니다.

데이터를 가공 하기 위한 기초 메타데이터를 호출 하는 과정은 프로덕트 몽고 서버에 접근 하여 컬렉션에 있는 데이터와 실시간으로 수신 한 데이터의 고유 값이 존재 하는지 검사 해야 합니다.

실시간 데이터 수신량이 많기 때문에 매번 데이터베이스에 I/O를 하는 것은 옳지 않다고 생각이 되어 이런 방법으로 시도 해보고자 합니다.

실시간 데이터를 수신 한 이후 시점에서 가공 하기 위한 기초 메타데이터를 주입 하여 메모리에 할당 되어 있는 데이터와 실시간 데이터를 존재하는지 판단 하여 데이터 정보를 가져오는 것이 목적입니다.

클래스 속성 값 이용하기

  
class classproperty:  
    def __init__(self, method):  
        self.func = method  
  
    def __get__(self, instance, cls):  
        return self.func(cls)

파이썬에서 @property 라는 인스턴스 변수를 설정 할 수 있는 데코레이터가 존재합니다.

클래스를 사용하기 위해 인스턴스화를 해야 하고 이후 메서드를 호출 해야 하는데 사실 이 클래스들은 그저 dataclass 와 별반 다를 것 없이 내부 속성 값에는 데이터만 존재 할 예정입니다.

그렇다면 적절히 클래스 변수를 이용 하여 데이터를 추가 한다면 클래스 자체만으로도 내부 속성 값에 접근 할 수 있게 됩니다.

클래스 속성 값 사용하기

class BaseCollection:  
    data = None  

    @classproperty  
    def has_preprocess_method(self) -> bool:  
        preprocess_method: Optional[classmethod] = self.__dict__.get("pre_process_pk_field")  
        return isinstance(preprocess_method, classmethod)

    @classmethod  
    def pre_process_pk_field(cls, data):  
        pass
    
    @classmethod  
    def pk_field_method(cls) -> Callable:
        if cls.has_preprocess_method:
            field: Callable = lambda x: cls.pre_process_pk_field(x)  
        else:  
            field: Callable = lambda x: x[cls.pk]  
  
        return field
 
 
 class ChildCollection(BaseCollection):  
    collection_name = "test_collection"  
    pk = "_id"  
  
    @classmethod  
    def pre_process_pk_field(cls, data):  
        pk_field = str(data[cls.pk]).lstrip('0')  
        return pk_field  

상속 시킬 베이스 클래스 하나를 선언 하여 내부에는 전처리가 필요한 컬렉션인지를 구분 합니다.이후 전처리가 필요한 컬렉션만 전처리를 진행 하게끔 만들기 위해 classproperty 에서 boolean 값을 반환 하는 것을 볼 수 있습니다.

그 반환 값을 사용 하는 곳은 pk_field_method 내부에서 조건 분기를 거친 후 lambda에 전처리 함수를 사용 할지, 기존 고유 값을 데이터로 사용할지 결정하게 됩니다.

번외

self.__dict__.get("pre_process_pk_field") 라는 코드를 사용 하기 전 geattr 을 이용하여 자식 클래스에서 전처리 함수가 있는지 판단을 시도 했었는데요.

getattr 을 사용 했을 때 부모 클래스에 속성 값을 온전히 자식 클래스에 전달 되었기 때문에 자식 클래스에서 전처리 함수가 없더라도 부모 클래스에 의해 빈 함수로 속성이 있다고 하며 의도하지 않은 결과 값을 반환 했습니다.

그 후, classproperty로 생성 된 속성 값에 의해 self.__dict__ 를 사용 하게 되면 인스턴스화 되지 않은 클래스에서 본인이 갖고 있는 classmethod 와 클래스 변수를 얻을 수 있었습니다.

만일, 인스턴스화 된 상태에서 self.__dict__ 를 사용한다면 인스턴스 변수가 존재 하기 때문에 인스턴스화 되지 않은 클래스 객체에서 사용 했었던 이유가 되었습니다.

이 부분은 의도에 맞게 수정하여 적용하면 자식 클래스에서 선언 한 메서드가 존재하는지 여부를 판단할 수 있었습니다.

__dict__가 아닌 __dir__() 을 사용 했다고 하더라도 의도에 맞게 작성 되었을 것이라고 생각이 듭니다.

이 때는 해당 인스턴스 내부에 사용 할 수 있는 모든 내용들이 list 타입이 반환 됩니다. in 을 사용 하여 메서드의 존재 여부를 조회가 가능하게 됩니다.

취향에 맞게 dir 을 통해 조건 분기로 존재하는 경우 Boolean Field를 반환 해도 되고, 저와 같이 __dict__ 를 사용하여 get 으로 접근 하여 결과 값을 반환해도 됩니다. 전 get에서 Key가 없는 경우 None으로 결과 값을 준다는 것을 이용 하여 isinstance 에서 타입 검사를 하면 필요 없는 조건을 줄일 수 있다고 생각 했기 때문에 딕셔너리 타입을 결정 하였습니다.

베이스 클래스 상속하기

class TestOneCollection(BaseCollection):  
    collection_name = "test_one_collection"
    pk = "test_one_id"
  
  
class TestTwoCollection(BaseCollection):  
    collection_name = "test_two_collection"  
    pk = "test_two_id"
  
    @classmethod  
    def pre_process_pk_field(cls, data):  
        pk_field = str(data[cls.pk]).lstrip('0')  
        return pk_field  
  
  
class TestThreeCollection(BaseCollection):  
    collection_name = "test_three_collection"  
    pk = "test_three_id"  
  
  
class TestFourCollection(BaseCollection):  
    collection_name = "test_four_collection"  
    query_condition = {"test_field": "test"}  
    pk = "test_four_id"  

이렇게 베이스 클래스를 상속 받아 클래스 변수 값만 수정한다면 저희가 의도 했던 바 대로 전처리가 필요한 컬렉션, 필요하지 않은 컬렉션에 대해 유연하게 대응 할 수 있는 구조가 되었습니다.

이 소스코드 외에도 셋팅 설정이나 컬렉션에 따라 쿼리 조건을 변경 하여 베이스 클래스의 메서드 하나로 관리 할 수 있는 과정을 덧 붙혀서 코드의 이해를 돕도록 하겠습니다.

전체 코드로 내부 구조 이해하기

class BaseCollection:  
    data = None
    collection_name: str
    query_condition = {}  
    exclude = {"_id": 0}  
    pk: str
    set_field = None  
  
    @classmethod  
    def set_context_data(cls):  
        collection_data: Dict[str, Any] = cls.query_set.select_master_data_find_all(cls.query_condition, cls.exclude)  
        result: Dict[str, Dict] = cls.set_collection_name_dict(collection_data)  
  
        setattr(cls, "data", result)  
  
    @classmethod  
    def set_collection_name_dict(cls, datas: Dict[str, Union[str, int]]) -> Dict[str, Dict]:  
        collection_name_dict: Dict = dict()  
        pk_field_method: Callable = cls.pk_field_method()  
  
        for data in datas:  
            pk_field = pk_field_method(data)  
            if cls.set_field:  
                collection_name_dict[pk_field] = data[cls.set_field]  
            else:  
                collection_name_dict[pk_field] = data  
        return collection_name_dict  
  
    @classmethod  
    def pre_process_pk_field(cls, data):  
        pass  
  
    @classmethod  
    def pk_field_method(cls) -> Callable:  
        is_preprocess_method_use: bool = cls.has_preprocess_method  
        if is_preprocess_method_use:  
            field: Callable = lambda x: cls.pre_process_pk_field(x)  
        else:  
            field: Callable = lambda x: x[cls.pk]  
  
        return field  
  
    @classproperty  
    def has_preprocess_method(self) -> bool:  
        print(self.__dict__)  
        preprocess_method: Optional[classmethod] = self.__dict__.get("pre_process_pk_field")  
        return isinstance(preprocess_method, classmethod)  
  
    @classproperty  
    def query_set(self) -> QueryManager:  # note: 클래스 변수의 collection_name 을 기준으로 MongoCollection 생성
        query = QueryManager
        return query(self.collection_name)  
  
    @classmethod  
    def get_data(cls):  
        if cls.data is None:  
            cls.set_context_data()  
        return cls.data  
  
  
class TestOneCollection(BaseCollection):  
    collection_name = "test_one_collection"
    pk = "test_one_id"
  
  
class TestTwoCollection(BaseCollection):  
    collection_name = "test_two_collection"  
    pk = "test_two_id"
  
    @classmethod  
    def pre_process_pk_field(cls, data):  
        pk_field = str(data[cls.pk]).lstrip('0')  
        return pk_field  
  
  
class TestThreeCollection(BaseCollection):  
    collection_name = "test_three_collection"  
    pk = "test_three_id"
    set_field = test_three_nm"
  
  
class TestFourCollection(BaseCollection):  
    collection_name = "test_four_collection"  
    query_condition = {"test_field": "test"}  
    pk = "test_four_id"
    set_field = test_four_nm"

설명을 덧 붙혀 추가 된 부분을 보시면 set_field, pk의 값을 기준으로 실제 메모리에 할당 하는 데이터 구조를 가공을 거친 후 저장 하는 것을 볼 수 있습니다.

이렇게 실시간 데이터를 쉽게 조회 하기 위해 가공 하는 과정의 반복을 최소화 하고 확장성을 고려하여 클래스를 설계한 구조 입니다. 그렇다면 이 데이터를 어떻게 사용하면 될까요?

마스터 데이터 생성

class MasterData(metaclass=Singleton):  
    master_data_classes = {  
        "test_one_collection": TestOneCollection,  
        "test_two_collection": TestTwoCollection,  
        "test_three_collection": TestThreeCollection,  
        "test_foure_collection": TestFourCollection,  
    }  
  
    def __str__(self):  
        docs = ", ".join([i for i in self.master_data_classes])  
        return f"{docs}"  
  
    def __call__(self, *args):  
        mst_cls = self.master_data_classes.get(*args)  
  
        if mst_cls:  
            mst_data = mst_cls.get_data()  
            setattr(self, *args, mst_data)  
            return mst_data  
        else:  
            raise ValueError(f"[Invalid Value] does not exists master table > {self.__str__()}")  
  
  
if __name__ == '__main__':  
    master_data = MasterData()  
  
    print(master_data("test_one_collection"))  
    print(master_data("test_two_collection"))  
    print(master_data("test_three_collection"))  
    # print(master_data("query_mst"))  # error!

저는 마스터 데이터 객체를 모듈화 하여 실제 데이터를 가공 해야 하는 곳에 메타데이터 정보를 주입 하기 위해 객체를 생성 하였는데요. 하지만 이 때는 다른 클래스들과 달리 인스턴스화가 필요한 객체라는 것을 알 수 있습니다. 여기에 큰 의미는 없었습니다. 인스턴스화 된 클래스를 주입하는게 더 큰 관심사였을 뿐입니다.

위 소스코드에는 내용이 포함 되어 있지 않지만 실제 실무에서 적용 하기 위해 작성한 코드에는 기존 베이스 클래스의 메서드 중 set_collection_name_dict에서 반환된 데이터가 실제 필요한 데이터인데요.

이 데이터를 호출 할 때 get_item(self, collection_name, pk_value) 라는 메서드를 통해 호출을 하게 되고 이 메서드 내부는 실시간 데이터와 데이터베이스에 저장 되어 있는 메타데이터 정보를 비교 하는 로직과 데이터를 가져오는 로직이 들어가게 됩니다.

그렇다면 실제 사용 할 때는 master_data.get_item("test_one_collection", pk_value=1234567) 를 통해 실제 정보를 비교 하고 필요한 값을 가져올 수 있게 됩니다.

해당 클래스를 모듈로 사용하고, 호출되는 파이썬 코드에서 인스턴스화 된 마스터 데이터 클래스를 넘겨도 되고, 클래스만 넘겨도 되겠죠. 저는 클래스 객체를 파라미터로 넘겨서 호출 하는 코드에서 인스턴스화를 거친 후 사용 하는 방법을 사용 했습니다. 이러한 이유는 모듈에서 인스턴스화를 시키고 싶지 않았기 때문이었죠.

끝맺음

이렇게 파이썬 내부에서 데이터베이스 호출을 최소화 하며 메타데이터 정보를 통해 데이터를 가공 하는 경우일 때 메모리에 할당 한 후 주입 하는 과정을 적어봤는데요.

당연히 이 소스코드에 정답이 있는 것은 아니듯이 아직 제가 보는 시야가 매우 좁기 때문에 더 효율적인 코드는 아니라는 것 입니다.

그리고 또 많은 소스코드가 파이썬의 장고 프레임워크와 상당히 닮아 있는 것을 볼 수 있고, classproperty 또한 여기서 영감을 얻어 작성 하게 되었는데요.

오픈소스의 내부 구조를 읽고 실무에 적용 하다보니 장고는 되게 견고한 오픈소스라는 것을 알 수 있었던 계기가 되었습니다.

감사합니다.

참고 자료

  1. Django-classproperty