슬기로운 개발생활

Service와 Repository를 완전히 분리하기 (with. DDD)

by coco3o
반응형

Intro

개발 오픈 톡방에서 "Service와 Repository는 완벽히 분리되어야 한다."의 내용이 화두 되었다.
즉, "도메인은 특정 기술(인프라)에 의존하지 않고 순수하게 유지되어야 한다."는 말인데,
어떻게 하면 도메인과 인프라를 완벽히 분리할 수 있는지 알아보도록 하자.
 

Layered Architecture와 DDD(Domain Driven Design)

레이어드 아키텍처는 가장 흔하게 사용되는 아키텍처이다.
이름 그대로 프로그램 내에서 계층을 나누는 설계 방식이며, 의존의 방향성은 오직 위에서 아래로만 내려간다.
일반적으로 Presentation, Business, Persistence, DataBase의 4개 표준 레이어로 구성한다.
물론 규모에 따라 병합하기도 하며, 그 이상의 레이어로 구성하기도 한다.
 
스프링 기준 대표적인 예시

Controller - Service - Domain - Repository

각 계층은 어플리케이션 내에서 특정 역할과 관심사(화면 표시, 비즈니스 로직 수행, DB 작업 등) 별로 구분되며,
이는 레이어드 아키텍처의 강력한 기능인 '관심사의 분리(Separation of Concern)'를 의미한다.
특정 계층의 구성 요소는 해당 계층의 관련 기능만 수행해야 한다는 것이다.
 
DDD에서 Layered Architecture를 적용하면 아마 다음과 같은 구조가 일반적으로 사용될 것이다.

https://learn.microsoft.com/ko-kr/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/infrastructure-persistence-layer-design

이제 DDD 관점에서 Repository를 생각해 보자.
우리는 JPA를 사용하는 세대이므로 JPA를 기준으로 글을 작성하겠다.

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

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

위 코드는 도메인이 인프라를 의존하고 있어 관심사 분리가 되지 않고 있다.
JpaRepository를 상속받은 인터페이스를 그대로 사용하면서 jpa 메서드가 서비스 도메인에 노출되어 내가 구현하지 않은 메서드를 사용할 수 있고, 반환값으로 엔티티를 받기 때문에 구현 기술에 의존하게 되면서 문제가 생긴다.
 
서비스 도메인은 인프라의 형태에 의존적이지 않아야 한다.

인프라의 구현이 jpa이든 mybatis이든 파일 시스템이든 상관없이 서비스의 구현은 동일해야 한다는 말이다.

 

구현 기술에 대한 의존 없이 도메인을 순수하게 유지하려면 어떻게 해야 할까?

이 부분은 의존성 역전 원칙(DIP, Dependency Inversion Principle)으로 해결할 수 있다.

현재 도메인 영역이 인프라를 의존하며, 고수준 모듈이 저수준 모듈을 의존하고 있는 것으로 보인다.
이는 반대로 저수준 모듈이 고수준 모듈에 의존하게 해야 한다 라는 의미로,
도메인 → 인프라 의존 관계를 인프라 → 도메인 의존 관계로 의존의 방향을 역전시키겠다는 이야기다.

하지만 한 가지 문제가 생긴다. 코드로 확인해 보자.

// 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;
}

위와 같이 도메인에 존재하는 Repository와 인프라에 존재하는 Repository의 형태가 서로 달라져버린다.
그래서 다음과 같이 중간에 Adapter 클래스를 하나 두고, 해당 Adapter가 도메인의 Repository와 인프라의 Repository 사이의 규격을 맞춰주면 된다.

// adapter class (infra layer)
@Repository
public class MemberRepositoryImpl implements MemberRepository {
    // JpaRepository를 injection하여 필요한 기능만 래핑하여 노출한다. *SOLID 중 ISP 원칙 준수
    private final MemberJpaRepository memberJpaRepository;

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

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

        memberJpaRepository.save(entity);
    }

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

@Service
public class MemberService {
    // MemberRepository가 jpa를 사용하는지 redis를 사용하는지 전혀 모름
    private final MemberRepository memberRepository;

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

    public void doSomething() {
        // TODO
    }

}

DIP를 이용해서 도메인 모델에 존재하는 Repository를 추상화로 만들고 실제 구현을 infrastructure에서 하게 한다. 도메인 관점에서 "나는 이런 것들을 이렇게 저장할 것이고, 이렇게 불러올 거야!"라는 명세를 만들어놓고 실제 구현 기술에 대한 부분을 분리시킨다는 것이다.
즉, domain layer에서는 저장하는 방법에 대해 관심을 갖고, infrastructure layer 에서는 실제로 어떻게 저장하는지에 대해 관심을 갖는다.
이로써 도메인은 어댑터 뒤에 어떤 기술을 사용하던 상관없이 데이터를 조작하는 데에 필요한 인터페이스만을 바라보고 협력하기 때문에 repository의 형태에 의존하지 않으면서 서비스의 구현은 일관성을 가진다.
 
