크래프톤 정글 8주차 - CS:APP Proxy Lab - 수요코딩회 DB API서버 + WIL & 팀원 회고

jypark·2026년 4월 23일
post-thumbnail

8주차 정글 스케쥴 (4월 17일 ~ 4월 23일)

이번 주는 Proxy lab을 하면서, 저번 Malloc lab과는 달리 개념적으로 정해진 길을 가야하고, 그거에 대한 학습이 요구되는 한 주였다. CGI를 배우면서, 현재는 CGI→FastCGI→각 언어에 탑재된 Http 라이브러리의 진행도를 배웠고, 그럼에 CGI를 배운건 OS process 흐름대로 Http request를 parsing하고 재조립해서 보낸다는 것에 의의를 두었다.

C Programming (2주차 / 총 3주 일정)

  • CS:APP Proxy lab
  • Carnegie Mellon University CS 213: Proxy Lab: Writing a Caching Web Proxy

4월 17일 금요일

  • Week 8팀 구성
  • Echo 서버 작성

4월 18일 토요일

  • Tiny 서버 작성

4월 19일 일요일

  • Tiny 서버와 아키텍처 학습

4월 20일 월요일

  • Tiny 서버 연습예제
  • Proxy Sequential, Concurrency

4월 21일 화요일

  • Proxy Cache 완성
  • 수요 코딩회 Preview

4월 22일 수요일

  • 수요코딩회 - API 서버 만들기 (전 SQL parser와 B+트리 자료구조 연동)
  • 수요코딩회 - 팀 리딩 및 PM (동적 스레드 풀, 및 단위 테스트, ThreadSanitizer CI 추가 등등)
  • 개발 및 시연 정리

4월 23일 목요일

  • 수요코딩회 개발한 것 발표
  • Week8 팀 회고
  • WIL 작성

1. 이번 주 마음가짐 + 목표

이번 주가 역대급 컨디션 난조였어서, 스스로도 힘들었다.

https://github.com/JYPark-Code/SW-AI-W08-Web-proxy-Lab

Tiny까지는 책에 있어서, 쳐보면서 이해하는 방식으로 접근했다. 내장 함수 하나하나 눌러보고, 주석도 달아보고, AI 서포트도 받아가면서 이해도를 높였다. Proxy는 달랐다 — 책에 코드가 없었다. 빈 종이에서 설계하는 첫 경험이었다.

Proxy Lab 학습 목표

  • HTTP 프록시 서버의 동작 원리 이해 — 브라우저와 서버 사이에서 요청을 중계하는 구조를 직접 구현하며 체득한다.
  • 네트워크 소켓 프로그래밍 체화 — socket, bind, listen, accept, connect 6개 API와 RIO 패키지의 동작 원리를 손으로 익힌다.
  • 동시성(Concurrency) 실전 경험 — pthread, race condition, rwlock을 단순히 배우는 것이 아니라 "왜 이 선택인가"를 설명할 수 있는 수준까지 이해한다.
  • 캐시 설계 — LRU 자료구조와 Readers-Writers 동기화를 조합해 멀티스레드 환경에서 안전한 캐시를 만든다.

2. 시도한 접근 방식

Proxy Lab

Part 1: Sequential Proxy (순차 처리)

Tiny 코드를 기반으로 시작했지만, doit 함수의 역할이 완전히 달라야 한다는 걸 일찍 깨달은 게 컸다.

측면TinyProxy
역할콘텐츠 생산자중계자
응답 출처서버 디스크 파일다른 서버의 응답
소켓 역할서버 전용서버 + 클라이언트
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 통과.


Part 2: Concurrent Proxy (동시성)

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.


Part 3: Caching Proxy (캐시)

캐시의 목적: 같은 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로 이동)을 넣었다. 문제는 동기화 패턴과 충돌이었다.

  • cache_find를 호출하는 컨텍스트는 rdlock (다중 reader 동시 허용)
  • 하지만 LRU 이동은 리스트 구조 변경 = 쓰기 작업
  • rdlock 상태에서 쓰기를 하면 두 스레드가 동시에 같은 노드를 이동시키며 리스트 깨짐

선택: 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.


Part 3 WIL 추가: Bulk Insert ↔ Reverse Proxy 대응

이번 주 가장 흥미로운 발견은 지난 수요코딩회에서 했던 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는 다섯 개를 다 한다.


3. 수요코딩회 (B+ 트리 인덱스)

이 섹션은 별도 채팅방에서 작성 예정이므로 기존 내용을 그대로 유지합니다.


4. 결과

Proxy Lab

파트점수내용
Part 1: Basic40/40Sequential 요청 중계, 바이너리 데이터 전달
Part 2: Concurrency15/15pthread, race condition 방지, SIGPIPE 처리
Part 3: Cache15/15LRU 이중 연결 리스트, rwlock 동기화
총점70/70만점

5. 팀 회고 (KPT)

