블로그로 돌아가기

[Proxy-lab] 프록시 서버 구현: 순차 처리에서 동시성 처리까지

3분 소요
[Proxy-lab] 프록시 서버 구현: 순차 처리에서 동시성 처리까지

프록시(Proxy) 서버란?

지난 포스트에서 Tiny 웹 서버를 분석했다면, 이번엔 프록시 서버​​다.

프록시(Proxy)는 '대리인'이라는 뜻이다.

인터넷 세상에서 클라이언트(웹 브라우저)와 서버(네이버, 구글 등) 사이에서 중계​ 역할을 해주는 서버를 말한다.

왜 굳이 중간에 낄까?

  • ​캐싱(Caching)​: 자주 찾는 데이터를 저장해두었다가 빠르게 준다.

  • ​보안/익명성​: 클라이언트의 진짜 IP를 숨기거나, 유해 사이트를 차단할 수 있다.

이번 과제의 목표는 ​HTTP/1.0 GET 요청​을 처리하는 간단한 프록시 서버를 만드는 것이다.

기본 뼈대: 순차적 프록시 (Sequential Proxy)

가장 먼저 할 일은 "받아서, 넘겨주고, 다시 받아서, 돌려주는" 기본 흐름을 만드는 것이다.

  1. ​클라이언트 요청 수신​: 브라우저가 프록시에게 GET http://www.google.com/index.html HTTP/1.1 같은 요청을 보낸다.

  2. ​파싱(Parsing)​: 요청을 뜯어서 호스트(www.google.com), 포트(80), 경로(/index.html)를 알아낸다.

  3. ​서버 연결​: 알아낸 호스트와 포트로 소켓을 열고 연결한다. (open_clientfd)

  4. ​요청 전달: 서버에게 GET /index.html HTTP/1.0 형태로 가공해서 보낸다.

    • 주의: HTTP/1.1 요청이 와도 서버에겐 1.0으로 바꿔서 보내는 게 과제 규칙이다.

    • Host, User-Agent, Connection: close 등의 필수 헤더도 챙겨서 보내줘야 한다.

  5. 응답 전달​​: 서버가 데이터를 보내주면, 그걸 그대로 클라이언트에게 토스한다.

/* 핵심 로직 요약 */
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을 리턴하고 errnoEPIPE로 설정할 뿐, 프로세스가 죽지는 않는다.

우리는 그냥 write 에러 처리를 해서 함수를 종료하면 그만이다.