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) 같은 개념과 잘 맞는다.

DEVIEW 2023 - Day1

2023-02-27 ~ 28 에 진행했던 deview day01 컨퍼런스 내용을 간단히 정리했다.
내용은 좋았다고 느꼈지만 당장 내 실무에 도움된다기 보단 그냥 시야를 넓히고 이런게 트랜드구나 하는 걸 들었다는거에 의의를 두기로 했다.

네이버 검색은 어떻게 나보다 더 내 의도를 잘 아는가? - AiRSearch 반응형 추천

네이버는 현재 사용자 의도에 적합한 검색 결과를 제공하도록 여러가지 추천 알고리즘이 적용되어있다.

사용자 의도를 이해하는 방법은 크게 두가지가 있는데

  1. 검색어
    • 사용자가 질의하는 검색어(쿼리)를 통해 의도를 예측한다.

    • 검색어 입력 후 탐색과정에서 관심사와 검색의도를 더 구체화할 수 있다.

      ex) 제주도여행 검색 후 결과목록
      Doc1 : 제주도에서 고등어가 맛있는 식당 추천
      Doc2 : 12월 제주도 겨울 여행
      Doc3 : 제주도 가파도 배시간 & 올레길 추천

      여기서 3번째 문서를 클릭했다면 사용자의 관심사는 고등어 보단 가파도에 있다는걸 파악할 수 있다.

    • 사용자의 행동을 바탕으로 의도를 이해하고 반응형으로 컨텐츠를 빠르게 제공하는데 여기서 반응형 추천결과란 선택한 문서를 읽고 다시 뒤로가기로 나오게 되면 관심사와 유사한 다른 문서를 추천하는 방식이다.

반응형 문제를 해결하기

  1. 반응형 문제 정의
    • 언제 / 어디서 / 누구에게? : 네이버 통합검색에서 문서를 소비하고 돌아온 사용자들에게
    • Input : 사용자의 행동 기반 정보들 (검색어, 클릭한 문서, 사용자의 취향 정보 등)
    • Output : 추천 문서
    1. 반응형 문제의 특징으로 검색 사용자의 2가지 탐색 패턴이 있다.
      1. 국내여행 → 제주도 여행 검색 후
        1. Narrow-Down → 가파도
        2. Side-By-Side → 일본여행
    2. 두번째는 검색과 추천의 융합이 필요하다.
      1. 검색어를 통해 단순히 결과를 보여주거나 클릭한 문서 기반의 추천문서만 보여주는게 아니라 검색어와 클릭한 문서를 종합하여 추천문서를 보여주려고 한다.
  2. 반응형 문제를 정의하였다면 이제 이러한 문제를 해결하기 위한 모델을 만들었다.
    • Input : Query, Clicked Document, User
    • Retriever : 각 요소를 기반 검색
    • Ranker : 결과 정렬
    • Output : 추천문서 제공

반응형 문제를 풀기 위한 3가지 방법

  1. Intent Query
    • 클릭한 문서의 제목에서 핵심 의도를 표한하는 키워드 찾기
    • 네이버 언어모델을 기반으로 (Q1,D1)와 적합한 Intent Query의 유사도가 높도록 학습
    • 검색 세션 로그 (Q1,D1) → Q2를 이용해서 학습 데이터를 구축하고 의도를 벗어나는 Q2는 예외처리
  2. Intent Walker
    • 그래프 기반으로 사용자 의도에 맞는 문서 추천하기
      • 클릭로그 활용 탐색과정 그래프 생성
    • 유저가 클릭한 문서를 시작점으로 아래의 방법들을 실행
      • 랜덤 워크 수행
      • Label Propagation 수행
      • (검색어-클릭문서), (세션ID - 클릭문서) 페어 등을 기반으로 GCN 임베딩 학습
  3. User Preference
    • 유저의 취향에 적합한 문서를 추천
      • Q1) 후드집업 + D1) 오버핏 후드집업 양털후리스 + U1) 30대 남성
        • D2) 너무 편한 남자 후리스 추천 모음
      • 추천 문서에 대한 long-term의 세대성별 선호도를 ranker의 feature로 추가
      • 개인의 short-term 관심사 기반한 개인화 랭킹 연구 중

