Published on

Dataclass 와 Factory method Pattern 을 이용한 Django Template Conent 구조화

Authors

타 프레임워크와 마찬가지로 Django에서도 Template(HTML 템플릿) 등에 데이터를 Render(렌더링)하는 기능을 제공합니다.

렌더링해야 할 데이터가 많아질수록 관리하는 포인트가 많아짐에 따라, 이를 효율적으로 관리하기 위해 데이터 스키마를 dataclass로 구성했고, 템플릿이 늘어나는 것을 대비하기 위해 팩토리 패턴을 사용한 과정을 기록합니다.

1. 일반적인 방법

Python에서는 가장 효율적인 자료구조인 딕셔너리를 사용하는 것이 일반적이고, 구현 속도도 빠릅니다.

예를 들어, 작성자 데이터를 렌더링할 때, HTML에 {{writer_name}}으로 데이터를 매핑해 넣습니다:

doc_context_first = {
    "position": "123",
    "writer_name": "hongreat",
    "year": "2024",
    "month": "8",
    "day": "9",
    "company_name": "주식회사 hongreat",
    "company_owner_name": "hongreat",
    "sign_image_url": "https://example.com/sign.png",
}

이 데이터를 렌더링하는 데는 문제가 없지만, 렌더링할 파일(문서)이 많아지면 어떤 파일에 어떤 데이터가 들어가는지 파악하기 어려워집니다.

doc_context_first, doc_context_second ...

이러한 문제를 해결하기 위해 더 나은 구조를 고민했습니다.

그렇지 않으면, 타입 안정성이 부족해지고, 필드 누락이 발생할 수 있으며, 코드 중복이 증가하고 유효성 검사도 복잡해지는 등 관리받지 못하는 하드코딩 이 되게 됩니다.

2. dataclass(데이터클래스) 응용

데이터를 구조화 할 수 있는 좋은 방법으로 dataclass 데이터클래스가 있는데, 타 프레임워크들에서 보통 Model, Schema, Interface, Entity 등과 마찬가지의 개념이라고 할 수 있습니다.

이 단순한 타입 명시 (ex: str, int) 에서 끝내지 않고 조금 더 (클래스를 이용해서)구조화 할 수 있도록 만들고 싶은 저를 충족시켰습니다.


from dataclasses import dataclass
from typing import Optional

@dataclass
class DynamicContentDocOne:
    position: str
    writer_name: str
    year: str
    month: str
    day: str
    company_name: str
    company_owner_name: str
    sign_image_url: Optional[str] = None

    def __post_init__(self):
        self.validate()

    def validate(self):
        if not self.year.isdigit() or len(self.year) != 4:
            raise ValueError("Year must be a 4-digit number")
    # 추가적인 유효성 검사...생략...

    def to_dict(self):
        return {field.name: getattr(self, field.name) for field in fields(self)}

이 접근 방식은 이전의 많은 문제를 해결했지만, 여전히 개선의 여지가 있었습니다.

2.1 추상화 헬퍼 ABC, abstractmethod

프로젝트가 발전하면서 여러 종류의 문서를 다뤄야 했고, 이는 추상화가 반드시 필요했습니다.

클래스와 문서가 1:1 로 매칭되고, 해당 클래스가 어떤 문서(A문서? B문서?) 의 클래스인지 알기 위해서 __name__ 로서 지정하고 싶었습니다.

그래서 언젠가 다시 소스코드를 봐도 헷갈리지 않도록, 반드시 __name__ 를 지정하도록, from abc import ABC, abstractmethod 를 상속받아서 역할의 클래스를 잡아줬습니다.

from abc import ABC, abstractmethod

class BaseDynamicContent(ABC):
    @abstractmethod
    def __name__(self) -> str:
        pass

    @abstractmethod
    def validate(self):
        pass

    def to_dict(self) -> Dict[str, Any]:
        return {field.name: getattr(self, field.name) for field in fields(self)}

