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

전통적인 계층형 아키텍처에서는 결국 모든 것이 영속성 계층에 의존하게 되어 ‘데이터베이스 주도 설계’가 되는 문제가 있다.

이러한 의존성을 역전시키기 위해 영속성 계층을 애플리케이션 계층의 플러그인으로 만드는 방식으로 구현해야 한다.

의존성 역전

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

육각형 아키텍처에서 영속성 어댑터는 아웃고잉 어댑터이다. 애플리케이션에 의해 호출뿐, 애플리케이션을 호출하진 않기 때문이다.

포트는 애플리케이션 서비스와 영속성 코드 사이의 간접적인 계층으로 영속성 계층에 대한 코드 의존성을 없애기 위해 이러한 간접 계층이 추가되었다.

이제 영속성 코드를 리팩토링하더라도 코어 코드를 변경하는 결과로 이어지진 않는다.

영속성 어댑터의 책임

영속성 어댑터가 하는 일은 보통 아래와 같다.

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

중요한건 영속성 어댑터의 입력 모델이 영속성 어댑터 내부가 아닌 애플리케이션 코어에 있기 때문에 영속성 어댑터 내부를 변경하는 것이 코어에 영향을 미치지 않는다는 점이다.

출력 모델도 동일하게 애플리케이션 코어에 위치해야 한다.

포트 인터페이스 나누기

보통 특정 엔티티가 필요로 하는 모든 데이터베이스 연산을 하나의 리포지토리 인터페이스에 넣는 식으로 구현한다.

하지만 이러한 방식은 코드에 불필요한 의존성이 생기게 되는데 데이터베이스 연산에 의존하는 각 서비스는 인터페이스에 단 하나의 메서드나 사용하더라도 ‘넓은’ 포트 인터페이스에 의존성을 갖게 되는 문제가 발생한다.

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

인터페이스 분리 원칙(Interface Segregation Principle, ISP)을 적용하여 클라이언트가 오직 자신이 필요로 하는 메서드만 알게 만들어 각각의 특화된 인터페이스로 분리해야 한다.

이렇게 되면 각 서비스는 실제로 필요한 메서드에만 의존하고, 포트의 이름은 역할을 명확하게 표현할 수 있으며 서비스 코드를 짤 때 필요한 포트에 연결만 하면 된다.

영속성 어댑터 나누기

위에서 나눈 포트 인터페이스처럼 영속성 어댑터도 한개만 만들라는 규칙은 없다. 예를 들면 영속성 연산이 필요한 도메인 클래스(DDD의 애그리거트) 하나당 하나의 영속성 어댑터를 구현할 수도 있다.

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

애그리거트당 하나의 영속성 어댑터 접근 방식 또한 나중에 여러 개의 바운디드 컨텍스트(bounded context)의 영속성 요구사항을 분리하기 위한 좋은 토대가 된다. 바운디드 컨텍스트 간의 경계를 명확하게 구분하고 싶다면 각 바운디드 컨텍스트가 영속성 어댑터를 하나씩 가지고 있어야 한다.

account와 관련된 서비스가 billing과 관련된 영속성 어댑터에 접근하지 않아야 한다. 경계 너머의 다른 무언가가 필요하다면 전용 인커밍 포트를 통해 접근해야 한다.

스프링 데이터 JPA 예제

앞서 살펴본 Account 클래스는 유효한 상태의 Account 엔티티만 생성할 수 있는 팩터리 메서드를 제공하고 계좌 잔고 확인 등 유효성 검증을 모든 상태 변경 메서드에서 수행하기 때문에 유효하지 않은 도메인 모델을 생성할 수 없다. 즉, 최대한 불변성을 유지하려고 한다.

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
package buckpal.domain;

@AllArgsConstructor(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(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 어노테이션이 추가된 클래스가 필요하다.

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 LocalDataTime timestamp;
@Column private Long ownerAccountId;
@Column private Long sourceAccountId;
@Column private Long targetAccountId;
@Column private Long amount;
}

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

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

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

}

