포스트

H2 Server

리눅스 환경에서 Modern C++(C++17)을 활용하여 개발한 epoll, io_uring, mmap, NGHTTP2, OpenSSL 기반의 고성능 http/2 서버

H2 Server
구분내용
플랫폼Linux
개발 도구 및 사용 언어g++ 11.4.0, C++, nghttp2 v1.60.0, OpenSSL v3.0.2, liburing2
개발 기간2025년 3월 ~ 2025년 10월
저장소바로 가기

1. 개요

oveview

h2-serverModern C++(C++17) 기반의 고성능 HTTP/2 서버 프로젝트이다.
Linux 환경에서 epoll(Edge-Triggered) 기반 비동기 I/O를 처리하고, nghttp2 + OpenSSL을 통해 HTTP/2 요청/응답을 처리한다.
또한 H2C(평문) 모드, mmap 기반 파일 캐시, 멀티스레드 워커 구조를 통해 저지연 파일 서비스와 고부하 환경에서의 처리량 확장을 목표로 개발했다.

이 프로젝트는 단순히 HTTP/2 기능을 구현하는 데 그치지 않고,
이벤트 기반 I/O, TLS 처리, 멀티스레드 확장성, 로드 밸런싱, 정적 분리(static partitioning), completion 기반 처리 모델과 같은 시스템 레벨 설계를 단계적으로 실험·검증하는 데 초점을 두고 발전시킨 개인 프로젝트이다.

초기에는 acceptor → load balancer → worker 구조를 중심으로 한 origin 브랜치를 통해 HTTP/2, TLS, mmap 캐시, 멀티스레드 처리 구조의 기본 타당성을 검증했다.
이후에는 워커 책임과 처리 경로를 더 명확히 분리하고, 특히 H2C 경로에 completion 모델을 도입한 static-partitioning 브랜치를 통해 고부하 환경에서의 처리량 개선을 실험했다.

이 글은 기본적으로 origin 브랜치의 설계와 구현을 상세히 설명하되,
후반부에서는 static-partitioning 브랜치에서의 구조 변화와 최신 성능 결과까지 함께 정리한다.

1.1. 브랜치 개요

이 프로젝트는 하나의 완성된 구현만을 목표로 하기보다,
고성능 HTTP/2 서버 아키텍처를 단계적으로 실험하고 비교·검증하는 과정에 초점을 맞춰 발전시켰다.

origin

  • acceptor → load balancer → worker 구조를 기반으로 한 초기 고성능 설계
  • HTTP/2, TLS, 멀티스레드 처리, 파일 서비스, mmap 캐시의 기본 구조와 성능 특성을 검증한 베이스라인 브랜치
  • 현재 블로그 본문의 구조 설명과 기존 상세 벤치마크는 주로 이 브랜치를 기준으로 한다.

static-partitioning

  • 워커 책임과 처리 경로를 더 명확히 분리하기 위해 정적 분리(static partitioning)를 적용한 브랜치
  • readiness 중심 접근의 한계를 보완하기 위해 H2C 경로에 completion 모델을 도입
  • 최신 구조 개선과 최신 성능 실험 결과가 반영된 브랜치이다.

즉, origin이 서버의 기본 구조와 멀티스레드 확장성의 출발점이었다면,
static-partitioning은 그 위에서 처리 경로를 더 단순하고 고정적으로 다듬어 고부하 throughput을 끌어올리는 실험 단계라고 볼 수 있다.

최신 브랜치 스냅샷 (static-partitioning)

Ubuntu 24.04 환경에서 수행한 최신 benchmark 기준으로,

  • 4스레드 이상 구간에서 h2server h2ch2server h2c-io-uringnghttpd를 추월
  • 최고 성능은 h2server_h2c_io_uring_n8
  • Max QPS: 605,540
  • static partitioning 및 completion 모델이 고부하 구간에서 의미 있는 처리량 개선을 보여줌

아래 본문에서는 먼저 origin 기준 구조와 설계 배경을 설명하고, 후반부에서 static-partitioning 결과를 이어서 정리한다.

1.2. 빌드

1.2.1. 소스 코드 빌드

1
2
3
4
5
6
7
# (예) 빌드
cd src
make

# (예) 정리
cd src
make clean

1.2.2. 도커로 빌드

프로젝트를 도커 이미지로 빌드하려면 프로젝트 루트(최상위 디렉터리) 를 다음과 같이 빌드 컨텍스트 위치로 지정해야 한다.
(docker_build/src/가 함께 위치한 상위 경로)

1
docker build -t h2-server -f ./docker_build/Dockerfile .

1.3. 실행

1.3.1. 빌드 후 직접 실행

1
2
3
4
5
6
7
# TLS (기본값 포트 443)
cd src
./h2server --port 443 --key ./cert/server.key --cert ./cert/server.crt --threads 4

