글 목록으로 돌아가기

Jungle / WIL(Weekly I Learned)

[WIL] 8주

8주차는 CS:APP 11장을 중심으로 네트워크 프로그래밍, HTTP, Tiny Web Server, TCP 연결 과정, 동시성 서버 구조를 학습하고, B+Tree 인덱스와 thread pool 기반 Mini DBMS SQL API Server를 설명 가능한 형태로 정리한 한 주였다.

임재환
임재환 2026년 4월 23일 · 8분 읽기 · 수정 2026년 4월 23일
[WIL] 8주

8주차 주간 회고: 네트워크와 웹 서버의 흐름을 코드로 따라간 한 주

8주차는 CS:APP 11장을 중심으로 네트워크 프로그래밍과 웹 서버 구현을 집중적으로 학습한 주였다. 8주차 발제에서 잡은 방향도 명확했다. 단순히 네트워크 이론을 넓게 훑는 것이 아니라, 서버를 단계적으로 구현하면서 웹 서버가 실제로 어떻게 요청을 받고 응답하는지 이해하는 것이 핵심이었다.

이번 주의 큰 흐름은 클라이언트-서버 모델 → 소켓 인터페이스 → HTTP → Tiny Web Server → TCP 연결 과정 → 동시성 서버로 이어졌다. 여기에 수요코딩에서 다룬 B+Tree 인덱스와 피드백까지 더해지면서, 단순 구현보다 “내가 만든 구조를 어떻게 설명하고 개선할 것인가”를 많이 고민하게 됐다.

브라우저 URL 입력부터 DNS, TCP 연결, HTTP 요청, 서버 응답까지 이어지는 웹 요청 실제 흐름
브라우저에서 URL을 입력한 뒤 화면이 보이기까지의 전체 흐름

1. CS:APP 11장과 네트워크 프로그래밍

가장 먼저 잡은 개념은 클라이언트-서버 모델이었다. 클라이언트는 서비스를 요청하고, 서버는 정해진 포트에서 기다리다가 요청을 처리한다. 이 구조 자체는 단순해 보이지만, 실제 코드로 들어가면 socket, bind, listen, accept, connect 같은 시스템 호출들이 각각 분명한 역할을 가진다.

특히 소켓을 파일 디스크립터처럼 다룬다는 점이 중요했다. 네트워크 통신도 결국 읽고 쓰는 인터페이스로 추상화되기 때문에, 서버 입장에서는 클라이언트와 연결된 connfd를 통해 요청을 읽고 응답을 쓰게 된다. 이 관점이 잡히니 echo server, Tiny server, proxy server가 전혀 다른 프로그램이 아니라 같은 뼈대 위에 있다는 점이 보였다.

Network Programming Part 1, Part 2를 보면서는 DNS, IP 주소, 포트 번호, getaddrinfo()의 역할도 정리했다. 사람이 입력한 도메인 이름이 IP 주소로 바뀌고, 그 주소와 포트를 이용해 연결이 만들어지는 과정이 웹 요청의 출발점이라는 것을 다시 확인했다.

2. HTTP와 Tiny Web Server

HTTP는 이번 주 학습에서 가장 실용적으로 와닿은 부분이었다. 브라우저가 서버에게 요청 라인과 헤더를 보내고, 서버는 상태 라인, 헤더, 본문으로 응답한다. 텍스트 기반 프로토콜이라 직접 보면 단순하지만, 서버 구현에서는 이 형식을 정확히 지켜야 한다.

Tiny Web Server를 정리하면서 서버의 흐름을 함수 단위로 나눠서 이해했다.

  • main()은 listen socket을 열고 클라이언트 연결을 받는다.
  • doit()는 요청 한 건의 전체 흐름을 담당한다.
  • read_requesthdrs()는 헤더를 끝까지 읽어 요청 스트림을 정리한다.
  • parse_uri()는 URI를 분석해 정적 컨텐츠와 동적 컨텐츠를 구분한다.
  • serve_static()은 파일을 읽어 HTTP 응답으로 보낸다.
  • serve_dynamic()은 CGI 프로그램을 실행해 동적 응답을 만든다.
Tiny Web Server에서 main, accept, doit, read_requesthdrs, parse_uri, serve_static, serve_dynamic으로 이어지는 요청 처리 흐름
Tiny Web Server에서 요청 한 건이 처리되는 함수 흐름

Tiny가 작은 예제임에도 웹 서버의 핵심 구조를 거의 다 담고 있다는 점이 인상적이었다. 정적 컨텐츠는 서버가 직접 파일을 읽어 보내고, 동적 컨텐츠는 fork, execve, dup2, 환경변수를 이용해 CGI 프로그램의 실행 결과를 클라이언트에게 전달한다. 이 흐름을 이해하니 FastCGI, Flask, Tomcat 같은 구조가 왜 등장했는지도 조금 더 자연스럽게 연결됐다.