영속성 측면과의 타협 없이 풍부한 도메인 모델을 생성하고 싶다면 도메인 모델과 영속성 모델을 매핑하는 것이 좋다.

트랜잭션 경계는 어디에 위치해야 할까?

트랜잭션은 하나의 유스케이스에 대해서 일어나는 모든 쓰기 작업에 걸쳐 있어야 한다.

가장 쉬운건 @Transactional 어노테이션을 서비스 클래스에 붙여서 모든 public 메서드를 트랜잭션으로 감싸게 하는 것이다

도메인 코드에 플러그인처럼 동작하는 영속성 어댑터를 만들면 서로 분리되서 풍부한 도메인 모델을 만들 수 있고 포트의 명세만 지켜진다면 영속성 계층 전체를 교체할 수도 있다.

5. 웹 어댑터 구현하기

의존성 역전

웹 어댑터는 인커밍 어댑터로 외부로부터 요청을 받아 애플리케이션 코어를 호출하여 어떤 일을 해야할지 알려준다. 이때 제어의 흐름은 웹 어댑터 계층의 컨트롤러에서 애플리케이션 계층의 서비스로 흐른다. 애플리케이션 계층은 서비스와 통신할 수 있는 특정 포트를 중간 계층으로 구현하고 웹 어댑터는 이러한 포트를 호출할 수 있다.

여기서 가만히 보면 의존성 역전 원칙이 적용된 걸 알수 있는데 제어의 흐름이 왼쪽에서 오른쪽 즉, 웹 어댑터가 유스케이스를 직접 호출할수도 있다.

컨트롤러에서 서비스를 직접 호출하지 않고 포트를 두는 이유는 애플리케이션 코어가 외부와 통신할 수 있는 곳의명세가 바로 포트이기 때문이다.

포트를 적절한 위치에 구현하게 되면 외부와 어떤 통신을 하는지 명확하게 알기 쉬워 유지보수에 큰 도움이 된다.

웹 어댑터의 책임

일반적으로 웹 어댑터는 다음과 같은 일을 한다.

  1. HTTP 요청을 자바 객체로 매핑
  2. 권한 검사
  3. 입력 유효성 검증
  4. 입력을 유스케이스의 입력 모델로 매핑
  5. 유스케이스 호출
  6. 유스케이스의 출력을 HTTP로 매핑
  7. HTTP 응답을 반환

이 과정에서 한가지라도 예외가 발생하면 웹 어댑터는 오류를 반환단다.

웹 어댑터에 많은 기능과 책임이 들어간거 같지만 이것들은 모두 애플리케이션 계층에서 신경쓰면 안되는 것들이다.

HTTP와 관련된 로직을 애플리케이션 계층에서 알게 된다면 HTTP가 아닌 다른 통신방식을 사용하는 인커밍 포트를 구현할 때 동일한 도메인 로직을 수행할 수 없게 된다.

컨트롤러 나누기

스프링 MVC 프레임워크에서는 위에서 언급한 책임들을 수행할 컨트롤러 클래스를 구현할 수 있다. 웹 어댑터를 구성할땐 하나의 클래스에 많은 기능을 담기보단 가능하면 최대한 기능을 작게 분할하여 여러 개의 어댑터로 구성하는 편이 좋다.

계좌와 관련된 REST API를 설계한다고 할때 보통 AccountController 클래스를 만들게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RestController
@RequiredArgsConstructor
public class AccountController{
//...
@GetMapping("/accounts")
List<AccountResource> listAccounts() {}

@GetMapping("/accounts/{id}")
AccountResource getAccount(@PathVariable("accountId") Long accountId) {}

@GetMapping("/accounts/{id}/balance")
long getAccountBalance(@PathVariable("accountId") Long accountId) {}

@PostMapping("/accounts")
AccountResource createAccount(@RequestBody AccountResource account) {}

@PostMapping("/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}")
void sendMoney(
@PathVariable("sourceAccountId") Long sourceAccountId;
@PathVariable("targetAccountId") Long targetAccountId;
@PathVariable("amount") Long amount) {}
}

