라봉이의 개발 블로그

prefork 기반 웹서버가 요청을 동시에 처리할 수 있는 이유 본문

컴퓨터 사이언스

prefork 기반 웹서버가 요청을 동시에 처리할 수 있는 이유

Labhong 2022. 7. 25. 00:42
반응형

서론

제가 현재 재직 중인 회사에서는 웹 어플리케이션 서버를 개발하기 위해 Django 스택을 사용하고 있습니다.

Django는 Web Server가 아니고 Web framework이기 때문에 django를 단순히 python으로 실행한다고 서버 어플리케이션이 되는게 아닙니다. 때문에 Django를 동시 다발적으로 실행해 여러 클라이언트의 요청을 받아 Django에게 요청을 넘길 수 있는 WSGI 서버를 사용해야 합니다. 그렇기 때문에 저희 회사는 Django와 Gunicorn을 같이 사용하고 있습니다.

gunicorn은 python의 prefork 방식의 웹 서버입니다. prefork란 이름 그대로 http 요청을 처리하기 위해 미리 자식 프로세스를 여러개 띄워서(fork) 동시에 처리하는 방식을 의미합니다. 따라서 gunicorn을 사용하면 django 프레임워크를 사용하는 다수의 자식 프로세스가 동시에 띄워지게 됩니다.

그런데 gunicorn을 사용해보면서 아래의 궁금한 점이 생겼습니다.

  1. tcp 요청을 처리하기 위해서는 ip와 port가 필요한데 각각의 자식 프로세스들은 ip와 port를 할당받지 않고 어떻게 클라이언트와 통신하는지?
  2. 한번에 몰려오는 다수의 요청들을 다수의 자식 프로세스에게 어떻게 분배되는지?

웹서버가 클라이언트와 통신하기 위해선 ip와 port가 필요합니다. 그런데 어찌된 영문인지 자식 프로세스들을 위한 ip와 port를 따로 설정해주지 않습니다. 게다가 동시다발적으로 들어오는 요청을 자식 프로세스들이 어떻게 선점해서 처리하는지도 궁금해졌습니다. 그래서 먼저 prefork 방식이 무엇인지 알아보고 궁금증을 해결해보도록 하겠습니다.

prefork 방식

prefork 방식은 이전에 설명했듯이 클라이언트의 요청을 처리하기 전에 미리 fork() 시스템 콜을 사용해 자식 프로세스 풀을 만듭니다. 그 후에 각 자식 프로세스마다 클라이언트의 요청을 배정받아 처리하는 구조입니다.

💡 fork 시스템 콜

새로운 프로세스를 생성하는 시스템 콜 입니다. 새롭게 생성된 자식 프로세스는 새로운 PID를 갖게되며 호출한 부모 프로세스를 그대로 복사합니다. 복사를 통해 자식 프로세스는 부모와 완전히 독립된 물리 메모리 공간을 갖습니다.

요청의 분배

자식 프로세스를 미리 여러개를 만들어 놓는다면 클라이언트의 요청이 물리적인 서버로 전달된 경우 누군가는 클라이언트의 요청을 자식 프로세스로 분배해야 합니다. 저는 당연하게도 부모 프로세스가 마치 load balancer 처럼 모든 클라이언트의 요청이 부모 프로세스로 전달되고 자식 프로세스에게 요청을 분배해서 전달할 줄 알았습니다.

하지만! 부모 프로세스의 역할은 단순히 자식 프로세스 풀을 관리할 뿐, 요청을 분배하는 역할을 수행하지 않습니다. 그렇다면 클라이언트의 요청은 어떻게 분배되어 자식 프로세스가 개별적으로 처리할 수 있는 걸까요?

요청이 어떻게 분배되는지 이해하기 위해선 소켓(Socket) 통신에 대해 이해해야 합니다.

소켓 통신

서버 소켓, 클라이언트 소켓

소켓(Socket) 통신은 두 프로그램이 네트워크를 통해 서로 통신을 수행할 수 있도록 양쪽에 생성되는 링크의 단자입니다.

서버에서는 통신을 위해 소켓을 생성하게 됩니다. 이를 서버 소켓(Server Socket)이라고 하겠습니다. 서버 프로세스가 서버 소켓을 생성할 때는 주소(ip)포트(port)결합(bind)한 뒤에 요청 대기(listen)함으로써 클라이언트의 요청을 수신할 수 있는 상태가 됩니다.

클라이언트도 마찬가지로 소켓을 생성해서 서버와 연결을 시도합니다. 클라이언트와 서버가 연결되면 클라이언트의 연결 정보는 시스템 내부적으로 관리되는 요청 대기 큐(accept queue)에 쌓이게 되는데, 이 시점에서 서버와 클라이언트의 연결 상태는 established 상태입니다. 대기 중인 연결 요청을 큐(queue)로 부터 꺼내와서 연결을 완료하기 위해서는 수락(accept)을 진행해야 합니다.

클라이언트와 서버 간에 연결이 수락(accept)되고 수립(established) 상태가 되면 새로운 소켓을 생성하게 되는데 이를 클라이언트 소켓(Client Socket)이라고 부르겠습니다. 이 클라이언트 소켓을 통해 클라이언트의 요청을 읽거나 클라이언트에게 데이터를 전송할 수 있게 됩니다.

