본문 바로가기
BackEnd/대규모 시스템 설계

[대규모 시스템 설계] 캐시 설계 전략

by 경험의 가치 2024. 5. 20.

캐싱 설계 전략 왜 필요한가?

캐싱은 서비스의 응답시간을 대폭 줄일 수 있는 아주 좋은 기술이다.

 

우리가 특정 사이트로부터 공지사항을 크롤링 해오고, 이를 목록으로 보여주는 기능을 구상했다고 해보자. 그리고 이 공지사항은 24시간에 한번만 크롤링을 해와 자주 바뀌지 않는 정보라고 가정해보자. 그런데 자주 바뀌지도 않는데 매번 RDB에서 정보를 읽어오면 어떨까? 가뜩이나 리스트 형태여서 많은 정보를 읽어와야하는데, 자주 바뀌지도 않는데도 매요청마다 읽어오면 매우 비효율적이고 성능에 치명적일 것이다.

 

따라서, 자주 바뀌지 않는 정보는 메모리에 캐싱을 해두면 빠르게 사용자의 요청에 응답할 수 있게 된다.

 

얼마나 빨리지는지 결론이 궁금한 사람들이 있을 것 같에서, 실제 우리 프로젝트에서 적용한 캐싱 결과를 공개하겠다.

AOP를 이용해서 메서드별 시간 측정을 하도록 만들었는데, 캐싱 전에는 공지사항 목록을 읽어오는데 414ms가 걸리는 모습이다.

캐싱된 데이터를 읽어오면 12ms만에 메서드가 실행된다. 거의 1/40 가까이 실행시간이 줄어든 모습을 볼 수 있다. 이렇듯, 캐싱을 하면 아주 효과적이다.

 

메모리가 무한하다면 모든 데이터를 캐싱해두면 확실히 좋긴 할 것이다. 하지만, 우리의 메모리는 무한하지 않다. 모든 데이터를 메모리에 캐싱해두면 용량이 부족해서져서 서버가 다운될 수 있다. 뿐만 아니라, 실제 데이터와 캐시 데이터가 서로 같지 않는 정합성 문제도 발생할 수 있다. 따라서, 우리는 어떤 데이터를 캐싱 해야하는지, 그리고 어떤 방식으로 캐싱을 해야하는지 캐싱 설계 전략을 사전에 짤 필요가 있다.

 

캐싱 전략은 읽기 전략과 쓰기 전략이 있다. 이번 포스팅에서는 이에 대해 다뤄보고자 한다. 참고로 캐시 서버는 Redis말고도 다른걸 사용할 수 있으나, 일반적으로 Redis를 가장 많이 사용하므로, 이를 바탕으로 다룰 것이다. 


캐시 읽기 전략

캐시 읽기 전략은 크게 두가지가 있다.

 

Look Aside 전략

 

Look Aside 전략은 다음과 같은 순서로 진행된다.

 

1. 우선적으로 Cache에 데이터를 받아온다. Cache Hit이 발생하면, 그 데이터를 전달한다. Cache Miss가 발생하면 2번으로 넘어간다.

2. 실제 RDB에서 데이터를 받아오고 전달한다.

3. RDB에서 읽어온 데이터를 Cache에 저장한다.

  • 가장 많이 사용되는 읽기 전략
  • Cache Aszie, Lazy Loading 전략이라고도 불림
  • 읽기가 많은 경우에 적합함
  • Cache가 Database에 직접적으로 연결되지 않아서, 캐시에 장애가 발생하더라도 서비스에는 문제가 없음
  • 하지만, Cache에 Connection이 많다면, 캐시가 다운되는 순간 많은 요청이 DB로 몰려서 부하가 발생
  • 데이터의 정합성 문제가 발생할 수 있음

위 전략은 좋지만 한가지 문제점이 있다. 서비스 초기에는 데이터 조회시에는 캐시에 데이터가 저장되있지 않아서 지속적으로 Cache miss가 발생하여 DB 성능 저하가 올 수 있다. 따라서, DB에 미리 캐시로 데이터를 넣어두는 작업인 Cache Warming 과정이 필요하다.  

 

 

