본문 바로가기
📙/Study

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

by kiwi_wiki 2024. 10. 14.

의존성 역전

코어의 서비스가 영속성 어댑터에 접근하기 위해 포트 사용

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

영속성 어댑터는 아웃고잉 어댑터다. 애플리케이션에 의해 호출될 뿐 애플리케이션을 호출하지는 않는다.

포트는 애플리케이션 서비스와 영속성 코드 사이의 간접적인 계층으로 영속성 코드를 리팩터링 하더라도 코어 코드를 변경하지 않아도 된다. 자연스럽게 런타임에도 의존성은 애플리케이션 코어에서 영속성 어댑터로 향한다. 포트가 계약을 만족하는 한 코어에 영향을 미치지 않으면서 영속성 코드를 마음껏 수정할 수 있다.

영속성 어댑터의 책임

영속성 어댑터가 하는 일

1. 입력을 받는다

포트 인터페이스를 통해 입력을 받는다. 입력 모델은 인터페이스가 지정한 도메인 엔티티나 특정 데이터베이스 연산 적용 객체가 된다.

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

데이터베이스를 쿼리 하거나 변경하는 데 사용할 수 있는 포맷으로 입력 모델을 매핑한다. 자바 프로젝트에서는 일반적으로 JPA를 사용하기 때문에 입력 모델을 데이터베이스 테이블 구조를 반영한 JPA 엔티티 객체로 매핑할 것이다. JPA나 다른 객체-관계 매핑 프레임워크 대신 데이터베이스와 통신하기 위해 어떤 기술을 사용해도 상관없다.

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

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

데이터베이스에 쿼리를 날리고 쿼리 결과를 받아온다.

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

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

5. 출력을 반환한다

포트 인터페이스 나누기

데이터베이스 연산을 정의하고 있는 포트 인터페이스를 어떻게 나눌 것인가.

 

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

단점

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

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

- 인터페이스의 일부만 모킹 하는 것은 다음에 이 테스트에서 작업하는 사람은 전체가 모킹 됐다고 기대하는 바람에 에러를 보게 될 수 있다.

 

인터페이스 분리 원칙(Interface Segregation Principle, ISP)

클라이언트가 오로지 자신이 필요로 하는 메서드만 알면 되도록 넓은 인터페이스를 특화된 인터페이스로 분리해야 한다.

인터페이스 분리 원칙 적용

각 서비스는 실제로 필요한 메서드만 의존한다. 매우 좁은 포트를 만드는 것은 코딩을 plug-and-play 경험으로 만든다. 서비스 코드를 짤 때 필요한 포트에 꽂기만 하면 된다. 응집성이 높고 함께 사용될 때가 많기 때문에 하나의 인터페이스에 묶고 싶은 데이터베이스 연산들이 있을 수 있기 때문에 모든 상황에 포트 하나당 하나의 메서드를 적용하지는 못할 수 있다.

영속성 어댑터 나누기

모든 영속성 포트를 구현하는 한, 하나 이상의 클래스 생성을 금지하는 규칙은 없다.

 

영속성 연산이 필요한 도메인 클래스는 하나당 하나의 영속성 어댑터를 구현하는 방법

하나의 애그리거트당 하나의 영속성 어댑터를 만들 수 있다

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

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

 

여러 개의 바운디드 콘텍스트의 영속성 요구사항을 분리하기 위한 좋은 토대가 된다.

각 바운디드 콘텍스트는 영속성 어댑터를 가지고 있다.

각 바운디드 컨텍스트가 영속성 어댑터들을 하나씩 가지고 있어야 한다

account 맥락의 서비스와 biling 맥락의 영속성 어댑터가 서로 접근하지 않는다. 어떤 맥락이 다른 맥락에 있는 무엇인가를 필요로 한다면 전용 인커밍 포트를 통해 접근해야 한다.

스프링 데이터 JPA 예제

package buckpal.domain;

@AllArgsConstructor(access = AccessLevel.PRIVATE) 
public classs 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(null, baselineBalance, activityWindow);
	}
    
	public static Account withId( 
			AccountId accountId,
			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) {
		//...
	}
}

이 어댑터는 데이터베이스로부터 계좌를 가져오거나 저장할 수 있어야 한다.

Account 클래스는 유효한 상태의 엔티티만 생성할 수 있는 팩토리 메서드를 제공하고 출금 전에 잔고를 확인하는 일과 같은 유효성 검증을 모든 상태 변경 메서드에서 수행하기 때문에 유효하지 않은 도메인 모델을 생성할 수 없다.

 

데이터베이스와의 통신에 스프링 데이터 JPA를 사용할 것이다.

 

account, activity 테이블

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

JPA의 @ManyToOne이나 @OneToMany 애너테이션을 이용해 ActivityJpaEntity와 AccountJpaEntity를 연결해서 관계를 표현할 수도 있지만 데이터베이스 쿼리에 부수효과가 생길 수 있기 때문에 일단 제외

 

리포지토리

interface AccountRepository extends JpaRepository<AccountJpaEntity, Long> {}
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); 
}

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

 

영속성 어댑터

@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
			.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)); 
			}
		}
    }
}

 

Account와 Activity 도메인 모델, AccountJpaEntity와 ActivityJpaEntity 데이터베이스 모델 간의 양방향 매핑이 존재한다. 그냥 JPA 애너테이션을 Account와 Activity 도메인 모델을 데이터베이스 모델로 사용하면 안 될까?

 

JPA 엔티티는 기본 생성자를 필요로 한다. 영속성 계층에서는 성능 측면에서 @ManyToOne 관계를 설정하는 것이 적절할 수 있지만 일부 데이터만 가져오는 유스케이스의 경우 도메인 모델에서는 이 관계가 반대가 되길 원하는 때도 있다. 영속성 측면과의 타협 없이 풍부한 도메인 모델을 생성하고 싶다면 도메인 모델과 영속성 모델을 따로 두고 서로 매핑하는 것이 좋다.

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

트랜잭션은 하나의 특정한 유스케이스에 대해서 일어나는 모든 쓰기 작업에 걸쳐 있어야 한다. 그래야 하나라도 실패할 경우 다 같이 롤백될 수 있다. 영속성 어댑터는 어떤 데이터베이스 연산이 같은 유스케이스에 포함되는지 모르기 때문에 영속성 어댑터 호출을 관장하는 서비스에 위임해야 한다.

 

package buckpal.application.service;

@Transactional // 모든 public 메서드를 트랜잭션으로 감싼다
public class SendMoneyService implements SendMoneyUseCase {
	...
}

서비스가 @Transactional 애너테이션으로 오염되지 않길 바란다면 AspectJ 같은 도구를 이용해 관점 지향 프로그래밍으로 트랜잭션 경계를 코드에 위빙 할 수 있다.

 

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

영속성 어댑터를 만들면 도메인 코드가 영속성과 관련된 것들로부터 분리되어 풍부한 도메인 모델을 만들 수 있다.

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

728x90
반응형