Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Web Proxy Server

브라우저와 웹 서버 사이에 끼어들어 요청을 중계하고 응답을 캐싱하는 HTTP 프록시입니다. C와 POSIX 소켓 API만 사용했습니다.

크래프톤 정글 12기 8주차 · CS:APP Proxy Lab · 2026.04

개발 환경 구축(Docker + DevContainer) 안내는 docs/DEVCONTAINER.md로 옮겼습니다.


어떻게 만들었나

한 번에 완성하지 않고 세 단계로 올렸습니다. 각 단계가 통과한 뒤에만 다음으로 넘어갔고, 커밋이 그대로 그 순서입니다.

1단계 — 순차 프록시 basic 40/40

먼저 에코 서버로 소켓 API를 익히고(echo.c), Tiny 웹 서버의 doit / read_requesthdrs / serve_dynamic을 구현해 HTTP 요청·응답의 생김새를 손에 익혔습니다. 그다음 프록시로 넘어갔습니다.

핵심은 요청 재작성입니다. 클라이언트가 보낸 헤더를 그대로 넘기면 안 되고, 프록시가 자기 것으로 갈아끼워야 합니다.

/* Host, User-Agent, Connection, Proxy-Connection은 버리고 프록시가 직접 채운다 */
while (strcmp(buf, "\r\n")) {
  if (strstr(buf, "Host:")) { /* 스킵 */ }
  else if (strstr(buf, "User-Agent:")) { /* 스킵 */ }
  ...
  else Rio_writen(serverfd, buf, strlen(buf));
  Rio_readlineb(&rio, buf, MAXLINE);
}

HTTP/1.1로 들어온 요청도 HTTP/1.0 + Connection: close로 낮춰서 보냅니다. keep-alive를 처리하지 않기로 했기 때문에, 응답 끝을 EOF로 판정할 수 있게 만든 선택입니다.

2단계 — 동시성 concurrency 15/15

연결마다 스레드를 띄우고 즉시 detach합니다. join할 일이 없으니 종료된 스레드 자원이 알아서 회수되게 하는 편이 단순합니다.

pthread_create(&tid, NULL, thread_func, (void *)connfd);
...
void *thread_func(void *arg) {
  pthread_detach(pthread_self());
  int fd = (int)arg;
  doit(fd);
  Close(fd);
}

3단계 — LRU 캐시

pthread_rwlock으로 보호되는 고정 크기 캐시 테이블입니다. 조회는 여러 스레드가 동시에(rdlock), 저장은 배타적으로(wrlock) 들어갑니다.

  • 슬롯 수는 MAX_CACHE_SIZE / MAX_OBJECT_SIZE로 잡았습니다. 동적 할당을 피하는 대신, 모든 객체가 최대 크기인 최악의 경우에도 총량을 넘지 않도록 상한을 고정한 것입니다.
  • 가득 차면 last_used가 가장 오래된 엔트리를 덮어씁니다.
  • 응답을 스트리밍하며 이어붙이다가 MAX_OBJECT_SIZE를 넘으면 cacheable 플래그를 내려 그 응답만 캐싱에서 제외합니다. 전송은 그대로 계속됩니다.
  • 복사는 전부 memcpy입니다. 이미지·바이너리 응답 중간에 \0이 들어 있으면 strcpy가 거기서 잘라먹습니다.

직접 밟은 함정

parse_uri가 원본 URI를 파괴합니다.

파싱을 추가 버퍼 없이 하려고 strchr로 구분자를 찾아 그 자리에 '\0'을 박는 방식을 썼습니다.

char *host_end = strchr(ptr, ':');
*host_end = '\0';          // "localhost:15213/index.html" → "localhost\0" + "15213/index.html"
strcpy(hostname, ptr);

그런데 캐시 키로 쓰려던 게 바로 그 URI였습니다. 파싱을 마치고 나면 키가 이미 잘려 있어서, 캐시가 조용히 어긋났습니다. 넣을 때와 찾을 때의 키가 달라지니 히트가 안 나고, 그렇다고 에러가 나지도 않습니다.

파싱 전에 키를 미리 떠두는 것으로 해결했습니다.

sscanf(buf, "%s %s %s", method, uri, version);
strcpy(cache_key, uri);    // parse_uri가 uri를 훼손하기 전에 확보

해당 커밋: fix(proxy): preserve original URI for cache key


알려진 한계

과제 제출 이후 코드를 다시 읽으며 찾은 것들입니다. 아직 고치지 않았고, 무엇이 왜 문제인지만 적어둡니다.

1. 읽기 락 안에서 공유 상태를 씁니다proxy.ccache_find

pthread_rwlock_rdlock(&cache_lock);
...
cache[i].last_used = time(NULL);   // rdlock은 동시 진입을 허용한다

rdlock은 여러 스레드를 동시에 들여보냅니다. 같은 엔트리에 동시에 히트하면 last_used에 대한 데이터 레이스입니다. x86에서 정렬된 time_t 저장이라 실제로 값이 깨질 일은 거의 없지만 형식상 정의되지 않은 동작이고, LRU 타임스탬프 갱신이 유실될 수 있습니다. 타임스탬프만 원자적으로 다루거나, 조회 경로를 wrlock으로 올리는 선택지가 있습니다.

2. 업스트림 연결 실패가 프록시 전체를 내립니다proxy.cdoit

int serverfd = open_clientfd(hostname, port);   // 실패하면 -1, 검사가 없다
Rio_writen(serverfd, request, strlen(request));

CSAPP의 대문자 래퍼 Rio_writen은 실패 시 unix_error를 거쳐 프로세스를 종료합니다. 단일 프로세스 과제를 전제로 만들어진 래퍼라서, 스레드 서버에서는 전제가 깨집니다. 도달 불가능한 호스트로 요청 하나만 들어와도 스레드 하나가 아니라 서버 전체가 죽습니다. 요청 처리 경로에서는 소문자 rio_writen을 쓰고 반환값을 검사한 뒤 해당 연결만 닫아야 합니다.


실행

cd webproxy-lab
make
./proxy 15213

# 채점
./driver.sh

구성

webproxy-lab/
├── proxy.c       # 프록시 본체 — 요청 재작성, 스레드, LRU 캐시
├── echo.c        # 소켓 API 연습용 에코 서버
├── tiny/         # 원본 서버 역할을 하는 Tiny 웹 서버
├── csapp.c/.h    # CS:APP 제공 래퍼
└── driver.sh     # 채점 스크립트

About

동시성 웹 프록시 서버 — pthreads 연결 처리 + rwlock LRU 캐시 (CS:APP Proxy Lab)

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages