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

청포도봉봉이·2025년 1월 22일
1
post-thumbnail

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

의존성 역전

그림 6.1은 영속성 어댑터가 애플리케이션 서비스에 영속성 기능을 제공하기 위해 어떻게 의존성 역전 원칙을 적용할 수 있을지 보여준다.

애플리케이션 서비스는 영속성 기능을 사용하기 위해 포트 인터페이스를 호출한다. 이 포트는 실제로 영속성 작업을 수행하고 데이터베이스와 통신할 책임을 가진 영속성 어댑터 클래스에 의해 구현된다.

포트는 영속성 계층에 대한 코드 의존성을 없애기 위해 이러한 간접 계층을 추가하고 있다. 따라서 영속성 문제에 신경 쓰지 않고 도메인 코드를 개발할 수 있다.

영속성 어댑터의 책임

영속성 어댑터는 일반적으로 다음과 같은 일을 한다.

  1. 입력을 받는다.
  2. 입력을 데이터베이스 포맷으로 매핑한다.
  3. 입력을 데이터베이스로 보낸다.
  4. 데이터베이스 출력을 애플리케이션 포맷으로 매핑한다.
  5. 출력을 반환한다.

영속성 어댑터의 입력 모델이 영속성 어댑터 내부에 있는 것이 아니라 애플리케이션 코어에 있기 때문에 영속성 어댑터 내부를 변경하는 것이 코어에 영향을 미치지 않는다.

포트 인터페이스 나누기

서비스를 구현하면서 생기는 의문을 데이터베이스 연산을 정의하고 있는 포트 인터페이스를 어떻게 나눌 것인가다.

그림 6.2처럼 특정 엔티티가 필요로 하는 모든 데이터베이스 연산을 하나의 레포지토리 인터페이스에 넣어 두는 게 일반적인 방법이다.

그럼 데이터베이스 연산에 의존하는 각 서비스는 인터페이스에서 단 하나의 메서드만 사용하더라도 하나의 '넓은' 포트 인터페이스에 의존성을 갖게 된다. 코드에 불필요한 의존성이 생겼다는 뜻이다.

맥락 안에서 필요하지 않은 메서드에 생긴 의존성은 코드를 이해하고 테스트하기 어렵게 만든다.

인터페이스 분리 원칙(Interface Segregation Principle, ISP)은 이 문제의 답을 제시한다. 이 원칙은 클라이언트가 오로지 자신이 필요로 하는 메서드만 알면 되도록 넓은 인터페이스를 특화된 인터페이스로 분리해야 한다고 설명한다.

이 원칙을 예제의 아웃고잉 포트에 적용해보면 그림 6.3과 같은 결과를 얻을 수 있다.

이제 각 서비스는 실제로 필요한 메서드에만 의존한다. 포트의 이름이 포트의 역할을 명확하게 잘 표현하고 있다. 테스트에서는 어떤 메서드를 모킹할지 고민할 필요가 없다. 왜냐하면 대부분의 경우 포트당 하나의 메서드만 있을 것이기 때문이다.

물론 모든 상황에 '포트 하나당 하나의 메서드’를 적용하지는 못할 것이다. 응집성이 높고 함께 사용될 때가 많기 때문에 하나의 인터페이스에 묶고 싶은 데이터베이스 연산들이 있을 수 있다.

영속성 어댑터 나누기

이전 그림에서는 모든 영속성 포트를 구현한 단 하나의 영속성 어댑터 클래스가 있었다. 그러나 모든 영속성 포트를 구현하는 한, 하나 이상의 클래스 생성을 금지하는 규칙은 없다.

예를 들어, 그림 6.4와 같이 영속성 연산이 필요한 도메인 클래스(또는 DDD에서의 애그리게이트) 하나 당 하나의 영속성 어댑터를 구현하는 방식을 선택할 수 있다.

이렇게 하면 영속성 어댑터들은 각 영속성 기능을 이용하는 도메인 경계를 따라 자동으로 나눠진다.

도메인 코드는 영속성 포트에 의해 정의된 명세를 어떤 클래스가 충족시키는지에 관심 없다는 사실을 기억하자. 모든 포트가 구현돼 있기만 한다면 영속성 계층에서 하고 싶은 어떤 작업이든 해도 된다.

'애그리게이트당 하나의 영속성 어댑터' 접근 방식 또한 나중에 여러 개의 바운디드 컨텍스트(bounded context)의 영속성 요구사항을 분리하기 위한 좋은 토대가 된다.