Keep

  • 지용: 동현이의 Quality&Deep한 질문 +High expectation(모든 팀원에게). 팀원들 역량이 모두 좋았다. 빠르게 목표를 잡은 게 좋았다.
  • 동현: 개인 공부 시간 많았고, 수요코딩회 너무 편안했다. 개개인의 작업의 Merge가 딱딱 맞은 건 너무 신기하다. 실무 환경과 유사하게 해서, 스스로의 업무 성향 파악은 잘됐어서, 좋았다.
  • 승진: 동현님의 질의 방식이 스스로의 관점의 확장에 도움이 됐다.
  • 용 형님: 개인 공부 시간 있어서, 전체적인 이해도 ↑ / Issue ↓ / 스스로의 업무 스타일 마지막은 항상 있었다.

Problem (아쉬운점)

  • 금주 내용이 다소 낮기 부족했고, 현업과의 거리감도 느끼고 있었는데, 다른 주에 비해 몰입도가 낮았다.
  • 정답에 정해져 왔었고, 다른 변칙이 용납 안된 시점에서, 순수 공부만 한 느낌이다.
  • 나태해지는 포인트들이 몇몇 있었으나. 수요코딩회에서 회복했다.
  • PM의 실무 분담은 능력 별로 나누는 것 이해하나, 각자 하고 싶은 일을 못한 거 같다. 그 외도 코드양이 너무 많아서, AI도 느려지고, 일일히 볼 수 없었다. 협업 규약으로 인해, 수정할 수 있는 코드가 한정적이었고, 그로 인해 다소 수동적으로 접근할 수밖에 없었다.
  • 미리 잡은 코어 타임 2일이 아쉬웠고 그것이 팀원과의 소통이 부족함으로 이어져서, 아쉬웠다.

Try

  • 같이 한 Activity가 부족해서, 약간 너무 4명에서 사무적이지 않지 않았나.
  • PM의 역할을 한번 해보고 싶다.
  • 커리큘럼에 (±α)를 해서, 자신을 특화하는 게 필요.

6. 기술 정리 (WIL용 핵심 개념)

소켓 프로그래밍

  • 파일 디스크립터(fd): 프로세스가 열어둔 자원을 가리키는 정수 인덱스. 소켓도 fd.
  • 서버: socket → bind → listen → accept 4단계 (bind = 주소 고정, listen = 대기 모드)
  • 클라이언트: socket → connect 2단계 (bind 생략, OS가 임시 포트 자동 할당)
  • TCP 4-tuple: (출발IP, 출발포트, 도착IP, 도착포트)로 연결 고유 식별. 서버 포트 하나에 수천 연결이 가능한 이유.

RIO (Robust I/O)

  • Short count 처리: read가 요청보다 적게 반환해도 루프로 보완
  • Buffered I/O: 8KB 내부 버퍼로 시스템 콜 횟수 최소화 (= DB Bulk Insert의 batching 패턴과 동일 원리)
  • readlineb: 텍스트 한 줄 읽기 (HTTP 헤더)
  • readnb: 바이너리 덩어리 읽기 (HTTP 바디, 이미지, 실행 파일)

HTTP 구조

  • 요청/응답 모두 시작라인 + 헤더 + \r\n\r\n + 바디 구조
  • 헤더 끝 신호: 빈 줄 (\r\n\r\n)
  • HTTP/1.0: Connection: close 강제 → 응답 끝이 곧 연결 종료 → Content-Length 파싱 불필요

동시성

  • Race condition: 실행 순서에 따라 결과가 달라지는 상황. 재현이 어렵다(하이젠버그).
  • 해결 원칙: "공유하지 말거나, 공유하면 동기화하라"
  • connfd 힙 복사: "공유 안 하기" 선택. 매번 다른 힙 주소 → 각 스레드 독립
  • pthread_detach: OS에 정리 위임. join 쓰면 기다려야 해서 동시성 파괴

동기화 계층

하드웨어 원자 연산 (CAS, test-and-set)
    ↓
OS 커널 대기 큐 (futex)
    ↓
Mutex (한 명) + Semaphore (N명)   ← 기본 원시
    ↓
RWLock = Mutex + Semaphore + 카운터   ← 조합 추상화
  • Mutex: 상호 배제, 소유권 있음 (잠근 스레드만 풀 수 있음)
  • Semaphore: 카운터 기반, 소유권 없음, 이벤트 알림에 적합
  • RWLock: 읽기 다수/쓰기 하나. 읽기 많은 워크로드에 최적

CGI 원리

  • fork + dup2 + execve 조합
  • dup2(fd, STDOUT_FILENO): 자식의 stdout을 소켓으로 리다이렉트
  • CGI 프로그램이 printf만 해도 네트워크로 전송되는 이유
  • CGI → FastCGI → 내장 HTTP 라이브러리로 발전한 역사

7. 느낀 점

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


8. 차주 계획

PintOS 시작

  1. 그 유명한 PintOS가 시작한다. 스탠포드에서 만들고 카이스트가 64bit 대응한 버전
  2. 발제에서 들은 코멘트: AI 코드 생성은 하지 말 것. 깨부 당하고 버텨보시길.

수요코딩회는 이제 없다.

  • 쭉 이어서 공부하고 목요일날 모두 다 발표 (KDT 수료 조건)

목표

  1. 항상 과하지 않게 목표에 충실하게 공부
  2. 열심히 공부하기 & 건강 챙기기

0개의 댓글