이렇게 분리하면 복잡성을 낮추고 확장성을 높이는 이점을 취할 수 있다.
하지만 분리하는 것이 마냥 좋은 것만은 아니며, 다음과 같은 몇 가지 문제들이 있다.

  • 너무 많은 컨버팅 코드
  • 휴먼 에러
  • 구현 기술의 강력한 기능 사용 불가

 

1. 너무 많은 컨버팅 코드

순수 도메인 객체와 영속성 객체는 분리되어야 한다.
즉, service ↔ repository의 파라미터와 반환 값은 외부에 의존적이지 않도록 컨버팅 작업이 필요하다.
만약, 하나의 애그리거트에 매우 많은 중첩 객체가 존재하면 어떻게 될까?

※ 애그리거트는 간략하게 말하자면, 같은 라이프 사이클을 가지는 관련된 객체들을 모아 하나의 단위로 취급하는 개념이다.

https://devlos.tistory.com/51#google_vignette

일반적으로 DDD에서는 하나의 애그리거트를 repository의 대상 엔티티로 삼는다.

Order라는 애그리거트가 존재할 때, 해당 애그리거트를 저장하고 로드하는 repository는 OrderRepository만 존재해야 한다는 소리다.

결국 해당 애그리거트에 포함되는 모든 entity와 value 들에 대해서 transaction consistency를 보장해야 하며, 컨버팅을 하느라 엄청난 시간을 쏟게 된다.
 

2. 휴먼 에러

위 컨버팅 문제와 직결된 문제인데, 아래 예시 코드에서 문제점을 찾아보자.

// domain pojo class
public class Order {
    private Long id;
    private String orderNum;
    private List<OrderItem> orderItems;
    private Long totalPrice;
    private String address;
    
    public Order(Long id, String orderNum, List<OrderItem> orderItems, Long totalPrice, String address) {
        this.id = id;
        this.orderNum = orderNum;
        this.orderItems = orderItems;
        this.totalPrice = totalPrice;
        this.address = address;
    }
    ...
}

위 코드는 domain의 Order이고, 아래는 infra의 Order이다.

// jpa entity class
@Entity
public class OrderEntity {
    @Id
    private Long id;
    private String orderNum;
    private List<OrderItem> orderItems;
    private Long totalPrice;
    
    public OrderEntity(Long id, String orderNum, List<OrderItem> orderItems, Long totalPrice) {
        this.id = id;
        this.orderNum = orderNum;
        this.orderItems = orderItems;
        this.totalPrice = totalPrice;
    }
    ...
}

Order객체에 address라는 필드가 추가되었는데, 누군가의 실수로 Entity에는 address가 없다.
이는 어쩌면 당연하겠지만 명시적으로 혹은 코드 상에서 domain과 infra의 연결이 분리되었기 때문에 발생하는 문제이다.
이를 컴파일 단에서 확인할 수 없으니 그만큼 안정성은 떨어질 수 있다.
 

3. 구현 기술의 강력한 기능 사용 불가

이 역시 컨버팅의 연장선이다.
Lazy Loading이나 Dirty Checking 같은 jpa에서 지원하는 기능은 영속성 계층에 의존하는 기능이다.
따라서 컨버팅 이후 해당 기능들을 사용할 수 없게 된다.

참고로 Aggregate에 대해서 Lazy Loading이 필요하지 않다고 보는 의견도 다수 존재한다.
aggregate에 value가 필요하면 한 번에 load 되어야 하고 필요하지 않으면 load 되지 않아야 하는데,
lazy loading이 필요하다는 것은 애그리거트의 설계가 잘못되었을 가능성이 있다는 말이다.

 

마무리

특정 기술에 의존하지 않는 순수한 도메인 모델 구조를 만들어봤다.
이 구조를 가지면 구현 기술이 변경되더라도 도메인이 받는 영향을 최소화할 수 있다.
하지만 구현 기술이 변경될 일이 잦을까..?🤔
실제로 repository와 도메인 모델의 구현 기술은 거의 변경되지 않는다.
변경이 거의 없는 상황에서 변경을 미리 대비하는 것은 과하다고 생각할 수 있다.
물론 프로젝트의 요구사항이나 규모, 자원 등에 따라 다른 판단이 나올 수 있기 때문에 이 부분은 도메인 모델과 구현 기술을 완전히 분리하면서 얻는 이점과 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 유지할 수 있도록 타협을 하는 것 중 선택해야 하는 문제라고 생각한다.
 
 
 
참고 자료 :
오픈 톡방
[DDD] Repository Pattern 이란, 이론편
[DDD] Repository Pattern - 실전편 (Spring 에서 DIP를 통해 Repository의 선언과 구현 분리시키기)

반응형

블로그의 정보

슬기로운 개발생활

coco3o

활동하기