각 바운디드 컨텍스트는 영속성 어댑터를 하나씩(앞에서 설명했듯이 하나 이상일 수도 있다) 가지고 있다. '바운디드 컨텍스트’라는 표현은 경계를 암시한다. account 맥락의 서비스가 billing 맥락의 영속성 어댑터에 접근하지 않고, 반대로 billing의 서비스도 account의 영속성 어댑터의 접근하지 않는다는 의미다. 어떤 맥락이 다른 맥락에 있는 무엇인가를 필요로 한다면 전용 인커밍 포트를 통해 접근해야 한다.

스프링 데이터 JPA 예제

@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Account {
    private AccountId id;
    private Money baselineBalance;
    private ActivityWindow activityWindow;

    public static Account withoutId(
            AccountId accountId,
            Money baselineBalance,
            ActivityWindow activityWindow
    ) {
        return new Account(accountId, baselineBalance, activityWindow);
    }

    public static Account withId(
            AccountId accountId,
            Money baselineBalance,
            ActivityWindow activityWindow) {
        return new Account(accountId, baselineBalance, activityWindow);
    }

    public Money calculateBalance() {
        return Money.add(
                this.baselineBalance,
                this.activityWindow.calculateBalance(this.id));
    }

    public boolean withdraw(Money money, AccountId targetAccountId) {

        if (!mayWithdraw(money)) {
            return false;
        }

        Activity withdrawal = new Activity(
                this.id,
                this.id,
                targetAccountId,
                LocalDateTime.now(),
                money
        );
        this.activityWindow.addActivity(withdrawal);
        return true;
    }

    public boolean deposit(Money money, AccountId sourceAccountId) {
        Activity deposit = new Activity(
                this.id,
                sourceAccountId,
                this.id,
                LocalDateTime.now(),
                money
        );
        this.activityWindow.addActivity(deposit);
        return true;
    }
}

Account 클래스는 getter, setter만 가진 간단한 데이터 클래스가 아니며 최대한 불변성을 유지하려 한다는 사실을 상기하자. 유효성 상태의 Account 엔티티만 생성할 수 있는 팩터리 메서드를 제공하고 출금 전에 계좌의 잔고를 확인하는 일과 같은 유효성 검증을 모든 상태 변경 메서드에서 수행하기 때문에 유효하지 않은 도메인 모델을 생성할 수 없다.

데이터베이스와의 통신에 스프링 데이터 JPA를 사용할 것이므로 계좌의 데이터베이스 상태를 표현하는 @Entity 어노테이션이 추가된 클래스도 필요하다.

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

    @Id
    @GeneratedValue
    private Long id;
}

@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;

}

이 단계에서는 계좌의 상태가 ID 하나로만 구성돼 있다. 나중에 사용자 ID 같은 필드가 추가될 것이다. 좀 더 흥미로운 엔티티는 특정 계좌의 대한 모든 활동을 들고 있는 ActivityJpaEntity다. JPA의 @ManyToOne 이나 @OneToMany 어노테이션을 이용해 ActivityJpaEntity와 AccountJpaEntity를 연결해서 관계를 표현할 수도 있었겠지만 데이터베이스 쿼리에 부수효과가 생길 수 있기 때문에 이 부분은 제외하기로 결정했다.

다음은 기본적인 CRUD 기능과 데이터베이스에서 activity 들을 로드하기 위한 레포지토리 인터페이스다.

interface ActivityRepository extends JpaRepository<ActivityJpaEntity, Long> {

    @Query("select a from ActivityJpaEntity a " +
            "where a.ownerAccountId = :ownerAccountId " +
            "and a.timestamp >= :since")
    List<ActivityJpaEntity> findByOwnerSince(
            @Param("ownerAccountId") Long ownerAccountId,
            @Param("since") LocalDateTime since);

    @Query("select sum(a.amount) from ActivityJpaEntity a " +
            "where a.targetAccountId = :accountId " +
            "and a.ownerAccountId = :accountId " +
            "and a.timestamp < :until")
    Long getDepositBalanceUntil(
            @Param("accountId") Long accountId,
            @Param("until") LocalDateTime until);

    @Query("select sum(a.amount) from ActivityJpaEntity a " +
            "where a.sourceAccountId = :accountId " +
            "and a.ownerAccountId = :accountId " +
            "and a.timestamp < :until")
    Long getWithdrawalBalanceUntil(
            @Param("accountId") Long accountId,
            @Param("until") LocalDateTime until);

}

