티스토리 뷰

Infra

Multi Data Source 에서 Data 모으기

지마켓 선현상 2021. 6. 9. 17:36

Data 모으기

사용할 다건의 data 목록을 마련할 때 상황에 따라 서로 다른 source 에서 조회해야 하는 경우가 있습니다. 예를 들면 data 가 일부 캐싱되는 경우를 들어봅시다. 10개의 data 를 조회하는데 일부는 캐시에서 조회가 가능할지도 모릅니다. 그리고 나머지는 DB 에서 조회해서 채워야 할 것 입니다. 이런 경우 캐시와 DB 라는 두 개의 data source 로 부터 필요한 data 목록을 조회하게 됩니다. 꼭 이런 경우가 아니더라도 우리의 data 아키텍쳐 상으로 여러 이유를 들어 data source 여러 곳에서 data 조회가 필요한 경우는 쉽게 생각할 수 있습니다.

Data Source 우선순위

너무 복잡한 상황을 전제하지는 않을 예정이지만 기본적인 요구사항들은 짚어보려고 합니다. 가장 먼저 data source 가 여러개 라는 것은 각 data source 가 서로 배타적인 data 를 서비스할 수도 있지만 많은 경우 특정 data 가 여러 source 에서 같이 나타날 가능성이 높습니다. 위 예로 들어보았던 캐시 전략 역시 캐시에 등록된 data 는 대부분 DB 에 존재할 것입니다.

 

따라서, 여러 data source 에서 data 를 모으는 중에 어떤 data source 를 먼저 반영할지 우선순위를 반영할 수 있으면 좋을 것 같습니다.

Monad

이 문제에도 Monad 패턴이 도움이 될까요? 분명 Monad 가 은탄환은 아니기에 직면한 문제에 도움이 될 수 있는지 검토가 필요합니다. 그러면 어떤 부분을 주목해 볼 수 있을까요? 역시 키워드는 '합성' 입니다. 무엇인가를 '합성' 하고 싶은가? 라는 질문을 통해 검토해볼 수 있습니다.

 

여러 data source 로 부터 우선순위를 고려하여 조회하는 과정은 흡사 순서대로 data source 를 조회해보고 아직 조회하지 못한 항목들을 그 다음 data source 에서 조회해가는 것의 연속입니다. 이를 pseudo code 로 적어봅니다.

values = findAll(keys)
remainKeys = keys - (values pick {value.key})
if remainKeys is empty then return values
else return values + next(remainKeys)

next 가 보입니다. 다음 순서의 data source 를 조회한다를 계속 합성할 수 있을 것 같습니다. 이 합성의 순서는 자연스럽게 data source 간 우선순위의 순서가 될 것입니다.

 

이번에도 Try Catch 지옥 벗어나기 와 같이 Middleware 패턴과 코드 수준에서의 활용 에서 소개한 MiddlewareTask 를 사용할 수 있을 것 같습니다. 이번 기능에는 WaterfallCollector 라는 이름을 붙여보았습니다. WaterfallCollector 는 역시 MiddlewareTask에 대한 컨테이너로 시작합니다.

public class WaterfallCollector<K, V> {

  private final MiddlewareTask<Collection<K>, Map<K, V>> task;

  public WaterfallCollector(MiddlewareTask<Collection<K>, Map<K, V>> task) {
    this.task = task;
  }

그리고 of 메서드를 만듭니다.

public static <K, V> WaterfallCollector<K, V> of(Function<Collection<K>, Map<K, V>> finder) {
  return new WaterfallCollector<>(
      MiddlewareTask.of(
          (keys, next) -> {
            var values = new HashMap<>(finder.apply(keys));
            var remainKeys = difference(keys, values.keySet()); // 차집합 연산

            if (!remainKeys.isEmpty()) {
              // 남은 키가 있으면 다음 data source 를 조회하여 merge 합니다.
              next.apply(remainKeys).forEach(values::putIfAbsent);
            }

            return values;
          }
      )
  );
}

키-값 관계를 유지하는 편이 이들을 모으거나 차집합을 연산할 때 수월하므로 각 조회의 결과와 middleware 의 반환 타입은 Map<K,V> 를 사용합니다. MiddlewareTask 를 사용하여 위 pseudo code 내용을 작성하였습니다. 이번엔 next 로의 진행이 선택적입니다. 불필요한 조회가 발생하지 않도록 충분한 시점에 조회를 마무리합니다.

 

이제 WaterfallCollector 를 위한 합성 규칙을 고려해봅시다.

public WaterfallCollector<K, V> andThen(WaterfallCollector<K, V> after) {
  return new WaterfallCollector<>(task.andThen(after.task));
}

사실 고려할 부분은 크게 없습니다. WaterfallCollector 의 합성은 담고 있는 MiddlewareTask 을 합성하는 것이고 이는 이미 그 규칙을 찾아두었기 때문입니다.

 

WaterfallCollector 의 전체 코드는 다음과 같습니다.

public class WaterfallCollector<K, V> {

