캐싱 전략

June·2022년 12월 8일
1

실무 문제

목록 보기
1/10

학습 동기

캐싱이라는 것이 그냥 얼마나 캐싱할지 시간만 잘 걸어두면 되는거라고 생각을했다. 하지만 뭐를 키로 잡을 것인지, 데이터가 변경이 일어났을 때 원본 데이터와 동기화를 어떻게 맞출 것인지 등 고려할 것이 많았다. 그래서 이번에는 이론적인 것보다는 경험으로 배운 것들을 정리해본다.

비즈니스 요구 사항

  • 이벤트 대상인 유저만 퀴즈를 풀 수 있다.
  • 퀴즈는 평일 9시부터 23:59:59까지 참여 가능하다.
  • 동일한 퀴즈에 대해서 중복 참여할 수 없다.
  • 이벤트 대상이며, 퀴즈를 풀 수 있는 시간이며, 그날 문제를 풀지 않은 유저에게만 배너가 보인다.

이런 상황에서 수 많은 유저들이 화면에서 배너를 조회하는 API를 칠 것이다. 메인 화면에 걸리는 배너라면 사용자가 가장 많이 왔다갔다 하는 곳인데 그럴때마다 DB 조회가 일어난다면 부담이된다. 그래서 적절한 캐싱 전략이 필요하다.

1트

단순하게 코드를 짜봤다. 우선 지금 풀 수 있는 문제가 있는지 조회한다. 없다면 빈 객체가 나갈 것이다. 그 이후 배너가 보여지는 조건의 대상이고 오늘 문제를 풀지 않았다면 배너를 보여준다는 dto가 응답으로 나갈 것이다. 캐싱 전략도 별 생각 없이 유저 id와 그날 날짜를 키로 잡아서 캐시 유효 기간을 24시간으로 잡았다.

문제점

여기에는 크게 두 가지 문제가 있다.

  1. 너무 덩어리가 큰 캐싱

    getBannerCondition() 메서드 안에 여러가지 메서드들의 결과를 조합해서 리턴값을 준다. 언제 캐시를 evict 해야할지 생각을 안한 것도 문제고, 캐시 evict를 하면 저 3가지 조건 중 하나가 바뀌어도 캐시를 evict 해야하므로 캐시 miss가 잦을 것이다. 처음에는 이벤트 대상이 바뀌는 경우가 거의 없지 않을까? 그날 풀 수 있는 문제가 바뀌는 경우는 없지 않을까? 생각했지만 제품을 운영하다보니 그렇지 않았다. 시의성 때문에 급하게 퀴즈가 빠지거나 추가되는 경우도 있었다. 또 본 제품이 나가기 전 dev 환경에서 qa를 할때 유저를 등록시키고 빼는 경우도 흔했다. 이럴때 캐시가 남아있다면 제대로 qa 테스트를 할 수 없다. 캐시를 다룰 수 있는 방법은 항상 제공해드려야 한다.

    -> 각 메서드 별로 쪼개서 캐싱을 하기로 했다.

  2. 지나치게 긴 캐시 유효 시간
    처음에는 하루에 한 문제를 풀 수 있으니 캐시 유효 시간을 24시간으로 넉넉하게 잡으면 된다고 생각했다. 하지만 퀴즈 유효시간은 아침 9시부터 23:59:59까지다. 만약 유저가 밤 11시에 처음으로 이 배너를 조회했다고 가정해보자. 그러면 그 날의 배너는 올바르게 조회가 되겠지만, 다음날 01시에는 현재 풀 수 있는 문제가 없다고 나와야함에도 불구하고 24시간의 캐싱 때문에 배너가 조회될 것이다. 또 9시가 지나서 새로운 문제가 조회되어야 함에도 불구하고 이전날의 캐시가 남아있어 이전날의 문제가 조회될 것이다.

    -> 캐시 유효 기간을 3시간으로 잡기로 했다. 위에서 각 메서드별로 쪼개기 때문에 다른 조건에서 충분히 걸린다고 생각했다.

2트

getNowSolvableQuiz() -> getCachedNowSolvableQuiz()

캐시 hit 하지 못하여 메서드 내부를 타게 되면 운영에서 알기 쉽게 로그를 찍어놨다.

isBannerDisplayTarget()

isBannerDisplayTarget도 내부적으로 캐싱된 메서드를 이용하도록 바꾸었다. 새로 퀴즈 대상을 추가/삭제하는 경우 캐시가 evict되도록 하여 유효한 데이터를 바라볼 수 있게 하였다.

notSolvedToday()

마찬가지로 내부적으로 캐싱된 메서드를 이용하게 바꾸어서 생략한다. 이전과 달라진 점이라면 캐싱 시간을 3시간으로 짧게 가져간 것이다. 3시간이라하더라도 하루에 8번도 조회되지 않기 때문에 괜찮을 것이라 생각했다.

하지만 이렇게 해도 문제는 남아있다.

문제점

  1. 엔티티를 캐싱해도 괜찮을까?
    현재 findCachedAllQuiz() 메서드나 cachedFindUserValidAndDisplay() 메서드를 보면 엔티티 자체를 캐싱하고 있다. 이렇게 코드를 짜서 두 가지 문제가 있었다. 첫번째는 잠재적인 문제인데 엔티티는 만들려고 하는 메서드에서 필요한 필드보다 많은 필드를 가지고 있었다. 전체를 캐싱하는 것은 메모리 낭비로 이어진다. 꼭 필요한 필드만 dto에 저장해서 캐싱하면 메모리를 아낄 수 있다. 두 번째는 배치 같은 곳에서 캐싱된 엔티티를 가져올 경우 데이터 정합성 문제가 발생할 수 있다는 점이다. 따라서 dto를 만들고 필요한 부분만 캐싱하기로 했다.

3트

isBannerDisplayTarget

이제 필요한 필드만 뽑아서 dto로 넘기고 있다. 메모리에 캐시되는 필드가 적어지는만큼 더 효율적인 자원 관리가 가능해졌다.

기타

캐시는 내부적으로 프록시를 사용한다. 따라서 한 서비스에서 캐시를 이용해서 조회하는 메서드 / 캐시를 이용하지 않고 조회하는 메서드 두 개를 두고 내부 호출을 할 수 없다. 그래서 나는 캐싱하는 메서드를 이용하는 서비스 / 그냥 메서드를 이용하는 서비스로 구분을 했다.

이때도 처음에는 일반 서비스 -> 캐시 서비스 -> 리포지토리 로 구현을 했는데, 이렇게하면 일반 서비스에서 트랜잭션을 열게된다. 캐싱이 되어있다면 굳이 트랜잭션을 열고 닫을 필요가 없다.

캐시 서비스 -> 일반 서비스 -> 리포지토리 이런식으로 흐름을 둔다면 캐싱되어있다면 애초에 트랜잭션을 열지 않고 데이터를 메모리에서 조회할 것이다.

정리

캐싱 전략을 세우는 것이 생각보다 단순하지 않았다. 비즈니스 요구사항은 물론이고, 메모리의 효율적 관리, 그리고 운영성 업무까지 고려해야했다. 아직도 완벽하게 캐싱 전략을 세운 것 같지는 않아 4트, 5트가 나오면 업데이트 할 계획이다.

0개의 댓글