# H2C (평문)
cd src
./h2server --port 8080 --h2c --threads 4

1.3.2. 도커로 실행

도커로 실행할 때는 환경 변수로 기본 동작을 제어 한다.

환경 변수설명
RUN_MODE실행 모드: h2(TLS) 또는 h2c(평문)
PORT서버 리스닝 포트 (예: 443, 8080)
THREADS워커 스레드 수

1) 단일 컨테이너 실행

  • TLS(H2) 예시
1
docker run -d --name h2server -e RUN_MODE=h2 -e PORT=443 -e THREADS=4 -p 443:443  h2-server
  • 평문(H2C) 예시
1
docker run -d --name h2server-h2c -e RUN_MODE=h2c -e PORT=8080 -e THREADS=4   -p 8080:8080   h2-server

2) docker compose로 실행

docker_build/docker-compose.yml 예시:

1
2
3
4
5
6
7
8
9
  h2_server:
    image: h2-server
    environment:
      - RUN_MODE=h2
      - PORT=443
      - THREADS=4
    ports:
      - 0.0.0.0:443:443
      - :::443:443

실행:

1
2
cd docker_build
docker-compose up -d

H2C로 실행하려면 위 compose 파일에서 RUN_MODE: "h2c", PORT: "8080", ports: ["8080:8080"] 로 변경 하면 된다.

1.4. 핸들러 사용법

등록

1
2
server.add_handler(GET,  "/files/{file_name}", downloader);
server.add_handler(POST, "/files",            uploader);

Request API

함수설명
reply_ok()바디 없이 :status=200 응답 전송
reply_ok_with_file()FileContext를 data source로 연결해 파일 응답
reply_404()간단한 404 HTML 바디와 함께 :status=404 전송

예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int downloader(Request& request) {
    StreamData* stream_data = request.stream_data;
    const std::string& rel_path = request.rel_path;

    if (rel_path.empty()) {
        return request.reply_404();
    }
    auto file = load_file_from_filecache(rel_path);
    if (!file) {
        return request.reply_404();
    }

    stream_data->file_ctx.data = file->get_data();
    stream_data->file_ctx.size = file->get_data_len();
    return request.reply_ok_with_file();
}

2. 자료 구조 (Data Structure Overview)

uml

2.1. 주요 자료 구조

1) StreamData — 스트림 레벨 컨텍스트

1
2
3
4
5
6
7
8
9
class StreamData {
public:
    uint32_t stream_id;
    METHOD method;
    std::string request_path;
    FileContext file_ctx;
    std::unique_ptr<MultipartFormParser> mime_parser;
    std::vector<uint8_t> upload_file_buffer;
};

2) SessionData — 세션(소켓) 레벨 컨텍스트

1
2
3
4
5
6
7
8
9
10
11
class SessionData {
public:
    const Router* router;
    std::list<std::unique_ptr<StreamData>> streams;
    nghttp2_session* session;
    std::vector<uint8_t> output_buffer;
    std::vector<uint8_t> input_buffer;
    uint32_t events;
    SessionState state;
    SSL* ssl;
};

3) Server — accept/분배 + 워커 관리

1
2
3
4
5
6
7
8
9
class Server {
private:
    Router router;
    std::vector<Worker> workers;
    std::vector<std::thread> threads;
    LoadBalancer load_balancer;
    int epfd, server_sock;
    struct sockaddr_in addr;
};

4) Worker — 이벤트 루프 & nghttp2/TLS I/O

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Worker {
private:
    const Router* router;
    std::function<bool(uint32_t)> check_rd_hup;
    std::function<void(int, std::shared_ptr<SessionData>)> update_events;
    std::function<IOResult(int, std::shared_ptr<SessionData>)> fill_input_buffer;
    std::function<IOResult(int, std::shared_ptr<SessionData>)> flush_output_buffer;

    std::mutex m;
    int signal_fd, epfd;
    bool use_tls;
    SSL_CTX* ssl_ctx;

    std::queue<int> socket_queue;
    std::unordered_map<int, std::shared_ptr<SessionData>> session_map;
};

5) Router — 정적/파라미터 경로 트리

1
2
3
4
5
6
7
8
9
10
11
12
class RouterNode {
public:
    std::unordered_map<std::string, std::unique_ptr<RouterNode>> static_children;
    std::unique_ptr<RouterNode> param_child;      // 같은 레벨에서 파라미터 노드는 1개 제한
    std::string param_child_name;                 // ex) "{id}" -> "id"
    std::array<Handler, METHOD_MAX> handlers;     // GET/POST 등
};

class Router {
private:
    RouterNode root_node;
};

6) File Cache — mmap 기반 zero-copy 지향

1
2
3
4
5
6
7
8
9
10
11
12
class FileContext {
public:
    size_t size;
    int offset;
    const char* data;
};

