Skip to content

Latest commit

 

History

History
308 lines (232 loc) · 11.1 KB

06_영속성 어댑터 구현하기.md

File metadata and controls

308 lines (232 loc) · 11.1 KB

06.영속성 어댑터 구현하기

이번 장에서는 영속성 계층과 어플리케이션 계층의 의존성을 역전시키기 위해 영속성 계층을 어플리케이션 계층의 플러그인으로 만드는 방법에 대해 살펴본다.

의존성 역전

[서비스(app.service)] → [포트(app.port.out)][영속성 어댑터(adapter.out.persistence)]


  • 어플리케이션 서비스에서는 영속성 기능을 사용하기 위해 포트 인터페이스를 호출한다
  • 포트는 데이터베이스와 통신할 책임을 가진 영속성 어탭터 클래스에 의해 구현된다
  • 육각형 아키텍처에서 영속성 어댑터는 애플리케이션에 의해 호출될뿐, 호출 하지 않는 주도되는(아웃고잉) 어댑터이다.
  • 포트는 어플리케이션 서비스의 영속성 계층에 대한 의존성을 없애기 위한 둘 사이의 간접적인 계층이다. → 양쪽 둘다 서로에게 영향받지 않으면서 코드를 수정/작성 하게 해준다.

영속성 어댑터의 책임


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

  • 위의 단계에서 입력모델은 포트 인터페이스가 지정한 도메인 엔티티 혹은 DB연산 전용 객체가 된다.
  • 영속성 어댑터는 위의 입력모델을 쿼리작성이나 DB변경에 용이한 포맷으로 매핑한다. → ex. JPA 의 경우 입력모델 → JPA entity
  • 4번에서 DB에서 원하는 결과가 출력 되도록 하는 기술에는 큰 제약이 없다.
  • 입력모델은 어플리케이션 계층에 있으므로 포트 인터페이스의 명세만 지킨다면 영속성 어댑터의 내부 변경은 코어에 영향을 미치지 않는다.
  • 출력모델 또한 어플리케이션 코어에 위치한다 → DB의 응답을 출력모델로 매핑하여 반환한다.

포트 인터페이스 나누기


  • 일반적인 포트 인터페이스 구성 → 특정 엔티티가 필요로 하는 모든 데이터베이스 연산을 하나의 레포지토리 인터페이스에 넣어둔다

Untitled

결과

  • 서비스에서 단 하나의 인터페이스 메서드만 사용해도 ‘넓은’ 포트 인터페이스에 의존성을 갖게된다

    →코드에 불필요한 의존성이 생기게 된다

  • 서비스의 단위 테스트에서 넓은 포트 인터페이스의 일부만 모킹하는 경우에도 문제가 발생할수 있다 →확인할게 많아진다

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

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

인터페이스 분리 원칙을 아웃고잉 포트에 적용

Untitled 1

결과

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

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

    → 테스트에서 어떤 메서드를 모킹할지 고민할 필요가 없어진다.

  • 위 그림처럼 매우 좁은 포트를 만드는 것은 ‘plug-and-play’ 를 가능하게 한다. → 서비스코드를 짤 때 필요한 포트에 그저 ‘꽂기만’ 하면 된다.

물론 모든 상황에 포트당 하나의 메서드를 적용하기는 힘들다. 응집성이 높고 함께 사용될 때가 많으면 하나의 인터페이스에 묶을 수 있다

영속성 어댑터 나누기

  • 이전 그림에서는 모든 영속성 포트를 단 하나의 영속성 어댑터 클래스가 구현하고 있다.

    → 분리해서 구현하는 방법을 생각해 볼 수 있다

분리하는 방식은 다양하다

  1. 영속성 연산이 필요한 도메인 클래스 하나당

  2. DDD에서의 애그리거트 하나당

    → 여러 개의 bounded context(도메인간의 경계)의 영속성 요구사항을 분리하기 좋은 토대가 된다.

  3. JPA 어댑터 또는 SQL 어댑터 하나당

    → 하나의 어댑터 클래스로 구현할 수 있다

Untitled 2

바운디드 컨텍스트

  • 경계를 암시하는 표현이다

  • 그림에서 각 바운디드 컨텍스트는 영속성 어댑터를 하나 이상씩 가지고 있다

    →하나의 맥락(그림에서 account,billing) 은 다른 맥락의 접근을 하지 않는다는 의미이다

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

스프링 데이터 JPA예제

Domain

@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Account {
/*
4장의 도메인 예제와 같이 모든 상태변경 메서드에서 유효성 검증을 하고있다 (ex.출금전 잔고 확인)
*/
	@Getter private final AccountId id;
	@Getter private final ActivityWindow activityWindow;
	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){...}
}

JPA entity

  • @OneToMay, @ManyToOne 같은 연관관계는 일단 제외하기로 결정했다. →데이터베이스 쿼리에 부수효과가 생길 수 있기 때문

AccountJpaEntity

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

}

ActivityJpaEntity

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

SpringDataAccountRepository

import org.springframework.data.jpa.repository.JpaRepository;
/*JPA가 기본적으로 제공하는 CRUD 기능*/
interface SpringDataAccountRepository extends JpaRepository<AccountJpaEntity, Long> {
}

ActivityRepository

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

AccountPersistenceAdapter

@RequiredArgsConstructor
@PersistenceAdapter
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));
/*
데이터베이스의 응답으로 조회한
				account,
				activities,
				withdrawalBalance,
				depositBalance 를
도메인엔티티(출력모델)로 매핑한 후 반환한다.
*/

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

	}

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

/*
ID가 없는 활동은 새로 들어온 (getActivities에서 조회 되지 않은) 활동 -> 저장한다
*/
	@Override
	public void updateActivities(Account account) {
		for (Activity activity : account.getActivityWindow().getActivities()) {
			if (activity.getId() == null) {
				activityRepository.save(accountMapper.mapToJpaEntity(activity));
			}
		}
	}

}

Domain Entity ←→ JPA Entity 간의 양방향 매핑 이유

  • ‘매핑하지 않기' 전략도 유효한 전략일 수 있다 →Acount , Activity를 JPA entity로 사용하고 바로 저장하는 방법
  • 위의 방법 처럼 하면 JPA로 인해 도메인 모델을 타협할 수 밖에 없다. →JPA의 문법, 성능적인 요구사항에 따라 어플리케이션 계층의 도메인 entity가 바뀔 수 있다.
  • 영속성 측면과의 타협 없이 풍부한 도메인 모델을 생성하고자 한다면 도메인 모델과 영속성 모델을 매핑하는 것이 좋다

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

  • 영속성 어댑터는 어떤 데이터베이스 연산이 같은 useCase에 포함되는지 알지 못하기 때문에 트랜잭션의 경계에 대한 결정은 영속성 어댑터 호출을 관장하는 서비스가 위임해야 한다
  • 구현 방법 → 어플리케이션 서비스 클래스에 @Tranactional 어노테이션을 붙여 스프링이 모든 메서드를 트랜잭션으로 감싸게 하는 것이다.

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

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