3. TCP 연결 과정과 웹 요청의 실제 흐름

TCP 3-way handshake는 “연결이 됐다”는 말을 더 구체적으로 이해하게 해준 주제였다. 클라이언트가 SYN을 보내고, 서버가 SYN + ACK로 응답하고, 클라이언트가 다시 ACK를 보내야 비로소 양방향 통신이 가능한 상태가 된다.

TCP 3-way handshake, 데이터 전송, FIN ACK 기반 연결 종료 흐름
TCP 연결은 handshake, data transfer, teardown 단계로 이어진다

이 과정의 목적은 단순히 인사를 나누는 것이 아니었다. 양쪽이 서로 데이터를 보낼 수 있는지 확인하고, 시퀀스 번호를 동기화하며, 과거에 지연된 패킷 때문에 잘못된 연결이 생기는 것을 막는다. 이후 데이터 전송과 연결 종료까지 생각하면, 웹 브라우저에서 주소를 입력하는 단순한 행동 뒤에 생각보다 많은 시스템 단계가 숨어 있다는 것을 알 수 있었다.

4/21 퀴즈에서 나온 DNS, TCP 연결, HTTP 요청/응답, 파일 디스크립터 문제도 이번 주 학습 내용을 점검하는 데 도움이 됐다. 특히 브라우저 주소창에 URL을 입력한 뒤 화면이 보이기까지의 과정을 단계별로 설명하는 문제는, 이번 주 배운 내용을 하나의 이야기로 묶어보는 좋은 연습이었다.

4. 동시성 프로그래밍과 서버 설계 고민

서버는 한 명의 클라이언트만 상대하지 않는다. 그래서 동시성 프로그래밍도 이번 주 중요한 주제였다. 프로세스, 스레드, I/O 멀티플렉싱은 모두 여러 연결을 처리하기 위한 방법이지만, 자원 사용 방식과 복잡도가 다르다.

I/O 멀티플렉싱은 하나의 프로세스나 스레드가 여러 파일 디스크립터를 감시하다가 이벤트가 발생한 것만 처리하는 방식이다. select, poll, epoll 같은 방식이 있고, 적은 자원으로 많은 연결을 처리할 수 있다는 장점이 있다. Redis, Nginx, Node.js 같은 시스템이 이런 구조와 연결된다는 점도 흥미로웠다.

반대로 스레드 기반 서버를 생각하면 공유 자원과 락 문제가 따라온다. 수요코딩 피드백에서도 스레드풀, bounded queue, 락을 거는 공유 자원, 로그와 메트릭, 에러 처리, 응답 지연 시간 같은 주제가 나왔다. 단순히 “동시에 처리한다”가 아니라, 어떤 자원을 공유하는지, 큐가 가득 찼을 때 어떻게 대응할지, 서버 상태를 어떻게 관찰할지까지 설계해야 한다는 점을 배웠다.

5. B+Tree Index와 설명 가능한 구현

수요코딩에서는 B+Tree 인덱스를 SQL Processor에 어떻게 연결했는지 정리했다. 실제 데이터는 배열에 저장하고, B+Tree는 id -> Record*를 연결하는 보조 인덱스 역할을 맡았다.

이 구조를 통해 WHERE id = ? 같은 단건 조회와 WHERE id >= ? 같은 범위 조회를 더 빠르게 처리할 수 있었다. 특히 범위 조회에서는 B+Tree로 시작 지점을 찾고 leaf link를 따라가면 되기 때문에, 배열 전체를 처음부터 끝까지 훑는 방식보다 효율적이다.

이번 주 피드백을 통해 느낀 점은 구현 자체보다 구현을 설명하는 방식도 중요하다는 것이다. 어려운 용어를 쓸 수밖에 없다면 그림이나 다이어그램으로 보완해야 하고, 어떤 공유 자원에 락을 걸었는지, 어떤 데이터 포맷으로 스레드와 메인 흐름이 소통하는지까지 명확히 보여줘야 한다.

6. Mini DBMS SQL API Server: Thread Pool, Queue, Lock

이번 주에 정리한 또 하나의 구현은 C 기반 in-memory SQL 엔진과 B+Tree를 HTTP API 서버로 감싼 Mini DBMS SQL API Server였다. 핵심은 SQL 엔진 자체를 멀티스레드용으로 크게 뜯어고친 것이 아니라, 서버 경계인 src/server/에서 요청 분산, 큐잉, 락, timeout, metrics를 관리하도록 만든 점이다.

Thread pool 기반 SQL API 서버 아키텍처와 요청 흐름
Thread pool 기반 SQL API 서버 아키텍처와 요청 흐름