Account와 관련된 모든 기능이 하나의 컨트롤러에서 처리되서 효율적이고 괜찮게 보일 수 있지만 몇가지 단점이 있다.

  1. 클래스의 코드는 적을수록 좋다.
    하나의 클래스에 너무 많은 코드가 있다면 시간이 지나면서 늘어나는 코드 그 이상으로 파악하는 것에 난이도가 높아진다. 이것은 메서드를 아무리 깔끔하게 분리해놔도 쉽지 않다.
    또한 테스트 코드를 작성하더라도 프로덕션 코드에 비해 더 추상적인 테스트 코드의 특성상 클래스 단위를 작게 해야 찾기 쉬워진다.
  2. 가장 중요한 부분은 모델의 재사용을 촉진한다는 점이다.
    위 코드는 AccountResource 라는 단일 모델 클래스를 공유하는데 여기에 연산에 필요한 모든 데이터를 담다보면 특정 연산에선 필요없는 필드도 생길수 밖에 없다.
    새로운 기능을 추가하거나 기존 기능을 수정할때 불필요한 필드에 대한 고민이 들어가면서 난이도만 늘어날뿐이다.

따라서 가급적 별도의 패키지 안에 별도의 컨트롤러를 만드는 방식이 좋다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequiredArgsConstructor
public class SendMoneyController {
private final SendMoneyUseCase;

@PostMapping("/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}")
void sendMoney(
@PathVariable("sourceAccountId") Long sourceAccountId,
@PathVariable("targetAccountId") Long targetAccountId,
@PathVaribale("amount") Long amount) {

SendMoneyCommand command = new SendMoneyCommand(new AccountId(sourceAccountId), new AccountId(targetAccountId), Money.of(amount));
sendMoneyUseCase.sendMoney(command);
}
}

각 컨트롤러는 Create, Update와 같은 컨트롤러 자체의 모델을 가지고 있거나, 원시값을 받아도 된다.

이러한 전용모델 클래스는 컨트롤러의 패키지에 private 으로 선언할 수 있어서 다른 곳에서 재사용될 일이 없고 다른 컨트롤러에서 사용할때도 한번 더 생각해볼 수 있게 된다.

웹어댑터를 구현할 땐 HTTP요청이 어플리케이션의 유스케이스 호출로 변환하고 결과를 다시 HTTP로 변환하여 어떠한 도메인 로직도 수행하지 않도록 구현해야 한다.

4. 유스케이스 구현하기

앞서 설계한 패키지대로 코드를 작성하면 애플리케이션, 웹, 영속성 계층이 아주 느슨하게 결합돼 있기 때문에 필요한 대로 도메인 코드를 자유롭게 모델링할 수 있다.

육각형 아키텍처는 도메인 중심의 아키텍처에 적합하기 때문에 도메인 엔티티를 만드는 것으로 시작한 후 해당 도메인 엔티티를 중심으로 유스케이스를 구현한다.

유스케이스 둘러보기

유스케이스는 일반적으로 아래와 같은 단계를 따른다.

  1. 입력을 받는다
  2. 비즈니스 규칙을 검증한다
  3. 모델 상태를 조작한다
  4. 출력을 반환한다.

유스케이스는 인커밍 어댑터로부터 입력을 받는데 유스케이스 코드는 도메인 로직에만 집중하고 ‘입력 유효성 검증’은 다른 곳에서 처리하는게 좋다.

그러나 유스케이스는 비즈니스 규칙을 검증할 책임이 있고 도메인 엔티티와 이 책임을 공유한다.

입력 유효성 검증

입력 유효성 검증이 유스케이스의 책임이 아니라도 애플리케이션 계층의 책임은 맞다. 유효성 검증을 하지 않으면 애플리케이션 코어의 바깥쪽으로부터 유효하지 않은 입력값을 받게 되고, 모델의 상태를 해칠 수 있다.

