티스토리 뷰
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 를 좀 더 이해할 수 있는 방식에 하나가 되었으면 좋겠습니다.
긴 글 읽어주셔서 감사합니다.
'Infra' 카테고리의 다른 글
짧고 굵게 지마켓 API Gateway 사용하기 (0) | 2022.09.14 |
---|---|
가볍게 시작하는 Databricks Community Edition 환경설정 for Spark (1) | 2022.09.07 |
MySQL만 써봤는데... MongoDB 프로젝트에 투입됐다🤯 (3) | 2022.08.05 |
확장성 높은 카프카 구성을 위한 서비스 설계 (0) | 2022.07.29 |
위상정렬과 안전한 최소한의 동시성 (0) | 2021.04.23 |