Claude Opus 4.7이 작성한 문서입니다.
참고: Benjamin Erb, Concurrent Programming for Scalable Web Architectures (Ulm University, 2012), §3.1 "Traditional Web Architectures". 이하 인용은 이 문서에 근거한다.
본문에서 사용하는 용어를 먼저 정리한다.
웹 서버 (web server)
HTTP 프로토콜을 처리하는 프로그램. 요청을 수신하고 응답을 반환한다. 그 자체는 애플리케이션 로직을 포함하지 않는다. 대표 예: Apache, nginx, 초기의 NCSA HTTPd.
애플리케이션 (application) 또는 애플리케이션 코드
비즈니스 로직을 담은 코드. 예를 들어 사용자 인증이나 주문 처리와 같은 판단을 수행한다. HTTP 프로토콜을 직접 다루지 않을 수도 있다. 대표 예: Django 뷰 함수, Spring 컨트롤러 메서드.
애플리케이션 서버 (application server)
웹 서버와 애플리케이션 코드 사이의 중간 다리. HTTP(또는 FastCGI 같은 게이트웨이 프로토콜)를 받아 애플리케이션 코드가 사용하는 형태(함수 인자, 객체)로 변환해 호출한다. 대표 예: Gunicorn, uWSGI, Unicorn, Tomcat(서블릿 컨테이너).
리버스 프록시 (reverse proxy)
클라이언트와 백엔드 서버 사이에서 요청을 중계하는 웹 서버의 한 역할. 정방향 프록시가 클라이언트를 대신한다면, 리버스 프록시는 서버를 대신해 외부 요청을 받는다. 현대의 nginx는 주로 이 역할로 쓰인다.
본문에서 "프론트엔드/백엔드"라는 용어는 사용하지 않는다. 이 문서가 다루는 대상은 전부 서버 측 구조이며, "프론트엔드"는 통상 브라우저 측 코드를 가리키는 용어로 혼동을 유발하기 때문이다.
각 단계는 이전 단계의 구체적 한계에 대한 응답으로 등장했다. 시대순으로 읽으면 현재 구조가 형성된 경위가 드러난다.
"In the early 90s, the first web servers were network servers that provided access solely to static files via HTTP." (Erb §3.1)
URI를 파일시스템 경로에 매핑해 그대로 반환하는 방식. 동적 생성 로직이 존재하지 않는다. 이 단계에서는 웹 서버만 존재하고, 애플리케이션은 없다.
최소 구현 (Python 표준 라이브러리):
python3 -m http.server 8000내부적으로 다음 코드와 동치이다:
from http.server import HTTPServer, SimpleHTTPRequestHandler
HTTPServer(("0.0.0.0", 8000), SimpleHTTPRequestHandler).serve_forever()동작: 요청 경로를 디스크 파일로 매핑해 바이트 스트림으로 응답한다. 서버 상태는 유지되지 않는다.
한계: 폼 제출에 응답하거나, 사용자별로 다른 HTML을 반환하거나, DB를 조회하는 것이 불가능하다. 정적 HTML에 가변 서버 상태를 덧붙이거나, HTML을 즉석에서 생성하거나, 폼 제출에 동적으로 응답하려는 수요가 곧바로 생겨났다 (Erb §3.1).
"For each incoming request against a URI mapped to a CGI application, the web server spawns a new process. ... Request entities can be read by the CGI process via STDIN, and the generated response ... are written to STDOUT. After generating the response, the CGI process terminates." (Erb §3.1)
웹 서버와 애플리케이션의 분리가 이 단계에서 처음 시작된다. 웹 서버는 HTTP만 담당하고, 동적 로직은 외부 실행 파일에 위임된다.
계약:
- 서버 → 프로세스: 요청 헤더·메타데이터를 환경변수로, 본문을 stdin으로 전달
- 프로세스 → 서버: 응답 헤더와 본문을 stdout으로 출력
- 요청당 프로세스 1개, 응답 후 종료
최소 구현 (cgi-bin/hello.py):
#!/usr/bin/env python3
import os, sys
sys.stdout.write("Content-Type: text/plain\r\n\r\n")
sys.stdout.write(f"Hello from PID {os.getpid()}\n")
sys.stdout.write(f"Method: {os.environ.get('REQUEST_METHOD')}\n")
sys.stdout.write(f"Path: {os.environ.get('PATH_INFO')}\n")실행:
chmod +x cgi-bin/hello.py
python3 -m http.server --cgi 8000
# GET http://localhost:8000/cgi-bin/hello.py매 요청마다 출력되는 PID가 달라지는 것을 확인할 수 있다. 이는 프로세스가 매번 새로 생성된다는 증거다.
cgi-bin 관례: 어느 요청을 실행할지 구분하는 장치다. Apache에서는 ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/로 URL 접두사와 실제 디렉터리를 연결한다. /cgi-bin/ 아래 경로로 들어온 요청만 프로그램으로 실행되고, 나머지는 정적 파일로 반환된다. URL 패턴으로 애플리케이션을 라우팅한다는 발상은 이 단계에서 확립되었다.
한계 (Erb §3.1):
- 프로세스 생성 비용. 프로세스는 작업에 비해 무거운 구조이며, 생성에 상당한 오버헤드와 자원을 요구한다. 요청마다 fork/exec이 일어나므로 지연이 증가하고 동시 요청 처리에 한계가 있다.
- 인터프리터 재시작. 스크립트 언어의 경우 매 요청마다 인터프리터가 재초기화되어 평균 지연이 더 악화된다.
- 분산 불가. stdin/stdout 통신은 두 프로세스가 반드시 같은 머신에 있어야 함을 강제한다. CGI로는 컴포넌트의 분산 배치가 불가능하다.
"FastCGI mitigates the main issues of CGI by specifying an interface protocol to be used via local sockets or TCP connections. ... the backend application can be implemented as a long-running process with internal multithreading. In effect, the overhead of per-request process creation is gone." (Erb §3.1)
CGI의 세 가지 한계에 대응하는 핵심 변화는 다음과 같다.
- stdin/stdout 파이프 대신 소켓(UNIX 도메인 소켓 또는 TCP) 기반 바이너리 프로토콜을 사용한다.
- 애플리케이션이 영속(long-running) 프로세스로 상주한다. fork 비용이 소멸된다.
- 소켓 기반이므로 애플리케이션을 다른 머신에 배치할 수 있다. 웹 서버와 애플리케이션이 물리적으로도 분리 가능해진다.
최소 구현 (Python flup 라이브러리 사용):
# app.py
from flup.server.fcgi import WSGIServer
def app(environ, start_response):
start_response("200 OK", [("Content-Type", "text/plain")])
return [b"Hello from FastCGI\n"]
if __name__ == "__main__":
# TCP 9000에서 FastCGI 프로토콜로 대기
WSGIServer(app, bindAddress=("127.0.0.1", 9000)).run()nginx 쪽 설정 (FastCGI 클라이언트 역할):
location / {
include fastcgi_params;
fastcgi_pass 127.0.0.1:9000;
}동작: nginx가 HTTP 요청을 받아 FastCGI 프로토콜로 app.py 프로세스에 전달한다. 애플리케이션은 종료하지 않고 다음 요청을 대기한다.
실무에서 가장 대표적인 사례는 PHP-FPM (FastCGI Process Manager)이다. nginx → fastcgi_pass → php-fpm 배선은 현재도 PHP 배포의 표준이다. nginx에서는 cgi-bin의 후예 격인 location ~ \.php$ { fastcgi_pass ...; } 패턴으로 "확장자가 .php인 요청을 FastCGI 소켓으로 위임"하는 라우팅을 구성한다. URL 패턴으로 애플리케이션을 라우팅한다는 CGI의 발상은 유지된 채, 실행 모델만 fork에서 영속 프로세스로 대체된 구조다.
남은 문제: 프로토콜이 언어 독립적이라는 장점이 있으나, 각 언어 프레임워크가 FastCGI 소켓을 직접 다루기에는 추상화 수준이 낮다. 서버 교체 시 프레임워크도 함께 교체해야 하는 상황이 Python/Ruby 진영에서 반복되었다.
이 시기에는 두 가지 발전이 동시에 진행된다. FastCGI가 서버와 프로세스 사이의 네트워크 프로토콜이라면, WSGI/Rack은 애플리케이션 서버와 애플리케이션 코드 사이의 함수 호출 규약으로 같은 프로세스 안에서 작동한다. 서로 다른 층의 규약이다.
대표적인 규약은 WSGI(Python, PEP 3333), Rack(Ruby), PSGI(Perl), Servlet API(Java)이다. WSGI 규약의 전부는 다음과 같다:
def application(environ, start_response):
start_response("200 OK", [("Content-Type", "text/plain")])
return [b"Hello, WSGI\n"]environ은 CGI 환경변수 딕셔너리를 그대로 계승한다. 즉 WSGI는 CGI의 함수 호출 버전에 해당하며, PEP 3333이 밝힌 설계 동기는 "프레임워크 선택과 서버 선택의 분리"이다.
이 구조는 객체지향 설계의 인터페이스-구현 분리와 동일하다. 게이트웨이 규약이 인터페이스, 애플리케이션 서버가 그 구현체, 애플리케이션 코드가 인터페이스를 구현하는 함수 본문이다. Java Servlet이 이 구조를 가장 명시적으로 드러낸다. jakarta.servlet.Servlet 인터페이스가 규약이고, 개발자는 HttpServlet을 상속해 doGet, doPost를 구현한다. Tomcat은 이 인터페이스를 이해하고 서블릿 객체의 생명주기를 관리·호출하는 서블릿 컨테이너, 즉 구현체다. 같은 서블릿 코드가 Tomcat, Jetty, WildFly 어디서든 동작하는 이유는 컨테이너들이 모두 동일한 명세를 구현하기 때문이다. WSGI도 구조적으로 같은 분리를 따른다:
| 층 | Java Servlet | Python WSGI |
|---|---|---|
| 명세/인터페이스 | Jakarta Servlet Spec (jakarta.servlet.Servlet) |
PEP 3333 (application(environ, start_response)) |
| 구현체(앱 서버) | Tomcat, Jetty, WildFly | Gunicorn, uWSGI, mod_wsgi, Waitress |
| 앱 코드 | HttpServlet 서브클래스 |
WSGI 호출 가능 객체 (Flask/Django 앱) |
형태상 차이는 있다. Servlet은 init, service, destroy 생명주기 메서드를 가진 객체지향 인터페이스인 반면, WSGI는 딕셔너리 하나와 콜백 하나로 이루어진 더 얇은 함수 호출 규약이다.
최소 구현 (Flask + Gunicorn):
# app.py
from flask import Flask
app = Flask(__name__)
@app.route("/")
def index():
return "Hello from Gunicorn worker\n"gunicorn --workers 4 --bind 127.0.0.1:8000 app:app동작: Gunicorn 마스터 프로세스가 워커 4개를 fork한다. 각 워커는 영속하며 app.application을 WSGI 규약으로 호출한다. HTTP 파싱, 동시성, 워커 수명 관리는 Gunicorn이 담당한다.
한계: 애플리케이션 서버의 주력 설계 목표는 애플리케이션 실행이지, 대량의 동시 연결 처리나 정적 파일 서빙, TLS 종단이 아니다. 이는 절대적 한계라기보다 구현별 특성의 문제다. Gunicorn 기본 sync 워커나 구버전 Tomcat BIO 커넥터처럼 블로킹 방식이 기본인 경우 slow client 문제가 발생하지만, Gunicorn은 gevent·gthread 워커 클래스로, Tomcat은 NIO 커넥터(버전 8부터 기본)로 이를 완화한다. 이 역할에 전문화된 이벤트 기반 웹 서버를 앞단에 두는 구성이 관행적으로 더 폭넓게 채택되어 왔다.
nginx는 Igor Sysoev가 C10K 문제(단일 서버에서 동시 연결 1만 개 처리) 해결을 목표로 이벤트 기반으로 설계한 웹 서버다 (Sysoev, The Architecture of Open Source Applications Vol. 2, Ch. "nginx").
Apache의 prefork/worker 모델은 연결당 프로세스 또는 스레드를 할당한다. 동시 연결 수가 증가하면 컨텍스트 스위칭과 메모리 사용량이 선형으로 증가한다. nginx는 단일 스레드 이벤트 루프와 epoll/kqueue를 사용해 수만 개의 연결을 유지할 수 있다.
현대의 전형적인 배치:
client ── HTTPS ──▶ nginx ──▶ Gunicorn/uWSGI ──▶ Flask/Django app
(80/443) (127.0.0.1:8000)
nginx가 담당하는 역할:
- TLS 종단 — 인증서 처리를 단일 지점에서 수행한다.
- 정적 파일 서빙 —
/static/경로를 디스크에서 직접 반환하며, 앱 서버로 전달하지 않는다. - 버퍼링 — 느린 클라이언트로부터 앱 서버 워커를 격리한다.
- 리버스 프록시 — 동적 요청만 앱 서버로 전달하고, 로드밸런싱을 수행한다.
- gzip 압축, 캐싱, rate limit 등 부가 기능.
최소 설정:
upstream app {
server 127.0.0.1:8000;
}
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/ssl/example.crt;
ssl_certificate_key /etc/ssl/example.key;
location /static/ {
alias /var/www/static/;
expires 1d;
}
location / {
proxy_pass http://app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}실행 계층:
# 애플리케이션 서버
gunicorn --workers 4 --bind 127.0.0.1:8000 app:app
# 웹 서버
nginx -c /etc/nginx/nginx.conf| 단계 | 웹 서버 | 애플리케이션 서버 | 애플리케이션 코드 | 해결한 문제 |
|---|---|---|---|---|
| 정적 HTTP | 단독 | — | — | 웹의 기본 |
| CGI | 있음 | — (요청마다 새 프로세스) | 별도 실행 파일 | 동적 콘텐츠 최초 도입 |
| FastCGI | 있음 | 영속 프로세스로 존재 | 앱 서버 안에 로드 | fork 비용·분산 해결 |
| WSGI + 앱 서버 | 있음 (선택적) | Gunicorn 등 | 앱 서버 안에 로드 | 서버/프레임워크 분리, 언어 내 추상화 |
| + nginx 리버스 프록시 | nginx | Gunicorn 등 | 앱 서버 안에 로드 | C10K, TLS, 정적 파일, 버퍼링 |
위 흐름은 지배적 패턴이며 유일한 배치는 아니다.
- 앱 서버와 앱 코드의 통합: Spring Boot는 Tomcat을 내장하고, Node.js와 Go는 HTTP 서버를 표준 라이브러리에 포함한다. 별도의 Gunicorn 같은 계층이 드러나지 않으며, 역할이 합쳐진 형태다.
- 웹 서버 생략: Spring Boot가 직접 TLS를 종단하고 CDN이나 클라우드 LB 뒤에 배치되는 경우, nginx 없이도 안전하게 운영할 수 있다.
- 다른 프록시로 대체: AWS ALB, Kubernetes Ingress, Envoy, Caddy, Traefik 등이 nginx 자리를 차지하는 경우가 있다. 배선 원리는 동일하고 구현체만 다르다.
- 병렬 갈래:
mod_php,mod_perl은 CGI의 다른 대안이었다. 인터프리터를 웹 서버에 임베드하는 방식이며, 현대에는 대부분 FastCGI 기반(php-fpm 등)으로 회귀했다.