구조를 나누면 다음과 같다.

영역 역할 대표 파일
Core DB Engine SQL 파싱/실행, users 테이블, B+Tree index 처리 src/core/sql.c, src/core/table.c, src/core/bptree.c
HTTP Server socket accept, bounded queue, worker thread pool, HTTP 응답 src/server/http_server.c
DB Server Boundary 공유 Table * 보관, SQL별 lock 선택, metrics 기록 src/server/db_server.c, src/server/db_server.h
Platform Wrapper pthread mutex, cond, rwlock, thread API 감싸기 src/server/platform.c, src/server/platform.h
API Layer HTTP request 파싱, JSON response 생성 src/server/api.c, src/server/api.h

서버는 요청이 올 때마다 thread를 새로 만들지 않는다. 시작할 때 정해진 수의 worker thread를 만들고, main accept thread는 client socket을 queue에 넣기만 한다. 기본 옵션은 --workers 4, --queue 16, --lock-timeout-ms 1000으로 잡았다. 그래서 동시에 처리 가능한 요청 수와 대기 가능한 요청 수를 서버 설정으로 제어할 수 있다.

HTTPRequestQueue는 bounded 원형 큐로 동작한다. main thread는 http_request_queue_push()로 새 socket을 넣고, worker thread는 http_request_queue_pop()으로 socket을 꺼낸다. queue가 비어 있으면 worker는 condition variable에서 대기하고, 새 요청이 들어오면 깨어난다. 반대로 queue가 가득 차면 요청을 무한히 쌓지 않고 즉시 503 queue_full을 반환한다. 이것이 이 서버에서의 backpressure이다.

공유 자원은 크게 두 가지로 나눴다.

공유 자원 보호 방식 이유
server->table PlatformRWLock db_lock SQL 실행 중 Table/B+Tree 구조가 깨지지 않도록 보호
server->metrics PlatformMutex metrics_mutex 여러 thread가 counter를 동시에 증가시키는 race condition 방지

src/core/의 SQL 엔진, Table, B+Tree는 자체적으로 lock을 알지 못한다. 따라서 여러 worker가 같은 Table *에 직접 접근하면 INSERTSELECT가 충돌하거나, B+Tree split 중 잘못된 포인터를 읽는 문제가 생길 수 있다. 이 프로젝트에서는 그 위험을 db_server_execute()에서 한 번에 통제한다.

SQL 문자열은 먼저 읽기와 쓰기로 분류한다.

SQL 종류 내부 분류 Lock
SELECT ... DB_SERVER_QUERY_KIND_READ read lock
INSERT ... DB_SERVER_QUERY_KIND_WRITE write lock
그 외 DB_SERVER_QUERY_KIND_NONE DB lock 없이 core가 오류 판정

정책은 단순하다. SELECT끼리는 read lock을 공유하므로 동시에 실행할 수 있고, INSERT는 Table row 배열, next_id, B+Tree index를 바꾸기 때문에 write lock으로 독점 실행한다. mutex 하나만 써도 안전성은 얻을 수 있지만, 그렇게 하면 읽기 요청끼리도 모두 순차 실행된다. read/write lock을 쓴 이유는 읽기 중심 workload에서 SELECT 병렬성을 살리기 위해서였다.

SELECT와 INSERT 요청 처리 시퀀스
SELECT와 INSERT 요청은 같은 API 경로로 들어오지만 lock 단계에서 다르게 처리된다

lock을 기다릴 때도 무한 대기하지 않는다. 현재 구현은 blocking lock을 한 번 호출하는 대신 try lock → 실패하면 1ms sleep → 다시 try lock을 반복한다. --lock-timeout-ms를 넘으면 SQL을 실행하지 않고 503 lock_timeout을 반환하며, metrics에는 total_lock_timeouts를 증가시킨다. 이 덕분에 worker thread가 DB lock 앞에서 무한히 묶이는 상황을 줄이고, 클라이언트도 서버가 멈춘 것처럼 기다리는 대신 명확한 실패 응답을 받는다.

metrics counter도 별도 mutex로 보호한다. counter++는 C에서 atomic 연산이 아니기 때문에 여러 worker가 동시에 증가시키면 값이 유실될 수 있다. 그래서 DB 자체를 보호하는 db_lock과 관측 지표를 보호하는 metrics_mutex를 분리했다. 이 분리 덕분에 metrics 갱신 때문에 DB read/write lock을 오래 잡지 않아도 된다.

이 설계를 발표에서 설명한다면 다음 한 문장이 핵심이다.

이 프로젝트는 기존 SQL 엔진을 thread-safe하게 직접 수정하지 않고, 서버 경계에서 thread pool, bounded queue, read/write lock을 적용해 외부 HTTP 요청을 안전하게 병렬 처리하도록 만든 구조입니다.