Read Through 전략

 

Read Through 전략은 다음과 같다.

 

1. 우선적으로 Cache에 데이터를 받아온다. Cache Hit이 발생하면, 그 데이터를 전달한다. Cache Miss가 발생하면 2번으로 넘어간다.

2.  "캐시"에서 실제 RDB에서 데이터를 조회하고 캐시 데이터를 업데이트한다. 

3.  캐시에서 읽어온 데이터를 전달한다.

  • Look Aside와 가장 큰 차이는 캐시에 데이터를 저장하는 주체이다. Look Aside는 서버가 저장하지만, Read Through는 캐시가 직접 저장한다.
  • 데이터 조회를 전적으로 캐시에 의지하므로, 캐시가 다운되면 서비스가 망가져버림. 따라서, 캐시 서버를 Replication 하거나 Cluster로 구성하여 운영하는 것이 거의 필수적임. (Look Aside도 물론 이렇게 운영하는 것이 더 좋음)
  • 캐시가 데이터를 읽어오기 때문에, 정합성 문제에서 벗어날 수 있음

캐시 쓰기 전략

캐시 쓰기 전략으로는 크게 3가지가 있다.

 

Write Back 전략

 

1. 우선적으로 Cache에 데이터를 저장한다.

2. 일정 시간마다 실제 RDB에 데이터를 반영한다.

  • 데이터를 캐시에 일정 주기 모아서 Batch를 통해 실제 DB에 반영하는 형태 (Batch에 대하여)
  • 데이터의 정합성이 확보됨
  • 재사용되지 않는 데이터도 무조건 캐시에 넣어버리기 때문에 TTL 설정을 잘 해야한다.
  • Scheduler를 통해 모았다가 실제 DB에 반영하기 때문에 쓰기 비용을 대폭 줄일 수가 있음
  • 캐시에서 오류가 발생시, 데이터가 영구 소실됨

 

또한, 해당 Write Back 전략을 응용하여 Race Condition을 해결하는데도 사용할 수 있다. 실제로, 조회수나 추천수 같이 Race Condition이 많이 발생하는 상황에서 우리 팀은 Redis를 이용하여 Race Condition을 해결했다. 관련 내용 링크는 아래 글을 참조 바란다.

 

 

레디스와 스케줄러를 통한 조회수 증가 (+ 동시성)

레디스와 스케줄러를 통해 조회수 증가 로직을 개선해보았습니다. ( + 동시성도 고려해 보았습니다..)

velog.io

 

 

Write Through 전략

 

1. Cache에 우선적으로 데이터를 저장

2. 그 다음 DB에 데이터를 저장

  • Cache는 항상 최신 정보를 가지고 있어서 정합성 문제가 없어진다.
  • 데이터가 유실될 위험이 적어진다.
  • 재사용되지 않는 데이터도 무조건 캐시에 넣어버리기 때문에 TTL 설정을 잘 해야한다.
  • 매 요청마다 Write 작업이 두번 일어나서 빈번하게 수정이 일어날 경우 성능 저하가 발생한다.
  • DB 성능이 느릴 경우, 대기시간이 많이 발생한다.

Write Around 전략

 

1. 반드시 데이터를 RDB에 기록한다.

2. 기존 Cache에 저장되있는 데이터를 삭제하거나 무효화한다.

  • 모든 데이터는 DB에 저장됨. 따라서, Write 전략 중에서 가장 빠름
  • 하지만, 데이터의 정합성 문제가 발생할 수 있음

어떤 전략 조합을 사용해야될까?

일반적으로 가장 많이 쓰이는 조합은 Look Aside + Write Around 조합이다.

 

해당 조합을 사용하면, 쓸때 마다 Cache를 무효화 하고 (그럼 자연스럽게 Cache Miss를 유도해서 Database에서 읽어올 수 있다), 읽어올 때 우선적으로 Cache로 부터 읽어온다. 이때, Cache Miss가 나면 Database에서 읽어와서 캐시에 저장이 된다.

 

Read Through + Write Around 조합도 쓰일 수 있다.

 