스프링 부트는 이 레포지토리를 자동으로 찾고, 스프링 데이터는 실제로 데이터베이스와 통신하는 레포지토리 인터페이스 구현체를 제공하는 마법을 부린다.

영속성 어댑터를 구현해보자.

@Component
@RequiredArgsConstructor
public class AccountPersistenceAdaptor 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));
            }
        }
    }
}

영속성 어댑터는 애플리케이션에 필요한 LoadAccountPort와 UpdateAccountStatePort라는 2개의 포트를 구현했다.

데이터베이스로부터 계좌를 가져오기 위해 AccountRepository로 계좌를 불러온 다음, ActivityRepository로 해당 계좌의 특정 시간 범위 동안의 활동을 가져온다.

유효한 Account 도메인 엔티티를 생성하기 위해서는 이 활동창 시작 직전의 계좌 잔고가 필요하다. 그래야 데이터베이스로부터 모든 출금과 입금 정보를 가져와 합할 수 있다. 마지막으로 이 모든 데이터를 Account 도메인 엔티티에 매핑하고 호출자에게 반환한다.

계좌의 상태를 업데이트하기 위해서는 Account 엔티티의 모든 활동을 순회하며 ID가 있는지 확인해야 한다. 만약 ID가 없다면 새로운 활동이므로 ActivityRepositoy를 이용해 저장해야 한다.

앞에서 설명한 시나리오에서는 Account와 Activity 도메인 모델, AccountJpaEntity와 ActivityJpaEntity 데이터베이스 모델 간에 양방향 매핑이 존재한다. 왜 굳이 이런 수고를 해야 할까? 그냥 JPA 애너테이션을 Account와 Acitivity 클래스로 옮기고 이걸 그래도 데이터베이스에 엔티티로 저장하면 안 되는 걸까?

8장에서 보겠지만 이런 '매핑하지 않기' 전략도 유효한 전략일 수 있다. 그러나 이 전략에서는 JPA로 인해 도메인 모델을 타협할 수 밖에 없다. 예를 들어 JPA 엔티티는 기본 생성자를 필요로 한다. 또, 영속성 계층에서는 성능 측면에서 @ManyToOne 관계를 설정하는 것이 적절할 수 있지만, 예제에서는 항상 데이터의 일부만 가져오기를 바라기 때문에 도메인 모델에서는 이 관계가 반대로 되기를 원한다.

그러므로 영속성 측면과의 타협 없이 풍부한 도메인 모델을 생성하고 싶다면 모데인 모델과 영속성 모델을 매핑하는 것이 좋다.

데이터베이스 트랜잭션은 어떻게 해야 할까?

트랜잭션 경계는 어디에 위치 시켜야 할까?

트랜잭션은 하나의 특정한 유스케이스에 대해서 일어나는 모든 쓰기 작업에 걸쳐 있어야 한다. 그래야 그중 하나라도 실패할 경우 다 같이 롤백될 수 있기 때문이다.

영속성 어댑터는 어떤 데이터베이스 연산이 같은 유스케이스에 포함되는지 알지 못하기 때문에 언제 트랜잭션을 열고 닫을지 결정할 수 없다. 이 책임은 영속성 어댑터 호출을 관장하는 서비스에 위임해야 한다.

@Transactional
public class SendMoneyService implements SendMoneyUseCase {
	// ...
}

만약 서비스가 @Transactional 어노테이션으로 오염되지 않고 깔끔하게 유지되길 원한다면 AspectJ 같은 도구를 이용해 관점 지향 프로그래밍(aspect-oriented-programming)으로 트랜잭션 경계를 코드에 위빙(weaving)할 수 있다.

유지보수 가능한 소프트웨어를 만드는 데 어떻게 도움이 될까?

도메인 코드에 플러그인처럼 동작하는 영속성 어댑터를 만들면 도메인 코드가 영속성과 관련된 것들로부터 분리되어 풍부한 도메인 모델을 만들 수 있다.

좁은 포트 인터페이스를 사용하면 포트마다 다른 방식으로 구현할 수 있는 유연함이 생긴다. 심지어 포트 뒤에서 애플리케이션이 모르게 다른 영속성 기술을 사용할 수도 있다. 포트의 명세만 지켜진다면 영속성 계층 전체를 교체할 수도 있다.

profile
서버 백엔드 개발자

0개의 댓글