Caching

Utilizing Caching Mechanisms

캐싱은 데이터 접근 시간을 크게 단축시켜주는 기술이다.

HTTP 캐싱

HTTP 캐싱은 웹 브라우저와 서버 사이에서 이루어지는 가장 기본적인 캐싱 형태이다.

1
2
3
Cache-Control: max-age=3600, public
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Last-Modified: Wed, 03 Apr 2025 13:22:05 GMT

위와 같은 HTTP 헤더를 통해 캐싱 정책을 제어할 수 있다. max-age는 리소스가 유효한 시간(초)을 지정하며, public은 모든 캐시가 응답을 저장할 수 있다는 의미이다. ETagLast-Modified는 리소스의 변경 여부를 판단하는 데 사용된다.

서버 사이드 캐싱

서버 측에서 이루어지는 캐싱으로, 요청 처리 시간을 단축시켜 준다.

Redis를 활용한 예시
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import redis

# Redis 연결 설정
r = redis.Redis(host='localhost', port=6379, db=0)

def get_user_data(user_id):
    # 캐시에서 데이터 조회 시도
    cached_data = r.get(f"user:{user_id}")
    
    if cached_data:
        # 캐시에 데이터가 있으면 반환
        return json.loads(cached_data)
    else:
        # 캐시에 없으면 DB에서 조회
        user_data = database.query(f"SELECT * FROM users WHERE id = {user_id}")
        
        # 조회한 데이터를 캐시에 저장 (1시간 유효)
        r.setex(f"user:{user_id}", 3600, json.dumps(user_data))
        
        return user_data

CDN (Content Delivery Network)

전 세계에 분산된 서버 네트워크를 통해 사용자와 가까운 위치에서 콘텐츠를 제공함으로써 지연 시간을 최소화한다.

CDN 구성 예시 (AWS CloudFront)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// AWS CloudFront 배포 설정 예시
const cloudfront = {
  distribution: {
    origins: [{
      domainName: 'example.com',
      id: 'myWebsite'
    }],
    defaultCacheBehavior: {
      targetOriginId: 'myWebsite',
      minTTL: 0,
      defaultTTL: 86400,    // 24시간
      maxTTL: 31536000,     // 1년
      viewerProtocolPolicy: 'redirect-to-https',
      allowedMethods: ['GET', 'HEAD'],
      cachedMethods: ['GET', 'HEAD'],
      forwardedValues: {
        queryString: false,
        cookies: {
          forward: 'none'
        }
      }
    }
  }
};

Application of Suitable Caching Patterns

다양한 캐싱 패턴이 있으며, 애플리케이션의 특성과 요구사항에 따라 적절한 패턴을 선택해야 한다.

Cache-Aside (Lazy Loading) 패턴

가장 일반적인 캐싱 패턴으로, 데이터가 필요할 때 캐시를 먼저 확인하고 없으면 원본 저장소에서 가져와 캐시에 저장한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def get_data(key):
    # 1. 캐시에서 데이터 조회
    data = cache.get(key)
    
    if data is None:
        # 2. 캐시에 없으면 DB에서 조회
        data = db.query(key)
        
        # 3. 데이터를 캐시에 저장
        cache.set(key, data, ttl=3600)
    
    return data

장점:

단점:

Write-Through 패턴

데이터를 쓸 때 DB와 캐시를 동시에 업데이트하는 방식이다.

1
2
3
4
5
6
7
8
def update_data(key, value):
    # 1. DB에 데이터 저장
    db.save(key, value)
    
    # 2. 캐시에도 데이터 저장/갱신
    cache.set(key, value, ttl=3600)
    
    return success

장점:

단점:

Read-Through 패턴

캐시 미스 발생 시 캐시 자체가 데이터를 로드하고 저장하는 방식이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 캐시 라이브러리/시스템 내부에 구현된 로직
class Cache:
    def get(self, key):
        data = self._get_from_cache(key)
        
        if data is None:
            # 캐시 미스 시 DB에서 데이터 로드
            data = self._load_from_db(key)
            self._save_to_cache(key, data)
        
        return data

장점:

단점:

Write-Behind (Write-Back) 패턴

데이터를 먼저 캐시에만 쓰고, 나중에 DB에 일괄 처리하는 방식이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def update_data(key, value):
    # 1. 캐시에 데이터 저장
    cache.set(key, value)
    
    # 2. 쓰기 큐에 추가 (비동기적으로 DB에 기록됨)
    write_queue.add(key, value)
    
    return success

# 백그라운드 프로세스
def process_write_queue():
    while True:
        # 큐에서 데이터를 가져와 DB에 일괄 저장
        batch = write_queue.get_batch(100)
        db.save_batch(batch)
        sleep(5)  # 5초 간격으로 처리

장점:

단점:

Efficient Cache-Invalidation Strategies

캐시된 데이터가 원본 데이터와 일치하지 않을 때 발생하는 문제를 해결하기 위한 전략이다.

시간 기반 무효화 (TTL, Time-To-Live)

가장 단순한 전략으로, 일정 시간이 지나면 캐시 항목을 자동으로 만료시킨다.

1
2
3
4
5
# Redis 예시
redis_client.setex("product:12345", 3600, product_data)  # 1시간 후 만료

# Memcached 예시
memcached_client.set("product:12345", product_data, 3600)

장점:

단점:

이벤트 기반 무효화

데이터가 변경될 때 관련 캐시를 즉시 무효화하는 방식이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def update_product(product_id, data):
    # 1. DB 업데이트
    db.update_product(product_id, data)
    
    # 2. 캐시 무효화 (삭제)
    cache.delete(f"product:{product_id}")
    
    # 관련 캐시도 함께 무효화
    cache.delete(f"product_list:category:{data['category_id']}")
    cache.delete("featured_products")
    
    # 3. 이벤트 발행 (다른 서비스의 캐시도 무효화하기 위함)
    message_broker.publish("product_updated", {
        "product_id": product_id,
        "timestamp": current_time()
    })

