Notice
Recent Posts
Recent Comments
Link
«   2024/07   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
Archives
Today
Total
관리 메뉴

csct3434

[만들면서 배우는 클린 아키텍처] 06. 영속성 어댑터 구현하기 본문

개발 서적/만들면서 배우는 클린 아키텍처

[만들면서 배우는 클린 아키텍처] 06. 영속성 어댑터 구현하기

csct3434 2024. 3. 3. 21:14

서문

1장에서 계층형 아키텍처에서는 모든 것이 영속성 계층에 의존하게 되어 데이터베이스 주도 설계가 된다고 이야기 했다.
이번 장에서는 이러한 의존성을 역전시키기 위해 영속성 계층을 애플리케이션 계층의 플러그인으로 만드는 방법을 살펴본다.

의존성 역전

  • 위 그림은 영속성 어댑터가 애플리케이션 서비스에 영속성 기능을 제공하기 위해 어떻게 의존성 역전 원칙을 적용할 수 있을지 보여준다.
  • 애플리케이션 서비스에서는 영속성 기능을 사용하기 위해 아웃고잉 포트 인터페이스를 호출한다
  • 포트는 사실상 애플리케이션 서비스와 영속성 코드 사이의 간접적인 계층이다
    • 영속성 계층에 대한 코드 의존성을 없애기 위해 이러한 간접 계층을 추가해서 사용한다
  • 포트가 계약을 만족하는 한, 코어에 영향을 미치지 않으면서 영속성 코드를 마음껏 수정할 수 있다

영속성 어댑터의 책임

영속성 어댑터의 책임

  • 영속성 어댑터는 포트 인터페이스를 통해 입력받은 입력 모델을 JPA 엔티티, SQL 구문 등의 포맷으로 매핑하여 데이터베이스에 보낸다.
  • 핵심은 영속성 어댑터의 입출력 모델이 영속성 어댑터 내부에 있는 것이 아니라 애플리케이션 코어(포트에 정의된 모델)에 있기 때문에 영속성 어댑터 내부를 변경하는 것이 코어에 영향을 미치지 않는다

포트 인터페이스 나누기

  • 특정 엔티티가 필요로 하는 모든 데이터베이스 연산을 하나의 리포지토리 인터페이스에 넣어 두는 일반적인 방식이다
  • 문제점
    • 서비스가 인터페이스에서 단 하나의 메서드만 사용하더라도 하나의 넓은 포트 인터페이스 전체에 의존성을 갖게된다(코드에 불필요한 의존성이 생긴다)
    • 필요하지 않은 메서드에 생긴 의존성은 코드를 이해하고 테스트하기 어렵게 만든다
    • 로버트 C.마틴 왈 : “필요없는 화물을 운반하는 무언가에 의존하고 있으면 예상하지 못했던 문제가 생길 수 있다”

인터페이스 분리 원칙

  • 인터페이스 분리 원칙은 이러한 문제점의 답을 제시한다
    • 인터페이스 분리 원칙 : 클라이언트가 오직 자신이 필요로 하는 메서드에만 의존하도록 넓은 인터페이스를 특화된 인터페이스로 분리해야 한다

  • 인터페이스 분리 원칙을 적용하면 불필요한 의존성을 제거하고 기존 의존성을 눈에 더 잘 띄게 할 수 있다
  • 이제 각 서비스는 실제로 필요한 메서드에만 의존한다
  • 포트의 이름이 포트의 역할을 명확하게 잘 표현하고 있다
  • 대부분의 경우 포트당 하나의 메서드만 존재하기 때문에 테스트에서는 어떤 메서드를 모킹할지 고민할 필요가 없다
  • 이렇게 매우 좁은 포트를 만드는 것은 코딩을 플러그 앤 플레이 경험으로 만든다
    • 서비스 코드를 짤 때는 필요한 포트에 그저 꽂기만 하면 된다 (운반할 다른 화물이 없다)
  • 물론 응집성이 높고 함께 사용될 때가 많은 경우 여러 연산을 하나의 인터페이스로 묶을 수 있다

영속성 어댑터 나누기

  • 영속성 연산이 필요한 도메인 클래스(DDD에서의 애그리거트) 하나당 하나의 영속성 어댑터를 구현하는 방식을 선택할 수 있다.
    이렇게 하면 영속성 어댑터들은 각 영속성 기능을 이용하는 도메인 경계를 따라 나눠진다.

  • ‘애그리거트당 하나의 영속성 어댑터’ 접근 방식은 여러 개의 바운디드 컨텍스트의 영속성 요구사항을 분리하기 위한 좋은 토대가 된다.
  • 이 시나리오에서는 각 바운디드 컨텍스트가 영속성 어댑터를 하나씩 가지고 있어야 한다 (하나 이상일 수도 있다)
  • account 맥락의 서비스가 billing 맥락의 영속성 어댑터에 접근하지 않고, 반대로 billing의 서비스도 account의 영속성 어댑터에 접근하지 않는다
  • 어떤 맥락이 다른 맥락에 있는 무엇인가를 필요로 한다면 인커밍 포트를 통해 접근해야 한다

스프링 데이터 JPA 예제

package buckpal.adapter.persistence;

@Entity
@Table(name = "account")
@Data
@AllArgsConstructor
@NoArgsConstructor
class AccountJpaEntity {

    @Id
    @GeneratedValue
    private Long id;

}
package buckpal.adapter.persistence;