입력 모델(input model)에서 이러한 검증을 담당해보자.

유스케이스에서 입력에 사용할 모델 클래스의 생성자 내에서 특정 조건에 위배될 경우 객체 생성 자체를 막고 예외를 던지면 될 것이다.

또한 필드에 final을 붙여서 불변 필드로 만들어 생성에 성공하면 유효한 상태를 유지하고 잘못된 상태로 변경할 수 없다는 사실을 보장할 수 있다.

사실 이런 기능들은 Bean Validation API를 사용하면 편하게 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
@Getter
public class SendMoneyCommand extends SelfValidating<SendMoneyCommand> {
@NotNull
private final Money;

public SendMoneyCommand(Money money){
this.money = money;
requiredGreaterThan(money, 0);
this.validateSelf();
}
}

SelfValidating 추상 클래스는 validateSelf() 메서드를 제공하여 호출하면 필드에 지정된 Bean Validation 어노테이션(@NotNull 같은)을 검증하고 유효성 검증 규칙을 위반한 경우 예외를 던진다.

생성자의 힘

필드가 많아질 경우 빌더패턴을 도입해서 생성자를 private으로 만들고 빌더의 build() 메서드 내부에 생성자 호출을 숨길 수 있다.

다만, 만약 빌더와 생성자에 새로운 필드를 추가하고 빌더를 호출하는 코드에 새로운 필드를 추가하는 걸 깜빡하더라도 컴파일러는 이러한 시도에 대해서 경고해주지 못할 수 있다.

빌더 뒤에 숨기지 말고 생성자를 직접 사용했다면 컴파일 에러에 따라 나머지 코드에 변경사항을 반영할 수 있을 것이다.

  • 난 저자랑 생각이 다름

    저자가 예시에서 파라미터가 20개인 생성자를 호출하는 대신 빌더를 사용하면 이러이러 하다 라고 말했는데 코드의 가독성 측면에서 빌더패턴이 훨씬 보기 좋다고 생각한다.

    빌더 호출 코드에 깜빡할 경우가 얼마나 될것이며 단위테스트 과정에서 어느정도 걸러낼 수 있다고 생각된다.

    오히려 생성자의 파라미터 20개가 모두 String 타입일 경우 지저분한 코드를 보는게 더 스트레스 받을거 같다.

유스케이스마다 다른 입력 모델

각기 다른 유스케이스에 동일한 모델을 사용하고 싶은 경우가 있다.

계좌 등록하기계좌 업데이트 하기라는 두개의 기능을 구현할 때 계좌에 대한 등록시점에선 소유권을 체크하기 위해 계좌의 소유자 ID 필드가 무조건 필요하지만 업데이트는 계좌번호만 체크한다고 했을때 ID 필드는 null을 허용하도록 만들수 있다.

일단 불변 도메인 모델에 null을 허용하는 것부터 일단 코드 스멜이 난다고 볼수 있다.(잠재적으로 side-Effect가 발생할수도 있는 코드를 말한다.)

단일책임원칙을 고려했을 때 각 유스케이스의 전용 입력모델을 사용하는게 결합도도 낮추고 부수효과도 줄일 수 있는 방법이다. 다만, 모든 입력 데이터에 대해서 각 유스케이스 별 모델 매핑을 해줘야하는 비용이 있지만 매핑 전략에 대해선 후술 한다.

비즈니스 규칙 검증하기

검증은 크게 두가지를 생각해볼 수 있다.

  1. 입력 유효성 검증
  2. 비즈니스 규칙 검증

둘을 구분하는 가장 실용적인 방법은 특정 검증이 도메인의 상태에 접근이 필요한지? **를 생각해보면 된다. 가령 단순히 입력 데이터의 유효성 체크는 도메인의 상태를 체크할 필요 없이 선언적으로 검증이 가능하지만 비즈니스 규칙은 좀 더 맥락을 이해하고 구현할 필요가 있다.

