MapBook 서비스에서 캐싱 기능을 사용 하는데, Redis를 도입하지 않고 CaffeingCache로 캐싱 데이터의 생명 주기를 관리하며 자체 캐시 시스템이 존재 한다.
@Service
@RequiredArgsConstructor
@Slf4j
public class CachedMapBookManager {
private Cache<ReqMapBookDto, List<RespMapBookDto>> mapBookCache;
private final LibraryFindService libraryFindService;
private final ApiQuerySender apiQuerySender;
private final ApiQueryBinder apiQueryBinder;
private final MapBookMatcher mapBookMatcher;
@PostConstruct
public void init() {
this.mapBookCache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.HOURS)
.maximumSize(1000)
.build();
}
@Timer
public List<RespMapBookDto> getMapBooks(ReqMapBookDto mapBookDto) {
List<RespMapBookDto> value = mapBookCache.getIfPresent(mapBookDto);
if (value != null) {
log.info(mapBookDto.getIsbn() + " cache hit !!!!!!!!!!!!!!!!");
} else {
log.info(mapBookDto.getIsbn() + " cache miss...............");
List<LibraryDto> nearByLibraries = libraryFindService.getNearByLibraries(mapBookDto);
if(!nearByLibraries.isEmpty()){
Map<Integer, ApiBookExistDto> bookExistMap = apiQueryBinder.bindBookExistMap(
apiQuerySender.multiQuery(
nearByLibraries,
mapBookDto.getIsbn(),
nearByLibraries.size()));
value = mapBookMatcher.matchMapBooks(nearByLibraries, bookExistMap);
}else{
value = new ArrayList<>();
}
mapBookCache.put(mapBookDto, value);
}
return value;
}
}
1) 관심사 분리를 통해서 캐싱을 한곳에서만 관리하는 CacheManager 클래스를 구현 했다.
2) 또한 캐싱을 등록하고 캐싱된 데이터를 사용하는 메소드 호출 등의 중복을 막기 위해 Annotation 기반으로 AOP를 활용 했다.
/**
* {@link CustomCacheManager}는 여러 개의 캐시 인스턴스를 관리하고 캐시의 라이프사이클을 조절합니다. 이 클래스는 캐시 객체를 저장하고, 캐시에 데이터를
* 추가하거나 가져오는 기능을 제공합니다.
*
* @param <K> 캐시에서 저장할 데이터 유형
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CustomCacheManager<K, I> {
private final Map<Class<?>, Cache<CacheKey<K,I>, I>> commonsCache = new ConcurrentHashMap<>();
/**
* 캐시를 등록하여 캐시 관리 시스템에 추가합니다.
*
* @param cache 등록할 캐시 인스턴스
* @param customer 캐시를 사용하는 클래스 정보
*/
public void registerCaching(Cache<CacheKey<K,I>, I> cache, Class<?> customer) {
log.info("[{}] is registered in caching System", customer);
commonsCache.put(customer, cache);
}
/**
* 주어진 클래스와 개인 키에 해당하는 아이템을 캐시에 추가합니다.
*
* @param customer 아이템을 추가할 클래스 정보
* @param personalKey 아이템에 대한 개인 키
* @param item 캐시에 추가할 아이템
*/
public void put(Class<?> customer, CacheKey<K,I> personalKey, I item) {
if (commonsCache.containsKey(customer)) {
log.info("CacheManger put item for [{}]", customer);
commonsCache.get(customer).put(personalKey, (I) item);
}
}
/**
* 주어진 클래스와 개인 키에 해당하는 아이템을 캐시에서 가져옵니다.
*
* @param customer 아이템을 가져올 클래스 정보
* @param personalKey 아이템에 대한 개인 키
* @return 캐시에서 가져온 아이템
*/
@Timer
public I get(Class<?> customer, CacheKey<K,I> personalKey) {
log.info("CacheManger find item for [{}]", customer);
return commonsCache.get(customer).getIfPresent(personalKey);
}
/**
* 주어진 클래스의 캐시를 제거합니다.
*
* @param customer 제거할 캐시의 클래스 정보
*/
public void removeCaching(Class<?> customer) {
if (commonsCache.containsKey(customer)) {
commonsCache.remove(customer);
} else {
log.error("해지 하고자 하는 캐싱 정보가 없습니다. [{}]", customer);
throw new IllegalArgumentException();
}
}
/**
* 주어진 클래스가 캐시를 사용 중인지 확인합니다.
*
* @param customer 확인할 클래스 정보
* @return 캐시를 사용 중이면 true, 그렇지 않으면 false
*/
public boolean isUsingCaching(Class<?> customer) {
return commonsCache.containsKey(customer);
}
/**
* 주어진 클래스와 개인 키에 해당하는 아이템이 캐시에 포함되어 있는지 확인합니다.
*
* @param customer 확인할 클래스 정보
* @param personalKey 아이템에 대한 개인 키
* @return 아이템이 캐시에 포함되어 있으면 true, 그렇지 않으면 false
*/
public boolean isContainItem(Class<?> customer, CacheKey<K,I> personalKey) {
if (isUsingCaching(customer)) {
return commonsCache.get(customer).getIfPresent(personalKey) != null;
} else {
throw new IllegalArgumentException(customer + "is not registered for caching");
}
}
/**
* 주어진 클래스와 인수를 사용하여 CacheKey 객체를 생성합니다. 적절한 CacheKey 구현을 찾지 못하면 UnsupportedOperationException을
* 발생시킵니다.
*
* @param arguments 캐시 키를 생성하는 데 필요한 인수
* @return 생성된 CacheKey 객체
* @throws UnsupportedOperationException 적절한 CacheKey 구현을 찾지 못한 경우 던져 진다.
*/
CacheKey<K,I> generateCacheKey(@NonNull Object[] arguments) throws UnsupportedOperationException {
for(Object obj : arguments){
if(obj instanceof CacheKey<?,?>){
return (CacheKey<K,I>) obj;
}
}
throw new UnsupportedOperationException(
"No suitable CacheKey implementation found for class: ");
}
}
/**
* 사용자 정의 캐싱 어스펙트로, CustomCacheable 어노테이션이 적용된 메서드의 결과를 캐싱합니다. 캐싱된 데이터는 CustomCacheManager를 통해
* 관리됩니다.
*
* @param <K> 캐시 키의 타입
* @param <I> 캐시 항목의 타입
*/
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class CustomCacheAspect<K, I> {
private final CustomCacheManager<K, I> cacheManager;
@Pointcut("@annotation(com.scaling.libraryservice.commons.caching.CustomCacheable)")
private void customCacheablePointcut() {
}
/**
* "CustomCacheable" 어노테이션을 사용하는 메서드를 위한 어드바이스입니다. 캐시에 해당 데이터가 존재하면 캐시로부터 그 값을 반환하고, 그렇지 않은 경우
* 메서드를 실행하고 그 결과를 캐시에 저장합니다.
*
* @param joinPoint 프록시된 메서드에 대한 정보를 제공하는 객체
* @return 캐시로부터 가져온 결과 또는 실제 메서드 실행의 결과
* @throws Throwable 메서드 실행 도중 예외가 발생할 경우
*/
@Around("customCacheablePointcut()")
@SuppressWarnings("unchecked")
public I cacheAround(@NonNull ProceedingJoinPoint joinPoint) throws Throwable {
// 해당 클래스로 등록된 caffeineCache 인스턴스가 있는지 찾기 위해
Class<?> customer = joinPoint.getTarget().getClass();
// 타겟 메소드의 매개 변수에서 해당하는 캐쉬 키를 찾는다.
CacheKey<K, I> cacheKey = cacheManager.generateCacheKey(joinPoint.getArgs());
// caching을 사용하고 있지 않다면, 먼저 등록을 한다. 그리고 마지막에 등록된 캐싱 객체에 캐시 데이터를 넣음
if (!cacheManager.isUsingCaching(customer)) {
cacheManager.registerCaching((Cache<CacheKey<K, I>, I>) cacheKey.configureCache(),
customer);
} else {
if (cacheManager.isContainItem(customer, cacheKey)) {
log.info("Cache Manager find this item ");
return cacheManager.get(customer, cacheKey);
}
}
return patchCacheManager(joinPoint,customer,cacheKey);
}
/**
* 이 메서드는 캐시 관리자(CacheManager)를 수정하여 메서드의 실행 결과를 캐시에 저장합니다.
* 실행 시간이 2초를 초과하거나 ApiRelatedService에 관련된 경우에만 결과를 캐시에 저장합니다.
*
* @param joinPoint 프록시된 메서드에 대한 정보를 제공하는 객체
* @param customer 캐싱 대상 객체의 클래스 정보
* @param cacheKey 캐시에 저장될 값의 키
* @return 메서드 실행 결과
* @throws Throwable 메서드 실행 도중 예외가 발생할 경우
*/
public I patchCacheManager(ProceedingJoinPoint joinPoint, Class<?> customer, CacheKey<K, I> cacheKey)
throws Throwable {
StopWatch stopWatch = new StopWatch();
// 캐싱이 필요한지를 판별하기 위해 해당 메소드의 시작과 끝나는 시간을 측정
stopWatch.start();
I result = (I)joinPoint.proceed();
stopWatch.stop();
// 메소드가 2초를 초과하거나 api 관련한 클래스면 caching 처리 한다.
if (stopWatch.getTotalTimeSeconds() > 2.0 | ApiRelatedService.class.isAssignableFrom(customer)) {
log.info(
"This task is related ApiRelatedService then CacheManger put this item");
cacheManager.put(customer, cacheKey, result);
}
return result;
}
}
캐싱 관련한 로직은 해당 서비스에 캐싱 관련 메소드 호출 없이 @CustomCacheable만 붙이면 등록부터 캐싱된 데이터 사용까지 바로 가능하다.
도서를 검색하기 위해서 사용자의 검색어를 분석한 결과를 토대로 DB에 연결하여 데이터를 가져오기 위한 단계에서의 문제이다.
문제는 아래와 같이 Data JPA에 정의된 각각의 메소드를 선택하는데 있어 7가지의 케이스별로 Switch문을 통해서 선택하고 있다.
@RequiredArgsConstructor
@Component @Slf4j
public class EnumBookFinder implements BookFinder<Page<BookDto>,Pageable> {
private final BookRepository bookRepository;
@Override
public Page<BookDto> findBooks(TitleQuery titleQuery, Pageable pageable){
return selectBooksEntity(titleQuery,pageable).map(BookDto::new);
}
/**
* 검색 대상(target)에 따라 적절한 검색 쿼리를 선택하여 도서를 검색하고, 결과를 반환하는 메서드입니다.
*
* @param titleQuery 검색 쿼리를 분석한 결과를 담고 있는 TitleQuery 객체
* @param pageable 페이지 관련 설정을 담은 Pageable 객체
* @return 검색 결과를 담은 Page<Book> 객체
*/
private Page<Book> selectBooksEntity(@NonNull TitleQuery titleQuery, Pageable pageable) {
TitleType type = titleQuery.getTitleType();
switch (type) {
case KOR_SG, KOR_MT_OVER_TWO -> {
return bookRepository.findBooksByKorNatural(titleQuery.getKorToken(), pageable);
}
case KOR_MT_TWO -> {
return bookRepository.findBooksByKorMtFlexible(titleQuery.getKorToken(), pageable);
}
case ENG_SG -> {
return bookRepository.findBooksByEngBool(titleQuery.getEngToken(), pageable);
}
case ENG_MT -> {
return bookRepository.findBooksByEngMtFlexible(titleQuery.getEngToken(), pageable);
}
case ENG_KOR_SG -> {
return bookRepository.findBooksByEngKorBool(titleQuery.getEngToken(),
titleQuery.getKorToken(), pageable);
}
case ENG_KOR_MT -> {
return bookRepository.findBooksByEngKorNatural(
titleQuery.getEngToken(),
titleQuery.getKorToken(),
pageable);
}
default -> throw new IllegalArgumentException("Invalid title type: " + type);
}
}
}
1) 제일 먼저 전략 패턴을 사용해 switch문에 해당되는 로직을 전략 클래스 구현체 별로 하나씩 분리 했다.
2) 그리고 아래 코드와 같이 생성자에서 관련 전략 구현체를 map에 등록하고 SQL 관련 메소드를 선택 해야 할 때는 findBooks 메소드를 통해 map에서 해당 전략에 맞는 메소드를 선택함으로써 변경 했다.
@Component
public class BookFinderImpl implements BookFinder<Page<BookDto>, Pageable>{
private final Map<TitleType,SelectStrategy> strategyMap;
public BookFinderImpl(BookRepository bookRepository) {
this.strategyMap = new EnumMap<>(TitleType.class);
strategyMap.put(KOR_SG, new KorNaturalSt(bookRepository));
strategyMap.put(KOR_MT_OVER_TWO, new KorNaturalSt(bookRepository));
strategyMap.put(KOR_MT_TWO, new KorBoolSt(bookRepository));
strategyMap.put(ENG_SG,new EngBoolSt(bookRepository));
strategyMap.put(ENG_MT,new EngBoolSt(bookRepository));
strategyMap.put(ENG_KOR_SG, new EngKorBoolSt(bookRepository));
strategyMap.put(ENG_KOR_MT, new EngKorNaturalSt(bookRepository));
}
/**
* 검색 대상(target)에 따라 적절한 검색 쿼리를 선택하여 도서를 검색하고, 결과를 반환하는 메서드입니다.
*
* @param titleQuery 검색 쿼리를 분석한 결과를 담고 있는 TitleQuery 객체
* @param pageable 페이지 관련 설정을 담은 Pageable 객체
* @return 검색 결과를 담은 Page<Book> 객체
*/
@Override
public Page<BookDto> findBooks(TitleQuery titleQuery, Pageable pageable) {
TitleType type = titleQuery.getTitleType();
SelectStrategy strategy = strategyMap.get(type);
if(strategy == null){
throw new IllegalArgumentException("Invalid title type: " + type);
}
return strategy.select(titleQuery,pageable).map(BookDto::new);
}
}
@RequiredArgsConstructor
@Repository
public class BookRepoQueryDsl implements BookRepository {
private final JPAQueryFactory factory;
private final static int LIMIT_CNT = 30;
private final static double SCORE_OF_MATCH = 0.0;
@Override
public Page<BookDto> findBooks(TitleQuery titleQuery, Pageable pageable) {
// match..against 문을 활용하여 Full text search를 수행
JPAQuery<Book> books = factory
.selectFrom(book)
.where(
getTemplate(
titleQuery.getTitleType(), titleQuery.getEngKorTokens()).gt(SCORE_OF_MATCH))
.offset(pageable.getOffset())
.limit(pageable.getPageSize());
long totalSize = getTotalSizeForPaging(titleQuery);
// 최종적으로 페이징 처리된 도서 검색 결과를 반환.
return PageableExecutionUtils.getPage(
books.fetch().stream().map(BookDto::new).toList(),
pageable, () -> totalSize);
}
private long getTotalSizeForPaging(TitleQuery titleQuery){
// 1.키워드가 하나인 포괄적 키워드는 count query 성능을 위해 size를 제한 한다.
// 2.그럼에도 결과값은 전체 대출 횟수를 기준으로 내림 차순으로 보여주기 때문에 검색 품질은 보장한다.
if (titleQuery.getTitleType() == TitleType.TOKEN_ONE) {
return LIMIT_CNT;
}else{
//키워드가 2개 이상일 땐, countQuery 메소드를 호출한다.
return countQuery(titleQuery).fetchOne();
}
}
private JPAQuery<Long> countQuery(TitleQuery titleQuery) {
return factory
.select(book.count())
.from(book)
.where(
getTemplate(
titleQuery.getTitleType()
, titleQuery.getEngKorTokens()).gt(SCORE_OF_MATCH));
}
// 사용자가 입력한 제목 쿼리를 분석한 결과를 바탕으로 boolean or natural 모드를 동적으로 선택
NumberTemplate<Double> getTemplate(TitleType type, String name) {
String function;
if (type.getMode() == BooleanMode) {
function = "function('BooleanMatch',{0},{1})";
// boolean 모드에서 모두 반드시 포함된 결과를 위해 '+'를 붙여주는 정적 메소드 호출.
name = TitleTrimmer.splitAddPlus(name);
} else {
function = "function('NaturalMatch',{0},{1})";
}
return Expressions.numberTemplate(Double.class,
function, book.titleToken, name);
}
private JPAQuery<Long> countQueryAll() {
return factory
.select(book.count())
.from(book);
}
// csv file로 변환 할 때 사용하기 위한 메소드.
public Page<Book> findAllAndSort(Pageable pageable){
JPAQuery<Book> books = factory
.selectFrom(book)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(book.loanCnt.desc());
return PageableExecutionUtils.getPage(
books.fetch(),
pageable, () -> countQueryAll().fetchOne());
}
}
아래 링크는 JPQL을 queryDsl을 활용하여 변경하는 과정을 담은 글이다.
https://velog.io/@alpahexia/JPQL%EC%9D%84-QueryDSL%EB%A1%9C-%EB%B3%80%ED%99%98
정말 좋은 글 감사합니다!