class MappedFile {
public:
    void*  data;     // mmap 주소
    size_t data_len;
};

3. 모듈 구조 (Module Architecture)

아래는 모듈 간 주요 데이터 플로우이다.

module flow

3.1. Server

  • --threads 개수만큼 Worker 생성 및 초기화
  • accept된 클라이언트 소켓을 LoadBalancer를 이용하여 각 Worker 큐에 분배
    (eventfd를 통해 워커를 깨워 즉시 처리)

3.1.1. LoadBalancer

다음은 Server 내에 위치하여 입력되는 client 연결 요청을 받아 각 Worker로 분배하는 로드 밸런서이다.

load_balancer

로드 밸런서는 입력된 요청을 받아 해시 연산을 통해 Indirect Table을 참조하여 요청을 전달 할 Worker Queue의 번호를 얻는다.
이때의 Worker Queue는 SPSC(Single Producer Single Consumer) 구조의 lock-free Queue 로써,
그 Worker Queue에 여유가 있는 경우 그대로 요청을 전달 한다.
그러나 만약 선택된 Worker Queue 가 full인 경우, 다른 여유가 있는 Worker Queue를 Round Robin 스케쥴링을 통해 찾아 작업을 요청한다.

이때, 선택된 Worker Queue의 번호는 hash 연산을 통해 접근된 indirect_table의 entry에 갱신 된다.
그리고 이후 이 entry에 접근하는 요청은 새롭게 선택된 Worker Queue로 전달 되게 된다.

만약, Round Robin 스케쥴링을 통해 Worker Queue 들을 한 바퀴 순회 하였음에도 여유 있는 Worker Queue를 발견하지 못하는 경우에는 해당 요청은 버려진다.

  • 새롭게 생성된 클라이언트 소켓에 대해 modular 연산을 수행하여 indirect_table의 인덱스 값으로 변환
  • indirect_table의 엔트리에 있는 Worker Queue 번호를 획득
    • 최초 indirect_table에는 Worker Queue의 번호가 순서대로 반복 입력 되어 있다.
  • Worker Queue 가 full이 아니라면, 해당 worker queue로 클라이언트 소켓을 전달한다.
    • 만약 Worker Queue 가 full 인 경우, Round Robin 스케쥴링을 통해 여분의 Worker Queue 를 찿는다.
      • 이것은 일종의 fallover 동작으로 볼 수 있다.
      • 여분의 Worker Queue 발견 시 방금 접근 했던 indirect_table의 엔트리에 Queue 번호 값을 갱신한다.
      • 새롭게 선택된 Worker Queue에 클라이언트 소켓을 전달 한다.
    • 만약 여력이 있는 Worker Queue 를 발견하지 못한 경우 생성된 소켓을 close 한다.

3.2. Worker

  • 큐로 전달받은 소켓을 epoll에 등록하고, TLS 핸드셰이크(옵션)nghttp2 세션을 초기화
  • 이벤트 처리:
    • handle_read: fill_input_bufferfeed_input_buffer(nghttp2_session_mem_recv2) → 콜백 체인
    • handle_write: fill_output_buffer(nghttp2_session_mem_send2)flush_output_buffer
    • check_rd_hup: FIN(RDHUP) 감지 시 안전 종료

3.2.1. worker 내 buffer 처리와 nghttp2 callback 함수들의 호출 flow

h2_server_worker_callback_flow

3.2.2. 주요 함수 호출 체인

| 함수명 | 의미 | 트리거 | | :—————————- | :——————- | :——————————- | | fill_input_buffer | 소켓을 통해 수신된 데이터들을 읽어 수신 버퍼에 저장 | 수신 소켓에서 이벤트 발생 시 | |feed_input_buffer| 채워진 수신 buffer 내 내용을 nghttp2 세션 데이터로 feed 할 준비| 더이상 소켓에서 수신할 데이터가 없는 경우 | | nghttp2_session_mem_recv2 | 채워진 buffer 내 데이터들을 nghttp2 라이브러리로 feed | 수신 버퍼에 데이터가 존재하는 경우 | | nghttp2_submit_response2 | 전송할 응답 프레임을 전송 대기큐로 큐잉, data_prd 존재 시 DATA 프레임 생성 | 응답 메시지 전송을 위해 직접 호출 | | nghttp2_session_mem_send2 | 전송 대기 큐 내 응답 프레임을 전송 할 수 있게 직렬화 화여 포인터 반환 | 응답할 수 있는 데이터 존재 시 | |fill_ouput_buffer|포인터가 가리키는 직렬화된 데이터를 출력 버퍼로 복사|| | flush_output_buffer | 출력 버퍼 내 데이터를 소켓을 통해 전송 | 직접 호출 |

3.2.3. 주요 nghttp2 콜백 함수들