“출금계좌는 초과출금될 수 없다.” 라는 규칙을 검증할땐 출금계좌의 존재여부 부터 체크하는 등 도메인의 상태에 접근해야 하지만
”출금금액은 0보다 커야한다.” 라는 규칙은 도메인의 상태에 접근없이 입력 데이터를 단순히 체크하기만 하면 된다

비즈니스 규칙 검증은 보통 도메인 엔티티 내부에 직접 구현하는게 가장 좋다.

1
2
3
4
5
6
7
8
9
public class Account {
//...
public boolean withDraw(Money money, AccountId targetAccountId) {
if(!mayWithDraw(money)){
return false;
}
//...
}
}

이렇게 하면 지켜야하는 비즈니스 로직 옆에 있기 때문에 위치를 정하기도, 추론하기도 쉽다.

만약 엔티티 내부에 위치하기 어렵다면 유스케이스에서 도메인을 사용하기 전에 검증하는 방식도 있을 것이다.

1
2
3
4
5
6
7
8
9
10
11
@RequriedArgsConstruct
@Transactional
public class SendMoneyService implements SendMoneyUseCase {
//...
@Override
public void SendMoney(SendMoneyCommand command){
requriedSourceAccount(command.getSourceAccountId());
requriedTargetAccount(command.getTargetAccountId());
//...
}
}

유효성 검증 코드를 실행 후 오류가 발생하면 유효성 전용 예외처리를 통해 사용자

풍부한 도메인 모델 vs 빈약한 도메인 모델

  • 풍부한 도메인 모델 : 애플리케이션의 코어에 있는 도메인 엔티티에 최대한 많은 로직이 담겨있다. 도메인의 상태를 변경하는 메서드를 제공하고 비즈니스 규칙 검증에 유효한 값만 허용한다.
  • 빈약한 도메인 모델 : 도메인 엔티티는 최대한 적은 로직을 가지고 있다. 보통 getter, setter를 제외한 다른로직은 모두 유스케이스에 구현한다.

결국 복잡한 비즈니스 모델이 어디에 있냐의 차이로 스타일의 차이라고 봐도 무방할 거 같다.

유스케이스마다 다른 출력모델

입력에 대한 처리가 끝나면 출력을 해야하는데 이때도 동일하게 각 유스케이스마다 다른 출력모델을 사용하는게 좋다.

어떤 출력에선 Account 모델 자체를 받고 싶어할 수도 있고, 단순히 성공실패 여부 등 boolean 값만을 받고 싶을 수도 있다.

정답은 없지만 명확한 규칙이 없다면 최대한 작은 데이터를 반환하는게 좋다. 모델 클래스를 통째로 반환하면 강한 결합이 일어나는데 한 유스케이스의 출력 모델에 새로운 필드가 추가될 경우 동일한 모델을 공유하는 다른 유스케이스에서도 해당 필드를 처리해야 하는 것처럼 영향이 있기 때문에 모델은 구체적이고 작게 결합은 약하게 하는게 좋다.

같은 이유로 도메인 엔티티 자체를 출력모델로 사용하는 것도 최대한 자제해야한다.

읽기 전용 유스케이스는 어떨까?

상태를 변경할 필요 없이 단순히 DB를 조회해서 값을 반환하기만 하는 읽기 전용 유스케이스를 구현해야 할때 상태변경 유스케이스와 동일한 형식으로 만들게 되면 간단한 기능에 비해 구현해야하는 것들이 많아질 수 있다. 간단히 쿼리만 조회해야 한다면 쿼리 서비스를 만들 수 있다.

인커밍 전용 포트를 만들어서 쿼리 서비스에서 구현하는 것이다.

