Back

웹서브

42서울 2023년 8월
웹서브
내 역할
구조 설계, Config 파싱, HTTP 파싱, 디버깅 로그 작성, 코드 리펙토링, 동작 확인 페이지 구현
허진호
안현준
임윤선
타임라인
4달, 2023년 8월 시작
사용 기술
C++98, Makefile, HTML, CSS, Object Oriented Programming, Non-block I/O, I/O Multiplexing (kqueue), HTTP/1.1
개요
웹서브는 42서울 프로젝트 중 하나로 C++98로 구현된 HTTP/1.1 호환 웹 서버를 구현하는 프로젝트입니다. 이 프로젝트는 저수준 네트워크 프로그래밍에 대한 숙련도와 HTTP 프로토콜에 대한 이해, 그리고 논블로킹 I/O 작업과 효율적인 요청 처리의 구현을 요구합니다.
주요 기능으로는 kqeue() (또는 이와 동등한 기능)를 사용한 I/O 멀티플렉싱이 있어, 서버가 절대 블로킹되거나 무한정 멈추지 않도록 보장합니다. 또한 GET, POST, DELETE 등 여러 HTTP 메서드를 지원하면서 정적 웹사이트를 제공 해야합니다.
이러한 고급 개념들을 구현함으로써, 웹서브는 웹 서버 아키텍처를 이해하기 위한 실용적인 학습 도구로서의 역할뿐만 아니라, 고성능 네트워크 애플리케이션을 만드는 데 있어 개체지향 프로그래밍 언어인 C++의 강력함과 유연성을 보여줍니다.
HIGHLIGHTS
C++98로 구현된 HTTP/1.1 호환 웹서버입니다.
THE PROBLEM
Nignx를 참고해서 만들면 되지 않을까?
Nignx 구조
처음에는 Nginx 구조를 참조해 마스터 프로세스가 워커 프로세스를 생성하고 워커 프로세스에서는 각 쓰레드에 따라서 신호를 등록하고 응답을 보내는 멀티 프로세스 멀티 스레드 구조를 생각하고 코드를 작성했습니다.
하지만 구조를 거의 다 작성했을 때 쯤, 프로세스는 하나만 사용해야 한다는 사용자 요구 사항을 발견해 결국 설정파일 파싱만 Nginx를 참고해서 만드는 것에 만족해야 했습니다.
webserv master process flow
webserv worker process flow
ARCHITECTURE
웹서버의 구조는 어떻게 되어야 하는가?
하나의 프로세스
하나의 프로세스에서 kqueue를 통해 이벤트를 관리하고 핸들링합니다.
설정파일을 토대로 포트를 확인하고 연결합니다. 이벤트는 kqueue에서 관리되고 상속된 클래스에 따라 Read, Write, CGI 등으로 나누어져서 처리가 됩니다.
응답이 들어오면 HTTP 형식에 맞춰서 파싱이 되며 그에 맞는 요청을 처리하고 응답을 내보냅니다.
webserv flow
CONFIG
Nignx와 유사한 설정 파일 파싱 구조.
Webserv config flow
Config Tree
설정 파일은 {}, ;를 기준으로 블럭 형식으로 되어있고, 감싸고 있는 괄호를 부모라고 한다면 자식요소는 부모 요소의 설정을 반영합니다.
그렇기 때문에 {}와 ;를 기준으로 트리를 구성한다면 location 블럭이 수 없이 들어가도 부모 요소의 모든 설정을 다 반영할 수 있습니다.
class Common {
public:
  static int mKqueue;       // Description for mKqueue
  static bool mRunning;     // Description for mRunning
  static Node *mConfigTree; // Root node for some configuration tree (assuming from the name)
  static ConfigMap *mConfigMap; // A complex map with described structure
};
트리와 Map은 Common 클래스에서 전역으로 관리됩니다.
Common.cpp
WebServer.cpp
Node.cpp
example.conf
Webserv config tree
Config Map
토큰을 기준으로 트리가 생성되었다면, location을 기준으로 Map을 생성해줍니다. 트리에서 location이 어디에 위치해있는지 기억하기만 한다면 들어온 location을 찾으려고 트리를 다 검색할 필요없이 location 위치에서 부모요소를 타고 올라가면서 설정들을 확인만 하면 되기 때문입니다.
class ConfigMap {
public:
  typedef std::map<std::string, Node *> UriMap;
  typedef std::multimap<std::string, UriMap>
      HostnameMap; // Allows duplicate hostnames