@dataclass
class DynamicContentDocOne(BaseDynamicContent):
# 필드 정의...

    def __name__(self) -> str:
        return "안전보건총괄책임자_지정서"

    def validate(self):
# 구체적인 유효성 검사 로직...

이 구조는 코드 재사용성을 크게 향상시켰지만, 유효성 검사 로직이 여전히 각 클래스 내부에 있어 중복의 여지가 있었습니다.

2.2 유효성 검사의 분리

여러 개의 클래스에 동일한 Validtor 를 사용하도록 아예 별도의 클래스로 생성했고, 위와 마찬가지로 abstractmethod 를 상속받아서 validate 로직을 만들게끔 만들었습니다.

이 방식은 유효성 검사 로직의 재사용성을 향상시켰습니다.


class Validator(ABC):
    @abstractmethod
    def validate(self, value: Any) -> bool:
        pass

    @abstractmethod
    def error_message(self) -> str:
        pass

class YearValidator(Validator):
    def validate(self, value: str) -> bool:
        return value.isdigit() and len(value) == 4

    def error_message(self) -> str:
        return "Year must be a 4-digit number."

@dataclass
class DynamicContentDocOne(BaseDynamicContent):
# 필드 정의...

    validate_year = YearValidator()

    def validate(self):
        for field in fields(self):
            validator = getattr(self, f"validate_{field.name}", None)
            if validator and not validator.validate(getattr(self, field.name)):
                raise ValueError(f"Invalid {field.name}: {validator.error_message()}")

2.3 팩토리 패턴 적용하여 사용

마지막으로, 팩토리 패턴을 도입하여 문서의 데이터를 담은 클래스의 객체 생성을 유연하게 만들었습니다.


from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass, fields
from typing import Any, Dict, Optional, Type, get_type_hints

class DynamicContentValidationError(Exception):
		"""
		에러를 별개로 생성
		"""
    pass

class Validator(ABC):
    @abstractmethod
    def validate(self, value: Any) -> bool:
        pass

    @abstractmethod
    def error_message(self) -> str:
        pass

class YearValidator(Validator):
    def validate(self, value: str) -> bool:
        return value.isdigit() and len(value) == 4

    def error_message(self) -> str:
        return "Year must be a 4-digit number."

class MonthValidator(Validator):
    def validate(self, value: str) -> bool:
        return value.isdigit() and 1 <= int(value) <= 12

    def error_message(self) -> str:
        return "Month must be a number between 1 and 12."

class DayValidator(Validator):
    def validate(self, value: str) -> bool:
        return value.isdigit() and 1 <= int(value) <= 31

    def error_message(self) -> str:
        return "Day must be a number between 1 and 31."

class URLValidator(Validator):
    def validate(self, value: str) -> bool:
        return value.startswith("http")

    def error_message(self) -> str:
        return "URL must start with 'http'."

@dataclass
class BaseDynamicContent(ABC):
    @abstractmethod
    def __name__(self) -> str:
        pass

    def __post_init__(self):
        self.validate()

    def validate(self):
        type_hints = get_type_hints(self.__class__)  # 타입을 가져오기 위해 get_type_hints 사용
        print("type_hints", type_hints)
        for field in fields(self):
            value = getattr(self, field.name)
            expected_type = type_hints[field.name]
            print("expected_type", expected_type)
            if not isinstance(value, expected_type) and value is not None:
                raise DynamicContentValidationError(f"{field.name} must be of type {expected_type}.")

            validator = getattr(self, f"validate_{field.name}", None)
            if validator and callable(validator):
                if not validator(value):
                    raise DynamicContentValidationError(f"Invalid {field.name}: {validator.error_message()}")

    def to_dict(self) -> Dict[str, Any]:
        return {field.name: getattr(self, field.name) for field in fields(self)}

