-
식탁위의 메뉴판, Local cache invalidateSpring Framework 2023. 5. 10. 23:47반응형
1) 배경 설명
햄버거 가게에 들어갑니다.
그리고 각 식탁에 놓인 메뉴판을 보려고 하는데요.- 메뉴판은 각 테이블에서 쉽게 볼 수 있어야 합니다
- 한 번 생성된 메뉴판은 거의 변경되지 않습니다
- 특정 테이블만 변경전 메뉴판 사용하더라도 심각한 문제는 아닙니다. 점원이 안내 후 새 메뉴판을 전달합니다.
2) 메뉴판을 조회합니다
- DB에 저장된 메뉴 조회 (= 점원이 직접 메뉴판을 가져다주는 행위)
현재는 테이블에 메뉴판이 없습니다. 손님이 메뉴판을 보내는 방법은 이렇습니다.- 손님은 점원을 불러야 합니다
- 점원은 메뉴판을 가지러 가야 합니다
- 점원은 메뉴판을 가지고 와야 합니다
- 손님은 메뉴판을 보기 위해 대기해야 합니다
- 만약 식사 중 메뉴판을 보기 위해서 다시 반복해야 합니다
메뉴판을 보기 위해서 위의 과정을 거쳐야 합니다. 만약 이것이 네트워크 및 DB 질의 비용이라고 한다면 비효율적입니다.
점원을 부르지 않고 메뉴를 바로 볼 수 있는 방법이 있다면 어떨까요? 단순히 테이블에 메뉴판이 존재한다면 효율적일것 같습니다. 점원을 부르지 않아도 되고, 메뉴판을 기다리는 시간도 줄일 수 있을 것 같습니다.
3) 메뉴판을 가까운곳에 캐싱해 봅시다
메뉴판을 식탁 위에 두려고 합니다. 손님이 곧바로 손을 뻗어 볼 수 있게 하기 위함인데요.
이를 위해서 캐시를 도입하려고 합니다. 그리고 두 가지 캐시 종류를 고민해야 하는데요.3. 1) Local vs Distributed cache
Local cache(= In-process cache)
- 각 프로세스, 즉 애플리케이션마다 캐시를 가집니다
- 다른 프로세스(Redis 등등)의 캐시를 참조하기 위해서 네트워크 I/O 비용이 발생하지 않습니다
Distributed cache
- in-memory에 전역 캐시를 설정해 두고 모든 프로세스(인스턴스)가 동일한 캐시를 참조합니다
- 애플리케이션 프로세스 <--> memory 간의 네트워크 비용 발생합니다
3. 2) Local cache 사용
아마도 현실에서 대부분 Redis 활용한 분산 캐시를 사용할 것인데요.
약간의 Network I/O가 발생하지만 성능적으로 매우 우수합니다.
특히 대용량 요청을 처리하기 위한 다중 서버 아키텍처에서는 필수적입니다. MSA 구조에서 프로세스(인스턴스) 하나의 수정과 배포는 아주 빈번히 발생합니다. 즉 Redis에 이미 캐싱된 데이터를 사용하고, 프로세스에서 다시 캐시를 하지 않아도 되는데요.
그럼에도 불구하고 local cache를 선택한 이유는 이렇습니다.- 메뉴판 데이터는 아주 가볍다고 가정합니다. 배포 후 1회 캐싱이 발생해도 문제없다고 가정합니다
- 메뉴가 변경되기 전까지는 메뉴 조회 시 캐시 히트율 100% 입니다 (단일 메뉴판)
- Network I/O를 최소화합니다
- (local cache invalidate 테스트 때문에 조금 억지도 있습니다)
스프링부트에서 local cache 사용은 매우 쉬운데요. 애노테이션을 이용해서 이미 추상화를 잘해두었습니다.
(local cache invalidate 테스트 위한 글이기 때문에 자세한 구현, 동작에 대해서는 설명하지 않습니다.)- ConcurrentMapCacheManager를 이용하여 CacheManager를 구현합니다
- ConcurrentHashMap 자료구조를 이용해서 캐싱합니다
- 최초의 요청에 대해서 캐싱을 하고
- AOP 이용하여 캐시를 확인합니다
- 당연히 동일 클래스 호출에서는 동작하지 않습니다
하지만 메뉴판의 수정이 필요하다면 어떻게 해야 될까요?
다중 프로세스(인스턴스) 아키텍처는 아래와 같을 것입니다.클라이언트 요청에 대해서 적절히 분산해 주는 LB(load balancer)가 존재하는데요. @Cacheput 사용하여 로컬 캐시 invalidate 하는 것은 불가능합니다. 왜냐하면 LB에서 어떤 서버로 요청을 보낼지 알 수 없기 때문입니다.
모든 서버의 @Cacheput 메서드가 호출될 때까지 요청해야 할까요? 이러한 방식은 비효율적인데요. 모든 프로세스의 로컬 캐시가 invalidate 될 때까지 계속해서 호출해줘야 할 것입니다. 그때까지 고객은 과거에 캐싱된 데이터를 보게 됩니다.
그렇다면 다중 프로세스 환경에서 local cache는 사용할 수 없는 것일까요?
5) Local cache invalidate
현재까지는 각 프로세스의 로컬 캐시를 일괄적으로 처리할 수 없는 문제가 있습니다. 이것을 Redis pub/sub을 이용해서 해결하려고 합니다.
마찬가지로 자세한 코드 설명은 생략합니다. 전체 흐름은 이렇습니다.- 메뉴 업데이트 요청 → 랜덤하게 3번 프로세스로 요청
- Redis로 캐싱된 메뉴 데이터 invalidate 요청 publish
- 모든 프로세스들은 invalidate 요청 subscribe
- 메뉴 업데이트
- local cache invalidate 요청 publish
- MessageListener에 등록해 둔 subscriber가 토픽 중
- subscribe 후 cache claer
- 치즈버거 19,000원으로 메뉴 변경
- 조회 시 변경된 데이터가 정상적으로 반영됩니다
- 재조회 시 캐싱되어 쿼리 발생하지 않습니다
6) Local cache invalidate는 반드시 Redis?
사실 local cache invalidate 방법에는 Redis뿐만 아니라 Kafka도 충분히 활용할 수 있다고 생각합니다. 개인적으로는 Kafka를 활용한 pub/sub을 활용하는 것이 더 좋지 않을까 생각하는데요.
Kafka를 활용하여 pub/sub을 구현했을 때는- 파티션 1개로 제한하여 다중 인스턴스에서 중복 subscribe 방지 가능합니다
- Redis의 경우 모든 인스턴스 subscribe 하여 한 번만 동작해야 하는 부분에서 문제 발생
- 파티션을 활용하여 요청량 제어 및 동시성 제어를 활용할 수 있습니다
- 파티션의 offset를 활용해서 장애가 발생하더라도, 특정 시점부터 subscribe 가능합니다
- Redis를 활용할 때는 각각의 subscriber를 bean으로 등록해야 하는 번거로움도 있습니다
하지만 여러 문서들을 참고했을 때 Kafka poll 행위로 인해 지연이 발생한다고 합니다.
특히 Use Cases and Comparison With Apache Kafka 문서를 확인했을 때는 Redis - 1ms / Kafka - 15ms로 지연시간 차이가 난다고 합니다.
개인적으로 15ms는 치명적인 지연이라고 생각하지 않는데요. 게임과 같이 1ms 지연도 중요한 곳에서는 Redis를 고려해 볼 수 있을 것 같습니다. 하지만 게임처럼 아주 실시간이 중요한 것이 아니라면 redis pub/sub이 정말 효율적인지 의문입니다.혹시 다른 경험이 있다면 편하게 댓글로 공유 부탁드립니다.
7) 결론
햄버거 가게 메뉴를 바탕으로 local cache를 활용해봤습니다.
사실 개인적으로 현실 업무에서는 로컬 캐시를 사용할 경우가 없었는데요. 하지만 Redis 캐싱조차 결국에는 네트워크 비용이 발생하기 때문에 로컬 캐시를 활용하면 어떨까 하는 단순한 생각으로 출발했습니다.
다만 인스턴스가 빈번히 배포되는 환경에서는 캐싱 비용이 발생할 것 같습니다. 그래서 데이터가 아주 가볍지만 히트율이 높은 것을 선정하여 로컬 캐시를 제한적으로 활용하는 것은 어떨까 생각합니다.
혹시 본인이 로컬 캐시를 적극적으로 활용해 본 경험이 있다면 편하게 공유 부탁드립니다!반응형'Spring Framework' 카테고리의 다른 글
JPA Repository 기본 postfix로 인한 순환참조 해결 (0) 2023.07.30 Spring Cloud Sleuth + logback 적용기 (0) 2023.02.01 스프링에서 재처리를 위한 @Retryable 사용하기 (2) 2022.08.22 Junit 테스트 병렬 수행으로 빌드 시간 단축하기 (0) 2022.08.16 Querydsl cross join 개선 하기 (0) 2022.06.18