이번 장에서는 영속성 계층과 어플리케이션 계층의 의존성을 역전시키기 위해 영속성 계층을 어플리케이션 계층의 플러그인으로 만드는 방법에 대해 살펴본다.
[서비스(app.service)] → [포트(app.port.out)] ← [영속성 어댑터(adapter.out.persistence)]
- 어플리케이션 서비스에서는 영속성 기능을 사용하기 위해 포트 인터페이스를 호출한다
- 포트는 데이터베이스와 통신할 책임을 가진 영속성 어탭터 클래스에 의해 구현된다
- 육각형 아키텍처에서 영속성 어댑터는 애플리케이션에 의해 호출될뿐, 호출 하지 않는 주도되는(아웃고잉) 어댑터이다.
- 포트는 어플리케이션 서비스의 영속성 계층에 대한 의존성을 없애기 위한 둘 사이의 간접적인 계층이다. → 양쪽 둘다 서로에게 영향받지 않으면서 코드를 수정/작성 하게 해준다.
- 입력을 받는다
- 입력을 데이터베이스 포맷으로 매핑한다
- 입력을 데이터베이스로 보낸다
- 데이터베이스 출력을 애플리케이션 포맷으로 매핑한다
- 출력을 반환한다
- 위의 단계에서 입력모델은 포트 인터페이스가 지정한 도메인 엔티티 혹은 DB연산 전용 객체가 된다.
- 영속성 어댑터는 위의 입력모델을 쿼리작성이나 DB변경에 용이한 포맷으로 매핑한다. → ex. JPA 의 경우 입력모델 → JPA entity
- 4번에서 DB에서 원하는 결과가 출력 되도록 하는 기술에는 큰 제약이 없다.
- 입력모델은 어플리케이션 계층에 있으므로 포트 인터페이스의 명세만 지킨다면 영속성 어댑터의 내부 변경은 코어에 영향을 미치지 않는다.
- 출력모델 또한 어플리케이션 코어에 위치한다 → DB의 응답을 출력모델로 매핑하여 반환한다.
- 일반적인 포트 인터페이스 구성 → 특정 엔티티가 필요로 하는 모든 데이터베이스 연산을 하나의 레포지토리 인터페이스에 넣어둔다
-
서비스에서 단 하나의 인터페이스 메서드만 사용해도 ‘넓은’ 포트 인터페이스에 의존성을 갖게된다
→코드에 불필요한 의존성이 생기게 된다
-
서비스의 단위 테스트에서 넓은 포트 인터페이스의 일부만 모킹하는 경우에도 문제가 발생할수 있다 →확인할게 많아진다
→ 클라이언트가 오로지 자신이 필요로 하는 메서드만 알면 되도록 넓은 인터페이스를 특화된 인터페이스로 분리해야한다.
결과
-
각 서비스는 실제로 필요한 메서드에만 의존하게 된다.
-
포트 인터페이스의 이름이 그 역할을 명확하게 잘 표현하고 있다.
→ 테스트에서 어떤 메서드를 모킹할지 고민할 필요가 없어진다.
-
위 그림처럼 매우 좁은 포트를 만드는 것은 ‘plug-and-play’ 를 가능하게 한다. → 서비스코드를 짤 때 필요한 포트에 그저 ‘꽂기만’ 하면 된다.
물론 모든 상황에 포트당 하나의 메서드를 적용하기는 힘들다. 응집성이 높고 함께 사용될 때가 많으면 하나의 인터페이스에 묶을 수 있다
-
이전 그림에서는 모든 영속성 포트를 단 하나의 영속성 어댑터 클래스가 구현하고 있다.
→ 분리해서 구현하는 방법을 생각해 볼 수 있다
분리하는 방식은 다양하다
-
영속성 연산이 필요한 도메인 클래스 하나당
-
DDD에서의 애그리거트 하나당
→ 여러 개의 bounded context(도메인간의 경계)의 영속성 요구사항을 분리하기 좋은 토대가 된다.
-
JPA 어댑터 또는 SQL 어댑터 하나당
→ 하나의 어댑터 클래스로 구현할 수 있다
바운디드 컨텍스트
-
경계를 암시하는 표현이다
-
그림에서 각 바운디드 컨텍스트는 영속성 어댑터를 하나 이상씩 가지고 있다
→하나의 맥락(그림에서 account,billing) 은 다른 맥락의 접근을 하지 않는다는 의미이다
-
어떤 맥락이 다른 맥락에 무언가 필요로 한다면 전용 인커밍 포트를 통해 접근해야 한다.
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));
}
}
}
}
- ‘매핑하지 않기' 전략도 유효한 전략일 수 있다 →Acount , Activity를 JPA entity로 사용하고 바로 저장하는 방법
- 위의 방법 처럼 하면 JPA로 인해 도메인 모델을 타협할 수 밖에 없다. →JPA의 문법, 성능적인 요구사항에 따라 어플리케이션 계층의 도메인 entity가 바뀔 수 있다.
- 영속성 측면과의 타협 없이 풍부한 도메인 모델을 생성하고자 한다면 도메인 모델과 영속성 모델을 매핑하는 것이 좋다
- 영속성 어댑터는 어떤 데이터베이스 연산이 같은 useCase에 포함되는지 알지 못하기 때문에 트랜잭션의 경계에 대한 결정은 영속성 어댑터 호출을 관장하는 서비스가 위임해야 한다
- 구현 방법 → 어플리케이션 서비스 클래스에 @Tranactional 어노테이션을 붙여 스프링이 모든 메서드를 트랜잭션으로 감싸게 하는 것이다.
- 도메인 코드에 플러그인 처럼 동작하는 영속성 어댑터를 만들면 , 도메인 코드가 영속성과 관련된 것들로 부터 분리되어 풍부한 도메인 모델을 만들수 있다.
- 좁은 포트 인터페이스로 포트마다 다른 방식으로 구현할 수 있는 유연함이 생긴다. →같은 기술 내의 다른 방식뿐 아니라 다양한 영속성 기술을 사용할 수도 있다.
- 포트 인터페이스의 명세만 지켜진다면 영속성 계층 전체를 교체할 수도 있다.