
이번 주는 Proxy lab을 하면서, 저번 Malloc lab과는 달리 개념적으로 정해진 길을 가야하고, 그거에 대한 학습이 요구되는 한 주였다. CGI를 배우면서, 현재는 CGI→FastCGI→각 언어에 탑재된 Http 라이브러리의 진행도를 배웠고, 그럼에 CGI를 배운건 OS process 흐름대로 Http request를 parsing하고 재조립해서 보낸다는 것에 의의를 두었다.
C Programming (2주차 / 총 3주 일정)
4월 17일 금요일
4월 18일 토요일
4월 19일 일요일
4월 20일 월요일
4월 21일 화요일
4월 22일 수요일
4월 23일 목요일
이번 주가 역대급 컨디션 난조였어서, 스스로도 힘들었다.
https://github.com/JYPark-Code/SW-AI-W08-Web-proxy-Lab
Tiny까지는 책에 있어서, 쳐보면서 이해하는 방식으로 접근했다. 내장 함수 하나하나 눌러보고, 주석도 달아보고, AI 서포트도 받아가면서 이해도를 높였다. Proxy는 달랐다 — 책에 코드가 없었다. 빈 종이에서 설계하는 첫 경험이었다.
socket, bind, listen, accept, connect 6개 API와 RIO 패키지의 동작 원리를 손으로 익힌다.Tiny 코드를 기반으로 시작했지만, doit 함수의 역할이 완전히 달라야 한다는 걸 일찍 깨달은 게 컸다.
| 측면 | Tiny | Proxy |
|---|---|---|
| 역할 | 콘텐츠 생산자 | 중계자 |
| 응답 출처 | 서버 디스크 파일 | 다른 서버의 응답 |
| 소켓 역할 | 서버 전용 | 서버 + 클라이언트 |
| fd 관리 | 1개 (connfd) | 2개 (clientfd + serverfd) |
| RIO 버퍼 | 1개 | 2개 |
parse_uri는 이름만 같고 알고리즘이 완전히 다른 함수였다. Tiny의 parse_uri는 /cgi-bin/adder?n1=15213을 받아 정적/동적 판별을 했다면, Proxy는 http://host:port/path를 세 조각으로 분해해야 했다.
설계에서 두 가지 결정이 있었다:
① / 먼저 찾고 : 나중에 찾기
: 먼저 찾으면 포트 생략된 URL에서 NULL 케이스를 분리해야 해서 복잡하다. /를 먼저 기준선으로 그으면 남은 host:port 영역에서만 :을 찾으면 된다. 영역을 먼저 제한한 뒤 내부를 쪼개는 순서.
② strchr 대신 범위 제한 for 루프
path 안에 :이 있을 수 있어서, 원본 uri를 수정하지 않고 hostbegin부터 hostend 범위 내에서만 순회.
build_requesthdrs에서 가장 크게 배운 건 루프 내/외 분리였다. 처음엔 모든 로직을 while 루프 안에 넣었다가 User-Agent와 Connection 헤더가 헤더 수만큼 반복 추가되는 버그를 만들었다. 헤더 분류를 "수용(Host) / 거부(UA·Connection·Proxy-Connection) / 기본값(나머지)"의 3단 게이트로 재구성하고서야 해결됐다.
응답 전달에 Rio_readnb를 쓰는 이유도 체득했다. driver.sh의 basic 테스트에 godzilla.jpg와 tiny 실행 바이너리가 포함돼 있다. readlineb로 전달하면 이미지 바이트 중 0x0A를 줄 끝으로 오인해 diff에서 실패한다. 바이너리 데이터는 readnb로 덩어리째 전달해야 한다.
driver.sh Basic 40/40 통과.
Sequential proxy의 한계는 명확했다. doit()이 끝나야 다음 accept가 가능하다. nop-server 같이 응답 없는 서버에 요청이 막히면 다른 모든 요청도 멈춘다.
해결 방법으로 멀티스레딩을 선택했다. fork(멀티프로세싱) 대신 thread를 고른 이유:
1. 생성 비용: thread는 μs 단위, fork는 ms 단위
2. 캐시 공유: Part 3 캐시가 모든 스레드에서 공유돼야 한다. fork는 IPC가 필요
3. 자원 효율: 같은 주소 공간 내에서 가벼운 컨텍스트 전환
이번 파트의 핵심 이슈는 race condition이었다.
/* 위험한 코드 */
while (1) {
int connfd = Accept(listenfd, ...); // 지역 변수
pthread_create(&tid, NULL, thread, &connfd); // 주소 전달
}
시각 메인 스레드 1
──── ────────────────── ──────────
t=0 connfd = 4 (A 요청)
t=1 pthread_create(&connfd)
t=2 시작됨, 아직 *vargp 안 읽음
t=3 (다음 루프)
t=4 connfd = 5 (B 요청) ← 덮어쓰기!
t=6 *vargp 읽음 → 5 ❌
pthread_create는 비동기 호출이다. 스레드가 언제 실행될지 OS 스케줄러가 결정한다. 메인이 다음 루프에서 connfd를 덮어쓰는 순간 그걸 보고 있는 스레드가 잘못된 fd를 받는다.
해결은 힙 복사:
int *connfdp = Malloc(sizeof(int)); // 매번 다른 힙 주소
*connfdp = Accept(...);
Pthread_create(&tid, NULL, thread, connfdp);
void *thread(void *vargp) {
int connfd = *((int *)vargp);
Pthread_detach(Pthread_self());
Free(vargp); // 힙 해제는 스레드가
doit(connfd);
Close(connfd);
return NULL;
}
Malloc은 매번 다른 힙 주소를 반환한다. 스레드 1의 주소(0x55aa1000)와 스레드 2의 주소(0x55aa2000)는 겹치지 않는다.
pthread_join 대신 pthread_detach를 쓴 이유: join은 메인이 기다려야 해서 다음 accept를 못 한다. 동시성이 깨진다. detach는 OS에게 자원 정리를 맡기고 메인은 즉시 다음 요청으로.
Signal(SIGPIPE, SIG_IGN)은 클라이언트가 갑자기 끊을 때 프로세스 전체가 죽는 걸 막는다. 동시성 서버에서 한 요청 때문에 모든 스레드가 죽는 건 치명적이다.
driver.sh Concurrency 15/15 통과. 누적 55/70.
캐시의 목적: 같은 URL 재요청 시 백엔드 왕복 없이 메모리에서 즉시 응답. driver.sh 캐시 테스트가 이 개념을 영리하게 검증한다 — Tiny를 kill하고 같은 파일을 다시 요청해서, 캐시에서 나오면 성공.
자료구조: 이중 연결 리스트 기반 LRU
MAX_CACHE_SIZE / MAX_OBJECT_SIZE = 1,049,000 / 102,400 ≈ 최대 10개 엔트리
최대 10개라 linear search도 O(1)과 실질적 차이 없다. 배열은 중간 제거 시 O(n) shift가 필요하지만, 이중 연결 리스트는 head 삽입/tail 제거/중간 이동 모두 O(1)로 처리 가능.
cache_find에서 LRU 이동을 뺀 이유 — 동기화 트레이드오프
처음엔 cache_find에 LRU 갱신(찾은 노드를 head로 이동)을 넣었다. 문제는 동기화 패턴과 충돌이었다.
rdlock (다중 reader 동시 허용)선택: cache_find에서 LRU 이동 제거 (순수 읽기). insert-order 기반 근사 LRU로 대체. driver.sh 채점엔 영향 없고, 동시 읽기 성능 유지.
교훈: 동기화 정책이 자료구조 설계를 제약한다. 정확도와 동시성 중 의도적으로 동시성을 선택했다.
동기화: pthread_rwlock
캐시 접근 패턴이 "읽기 >> 쓰기"다. 뮤텍스로 전 구간을 직렬화하면 동시 읽기를 못 해 동시성 의미가 거의 사라진다. rwlock은 여러 reader가 동시 진입을 허용하고 writer만 단독 접근한다.
사실 rwlock은 원시 동기화가 아니라 mutex + 세마포어 + 카운터의 조합이다. CSAPP 12.5가 세마포어 두 개로 직접 구현하는 방식을 보여주는데, 그게 rwlock의 내부 구조다. pthread_rwlock이 이를 표준 POSIX로 제공한다.
/* 캐시 조회 — rdlock (다중 reader 허용) */
pthread_rwlock_rdlock(&cache.lock);
cache_entry_t *cached = cache_find(&cache, uri);
if (cached != NULL) {
Rio_writen(clientfd, cached->data, cached->size); // 락 안에서 전송
pthread_rwlock_unlock(&cache.lock);
return;
}
pthread_rwlock_unlock(&cache.lock);
/* 캐시 저장 — wrlock (단독) */
pthread_rwlock_wrlock(&cache.lock);
cache_insert(&cache, uri, cache_buf, cache_size);
pthread_rwlock_unlock(&cache.lock);
HIT 시 응답 전송도 락 안에서 해야 한다. 락 풀고 전송하면 그 사이 다른 스레드가 evict로 해당 노드를 제거할 수 있다 — use-after-free.
cache_insert의 공간 확보 루프를 if가 아닌 while로 쓴 이유: 새 객체 하나 넣으려고 여러 개 evict가 필요할 수 있다. if로 한 번만 제거하면 크기 초과가 발생할 수 있다.
driver.sh Cache 15/15 통과. 최종 70/70.
이번 주 가장 흥미로운 발견은 지난 수요코딩회에서 했던 Bulk Insert 최적화와 Reverse Proxy 최적화가 같은 원리의 다른 표현이라는 것이었다.
| # | Bulk Insert 최적화 | Reverse Proxy 대응 |
|---|---|---|
| ① | 파일 포인터 재사용 (매번 open/close X) | keepalive upstream connection pool |
| ② | 스키마 캐싱 | proxy_cache (정적 메타/응답 캐싱) |
| ③ | 경로 캐싱 | resolver cache / routing table |
| ④ | meta 캐싱 (O(N²) 제거) | microcache (hot path 중복 제거) |
| ⑤ | BULK_INSERT_MODE (fflush 지연) | proxy_buffering + write coalescing |
관통하는 메타 원칙: "비싼 경계를 덜 넘고, 반복은 캐싱하고, 작은 건 묶는다."
우리가 만든 캐시는 이 중 ② 하나이지만, 실무 Nginx는 다섯 개를 다 한다.
이 섹션은 별도 채팅방에서 작성 예정이므로 기존 내용을 그대로 유지합니다.
| 파트 | 점수 | 내용 |
|---|---|---|
| Part 1: Basic | 40/40 | Sequential 요청 중계, 바이너리 데이터 전달 |
| Part 2: Concurrency | 15/15 | pthread, race condition 방지, SIGPIPE 처리 |
| Part 3: Cache | 15/15 | LRU 이중 연결 리스트, rwlock 동기화 |
| 총점 | 70/70 | 만점 |
socket → bind → listen → accept 4단계 (bind = 주소 고정, listen = 대기 모드)socket → connect 2단계 (bind 생략, OS가 임시 포트 자동 할당)(출발IP, 출발포트, 도착IP, 도착포트)로 연결 고유 식별. 서버 포트 하나에 수천 연결이 가능한 이유.read가 요청보다 적게 반환해도 루프로 보완readlineb: 텍스트 한 줄 읽기 (HTTP 헤더)readnb: 바이너리 덩어리 읽기 (HTTP 바디, 이미지, 실행 파일)시작라인 + 헤더 + \r\n\r\n + 바디 구조\r\n\r\n)Connection: close 강제 → 응답 끝이 곧 연결 종료 → Content-Length 파싱 불필요하드웨어 원자 연산 (CAS, test-and-set)
↓
OS 커널 대기 큐 (futex)
↓
Mutex (한 명) + Semaphore (N명) ← 기본 원시
↓
RWLock = Mutex + Semaphore + 카운터 ← 조합 추상화
fork + dup2 + execve 조합dup2(fd, STDOUT_FILENO): 자식의 stdout을 소켓으로 리다이렉트printf만 해도 네트워크로 전송되는 이유Tiny까지는 "책을 따라가는 학습"이었다. Proxy부터는 달랐다. parse_uri를 직접 설계하고, race condition 시나리오를 시간순으로 그려보고, LRU 이동과 동시 읽기 사이에서 트레이드오프를 의식적으로 선택했다.
가장 인상 깊었던 통찰: RIO의 buffered I/O와 DB Bulk Insert가 같은 원리다. "비싼 경계를 묶어서 한 번에 넘는다"는 batching 패턴이 도메인을 가로질러 반복된다. 이걸 발견하는 순간 새 기술을 배울 때마다 "어디서 본 패턴이지?"라고 물을 수 있게 됐다.
다음 주 PintOS가 시작한다. "AI 코드 생성 금지, 깨부 당하고 버텨보시길"이라는 코멘트가 벌써 부담스럽지만 — 이번 주 Proxy에서 빈 종이를 채워본 경험이 그 부담을 조금은 덜어준다.
GitHub: https://github.com/JYPark-Code/SW-AI-W08-Web-proxy-Lab