함수명의미트리거
on_begin_headers_callback헤더 블록 수신 시작mem_recv2()
on_header_callback헤더 name/value 수신헤더 수신 때마다 반복
on_data_chunk_recv_callbackDATA 바디 청크 수신DATA 프레임 수신 중 반복
on_frame_recv_callback프레임 수신 완료요청 완료 시 Router→Handler 호출
on_stream_close_callback스트림 종료스트림 close 요청 수신 시

3.2.2. TLS(SSL_CTX)

  • ALPN 협상에서 h2만 수락(미지원 시 종료)
  • TLS 미사용(H2C) 모드에서는 ALPN 협상 생략

3.3. Router

다음 4개 경로가 등록되면 트리는 다음과 같은 형태로 구성된다.

1
2
3
4
POST /files/images/{file_name} : handler_b
GET  /files/docs/{file_name}   : handler_c
POST /users/{user_num}         : handler_d
GET  /files/images/{file_name} : handler_a

routing_tree

  • 정적 경로 우선 → 없으면 파라미터 경로 탐색
  • 핸들러는 프로그램 구동 시에만 등록(런타임엔 읽기 전용)

3.4. File Cache

filecache

  • 디스크 병목 완화 목적의 mmap 캐시
  • 주의: 동일 파일에 대한 동시 쓰기 시 경쟁/일관성 문제가 발생할 수 있으므로 운영 시 동기화 정책이 필요하다.

3.5. Multipart Form Parser

  • 업로드 요청 처리: multipart/form-data → 파일 추출
  • 플로우: boundary 파싱 → filename 추출 → temp 쓰기 → 종료 시 rename
  • temp 파일명: h2_tmp_<thread_id>_<count>

3.6. Config Option

FlagType / DefaultDescription
-p, --port <NUM>integer / 443리스닝 포트
-k, --key <PATH>path / ./cert/server.keyTLS 개인키(PEM). --h2c면 불필요
-c, --cert <PATH>path / ./cert/server.crtTLS 인증서(PEM). --h2c면 불필요
-n, --threads <NUM>integer / 1워커 스레드 수
--h2cflagHTTP/2 cleartext 모드
-h, --helpflag도움말 출력

4. 성능 측정 (Benchmark)

이 프로젝트의 벤치마크는 단순히 “얼마나 빠른가”만 보기 위한 것이 아니라,
아키텍처 변화가 실제로 QPS, tail latency, CPU 사용량, 스레드 확장성에 어떤 영향을 주는지를 확인하기 위한 목적에서 진행했다.

먼저 아래의 기존 측정 결과들은 주로 origin 브랜치 계열의 구조 변화를 기준으로,
Prototype → mmap → TLS → Router → Load Balancer → Acceptor/Worker 순으로 어떤 개선과 트레이드오프가 있었는지를 보여준다.

이후 후반부에서는 별도 섹션으로 static-partitioning 브랜치의 최신 벤치마크 결과를 정리한다.

4.1. 개선 단계별 성능 변화

Prototype → mmap(파일 캐시) 적용 → TLS(https) 적용 → Router 적용 → socket port reuse 멀티스레딩 구조 적용 → acceptor/worker 멀티스레딩 구조 적용 -> Load Balancer 적

주: mmap 도입 전 -c1000 -m100에서는 FD 제한(기본 1024)으로 성공률이 0.1%에 불과.
동일 이슈는 libevent-server, nghttpd(≥8 threads)에서도 관측되어 ulimit -n 상향으로 대응.

1) h2load -c100 -m10


ServerQPSP99 Latency (ms)P90 Latency (ms)P50 Latency (ms)CPU Usage (%)Mem Usage (MB)
Prototype122060.23210.6748.92488.067498.4127.451171875
with MMAP (h2c)224543.53.78082.91482.481689.63220.30703125
with MMAP + TLS190543.6346.3274.6263.07492.72815.84726563
Router (TLS)175203.9347.38685.81764.742498.97415.94824219
Router (h2c)199643.80.0063270.0046260.00307492.7280.015475845
LoadBalancer (TLS)173702.0987.3065.95625.024899.20818.12
LoadBalancer (h2c)192895.7666.70445.50924.521899.0789.565039063

http2 서버 스크린샷 1
http2 서버 스크린샷 2
http2 서버 스크린샷 3
http2 서버 스크린샷 4

2) h2load -c1000 -m100


ServerQPSP99 Latency (ms)P90 Latency (ms)P50 Latency (ms)CPU Usage (%)Mem Usage (MB)
Prototype108745.666968.666891.9512740.180485.97876.84316406
with MMAP (h2c)361051.334290.2608276.253270.298899.23684.17207031
with MMAP + TLS340922.308324.6172290.5728277.594899.892125.9441406
Router (TLS)218128.414505.9948473.1074458.96899.81140.4175781
Router (h2c)226110.666486.169453.9088448.780699.53698.51445313
Load Balancer (TLS)205574.332547.7828508.3812486.718299.942141.19
Load Balancer (h2c)221886.068514.821474.6454445.100699.93896.4296875

