Christmas Pikachu 레이어드 아키텍처와 도메인 주도 설계(DDD)
개발일지/설계 패턴

레이어드 아키텍처와 도메인 주도 설계(DDD)

ZI_CO 2024. 11. 18.

레이어드 아키텍처(Layered Architecture)는 가장 흔하게 사용되는 아키텍처 패턴입니다. 말 그대로 프로그램 내에서 계층을 나누는 설계 방식이며, 의존의 방향은 위에서 아래로만 내려갑니다. 보통 4개의 표준 레이어(Presentation, Business, Persistence, DataBase)로 구성되며, 규모에 따라 레이어를 합치거나 추가하기도 합니다.

스프링 프레임워크를 예로 들면 다음과 같은 계층 구조를 갖게 됩니다:

  • ControllerServiceDomainRepository

각 계층은 특정 역할과 관심사(화면 표시, 비즈니스 로직 수행, DB 작업 등)별로 나누어지며, 이를 '관심사의 분리(Separation of Concern)'라고 합니다. 즉, 각 계층은 자신만의 역할을 수행해야 하며 다른 계층의 역할에 영향을 받지 않아야 합니다.

 

DDD와 레이어드 아키텍처 적용

도메인 주도 설계(DDD)에서 레이어드 아키텍처를 적용하면 보통 다음과 같은 구조를 사용하게 됩니다.

하지만 여기서 Repository에 대해 한 가지 문제를 생각해볼 필요가 있습니다. JPA를 사용하는 세대이기 때문에 JPA를 기준으로 이야기해보겠습니다.

public class MemberService {
    @Autowired
    private MemberRepository memberRepository;
    // ...
}

public interface MemberRepository extends JpaRepository<MemberEntity, Long> {}

위 코드에서는 도메인이 인프라(JPA)에 의존하고 있어, 관심사의 분리가 제대로 이루어지지 않았습니다. JpaRepository를 상속받은 인터페이스를 사용하면서 JPA 메서드가 서비스 도메인에 노출되어, 반환 값으로 엔티티를 사용하게 되고 이는 구현 기술에 대한 의존을 초래합니다.

의존성 역전 원칙(DIP) 적용하기

도메인을 순수하게 유지하려면 어떻게 해야 할까요? 이를 해결하기 위해 의존성 역전 원칙(DIP, Dependency Inversion Principle)을 적용할 수 있습니다. 현재 도메인 영역이 인프라를 의존하는 구조에서, 의존의 방향을 반대로 하여 인프라가 도메인에 의존하게 만들어야 합니다.

DIP 적용 예시

// domain layer
public interface MemberRepository {
    void save(Member member);
    Member findById(Long id);
}

// pojo class
public class Member {
    private Long id;
    private String name;
}

// infrastructure layer
interface MemberJpaRepository extends JpaRepository<MemberEntity, Long> {
    void save(MemberEntity entity);
    MemberEntity findById(Long id);
}

// jpa entity class
@Entity
class MemberEntity {
    @Id
    private Long id;
    private String name;
}

위와 같이 도메인에 있는 MemberRepository와 인프라에 있는 MemberJpaRepository의 형태가 달라지게 됩니다. 그래서 이 둘을 연결해주는 Adapter 클래스를 추가하여 이를 해결합니다.

// adapter class (infra layer)
@Repository
public class MemberRepositoryImpl implements MemberRepository {
    private final MemberJpaRepository memberJpaRepository;

    public MemberRepositoryImpl(MemberJpaRepository memberJpaRepository) {
        this.memberJpaRepository = memberJpaRepository;
    }

    @Override
    public void save(Member member) {
        MemberEntity entity = MemberEntity.from(member);
        memberJpaRepository.save(entity);
    }

    @Override
    public Member findById(Long id) {
        MemberEntity entity = memberJpaRepository.findById(id).orElse(null);
        return (entity != null) ? new Member(entity.getId(), entity.getName()) : null;
    }
}

@Service
public class MemberService {
    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public void doSomething() {
        // TODO
    }
}

위와 같이 DIP를 적용하여 도메인 모델에 있는 MemberRepository를 추상화하고, 실제 구현을 인프라에서 수행하게 합니다. 이렇게 하면 도메인은 저장 방식에 대한 세부 사항을 몰라도 되므로, 특정 기술에 의존하지 않고 순수하게 유지할 수 있습니다.

DIP 적용 시의 문제점

하지만 이렇게 분리하는 것이 항상 장점만 있는 것은 아닙니다. 몇 가지 문제점도 존재합니다.

  1. 너무 많은 컨버팅 코드: 순수 도메인 객체와 영속성 객체는 분리되어야 하기 때문에, 서비스와 레포지토리 간의 데이터를 주고받을 때 매번 컨버팅 작업이 필요합니다. 중첩된 객체가 많을수록 컨버팅 작업은 더 복잡해집니다.
  2. 휴먼 에러: 컨버팅 작업에서 실수가 발생할 수 있습니다. 예를 들어, 도메인 객체에 새로운 필드를 추가했지만 영속성 객체에는 추가하지 않는 실수가 발생할 수 있습니다.
  3. 구현 기술의 강력한 기능 사용 불가: Lazy Loading이나 Dirty Checking과 같은 JPA의 기능은 영속성 계층에 의존하는 기능이기 때문에, 이를 사용할 수 없게 됩니다.

마무리

이렇게 특정 기술에 의존하지 않는 순수한 도메인 모델 구조를 만들어 보았습니다. 이 구조는 구현 기술이 변경되더라도 도메인에 미치는 영향을 최소화할 수 있다는 장점이 있습니다. 하지만 현실적으로 구현 기술이 자주 변경되지 않는 상황에서 이러한 구조를 채택하는 것이 항상 최선은 아닙니다. 프로젝트의 요구 사항과 규모, 자원 등을 고려하여 이러한 분리가 실제로 필요한지 판단하고, 적절히 타협하는 것이 중요합니다.

댓글