과정을 즐기자

로컬 캐시를 적용하여 이메일로 전송하고자 하는 퀴즈 캐싱하기 본문

Spring

로컬 캐시를 적용하여 이메일로 전송하고자 하는 퀴즈 캐싱하기

320Hwany 2023. 5. 3. 10:10

해결하고싶은 문제

현재 CS 면접 질문 리스트를 이메일로 전송해주는 서비스를 만들고 있습니다.   

특정 시간이 되면 가입된 회원이 원하는 분야의 문제를 원하는 문제 수만큼 보내줍니다.   

이때 DB에 저장되어있는 퀴즈는 실시간으로 변경되는 데이터가 아닙니다.   

따라서 매번 DB에서 퀴즈 데이터를 가져오는 것은 비효율적이라는 생각이 들었습니다.  

로컬 캐시를 이용해서 이러한 성능 문제를 개선해보겠습니다.   

 

도메인 이해

테이블이 어떻게 설계되었는지 살펴보겠습니다.  

각 회원은 Quiz_Filter와 OneToOne 관계를 맺고 있습니다.    

특정 시간이 되면 모든 회원에게 num_of_problem의 수만큼 분야가 true인 분야의 문제만 퀴즈 테이블에서 찾아서  

이메일로 전송해줍니다.  

Before

개선하기 전 로직에 대해 살펴보겠습니다.  

QuizService

@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class QuizService {

    private final QuizRepository quizRepository;
    private final QuizFilterRepository quizFilterRepository;
    private final EmailQuizSender emailQuizSender;

    ...
 
   
    public void sendRandomQuizList() {
        List<QuizFilterSearch> quizFilterSearchList = 
        			quizFilterRepository.findAllQuizFilterSearch();
        for (QuizFilterSearch quizFilterSearch : quizFilterSearchList) {
            List<Quiz> randomQuizList = 
            		quizRepository.findRandomQuizList(quizFilterSearch);
            emailQuizSenderProd
            		.sendQuizList(randomQuizList, quizFilterSearch.getEmail());
        }
    }

    ...
}

QuizRepositoryImpl

@RequiredArgsConstructor
@Repository
public class QuizRepositoryImpl implements QuizRepository {

    private final QuizJpaRepository quizJpaRepository;
    private final JPAQueryFactory queryFactory;

    @Override
    public List<Quiz> findRandomQuizList(QuizFilterSearch quizFilterSearch) {
        List<BooleanExpression> expressions = findExpressionList(quizFilterSearch);
        if (expressions.isEmpty()) {
            return new ArrayList<>();
        }

        BooleanExpression finalExpression = expressions.get(0);
        for (int i = 1; i < expressions.size(); i++) {
            finalExpression = finalExpression.or(expressions.get(i));
        }

        List<Quiz> quizList = queryFactory.selectFrom(quiz)
                .where(finalExpression)
                .fetch();

        Collections.shuffle(quizList);

        return quizList.stream()
                .limit(quizFilterSearch.getNumOfProblem())
                .collect(toList());
    }

    private List<BooleanExpression> findExpressionList(
    				QuizFilterSearch quizFilterSearch) {
        BooleanExpression networkExpression = networkTrue(quizFilterSearch);
        BooleanExpression databaseExpression = databaseTrue(quizFilterSearch);
        BooleanExpression operatingSystemExpression = 
        					operatingSystemTrue(quizFilterSearch);
        BooleanExpression dataStructureExpression = 
        					dataStructureTrue(quizFilterSearch);
        BooleanExpression javaExpression = javaTrue(quizFilterSearch);
        BooleanExpression springExpression = springTrue(quizFilterSearch);

        List<BooleanExpression> expressions = new ArrayList<>();
        if (networkExpression != null) expressions.add(networkExpression);
        if (databaseExpression != null) expressions.add(databaseExpression);
        if (operatingSystemExpression != null) 
        			expressions.add(operatingSystemExpression);
        if (dataStructureExpression != null) expressions.add(dataStructureExpression);
        if (javaExpression != null) expressions.add(javaExpression);
        if (springExpression != null) expressions.add(springExpression);

        return expressions;
    }

    private BooleanExpression networkTrue(QuizFilterSearch quizFilterSearch) {
        return quizFilterSearch.isNetwork() 
        		? quiz.subject.eq(Subject.NETWORK) : null;
    }
	
    private BooleanExpression databaseTrue(QuizFilterSearch quizFilterSearch) {
        return quizFilterSearch.isDatabase()
        		? quiz.subject.eq(Subject.DATA_BASE) : null;
    }

    private BooleanExpression operatingSystemTrue(QuizFilterSearch quizFilterSearch) {
        return quizFilterSearch.isOS()
        		? quiz.subject.eq(Subject.OPERATING_SYSTEM) : null;
    }

    private BooleanExpression dataStructureTrue(QuizFilterSearch quizFilterSearch) {
        return quizFilterSearch.isDataStructure()
        		? quiz.subject.eq(Subject.DATA_STRUCTURE) : null;
    }