http2 서버 스크린샷 5
http2 서버 스크린샷 6
http2 서버 스크린샷 7
http2 서버 스크린샷 8

4.1.1. Socket Port Reusing vs Acceptor/Worker

개요

SO_REUSEPORT 옵션을 사용하면 동일한 포트 번호에 대해 여러 개의 리스닝 소켓을 동시에 bind 할 수 있다.
본 HTTP/2 서버 아키텍처에서는 다음 두 가지 모델을 비교하였다.

  • Socket Port Reusing 방식 (커밋 해시: b2985a2d81e4db92e00b7171dd9ebb93b4bc2df3)
  • Acceptor/Worker 방식 (커밋 해시: db6af3ddcdcf329e5a72bc0df9b7c236284df795)

💡 참고: 본 절의 내용과 수치는 Load Balancer 도입 이전 버전을 기준으로 한다.

Socket Port Reusing 방식

http2 서버 스크린 57

참고: Socket Port Reusing 방식은 현재 버전의 구조를 갖추기 이전에 사용되었던 설계이며,
해당 구현은 커밋 해시 e4c03916b3ca61d2a82fe72fe4b67f0ddd75ab05 에 포함되어 있다.

특징

  • 각 스레드 또는 프로세스가 독립적인 리스닝 소켓을 생성하고 SO_REUSEPORT를 설정함.
  • 커널은 동일 포트에 바인딩된 소켓들을 reuseport 그룹으로 묶어 관리.
  • 커널이 접속 요청을 리스닝 소켓 단위로 로드밸런싱해 분배함.

장점

  • 구현 난이도가 낮고 구조가 단순함.
  • Accept 경합을 줄여 락 경쟁이 거의 없음.
  • 커널 내에서 로드밸런싱이 수행 됨.

단점

  • 애플리케이션 레벨에서 연결 분배를 직접 제어하기 어려움.
  • 서버 구조가 커지면 역할 분리·모듈성 측면에서 확장성이 떨어짐.

Acceptor/Worker 방식

http2 서버 스크린 58

특징

  • Acceptor 스레드
    • 오직 accept()만 담당
    • 새 연결 생성 후 그 FD를 worker에 전달
  • Worker 스레드
    • Session(HTTP/2 프레임 처리, SSL, I/O 등) 전담

장점

  • 역할 분리가 명확해 구조적 일관성/가독성 우수.
  • 애플리케이션 레벨에서 connection dispatch 전략을 자유롭게 설계 가능.
  • 확장성 및 유지보수성이 매우 높음.

단점

  • 구현 복잡도가 socket reuseport 방식보다 높음.
  • inter-thread communication(파이프/lock-free 큐 등) 필요.

결론

두 구조는 성능적으로 유사하지만, 본 프로젝트에서는 다음과 같은 이유로 Acceptor/Worker 구조를 택함.

  • 역할 분리 및 모듈성 우수
  • 유지보수 용이
  • 구조 확장에 유리
  • Session 핸들링 로직이 Worker로 집중되어 코드 가독성 및 명확성 증가

4.1.1.1. H2

1) h2load -c100 -m10

Port reusing

ThreadsQPSP99 Latency (ms)CPU Usage (%)Mem Usage (MB)
1170082.7687.883898.85216.43515625
2191969.94.9168138.79215.86367188
4185166.9684.157146.41815.86210938
8179178.12753.8758162.19615.91289063

Acceptor/worker

ThreadsQPSP99 Latency (ms)CPU Usage (%)Mem Usage (MB)
1186105.49.007698.95615.99785156
2195737.55.7036143.21415.89492188
4179277.3683.4026149.85615.87246094
8183349.3683.3312169.315.98984375

http2 서버 스크린샷 9
http2 서버 스크린샷 11
http2 서버 스크린샷 13
http2 서버 스크린샷 15


2) h2load -c1000 -m100

Port reusing

ThreadsQPSP99 Latency (ms)CPU Usage (%)Mem Usage (MB)
1220103.334495.866699.726141.2958984
2399747.666298.155187.066136
4421299.026160.2804204.244126.6503906
8417792.915157.9678209.762126.0441406

Acceptor/worker

ThreadsQPSP99 Latency (ms)CPU Usage (%)Mem Usage (MB)
1217159.334510.086899.846140.0394531
2389953.44295.7502186.524139.2839844
4426597.082153.9212208.794128.4552734
8423505.2148.8116210.396128.0207031

http2 서버 스크린샷 10
http2 서버 스크린샷 12
http2 서버 스크린샷 14
http2 서버 스크린샷 16


4.1.1.2. H2C

1) h2load -c100 -m10

Port reusing