정리하면 서버 소켓과 클라이언트 소켓의 역할을 다음과 같이 정리할 수 있습니다.

서버 소켓

  • ip와 port를 사용해 클라이언트와 통신이 가능하도록 설정
  • 클라이언트와 연결 수립 후 클라이언트 소켓 생성

클라이언트 소켓

  • 서버와 클라이언트가 서로 연결될 때 생성됨
  • 데이터 송수신 처리를 담당

요청 대기 큐

서버와 클라이언트가 네트워크 연결이 되면 클라이언트 연결 요청에 대한 정보는 운영체제(OS) 내부적으로 관리되는 큐(queue)에 쌓이게 되는데, 이를 요청 대기 큐(accept queue)라고 합니다.

이 요청 대기 큐에는 서버 어플리케이션에서 클라이언트와 데이터를 주고받을 수 있는 established 상태인 연결 정보가 저장됩니다.

💡 accept queue, syn queue?

사실 내부적으로 관리되는 큐를 부르는 명칭은 명확하게 정의된 게 없지만 cloudflare 블로그에서 정의한대로 부르도록 하겠습니다.

클라이언트에서 서버 소켓으로 연결이 요청될 때마다 요청 대기 큐에 연결이 쌓이게 됩니다. 자식 프로세스는 이 요청 대기 큐에서 accept() 시스템 콜을 통해 클라이언트의 요청을 하나씩 꺼내오게 됩니다.

자식 프로세스가 클라이언트 요청을 처리하는 방법

클라이언트와의 연결을 수립하기 위한 서버 소켓, 그리고 데이터를 송수신하기 위한 클라이언트 소켓. 이 두 개의 소켓은 같은 소켓이지만 서로 다른 역할을 수행합니다.

그런데 만약 각 자식 프로세스가 서버 소켓의 정보를 가지고 있고, 각 자식 프로세스가 서버 소켓을 이용해 클라이언트 소켓을 만들어낸다면 어떻게 될까요? 맞습니다. 자식 프로세스들은 1개의 ip와 port를 사용하면서 클라이언트와 데이터를 송수신 할 수 있게 됩니다.

먼저 부모 프로세스는 서버 소켓을 생성한 뒤에 fork 시스템 콜을 사용해 자식 프로세스를 생성합니다. 이렇게 되면 자식 프로세스의 메모리 공간에 같은 서버 소켓을 가지게 됩니다. 각 자식 프로세스는 서버 소켓을 사용해 클라이언트와 연결이 accept될 수 있도록 기다리게 됩니다. 클라이언트와 연결이 수립되면 각 자식 프로세스는 클라이언트 소켓을 생성하게 되고 이를 통해 클라이언트와 통신할 수 있게 됩니다.

이해를 돕기 위해 위의 프로세스를 하나의 그림으로 표현했습니다.

  • 부모 프로세스가 서버 소켓을 생성 및 요청 대기 상태(listen)로 진입
  • 클라이언트가 서버로 요청을 보냄. 연결 정보는 요청 대기 큐에 쌓임.
  • 부모 프로세스가 fork를 통해 자식 프로세스를 생성. 자식 프로세스들은 같은 서버 소켓을 가지게 됨
  • 각 자식 프로세스의 서버 소켓이 연결을 accept하면서 요청 대기 큐에서 가져온 연결 정보를 가지고 클라이언트 소켓을 생성
  • 각 자식 프로세스는 클라이언트를 통해 요청을 분산 처리할 수 있게 됨

마무리 지으면서

서론에서 궁금했던 점이 해결됐습니다.

자식 프로세스들은 ip와 port를 할당받지 않고 어떻게 클라이언트와 통신하는지?

자식 프로세스는 부모 프로세스가 가지고 있던 서버 소켓 정보를 복사해서 각각 가지고 있기 때문에 이를 통해서 클라이언트와 통신을 주고 받을 수 있게 됩니다.

한번에 몰려오는 다수의 요청은 다수의 자식 프로세스에게 어떻게 분배되는지?

다수의 요청은 요청 대기 큐에 쌓이게 되며 자식 프로세스는 각자 가지고 있는 서버 소켓을 이용해 요청 대기 큐에 있는 클라이언트 요청 정보를 꺼내와서 각자 처리할 수 있게 됩니다.

경합이 일어나지 않을까?

위의 구조에서 의문점이 하나 들 수가 있습니다. prefork 모델에서 자식 프로세스가 여러개일 때, 각 자식 프로세스들이 요청 대기 큐에 접근해서 클라이언트의 요청을 가져올 때 프로세스 간 경합이 일어나지 않을까?

결론적으로 말하자면 경합이 일어나지 않습니다. 여러 개의 자식 프로세스가 accept 시스템 콜을 호출해도 운영체제 커널에 의해 관리되기 때문에 1개의 자식 프로세스만 요청 대기 큐에 접근할 수 있고 나머지는 새로운 클라이언트 요청이 오기 전까지 block 상태에서 대기하게 됩니다.

이런 특성을 이용해 prefork 방식은 프로세스 풀을 만들어 컴퓨팅 리소스를 아낄 뿐만 아니라, 클라이언트 요청을 각 자식 프로세스에서 분산 처리할 수 있는 환경이 만들 수 있게 됩니다.

반응형
Comments