  private final MiddlewareTask<Collection<K>, Map<K, V>> task;

  public WaterfallCollector(MiddlewareTask<Collection<K>, Map<K, V>> task) {
    this.task = task;
  }

  public static <K, V> WaterfallCollector<K, V> empty() {
    return new WaterfallCollector<>(MiddlewareTask.empty());
  }

  public static <K, V> WaterfallCollector<K, V> of(Function<Collection<K>, Map<K, V>> finder) {
    return new WaterfallCollector<>(
        MiddlewareTask.of(
            (keys, next) -> {
              final var values = new HashMap<>(finder.apply(keys));
              final var remainKeys = difference(keys, values.keySet()); // 차집합 연산

              if (!remainKeys.isEmpty()) {
               // 남은 키가 있으면 다음 data source 를 조회하여 merge 합니다.
                next.apply(remainKeys).forEach(values::putIfAbsent);
              }

              return values;
            }
        )
    );
  }

  private static <K> List<K> difference(Collection<K> keys, Set<K> keySet) {
    return keys.stream().filter(not(keySet::contains)).collect(toUnmodifiableList());
  }

  public WaterfallCollector<K, V> andThen(WaterfallCollector<K, V> after) {
    return new WaterfallCollector<>(task.andThen(after.task));
  }

  public WaterfallCollector<K, V> andThen(Function<Collection<K>, Map<K, V>> finder) {
    return andThen(WaterfallCollector.of(finder));
  }

  public List<V> findAll(Collection<K> keys) {
    final var values = task.run(keys, remainKeys -> Map.of());
    return keys.stream()
        .map(values::get)
        .filter(Objects::nonNull)
        .collect(toUnmodifiableList());
  }
}

static 메서드 empty()MiddlewareTask 에 위임하면 충분하고 직관적인 사용을 위해 andThen(Function<Collection<K>, Map<K, V>>) 을 추가 하더라도 길지 않은 코드입니다. findAll(Collection<K>) 의 경우는 파라미터의 키 순서대로 조회한 결과를 정렬하여 List<V> 타입으로 반환합니다.

 

이제 사용해 봅시다. 캐시를 통해 먼저 조회하고 캐시에서 찾지 못한 값은 DB 에서 마저 조회하는 예시입니다. 간단히 DB 를 통한 조회가 수행되었다면 그 내용은 캐시에 업데이트합니다.

private WaterfallCollector<String, Item> itemCollector =
    WaterfallCollector.of(itemCacheService::findAll)
        .andThen(remainItemNos -> {
          var items = itemRepository.findAll(remainItemNos);
          itemCacheService.saveAll(items); // DB 에서 조회해온 내용을 캐시에 업데이트
          return items.stream().collect(toMap(Item::getItemNo, identity()));
        });

public List<Item> findAll(Collection<String> itemNos) {
  return itemCollector.findAll(itemNos);
}

만약 새로운 data source 가 이 과정에 참여해야 한다면 이를 추가하는 것은 어렵지 않을 것입니다. 대부분의 경우 DB 정도면 충분히 조회가 끝나지만 혹시 모를 누락에 대비해 느려도 안전하게 data 를 보관하는 data source 가 있다면 이렇게 추가하여 안전망 역할로 사용할 수 있습니다.

private WaterfallCollector<String, Item> itemCollector =
    WaterfallCollector.of(itemCacheService::findAll)
        .andThen(remainItemNos -> {
          var items = itemRepository.findAll(remainItemNos);
          itemCacheService.saveAll(items); // DB 에서 조회해온 내용을 캐시에 업데이트
          return items.stream().collect(toMap(Item::getItemNo, identity()));
        })
        .andThen(itemBackupRepository::findAll); // 느리지만 백업용 DB 가 있다면 안전망 역할을 할 수 있습니다.

public List<Item> findAll(Collection<String> itemNos) {
  return itemCollector.findAll(itemNos);
}

정리

이번에도 MiddlewareTask 를 재사용하여 간단한 구현으로 WaterfallCollector 의 요구사항들을 Monad 의 규칙을 활용할 수 있는 도구로 만들어 보았습니다. MiddlewareTask 가 재사용하기 좋은 기능의 도구이기도 하지만 역시 쉽게 '합성' 할 수 있다는 점이 재사용을 더 쉽게 해 줍니다. 앞서 언급한 대로 Monad 가 은탄환은 아니지만 특정한 합성 규칙을 만들 수 있을 때 주어진 문제를 단순하게 표현하는 방법이기에 적절한 Monad 패턴의 사용은 훨씬 문제를 단순한 형태로 표현 가능하게 도와주는 것 같습니다.

 

Monad 를 이해하는 방법 부터 시작하여 Middleware 패턴과 코드 수준에서의 활용, Try Catch 지옥 벗어나기 과 함께 이번 포스팅을 통해 컨테이너와 합성의 관점에서 살펴보는 방법이 Monad 를 좀 더 이해할 수 있는 방식에 하나가 되었으면 좋겠습니다.

 

긴 글 읽어주셔서 감사합니다.

댓글