항상 DB에 쓰고, 읽어올 때는 캐시가 DB로 부터 끌어와서 읽어오므로 데이터 정합성 문제가 발생하지 않는 완벽하게 안전한 구성이다.

 

그렇다면 Spring에서 이를 어떻게 구현하는가? 추후 다룰 예정이지만, Spring에서는 @Cacheable이라는 어노테이션을 통해서 쉽게 캐싱이 가능하다. 추가적으로, 캐시 저장소를 Redis로 설정해기만 하면된다. 현재 글을 작성하지 않아 관련 링크로 대체한다.

 

 

Spring Boot caching with Redis

Implementing Caching in Spring Boot Application Using Redis

medium.com

 


어떤 데이터를 캐싱해야될까?

캐싱할 데이터는 자주 사용되면서 자주 바뀌지 않는 데이터를 우선적으로 선택해야된다. 두번째 고려해야 될 사항은, 캐시는 메모리에 저장하기 때문에 데이터의 휘발성을 기본으로 한다. 또한, 앞서 언급했다싶이 데이터의 정합성 문제가 발생할 수 있다. 따라서, 중요한 정보는 되도록 캐싱하지 않아야한다.

 

 

내가 소프트웨어 공학 강의를 들으면서, 들었던 얘기중 하나가 파레토의 법칙이다. 캐싱도 이 법칙을 따른다. 모든 데이터를 굳이 캐싱할 필요가 없다. 파레토 법칙에 따라서 일부 데이터만 캐싱해도 대부분 데이터가 커버가 되기 때문이다.


캐싱 유효기간을 얼마나 잡아야될까?

우리가 캐싱을 했는데, 오랫동안 그 데이터를 제거하지 않고 유지한다면 반드시 정합성 문제가 발생할 것이다. 뿐만 아니라, 데이터를 캐싱했는데, 업데이트 했음에도 불구하고 옛날 데이터를 그대로 보관하면 메모리 사용량에 문제도 생길 것이다. 따라서, TTL을 설정해서 캐싱의 유효기간을 설정할 필요가 있다.

 

이게 참 시간 설정하는 부분이 어려운 일이다. 분명하게 이 정도가 좋다고 말할 수 있는게 없다. 데이터의 특성에 따라서, 사용 빈도에 따라서 다 다르기 때문에 스스로 알잘딱하게 판단해서 정할 필요가 있다.

 

예를 들어, TTL을 너무 길게 설정하면, 정합성 문제가 발생하고, 쓸모없는 데이터들이 메모리를 차지하게 된다. 반대로, TTL을 너무 짧게 설정하면 데이터가 너무 빨리 제거되어서 캐싱을 하는 의미가 없어진다.

 

nhncloud에서 설명하는 이미지

 

또한, Cache Stampede 현상이라고 존재하는데, 우리가 Look Aside 전략에서 TTL을 너무 짧게 설정했다고 가정해보자. 그런데, 우연히도 수많은 트래픽이 딱 캐싱이 만료되는 시간에 접근했다고 해보자. 그러면, 그 수많은 트래픽이 동시에 DB에 접근할 것이고, duplicate read와 duplicate write가 발생하여 서비스 장애로 이어질 가능성이 커진다.

 

따라서, 적절하게 캐싱 TTL을 설정하는 것이 좋다. 이건 스스로 판단해서....


캐싱은 어떤 기술 스택을 사용해야 될까?

대표적으로 캐싱에 사용할 수 있는 방법을 Local과 Global 2가지로 구분해보자. 첫번쨰, Spring 라이브러리가 제공해주는 방법을 이용해 서버 인스턴스 메모리에 Local Caching 한다. 하지만 해당 방식은 로드밸린서을 통해서 인스턴스가 많아지면 캐시 Hit률이 현저히 떨어져서 좋은 방법이 아니다. 두번째는 Glboal Cache를 두는 방법이다. 이를 위해서 대표적으로 Redis와 Memcached가 많이 사용된다. 둘다 In-Meomory Key-Value 방식의 No SQL DB인데, 가장 큰 차이는 Redis는 싱글 스레드이고 Memcached는 멀티스레드이다. 또한, Redis는 여러가지 자료 구조를 지원해주는데 Memcached는 오로지 string만 된다.

 