반응형 서비스 개발을 위한 꿀팁

  1. 문제를 작게 정의하기
    • 이런경우 막연하게 풀고 싶은 큰 문제를 구체적이고 해결 가능성이 있는 작은 문제부터 시작한다.
    • ex) 사용자의도가 Narrow-Down vs Side-By-Side ? → Narrow-Down만 있다고 가정
      대상 문서 : 여행 등 일부 카테고리 한정
    • 작은 문제의 장점은 아래와 같은데 애자일 방법론과 유사한거 같다.
      • 빠른 개발
      • 문제 해결 가능성과 효과 검증
      • 사용자의 피드백으로 문제 확장
  2. 학습데이터 잘 구축하기
    • (Q1, D1) → 후보 D2의 적합도 [0,1] or [1,2,3,4]
      • 서비스 출시 전 소량의 Manual 평가를 거친 후
      • 서비스 출시 후 사용자 피드백을 기반으로 생성한다.
        (ex) 클릭 받으면 1, 아니면 0)
        • 단점?어려운점? : 모델 bias(편향), 클릭은 없지만 적합 문서
        • 개선 아이디어로 CTR 역전되면 Negative
          • 검색에는 Rank Bias 존재
          • 하위 노출 문서보다 CTR이 낮은 상위 문서는 매력이 낮을 가능성 있음
          • D1,D2 보다 D3의 CTR이 높은 경우 D1,D2 는 Negative 후보
      • positive set과 negative set을 후보로 모아서 Manual그룹평가와 비교하여 신뢰도를 측정하고 최종 선정된 Top3 학습데이터 weekly 생성한다.
  3. ABTest 활용하기 : ABTest 결과를 바탕으로 최종 검증 모델로 서비스 적용
  4. 지표 모니터링하기 : CTR, 체류시간, LCR(마지막 클릭 비율) 등을 모니터링하여 사용자 만족도 측정 및 모델 개선 참고

눈으로 보며 듣는 음성 기록, 클로바노트 서비스의 웹 기술 톺아보기

