복사되었습니다.

Domain entity와 ORM을 따로 구분해서 정의하는 방법 - Domain entity with imperative ORM

Cover Image for Domain entity와 ORM을 따로 구분해서 정의하는 방법 - Domain entity with imperative ORM

Domain entity를 정의할 때 declarative 방식의 ORM을 사용하면 domain model에 DB 관련 정보가 노출되는 문제가 있다. Python sqlalchemy를 활용하여 imperative mapping style로 domain entity로부터 ORM을 분리해보자.

Declarative mapping 방식으로 정의된 domain entity에 대한 비판

이전 글에서는 declarative mapping style로 domain entity를 정의하는 방법에 대해 살펴봤다. 이 방식은 실용적이지만, DDD 정신이 투철한 사람들에게 비판을 받을 수 있다고 언급했었다. 비판의 주된 이유는 domain model에 DB와 관련된 정보가 노출되기 때문이다.

class Jogger(Base):
  __tablename__ = "joggers"

  _id = Column(name="id", type_=Integer, primary_key=True)
  _name = Column(name="name", type_=String(255), nullable=False)
  _distance = Column(name="distance", type_=Float, nullable=False)
  _state = State.STOPPED

위 코드는 이전 글에서 소개했던 예제를 그대로 가져온 것이다. Jogger라는 domain entity에서 진한 DB의 향기가 느껴지지 않는가? Domain model에서는 인프라적인 요소는 전혀 모르고 비즈니스 로직에만 집중해야 하는데, 테이블 이름이나 컬럼 타입 등이 눈살을 찌푸리게 한다.

<Architecture Patterns with Python>에서는 이렇게 ORM으로 인해 DB 냄새를 풍기는 domain entity에 대해 다음과 같이 언급하고 있다.

새 모델이 전적으로 ORM에 의존하고 엄청나게 못생겨 보이기 시작했다는 사실을 알기 위해 SQLAlchemy를 알 필요는 없다. 이 모델이 정말 데이터베이스에 대해 무지하다고 말할 수 있을까? 모델 프로퍼티가 직접 데이터베이스 열과 연관되어 있는데 어떻게 저장소와 관련된 관심사를 모델로부터 분리할 수 있을까?

이번 글에서는 이전 글과 달리 domain entity를 정의한 클래스로부터 ORM 관련 내용을 분리하는 방법을 알아보도록 하겠다. 이러한 접근 방식은 보통 imperative 또는 classical mapping 방식이라고 일컫는다. Python의 sqlalchemy를 활용하여 예제와 함께 자세히 살펴보자.

Python sqlalchemy로 imperative ORM 방식의 domain entity 구현하기

Imperative mapping 방식은 쉽게 말해서 DB 테이블을 명시적으로 정의해두고 해당 테이블과 객체를 mapping하는 방법이다. Domain entity 외부에 테이블 객체를 별도로 정의한다. 이때 domain entity와 테이블 row가 서로 변환되도록 연결시켜주는 존재를 mapper라고 한다.

Domain entity 구현하기

예제는 이전 글의 코딩 실습: 조깅하는 사람 모델링하기에서 다룬 내용을 그대로 사용하겠다. 동일한 요구사항을 mapper를 사용한 방식으로 구현하여 만족시키는 방법을 살펴보자. 우선 domain entity에 해당하는 Jogger 클래스는 어떤 모습일까? jogger.py 파일에 작성해보자.

from dataclasses import dataclass
from typing import Optional

@dataclass
class Jogger:
  _name: str
  _distance: float = 0.0
  _state: State = State.STOPPED
  _id: Optional[str] = None

  def __init__(self, name: str) -> None:
    self._id = None
    self._name = name
    self._distance = 0.0
    self._set_state(state=State.STOPPED)

  def _set_state(self, state: State) -> None:
    self._state = state

DB와 관련된 내용이 전혀 없다는 것을 확인할 수 있다. 인프라적인 요소인 DB 테이블이나 column type 등에 전혀 종속되지 않고, 독립적으로 비즈니스 모델에만 집중할 수 있는 클래스가 탄생했다. 앞서 declarative 방식으로 정의한 경우와 확연한 차이가 느껴지지 않는가?

한 가지 주목할 점은 dataclass로 정의했다는 것이다. 요구사항 중 state 값은 DB에 저장하지 않는다는 항목 때문이다. dataclass 사용이 필수는 아니다. 단, _state를 default 값과 함께 class 변수로 정의해두어야 객체를 DB에서 불러왔을 때 정상적으로 attribute가 생긴다.

Imperative ORM 정의하기

이제 위 Jogger 클래스를 DB의 table row로 매핑해주는 부분을 살펴보자. 명시적으로 테이블을 정의하고, 객체를 연결해주는 mapper를 사용하면 된다. 별도로 jogger_orm.py라는 파일을 생성하여 작성해보자.

from sqlalchemy.orm import mapper
from sqlalchemy import MetaData, Table, Column, Integer, Float, create_engine, String
from jogger import Jogger

metadata = MetaData()

