[Proxy-lab] 프록시 서버 구현: 순차 처리에서 동시성 처리까지
![[Proxy-lab] 프록시 서버 구현: 순차 처리에서 동시성 처리까지](https://firebasestorage.googleapis.com/v0/b/cruz-lab.firebasestorage.app/o/images%2Fheroes%2Fhero-1766488058292.webp?alt=media&token=81cc6548-5de0-4d49-8ff3-6443083a095e)
프록시(Proxy) 서버란?
지난 포스트에서 Tiny 웹 서버를 분석했다면, 이번엔 프록시 서버다.
프록시(Proxy)는 '대리인'이라는 뜻이다.
인터넷 세상에서 클라이언트(웹 브라우저)와 서버(네이버, 구글 등) 사이에서 중계 역할을 해주는 서버를 말한다.
왜 굳이 중간에 낄까?
-
캐싱(Caching): 자주 찾는 데이터를 저장해두었다가 빠르게 준다.
-
보안/익명성: 클라이언트의 진짜 IP를 숨기거나, 유해 사이트를 차단할 수 있다.
이번 과제의 목표는 HTTP/1.0 GET 요청을 처리하는 간단한 프록시 서버를 만드는 것이다.
기본 뼈대: 순차적 프록시 (Sequential Proxy)
가장 먼저 할 일은 "받아서, 넘겨주고, 다시 받아서, 돌려주는" 기본 흐름을 만드는 것이다.
-
클라이언트 요청 수신: 브라우저가 프록시에게
GET http://www.google.com/index.html HTTP/1.1같은 요청을 보낸다. -
파싱(Parsing): 요청을 뜯어서 호스트(
www.google.com), 포트(80), 경로(/index.html)를 알아낸다. -
서버 연결: 알아낸 호스트와 포트로 소켓을 열고 연결한다. (
open_clientfd) -
요청 전달: 서버에게
GET /index.html HTTP/1.0형태로 가공해서 보낸다.-
주의: HTTP/1.1 요청이 와도 서버에겐 1.0으로 바꿔서 보내는 게 과제 규칙이다.
-
Host,User-Agent,Connection: close등의 필수 헤더도 챙겨서 보내줘야 한다.
-
-
응답 전달: 서버가 데이터를 보내주면, 그걸 그대로 클라이언트에게 토스한다.
/* 핵심 로직 요약 */
void doit(int client_fd) {
// 1. 클라이언트 요청 읽기
Rio_readinitb(&rio, client_fd);
Rio_readlineb(&rio, buf, MAXLINE);
parse_uri_proxy(uri, hostname, port, path);
// 2. 엔드 서버에 연결
server_fd = Open_clientfd(hostname, port);
// 3. 엔드 서버에 요청 전송 (헤더 가공 포함)
Rio_writen(server_fd, request_buf, strlen(request_buf));
// 4. 엔드 서버 응답 받아서 클라이언트에 전달
while ((n = Rio_readnb(&server_rio, buf, MAXLINE)) > 0) {
Rio_writen(client_fd, buf, n);
}
}
여기까지 만들면 "나 혼자만" 쓸 수 있는 프록시 서버가 된다.
하지만 다른 사람이 접속하려고 하면? 내가 구글 페이지를 다 로딩할 때까지 다른 사람은 무한 대기 상태에 빠진다. 😱
이것이 순차 처리(Iterative)의 한계다.
동시성(Concurrency) 도입: 멀티스레딩 🧵
여러 명이 동시에 쓸 수 있게 하려면 동시성을 지원해야 한다.
프로세스(fork)를 쓸 수도 있고, I/O 멀티플렉싱을 쓸 수도 있지만, 우리는 **스레드(Thread)**를 사용했다.
스레드는 프로세스보다 가볍고 데이터 공유가 쉽다. (물론 그만큼 동기화 문제가 따르지만...)
void *thread(void *vargp) {
int connfd = *((int *)vargp);
Pthread_detach(pthread_self()); // 스레드 분리 (자원 자동 회수)
Free(vargp); // 인자 메모리 해제
doit(connfd); // 아까 그 로직 실행
Close(connfd);
return NULL;
}
int main(...) {
// ...
while (1) {
connfd = Malloc(sizeof(int)); // 경쟁 상태 방지!
*connfd = Accept(...);
Pthread_create(&tid, NULL, thread, connfd);
}
}
💡 주의할 점:
Race Condition과Malloc처음에
Pthread_create를 할 때connfd의 주소(&connfd)를 그냥 넘겼다가 큰 코 다칠 뻔했다.메인 스레드가
Accept를 다시 호출해서connfd값이 바뀌어버리면, 이미 생성된 스레드가 바뀐 값을 참조하게 된다! (경쟁 상태)그래서
Malloc으로 힙에 별도 공간을 파서 값을 복사해 넘겨주고, 스레드 내부에서Free하는 방식을 썼다. 아주 클래식한 해결법이다.
💡 주의할 점:
pthread_detach생성된 스레드는 기본적으로 joinable 상태다.
즉, 누군가(
pthread_join) 기다려주지 않으면 종료되어도 자원이 남는다(좀비 스레드🧟).우리는 메인 스레드가 자식 스레드를 일일이 기다려줄 여유가 없으므로, 스레드 시작하자마자
pthread_detach(pthread_self())를 호출해서 detached 상태로 만든다.이렇게 하면 스레드가 종료될 때 알아서 커널이 자원을 싹 회수해간다.
트러블 슈팅: SIGPIPE
테스트를 하다가 서버가 툭하면 죽어버리는 현상이 발생했다.
원인은 SIGPIPE 시그널이었다.
클라이언트가(브라우저가) 요청을 보내놓고, 답을 다 받기도 전에 연결을 끊어버리는 경우가 있다. (새로고침 연타라던가...)
이때 우리 프록시 서버는 끊긴 소켓에 대고 write를 시도하게 되는데, 그러면 커널은 "야, 파이프 끊어졌어!"라며 SIGPIPE 시그널을 날린다.
기본 동작은 프로세스 종료다. 그래서 서버가 죽었던 것이다.
해결책은 간단하다. 무시하면 된다.
Signal(SIGPIPE, SIG_IGN);
이렇게 설정해두면 write 함수가 -1을 리턴하고 errno를 EPIPE로 설정할 뿐, 프로세스가 죽지는 않는다.
우리는 그냥 write 에러 처리를 해서 함수를 종료하면 그만이다.