이제 본격적으로 캐시 기능을 구현해 보려고 한다. 캐시 기능을 구현하기 위해 cache.c, cache.h 를 만들어 별도로 분리를 해도 될 것이며 그냥 proxy.c에 전부 담아서 개발을 진행해도 된다. 일단은 proxy.c에 몰아서 개발을 하고 추 후 리팩터링을 통해 분리를 하는 방향으로 진행을 할 것 같다.
그럼 시작하기 앞서 먼저 이 전 포스팅의 마지막 부분에 있던 캐시용 구조체들을 구현하고 시작하는 것이 좋을 것이다. 자세한 사항은 이 전 포스트를 확인하라
2025.05.05 - [크래프톤 정글] - [WebProxy-Lab] proxy 서버 구현하기 Part.4 - 캐시 기능: 개념 정리
[WebProxy-Lab] proxy 서버 구현하기 Part.4 - 캐시 기능: 개념 정리
이 전 포스팅까지 해서 WebProxy-Lab의 동시성 기능까지 구현을 했다. 이제 마지막 요구 사항인 캐시 기능에 대해 접근을 시도하려고 한다. 2025.05.05 - [크래프톤 정글] - [WebProxy-Lab] proxy 서버 구현하
www.gowoong.com
구조체를 이용해 이중연결 리스트를 활용할 것이다.
1. 캐시 초기화 cache_init()
cache_init 의 경우 전역 변수로 선언한 cache_list_t 타입 변수인 cache를 초기화하는 코드로 매우 간단하다.
void cache_init(){
cache.head = NULL;
cache.tail = NULL;
cache.total_size = 0;
pthread_rwlock_init(&cache.lock, NULL);
}
코드를 실행한 직 후에는 캐시 블럭이 없기 때문에 head와 tail에 NULL을 설정하고 전체 크기 역시 0으로 설정한다. 그리고 해당 캐시를 각 스레드에서 조작하려고 할 때 막기 위해 lock을 사용하는데 현재는 초기화 중이니 그 값을 NULL로 설정한다.
2. 캐시를 히트 시 캐시 블럭 처리 cache_move_to_end()
cache_move_to_end() 함수는 클라이언트가 요청을 했을 때 캐시 내부에 해당 데이터가 있다 즉 캐시 히트 상황이 발생했을 때 해당 캐시를 꺼내 사용을 하고 난 뒤에 해당 캐시 블록을 tail로 즉 이중 연결 리스트의 마지막으로 옮겨 붙이는 작업을 수행하게 된다.
void cache_move_to_end(cache_list_t *cache, cache_block_t *node);
해당 함수를 파일의 상단부에 위와 같이 선언했다. 어디서 많이 봤던 모습이지 않나? 크래프톤 정글 5주 차에 다뤘던 연결리스트 혹은 다른 자료 구조들에서 많이 사용했던 구조와 같다고 할 수 있다.
함수 구조 설명:
캐시의 이전, 다음 노드의 값에 따라 노드를 맨 뒤로 옮기는 과정을 구현한다.
void cache_move_to_end(cache_list_t *cache, cache_block_t *node) {
if (cache->tail == node) return; // 이미 끝이면 할 필요 없음
// 리스트에서 node 제거
if (node->prev) node->prev->next = node->next;
else cache->head = node->next; // node가 head였을 경우
if (node->next) node->next->prev = node->prev;
else cache->tail = node->prev; // node가 tail이었던 경우
// tail 뒤에 붙이기
node->prev = cache->tail;
node->next = NULL;
if (cache->tail)
cache->tail->next = node;
else
cache->head = node; // 캐시가 비어 있었던 경우
cache->tail = node;
}
1. 기존 위치에서 노드 제거
if (node->prev) node->prev->next = node->next;
else cache->head = node->next;
- 노드가 중간에 있는 경우 → 앞 노드가 이 노드의 next를 현재 노드의 next로 설정
- 노드가 head인 경우 → head 포인터를 다음 노드로 옮김
if (node->next) node->next->prev = node->prev;
else cache->tail = node->prev;
- 노드가 중간에 있는 경우 → 뒤 노드의 prev를 현재 노드의 prev로 설정
- 노드가 tail인 경우 → tail 포인터를 앞 노드로 이동
2. tail 뒤에 노드를 붙이기
node->prev = cache->tail;
node->next = NULL;
- 현재 노드를 새로운 마지막 노드로 만들 준비를 합니다
- prev는 현재 tail, next는 NULL
if (cache->tail)
cache->tail->next = node;
else
cache->head = node;
- 기존 tail이 존재하면 → 그 뒤에 현재 노드를 연결
- 캐시가 비어 있었던 경우 → node가 head가 됨
cache->tail = node;
- tail 포인터 갱신
3. 캐시 공간 부족 시 캐시 블록 제거 cache_evict()
cache_evict() 함수는 삽입 하기 전에 공간이 부족한 경우에 호출한다. head부터 하나씩 제거를 하여 total_size를 줄이고 MAX_CACHE_SIZE를 만족할 때까지 반복을 한다.
void cache_evict(cache_list_t *cache, int size_needed);
함수 선언 코드는 다음과 같이 구성했다.
매개변수 설명
- cache: 전역 캐시 구조체 포인터
- size_needed: 앞으로 삽입할 새로운 객체 크기 (바이트 단위)
함수 구조 설명:
void cache_evict(cache_list_t *cache, int size_needed){
while (cache->total_size + size_needed > MAX_CACHE_SIZE){
if (cache->head == NULL) return;
cache_block_t *oldest = cache->head;
cache->head = oldest->next;
if (cache->head){
cache->head->prev = NULL;
}else{
cache->tail = NULL;
}
cache->total_size -= oldest->size;
free(oldest->object);
free(oldest);
}
}
1. 공간이 충분할 때까지 반복
while (cache->total_size + size_needed > MAX_CACHE_SIZE){
캐시에 새로운 데이터를 넣었을 때 전체 크기를 초과하는지 확인하고 초과하는 경우 head를 삭제하는 과정을 반복해서 조건을 만족할 때까지 진행한다.
2. 캐시가 비어있으면 탈출
if (cache->head == NULL) return;
이 조건이 의미하는 것은 캐시가 비어있는 상태인데도 캐시하려는 파일의 크기가 가능한 전체 크기를 초과했기 때문에 그냥 return을 수행한다.
3. 가장 오래된 노드 선택
cache_block_t *oldest = cache->head;
cache 내에 있는 head 노드는 가장 오래전에 사용된 데이터이기에 삭제 대상이 된다.
4. 연결 리스트에서 제거
cache->head = oldest->next;
- head를 다음 노드로 이동 (제거)
if (cache->head){
cache->head->prev = NULL;
}else{
cache->tail = NULL;
}
- head가 NULL이 아니면 → 새 head의 prev를 NULL로 초기화
- head가 NULL이면 → 캐시에 노드가 아예 없는 상태가 된 것 → tail도 NULL로 설정
5. 캐시 용량 감소
cache->total_size -= oldest->size;
- 제거한 노드의 바이트 수만큼 총 크기 감소
6. 메모리 해제
free(oldest->object);
free(oldest);
- 노드 내부의 데이터(object) 먼저 해제
- 마지막으로 노드 자체도 해제
4. 새 항목을 캐시에 저장 cache_insert(uri, object, size)
새로 받아온 uri 를 키로 응답 데이터를 object로 응답 데이터의 크기를 캐시에 저장한다.
void cache_insert(cache_list_t *cache, const char *uri, const char *object, int size)
함수 구조 설명:
void cache_insert(cache_list_t *cache, const char *uri, const char *object, int size){
if (size > MAX_OBJECT_SIZE){
return;
}
pthread_rwlock_wrlock(&cache->lock);
cache_evict(cache, size);
cache_block_t *new_block = malloc(sizeof(cache_block_t));
if (!new_block){
pthread_rwlock_unlock(&cache->lock);
return;
}
strncpy(new_block->uri, uri, MAXLINE - 1);
new_block->uri[MAXLINE - 1] = '\0';
new_block->object = malloc(size);
if (!new_block->object){
free(new_block);
pthread_rwlock_unlock(&cache->lock);
return;
}
memcpy(new_block->object, object, size);
new_block->size = size;
new_block->prev = cache->tail;
new_block->next = NULL;
if (cache->tail){
cache->tail->next = new_block;
} else{
cache->head = new_block;
}
cache->tail = new_block;
cache->total_size += size;
pthread_rwlock_unlock(&cache->lock);
}
1. 크기 초과 체크
if (size > MAX_OBJECT_SIZE){
return;
}
- 너무 큰 객체는 캐시에 넣을 수 없음
- 예) 100KB 초과 → 무시하고 바로 반환
2. 쓰기 락 획득
pthread_rwlock_wrlock(&cache->lock);
- 캐시 리스트를 변경하므로 쓰기 락 필요
- 다중 스레드 환경에서 동시 수정 방지
3. 공간 확보
cache_evict(cache, size);
- 현재 캐시 크기 + 새 객체 크기가 MAX_CACHE_SIZE 초과 시,
- 가장 오래된 캐시 블록부터 제거
4. 새 노드 생성
cache_block_t *new_block = malloc(sizeof(cache_block_t));
- 캐시에 저장할 새 블록 구조체 할당
if (!new_block){
pthread_rwlock_unlock(&cache->lock);
return;
}
- malloc 실패 시 메모리 부족 → 예외 처리 후 반환
5. URI 복사
strncpy(new_block->uri, uri, MAXLINE - 1);
new_block->uri[MAXLINE - 1] = '\0';
- URI는 최대 MAXLINE 길이로 복사 (NULL 종료 보장)
6. 데이터 복사
new_block->object = malloc(size);
- 실제 데이터용 공간 할당
if (!new_block->object){
free(new_block);
pthread_rwlock_unlock(&cache->lock);
return;
}
- 실패 시 구조체도 해제 후 반환
memcpy(new_block->object, object, size);
new_block->size = size;
- 응답 데이터를 캐시 블록에 복사
7. 연결 리스트에 추가
new_block->prev = cache->tail;
new_block->next = NULL;
- 새 블록은 가장 최근 항목 → 항상 tail에 붙임
if (cache->tail){
cache->tail->next = new_block;
} else{
cache->head = new_block;
}
- 기존에 tail이 있으면 그 뒤에 연결
- 캐시가 비어있으면 이 블록이 head도 됨
cache->tail = new_block;
- 항상 새 블록이 tail
8. 크기 업데이트
cache->total_size += size;
- 캐시 전체 크기 갱신
9. 락 해제
pthread_rwlock_unlock(&cache->lock);
- 캐시 변경 끝 → 다른 스레드가 접근 가능하게 함
5. URI로 노드 탐색 cache_find(uri)
이 함수는 URI와 일치하는 캐시 항목이 있는지 찾는 것이 목적이다.
캐시 히트 상황과 캐시 미스 상황을 int 타입으로 반환을 하게 된다.
int cache_find(cache_list_t *cache, const char *uri, char *object_buf, int *size_buf);
함수 구조 설명:
int cache_find(cache_list_t *cache, const char *uri, char *object_buf, int *size_buf){
pthread_rwlock_rdlock(&cache->lock);
cache_block_t *node = cache->head;
while (node){
if(strcmp(node->uri, uri) == 0){
pthread_rwlock_unlock(&cache->lock);
pthread_rwlock_wrlock(&cache->lock);
cache_move_to_end(cache, node);
memcpy(object_buf, node->object, node->size);
*size_buf = node->size;
pthread_rwlock_unlock(&cache->lock);
return 1;
}
node = node->next;
}
pthread_rwlock_unlock(&cache->lock);
return 0;
}
1. 읽기 락 획득
pthread_rwlock_rdlock(&cache->lock);
- 다른 스레드들이 읽기 가능하지만, 쓰기는 불가
- 캐시를 탐색(read-only)만 하므로 우선 읽기 락
2. 리스트 순회
cache_block_t *node = cache->head;
while (node){
- head부터 시작해서 다음 노드로 이동하며 탐색
if(strcmp(node->uri, uri) == 0){
- 일치하는 URI 발견 시 (= 캐시 hit)
3. 읽기 락 해제 → 쓰기 락 전환
pthread_rwlock_unlock(&cache->lock);
pthread_rwlock_wrlock(&cache->lock);
- 이제 캐시 구조를 수정해야 하므로
- 쓰기 락으로 업그레이드
💡 락 교체 이유
- cache_move_to_end()는 리스트를 수정하므로 반드시 쓰기 락 필요
4. LRU 순서 갱신 + 데이터 복사
cache_move_to_end(cache, node);
- 해당 노드를 가장 최근 위치(tail)로 이동
memcpy(object_buf, node->object, node->size);
*size_buf = node->size;
- 클라이언트 응답을 보낼 수 있도록 데이터 복사
5. 락 해제 + 성공 반환
pthread_rwlock_unlock(&cache->lock);
return 1;
- 쓰기 락 해제
- 1 반환 → 캐시 hit
6. 미스 처리
node = node->next;
- 현재 노드가 아니면 다음 노드로 이동
pthread_rwlock_unlock(&cache->lock);
return 0;
- 리스트 끝까지 찾지 못하면 → 캐시 miss
다음 포스팅에서 마지막으로 캐시를 적용하기 위해 구현했던 기능들을 기존의 코드와 통합하는 과정을 설명하겠다.
'크래프톤 정글' 카테고리의 다른 글
[pintos] 우선순위 역전과 우선순위 기부 (0) | 2025.05.10 |
---|---|
WebProxy-Lab 회고 (2) | 2025.05.07 |
[WebProxy-Lab] proxy 서버 구현하기 Part.4 - 캐시 기능: 개념 정리 (0) | 2025.05.05 |
[WebProxy-Lab] proxy 서버 구현하기 Part.3 - 동시성 처리 (0) | 2025.05.05 |
[WebProxy-Lab] proxy 서버 구현하기 Part.2 - 프록시 서버 구현 (1) | 2025.05.05 |