복사되었습니다.

도메인 entity와 ORM을 동시에 추구하면 안 되는 걸까? - Domain entity with declarative ORM

Cover Image for 도메인 entity와 ORM을 동시에 추구하면 안 되는 걸까? - Domain entity with declarative ORM

ORM을 사용하는 환경에서 DDD를 따르는 코드를 작성하다보면, domain entity를 어떤식으로 정의해야 하는지 혼란스러울 때가 있다. Python sqlchemy를 통해 declarative mapping 방식으로 domain entity를 어떻게 구현하면 좋은지 알아보자.

Domain entity와 ORM을 둘러싼 갈등

DDD에서는 domain entity가 DB같은 infra적인 요소에 종속되면 안된다고 경고한다. 따라서, DDD를 추구하는 프로젝트에서는 항상 다음 두 가지 방식 중 어떤 것을 선택할지 선택의 기로에 놓이게 된다.

  • Domain entity를 ORM 객체로 구현하자.
  • Domain entity 객체와 ORM 객체를 분리하자.

Domain entity 객체와 ORM 객체를 구분하지 않는 것을 선호하는 개발자들은 "개발 속도"와 "실용성"을 강조한다. 반면, 두 객체를 분리하는 것을 선호하는 개발자들은 "확장성"과 "유연성"을 강조하는 경향이 있다.

보통의 경우, 프로젝트 초기에는 첫 번째 방식을 따라 ORM으로 domain entity 객체를 정의한다. 그리고 프로젝트 규모가 점점 커지면 두 번째 방식을 따라 domain entity와 ORM을 분리하는 리팩토링을 점진적으로 진행하는 듯 하다.

이 글에서는 우선 domain entity와 ORM을 따로 구분하지 않는 첫 번째 방식에 초점을 맞추도록 하겠다. 보통 이 방식을 declarative mapping이라고 부른다. 이 방식의 장단점과 구현 방법에 대해 살펴보자. 두 객체를 분리해서 정의하는 방식은 다른 글에서 더욱 자세히 다루겠다.

ORM과 domain entity를 구분하지 않을 때의 장단점

장점: 간결해지는 코드와 빠른 개발 속도

하나의 클래스로 모든 것을 처리하므로 코드 구조가 단순해진다. 그리고 중복 코드가 최소화되기 때문에 전체적으로 코드의 양이 줄어든다. 만약 ORM 객체와 domain entity를 별도의 클래스로 정의한다면 객체의 데이터 구조가 변경될 때 양쪽 클래스에 모두 반영해야 한다.

초기 개발 속도가 빨라진다는 장점도 무시할 수 없다. 보통 개발자들은 행동적 도메인 모델링보다 데이터 모델링을 하는 습관이 있다. 따라서, 프로젝트 초기의 app은 보통 단순 CRUD를 제공하는 일종의 DB wrapper처럼 구현될 확률이 높다. 이 경우 한 클래스로 정의하는 것이 간편하다.

단점: 종속성의 늪에 빠져 뻣뻣한 유연성

비즈니스 로직에 집중해야 할 곳에서 인프라적인 요소까지 신경써야 한다는 점은 유지보수를 오히려 어렵게 만들 수 있다. 비즈니스 로직이 ORM 툴에 종속되어 데이터베이스 스키마나 ORM 프레임워크의 변경에 민감해지고 유연성이 떨어진다.

비즈니스 로직을 설계하는 관점에서는 사용자들에게 어떤 "동작"을 제공하는지가 중요하다. 데이터를 DB에 VARCHAR(20)로 저장할지, INTEGER로 저장할지가 중요하진 않다. 그런데, 이런 영속성에 국한된 부분만 변경되어도 비즈니스 로직이 담긴 클래스에 영향을 미치게 된다.

Declarative 방식의 ORM으로 domain entity를 정의해도 될까?

이상적인 것은 도메인 모델이 DB에 대해 아예 모르도록 설계하는 것이다. 따라서, 엄밀히 따지면 ORM 객체와 domain entity를 분리하여 정의하는 것이 DDD에 더 가깝다고 할 수 있다. 그렇다면 왜 두 가지 객체를 구분하지 않고 정의해도 된다는 얘기들이 나오는 것일까?

앞서 장점에서 언급했듯이 개발 편의성이 가장 큰 이유다. 대부분의 개발 프로젝트에서는 초기에 유지보수성보다는 "일단 돌아가게 만들어 놓고 생각해보자"는 식으로 진행된다. 따라서, 상대적으로 짧은 코드로 빠르게 개발할 수 있도록 ORM 객체를 그대로 domain entity로 사용한다.

