자식 엔티티의 생성과 수정은 어디서 해야 할까?

June·2023년 11월 14일
0

일반적으로 소프트웨어를 설계할 때 하나의 엔티티 클래스만 가지게 되는 경우는 매우 드물다. 다양한 엔티티들이 서로 깊이 연관되어 있을 때, "엔티티들의 수정과 생성에 대한 책임을 어디서 가져야 할지"는 중요한 고민거리이다.

※ 이 글에서 말하는 엔티티는 DB 테이블 mapping 객체가 아닌 도메인 객체를 의미한다

Entity: Objects that have a distinct identity that runs through time and different representations. You also hear these called "reference objects".
As such some examples may help. Entities are usually big things like Customer, Ship, Rental Agreement. - Martin Fowler

DDD 에서는 Aggregate root / member 등의 용어를 사용하지만 여기서는 DDD 처럼 거창한 설계가 아니라 더 실용적이고 구체적인 상황에 초점을 맞춰 설명하고자 한다. 그래서 단순히 부모 / 자식 엔티티 라는 용어를 사용할 것이다.

예시

요구사항

  • 전시(Exhibition)를 관리하는 시스템을 구축하고자 한다. 각 전시는 여러 작품(Artwork)을 포함하고 있으며, 이들 작품은 전시 내에서 각각 다른 위치에 배치되어야 하고, 작품의 위치를 수정할 수 있어야 한다.

Entity 클래스 선언

  • Exhibition (부모 엔티티)
export class Exhibition {
  id: number;
  name: string;
  artworks: Artwork[];

  constructor(id: number, name: string, artworks: Artwork[]) {
    this.id = id;
    this.name = name;
    this.artworks = artworks;
  }
}
  • Artwork (자식 엔티티)
export class Artwork {
  id: number;
  title: string;
  width: number;
  height: number;
  position: Position;

  constructor(id: number, title: string, width: number, height: number, position: Position) {
    // 생략
  }
}

Exhibition 과 Artwork 는 1:N 관계로 하나의 Exhibition(부모)이 여러 Artwork(자식)를 가지고 있다.

case 1 : 서비스 클래스에서 직접 자식 엔티티 업데이트

export class Artwork {
  // 생략
  setPosition(position: Position): void {
    this.position = position;
  }
}
  • Artwork 클래스에 location 속성을 업데이트하는 메서드 추가
export class ExhibitionService {
  constructor(private readonly exhibitionRepository : IExhibitionRepository) {}
  
  async updateArtworkPosition(exhibitionId: number, artworkId: number, position: Position): Promise<boolean> {
    const exhibition = await this.exhibitionRepository.findById(exhibitionId);
    if (!exhibition) {
      throw new Error('Exhibition not found');
    }
	
    if (this.validateArtworkPosition(exhibition, position)){
    	await this.exhibitionRepository.save(exhibition);
    	return true;
  	} else {
      	return false;
    }
  }
  
  private validateArtworkPosition(exhibition: Exhibition, artworkId: number, position: Position) : boolean {
    // exhibition에 position 위치에 id가 artworkId인 아트워크를 배치할 수 있는지 확인하는 로직
  }
}

문제점

  • SRP 위반 : 서비스 클래스에서 직접 자식 엔티티를 업데이트하게 되면 아트워크의 배치 유효성을 판단하는 로직 (validateArtworkPosition) 을 서비스 클래스에 선언하게 되는 등 서비스 클래스의 책임을 넘어서게 된다.
    • 서비스 클래스의 주요 역할은 여러 레이어 간의 상호 작용을 조정하고 비즈니스 로직의 수행하고 조합하는데 사용되어야 한다.
  • 유연성 부족 : 서비스 클래스에서 직접 자식 엔티티인 Artwork를 조작하면 Artwork의 구조가 변경될 때, 모든 서비스 클래스에 영향을 미칠 가능성이 높아진다.
  • 테스트 복잡도 증가 : 자식 엔티티 클래스와 서비스 클래스가 강결합되어 있는 경우 서비스 클래스에 자식 엔티티 클래스까지 생성하고 관리해야 할 수 있으며, 이로 인해 서비스 클래스의 테스트 복잡도가 증가한다.
    • 서비스 클래스는 여러 레이어들의 상호 작용을 조정하는 역할을 하기 때문에 복잡한 비즈니스 로직까지 직접 포함되어 있는 경우 단위 테스트의 복잡도가 증가한다.

Case 2: 부모 엔티티에서 자식 엔티티 업데이트

class Exhibition {
  // (...생략)
  
