복사되었습니다.

영속성 domain entity를 정의할 때 상속을 사용해도 괜찮을까? - Repository code smell

Cover Image for 영속성 domain entity를 정의할 때 상속을 사용해도 괜찮을까? - Repository code smell

객체지향 프로그래밍에서는 코드 중복 최소화 및 다형성을 위해 인터페이스나 추상 클래스를 정의하고 상속을 사용하는 경우가 많다. 그러나, 이는 도메인 주도 설계(DDD) 관점에서 항상 바람직한 것은 아니다. 영속성 domain entity를 정의할 때 상속을 사용하는 것이 적절한지, 그리고 어떤 것들을 고려해야 하는지에 대해 논의해보자.

Inheritance를 사용하고자 하는 개발자들의 욕구

"유사하지만 조금씩 차이점이 있는" 코드들을 작성하다보면, 코드 중복 최소화를 위해 상속 관계를 사용하고 싶어지는 것이 당연하다. 공통된 상태와 행동을 부모 클래스에 정의해두고, 이를 상속하는 자식 클래스들이 각자의 차이점을 구현하도록 하는 것이다.

상속 관계를 사용하는 것은 다형성 관점에서도 바람직해보인다. 클라이언트가 부모 클래스 타입의 참조를 통해 자식 클래스 인스턴스들을 상호 교체 가능하도록 만들면 다양한 사용자 시나리오를 객체지향적으로 지원할 수 있다.

그런데, DB나 파일 등에 영속적으로 저장 및 관리해야 하는 영속성 도메인 entity를 정의할 때 inheritance를 사용하면 오히려 code smell을 유발하게 된다. 따라서, DDD에서는 상속의 사용을 지양해야 한다는 목소리가 있다.

Repository에서 발생하는 code smell

Repository 설계 방법에 초점을 맞추면, 영속성 domain entity를 정의할 때 상속을 지양해야 하는 이유를 쉽게 이해할 수 있다. Repository는 보통 주요 도메인 entity를 1:1로 전담한다. 예를 들어, Fruit라는 도메인 entity가 있다면, 이를 저장하기 위한 repository는 FruitRepository로 정의하는 식이다.

엄밀히 말하면, repository가 하나의 entity를 담당하는 것이 아니라 aggregate 하나를 담당해야 한다. Aggregate에 대한 설명은 이 글의 범위를 벗어나므로 다른 글에서 더욱 자세히 살펴보겠다. 우선 repository가 entity 하나를 1:1로 전담 마크한다고 가정하자.

repositories for domain entities with inheritance

그렇다면, 공통 부모 클래스를 상속한 다양한 자식 클래스들을 관리하려면 repository는 어떻게 설계해야 할까? Repository가 담당하는 범위에 따라 크게 두 가지 방법으로 나눌 수 있다.

  • 자식 클래스마다 별도의 repository 정의
  • 하나의 repository에서 모든 자식 클래스들을 담당

DB 활용 관점으로 보면, 자식 클래스들을 별도의 테이블에 나누어 저장할지, 아니면 하나의 테이블에 함께 저장할지 고민하는 것이라고 이해하면 된다.

자식 클래스마다 별도의 repository를 정의한다면?

자식 클래스와 repository가 1:1 대응이 된다면, 상속을 사용하지 않았을 때의 일반적인 repository 정의와 별반 다를 것이 없다. 자식 클래스가 새로 추가될 때마다 repository의 수도 그만큼 늘어난다. 결국 repository들 사이에 코드 중복이 발생하게 된다.

하나의 repository가 모든 자식 클래스를 담당한다면?

저장된 객체를 꺼내오는 finder method를 중심으로 생각해보자. 만약 finder method가 자식 클래스 타입을 반환한다면 어떨까? 예를 들어, Fruit라는 부모 클래스를 상속하는 AppleBanana 클래스가 있다고 가정하자.

from typing import Any

class FruitRepository:
  def find_apple_by_id(self, apple_id: int, apple_info: Any) -> Apple:
    ...
    return apple

  def find_banana_by_id(self, banana_id: int, banana_info: Any) -> Banana:
    ...
    return banana

위 python 코드 예제에는 자식 클래스마다 finder method가 정의되어 있다. 이렇게 구현할 경우, 클라이언트가 메서드들을 제대로 활용하려면 지나치게 많은 정보를 알고 있어야 한다는 문제가 있다. 대표적으로 다음과 같은 책임을 떠맡게 된다.

  • 각 자식 클래스마다 구분되는 식별자를 정확히 알고 있어야 한다. find_banana_by_id 메서드에 apple_id를 전달한다면, 타입 캐스팅 오류같은 exception을 발생시키도록 추가적인 로직이 요구된다.
  • 각 자식 클래스를 인스턴스화하기 위해 필요한 attribute들을 정확히 알고 있어야 한다. 예를 들어, find_apple_by_id 메서드를 사용하려면, Apple 클래스의 생성자 호출에 필요한 apple_info같은 상세정보가 필요하다.

클라이언트에게 많은 역할과 책임을 강요하는 코드는 바람직하지 않은 코드이다. 그리고 조금만 생각해보면 굳이 Fruit라는 부모 클래스를 상속할 필요가 없지 않냐는 의문이 든다. 다형성을 전혀 활용하지 않기 때문에 inheritance를 적용한 정당성이 약화된 것이다.

따라서, 다형성의 이점을 유지하기 위해서는 finder method가 추상적인 부모 클래스를 반환하도록 설계해야 한다. 하나의 finder method를 통해 다양한 자식 클래스 인스턴스를 얻을 수 있도록 만드는 것이다. 우선, 식별자에 클래스 타입 정보를 담는 방법을 살펴보자.