현실적으로 단점을 느끼기 어렵다는 것도 한 몫 한다. ORM을 사용하면 어짜피 DB 종류를 변경해도 코드 변화는 없을 확률이 높다. DB 변경으로 코드에 영향을 주려면 아예 RDB가 아닌 NoSQL DB로 변경하거나 ORM 프레임워크 자체를 변경해야 하는데, 이는 흔한 경우가 아니다.

db_info = "sqlite:///test.sqlite"
# db_info = "postgresql://username:password@dbhost/dbname"
engine = create_engine(db_info)
...
stmt = select(Student).where(Student.name == "Steve")
result = session.execute(stmt)

그리고 객체의 변수들을 DB에 그대로 저장하는 경우가 많아서 객체의 데이터 구조가 변경되면 어짜피 DB 스키마에도 반영을 해야 한다. 하나의 클래스로 정의하면 한 곳만 수정하면 되는데, 두 개의 클래스로 나뉘어 있다면 수정할 곳이 늘어난다.

인터넷의 DDD 관련 예제들을 보면 ORM을 통해 domain entity를 정의하는 경우를 쉽게 찾아볼 수 있다. 그리고 개인적으로 참여했던 스타트업 프로젝트에서도 ORM 객체와 domain entity를 딱히 구분하지 않고 정의하여 사용하고 있었다. 딱히 불편함은 없었다.

따라서, domain entity를 ORM 객체와 분리하지 않고 정의하는 것에 너무 거부감을 갖진 말자. 모든 경우에 최선은 아니지만, 많은 경우 실용적이고 효율적인 선택이 될 수 있다. 주어진 프로젝트의 상황에 맞는 최적의 선택을 하는 것이 중요하다.

Python sqlalchemy로 domain entity 구현하기

꼭 필요한 것들만 노출시키고 나머지는 모두 숨겨라

앞서 살펴봤듯이, ORM 객체와 domain entity를 구분하지 않고 구현하는 것에는 논란이 많다. 따라서, 마구잡이로 코드를 짜면 DDD 정신이 투철한 사람의 비판을 피해갈 수 없다. 그렇다면 그나마 DDD 정신에 가깝도록 domain entity를 구현하려면 어떻게 해야 할까?

적어도 attribute들의 무분별한 노출만 막더라도 어느 정도 수용 가능하다고 생각한다. DB 의존성 외에도 DDD에서 ORM을 견제하는 주된 이유 중 하나는 ORM이 public getter와 setter를 강요하여 무기력한 도메인 모델(anemic domain model)이 될 우려가 있다는 것이다.

DDD의 교과서 중 하나인 일명 "빨간책" <Implementing Domain-Driven Design>에서 이러한 우려를 나타내는 뉘앙스를 확인할 수 있다. 변수를 외부에 노출하는 것을 java의 hibernate에서 어떤식으로 완화하는지 짐작할 수 있고, 이에 대한 저자의 견해를 엿볼 수 있다.

DDD의 객체 관계성 매퍼를 사용하는 것을 고민해야 할까?
앞선 하이버네이트의 비판은 역사적 관점에서 비롯됐다. 시간이 꽤 지난 지금, 하이버네이트는 숨겨진 게터와 세터를 사용하게 해주며, 직접적 필드 액세스도 가능하다. 나는 1장 후반부에 하이버네이트와 다른 영속성 메커니즘을 사용하면서 당신의 모델에서 무기력증이 나타나지 않도록 막는 법을 보여준다. 그러니 너무 걱정하지 말라.

물론, 해당 영역을 private으로 선언해서 이런 메소드에 관한 어떤 지식도 갖지 못하도록 할 수 있다. 하이버네이트는 메소드나 필드 리플렉션을 통해 public 이외의 영역도 문제없이 사용할 수 있다. ... (중략) ... 나는 모델에서 발생하는 이런 ORM의 누수는 큰 문제가 되지 않는다고 생각하는데, 여기선 일반적인 Collection 기능을 사용했을 뿐만 아니라 클라이언트가 이를 알지도 못하기 때문이다.

Public getter와 setter를 최대한 사용자로부터 숨긴다면 무기력증을 완화할 수 있다. 그런데, hibernate나 sqlalchemy같은 도구가 DB에 객체를 저장하거나 꺼내올 때 어짜피 객체의 변수에 직접 접근해야 하는 경우가 생긴다. 즉, private 변수들에 접근할 수 있어야 한다는 것이다.