    private BooleanExpression javaTrue(QuizFilterSearch quizFilterSearch) {
        return quizFilterSearch.isJava() ? quiz.subject.eq(Subject.JAVA) : null;
    }

    private BooleanExpression springTrue(QuizFilterSearch quizFilterSearch) {
        return quizFilterSearch.isSpring() ? quiz.subject.eq(Subject.SPRING) : null;
    }
}

로직이 조금 복잡해보이지만 findRandomQuizList() 메소드 밑에 있는 메소드들은

동적 쿼리를 만들기 위한 메소드입니다.  

모든 분야가 false라면 where 절에 아무것도 들어가지 않는 것을 방지하기 위해 빈 ArrayList를 반환했습니다.  

또한 모든 BooleanExpression을 or로 연결한 후 랜덤으로 만들기 위해 shuffle 한 후

numOfProblem의 수만큼 반환하였습니다.  

 

After

이제 개선한 후 로직에 대해 살펴보겠습니다.  

 

quizService

 

@Slf4j
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class QuizService {

    private final QuizRepository quizRepository;
    private final QuizFilterRepository quizFilterRepository;
    private final EmailQuizSender emailQuizSender;
    private final CacheManager cacheManager;

    public void sendRandomQuizList() {
        List<QuizFilterSearch> quizFilterSearchList = 
        			quizFilterRepository.findAllQuizFilterSearch();
        for (QuizFilterSearch quizFilterSearch : quizFilterSearchList) {
            List<Quiz> filteredQuizList = getFilteredQuizList(quizFilterSearch);

            Collections.shuffle(filteredQuizList);
            List<Quiz> randomQuizList = filteredQuizList.stream()
                    .limit(quizFilterSearch.getNumOfProblem())
                    .toList();

            emailQuizSender.sendQuizList(randomQuizList, quizFilterSearch.getEmail());
        }
    }

    protected List<Quiz> getFilteredQuizList(QuizFilterSearch quizFilterSearch) {
        return new ArrayList<>(findAll()
                .stream()
                .filter(q -> q.filterQuizList(quizFilterSearch))
                .toList());
    }

   @Cacheable(value = QUIZ_CACHE)
    public List<Quiz> findAll() {
        Cache cache = requireNonNull(cacheManager.getCache(QUIZ_CACHE));
        List<Quiz> quizList = cache.get(QUIZ_LIST, List.class);

        if (quizList == null) {
            quizList = quizRepository.findAll();
            cache.put(QUIZ_LIST, quizList);
        }

        return quizList;
    }
    
    @Scheduled(fixedRate = ONE_DAY)
    @CacheEvict(value = QUIZ_CACHE, allEntries = true)
    public void flushCacheToDB() {
        log.info("flushCacheToDB");
    }

	...
}

 

먼저 모든 회원의 quizFilter 정보를 가져옵니다. 각각의 findAll()로 모든 퀴즈를 가져온 후

filterQuizList(quizFilterSearch)로 분야가 true인 퀴즈만 가져옵니다.  

이때 findAll() 메소드를 살펴보면 만약 캐시에 데이터가 있다면 캐시에서 가져오고 

없다면 DB에서 가져오도록 하였습니다. 

이렇게 퀴즈를 가져온 후 shuffle 하여 랜덤으로 원하는 문제 수 만큼 이메일로 보내는 로직으로 개선하였습니다.    

 

이때 캐시에 대해 조금 더 알아보면 사용한 캐시는 Spring Cache의 대표격이고

가볍고 빠른 echache를 사용하였습니다.

cacheManager에서 value값으로 캐시를 가져오고 이 캐시에서 QUIZ_LIST를 List 형태로 가져옵니다.

값이 null이라면 DB에서 가져오고 캐시에 값을 넣고 null이 아니라면 캐시에서 값을 가져옵니다.   

flushCacheToDB로 캐시의 데이터는 하루에 한번씩 비워서 DB의 정보로 업데이트 할 수 있도록 하였습니다.  

 

정리

주어진 조건으로 필터링을 할 때 개선하기 전 로직은 DB에 직접 접근을 하기 때문에 

동적 쿼리를 사용하였습니다. 따라서 로직이 더 복잡했습니다.  

개선한 후 로직은 필터링을 할 때 DB에 접근하지 않고 stream()의 filter()를 사용했기 때문에  

로직이 더 단순하였습니다.  또한 DB 한테는 어려운 일을 주는 것보다 쉽고 빠른 일을 여러번 주는 것이

더 낫기 때문에 이 방법이 더 합리적입니다.

 

데이터의 변경이 빈번하지 않고 업데이트가 실시간으로 이루어지지 않아도 되기 때문에

로컬 캐시를 이용하여 JVM 힙 메모리에 퀴즈를 저장하여 불필요한 트래픽을 줄일 수 있습니다.  

 

다음에는 nGrinder와 같은 성능 테스트 도구를 이용하여 객관적인 자료로 성능을 비교해보겠습니다.