장점:

단점:

버전 기반 무효화

각 리소스에 버전 정보를 부여하고, 데이터 변경 시 버전을 증가시켜 캐시 키에 반영하는 방식이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def get_product(product_id):
    # 제품의 현재 버전 조회
    product_version = version_store.get(f"product_version:{product_id}")
    
    # 버전을 포함한 캐시 키 생성
    cache_key = f"product:{product_id}:v{product_version}"
    
    # 캐시에서 조회
    product_data = cache.get(cache_key)
    
    if product_data is None:
        # DB에서 데이터 로드
        product_data = db.get_product(product_id)
        
        # 버전과 함께 캐시에 저장
        cache.set(cache_key, product_data, ttl=86400)  # 24시간 유효
    
    return product_data

def update_product(product_id, data):
    # 1. DB 업데이트
    db.update_product(product_id, data)
    
    # 2. 버전 증가 (기존 캐시를 무효화하는 효과)
    version_store.increment(f"product_version:{product_id}")

장점:

단점:

실제 애플리케이션에서의 캐싱 전략 적용 사례

전자상거래 플랫폼

소셜 미디어 플랫폼

금융 서비스 플랫폼

캐싱 관련 고려사항 및 모범 사례

콜드 스타트(Cold Start) 문제 해결

새로운 서버 인스턴스가 시작될 때 캐시가 비어 있는 문제를 해결하는 방법:

  1. 워밍업 스크립트: 서버 시작 시 자주 액세스하는 데이터를 미리 캐싱

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    def cache_warmup():
        # 주요 카테고리 캐싱
        categories = db.get_all_categories()
        for category in categories:
            cache.set(f"category:{category.id}", category, ttl=86400)
    
        # 인기 제품 캐싱
        popular_products = db.get_popular_products(limit=100)
        for product in popular_products:
            cache.set(f"product:{product.id}", product, ttl=3600)
    
  2. 글로벌 캐시 계층: Redis 또는 Memcached 클러스터를 별도로 유지하여 서버 간에 캐시 공유

캐시 폭발(Cache Stampede) 방지

여러 요청이 동시에 캐시 미스를 경험하여 데이터베이스에 과부하가 발생하는 문제를 해결하는 방법:

  1. Singleflight 패턴: 동일한 키에 대한 여러 요청을 하나로 병합

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    
    import threading
    
    class SingleflightCache:
        def __init__(self, cache_client, db_client):
            self.cache = cache_client
            self.db = db_client
            self.locks = {}
            self.lock_mutex = threading.Lock()
    
        def get(self, key):
            # 캐시에서 먼저 확인
            data = self.cache.get(key)
            if data is not None:
                return data
    
            # 캐시 미스 시 락 획득
            with self.lock_mutex:
                if key not in self.locks:
                    self.locks[key] = threading.Lock()
    
            # 같은 키에 대한 중복 요청 방지
            with self.locks[key]:
                # 다시 캐시 확인 (다른 스레드가 이미 가져왔을 수 있음)
                data = self.cache.get(key)
                if data is not None:
                    return data
    
                # DB에서 데이터 로드
                data = self.db.query(key)
    
                # 캐시에 저장
                self.cache.set(key, data, ttl=3600)
    
                return data
    
  2. 확률적 조기 만료: 캐시 항목의 실제 만료 시간보다 조금 일찍 재생성

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    
    import random
    
    def get_with_early_refresh(key, ttl=3600):
        data = cache.get(key)
    
        # 데이터가 있고, 만료 임계값(80%)을 넘은 경우 확률적으로 갱신
        if data and data.get('created_at') < (current_time() - ttl * 0.8):
            # 10% 확률로 백그라운드 갱신 트리거
            if random.random() < 0.1:
                threading.Thread(target=refresh_cache, args=(key,)).start()
    
        # 데이터가 없으면 동기적으로 로드
        if data is None:
            data = load_and_cache(key, ttl)
    
        return data.get('value')
    

캐시 계층화 전략

다양한 데이터 유형과 접근 패턴에 맞게 여러 캐시 계층을 활용하는 방법:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
사용자 요청
 로컬 캐시 (메모리 내 캐시, 매우 빠름, 짧은 TTL)
분산 캐시 (Redis/Memcached, 중간 속도, 중간 TTL)
 데이터베이스 캐시 (쿼리 캐시, 느림, 긴 TTL)
   DB

캐싱 성능 모니터링 및 측정

효과적인 캐싱 전략을 유지하기 위해서는 지속적인 모니터링이 필요하다.

주요 캐싱 성능 지표

모니터링 구현 예시

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MonitoredCache:
    def __init__(self, cache_client, metrics_client):
        self.cache = cache_client
        self.metrics = metrics_client
    
    def get(self, key):
        start_time = time.time()
        
        data = self.cache.get(key)
        
        # 지연 시간 측정
        latency = time.time() - start_time
        self.metrics.record("cache.latency", latency)
        
        if data is None:
            # 캐시 미스 기록
            self.metrics.increment("cache.miss")
            return None
        else:
            # 캐시 적중 기록
            self.metrics.increment("cache.hit")
            return data
    
    def set(self, key, value, ttl=None):
        self.cache.set(key, value, ttl)
        
        # 캐시 크기 측정 (항목 수)
        cache_size = self.cache.info().get("curr_items", 0)
        self.metrics.gauge("cache.size", cache_size)

용어 정리

용어설명

참고 및 출처