class FruitRepository:
  def find_fruit_by_id(self, id: FruitId, info: Any) -> Fruit:
    if id.is_apple_type():
      return Apple(id=id, info=info)
    elif id.is_banana_type():
      return Banana(id=id, info=info)
    else:
      ...

위 코드 예제에서는 FruitId라는 별도의 식별자 클래스를 정의하여 내부에 클래스 타입 정보를 담았다고 가정한다. 이로써 finder method 정의 부분이 마치 다형성을 염두한 코드처럼 보이기도 한다.

하지만, 여전히 클라이언트가 식별자를 특수한 타입으로 매핑해야 하는 책임을 진다는 문제가 있다. <Implementing Domain-Driven Design>의 저자 Vaughn Vernon은 이렇게 상속에 의해 클라이언트가 많은 책임을 지게 되는 문제에 대해 다음과 같이 경고하고 있다.

클라이언트가 타입에 기반한 결정을 하지 않도록 보호하기 위해 이런 내용이 클라이언트로 누수되지 않도록 분명히 해야 한다.

문제를 완화하는 방법들 중 하나는 자식 클래스 타입 정보를 식별자가 아니라 domain entity의 내부 attribute에 포함시키는 것이다. 쉽게 말해서, 객체를 담을 DB table에 "class_type"같은 column을 정의해두고 자식 클래스 타입 정보를 해당 column에 저장하는 것이다.

class FruitRepository:
  def find_fruit_by_id(self, id: int, info: Any) -> Fruit:
    data = self.extract_fruit_data(id=id)
    if data.is_apple_type():
      return Apple(id=id, info=info)
    elif data.is_banana_type():
      return Banana(id=id, info=info)
    else:
      ...

확실히 finder method를 사용하기 위해 특정 클래스 타입에 대한 지식이 상대적으로 덜 필요하다는 것을 알 수 있다. 단, 여전히 if, elif, else가 덕지덕지 붙는 code smell이 풍긴다. 자식 클래스가 추가 또는 변경될 때마다 finder method가 매번 수정되어야 한다.

그래도 상속을 사용하려는 당신을 위한 지침서

영속성 domain entity에 inheritance를 적용하면 repository를 설계하기가 까다롭다는 것을 살펴보았다. 이로 인해 대부분의 경우 inheritance로 인한 이점은 매우 제한적이다. 따라서, DDD 관점에서는 영속성 domain entity에 상속을 사용하는 것을 권장하지 않는다.

만약 여러가지 문제를 내포하고 있음에도 영속성 domain entity에 상속을 사용해야 한다면, 어떻게 repository를 설계하는 것이 좋을까? Vaughn Vernon의 말을 인용하면, 다음과 같이 각자 상황에 맞는 방법을 따르면 된다.

단 두 개 정도의 구체적인 서브클래스가 필요한 경우라면 별도의 리파지토리를 생성하는 편이 최선일 것이다. 구체적 서브클래스의 수가 서넛이나 그 이상으로 늘어나고 대부분이 완전히 상호 교체 가능하도록 사용될 수 있다면, 공통의 리파지토리를 공유하는 편이 더 낫다.

정리하면, 잠재적인 자식 클래스의 종류를 고려하여 단 하나의 repository를 사용할지 다수의 repository를 사용할지 결정하면 된다. 단, 공통 repository를 사용한다면, 클라이언트의 부담을 최대한 덜어내도록 domain entity 안쪽에 클래스 타입 정보를 포함하는 것이 좋다.

이렇게 복잡한 고민들을 피하고 싶다면 상속을 지양하자. 오히려 도메인 모델을 다시 설계하는 것이 더 쉬울 수도 있다. 객체지향 프로그래밍에서도 객체들 사이의 강한 의존성을 막기 위해 상속 관계보다는 포함 관계(composition)를 권장한다는 것을 명심하자.

결론: 되도록이면 영속성 domain entity에는 상속을 사용하지 말자!

  • 되도록이면 영속성 domain entity에는 inheritance 사용을 지양하자.
  • 자식 클래스 종류가 적다면 자식 클래스마다 개별 repository를 정의하자.
  • 자식 클래스 종류가 많다면 공통 repository를 공유하도록 만들자.
  • 공통 repository를 공유한다면 클래스 타입 정보를 domain entity 속성에 포함시키자.
  • Inheritance보다는 composition을 통해 도메인 모델을 설계하자.

Comments

    More Posts

    Aggregate란 무엇인가 도대체! - DDD aggregate의 기초 쉽게 이해하기

    DDD에서는 aggregate라는 용어가 자주 등장한다. DDD의 다양한 개념들을 이해하기 위해서는 aggregate에 대한 이해가 선행되어야 하는데, 보통 DDD 자료들에서는 aggregate에 대한 설명이 중후반부에 등장한다. DDD 초보자도 쉽게 이해할 수 있도록 aggregate를 겉핥기해보자.

    Event와 command의 차이점 쉽게 이해하기 - Events vs commands in EDA

    Event Driven Architecture(EDA)를 시스템에 적용할 때는 거창한 이름 때문에 마치 모든 것을 event로 처리해야 할 것만 같다. 반은 맞고 반은 틀리다. Command라는 개념을 event와 분리하여 생각할 필요가 있다. Event와 command가 도대체 뭐가 다른지 간단히 정리해보자.

    CQRS 개념 초간단 정리 - Understanding CQRS pattern

    개발을 하다보면 CQRS라는 용어를 자주 만나게 된다. 특히 이벤트 기반 아키텍처나 대용량 트래픽 처리와 관련된 주제에서는 항상 빠지지 않고 등장하는 단골 메뉴다. 예제와 함께 CQRS 패턴의 정의, 장단점, 그리고 적용 방법 등을 최대한 간단하게 정리해보자.

    Font Size