1
2
3
4
5
6
7
8
9
10
@RequiredArgsConstructor
public class GetAccountQueryService implements GetAccountBalanceService {
private final LoadAccountPort loadAccountPort;

@Override
public Money getAccountBalance(AccountId accountId) {
return loadAccountPort.loadAccount(accountId, LocalDateTime.now())
.calculateBalance();
}
}

쿼리서비스는 유스케이스 서비스와 동일한 방식으로 동작하는데 GetAccountBalanceQuery 라는 인커밍 포트를 구현하고, 데이터베이스로부터 실제로 데이터를 로드하기 위해 LoadAccountPort라는 아웃고잉 포트를 호출한다.

이처럼 읽기 전용 쿼리는 쓰기가 가능한 유스케이스(or 커맨드)와 코드 상에서 명확히 구분되는데 이런 방식은 CQS(Command-Query Separation)나 CQRS(Command-Query Responsibility Segregation) 같은 개념과 잘 맞는다.

2. 의존성 역전하기

계층형 아키텍처의 대안을 이야기 하기전에 SOLID 원칙의 ‘S’와 ‘D’를 담당하는 아래 원칙들을 먼저 살펴보자.

단일 책임 원칙(Single Responsibility Principle, SRP)

  • 하나의 컴포넌트는 오로지 한 가지 일만 해야 하고, 그것을 올바르게 수행해야 한다.
    • 이 말은 실제 의도와는 조금 다른 오해가 발생할 여지가 있으니 아래 정의가 좀 더 정확하다고 볼 수 있다.
  • 컴포넌트를 변경하는 이유는 오직 하나뿐이어야 한다.

만약 컴포넌트를 변경할 이유가 한 가지라면 우리가 “어떤 다른 이유로” 소프트웨어를 변경하더라도 이 컴포넌트에 대해선 전혀 신경 쓸 필요가 없다.

하지만 변경할 이유라는건 컴포넌트 간의 의존성을 통해 너무 쉽게 전파된다.
A 컴포넌트는 B,C,D,E에 의존하고 E는 다른 의존성이 없다면
A는 다른 B,C,D,E가 바뀔 때 함께 바뀌어야 하지만 E는 E에 기능이 추가,변경될 때만 바뀌게 될 것이다.

의존성 역전 원칙(Dependency Inversion Principle, DIP)

계층형에서 계층 간 의존성은 항상 다음 계층인 아래 방향을 가리킨다. 단일 책임 원칙을 고수준에서 적용할 경우 상위 계층들이 하위 계층들에 비해 변경할 이유가 더 많다.

그러므로 영속성 계층에 대한 도메인 계층의 의존성 때문에 영속성 계층을 변경할 때마다 잠재적으로 도메인 계층도 변경해야 한다. 하지만 도메인 코드는 애플리케이션에서 가장 중요한 코드인데 영속성 코드가 바뀐다고 도메인 코드까지 바꾸는게 맞을까? 이 의존성은 어떻게 제거할 수 있을까? 의존성 역전 원칙은 말 그대로의 의미이다.

  • 코드 상의 어떤 의존성이든 그 방향을 바꿀 수(역전시킬 수) 있다.
    • 단, 서드파티 라이브러리처럼 제어할 수 없는 코드에 의존성을 가지고 있다면 역전이 불가능하다.

일반적으로 보기 쉬운 계층형 구조의 서비스가 있다. 도메인 계층의 서비스는 영속성 계층의 엔티티와 리포지토리와 상호작용한다.

엔티티는 도메인 객체를 표현하고 도메인 코드는 이러한 엔티티의 상태를 변경하는 일을 중심으로 하니까 일단 엔티티를 도메인 계층으로 올려보면 영속성의 리포지토리가 도메인의 엔티티를 의존하는 순환의존성이 생기게 된다.

여기서 DIP를 적용하면 도메인 계층에 리포지토리에 대한 인터페이스를 만들고, 실제 리포지토리는 영속성 계층에서 구현하게 하는 것이다.