  public updateArtworkPosition(artworkId: number, position: Position): boolean {
    const artwork = this.artworks.find((a) => a.id === artworkId);

    if (artwork && this.validateArtworkPosition(artworkId, position)) {
      artwork.setPosition(position);
      return true;
    }

    return false;
  }

  private validateArtworkPosition(artworkId: number, position: Position): boolean {
    //(... 생략)
  }
}
export class ExhibitionService {
  constructor(private readonly exhibitionRepository: IExhibitionRepository) {}

  async updateArtworkPosition(exhibitionId: number, artworkId: number, position: Position): Promise<void> {
    const exhibition = await this.exhibitionRepository.findById(exhibitionId);
    if (!exhibition) {
      throw new Error('Exhibition not found');
    }

    const updateRes = exhibition.updateArtworkPosition(artworkId, position);
    if (!updateRes) {
      // 업데이트 실패시 처리 로직 
    }
    await this.exhibitionRepository.save(exhibition);
  }
}

개선점

  • SRP : 엔티티 클래스와 서비스 클래스 간의 책임이 명확하게 분리
    • 부모 엔티티 (Exhibition) 에서 직접 자식 엔티티 (Artwork) 관련 로직을 처리하고 서비스 클래스는 비즈니스 로직을 실행하는 역할만을 수행하게 된다.
  • 유지 보수성 : 비즈니스 로직 변경이 발생할 때 Exhibition 클래스 내부에서만 수정하고 서비스 클래스까지 변경이 전파될 가능성이 낮아지므로 유지 보수성이 향상된다.
  • 캡슐화 : Artwork 내부의 상태와 동작을 숨기고 캡슐화한다. 외부에서 Artwork 를 직접 조작하는 대신 Exhibition 을 통해 상호 작용하므로 Artwork의 내부 구현을 보호할 수 있다.
  • 테스트 용이성 : ExhibitionServiceupdateArtworkPosition 메서드를 테스트할 때 해당 메서드에서 상호작용하는 부분들만 테스트하면 된다. ExhibitionupdateArtworkPosition 메서드가 정확하게 동작하는지에 대한 확인은 Exhibition을 대상으로하는 테스트의 책임이 된다.

결론

  • 엔티티 클래스와 서비스 클래스 간의 책임 분리는 소프트웨어 설계에서 매우 중요한 원칙 중 하나이다. 복잡한 비즈니스 로직이 있는 경우, 어떤 엔티티가 업데이트 책임을 가지는지 신중하게 고려해야 한다.

  • DDD에서는 외부에서 Aggregate root를 통해서만 Aggregate member의 상태를 변경할 수 있다. 위 예시에서는 ExhibitionAggregate root, ArtworkAggregate member로 볼 수 있다.

  • 서비스 클래스에서 직접 자식 엔티티를 업데이트하는 방식은 단일 책임 원칙을 위반하고, 유지 보수성과 테스트 용이성을 저해할 수 있다. 대신, 부모 엔티티에서 자식 엔티티를 업데이트하고, 서비스 클래스는 비즈니스 로직 실행 및 엔티티 간의 조정에 집중하도록 설계하는 것이 더 좋은 방법이다.

  • 이러한 설계 원칙을 준수하면 코드의 가독성과 유지 보수성이 향상되며, 테스트 작성 및 관리가 용이해진다. 따라서 엔티티와 서비스 클래스 간의 책임 분리는 소프트웨어 시스템의 품질과 유지 보수성을 높이는 데 도움이 된다.

  • 하지만 모든 상황에서 엔티티 클래스에서 자식 엔티티를 직접 업데이트하는 것이 항상 부적절하다는 것은 아니다. 상황에 따라서는 다음과 같은 경우에는 이러한 접근 방식이 적절할 수 있다

    • 간단한 엔티티 관계: 엔티티 간의 관계가 간단하고 복잡한 비즈니스 로직이 필요하지 않을 때, 서비스 클래스 없이 엔티티 클래스에서 직접 업데이트하는 것이 더 간단한 방법일 수 있다.

    • 설계의 명확성: 엔티티 클래스 내에서 자식 엔티티를 업데이트하는 것이 설계의 명확성을 높일 수 있는 경우가 있다. 이 경우, 복잡성이 증가하지 않고 코드를 이해하기 쉽다면 이러한 방식을 고려할 수 있다.

    • 성능 최적화: 서비스 클래스를 거치지 않고 엔티티 클래스 내에서 업데이트하는 방식이 성능상 이점을 제공하는 경우가 있다. 예를 들어, 매우 빈번한 업데이트 작업이 필요한 경우 서비스 클래스를 거치지 않고 직접 업데이트하는 것이 더 효율적일 수 있다.

0개의 댓글