@Entity
@Table(name = "activity")
@Data
@AllArgsConstructor
@NoArgsConstructor
class ActivityJpaEntity {

    @Id
    @GeneratedValue
    private Long id;

    @Column
    private LocalDateTime timestamp;

    @Column
    private Long ownerAccountId;

    @Column
    private Long sourceAccountId;

    @Column
    private Long targetAccountId;

    @Column
    private Long amount;

}
package buckpal.adapter.out.persistence;

@RequiredArgsConstructor
@Component
class AccountPersistenceAdapter implements LoadAccountPort, UpdateAccountStatePort {

    private final SpringDataAccountRepository accountRepository;
    private final ActivityRepository activityRepository;
    private final AccountMapper accountMapper;

    @Override
    public Account loadAccount(AccountId accountId, LocalDateTime baselineDate) {
        AccountJpaEntity account =
                accountRepository.findById(accountId.getValue())
                        .orElseThrow(EntityNotFoundException::new);

        List<ActivityJpaEntity> activities =
                activityRepository.findByOwnerSince(
                        accountId.getValue(),
                        baselineDate);

        Long withdrawalBalance = orZero(activityRepository
                .getWithdrawalBalanceUntil(
                        accountId.getValue(),
                        baselineDate));

        Long depositBalance = orZero(activityRepository
                .getDepositBalanceUntil(
                        accountId.getValue(),
                        baselineDate));

        return accountMapper.mapToDomainEntity(
                account,
                activities,
                withdrawalBalance,
                depositBalance);

    }

    private Long orZero(Long value){
        return value == null ? 0L : value;
    }

    @Override
    public void updateActivities(Account account) {
        for (Activity activity : account.getActivityWindow().getActivities()) {
            if (activity.getId() == null) {
                activityRepository.save(accountMapper.mapToJpaEntity(activity));
            }
        }
    }

}
  • 앞의 그림에서 본 AccountPersistenceAdapter를 구현한 코드이다
  • 영속성 어댑터는 애플리케이션에 필요한 LoadAccountPort와 UpdateAccountStatePort라는 2개의 포트를 구현했다
  • 데이터베이스로부터 계좌를 가져오기 위해 AccountRepository로 계좌를 불러온 다음, AcitivityRepository로 해당 계좌의 특정 시간 범위 동안의 활동을 가져온다.
  • 이 시나리오에서는 Account 도메인 모델과 AccountJpaEntity 데이터베이스 모델, Activity 도메인 모델과 ActivityJpaEntity 데이터베이스 모델 간에 양방향 매핑이 존재한다. 그냥 JPA 애너테이션을 Account와 Activity 클래스로 옮기고 이걸 그대로 데이터베이스에 엔티티로 저장하면 안 되는 걸까?
    • 이런 ‘매핑하지 않기’ 전략도 유효한 전략일 수도 있다
    • 그러나 이 전략에서는 JPA로 인해 도메인 모델을 타협할 수 밖에 없다. 예를 들어, JPA 엔티티는 기본 생성자를 필요로 한다.
      또, 영속성 계층에서는 성능 측면에서 @ManyToOne 관계를 설정하는 것이 적절할 수 있지만, 예제에서는 항상 데이터의 일부만 가져오기를 바라기 때문에 도메인 모델에서는 이 관계가 반대를 되기를 원한다.
    • 그러므로 영속성 측면과의 타협 없이 풍부한 도메인 모델을 생성하고 싶다면 도메인 모델과 영속성 모델을 분리하여 매핑하는 것이 좋다.

데이터베이스 트랜잭션

  • 트랜잭션은 하나의 특정한 유스케이스에 대해서 일어나는 모든 쓰기 작업에 걸쳐 있어야 한다.
    • 그래야 그중 하나라도 실패할 경우 다 같이 롤백될 수 있기 때문이다.
    • 영속성 어댑터는 어떤 데이터베이스 연산이 같은 유스케이스에 포함되는지 알지 못하기 때문에 언제 트랜잭션을 열고 닫을지 결정할 수 없다.
  • 따라서, 트랜잭션을 설정하는 책임은 영속성 호출을 관장하는 서비스에 위임해야 한다
package buckpal.application.service;

@Transactional
public class SendMoneyService implements SendMoneyUseCase {
  ...
}
  • 자바와 스프링에서 가장 쉬운 방법은 @Transaction 애너테이션을 애플리케이션 서비스 클래스에 붙여서 스프링이 모든 public 메서드를 트랜잭션으로 감싸게 하는 것이다.
  • 만약 서비스가 @Transaction 애너테이션으로 오염되지 않고 깔끔하게 유지되길 원한다면 AspectJ 같은 도구를 이용해 AOP로 트랜잭션 경계를 코드에 위빙(weaving)할 수 있다

정리

  • 도메인 코드에 플러그인처럼 동작하는 영속성 어댑터를 만들면 도메인 코드가 영속성과 관련된 것들로부터 분리되어 풍부한 도메인 모델을 만들 수 있다
  • 좁은 포트 인터페이스를 사용하면 포트마다 다른 방식으로 구현할 수 있는 유연함이 생긴다. 심지어 포트 뒤에서 애플리케이션이 모르게 다른 영속성 기술을 사용할 수도 있다.
  • 포트의 명세만 지켜진다면 영속성 계층 전체를 교체할 수도 있다.