Posts [만들면서 배우는 클린 아키텍처] Chapter6 - 영속성 어댑터 구현하기
Post
Cancel

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

image

‘만들면서 배우는 클린 아키텍처’ 기술 서적을 읽고 학습 내용을 정리하기 위한 목적의 TIL 포스팅입니다🙆‍♂️ 예제코드는 깃허브 레포지토리를 참고해주세요.

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

의존성 역전

image 출처: https://jandari91.tistory.com/56

  • 영속성 어댑터는 ‘아웃고잉’ 어댑터다. 애플리케이션에 의해 호출될뿐, 애플리케이션을 호출하진 않는다.
  • 영속성 계층에 대한 코드 의존성을 없애기 위해 포트라는 간접 계층을 추가한것이다. 이렇게 되면 영속성 코드를 리팩터링하더라도 코어 코드를 변경하는 결과로 이어지지 않을것이다.

영속성 어댑터의 책임

  1. 입력을 받는다.
  2. 입력을 데이터베이스 포멧으로 매핑한다.
  3. 입력을 데이터베이스로 보낸다.
  4. 데이터베이스 출력을 애플리케이션 포맷으로 매핑한다.
  5. 출력을 반환한다.
  • 핵심은 영속성 어댑터의 입출력 모델이 영속성 어댑터 내부에 있는 것이 아니라 애플리케이션 코어에 있기 때문에 영속성 어댑터 내부를 변경하는것이 코어에 영향을 미치지 않는다는것이다.

포트 인터페이스 나누기

  • 아래 이미지처럼 모든 DB 연산을 하나의 리포지토리 인터페이스에 넣는게 일반적인 방법이다.

image 출처: https://jandari91.tistory.com/56

  • 위 방법은 각 서비스들이 인터페이스에서 단 하나의 메서드만 사용하더라도 넓은 포트 인터페이스에 대한 의존성을 갖게 된다. 코드에 불필요한 의존성이 생겼다는 뜻이다.
    • 필요치 않은 메서드에 생긴 의존성은 코드를 이해하고 테스트하기 어렵게 만든다.
    • RegisterAccountService 의 단위 테스트를 작성하려할때 AccountRepository 인터페이스의 어떤 메서드를 호출하는지 찾아 모킹해야 한다.
    • 일부만 모킹하는것은 다음 작업자가 인터페이스 전체가 모킹됐다고 기대하며 에러를 마주하게 될 수 있다. 그래서 또 다시 확인해야하는 상황이 생긴다.
  • 이 문제에 대한 해결책은 ISP(인터페이스 분리 원칙)다.
    • 클라이언트가 오로지 자신이 필요로 하는 메서드만 알면 되도록 넓은 인터페이스를 특화된 인터페이스로 분리해야 한다고 설명한다.

image 출처: https://jandari91.tistory.com/56

  • 이제 각 서비스는 실제 필요한 메서드에만 의존한다. 포트 이름이 역할을 명확하게 잘 표현한다.
  • 테스트에서는 어떤 메서드를 모킹할지 고민할 필요가 없다. 왜냐하면 대부분 포트당 하나의 메서드만 있을것이기 때문이다.
  • 매우 좁은 포트를 만드는것은 코딩을 플러그 앤드 플레이(plug and play) 경험으로 만든다. 서비스 코드 작성시 필요한 포트에 그저 ‘꽂기만’하면 된다.

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

영속성 어댑터 나누기

  • 이전 이미지에선 모든 영속성 포트를 구현한 단 하나의 영속성 어댑터 클래스가 있었다.
  • 하지만 아래 이미지와 같이 영속성 연산이 필요한 도메인 클래스(또는 DDD 에서의 ‘애그리거트’) 하나당 하나의 영속성 어댑터를 구현하는 방식을 택할수도 있다.

