캐싱 가이드라인
GitLab v19.1이 문서는 GitLab에서 사용하는 다양한 캐싱 전략, 효과적인 구현 방법, 그리고 여러 주의사항에 대해 설명합니다. 데이터를 위한 더 빠른 저장소로, 다음과 같은 특징이 있습니다: 프로세서에도 캐시가 있고, 하드 디스크에도 캐시가 있으며, 많은 곳에 캐시가 있습니다!
이 문서는 GitLab에서 사용하는 다양한 캐싱 전략, 효과적인 구현 방법, 그리고 여러 주의사항에 대해 설명합니다. 이 내용은 훌륭한 캐싱 워크숍에서 발췌했습니다.
캐시란 무엇인가?#
데이터를 위한 더 빠른 저장소로, 다음과 같은 특징이 있습니다:
- 컴퓨팅의 다양한 영역에서 사용됩니다.
프로세서에도 캐시가 있고, 하드 디스크에도 캐시가 있으며, 많은 곳에 캐시가 있습니다!
-
데이터가 최종적으로 도달해야 할 위치에 더 가깝게 위치하는 경우가 많습니다.
-
데이터를 위한 더 단순한 저장소입니다.
-
임시적입니다.
무엇이 빠른가?#
모든 웹 페이지의 목표는 100ms 이내에 응답을 반환하는 것이어야 합니다:
-
이는 달성 가능하지만, 현대 애플리케이션에서는 캐싱이 필요합니다.
-
더 큰 응답은 빌드하는 데 더 오래 걸리며, 일정한 속도를 유지하기 위해서는 캐싱이 매우 중요합니다.
-
캐시 읽기는 일반적으로 1ms 미만입니다. 이것으로 개선되지 않는 경우는 거의 없습니다.
-
이후 페이지 로드에서만 빠른 것은 충분하지 않습니다. 초기 경험도 중요하기 때문에, 이것만으로는 완전한 해결책이 되지 않습니다.
-
사용자별 데이터는 이를 어렵게 만들며, 기존 애플리케이션을 이 속도 목표에 맞게 리팩토링하는 데 있어 가장 큰 과제입니다.
-
사용자별 캐시도 효과적일 수 있지만, 사용자 간에 공유되는 일반 캐시보다 캐시 히트 수가 적습니다.
-
우리는 페이지 로드의 대부분이 항상 캐시에서 가져오는 것을 목표로 합니다.
캐시를 사용하는 이유#
-
작업을 더 빠르게 만들기 위해!
-
IO를 피하기 위해.
디스크 읽기.
-
데이터베이스 쿼리.
-
네트워크 요청.
-
동일한 결과를 여러 번 재계산하는 것을 피하기 위해:
뷰 렌더링.
-
JSON 렌더링.
-
Markdown 렌더링.
-
중복성을 제공하기 위해. 일부 경우에는 캐싱이 CloudFlare의 "Always Online" 기능과 같이 다른 곳에서의 장애를 숨기는 데 도움이 될 수 있습니다.
-
메모리 소비를 줄이기 위해. Ruby에서 더 적게 처리하고 큰 문자열만 가져옵니다.
-
비용을 절약하기 위해. RAM에 비해 프로세서가 비싼 클라우드 컴퓨팅에서 특히 그렇습니다.
캐싱에 대한 의구심#
-
일부 엔지니어들은 캐싱을 마지막 수단으로만 사용하자며 반대합니다. 이들은 캐싱을 임시방편으로 보고, 진짜 해결책은 근본적인 코드를 더 빠르게 개선하는 것이라고 생각합니다.
-
이는 이해할 수 있는 캐시 만료에 대한 두려움에서 비롯될 수 있습니다.
-
하지만 캐싱은 여전히 더 빠릅니다.
-
진정한 성능을 달성하려면 두 가지 기법을 모두 사용해야 합니다:
예를 들어, 초기 콜드 쓰기가 너무 느려서 타임아웃이 발생한다면 캐싱을 해도 의미가 없습니다.
-
하지만 캐싱이 성능 향상이 아닌 경우는 거의 없습니다.
-
그러나 캐싱을 빠른 임시방편으로 충분히 활용할 수 있으며, 그것도 괜찮습니다. 때로는 "진짜" 수정에 몇 달이 걸리지만, 캐싱은 하루 만에 구현할 수 있습니다.
GitLab에서의 캐싱#
Redis 캐싱의 단점에도 불구하고, GitLab 애플리케이션 내부와 GitLab.com의 캐싱 설정을 자유롭게 잘 활용하세요. 캐시 활용에 대한 예측에 따르면 여유 공간이 충분합니다.
워크플로#
방법론#
- 최종 사용자에게 가능한 한 가깝게, 가능한 한 자주 캐시하세요.
뷰 렌더링을 캐시하는 것이 단연 가장 좋은 성능 향상입니다.
- 가능한 한 많은 사용자를 위해 가능한 한 많은 데이터를 캐시하려고 노력하세요:
일반 데이터는 모든 사람을 위해 캐시할 수 있습니다.
-
새로운 기능을 개발할 때 이 점을 염두에 두어야 합니다.
-
캐시 데이터를 가능한 한 많이 보존하려고 노력하세요:
만료 시 최대한 많은 캐시 데이터를 유지하기 위해 중첩 캐시를 사용하세요.
- 캐시에 대한 요청 수를 최대한 줄이세요:
이렇게 하면 네트워크 문제로 인한 가변 지연이 줄어듭니다.
- 캐시에 대한 각 읽기의 오버헤드를 낮춥니다.
캐싱의 이점 파악#
추가하는 캐시가 "가치 있는가"? 측정하기 어렵지만 다음을 고려할 수 있습니다:
- 캐시된 데이터의 크기는 얼마나 됩니까?
이는 대용량 HTML 응답을 RAM이 아닌 디스크에 저장하는 것과 같이 사용해야 할 캐시 저장소 유형에 영향을 줄 수 있습니다.
- 데이터를 캐시함으로써 절약되는 I/O, CPU, 응답 시간은 얼마나 됩니까?
캐시된 데이터가 크지만 렌더링하는 데 걸리는 시간이 낮은 경우(예: 큰 텍스트 덩어리를 페이지에 덤프하는 경우), 이것이 캐시하기 가장 좋은 위치를 나타낼 수 있습니다.
- 이 데이터는 얼마나 자주 접근됩니까?
자주 접근하는 데이터를 캐시하는 것이 일반적으로 더 큰 효과를 냅니다.
- 이 데이터는 얼마나 자주 변경됩니까?
캐시가 다시 읽히기 전에 로테이션된다면, 이 캐시가 실제로 유용한가요?
도구#
조사#
-
성능 바는 로컬 및 프로덕션에서 조사할 때의 첫 번째 단계입니다. 비용이 많이 드는 쿼리, 과도한 Redis 호출 등을 찾으세요.
-
플레임그래프 생성: URL에
?performance_bar=flamegraph를 추가하여 시간이 소비되는 메서드를 찾는 데 도움을 받으세요. -
Rails 로그를 자세히 살펴보세요:
파셜의 렌더 시간도 자세히 살펴보세요.
- 응답 시간만 측정하려면
jq를 사용하여 JSON 로그를 파싱할 수 있습니다:
tail -f log/development_json.log | jq ".duration_s"
-
tail -f log/api_json.log | jq ".duration_s" -
development.log를 tail할 때 확인할 항목에 대한 몇 가지 힌트:
tail -f log/development.log | grep "cache hits"
-
tail -f log/development.log | grep "Rendered " -
올바른 위치를 찾은 후:
원인을 찾을 때까지 코드 섹션을 제거하거나 주석 처리하세요.
binding.pry를 사용하여 라이브 요청을 분석하세요. 이는 포그라운드 웹 프로세스가 필요합니다.
검증#
- Grafana, 특히 다음 대시보드:
Grafana 차트가 필요한 것을 커버하지 않는 경우, 대신 Kibana를 사용하세요.
- 기능 플래그:
캐시를 추가할 때 기능 플래그를 사용하는 것이 거의 항상 가치 있습니다.
-
켜고 끄면서 Grafana의 변화하는 선을 살펴보세요.
-
캐시가 워밍업되면서 처음에는 응답 시간이 올라가는 것을 예상하세요.
-
플래그를 100%로 실행하기 전까지는 효과가 명확하지 않습니다.
-
성능 바:
이것을 로컬에서 사용하고 Redis 목록에서 캐시 호출을 찾으세요.
-
프로덕션에서도 이것을 사용하여 캐시 키가 예상한 것과 일치하는지 확인하세요.
-
플레임그래프:
페이지에 ?performance_bar=flamegraph를 추가하세요.
캐시 레벨#
고수준#
- HTTP 캐싱:
ETag와 만료 시간을 사용하여 브라우저가 자체 캐시된 버전을 제공하도록 지시합니다.
-
이것은 여전히 Rails에 도달하지만, 뷰 레이어는 건너뜁니다.
-
리버스 프록시 캐시에서의 HTTP 캐싱:
위와 동일하지만 public 설정을 사용합니다.
-
브라우저 대신, 이것은 리버스 프록시(예: NGINX, HAProxy, Varnish)에게 캐시된 버전을 제공하도록 지시합니다.
-
이후 요청은 Rails에 도달하지 않습니다.
-
HTML 페이지 캐싱:
HTML 파일을 디스크에 씁니다.
-
웹 서버(예: NGINX, Apache, Caddy)가 Rails를 건너뛰고 HTML 파일 자체를 제공합니다.
-
뷰 또는 액션 캐싱
Rails가 전체 렌더링된 뷰를 캐시 저장소에 쓰고 다시 제공합니다.
- 프래그먼트 캐싱:
Rails 캐시 저장소에 뷰의 일부를 캐시합니다.
- 캐시된 부분은 렌더링될 때 뷰에 삽입됩니다.
저수준#
- 메서드 캐싱:
동일한 메서드를 여러 번 호출하지만 값은 한 번만 계산합니다.
-
Ruby 메모리에 저장됩니다.
-
@article ||= Article.find(params[:id]) -
strong_memoize_attr :method_name -
요청 캐싱:
웹 요청 기간 동안 키에 대해 동일한 값을 반환합니다.
-
Gitlab::SafeRequestStore.fetch -
읽기형 또는 쓰기형 SQL 캐싱:
데이터베이스 앞에 위치하는 캐시입니다.
-
Rails는 동일한 쿼리에 대해 요청 내에서 이를 수행합니다.
-
특수 목적 캐시(Novelty cache).
-
하나의 사용 사례를 위한 매우 구체적인 캐시입니다.
Rails의 내장 캐싱 헬퍼#
이는 Rails 가이드에 잘 문서화되어 있습니다.
-
HTML 페이지 캐싱과 액션 캐싱은 더 이상 기본으로 포함되지 않지만, 여전히 유용합니다.
-
Rails 가이드는 HTTP 캐싱을 조건부 GET이라고 부릅니다.
-
Rails의 캐시 저장소에서는 두 가지 매우 중요한 (거의 동일한) 메서드를 기억하세요:
뷰의 cache는 거의 다음과 같은 별칭입니다:
-
Rails.cache.fetch로, 어디서나 사용할 수 있습니다. -
cache에는 뷰 파일을 수정할 때 변경되는 "템플릿 트리 다이제스트"가 포함됩니다.
Rails 캐시 옵션#
expires_in#
이는 캐시 항목의 TTL(Time To Live)을 설정하며, 가장 유용하고 가장 일반적으로 사용되는 단일 캐시 옵션입니다. 이는 대부분의 Rails 캐싱 헬퍼에서 지원됩니다.
expires_in으로 설정하지 않은 경우 TTL은
기본값인 8시간이 됩니다.
일반적인 캐싱에는 8시간 TTL을 사용하는 것을 고려하세요. 이는 하루 근무 시간과 일치하며, 사용자가 동일한 콘텐츠에 대해 하루에 한 번만 캐시 미스를 경험하게 됩니다.
대용량 데이터를 쓸 때는 메모리 사용량에 대한 영향을 줄이기 위해 더 짧은 만료 시간을 사용하는 것을 고려하세요.
race_condition_ttl#
이 옵션은 동시에 같은 키에 대해 캐시되지 않은 히트가 여러 번 발생하는 것을 방지합니다. 키가 만료된 것을 발견한 첫 번째 프로세스가 이 양만큼 TTL을 늘리고, 그런 다음 새 캐시 값을 설정합니다.
캐시 키가 매우 높은 부하를 받을 때 여러 동시 쓰기를 방지하기 위해 사용하지만, 10초와 같이 낮은 값으로 설정해야 합니다.
GitLab에서의 Rails 캐시 동작#
Rails.cache는 Redis를 저장소로 사용합니다.
GitLab.com과 같은 GitLab 인스턴스는 키 제거를 위해 Redis를 구성할 수 있습니다.
Redis 개발 가이드를 참조하세요.
HTTP 캐싱을 사용해야 할 때#
전체 응답이 캐시 가능한 경우 조건부 GET 캐싱을 사용하세요:
-
퍼블릭 캐시를 사용하지 않는 경우 개인 정보 보호 위험이 없습니다. 사용자가 보는 것만 해당 사용자를 위해 브라우저에서 캐시합니다.
-
폴링되는 엔드포인트에 특히 유용합니다.
-
좋은 예:
업데이트를 위해 폴링하는 토론 목록. etag에 대해 마지막으로 생성된 항목의 updated_at 값을 사용하세요.
- API 엔드포인트.
가능한 단점#
-
사용자와 API 라이브러리가 캐시를 무시할 수 있습니다.
-
때로는 Chrome이 캐시와 관련하여 이상한 동작을 합니다.
-
개발 모드에서 캐시가 존재한다는 것을 잊고 변경 사항이 나타나지 않아 화가 날 수 있습니다.
-
이론적으로 조건부 GET 캐싱은 모든 곳에서 의미가 있지만, 실제로는 때로 이상한 문제를 일으킬 수 있습니다.
뷰 또는 액션 캐싱을 사용해야 할 때#
이는 Rails 세계에서 더 이상 흔히 사용되지 않습니다:
-
Rails 코어에서 이에 대한 지원이 제거되었습니다.
-
일반적으로 리버스 프록시 캐싱이나 조건부 GET 응답을 살펴보는 것이 더 낫습니다.
-
그러나 디스크에 쓰지 않고 HTML 페이지 캐싱을 에뮬레이션하는 다소 간단한 방법을 제공하므로 클라우드 환경에서 유용합니다.
-
상당히 큰 마크업 덩어리를 캐시 저장소에 저장합니다.
-
API에서는 더 유용한 곳에서 이것의 커스텀 구현인
cache_action을 사용할 수 있습니다.
프래그먼트 캐싱을 사용해야 할 때#
항상 사용하세요!
-
아마도 Rails에서 가장 유용한 캐싱 유형으로, 뷰의 섹션, 전체 파셜, 파셜 컬렉션을 캐시할 수 있습니다.
-
렌더링된 파셜 컬렉션은
cached: true를 사용하는 것을 목표로 설계되어야 합니다. -
파셜 내부보다 파셜의 렌더 호출 주변에 캐시를 두는 것이 더 빠르지만, 그렇게 하면 템플릿 트리 다이제스트를 잃게 됩니다. 이는 해당 파셜을 업데이트할 때 캐시가 자동으로 만료되지 않는다는 것을 의미합니다.
-
루프 내에 캐시 호출을 배치하는 것과 같이 많은 캐시 호출을 도입하지 않도록 주의하세요. 때로는 피할 수 없지만, 파셜 컬렉션 캐싱과 같이 이를 우회하는 옵션이 있습니다.
-
뷰 렌더링과 JSON 생성은 느리며, 가능한 한 모든 곳에서 캐시해야 합니다.
메서드 캐싱을 사용해야 할 때#
-
인스턴스 변수 또는
StrongMemoize를 사용하세요. -
요청에서 동일한 값이 여러 번 필요할 때 유용합니다.
-
동일한 키에 대한 여러 캐시 호출을 방지하는 데 사용할 수 있습니다.
-
reload를 호출할 때까지 값이 변경되지 않는 ActiveRecord 객체에서 문제를 일으킬 수 있으며, 이는 테스트 스위트에서 나타나는 경향이 있습니다.
요청 캐싱을 사용해야 할 때#
-
메서드 캐싱과 유사한 사용 패턴이지만 여러 메서드에 걸쳐 사용할 수 있습니다.
-
요청 기간 동안 무언가를 저장하는 표준화된 방법입니다.
-
조회가 캐시 조회와 유사하므로(GitLab 구현에서), 두 가지 모두에 동일한 키를 사용할 수 있습니다. 이것이
Gitlab::Cache.fetch_once가 작동하는 방식입니다.
가능한 단점#
- 예를 들어
Gitlab::Cache::JsonCache와Gitlab::SafeRequestStore를 사용하여 캐시된 객체에 새 속성을 추가하면, 캐시 데이터에 새 속성에 대한 적절한 값이 없어 오래된 데이터 문제가 발생할 수 있습니다 (이 과거 인시던트를 참조하세요).
SQL 캐싱을 사용해야 할 때#
Rails는 요청 내 동일한 쿼리에 대해 자동으로 이를 사용하므로, 해당 사용 사례에는 별도 조치가 필요하지 않습니다.
-
그러나
identity_cache와 같은 gem을 사용하는 것은 다른 목적을 가집니다: 여러 요청에 걸쳐 쿼리를 캐싱하는 것입니다. -
Article.find(params[:id])와 같은 단일 객체 조회에는 사용하지 마세요. -
읽기 전용 객체를 제공하기 때문에 결과를 사용할 수 없는 경우가 있습니다.
-
관계도 캐시할 수 있으며, 다르게 필터링하거나 정렬하지 않고 목록을 반환하려는 상황에서 유용합니다.
특수 목적 캐시를 사용해야 할 때#
다른 옵션을 모두 소진했고 정말 까다로운 것을 캐시해야 한다면, 커스텀 솔루션을 살펴볼 때입니다:
-
GitLab의 예로는
RepositorySetCache,RepositoryHashCache,AvatarCache가 있습니다. -
가능하다면 일관성이 없어지는 커스텀 캐시 구현 생성은 피해야 합니다.
-
매우 효과적일 수 있습니다. 예를 들어, RepositoryHashCache를 사용하는
merged_branch_names주변의 캐싱이 있습니다.
캐시 만료#
Redis의 키 만료 방식#
요약하면: 가장 오래된 것이 새로운 것으로 교체됩니다:
-
Redis를 LRU 캐시로 구성하는 방법에 대한 유용한 기사.
-
다양한 캐시 제거 전략을 위한 많은 옵션이 있습니다.
-
Memcached와 기능적으로 유사한
allkeys-lru를 원할 것입니다. -
Redis 4.0 이상에서는 allkeys-lfu가 사용 가능하며, 유사하지만 다릅니다.
-
이제 모든 명시적 삭제는
DEL대신UNLINK를 사용하여 처리합니다. 이렇게 하면 Redis가 즉시 메모리를 회수하는 대신 자체 시간에 회수할 수 있습니다.
이는 키를 삭제됨으로 표시하고 빠르게 성공 값을 반환하지만, 실제로는 나중에 삭제합니다.
Rails의 키 만료 방식#
-
Rails는 명시적 삭제 대신 TTL과 캐시 키 만료를 사용하는 것을 선호합니다.
-
뷰에서 프래그먼트 캐싱 시 캐시 키에는 기본적으로 템플릿 트리 다이제스트가 포함되어, 템플릿 변경 시 자동으로 캐시가 만료됩니다.
하지만 경고로서, 헬퍼에서는 그렇지 않습니다.
- Rails에는 ActiveRecord 객체에 대한 두 가지 캐시 키 메서드가 있습니다:
cache_key_with_version과cache_key. 첫 번째는 버전 5.2 이상에서 기본으로 사용되며, 이전의 표준 동작으로 키에updated_at타임스탬프가 포함됩니다.
캐시 키 구성 요소#
application.log에서 찾을 수 있는 예:
cache(@project, :tag_list)
views/projects/_home_panel:462ad2485d7d6957e03ceba2c6717c29/projects/16-2021031614242546945
2/tag_list
-
뷰 이름과 템플릿 트리 다이제스트
views/projects/_home_panel:462ad2485d7d6957e03ceba2c6717c29 -
모델 이름, ID,
updated_at값projects/16-20210316142425469452 -
전달한 심볼, 문자열로 변환됨
tag_list
주의할 사항#
- 사용자별 데이터
이것이 가장 중요합니다!
-
이는 특히 뷰에서 항상 명확하지 않습니다.
-
캐시하려는 영역에서 사용되는 모든 헬퍼 메서드를 꼼꼼히 검토해야 합니다.
-
"Billy가 8분 전에 이것을 게시했습니다"와 같이 시간에 따라 달라지는 데이터.
-
updated_at필드가 변경되지 않고 레코드가 업데이트되는 경우. -
Rails 헬퍼는 뷰의 키에 템플릿 다이제스트를 포함시키지만, 헬퍼와 같은 다른 곳에서는 그렇지 않습니다.
-
Grape::Entity는 API 레이어에서 효과적인 캐싱을 매우 어렵게 만듭니다. 이에 대해서는 나중에 더 설명합니다. -
뷰에서 프래그먼트 캐시 헬퍼 내에서
break나return을 사용하지 마세요 - 캐시 항목이 절대 기록되지 않습니다. -
이전 데이터를 반환할 수 있는 캐시 키의 항목 순서 변경:
예를 들어 nil을 반환할 수 있는 두 값을 서로 바꾸는 경우.
-
대신
{ project: nil }과 같은 해시를 사용하세요. -
Rails는 배열의 멤버에 대해
#cache_key를 호출하여 키를 찾지만, 해시의 값에는 호출하지 않습니다.