joggers = Table(
  'joggers',
  metadata,
  Column(name="id", type_=Integer, primary_key=True),
  Column(name="name", type_=String(255), nullable=False),
  Column(name="distance", type_=Float, nullable=False)
)

joggers_mapper = mapper(Jogger, joggers, properties={
  '_id': joggers.c.id,
  '_name': joggers.c.name,
  '_distance': joggers.c.distance,
})

engine = create_engine("sqlite:///test.sqlite")
metadata.create_all(engine)

Table이라는 클래스를 활용하여 column 정보들을 담았다. 그리고 mapper를 통해 domain entity인 Jogger와 테이블 객체를 연결해주었다. 이때, properties라는 인자를 통해 객체 속성과 DB column 이름이 다른 경우를 처리해주었다.

이로써 비즈니스 로직이 관리되는 파일과 DB 저장 관련 내용이 작성되는 파일이 명확하게 분리되었다. Domain layer에서 인프라적인 요소에 의존하지 않기 때문에 DDD 측면에서 바람직하고, 책임과 역할이 명확히 분리된다는 점도 OOP 측면에서 바람직하다고 할 수 있다.

Imperative mapping 방식에 대한 차가운 시선

이론적으로는 imperative mapping 방식이 좋아보인다. 하지만, 이전 글에서도 언급했듯이 이 방식은 실용적인 측면에서 개발자들에게 회의감을 주고 있다. 특히 비교적 최근에 작성된 sqlalchemy github 글을 보면 sqlalchemy 개발에 직접 참여한 개발자의 의견을 엿볼 수 있다.

you would use imperative mapping for that, however I don't recommend this pattern overall. SQLAlchemy mappings modify the behavior of the class and the object so trying to keep that all "separate" does not really accomplish anything IMO. I was hoping to remove imperative mapping from 2.0 entirely but a lot of people still like it a lot, so you are supported with this pattern.

Imperative 방식의 실효성을 느끼지 못하여 최신 버전에서는 아예 지원을 중단하는 것까지 생각했다는 것이다. 여전히 imperative 방식을 옹호하는 사람들이 남아있기 때문에 어쩔 수 없이 남겨두었다는 뉘앙스다.

Java 진영의 hibernate 문서를 보면 아예 persistence layer 자체의 존재 의미를 모르겠다는 식으로 얘기한다. DB 저장 관련 내용을 domain entity로부터 분리하라는 지침을 전통적이고 고리타분한 이야기로 취급한다는 점이 재밌다.

The stodgy, dogmatic, conventional wisdom, which we hesitate to challenge for simple fear of pricking ourselves on the erect hackles that inevitably accompany such dogma-baiting is:

"Code which interacts with the database belongs in a separate persistence layer."

We lack the courage—perhaps even the conviction—to tell you categorically to not follow this recommendation. But we do ask you to consider the cost in boilerplate of any architectural layer, and whether the benefits this cost buys are really worth it in the context of your system.

이렇듯 ORM 개발자들은 이론적인 틀에서 벗어나 적극적으로 ORM에 의존적인 코드를 작성하도록 권장하고 있다. ORM 도구들이 고도화되어 다양하고 강력한 기능들을 제공하기 때문이다. 물론 ORM을 홍보하기 위한 의도가 담겨있긴 하겠지만 어느 정도 납득이 되긴 한다.

결국 domain entity와 ORM 관련 부분을 결합하여 구현할지 분리하여 구분할지에는 정답이 없다. 개인적으로는 ORM이 아무리 좋은 툴이어도 지나치게 의존하면 안된다는 입장이다. 고리타분하지만 DDD의 지침에 따라 imperative mapping 방식을 사용하는 것에 한 표를 던지겠다.

Comments

    More Posts

    Validation 코드는 어디에 작성해야 할까? - The 3 types of validation logics

    개발자들은 다양한 validation 코드들을 작성하는데 많은 시간을 소비한다. 이곳저곳에 덕지덕지 붙어있는 validation 코드들을 바라보면, 과연 이 코드들이 여기에 있어도 되는 것인지 의문이 생긴다. 다양한 종류의 validation 코드들을 어디에 작성해야 하는지 정리해보자.

    Python으로 kubernetes 환경에 app 배포하기 - Kubernetes deployment with python

    Kubectl 명령어를 통해 kubernetes 환경에 다양한 구성요소들을 배포할 수 있지만, python으로 템플릿 코드를 짜두면 개발자들도 편리하게 배포를 자동화할 수 있다. Jinja2와 kubernetes 패키지를 통해 사용자 요청에 따라 kubernetes 환경에 pod들을 띄우는 기능을 만들어보자.

    Domain model에서 repository를 직접 사용해도 될까? - 도메인 모델의 영속성 무지

    Repository는 인프라적인 요소에 가깝다고 할 수 있다. 그런데, 이 repository를 도메인 모델에서 직접 사용해도 괜찮을까? DDD와 관련된 여러 참고 문서들에서는 이에 대해 통일되지 않은 견해를 보이고 있다. 이 글을 통해 여러 견해들을 한꺼번에 모아서 생각해보자.

    Font Size