  ConfigMap(Node *configTree);
	//...

private:
  class PortMap {
	//...
  private:
    HostnameMap mHostnameConfigs;
    UriMap *mDefaultServer; // pointer to default server config
    bool mbDefaultServerSet;
  };

  std::map<int, PortMap> mPortConfigs;
};
port, server_name, location을 기준으로 Map을 생성합니다. MultiMap을 사용함으로서. 트리를 다 순회하면서 location 노드를 찾을 필요없기때문에 접근시간을 O(N)에서 O(logN)으로 줄였습니다.
std::unordered_map(hashmap)을 사용하면 O(1)로 더 줄일 수 있었지만, c++98에서는 해당 STL을 제공하지 않아 사용할 수 없었습니다.
ConfigMap.hpp
ConfigMap.cpp
example.conf
Http.cpp
REQUEST&&RESPONSE
서버에서 요청을 받고 응답을 하는 과정.
요청 수신
서버는 recv() 함수를 통해 클라이언트로부터 데이터를 수신하고, 이 데이터를 각각의 Connection 객체가 소유한 Http 객체로 전달하여 HTTP 요청 파싱을 시작합니다.
응답
파싱된 요청이 HTTP 요청인 경우, 서버는 Router를 통해 HTTP 메서드(GET, POST, DELETE)에 맞는 핸들러를 호출합니다.
각 메서드는 HTTP/1.1 프로토콜을 준수하여 규칙에 맞는 적합한 비즈니스 로직을 처리한 후 적절한 Response 메시지를 생성하여 클라이언트에 응답합니다.
class Http {
public:
  Http(int socket, int port, std::string &sendBuffer, bool &keepAlive,
       int &remainingRequest);
//...
private:
  std::string mBuffer;
  Request mRequest;
  Response mResponse;
  RequestParser mRequestParser;
  ResponseParser mResponseParser;

  int mPort;
  int mSocket;
  bool &mKeepAlive;
  int &mRemainingRequest;
  std::string &mSendBufferRef;
  std::vector<SharedPtr<CGI> > mCGIList;
};
Http.hpp
Http.cpp
CGI 요청 처리
CGI 요청인 경우, 서버의 메인 프로세스는 fork() 시스템 호출을 통해 자식 프로세스를 생성하고, 자식 프로세스는 execve()를 통해 CGI 스크립트를 실행합니다.
위와같은 방식은 CGI 요청이 메인 프로세스의 성능에 영향을 주지 않도록 설계된 비동기 처리 방식입니다.
Webserv request response flow
HTTP PARSER
HTTP 요청을 파싱하는 방법.
HTTP 요청 처리
파싱 과정에서 요청의 유효성을 검증한 후, 유효한 요청인 경우에만 다음 단계로 넘어갑니다. 요청은 한 글자씩 세밀하게 분석되어 상태에 따라 처리됩니다.
Response 메시지 또한 HTTP/1.1 프로토콜을 준수하여 상태코드,헤더,본문 등과 같은 내용들로 메시지를 구성하여 생성합니다.
void Http::SetRequest(eStatusCode state, std::vector<char> &RecvBuffer) {
	//...
  while (true) {
    eStatusCode ParseState = mRequestParser.Parse(
        mRequest, mBuffer.c_str(), mBuffer.c_str() + mBuffer.size());

    if (ParseState == PARSING_INCOMPLETED) {
	    //...
    } else if (ParseState == PARSING_COMPLETED) {
	    //...
    } else {
	    //...
    }
  }
}
요청이 한번에 다 들어오는 것이 아닌 잘려서 들어오거나 겹쳐서 들어오기 때문에 while을 돌면서 mBuffer에 있는 값을 파싱해줍니다.
Http.hpp
Enum.hpp
RequestParser.cpp
ResponseParser.hpp
TEST
만든 웹서버가 작동을 잘 하는가?
테스트 방법
여러 location으로 redirect가 잘 되는지, 수천번의 요청에도 서버가 종료되지 않고 잘 작동을 하는지, 크기가 max를 넘어간 요청을 보내도 종료되지는 않는지, 클라이언트가 접속을 끊었을때 소켓이 잘 닫히는지, 올바르지 않은 요청을 보냈을 때 오류를 잘 내보내는지 등을 확인합니다.