[WebProxy-Lab] proxy 서버 구현하기 Part.4 - 캐시 기능: 개념 정리

이 전 포스팅까지 해서 WebProxy-Lab의 동시성 기능까지 구현을 했다. 이제 마지막 요구 사항인 캐시 기능에 대해 접근을 시도하려고 한다. 

2025.05.05 - [크래프톤 정글] - [WebProxy-Lab] proxy 서버 구현하기 Part.3 - 동시성 처리

2025.05.05 - [크래프톤 정글] - [WebProxy-Lab] proxy 서버 구현하기 Part.2 - 프록시 서버 구현

2025.05.05 - [크래프톤 정글] - [WebProxy-Lab] proxy 서버 구현하기 Part.1 - 요구사항 확인 및 설계

이번 Part.4 에서는 캐시를 구현하기 위해 필요할 것으로 생각되는 개념이나 아이디어를 정리하려고 한다.


1. 요구 사항 확인

먼저 proxylab.pdf 에 있는 캐시 관련 요구사항을 보겠다.

캐시 기능 (Part III)

  • 프록시에서 자주 요청되는 객체를 메모리에 캐싱
  • 다음 제한 조건을 반드시 만족해야 함:
    • 총 캐시 크기 ≤ 1MB (MAX_CACHE_SIZE)
    • 개별 객체 크기 ≤ 100KB (MAX_OBJECT_SIZE)
  • 객체가 최대 크기를 넘으면 캐시 하지 않음
  • 캐시 교체 정책은 LRU(최근 사용 안 한 객체 제거) 유사 정책 사용
  • 캐시 접근은 스레드 안전해야 함:
    • 여러 읽기 스레드는 동시에 접근 가능
    • 쓰기 중에는 읽기/쓰기 모두 차단
    • 단순한 mutex 하나로 전부 막는 것은 허용되지 않음

위 조건을 만족하며 캐싱 기능을 구현해야 한다. 즉 우리가 구현해야 하는 것은 목적 서버로부터 요청받은 데이터를 클라이언트에 돌려줄 때 이 데이터의 크기등을 확인해 캐싱이 가능하면 클라이언트에 반환을 하고 이후에 캐시에 데이터를 캐싱하여 추 후에 같은 요청이 들어오면 캐시에서 데이터를 꺼내 전달하겠다는 것이다.

또한 LRU와 유사한 방식으로 캐시 교체 정책을 구현하여야 한다.

마지막으로 캐시는 스레드 안전해야 한다는 것인데 즉 읽기의 경우 여러 스레드가 동시에 접근 가능하지만 만약 쓰기가 발생 즉 캐시 미스가 발생해 직접 목적 서버에 데이터를 요청해야 하는 경우에는 다른 스레드의 읽기와 쓰기 작업을 모두 차단하라는 것이다. 이때 thread를 mutex로 구현하지 않아야 한다는 것이다.

💡 C언어의 Thread 관련 정보는 이전에 작성했던 포스팅에서 확인 할 수 있다.

2025.05.03 - [Deep Dive/CS] - [Deep Dive] 쓰레드와 병렬 프로그래밍 - 1탄 개념 이해

2025.05.03 - [Deep Dive/CS] - [Deep Dive] 쓰레드와 병렬 프로그래밍 - 2탄 동기화 기법 Mutex

2025.05.03 - [Deep Dive/CS] - [Deep Dive] 쓰레드와 병렬 프로그래밍 - 4탄 동기화 기법 : 읽기/쓰기 락 (Read-Write Lock)


2. 개념 정리

캐시를 구현하기 위해 어떤 항목을 캐시해야 할까?

캐시 항목(Cache Entry)

  • URI: 요청 대상의 고유 식별자
  • object: 실제 서버에서 받은 응답 데이터
  • size: object의 바이트 크기

전체 캐시 구조

  • 총 크기 추적 필요
  • 객체는 크기순이 아니라 사용 순서(LRU) 기준으로 관리
  • 객체 추가 시:
    • 총 크기를 초과하면 오래된 항목부터 제거해야 함

LRU(Least Recently Used, 최소 최근 사용)

캐시에 공간이 부족할 때 가장 오래전에 사용된 데이터를 제거하는 방식이다.

목표: 최근에 사용되지 않은 데이터는 앞으로도 사용될 가능성이 낮다고 가정하고, 이를 캐시에서 제거하는 방법이다.


그렇다면 캐시를 구현하기 위해 어떤 방법을 사용해야 할까? 처음에는 타임스탬프를 적용해 관리하는 방법을 생각했었다. 하지만 LRU 순서를 유지하기 위해서는 단순히 타입스탬프를 이용해 관리하는 것보다 큐 혹은 이중 연결리스트를 이용해 head와 tail 값을 이용하면 좋다는 자료를 발견했다.

동작 방식 요약

  • 새 요청이 캐시에 없으면 서버로부터 받아서 리스트 뒤에 추가
  • 기존 요청이 캐시에 있으면:
    • 리스트에서 해당 노드를 뒤로 이동 (가장 최근으로)
  • 삽입 시 총 크기 초과되면:
    • 앞에서부터 삭제 (가장 오래된 항목부터)0

그렇다면 쓰레드 안전성은 어떻게 구현해야 할까? 나는 pthread_rwlock를 기반으로 구현을 할 생각이다. 

유형 동시 접근 가능 여부 방법
여러 읽기 가능 pthread_rwlock_rdlock
쓰기 (삽입/삭제) 단독만 가능 pthread_rwlock_wrlock

3. cache 구조체 정의

1. 캐시 블록 구조 (cache_block_t)

typedef struct cache_block {
    char uri[MAXLINE];         // 요청된 URI (key 역할)
    char *object;              // 실제 응답 데이터 (heap에 동적 할당)
    int size;                  // object 크기 (바이트)
    
    struct cache_block *prev;  // 이전 블록 (LRU 리스트)
    struct cache_block *next;  // 다음 블록 (LRU 리스트)
} cache_block_t;
  • uri: 캐시 탐색 시 key
  • object: malloc(size)로 저장된 응답 내용
  • prev/next: 이중 연결 리스트 구성 → LRU 구현

2. 전체 캐시 리스트 구조 (cache_list_t)

typedef struct {
    cache_block_t *head;       // LRU 기준: 가장 오래된 블록
    cache_block_t *tail;       // LRU 기준: 가장 최근 사용된 블록
    int total_size;            // 현재 캐시된 전체 바이트 수

    pthread_rwlock_t lock;     // 읽기/쓰기 보호용 락
} cache_list_t;
  • head부터 삭제하면서 공간 확보 (evict)
  • tail에 새 블록 추가
  • total_size로 1MB 초과 여부 판단
  • lock은 다중 스레드에서 동시 읽기 허용 + 단독 쓰기 보장