image 출처: https://jandari91.tistory.com/56

  • 이렇게 하면 영속성 어댑터들은 각 영속성 기능을 이용하는 도메인 경계를 따라 자동으로 나눠진다.
  • 영속성 어댑터를 훨씬 더 많은 클래스로 나눌수도 있다. 예를 들어 JPA, 매퍼, 성능을 개선하기 위한 평범한 SQL을 이용하는 다른 종류의 포트도 함께 구현하게 될수 있다. 그후에 JPA 어댑터 하나와 평이한 SQL 어댑터 하나를 만들고 각각이 영속성 포트의 일부분을 구현하면 된다.
  • 도메인 코드는 영속성 포트에 의해 정의된 명세를 어떤 클래스가 충족시키지는지에 관심없다는 사실이 중요하다.
  • ‘애그리거트당 하나의 영속성 어댑터’ 접근 방식 또한 나중에 여러 개의 바운디드 컨텍스트(bounded context)의 영속성 요구사항을 분리하기 위한 좋은 토대가 된다.
  • 청구(billing) 유스케이스를 책임지는 바운디드 컨텍스트가 정의되면 아래와 같이 될것이다.

image 출처: https://jandari91.tistory.com/56

  • 만약 하나의 바운디드 컨텍스트 맥락에서 다른 맥락에 있는 무언가를 필요로 한다면 전용 인커밍 포트를 통해 접근해야 한다.

스프링 데이터 JPA 예제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package buckpal.domain;

@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Account {

	@Getter private final AccountId id;
	@Getter private final Money baselineBalance;
	@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, AccoutId targetAccountId) {
    // ...
  }

  public boolean deposit(Money money, AccoutId sourceAccountId) {
    // ...
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
package buckpal.adapter.persistence;

@Entity
@Table(name = "account")
@Data
@AllArgsConstructor
@NoArgsConstructor
class AccountJpaEntity {
    @Id
    @GeneratedValue
    private Long id;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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;
}
1
2
3
4
package buckpal.adapter.persistence;

interface AccountRepository extends JpaRepository<AccountJpaEntity, Long> {
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package buckpal.adapter.persistence;

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

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package buckpal.adapter.persistence;

@RequiredArgsConstructor
@Component
class AccountPersistenceAdapter implements
		LoadAccountPort,
		UpdateAccountStatePort {

	private final AccountRepository accountRepository;
	private final ActivityRepository activityRepository;
	private final AccountMapper accountMapper;

	@Override
	public Account loadAccount(
					Account.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));
			}
		}
	}

}
  • JPA의 @ManyToOne 이나 @OneToMany 애너테이션을 이용해 ActivityJpaEntity와 AccountJpaEntity 를 연결해서 관계를 표현할수도 있었겠지만 DB쿼리에 부수효과가 생길수 있기에 일단 이부분은 제외하였다.
  • 도메인 엔티티와 JPA 엔티티를 같이 사용하는것이 유효한 전략일수도 있다. 그렇게되면 JPA로 인해 도메인 모델을 타협할수밖에 없다.
    • 예를 들어, JPA 에 맞춰 기본 생성자를 무조건 생성해줘야만 한다.
    • 또한, 영속성 계층에선 성능을 고려하여 @ManyToOne 관계를 설정하는것이 적절할수있겠지만, 예제에선 항상 데이터 일부만 가져오기를 바라기 때문에 도메인 모델에선 이 관계가 반대가 되기를 원한다.
    • 그러므로 영속성 측면과의 타협 없이 풍부한 도메인 모델을 생성하고자 한다면 도메인 모델과 영속성 모델을 분리하는것이 좋다.

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

  • 트랜잭션은 하나의 유스케이스에 대해서 일어나는 모든 쓰기 작업에 걸쳐있어햐 하기에 영속성 어댑터를 호출하는 서비스에 위임해야 한다. 영속성 어댑터는 어떻게 묶일지를 모른다.
  • AOP 로 서비스에 자동적으로 트랜잭션을 걸어주는 방법도 있다.

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

  • 도메인 코드가 영속성과 관련된것들로부터 분리되어 풍부한 도메인 모델을 만들 수 있다.
  • 좁은 포트 인터페이스를 사용하면 포트마다 다른 방식으로 구현할 수 있는 유연함이 생긴다. 심지어 포트 뒤에서 애플리케이션이 모르게 다른 영속성 기술을 사용할수도 있다.(JPA, MyBatis, QueryDSL 등) 포트 명세만 지켜진다면 영속성 계층의 전체를 다른 기술로 교체할 수도 있다.

Reference

This post is licensed under CC BY 4.0 by the author.

[만들면서 배우는 클린 아키텍처] Chapter5 - 웹 어댑터 구현하기

[만들면서 배우는 클린 아키텍처] Chapter7 - 아키텍처 요소 테스트하기