웹서버 이야기를 하다 보면 "Nginx가 Apache보다 빠르다더라"는 말이 거의 반사적으로 나온다. 예전에 같은 주제로 간이 비교 글을 세 편 쓴 적이 있는데(얼렁뚱땅 연구소의 Apache vs. Nginx 간이 성능 비교), 지금 다시 읽어보면 측정 방식이 아쉬운 구석이 많다. 요즘도 같은 질문을 받는 김에 현행 버전 기준으로 다시 정리해 둔다.
결론부터 말하자면, "Nginx가 Apache보다 빠르다"는 문장은 조건을 붙이지 않으면 성립하지 않는다. 정적 파일 서빙에서 양쪽 모두 sendfile()을 쓰도록 맞춰 놓으면 대역폭이나 페이지 캐시가 먼저 한계에 닿아 차이가 잘 드러나지 않고, 차이가 벌어지는 지점은 유휴 keep-alive 연결이 대량으로 쌓일 때의 연결당 메모리다. 동적 콘텐츠 응답 시간은 웹서버가 아니라 뒤에 붙은 php-fpm이나 Tomcat이 결정한다.
그래서 비교를 숫자 하나로 낼 수가 없고, 축을 나눠야 한다. 요청 처리 모델, 정적 파일 서빙, keep-alive와 동시연결, 리버스 프록시, 설정 해석 비용(.htaccess), 프로토콜 지원 범위다. 아래에서 각 축을 차례로 보고, 마지막에 자기 환경에서 wrk로 재는 절차를 정리한다.
버전 기준은 이 글을 쓰는 시점의 Apache HTTP Server 2.4.68(2026-06-08 GA)과 Nginx stable 1.30.4, mainline 1.31.4다. 배포판 저장소 상태에 따라 설치되는 버전은 다를 수 있다.
MPM은 무엇이고 왜 성능 이야기의 출발점인가
MPM(Multi-Processing Module)은 Apache httpd가 들어온 연결을 프로세스와 스레드에 어떻게 배분할지 정하는 교체 가능한 모듈이다. httpd는 이 부분을 코어에 못 박지 않고 모듈로 빼 두었다. 그래서 같은 2.4.68이라도 어떤 MPM을 올렸는지에 따라 동시연결 특성이 달라진다.
Unix 계열에서 고를 수 있는 것은 세 가지다.
- prefork: 자식 프로세스 하나가 연결 하나를 처리한다. 스레드를 쓰지 않으므로 스레드 안전하지 않은 모듈(대표적으로
mod_php)을 인프로세스로 올릴 때 필요하다. - worker: 여러 자식 프로세스가 각각 여러 스레드를 띄우고, 연결 하나를 스레드 하나가 맡는다.
- event: worker 기반이면서, 응답을 다 내보낸 뒤의 keep-alive 소켓을 리스너 스레드에 넘긴다. 공식 문서 표현대로 worker MPM은 keep-alive 상태에서도 스레드를 붙잡아 두는데, event는 그 스레드를 놓아준다.
2.4의 기본 MPM은 시스템 능력에 따라 결정된다. 스레드와 스레드 안전한 폴링(epoll, kqueue)을 모두 지원하면 event, 스레드만 지원하면 worker, 둘 다 없으면 prefork다. 요즘 리눅스 빌드는 사실상 event가 기본이다. 현재 무엇이 올라와 있는지는 이렇게 확인한다.
$ httpd -V | grep -E 'Server version|Server MPM' Server version: Apache/2.4.68 (Unix) Server MPM: event # Debian/Ubuntu 계열은 바이너리 이름이 apache2다 $ apache2ctl -M 2>/dev/null | grep mpm mpm_event_module (shared)
DSO로 빌드된 경우 MPM 교체는 LoadModule 한 줄이다. 두 개를 동시에 올리면 AH00534: httpd: Configuration error: More than one MPM loaded.로 기동이 실패한다. 처음 MPM을 바꿔볼 때 여기서 한 번 걸리기 쉬우니, 기존 LoadModule mpm_prefork_module 줄을 주석 처리했는지 확인해 볼 필요가 있다.
Nginx는 워커 프로세스와 이벤트 루프로 처리한다
Nginx에는 MPM 같은 선택지가 없다. 마스터 프로세스가 설정을 읽고 포트를 열며, 실제 요청은 고정 개수의 워커 프로세스가 처리한다.
워커 하나는 이벤트 루프(리눅스에서는 epoll)를 돌면서 수천 개 연결을 논블로킹으로 다룬다. 연결 하나에 스레드나 프로세스를 대응시키지 않으므로, 연결이 늘어날 때 증가하는 것은 커널 소켓 버퍼와 연결 구조체 정도다.
관련 코어 지시어의 공식 기본값은 이렇다.
worker_processes: 기본1.auto를 쓰면 CPU 코어 수에 맞춘다.worker_connections: 기본512. 워커 하나가 동시에 열 수 있는 연결 수이며, 리버스 프록시에서는 클라이언트 연결과 업스트림 연결이 각각 하나씩 소비된다.multi_accept: 기본off.worker_rlimit_nofile: 기본값 없음.worker_connections를 올리면 이 값도 같이 올려야too many open files를 피할 수 있다.
$ nginx -v nginx version: nginx/1.30.4
정적 파일 서빙에서 차이가 작은 이유
정적 파일은 커널의 sendfile()로 소켓에 바로 흘려보낼 수 있다. Nginx는 sendfile on;, Apache는 EnableSendfile On으로 같은 경로를 쓴다. 파일이 커지면 두 서버 모두 유저 공간 복사를 거의 하지 않으므로, 병목은 NIC 대역폭이나 페이지 캐시 적중률로 옮겨간다. 이 구간에서 초당 요청 수를 비교하면 서버 차이보다 측정 오차가 더 크게 보일 수 있다.
여기서 짚고 갈 것이 기본값이다. Nginx의 sendfile 기본값은 off이고, Apache의 EnableSendfile도 2.3.9부터 기본값이 Off로 바뀌었다. 네트워크 파일시스템 위의 문서 루트에서 sendfile이 문제를 일으키는 사례 때문에 안전한 쪽으로 기본값을 잡아 둔 것이라고 문서는 설명한다.
그래서 정적 서빙을 비교할 생각이면 양쪽 조건을 손으로 맞춰야 한다. Nginx에만 sendfile on;을 켜 두고 Apache는 손대지 않은 상태로 붙이면, 재고 있는 것이 서버 구조 차이가 아니라 커널 경로 차이가 된다. Apache 쪽에도 EnableSendfile On을 명시하고, 문서 루트가 로컬 파일시스템인지 확인해 둘 필요가 있다.
차이가 나타나는 쪽은 작은 파일을 대량으로 뿌릴 때다. 요청당 stat()과 open() 횟수, 설정 해석 비용이 상대적으로 커진다. Nginx에는 열린 파일 디스크립터와 메타데이터를 캐시하는 open_file_cache가 있는데 기본값은 off다. 작은 파일 위주 사이트라면 켜 두는 값이 있다.
http {
sendfile on;
tcp_nopush on; # sendfile이 켜져 있을 때만 동작 (Linux는 TCP_CORK)
open_file_cache max=10000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
}
open_file_cache_valid 기간 동안은 파일 교체가 즉시 반영되지 않는다. 배포 직후 옛 파일이 잠깐 더 나가는 것을 버그로 오해하기 쉬운 지점이다. 배포 파이프라인이 있는 환경이라면 valid 값을 짧게 잡거나 심볼릭 링크 전환 방식과 함께 검토할 필요가 있다.
keep-alive 유휴 연결이 실제 승부처다
브라우저 하나가 연결을 열어 두고 다음 요청을 기다리는 시간은 실제 처리 시간보다 훨씬 길다. 이 유휴 시간에 서버가 무엇을 붙잡고 있는지가 동시연결 한계를 정한다.
prefork나 worker MPM에서는 유휴 keep-alive 연결이 프로세스 또는 스레드를 하나 점유한다. MaxRequestWorkers가 곧 동시연결 상한이 되고, 기본값은 prefork 256, worker와 event는 ServerLimit 16 × ThreadsPerChild 25 = 400이다.
event MPM은 응답을 flush한 뒤 소켓을 리스너 스레드에 넘기므로 워커 스레드 수보다 많은 연결을 유지할 수 있다. 상한에는 AsyncRequestWorkerFactor(기본 2)가 관여하는데, 모든 워커가 유휴일 때 최대 연결 수는 대략 (AsyncRequestWorkerFactor + 1) × MaxRequestWorkers 수준이다.
Nginx는 유휴 연결이 이벤트 루프의 감시 대상 하나로 남는다. 연결당 비용이 스레드 스택 단위가 아니라 구조체 단위이므로, 같은 메모리로 유지할 수 있는 유휴 연결 수가 커진다. 높은 동시연결에서 Nginx의 연결당 메모리가 작다고 알려진 것은 이 구조 차이에서 나온다.
event MPM 설정은 이런 모양이 된다. 아래 숫자는 설명을 위한 예시이고 권장값이 아니다.
<IfModule mpm_event_module>
StartServers 4
ServerLimit 8
ThreadsPerChild 64
ThreadLimit 64
MaxRequestWorkers 512 # 8 * 64
MinSpareThreads 64
MaxSpareThreads 256
MaxConnectionsPerChild 0 # 기본값, 프로세스를 재활용하지 않음
AsyncRequestWorkerFactor 2
</IfModule>
KeepAlive On
KeepAliveTimeout 5
MaxKeepAliveRequests 100
산정은 순서대로 하면 된다. 목표 동시연결 수를 ThreadsPerChild로 나눠 필요한 자식 프로세스 수를 얻고 그 값을 ServerLimit으로 잡는다. 그다음 자식 프로세스 하나의 RSS에 프로세스 수를 곱한 값이 가용 메모리 안에 들어오는지로 상한을 자른다. 프로세스당 RSS는 모듈 구성에 따라 크게 달라지므로 자기 서버에서 ps -C httpd -o rss=로 직접 확인하고 계산해야 한다.
MaxRequestWorkers는 ThreadsPerChild의 정수배로 잡는 것이 바람직하다. 정수배가 아니면 기동을 막지는 않고, 가장 가까운 배수로 내려 조정한 뒤 경고를 로그에 남긴다.
ThreadsPerChild와 ThreadLimit의 관계도 헷갈리기 쉽다. worker와 event MPM의 ThreadLimit 기본값은 64이므로, 위 예시의 ThreadsPerChild 64는 ThreadLimit을 따로 적지 않아도 그대로 적용된다. 설정에 ThreadLimit 64를 같이 써 둔 것은 의도를 드러내려는 것이지 필수는 아니다.
문제가 되는 쪽은 64를 넘길 때다. ThreadsPerChild 128처럼 ThreadLimit보다 큰 값을 주면, 요청한 값이 ThreadLimit 값으로 낮춰지고 기동 시 경고가 로그에 남는다. 기본값 25로 되돌아가는 것이 아니라 상한선까지만 적용되는 것이라, 로그를 안 보면 조용히 절반만 반영된 상태로 운영하게 된다. ServerLimit도 같은 방식으로 MaxRequestWorkers를 제한한다.
대응하는 Nginx 쪽 설정은 훨씬 짧다. 이 숫자들 역시 예시다.
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 8192;
multi_accept on;
}
http {
keepalive_timeout 65; # 기본값 75s
keepalive_requests 1000; # 1.19.10 이전 기본값은 100
}
worker_connections는 목표 동시연결 수를 worker_processes로 나눈 값에서 출발한다. 리버스 프록시로 쓴다면 요청 하나가 클라이언트 연결과 업스트림 연결을 각각 소비하므로 그 두 배로 잡는다. worker_rlimit_nofile은 워커가 여는 파일과 소켓을 모두 덮어야 하니 worker_connections보다 넉넉하게 둔다.
지시어 수만 봐도 알 수 있다. 같은 목표를 Apache는 여러 값의 상호 제약을 맞춰가며 잡고, Nginx는 두 개로 끝낸다. 성능 차이 이전에 설정을 틀릴 확률의 차이가 있다.
동적 콘텐츠는 웹서버 성능이 아니다
Apache는 mod_php처럼 인터프리터를 서버 프로세스 안에 올릴 수 있다. 대신 그 모듈이 스레드 안전하지 않으면 prefork를 써야 하고, prefork를 쓰는 순간 앞에서 본 연결당 프로세스 비용을 그대로 받는다. Nginx에는 인프로세스 실행 모델이 없어서 fastcgi_pass로 php-fpm에, proxy_pass로 Tomcat이나 Node에 넘긴다.
그래서 PHP 페이지 하나를 두고 Apache와 Nginx를 재면, 측정하고 있는 대상은 대부분 php-fpm 워커 수와 애플리케이션 코드다. 웹서버를 바꿔서 동적 페이지 응답 시간이 크게 줄었다면 실제로 바뀐 것은 실행 모델(mod_php에서 php-fpm으로)일 가능성이 높다.
비교 실험을 설계할 때는 동적 요청을 아예 분리하고, 웹서버 축은 정적 서빙과 프록시 오버헤드로 한정하면 해석이 깔끔해진다.
업스트림 keep-alive는 1.29.7부터 기본 동작이다
프록시 구간에서는 백엔드 연결 재사용이 관건이다. Nginx는 upstream 블록의 keepalive로 업스트림 연결을 캐시에 유지한다. 이 연결 캐시는 1.29.7부터 기본으로 활성이며, 기본값은 keepalive 32 local;로 워커 프로세스당 32개다. 같은 1.29.7부터 proxy_http_version의 기본값도 1.1이 되었다. 그 이전 기본값은 1.0이었다.
따라서 이 글의 기준 버전인 stable 1.30.4와 mainline 1.31.4에서는 업스트림 연결 유지가 별도 설정 없이 동작한다. 설정에 keepalive를 적는 것은 기본값 32를 늘리거나 줄이려는 경우다.
upstream app {
server 10.0.0.30:8080;
keepalive 64; # 기본값 32에서 조정할 때만 명시
}
location / {
proxy_pass http://app;
}
1.29.7 이전 버전을 쓰고 있다면 이야기가 다르다. 업스트림 연결 캐시가 기본으로 꺼져 있고 프록시 요청이 HTTP/1.0으로 나가므로, keepalive를 켠 다음 아래 두 줄을 함께 넣어야 연결이 실제로 유지된다.
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
인터넷에 돌아다니는 설정 예제 대부분이 이 두 줄을 포함하고 있다. 최신 버전에서는 기본 동작을 명시적으로 다시 적는 형태가 되어 해가 되지 않는다. 반대로 1.29.7 이전 버전에서 이 두 줄을 빼고 keepalive만 켜 두면 연결은 유지되지 않으므로, 쓰고 있는 버전을 nginx -v로 먼저 확인해 볼 필요가 있다.
.htaccess는 요청마다 파일시스템을 두드린다
Apache에서 AllowOverride가 켜져 있으면 요청 경로의 각 상위 디렉터리에서 .htaccess를 찾는다. 공식 문서의 설명은 명확하다. 파일이 존재하지 않아도 탐색은 일어나며, 문서가 요청될 때마다 로드된다.
/www/htdocs/example 아래 파일 하나를 요청하면 /.htaccess, /www/.htaccess, /www/htdocs/.htaccess, /www/htdocs/example/.htaccess 네 번의 추가 파일시스템 접근이 붙는다. 여기에는 공식 문서가 붙여 둔 조건이 있다. 이 예시는 루트(/)까지 .htaccess를 허용한 경우이고, 일반적인 설정에서는 그렇게까지 열어 두지 않는다. 허용 범위를 문서 루트 이하로만 두었다면 탐색 횟수도 그만큼 줄어든다.
그래도 정적 파일 벤치마크에서 Apache 쪽 숫자가 유난히 낮게 나온다면 이 설정을 먼저 볼 필요가 있다. 문서가 권하는 값은 AllowOverride None이고, Nginx에는 디렉터리 단위 설정 파일 개념 자체가 없어서 같은 비용이 발생하지 않는다.
<Directory "/var/www/html">
AllowOverride None
Require all granted
</Directory>
대신 .htaccess는 애플리케이션 디렉터리마다 리라이트 규칙과 접근 제어를 따로 두는 운영 방식을 가능하게 한다. 공유 호스팅이나 WordPress류 배포물처럼 규칙이 코드와 함께 오는 구조라면 이 기능이 Apache를 고르는 실질적 이유가 된다.
HTTP/2와 HTTP/3 지원 범위는 다르다
HTTP/2는 양쪽 다 된다. Apache는 mod_http2가 2.4.17부터 제공되고, Protocols h2 http/1.1(TLS 전용) 또는 Protocols h2 h2c http/1.1(평문 h2c 포함)로 켠다. Nginx는 ngx_http_v2_module로 처리한다.
HTTP/3(QUIC)에서 갈린다. Nginx는 1.25.0부터 ngx_http_v3_module이 들어왔고, 빌드 시 --with-http_v3_module을 주고 listen 443 quic reuseport;와 listen 443 ssl;를 같은 포트에 함께 두는 방식으로 활성화한다. 공식 문서는 이 모듈을 여전히 experimental로 표기한다. Apache httpd 2.4 계열에는 HTTP/3 지원 모듈이 없고, mod_http2 문서에도 HTTP/3 언급이 없다.
HTTP/3가 요구사항에 들어 있다면 이건 성능 비교 이전의 기능 유무 문제다. 프런트를 Nginx로 두는 근거가 하나 더 붙는다.
자기 환경에서 재는 절차
남의 벤치마크 숫자는 그 사람의 NIC와 커널 파라미터에 대한 정보다. 재는 방법을 정리한다.
1. 부하 발생기를 서버와 분리한다. 같은 머신에서 wrk와 httpd를 함께 돌리면 CPU를 나눠 쓰게 되고, 결과는 서버 성능이 아니라 스케줄러 경합을 반영한다. 최소한 같은 서브넷의 다른 인스턴스에서 부하를 준다.
2. 양쪽 조건을 맞춘다. Apache는 EnableSendfile On과 AllowOverride None, Nginx는 sendfile on;을 명시해 두고 시작한다. 앞서 본 것처럼 두 서버의 기본값이 서로 다르기 때문에, 손대지 않은 상태끼리 붙이면 구조가 아니라 기본값을 재게 된다.
3. 테스트 대상 파일을 두 종류 만든다. 작은 파일은 요청당 오버헤드를, 큰 파일은 sendfile()과 대역폭을 본다.
$ sudo dd if=/dev/urandom of=/var/www/html/1k.bin bs=1K count=1 1+0 records in 1+0 records out 1024 bytes (1.0 kB, 1.0 KiB) copied, 0.000105 s, 9.8 MB/s $ sudo dd if=/dev/urandom of=/var/www/html/1m.bin bs=1M count=1 1+0 records in 1+0 records out 1048576 bytes (1.0 MB, 1.0 MiB) copied, 0.00612 s, 171 MB/s # 시간과 속도는 환경에 따라 다름
4. 동시연결을 단계적으로 올린다. -c를 50, 200, 1000, 4000으로 올리면서 같은 항목을 기록한다. 한 지점만 재면 두 서버의 곡선 모양을 못 본다.
$ wrk -t4 -c200 -d30s --latency http://10.0.0.20/1k.bin $ wrk -t4 -c1000 -d30s --latency http://10.0.0.20/1k.bin $ wrk -t4 -c200 -d30s --latency http://10.0.0.20/1m.bin
wrk 출력에서 볼 항목은 네 가지다.
- Requests/sec: 전체 구간 평균 처리량. 서버 두 대를 비교할 때의 1차 지표다.
- Latency Avg / Max: 평균과 최대 응답 지연. Max가 평균의 수십 배로 튀면 GC나 accept 큐 대기 같은 다른 원인을 의심한다.
- Latency Distribution:
--latency를 줘야 나온다. 50%, 75%, 90%, 99% 분위가 표시되며, 사용자 체감에 가까운 값은 평균이 아니라 99%다. - Socket errors: connect, read, write, timeout 개수. 여기에 숫자가 찍힌 결과의 Requests/sec는 비교 대상이 아니다. 상한(
MaxRequestWorkers,worker_connections)이나 파일 디스크립터 한계에 닿았다는 뜻이다.
5. 메모리를 같이 잡는다. 처리량만 보면 동시연결 구간의 차이를 놓친다. 부하 중에 RSS 합계를 재 둔다.
$ ps -C nginx -o rss= | awk '{s+=$1} END {print s" KB"}'
$ ps -C httpd -o rss= | awk '{s+=$1} END {print s" KB"}'
$ ss -s | head -3
ss -s로 확인한 ESTAB 연결 수를 함께 적어 두면 연결당 메모리를 나눠볼 수 있다. 여기서 나오는 값이 두 서버의 구조 차이를 가장 잘 드러낸다.
6. ab를 쓸 때는 옵션을 확인한다. ApacheBench는 공식 문서 표현대로 HTTP/1.x를 완전히 구현하지 않으며, -k를 주지 않으면 keep-alive를 쓰지 않는다. 오래된 비교 결과가 유난히 낮게 나올 때 자주 발견되는 원인 중 하나가 매 요청마다 TCP 연결을 새로 맺은 경우다.
$ ab -k -c 100 -n 20000 http://10.0.0.20/1k.bin
또 하나, ab는 단일 스레드라서 서버보다 부하 발생기가 먼저 포화될 수 있다. 동시연결을 크게 올리는 실험에는 wrk를 쓴다.
어느 쪽을 고를까
정적 파일과 리버스 프록시가 주 업무이고 동시연결이 수천 단위로 올라간다면 Nginx다. event MPM을 제대로 튠한 Apache로도 도달할 수 있는 영역이지만, 앞에서 본 것처럼 맞춰야 하는 값이 많고 상호 제약이 있다.
.htaccess 기반 배포물, mod_rewrite와 mod_security 같은 모듈 생태계, 인프로세스 실행 모듈이 필요하면 Apache다. 이때는 AllowOverride를 필요한 디렉터리에만 열고 나머지는 None으로 두는 것으로 손실을 줄인다.
실무에서 흔한 구성은 둘 중 하나를 고르는 것이 아니다. Nginx를 프런트에 두고 정적 파일과 TLS 종료, HTTP/3를 처리하게 하고, 그 뒤로 Apache나 Tomcat을 둔다. 이 경우 성능 비교의 대상은 프런트 구간뿐이고, 애플리케이션 응답 시간은 백엔드 튜닝의 몫으로 분리된다.
정리하면, 웹서버 선택은 정적 서빙 처리량보다 동시연결 구간의 연결당 메모리, 설정 방식, 필요한 모듈과 프로토콜로 갈린다. 결정 전에 위 절차로 30초짜리 wrk를 몇 번 돌려 볼 필요가 있다. 자기 환경의 곡선을 한 번 보고 나면 남의 숫자를 인용할 필요가 없어진다.