ThreadsQPSP99 Latency (ms)CPU Usage (%)Mem Usage (MB)
1196712.6666.994898.7589.514257813
2220055.0944.6052146.1569.423828125
4214679.3343.329151.4049.4296875
8207702.4943.5508164.4889.51796875

Acceptor/worker

ThreadsQPSP99 Latency (ms)CPU Usage (%)Mem Usage (MB)
1199005.8346.342899.079.519335938
2219053.9344.0376140.3089.49453125
4210013.1023.3358143.3449.295898438
8209466.563.5956166.5629.462109375

http2 서버 스크린샷 17
http2 서버 스크린샷 19
http2 서버 스크린샷 21
http2 서버 스크린샷 23

2) h2load -c1000 -m100

Port reusing

ThreadsQPSP99 Latency (ms)CPU Usage (%)Mem Usage (MB)
1224862.99384.241699.62676.52480469
2404943.208307.5908192.0694.28222656
4438058.332160.4104217.45489.04765625
8435534156.368218.66681.909375

Acceptor/worker

ThreadsQPSP99 Latency (ms)CPU Usage (%)Mem Usage (MB)
1225519.504501.166899.72497.57636719
2406958.212281.1706187.63294.61152344
4451179153.3158214.99889.13046875
8445782.332146.397219.95880.2796875

http2 서버 스크린샷 18
http2 서버 스크린샷 20
http2 서버 스크린샷 22
http2 서버 스크린샷 24

4.2. 서버 프로그램 간 성능 비교

  • h2server commit: c34cd1d9ce9
  • nghttpd: nghttp2 v1.60.0
  • libevent-server: nghttp2 v1.60.0

요약:

  • nghttpd는 스레드 증가에 따른 메모리 안정성이 높지만 성능 스케일링 이득은 제한적
  • h2server는 스레드 증가와 함께 CPU/메모리 사용량이 증가하나 처리량도 동반 증가

4.2.1. Single Thread

2server, nghttpd, libevent 간 성능을 비교.
libevent의 경우 h2c 모드를 지원하지 않으므로 h2 테스트에서는 제외.

4.2.1.1. H2


1) h2load -c100 -m10

ServerQPSP99 Latency (ms)P90 Latency (ms)P50 Latency (ms)CPU Usage (%)Mem Usage (MB)
h2server173702.097.305.95625.0299.2018.12
Libevent-server154655.110.077.185.5098.0215.41
nghttpd1816806.084.152.7990.94218.23

http2 서버 스크린샷 25
http2 서버 스크린샷 26
http2 서버 스크린샷 27
http2 서버 스크린샷 28

2) h2load -c1000 -m100

ServerQPSP99 Latency (ms)P90 Latency (ms)P50 Latency (ms)CPU Usage (%)Mem Usage (MB)
h2server205574.33547.78508.38486.7199.94141.19
Libevent-server204798.99541.53507.15479.8999.9195.45
nghttpd321848.43235.78166.42157.6185.59112.38

http2 서버 스크린샷 29
http2 서버 스크린샷 30
http2 서버 스크린샷 31
http2 서버 스크린샷 32

4.2.1.2. H2C


1) h2load -c100 -m10

ServerQPSP99 Latency (ms)P90 Latency (ms)P50 Latency (ms)CPU Usage (%)Mem Usage (MB)
h2server192895.766.705.504.5299.079.56
nghttpd205405.525.653.942.4891.7714.84

http2 서버 스크린샷 33
http2 서버 스크린샷 34
http2 서버 스크린샷 35
http2 서버 스크린샷 36

2) h2load -c1000 -m100

ServerQPSP99 Latency (ms)P90 Latency (ms)P50 Latency (ms)CPU Usage (%)Mem Usage (MB)
h2server221886.06514.82474.64445.1099.9396.42
nghttpd375828.33254.24213.16134.7991.0475.29

http2 서버 스크린샷 37
http2 서버 스크린샷 38
http2 서버 스크린샷 39
http2 서버 스크린샷 40

4.2.2. 스레드 스케일링 비교

  • libevent-server는 멀티스레드 미지원 → 제외
  • nghttpd는 8스레드에서 FD 부족 이슈 → ulimit -n 65535로 상향하여 측정

4.2.2.1 H2

1) h2load -c100 -m10

h2server

ThreadsQPSP99 Latency (ms)CPU Usage (%)Mem Usage (MB)
1173702.0987.30699.20818.12363281
2187681.7344.2538139.28818.12070313
4178973.0324.4518155.9118.06914063
8172458.5854.4838166.85418.3203125

nghttpd

ThreadsQPSP99 Latency (ms)CPU Usage (%)Mem Usage (MB)
11816806.08390.94218.22597656
2179038.5664.3452108.58518.2668457
4174970.7644.2538119.61418.61054688
8171352.5024.23130.347519.821875