이제 도메인 계층에 인터페이스를 도입함으로써 의존성을 역전시켜서 도메인 로직은 영속성 코드에 의존하지 않고 영속성 계층이 도메인 계층에 의존하게 된다.

클린 아키텍처

도메인 코드가 바깥으로 향하는 어떤 의존성도 없어야 함을 의미한다. 대신 의존성 역전 원칙의 도움으로 모든 의존성이 도메인 코드를 향하고 있다.

클린 아키텍처의 코어에는 주변 유스케이스에서 접근하는 도메인 엔티티들이 있다. 유스케이스는 서비스를 의미하는데 단일 책임을 갖기 위해 좀 더 세분화 시켜서 넓은 서비스 문제를 피한다.

도메인 코드에선 어떤 영속성 프레임워크나 UI 프레임워크가 사용되는지 알 수 없기 때문에 특정 프레임워크에 특화된 코드를 가질 수 없고 비즈니스 규칙에 집중할 수 있어서 자유롭게 모델링할 수 있다.

다만, 도메인 계층이 영속성이나 UI 같은 외부 계층과 철저하게 분리돼야 하므로 애플리케이션의 엔티티에 대한 모델을 각 계층에서 유지보수 해야 한다.

영속성에서 ORM을 사용하는 경우, 도메인 계층과 영속성 계층이 데이터를 주고받을 때, 두 계층에 각각 엔티티 클래스를 만들어서 서로 변환해야 하는데 이는 바람직한 방향이다.

특정 프레임워크에 특화된 문제로부터 해방시키고자 했던, 결합이 제거된 상태이다.

클린 아키텍처는 약간 추상적인 느낌이 강해서 이 원칙들을 좀 더 구체적으로 만들어주는 ‘육각형 아키텍처(헥사고날 아키텍처)’에 대해서 살펴보자.

육각형 아키텍처(헥사고날 아키텍처)

애플리케이션 코어가 각 어댑터와 상호작용하기 위해 특정 포트를 제공하기 때문에 ‘포트와 어댑터 아키텍처라고도 불린다. 꼭 육각형의 모양이 중요한건 아니고 팔각형이어도 상관없다.

육각형 안에는 도메인 엔티티와 이와 상호작용하는 유스케이스가 있다. 외부로 향하는 의존성이 없고 모든 의존성은 코어를 향한다.

육각형 바깥에는 웹 브라우저와 상호작용하는 웹 어댑터, 데이터베이스와 상호작용하는 영속성 어댑터, 외부 시스템와 상호작용하는 어댑터 등 애플리케이션과 상호작용하는 다양한 어댑터들이 있다.

코어와 어댑터들 간의 통신이 가능하려면 애플리케이션 코어가 각각의 포트를 제공해야 한다.

주도하는 어댑터에게는 포트가 코어에 있는 유스케이스 클래스들에 의해 구현되고 호출되는 인터페이스가 될 것이고, 주도되는 어댑터에는 어댑터에 의해 구현되고 코어에 의해 호출되는 인터페이스가 될 것이다.

이러한 아키텍처의 목적은 결국 도메인 코드가 바깥쪽 코드에 의존하지 않게 함으로써 영속성과 UI에 특화된 모든 문제로부터 도메인 로직의 결합을 제거하고 코드를 변경할 이유의 수를 줄이는 효과가 있다.

3. 코드 구성하기

코드만 보더라도 어떤 아키텍처인지 알 수 있다면 좋지 않을까? 보통 새로운 프로젝트를 시작할 때 가장 먼저 패키지 구조를 설계하게 된다.

계층으로 구성하기

기본적인 계층형으로 프로젝트를 생성한다면 아래와 같은 구조가 될 것이다.

1
2
3
4
5
6
7
8
9
10
buckpal
├─domain
│ ├─Account
│ ├─Activity
│ ├─AccountRepository
│ └─AccountService
├─persistence
│ └─AccountRepositoryImpl
└─web
└─AccountController