그러면 어떤 것을 선택해야되는가...? 사실 이와 관련해서 여러 자료를 찾아봤는데 명확한 답변은 없었다. 그래서 내가 개인적으로 생각한 선정 기준은 동시성 예방이 중요한가 여부 or 다양한 자료구조가 필요한가 여부인 것 같다. 아무래도 Memcached는 멀티 스레드다 보니깐 동시성 예방이 안된다. 그래서 동시성 고려가 전혀 필요하지 않은 데이터 캐싱은 Memcached, 아니면 Redis를 사용한다... 이렇게 보고 있다. 또한, Set, List, Sorted Set 등의 자료구조가 필요하면 Redis를 사용할 수 밖에 없다. 그런데 사실 Stackoverflow의 여러 답변들을 보면 외국인 형님들은 애초에 Redis가 Memcached의 단점을 보완하기 위해서 나온거라 그냥 Redis를 이용하면 된다고 말들 하긴 한다. 


추가 보충 내용 (+ 2024.11.06 보충)

 

현재 프로젝트 개발을 하면서 내가 잘못 생각 했던 부분이 있어서 공유를 좀 해보고자 한다.

 

우선, 페이지네이션 캐싱과 관련된 내용이다. 페이지네이션은 많은 데이터를 동시에 SELECT해서 정렬 후, 잘라서 보내주는 방식이므로 생각보다 시간이 오래 걸린다. 따라서, 캐싱하면 좋긴하다. 하지만, 여기서 페이지네이션의 함정이 있다. 내가 전 프로젝트에서는 모든 페이지네이션에 대해 캐싱을 했다. 그런데 생각해보면, 사용자는 보통 최신 정보만 찾아보지 밑에 내리는 경우는 적다. 또한, 필터를 통해 검색한 페이지네이션도 사용자마다 필요한 정보가 다르기 때문에 캐시가 Hit 될 가능성이 현저히 낮아진다. 그래서 오히려 이건 캐싱의 장점이 없고 리소스 낭비라고 생각했다. 따라서, 새로하는 프로젝트에서 생각한 방법은 첫번째 페이지에 대해서만 캐싱을 하자이다. 사용자가 목록 페이지 접속시, 첫 페이지는 반드시 노출된다. 즉, 캐시가 Hit될 가능성이 굉장히 높아진다.

 

또한, 페이지네이션이 있으면 글이 몇개가 검색됐다고 count 쿼리를 날릴 것이다. 즉, 페이지네이션 세부 정보 쿼리 한개, count 쿼리 한개, 총 2개의 쿼리가 나간다. 그런데 매번 페이지네이션 요청을 날릴 때 마다 count 쿼리를 실행하는 것은 좋은 방법이 아니다. 따라서, 전체 검색에 대해서는 count를 캐싱해서 사용하거나 graphql을 통해 count만 뺴고 받아오도록 구성하거나 count는 안주는 endpoint를 따로 파거나 하는게 좋다.

 

마지막으로, Global Cahce가 다운됐을 상황이다. 서버를 로드 밸런싱해서 Redis를 이용한 Global Cache를 사용한다고 해보자. 그런데 Redis가 다운되었다면 캐싱 결과는 null값이 아닌 Exception을 뱉는다. 즉, 작동되지 않는 것이다. Redis 캐싱 서버가 다운됐다고 서비스가 작동이 안되는 것은 잘못됐다. 따라서, Redis Cluster를 구성해서 Master-Slave 구조로 고가용성을 유지하는 것이 좋다. 또한, Global Cahce가 다운되면 Local Cache를 이용하도록 구성하는게 좋다. 이는 서킷브레이커 패턴을 사용해서 자동으로 Local Cache로 전환되도록 하는 식으로 구성할 수도 있다. 아니면, Local Cache를 우선적으로 조회하고, 캐시가 없는 경우 Global Cache를 조회하고, 없으면 DB를 조회하는 식으로 단계적으로 구성해도 좋다. 이 부분에 대해서는 추후 글을 따로 작성해서 다뤄보고자 한다.


💡참고자료