6장 : 영속성 어댑터 구현하기

계층형 아키텍처에서 결국 모든 것이 영속성 계층에 의존하게 되어 ‘데이터베이스 주도 설계’가 된다고 했다. 이러한 의존성을 역전시키기 위해 영속성 계층을 애플리케이션 계층의 플러그인으로 만들어보자 !!

의존성 역전

영속성 어댑터가 애플리케이션 서비스에 영속성 기능을 제공하기 위해 어떻게 의존성 역전 원칙을 적용한다.

  • 애플리케이션 서비스에서 영속성 기능을 사용하기 위해 포트 인터페이스를 호출한다.

  • 이 포트는 영속성 작업을 수행하고 데이터베이스와 통신할 책임을 가진 영속성 어댑터 클래스에 의해 구현된다.

  • 육각형 아키텍처에서 영속성 어댑터는 '주도되는' 혹은 '아웃고잉' 어댑터다. 애플리케이션에 의해 호출될 뿐, 애플리케이션을 호출하지는 않는다.

예를 들면

영속성 계층의 코드를 변경하는 중에 버그가 생기면 애플리케이션 코어의 기능은 망가질 것이지만, 포트가 계약을 만족하는 한, 코어에 영샹을 미치지 않으면서 영속성 코드를 마음껏 수정할 수 있다.

영속성 어댑터의 책임

  1. 입력을 받는다.

    • 영속성 어댑터는 포트 인터페이스를 통해 입력을 받는다. 입력 모델은 인터페이스가 지정한 도메인 엔티티나 특정 데이터베이스 연산 전용 객체가 될 것이다.

  2. 입력을 데이터베이스 포맷으로 매핑한다.

    • 영속성 어댑터는 데이터베이스를 쿼리하거나 변경하는 데 사용할 수 있는 포맷으로 입력 모델을 매핑한다.

    • 자바 프로젝트에서는 데이터베이스와 통신할 때 일반적으로 JPA(Java Persistence API)를 사용하기 때문에 입력 모델을 데이터베이스 테이블 구조를 반영한 JPA 엔티티 객체로 매핑한다.

    ✔️ 맥락에 따라 입력 모델을 JPA 엔티티로 매핑하는 것이 들이는 노력에 비해 얻는 것이 많지 않은 일이 될 수도 있으므로 8장에서는 매핑하지 않는 전략에 대해서도 살펴본다.

    ✔️ JPA나 다른 객체-관계 매핑 프레임워크 대신, 데이터베이스와 통신하기 위해 어떤 기술을 사용해도 상관없다. 입력 모델을 평범한 SQL 구문에 매핑해서 데이터베이스에 보내도 되고, 들어오는 데이터를 파일로 직렬화해서 그것으로부터 데이터를 읽어와도 된다. 핵심은 영속성 어댑터의 입력 모델이 영속성 어댑터 내부에 있는 것이 아니라 애플리케이션 코어에 있기 때문에 영속성 어댑터 내부를 변경하는 것이 코어에 영향을 미치지 않는다는 것이다.

  3. 입력 데이터베이스로 보낸다.

    • 영속성 어댑터는 데이터베이스에 쿼리를 날리고 쿼리 결과를 받아온다.

  4. 데이터베이스 출력을 애플리케이션 포맷으로 매핑한다.

    • 데이터베이스 응답을 포트에 정의된 출력 모델로 매핑한다. 출력 모델이 영속성 어댑터가 아니라 애플리케이션 코어에 위치한다.

  5. 출력을 반환한다.

포트 인터페이스 나누기

특정 엔티티가 필요로 하는 모든 데이터베이스 연산을 하나의 리포지토리 인터페이스에 넣어 둔다.

  • 문제

    • 데이터베이스 연산에 의존하는 각 서비스는 인터페이스에서 단 하나의 메서드만 사용하더라고 하나의 '넓은' 포트 인터페이스에 의존성을 갖게 된다.

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

예를 들면

RegisterAccountService의 단위 테스트를 작성한다고 생각해보자. AccountRepository 인터페이스의 어떤 메서드를 모킹해야 할까 ?

먼저 서비스가 실제로 AccountRepository의 어떤 메서드를 호출하는지 찾아야 한다. 인터페이스의 일부만 모킹하는 것은 또 다른 문제로 이어지는데, 다음에 이 테스트에서 작업하는 사람은 인터페이스 전체가 모킹됐다고 기대하는 바람에 에러를 보게 될 수 있다.

필요없는 화물을 운반하는 무언가에 의존하고 있으면 예상하지 못했던 문제가 생길 수 있다.

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

  • 장점

    • 각 서비스는 실제 필요한 메서드에만 의존한다.

    • 포트의 이름이 포트의 역할을 명확하게 잘 표현하고 있다.

    • 포트당 하나의 메서드만 있을 것이므로, 테스트에서 어떤 메서드를 모킹할지 고민할 필요가 없다.