정리하면 이 프로젝트의 동시성 설계는 세 단계로 나눌 수 있다.

단계 해결하는 문제
Thread pool 요청마다 thread를 만들지 않고 제한된 worker로 병렬 처리
Bounded queue 서버가 감당 못 할 요청을 무한히 쌓지 않고 503 queue_full로 제어
Read/write lock 공유 DB는 안전하게 보호하면서 SELECT끼리는 병렬 실행

주의할 점도 있다. README 일부 설명에는 phase gate라는 표현이 있지만, 현재 코드 기준으로는 별도의 writer 우선 phase gate 구조체가 구현되어 있지는 않다. 실제 구현은 pthread_rwlock_tryrdlock(), pthread_rwlock_trywrlock()을 wrapper로 감싼 뒤 timeout loop를 도는 방식이다. 따라서 “별도 phase gate로 writer starvation을 직접 방지한다”는 표현보다는 “read/write lock과 try-lock timeout으로 공유 Table 접근을 제어한다”는 표현이 더 정확하다.

면접이나 발표에서 예상되는 질문도 몇 가지로 정리할 수 있다.

질문 답변 요지
왜 thread pool을 사용했나요? 요청마다 thread를 만들면 생성 비용이 크고 요청 폭주 시 thread 수가 제한 없이 늘어날 수 있다. worker 수를 고정하면 동시 처리량과 자원 사용량을 제어할 수 있다.
queue가 왜 필요한가요? accept한 socket을 worker에게 전달하는 중간 버퍼가 필요하다. bounded queue를 쓰면 처리 한계를 넘는 요청에 503 queue_full로 backpressure를 줄 수 있다.
mutex 하나로 처리하면 안 되나요? 가능하지만 모든 SQL이 순차 실행된다. SELECT끼리는 데이터를 변경하지 않으므로 read/write lock으로 SELECT 병렬성과 INSERT 안전성을 함께 얻었다.
INSERT는 왜 write lock인가요? INSERT는 Table.rows, next_id, B+Tree index를 변경한다. 동시에 실행되면 id 증가나 index 갱신이 충돌할 수 있어 독점 실행해야 한다.
lock timeout은 왜 필요한가요? lock을 무한 대기하면 worker가 계속 묶여 서버가 멈춘 것처럼 보일 수 있다. timeout을 두면 일정 시간 이후 503 lock_timeout으로 실패하고 metrics로 관찰할 수 있다.
core SQL 엔진은 thread-safe한가요? core 자체가 thread-safe하게 설계된 것은 아니다. 대신 DBServer 경계에서 같은 Table *에 접근하기 전에 lock을 잡도록 만들어 thread-safe하게 사용한다.

이번 주에 배운 점

이번 주의 핵심은 웹 서버를 “마법처럼 동작하는 프로그램”이 아니라, 파일 디스크립터와 문자열 프로토콜, 프로세스, 스레드, 큐, 락이 조합된 시스템으로 보는 관점을 얻은 것이다.

CS:APP 11장을 따라가면서 네트워크 요청이 코드에서 어떤 함수 호출로 표현되는지 이해했고, Tiny Server를 통해 HTTP 요청이 실제 서버 함수들 사이를 어떻게 흐르는지 정리했다. TCP handshake와 DNS까지 연결하니 브라우저에서 주소를 입력하는 행동도 더 이상 추상적인 일이 아니었다.

동시에, 구현을 잘하는 것과 설명을 잘하는 것은 다르다는 점도 느꼈다. B+Tree 인덱스나 thread pool 기반 SQL API 서버처럼 구조가 있는 구현은 성능 수치만 보여주는 것보다, 어떤 문제를 해결하려고 도입했고 어떤 흐름으로 동작하는지 보여주는 것이 더 중요했다.

아쉬웠던 점과 다음 액션

아쉬운 점은 Tiny Server와 동시성 서버를 아직 완전히 내 코드처럼 설명할 정도로 반복하지는 못했다는 것이다. 함수 흐름은 정리했지만, 직접 코드를 다시 작성해보거나 오류 상황을 넣어가며 테스트하는 과정은 더 필요하다.

다음 주에는 네트워크 프로그래밍을 단순 학습에서 구현으로 더 끌고 가고 싶다. 특히 proxy server를 구현하면서 요청 헤더를 어떻게 재구성할지, 여러 클라이언트를 어떻게 처리할지, 로그와 에러 처리를 어떻게 남길지까지 같이 고민해볼 계획이다.

이번 8주차는 웹 서버를 이해하기 위한 기반을 다진 주였다. 이제부터는 이 기반 위에서 실제로 더 안정적인 서버를 구현하고, 내가 만든 구조를 다른 사람에게 설명할 수 있는 수준으로 끌어올리는 것이 목표다.