Go 캐싱 모범 사례
Grace Collins
Solutions Engineer · Leapcell

캐싱은 API 애플리케이션의 속도를 높이는 데 필수적이므로, 높은 성능이 필요한 경우 설계 단계에서 캐싱이 필수적입니다.
설계 단계에서 캐싱을 고려할 때 가장 중요한 것은 필요한 메모리 양을 추정하는 것입니다.
먼저 캐시해야 할 데이터가 정확히 무엇인지 명확히 해야 합니다.
사용자 기반이 계속 증가하는 애플리케이션에서는 사용되는 모든 데이터를 캐시하는 것이 불가능합니다.
애플리케이션의 로컬 메모리는 단일 시스템의 물리적 리소스에 의해 제한되기 때문입니다. 데이터를 제한 없이 캐시하면 결국 OOM(Out of Memory)으로 이어져 애플리케이션이 강제로 종료됩니다.
분산 캐시를 사용하는 경우 높은 하드웨어 비용으로 인해 절충해야 합니다.
물리적 리소스가 무제한이라면 가장 빠른 물리적 장치에 모든 것을 저장하는 것이 당연히 가장 좋습니다.
그러나 실제 비즈니스 시나리오에서는 이를 허용하지 않으므로 데이터를 핫 데이터와 콜드 데이터로 분류하고 콜드 데이터를 적절하게 보관 및 압축하여 더 경제적인 미디어에 저장해야 합니다.
어떤 데이터를 로컬 메모리에 저장할 수 있는지 분석하는 것이 효과적인 로컬 캐싱을 구현하는 첫 번째 단계입니다.
상태 저장 애플리케이션과 상태 비저장 애플리케이션 간의 균형
데이터가 애플리케이션에 로컬로 저장되면 애플리케이션은 더 이상 분산 시스템에서 상태 비저장이 아닙니다.
웹 백엔드 애플리케이션을 예로 들어 보겠습니다. 백엔드 애플리케이션으로 10개의 Pod를 배포하고 요청을 처리하는 Pod 중 하나에 캐싱을 추가하면 동일한 요청이 다른 Pod로 전달될 때 해당 데이터에 액세스할 수 없습니다.
세 가지 해결 방법이 있습니다.
- Redis와 같은 분산 캐시 사용
- 동일한 요청을 동일한 Pod로 전달
- 모든 Pod에 동일한 데이터 캐시
첫 번째 방법은 더 이상 설명이 필요하지 않습니다. 이는 기본적으로 스토리지를 중앙 집중화합니다.
두 번째 방법은 사용자의 uid와 같은 특정 식별 정보를 사용하여 특수 전달 로직을 구현해야 하며 실제 시나리오에 의해 제한됩니다.
세 번째 방법은 더 많은 스토리지 공간을 소비합니다. 두 번째 방법과 비교하여 모든 Pod에 데이터를 저장해야 합니다. 완전히 상태 비저장이라고 할 수는 없지만 캐시 침투 가능성은 두 번째 방법보다 낮습니다. 이는 게이트웨이가 특정 데이터가 있는 Pod로 요청을 전달하지 못하더라도 다른 Pod가 요청을 정상적으로 처리할 수 있기 때문입니다.
만병통치약은 없습니다. 실제 시나리오에 따라 방법을 선택하십시오. 그러나 캐시가 애플리케이션에서 멀어질수록 액세스하는 데 시간이 더 오래 걸립니다.
Goim은 또한 메모리 정렬을 통해 캐시 적중률을 극대화합니다.
CPU가 계산을 수행할 때 먼저 L1, 그 다음 L2, 그 다음 L3 캐시에서 필요한 데이터를 찾습니다. 데이터가 이러한 캐시에서 발견되지 않으면 주 메모리에서 데이터를 가져와야 합니다. 데이터가 멀리 있을수록 계산 시간이 더 오래 걸립니다.
제거 정책
캐시에 대한 엄격한 메모리 크기 제어가 필요한 경우 LRU(Least Recently Used) 정책을 사용하여 메모리를 관리할 수 있습니다. Go의 LRU 캐시 구현을 살펴보겠습니다.
LRU 캐시
LRU 캐시는 캐시 크기를 제어하고 덜 자주 사용되는 항목을 자동으로 제거해야 하는 시나리오에 적합합니다.
예를 들어 128개의 키-값 쌍만 저장하려는 경우 LRU 캐시는 제한에 도달할 때까지 새 항목을 계속 추가합니다. 캐시된 항목에 액세스하거나 새 값이 추가될 때마다 키가 앞으로 이동되어 제거되는 것을 방지합니다.
https://github.com/hashicorp/golang-lru는 Go의 LRU 캐시 구현입니다.
LRU가 어떻게 사용되는지 보여주는 예제 테스트를 살펴보겠습니다.
func TestLRU(t *testing.T) { l, _ := lru.New for i := 0; i < 256; i++ { l.Add(i, i+1) } // Value has not been evicted value, ok := l.Get(200) assert.Equal(t, true, ok) assert.Equal(t, 201, value.(int)) // Value has already been evicted value, ok = l.Get(1) assert.Equal(t, false, ok) assert.Equal(t, nil, value) }
보시다시피 키 200은 제거되지 않았으므로 계속 액세스할 수 있습니다.
그러나 키 1은 캐시 크기 제한인 128을 초과하여 이미 제거되었으므로 더 이상 검색할 수 없습니다.
이는 저장하려는 데이터의 양이 너무 큰 경우에 유용합니다. 가장 자주 사용되는 데이터는 항상 앞으로 이동하여 캐시 적중률을 높입니다.
오픈 소스 패키지의 내부 구현은 연결된 목록을 사용하여 캐시된 모든 요소를 유지 관리합니다.
Add
가 호출될 때마다 키가 이미 존재하면 앞으로 이동됩니다.
func (l *LruList[K, V]) move(e, at *Entry[K, V]) { if e == at { return } e.prev.next = e.next e.next.prev = e.prev e.prev = at e.next = at.next e.prev.next = e e.next.prev = e }
키가 존재하지 않으면 insert
메서드를 사용하여 삽입됩니다.
func (l *LruList[K, V]) insert(e, at *Entry[K, V]) *Entry[K, V] { e.prev = at e.next = at.next e.prev.next = e e.next.prev = e e.list = l l.len++ return e }
캐시 크기가 초과되면 목록의 끝에 있는 요소(가장 오래되고 가장 덜 사용됨)가 제거됩니다.
func (c *LRU[K, V]) removeOldest() { if ent := c.evictList.Back(); ent != nil { c.removeElement(ent) } } func (c *LRU[K, V]) removeElement(e *internal.Entry[K, V]) { c.evictList.Remove(e) delete(c.items, e.Key) // Callback after deleting key if c.onEvict != nil { c.onEvict(e.Key, e.Value) } } func (l *LruList[K, V]) Remove(e *Entry[K, V]) V { e.prev.next = e.next e.next.prev = e.prev // Prevent memory leak, set to nil e.next = nil e.prev = nil e.list = nil l.len-- return e.Value }
캐시 업데이트
분산 시스템에서 적시에 캐시를 업데이트하면 데이터 불일치 문제를 줄일 수 있습니다.
서로 다른 방법이 서로 다른 시나리오에 적합합니다.
캐시된 데이터를 가져올 때 다양한 상황이 있습니다. 예를 들어 사용자와 관련이 없는 인기 랭킹 목록의 경우 모든 Pod의 로컬 캐시에서 이 데이터를 유지 관리해야 합니다. 쓰기 또는 업데이트가 발생하면 모든 Pod에 캐시를 업데이트하라는 알림이 전송되어야 합니다.
데이터가 각 사용자에 특정된 경우 고정된 Pod에서 요청을 처리하고 사용자 식별자(uid)를 사용하여 요청을 동일한 Pod로 라우팅하는 것이 좋습니다. 이렇게 하면 다른 Pod에 여러 복사본을 저장하는 것을 방지하고 메모리 소비를 줄일 수 있습니다.
대부분의 경우 애플리케이션은 상태 비저장이기를 원하므로 캐시된 데이터의 이 부분은 Redis에 저장됩니다.
세 가지 주요 분산 캐시 업데이트 전략이 있습니다. 캐시-사이드(바이패스), 쓰루, 백 쓰기입니다.
캐시-사이드(바이패스) 전략
캐시-사이드(바이패스) 전략은 가장 자주 사용하는 전략입니다. 데이터를 업데이트할 때 먼저 캐시를 삭제한 다음 데이터베이스에 씁니다. 다음에 데이터를 읽고 캐시가 누락된 것으로 확인되면 데이터베이스에서 검색되고 캐시가 새로 고쳐집니다.
이 전략은 읽기 QPS가 매우 높을 때 불일치를 초래할 수 있습니다. 즉, 캐시가 삭제되었지만 데이터베이스가 업데이트되기 전에 읽기 요청이 들어와 이전 값을 캐시에 다시 로드할 수 있으므로 후속 읽기에서는 여전히 데이터베이스에서 이전 값을 가져옵니다.
이러한 일이 실제로 발생할 가능성은 낮지만 시나리오를 신중하게 평가해야 합니다. 이러한 불일치가 시스템에 치명적인 경우 이 전략을 사용할 수 없습니다.
이러한 상황이 허용되지만 불일치를 최소화하려는 경우 캐시 만료 시간을 설정할 수 있습니다. 쓰기 작업이 발생하지 않으면 캐시가 자동으로 만료되어 캐시 데이터가 새로 고쳐집니다.
쓰루 및 백 쓰기 전략
쓰루 및 백 쓰기 전략은 모두 먼저 캐시를 업데이트한 다음 데이터베이스에 씁니다. 차이점은 업데이트가 개별적으로 수행되는지 일괄적으로 수행되는지에 있습니다.
이러한 전략의 중요한 단점은 데이터 손실이 쉽게 발생할 수 있다는 것입니다. Redis는 디스크에 다시 쓰기와 같은 영구 전략을 지원하지만 QPS가 높은 애플리케이션의 경우 서버 충돌로 인해 1초의 데이터만 손실되더라도 막대한 양이 될 수 있습니다. 따라서 비즈니스 및 실제 시나리오에 따라 결정을 내려야 합니다.
Redis가 여전히 성능 요구 사항을 충족할 수 없는 경우 캐시된 콘텐츠를 애플리케이션 변수(로컬 캐시)에 직접 저장해야 하므로 사용자 요청은 네트워크 요청 없이 메모리에서 직접 처리됩니다.
아래에서는 분산 시나리오에서 로컬 캐시를 업데이트하는 전략에 대해 설명합니다.
활성 알림 업데이트(캐시-사이드 전략과 유사)
분산 시스템에서는 ETCD 브로드캐스트를 사용하여 데이터를 다시 로드하기 위해 다음 쿼리를 기다리지 않고 캐시 업데이트를 빠르게 전파할 수 있습니다.
그러나 이 접근 방식에는 문제가 있습니다. 예를 들어 T1 시간에 캐시 업데이트 알림이 전송되지만 다운스트림 서비스는 아직 업데이트를 완료하지 못했습니다. T2 = T1 + 1초 시간에 캐시 업데이트 신호가 다시 전송되지만 T1의 업데이트는 아직 완료되지 않았습니다.
이로 인해 업데이트 속도 차이로 인해 T2의 최신 값이 T1의 이전 값으로 덮어쓰기될 수 있습니다.
이는 단조롭게 증가하는 버전 번호를 추가하여 해결할 수 있습니다. T2 버전의 데이터가 적용되면 T1 버전은 더 이상 캐시를 업데이트할 수 없으므로 새 값이 이전 값으로 덮어쓰기되는 것을 방지할 수 있습니다.
활성 알림을 사용하면 관련 키를 지정하여 특정 캐시된 항목만 업데이트하고 한 번에 캐시된 모든 데이터를 업데이트하여 발생하는 높은 부하를 방지할 수 있습니다.
이 업데이트 전략은 분산 캐시 대신 로컬 캐시를 업데이트한다는 점만 제외하면 캐시-사이드 전략과 유사합니다.
캐시 만료 대기
이 접근 방식은 엄격한 데이터 일관성이 필요하지 않은 경우에 적합합니다. 로컬 캐시의 경우 업데이트를 모든 Pod에 전파하려면 유지 관리 전략이 더 복잡해집니다.
Go의 오픈 소스 패키지 https://github.com/patrickmn/go-cache를 사용하여 자체 로직을 구현하지 않고도 메모리에서 캐시 만료를 처리할 수 있습니다.
go-cache가 로컬 캐싱을 어떻게 구현하는지 살펴보겠습니다.
Go Cache
https://github.com/patrickmn/go-cache는 Go용 오픈 소스 로컬 캐싱 패키지입니다.
내부적으로는 데이터를 맵에 저장합니다.
type Cache struct { *cache } type cache struct { defaultExpiration time.Duration items map[string]Item mu sync.RWMutex onEvicted func(string, interface{}) janitor *janitor }
items
필드는 모든 관련 데이터를 저장합니다.
Set
또는 Get
을 할 때마다 items
맵에서 작동합니다.
janitor
는 지정된 간격으로 만료된 키를 주기적으로 삭제합니다.
func (j *janitor) Run(c *cache) { ticker := time.NewTicker(j.Interval) for { select { case <-ticker.C: c.DeleteExpired() case <-j.stop: ticker.Stop() return } } }
Ticker를 사용하여 신호를 트리거하고 주기적으로 DeleteExpired
메서드를 호출하여 만료된 키를 제거합니다.
func (c *cache) DeleteExpired() { // Key-value pairs to be evicted var evictedItems []keyAndValue now := time.Now().UnixNano() c.mu.Lock() // Find and delete expired keys for k, v := range c.items { if v.Expiration > 0 && now > v.Expiration { ov, evicted := c.delete(k) if evicted { evictedItems = append(evictedItems, keyAndValue{k, ov}) } } } c.mu.Unlock() // Callback after eviction, if any for _, v := range evictedItems { c.onEvicted(v.key, v.value) } }
코드에서 캐시 만료는 주기적인 제거에 의존한다는 것을 알 수 있습니다.
만료되었지만 아직 삭제되지 않은 키를 검색하려고 하면 어떻게 될까요?
데이터를 가져올 때 캐시는 키가 만료되었는지 여부도 확인합니다.
func (c *cache) Get(k string) (interface{}, bool) { c.mu.RLock() // Return directly if not found item, found := c.items[k] if !found { c.mu.RUnlock() return nil, false } // If the item has expired, return nil and wait for periodic deletion if item.Expiration > 0 { if time.Now().UnixNano() > item.Expiration { c.mu.RUnlock() return nil, false } } c.mu.RUnlock() return item.Object, true }
값을 검색할 때마다 만료가 확인되어 만료된 키-값 쌍이 반환되지 않도록 합니다.
캐시 워밍
시작 시 데이터를 미리 로드하는 방법, 시작하기 전에 초기화가 완료될 때까지 기다릴지 여부, 분할 시작을 허용할지 여부, 동시 로딩이 미들웨어에 압력을 가할지 여부 등은 모두 시작 시 캐시 워밍을 고려해야 할 문제입니다.
전체 리소스 소비가 높지만 시작하기 전에 모든 초기화가 완료될 때까지 기다리는 경우 초기화와 미리 로드를 병렬로 실행할 수 있습니다. 그러나 미리 로드 중에 리소스 가용성이 없음을 방지하기 위해 특정 주요 구성 요소(예: 데이터베이스 연결, 네트워크 서비스 등)가 이미 사용 가능한지 확인해야 합니다.
로딩이 완료되기 전에 요청이 도착하면 정상적인 응답을 보장하기 위해 적절한 폴백 전략이 필요합니다.
분할 로딩의 장점은 동시성을 통해 초기화 시간을 줄일 수 있다는 것이지만, 동시 미리 로드는 효율성을 향상시키면서도 미들웨어(예: 캐시 서버, 데이터베이스 등)에 압력을 가합니다.
코딩하는 동안 시스템의 동시 처리 기능을 평가하고 합리적인 동시성 제한을 설정해야 합니다. 속도 제한 메커니즘을 적용하면 동시 압력을 완화하고 미들웨어 과부하를 방지하는 데 도움이 될 수 있습니다.
Go에서는 채널을 사용하여 동시성을 제한할 수도 있습니다.
캐시 워밍은 실제 프로덕션 시나리오에서 중요한 역할을 합니다. 배포 중에 애플리케이션의 로컬 캐시는 다시 시작 후 사라집니다. 롤링 업데이트의 경우 오리진에서 데이터를 가져와야 하는 Pod가 하나 이상 있습니다. QPS가 극도로 높은 경우 단일 Pod의 최대 QPS가 데이터베이스를 압도하여 연쇄 오류(눈사태 효과)가 발생할 수 있습니다.
이러한 상황을 처리하는 방법에는 두 가지가 있습니다. 하나는 트래픽이 가장 많은 기간 동안 버전 업그레이드를 피하고 트래픽이 적은 시간에 예약하는 것입니다. 이는 모니터링 대시보드에서 쉽게 식별할 수 있습니다.
다른 방법은 시작 시 데이터를 미리 로드하고 로딩이 완료된 후에만 서비스를 제공하는 것입니다. 그러나 이렇게 하면 결함이 있는 릴리스로 인해 롤백이 필요한 경우 시작 시간이 길어질 수 있으므로 빠른 롤백이 더 어려워집니다.
두 가지 접근 방식 모두 장단점이 있습니다. 실제 시나리오에서는 특정 요구 사항에 따라 선택해야 합니다. 가장 중요한 것은 특수한 경우에 대한 의존도를 최소화하는 것입니다. 릴리스 중에 의존성이 많을수록 문제가 발생할 가능성이 높습니다.
Leapcell은 Go 프로젝트 호스팅을 위한 최고의 선택입니다.
Leapcell은 웹 호스팅, 비동기 작업 및 Redis를 위한 차세대 서버리스 플랫폼입니다.
다국어 지원
- Node.js, Python, Go 또는 Rust로 개발하십시오.
무제한 프로젝트를 무료로 배포
- 사용량에 대해서만 지불하십시오. 요청도 없고 요금도 없습니다.
탁월한 비용 효율성
- 유휴 요금 없이 사용한 만큼 지불하십시오.
- 예: $25는 평균 응답 시간 60ms에서 694만 건의 요청을 지원합니다.
간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅
간편한 확장성 및 고성능
- 고도의 동시성을 쉽게 처리하기 위한 자동 확장
- 운영 오버헤드가 제로입니다. 구축에만 집중하십시오.
설명서에서 자세히 알아보십시오!
X에서 팔로우하세요: @LeapcellHQ