영속성 어댑터 나누기

영속성 연산이 필요한 도메인 클래스(또는 DDD에서의 '애그리거트') 하나당 하나의 영속성 어댑터 구현한다.

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

  • 영속성 어댑터를 훨씬 더 많은 클래스로 나눌 수도 있다.

예를 들면

JPA나 OR 매퍼를 이용한 영속성 포트도 구현하면서 성능을 개선하기 위해 평범한 SQL을 이용하는 다른 종류의 포트도 함께 구현하는 경우가 여기에 해당한다.

그 후에 JPA 어댑터 하나와 평이한 SQL 어댑터 하나를 만들고 각각이 영속성 포트의 일부분을 구현하면 된다.

바운디드 컨텍스트 간 경계를 명확하게 구분한다.

  • 각 바운디드 컨텍스트는 영속성 어댑터를 하나씩 가지고 있따.

  • '바운디드 컨텍스트'라는 표현은 경계를 암시한다.

  • 어떤 맥락이 다른 맥락에 있는 무엇인가를 필요로 한다면 전용 인커밍 포트를 통해 접근해야 한다.

스프링 데이터 JPA 예제

Account Entity snippet

  • Account 클래스는 getter와 setter만 가진 간단한 데이터 클래스가 아니며 최대한 불변성을 유지하려 한다.

  • 유효한 상태의 Account 엔티티만 생성할 수 있는 팩터리 메서드를 제공한다.

  • 출금 전에 계좌의 잔고를 확인하는 일과 같은 유효성 검증을 모든 상태 변경 메서드에서 수행하기 때문에 유효하지 않은 도메인 모델을 생성할 수 없다.

@AllArgsContructor(access = AccessLevel.PRIVATE)
public class Account {
    
    @Getter private final AccountId id;
    @Getter private final ActivityWindow activityWindow;
    private final Money baselineBalance;
    
    public static Account withoutId(
            Money baselineBalance,
            ActivityWindow activityWindow) {
        return new Account(accountId, baselineBalance, activityWindow); 
    }
    
    public Money calculateBalance() {
        // ... 
    }
    
    public boolean withdraw(Money money, AccountId targetAccountId) {
        // ...
    }
    
    public boolean deposit(Money money, AccountId sourceAccountId) {
        // ...
    }
}

AccountJpaEntity snippet

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

@Entity
@Table(name = "account")
@Data
@AllArgsConstructor
@NoArgsConstructor
class AccountJpaEntity {
    
    @Id
    @GeneratedValue
    private Long id;
    
}

Activity

@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;
    @Coulmn private Long targetAccountId;
    @Column private Long amount;
}

AccountRepository

interface AccountRepository extends JpaRepository<AccountJpaEntity, Long> {
}

ActivityRepository

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

interface ActivityRepository extends JpaRepository<ActivityJpaEntity, Long> {

    @Query("selec 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.timestamp < :until")
    Long getDepositBalanceUntil(
        @Param("accountId") Long accountId,
        @Param("until") LocalDateTime until);
        
    @Query("select sum(a.amount) from ActivityJpaEntity a " + 
        "where a.sourceAccountId = :accountId " + 
        "where a.ownerAccountId = :accountId " +
        "and a.timestamp < :until")
    Long getWithdrawlBalanceUntil(
        @Param("accountId") Long accountId,
        @Param("until") LocalDateTime until);
}

AccountPersistenceAdapter

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

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

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

  • 마지막으로 이 모든 데이터를 Account 도메인 엔티티에 매핑하고 호출자에게 반환한다.

@RequiredArgsConstructor
@Component
class AccountPersistenceAdapter implements LoadAccountPort, UpdateAccountStatePort {
    
    private final AccountRepository 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
            .getDepositBalanceUntil(
                accountId.getValue(),
                baselineDate));
                
        return accountMapper.mapTopDomainEntity (
            account,
            activities, 
            withdrawalBalance,
            depositBalance);

    }
    
    private Long orZero(Long value) {
        return value == null ? 0L : value;
    }
    
    @Override
    public void updateActivities(Account account) {
        
        for(Activity activity : acount.getActivityWindow().getActivities()) {
            if(activity.getId() == null) {
                activityRepository.save(accountMapper.mapToJpaEntity(activity));
            }
        }
    }
}

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

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

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

@Transactional

  • 자바와 스프링에서 가장 쉬운 방법은 @Transactional 애너테이션을 애플리케이션 서비스 클래스에 붙여서 스프링이 모든 public 메서드를 트랜잭션으로 감싸게 하는 것이다.

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

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

DISCUSSION

Last updated