http2 서버 스크린샷 41
http2 서버 스크린샷 43
http2 서버 스크린샷 45
http2 서버 스크린샷 47


2) h2load -c1000 -m100

h2server

ThreadsQPSP99 Latency (ms)CPU Usage (%)Mem Usage (MB)
1205574.332547.782899.942141.1904297
2393091.732305.2898194.396136.2841797
4403797.186157.3542203.208141.8414063
8404569.212152.979214.93143.1271484

nghttpd

ThreadsQPSP99 Latency (ms)CPU Usage (%)Mem Usage (MB)
1321848.43235.78885.59112.3808594
2358788.664188.0264105.616116.7830078
4367662.9177.935113.07115.0414063
8348835.566182.8562122.024112.4423828

http2 서버 스크린샷 42
http2 서버 스크린샷 44
http2 서버 스크린샷 46
http2 서버 스크린샷 48


4.2.2.2. H2C

1) h2load -c100 -m10

h2server

ThreadsQPSP99 Latency (ms)CPU Usage (%)Mem Usage (MB)
1192895.7666.704499.0789.565039063
2216457.44.2202148.0569.491796875
4202325.6343.7672154.1269.574414063
8199447.333.4988168.019.599609375

nghttpd

ThreadsQPSP99 Latency (ms)CPU Usage (%)Mem Usage (MB)
1205405.5245.656291.7714.84101563
2198241.1983.7884104.03612.01835938
4195620.473.8596115.08212.3109375
8192671.31173.5740383118.582833311.59673754

http2 서버 스크린샷 49
http2 서버 스크린샷 51
http2 서버 스크린샷 53
http2 서버 스크린샷 55


2) h2load -c1000 -m100

h2server

ThreadsQPSP99 Latency (ms)CPU Usage (%)Mem Usage (MB)
1221886.068514.82199.93896.4296875
2410807.138274.1966193.75694.84179688
4428543.664154.3328219.2291.43515625
8429380144.1536225.90281.98554688

nghttpd

ThreadsQPSP99 Latency (ms)CPU Usage (%)Mem Usage (MB)
1375828.332254.246691.04875.29472656
2377719243.627687.61275.25371094
4400143.366241.793290.76875.19921875
8352529.364264.866285.35275.21660156

http2 서버 스크린샷 50
http2 서버 스크린샷 52
http2 서버 스크린샷 54
http2 서버 스크린샷 56

4.3. static-partitioning 브랜치 도입 배경

origin 브랜치에서는 acceptor → load balancer → worker 구조를 통해
HTTP/2 세션 처리, TLS 처리, 파일 서비스, 멀티스레드 분산 구조의 기본 타당성을 검증했다.
실제로 고부하 멀티스레드 환경에서 h2servernghttpd 대비 더 나은 확장성을 보였고, 특히 4~8 스레드 구간에서 처리량과 p99 latency 측면에서 의미 있는 결과를 확인할 수 있었다.

다만 구조를 발전시키는 과정에서, readiness 중심의 처리 추상화가 모든 경로에 동일하게 최적은 아니라는 점도 보이기 시작했다. 특히 H2C 경로에서는 워커 책임과 처리 경로를 더 명확히 분리하고, 이벤트를 단순 readiness가 아니라 completion에 가깝게 다루는 방식이 고부하에서 더 유리할 수 있다고 판단했다.
이 문제의식을 바탕으로 static-partitioning 브랜치를 별도로 구성했다.

이 브랜치의 목표는 단순 기능 추가가 아니라,

  • 워커의 책임을 더 명확히 나누고,
  • 처리 경로를 더 고정적이고 단순하게 만들며,
  • H2C 경로에서 completion 모델을 적용해 실제 처리량(QPS)과 스레드 확장성이 얼마나 개선되는지를 검증하는 데 있었다.

4.3.1. static-partitioning 구조

acceptor → load balancer → worker 구조가 람다를 이용하여 컴파일 타임에 TLS ON/OFF 여부에 따라 소켓 연결 이후의 처리 경로(H2 처리 or H2C 처리)를 함수 포인터 처럼 바인딩 해놓고 런타임 때 실체를 생성 되도록 한 구조라면,

static-partitioning구조는 소켓 연결 이후의 처리 경로를 실행 모드별로 컴파일 타임에 템플릿을 이용하여 명확하게 분리되어 빌드 되도록 한 구조이다.

origin 브랜치가 acceptor → load balancer → worker 구조 안에서 공통적인 worker 처리 흐름을 중심으로 확장성을 검증했다면, static-partitioning 브랜치는 그 위에서 H2(TLS), H2C(epoll), H2C(io_uring) 경로를 서로 다른 워커 구현과 이벤트 처리 방식으로 분리해 고부하 상황에서 각 경로의 특성에 맞는 처리 모델을 실험하는 데 초점을 두었다.