@dataclass
class DocSafetyHealthManagerDesignation(BaseDynamicContent):
    position: str
    writer_name: str
    year: str
    month: str
    day: str
    company_name: str
    company_owner_name: str
    sign_image_url: Optional[str] = None

    def __name__(self) -> str:
        return "안전보건총괄책임자_지정서"

    validate_year = YearValidator()
    validate_month = MonthValidator()
    validate_day = DayValidator()
    validate_sign_image_url = URLValidator()

class DynamicContentFactory:
    """
    Manage Document name and Class by doc_classess.
		key is file name, doc's title is class's name.

    ex) safety_health_manager_designation.html ; 안전보건총괄책임자_지정서

    """

    @staticmethod
    def create(doc_type: str, **kwargs) -> BaseDynamicContent:
        doc_classes: Dict[str, Type[BaseDynamicContent]] = {
            "safety_health_manager_designation": DocSafetyHealthManagerDesignation,
        }
        if doc_type not in doc_classes:
            raise ValueError(f"Unknown document type: {doc_type}")
        return doc_classes[doc_type](**kwargs)  # 객체화

DynamicContentFactory 를 여러 문서의 공장, 즉 집합체로 여기고 문서 유형을 doc_classess 로 지정해서 사용해, 코드와 구체적인 클래스 사이의 결합도를 느슨하게 해줬습니다.

2.4 get_type_hints

typing 의 get_type_hints(self.__class__) 사용하여 클래스의 타입 힌트를 동적으로 가져왔습니다.

이를 통해 isinstance()가 올바르게 작동할 수 있도록 보장합니다.

from typing import Any, Dict, Optional, Type, get_type_hints

# get_type_hints 프린트 되는 부분
type_hints {'position': <class 'str'>, 'writer_name': <class 'str'>, 'year': <class 'str'>, 'month': <class 'str'>, 'day': <class 'str'>, 'company_name': <class 'str'>, 'company_owner_name': <class 'str'>, 'sign_image_url': typing.Optional[str]}

3. 결론

  1. doc_classes
    • 이 딕셔너리는 문자열 키(doc_type)와 해당 클래스(DocSafetyHealthManagerDesignation)를 매핑합니다. 예를 들어, "safety_health_manager_designation"이라는 키를 사용하면 DocSafetyHealthManagerDesignation 클래스를 가져옵니다.
  2. create()
    • create() 메서드는 doc_type이라는 문자열을 받아서, 적절한 클래스를 선택하고 그 클래스를 사용해 객체를 생성합니다.
    • 이 메서드가 팩토리 역할을 합니다. 사용자는 어떤 클래스가 실제로 생성되는지 신경 쓰지 않고, 단지 doc_type이라는 정보와 생성에 필요한 데이터를 넘겨주기만 하면 됩니다.
  • 팩토리 구조
    • 팩토리 구조 덕분에 객체 생성 로직이 분리되어 있고, 이 덕분에 코드가 더 간결해지고, 객체를 생성하는 방식도 유연하게 변경할 수 있습니다.

3.1 사용법

    def test_factory(self):
        try:
            safety_health_manager_designation = DynamicContentFactory.create(
                "safety_health_manager_designation",
                position="직책테스트",
                writer_name="hongreat",
                year=2024,  # 타입이 달라서 에러를 발생시키는 부분!
                month="08",
                day="13",
                company_name="ABC",
                company_owner_name="홍인영",
                sign_image_url="https://example.com/sign.png",
            )
            file_name = "안전보건총괄책임자_지정서"
            template = (
                f"filepath.html",
            )
            rendered_html = render_template(template, safety_health_manager_designation)
            print()
            write_html(
                rendered_html,
                f"filepath.html",

        except DynamicContentValidationError as e:
            print(f"Validation error: {str(e)}")
        except ValueError as e:
            print(f"Error: {str(e)}")

4. 참고 문서 링크

hongreat 블로그의 글을 봐주셔서 감사합니다!