인용문을 보면 이러한 문제를 해결하기 위해 hibernate는 reflection이라는 것을 통해 private 변수나 메서드에 접근할 수 있다고 한다. Python의 경우 완전한 private 접근제어자가 없기 때문에 reflection같은 별도의 기술이 없어도 non-public 변수들에 접근할 수 있다. (참고)

따라서, 최대한 변수들을 non-public하게 만들어서 외부 노출을 최소화하고, 사용자 시나리오 중심의 메서드들만 제한적으로 노출시키도록 구현해야 한다. 코드 예제를 통해 구체적인 구현 방법을 살펴보도록 하자.

코딩 실습: 조깅하는 사람 모델링하기

조깅하는 사람들이 이동한 거리를 재기 위해 Jogger라는 domain entity를 정의하고자 한다. Jogger에 대한 요구사항은 다음과 같다.

저장해야 하는 데이터

  • Joggername, distance, state 데이터를 가지고 있다.
  • state는 DB에 저장할 필요 없다.
  • Jogger 데이터를 저장하는 save 함수를 구현해야 한다.
  • distance가 n 이상인 Jogger들을 불러오는 load 함수를 구현해야 한다.

Jogger의 동작과 상태 변화

  • Jogger의 최초 상태는 State.STOPPED이다.
  • Jogger가 걸어가기 동작을 수행하면 상태가 State.WALKING으로 변경되며, distance가 1 증가한다.
  • Jogger가 뛰어가기 동작을 수행하면 상태가 State.RUNNING으로 변경되며, distance가 3 증가한다.
  • 멈추기 동작을 수행하면 상태가 State.STOPPED로 변경된다.

템플릿 코드는 다음과 같이 주어졌다. Python의 sqlalchemy를 활용하여 # Write code here. 주석 부분에 답안 코드를 작성해보자.