즉, 이 브랜치의 핵심은 “프로토콜/실행 모드별 처리 경로를 덜 섞고 더 고정적으로 운영하는 것”에 가깝다.
특히 H2C 경로에서는 epoll 기반 readiness 모델과 io_uring 기반 completion 모델을 나누어 비교함으로써, 고부하 구간에서의 처리량 차이를 직접 검증했다.

4.4. static-partitioning 최신 벤치마크

Ubuntu 24.04 업그레이드 이후의 static-partitioning 기준 성능 측정에서는,
프로파일링 도구 자체의 오버헤드를 줄이기 위해 h2load만 사용하여 QPS 중심으로 비교했다.
즉, 이 섹션은 origin에서의 상세한 latency/CPU/memory 분석을 대체하기보다는,
최신 구조에서의 처리량과 스레드 확장성 변화를 빠르게 확인하는 데 목적이 있다.

테스트 환경

  • Host OS: Windows 11
  • Host CPU: AMD Ryzen 5 7500F
  • Virtualization: VMware Workstation Pro
  • Guest OS: Ubuntu 24.04
  • Server 4 cores / Client 2 cores
  • Clients: 1000
  • Max Streams: 100
  • Duration: 60s
  • Warm-up: 5s
  • Repeat: 5 runs

Average QPS

Threadsnghttpd h2nghttpd h2ch2server h2h2server h2ch2server h2c-io-uring
1237,691.400265,844.880204,269.332231,736.878215,849.772
2357,122.614415,930.222281,325.252313,524.066406,326.790
4374,448.666457,900.000426,418.254555,360.632553,962.568
8402,795.666454,465.334408,376.000538,905.998546,887.852

Max QPS

Threadsnghttpd h2nghttpd h2ch2server h2h2server h2ch2server h2c-io-uring
1274,885.750279,853.330234,428.330245,015.670218,185.600
2417,983.830473,583.330299,750.000326,406.670421,581.880
4417,850.000507,730.000461,459.470585,625.000582,921.700
8406,971.670498,368.330417,993.330597,368.330605,540.000

요약

이 결과에서 가장 눈에 띄는 점은 다음과 같다.

  • 4스레드 이상 구간에서 h2server h2ch2server h2c-io-uringnghttpd를 추월
  • 최고 성능은 h2server_h2c_io_uring_n8
  • 최대 QPS는 605,540
  • 정적 분리(static partitioning)와 completion 모델이 고부하 구간에서 의미 있는 처리량 개선을 보여줌

4.5. origin과 static-partitioning 비교 해석

origin 브랜치의 목표는 HTTP/2 서버의 기본 골격을 직접 설계하고,
acceptor → worker 분리, 로드 밸런싱, mmap 파일 캐시, 사용자 라우팅, TLS/H2C 지원 같은 핵심 구성 요소가 실제로 어느 정도의 성능과 확장성을 보이는지 확인하는 데 있었다.
실제로 origin 계열의 벤치마크에서는 멀티스레드 구간에서 nghttpd 대비 더 나은 확장성과 더 낮은 p99 latency를 확인할 수 있었고, H2C 8스레드 기준 약 429K QPS 수준까지 도달했다.

반면 static-partitioning 브랜치는 그 다음 단계의 실험이다.
이미 기본 골격이 검증된 상태에서, 워커 책임과 처리 경로를 더 명확히 분리하고 H2C 경로에 completion 모델을 도입함으로써, 고부하 환경에서의 throughput을 얼마나 더 끌어올릴 수 있는지를 확인하는 데 초점을 맞췄다.
그 결과 최신 기준에서는 h2server h2c-io-uring이 8스레드에서 605,540 QPS를 기록하며, origin 대비 한 단계 더 높은 처리량을 달성했다.

즉, 이 프로젝트의 흐름은
기본 구조 검증(origin) → 처리 경로 고정화 및 고부하 최적화(static-partitioning)
로 이해하는 것이 가장 자연스럽다.

5. 정리

이 프로젝트는 단순히 HTTP/2 기능을 구현하는 데서 끝나지 않고,
고성능 서버 구조를 실제로 설계하고 측정하면서 어디에서 병목이 생기고 어떤 아키텍처 선택이 throughput과 latency에 영향을 주는지를 검증하기 위한 실험 프로젝트로 발전해 왔다.

origin 브랜치에서는 acceptor → load balancer → worker 구조, mmap 캐시, TLS/H2C 처리, Router 분리, 멀티스레드 확장성을 중심으로 서버의 기본 타당성을 검증했다.
그 다음 단계인 static-partitioning 브랜치에서는 워커 책임과 처리 경로를 더 명확히 분리하고, H2C 경로에 completion 모델을 도입해 고부하 환경에서의 확장성을 더 밀어붙였다.
그 결과 최신 측정에서는 h2server_h2c_io_uring_n8 기준 Max QPS 605,540을 기록할 수 있었다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.