도메인, 웹, 영속성 계층별로 패키지를 만들었고 앞서 나왔던 의존성 역전을 사용하여 domain 패키지에 AccountRepository 인터페이스를 두고 persistence 패키지에 구현체를 둬서 의존성이 도메인을 바라보도록 구성되어있다.

다만 몇가지 단점이 보이는데

  1. 기능이나 특성을 구분짓는 패키지의 경계가 없다.
    • 새로운 기능(ex) 사용자관리)을 추가하려면 각 계층 패키지에 UserController, UserService, User 등을 추가하게 되는데 다른 기능과 섞이게 되면 예상치 못한 부수효과가 발생할 수 있다.
  2. 애플리케이션이 어떤 유스케이스를 제공하는지 파악하기 어렵다.
    • AccountController와 AccountService가 구체적으로 어떤 기능을 제공하는지 파악하려면 내부 구현 메서드를 살펴봐야 한다.
  3. 패키지만 봐서 의도하는 아키텍처를 짐작하기 어렵다.
    • 육각형 아키텍처라고 추측하고 웹 어댑터와 영속성 어댑터를 찾기 위해 web, persistence 패키지를 조사해볼 순 있지만 어떤 기능이 웹어댑터에서 호출되는지, 영속성 어댑터가 도메인 계층에 어떤 기능을 제공하는지 한눈에 알 수 없다. 인커밍 포트와 아웃고잉 포트가 코드 속에 숨겨져 있다.

기능으로 구성하기

1
2
3
4
5
6
7
buckpal
└─account
├─Account
├─AccountController
├─AccountRepository
├─AccountRepositoryImpl
└─SendMoneyService

계좌 관련 기능을 모두 account 라는 패키지에 모았고 AccountService도 책임을 좁히기 위해서 SendMoneyService로 변경하였다.

이렇게 되면 ‘송금하기’ 유스케이스를 구현한 코드는 클래스명만 봐도 바로 찾을 수 있다.

하지만 기능을 기준으로 코드를 구성하면 기반 아키텍처가 명확하게 보이지 않아서 가시성이 많이 떨어진다는 큰 단점이 있다.

아키텍처적으로 표현력 있는 패키지 구조

육각형 아키텍처에서 구조적으로 핵심적인 요소는 엔티티, 유스케이스, 인커밍/아웃고잉 포트, 인커밍/아웃고잉(혹은 주도하거나 주도되는) 어댑터이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
buckpal
└─account
├─adapter
│ ├─in
│ │ └─web
│ │ └─AccountController
│ ├─out
│ └─persistence
│ ├─AccountPersistenceAdapter
│ └─SpringDataAccountRepository
├─domain
│ ├─Account
│ └─Activity
└─application
├─SendMoneyService
└─port
├─in
│ └─SendMoneyUseCase
└─out
├─LoadAccountPort
└─UpdateAccountStatePort

도메인 모델이 속한 domain 패키지와 도메인 모델을 둘러싼 서비스 계층을 포함하는 application패키지가 있다.

SendMoneySerivce는 인커밍 포트 인터페이스인 SendMoneyUseCase를 구현 아웃고잉 포트 인터페이스이자 영속성 어댑터에 의해 구현된 LoadAccountPort와 UpdateAccountStatePort를 사용한다.

adapter 패키지는 애플리케이션 계층의 인커밍 포트를 호출하는 인커밍 어댑터와 애플리케이션 계층의 아웃고잉 포트에 대한 구현을 제공하는 아웃고잉 어댑터를 포함한다.

  • 책을 읽다가 곰곰히 생각해 봤지만
    패키지의 구조가 표현력이 있긴한데 아직 익숙치 않아서 한눈에 들어오진 않는다. 다만, 팀원들과 이러한 아키텍처에 대한 논의가 충분히 되고 합의된 상태에서 구조를 잡는다면 코드와 아키텍처가 직접적으로 매핑되면서 추상적이던 아키텍처가 좀 더 구체적으로 파악이 가능해진거 같기도 하다.