클로바노트 2.x 버전에서 지원할 웹기술을 살펴보자

  1. 오프라인 지원
    • Progressive Web Application(PWA) : Next.js +next-pwa + workbox로 구현
    • 오프라인 API 중 R-Get을 제외한 나머지 C-Post U-Put,Patch D-Delete 는 불필요하여 막았고 LocalStorage와 IndexedDb를 활용하였음
    • 가장 큰 골칫거리는 Update 후 Deploy 방식이었는데
      • 주기적인 최신버전 체크, 수동 업데이트 진행, 강제 업데이트 로직 추가, 에러 시 SW 제거 및 해당버전 재설치 방지 등을 통해 처리하였음?
    • PWA의 Service Worker의 XHR cache에 XHR 분기를 추가
      • [API Wrapper - [Fetch, Axios, Socket.io]] → Service Worker
  2. 반응형 지원
  3. ZOOM 서비스 연결
    1. 줌에서 제공하는 API 중 REST API와 WebHook을 이용하였음
    2. 회의 시작, 종료에 맞춰서 훅을 받아서 녹음파일을 업로드하여 노트를 생성하도록 자동화 하였으나 좀 더 직접적인 도움을 주고 싶었음
      1. 실시간으로 가져오도록 구현해보자
      2. ZOOM의 라이브스트림을 통해 다른 플랫폼으로 스트리밍을 제공하는데 이것을 활용 - RTMP(Real Time Message Protocol)
      3. 이를 위해 백엔드 서버가 필요했는데 Node-Media-Server 가 원하는 스펙이 모두 있었고 BFF(Backend for Frontend) 서버를 개발하였음
      4. 이제 회의시작 후 실시간으로 노트생성이 가능해졌다.
    3. 예외처리
      1. 웹훅 API, RTMP 타이밍 문제
      2. 네트워크 등의 오류로 웹훅을 받을 수 없을때
        • 프로세스가 진행되는 동안 주기적으로 REST API 더블체크하도록 처리하였음
  4. 모노레포
    • Multi Repo에서 Mono Repo로 합치게 되면서 팀으로서의 장점이 뚜렷해졌음
    • 기술적 장점
      • 반복적인 환경 셋팅, 코드 중복 최소화
      • 효율적인 의존성 관리
      • 단일 이슈 트래킹
      • 각자의 역할에 집중된 작은 패키지들
      • 린트외의 팀 규칙을 적용하여 신규입사자의 컨텍스트 파악이 용이해지고 적극적인 리뷰문화를 도입할 수 있었다.
    • 모노레포는 최대한 간단하게하고 빌드 캐싱은 Docker 활용
  5. 아토믹 디자인 패턴
    • 컴포넌트를 가장 작은 단위로 설정하여 상위 컴포넌트를 만들어 코드 재사용을 최대화하는 방식
      • 원자(아톰) → 분자 → 유기체 → 템플릿 → 페이지 구조
    • 그대로 사용하기엔 재사용하지 않아도 되는 부분까지 나눠야하는 불편함이 있어서 일반적인 UI 라이브러리에서 제공하는 수준을 아톰으로 정의하여 구조를 잡았음
  6. 리코일
    • Flux / Redux 의 모든 기능이 가능하고 상대적으로 낮은 러닝커브를 가졌음
    • 자체적인 비동기 지원
    • React18 동시성의 장점을 그대로 사용할 수 있는 라이브러리
    • 일반적인 리코일 폴더구조는 리코일에 직접접근이 가능하여 프로젝트가 커질수록 종속성이 복잡해지고 디버깅이 어렵다.
      • 결과적으로 컴포넌트의 복잡함이 리코일로 이동한것에 불과하다.
    • 격리된 도메인별 리코일
      • 아톰, 셀렉터에 직접 접근 없이 훅으로만 연결
      • 리코일 훅은 서로 참조하지 않는다.(인증 제외)
      • 순서가 필요하다면 비지니스 컴포넌트 사용

네이버 스마트블록 개인화 검색

와… 이건 듣는거만으론 이해불가라 포기


네이버 스케일로 카프카 컨슈머 사용하기

  1. Kafka Consumer 동작 원리
    1. Topic : 1개 이사의 Partition으로 분할, 1개 이상의 Replica로 복제된 log 자료 구조
    2. Client
      1. Producer : 쓰고자 하는 Topic Partition의 맨 끝에 record를 추가
      2. Consumer : 읽어오고자 하는 Topic의 Partition에 저장된 record를 순차적으로 읽어 옴
    3. Consumer Group
      • 같은 ‘ group.id’ 설정값을 가진 Consumer들은 하나의 Consumer Group 을 이룬다.
      • 같은 Consumer Group에 속한 Consumer들이 Topic에 속한 Parition들을 나눠서 읽는다.
      • Consumer Group == “논리적인 Consumer”
      • Consumer Group이 제대로 동작하기 위해선 아래 기능이 필요하다.
        1. Parition Assignment 기능
        2. Offset Commit 기능
    4. Consumer Coordination 동작원리
    • Consumer Group Coordinator
      1. Consumer Group에 변경이 생겼는지 탐지
      2. TopicParition에 변경이 생겼는지 탐지
      3. Consumer Group Leader와 나머지 Consumer들간의 communication 중개
    • Consumer Group Leader
      • 현재 구독중인 topic의 파티션들을 consumer들에 할당
    • Broker를 재시작할 필요 없이 더 유연하고 확장 가능한 파티션 할당을 지원하기 위해 좀 복잡한 구조로 만들어짐
    • 관련 설정
      • partition.assignment.strategy
        • List of org.apache.kafka.clients.consumer.ConsumerPartitionAssignor class
        1. Consumer Group에 참여한 모든 Consumer에 공통으로 설정된 Assignor중에서
        2. 우선순위가 가장 높은 것이 파티션 할당 전략으로 선택
  2. Cloud 환경에서 Kafka Consumer 사용하기
    1.
  3. 네이버 스케일로 Kafka Consumer 사용하기