from typing import List
from enum import Enum, auto
from sqlalchemy import (
    create_engine,    
    Column,
    Integer,
    String,
    Float,
    select,
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session

Base = declarative_base()
engine = create_engine("sqlite:///test.db")

class State(Enum):
  STOPPED = auto()
  WALKING = auto()
  RUNNING = auto()

class Jogger(Base):
  pass  # Write code here.

def save(jogger: Jogger) -> None:
  with Session(engine) as session:
    session.add(jogger)
    session.commit()
    session.refresh(jogger)

def load(distance: float) -> List[Jogger]
  pass  # Write code here.

Base.metadata.create_all(engine)

Sqlalchemy로 무기력하지 않은 domain entity를 구현하는 방법

우선 Jogger 클래스를 정의해보자. 최대한 멤버 변수들은 non-public하게 숨기고, 꼭 필요한 메서드들만 노출시키는 방식으로 구현하려면 어떻게 해야 할까?

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

  def __init__(self, name: str) -> None:
    super().__init__()
    self._set_name(name=name)
    self._set_distance(distance=0.0)
    self.stop()

  def _set_name(self, name: str) -> None:
    if 'test' in name:
      raise ValueError("'test' should not be in the name.")
    self._name = name

  def _set_distance(self, distance: float) -> None:
    if distance < 0.0:
      raise ValueError("Distance cannot be less than 0.")
    self._distance = distance

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

  def walk(self) -> None:
    self._set_state(State.WALKING)
    self._distance += 1.0

  def run(self) -> None:
    self._set_state(State.RUNNING)
    self._distance += 3.0

  def stop(self) -> None:
    self._set_state(State.STOPPED)

멤버 변수들은 언더스코어를 붙여서 non-public하게 만들어주고, column 이름은 언더스코어를 생략하였다. 이렇게 구현하면 객체의 변수들을 외부로부터 모두 숨기면서 "id", "name", "distance"라는 이름을 가진 column들이 "joggers" 테이블에 생성된다.

_variable_name = Column(name="column_name", type_=String(255), ...)

_state처럼 DB에 저장하지 않아도 되는 객체의 변수는 Column을 통해 정의하지 않고, 기본값을 할당해주면 된다. 이때, 클래스 변수로 정의했다는 사실에 주목하자. 클래스 변수로 정의하지 않으면 DB로부터 꺼내올 때 _state 변수 자체가 없는 객체가 반환된다.

Sqlalchemy에서 DB로부터 객체를 불러오면 사용자가 정의한 __init__ 메서드가 호출되지 않는다. 따라서, 위 예제 코드에서 _state를 클래스 변수로 정의하지 않고 인스턴스 변수로 정의하면 다음과 같은 결과를 맞이한다.

class Jogger(Base):
  ...
  # _state = State.STOPPED

  def __init__(self, name: str) -> None:
    ...
    self._state = State.STOPPED

>>> joggers = load(distance=2.0)
>>> joggers[0]._state
AttributeError: 'Jogger' object has no attribute '_state'

인스턴스 변수를 클래스 변수로 정의해야 한다는 점이 찝찝하긴 하지만, ORM을 활용하여 구현하기로 한 이상 어쩔 수 없이 감수해야 하는 부분이다. 클래스 변수를 사용했다는 것에 초점을 맞추기 보다는, DB에 존재하지 않는 변수의 기본값을 설정해주기 위한 장치라고 생각해주길 바란다.

다시 코드 예제로 돌아가자. 생성자는 필요한 정보만 인자로 받고, 값을 할당할 때는 자가 위임과 자가 캡슐화를 통해 값의 무결성과 일관성을 보장했다. 쉽게 말해, private setter들을 사용해 변수들에 값을 할당했다는 뜻이다. 요구사항엔 없지만 장점 부각을 위해 임의의 if문들을 추가했다.

각 변수에 값을 할당하는 단순 public setter들의 정의는 지양하고, 비즈니스 로직을 잘 표현하는 유비쿼터스 언어로 public 메서드들을 정의했다. 쉽게 말해서, _set_distance처럼 단순히 거리값만 수정해주는 setter들은 숨기고, walk, run, stop을 사용하도록 노출한 것이다.

이렇게 구현하면 Jogger를 사용하는 측에서 비즈니스 언어를 사용하는 것과 같은 경험을 할 수 있게 된다. 이로써 Jogger 클래스는 단순한 데이터 모델이 아니라, 비즈니스 로직을 담은 도메인 엔티티의 역할과 책임을 맡게 된다.

예를 들어, "Jogger가 뛴다"라는 기능은 jogger.run()으로 표현된다. 무기력한 클래스 AnemicJogger와 그렇지 않은 BehavioralJogger를 사용할 때의 차이를 느껴보자.

def anemic_jogger_run():
  # case 1: anemic domain model
  jogger = AnemicJogger(
    name="Steve",
    distance=0.0,
    state=State.STOPPED,
  )
  jogger.state = State.RUNNING
  jogger.distance += 3.0

def behavioral_jogger_run():
  # case 2: behavioral domain model
  jogger = BehavioralJogger(name="Steve")
  jogger.run()

다음으로 load 메서드를 정의해보자. Sqlalchemy를 통해 위 Jogger 클래스를 불러오는 코드는 어떻게 짜야 할까? ORM 객체를 불러오는 보통의 방법과 크게 다를 것은 없다.

def load(distance: float) -> List[Jogger]:
  with Session(engine) as session:
    stmt = select(Jogger).where(Jogger._distance >= distance)
    result = session.execute(stmt)
    return result.scalars().all()

단, where 조건을 설정할 때 언더스코어를 붙여서 Jogger._distance같은 식으로 column을 참조해야 한다. Non-public 변수를 외부에서 참조하는 것 같아서 찝찝하겠지만, hibernate도 reflection을 통해 private 변수에 접근한다고 언급했던 것을 기억하자. 괜찮다!

ORM을 통해 행동적 도메인 모델의 entity를 정의하는 방법 요약

  • 멤버 변수는 언더스코어를 붙여서 non-public하게 만들자.
  • Column에는 name을 지정하여 대응하는 변수명과 다르게 설정하자.
  • DB에 저장하지 않을 멤버 변수가 있다면 초기값과 함께 클래스 변수로 정의하자.
  • Non-public setter들은 자가 위임과 자가 캡슐화를 위해서만 사용하자.
  • Public setter는 지양하고, 유비쿼터스 언어로 표현된 메서드들을 노출시키자.
  • 객체를 불러올 때는 where 조건에서 column에 언더스코어를 붙여서 참조하자.

Comments

    More Posts

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

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

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

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

    Aggregate의 문제점과 바람직한 설계 방법 - DDD aggregate diet

    DDD의 aggregate는 일관성 관리를 위해 매우 중요한 개념이지만, 덩치가 커질수록 성능과 확장성 측면에서 문제가 발생하게 된다. Composition 기반 aggregate의 문제점에 대해 자세히 살펴보고, 효율적인 aggregate를 설계하기 위한 방법들을 알아보자.

    Font Size