카프카에 대해서 제대로 공부하지 않고 이 내용을 정리하는건 의미가 없다고 판단되서 나중에 다시 정리해보자…


GraphQL 잘 쓰고 계신가요?

  1. GraphQL이란
    • 필요한 것만 요청하고 받아오기
    • 단일 요청으로 많은 데이터 가져오기
    • 가능한 케이스를 타입 시스템으로 표현하기

단순히 Payload를 줄이기 위해서만 사용하는건 아니다. 제대로 사용하지 않는다면 REST API와 크게 다를게 없는 경우도 발생한다.

도입배경

화면 컴포넌트를 구성하는 다양한 데이터를 여러 API를 호출하여 조회하는데 GraphQL BFF 를 통하면 개별 API 스펙을 크게 신경쓰지 않아도 된다.

  1. Schema

    1. 데이터의 모양으로 DB의 스키마는 데이터를 R/W 하는데 최적화를 목적으로 관계와 참조로 중복을 해소한다(정규화)

    2. GraphQL 스키마는 클라이언트를 중심으로 생각하는 출처가 다른 데이터들의 통합 타입 시스템을 의미한다.

    3. Enum을 적극적으로 활용한다

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      type Profile {
      name : String
      thumbnail42 : String
      thumbnail60 : String
      ...
      backgroundImage42: String
      ...
      backgroundImage200: String
      }

      // Enum 사용
      type Profile {
      name : String
      thumbnailImage(width: ImageWidth = W_42): String!
      backgroundImage(width: ImageWidth = W_100): String!
      }

      Enum을 사용함으로 좀 더 명확하게 표현하여 실수를 줄일 수 있다.

    4. 암시적인 의미의 API를 좀 더 명시적으로 한다.

      1. 여기서도 Enum을 사용하는데 empty가 의미를 가질 때 Enum을 사용할 수 있다.
      1
      2
      3
      4
      5
      6
      7
      8
      9
      enum MediaFilter {
      WITH_MEDIA
      WITHOUT_MEIDA
      ALL
      }

      type Query {
      placeReviews(placeId: ID!, mediaFilter: MediaFilter = ALL): [Review!]!
      }

      API 사용자관점으로 명확하게 스키마를 표현한다.

    5. Error Handling

      1. 다양한 에러를 대응하고 메타데이터가 필요한 경우 이러한 상태값을 단순히 타입으로 추가한다면 여러 데이터가 복잡하게 얽히게 된다.

      2. Union 타입을 사용한다.

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        //union Result = Succeed | Error

        type DuplicationError {
        message: String!
        }
        type PwordError {
        workds: [String!]!
        message: String!
        }

        union CheckNickNameOutput = NickNameSucceed | DuplicationError | PwordError

        다만, 유니온 타입만으론 새로운 스펙과 에러가 추가된다면 데이터를 받을 수 없다. 즉, 확장에 닫혀있는 상태이다.

      3. Interface를 적용하여 명시적이고 유연하게 에러 핸들링이 가능하다.

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        interface BaseError {
        message: String!
        }

        type Duplication implements BaseError {
        message: String!
        }
        type PwordError implements BaseError {
        words: [String!]!
        message: String!
        }
        type CountOverError implements BaseError {
        count: Int!
        message: String!
        }

    아래 내용이 더 있으나 GraphQL이 어떻게 사용되는지 대략적인 개요를 한번 들어보고 싶었던거고 뒷부분은 리액트 관련 기술들이랑 좀 더 심화된 내용들이라 굳이 정리하지 않음

    결국 해당 팀에서도 별도의 GraphQL용 BFF 서버를 만들어서 운영중이라고 하니 REST API 처럼 별도의 GraphQL 스펙의 API 서버 쪽 공부를 더 해보도록 하자

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에 특화된 모든 문제로부터 도메인 로직의 결합을 제거하고 코드를 변경할 이유의 수를 줄이는 효과가 있다.