← Blog
Computer Science / 웹 / 서버와 API

웹 서버와 WAS — 제품의 구분이 아니라 역할의 구분

이 주제를 정리한 글은 대개 한 줄로 끝난다. "정적 파일은 웹 서버, 동적 처리는 WAS."

외우기는 쉬운데, 이 한 줄로는 실제로 부딪히는 질문에 답할 수가 없다.

  • Node 서버 하나로 정적 파일도 주고 API도 처리되는데, 왜 굳이 앞에 nginx를 두는가?
  • Spring Boot는 톰캣을 안에 품고 있다. 그럼 WAS는 어디에 있는가?
  • 정적 파일도 잘 주는 Node는 웹 서버인가 WAS인가?

세 질문 모두 "정적/동적"이라는 기준으로는 풀리지 않는다. 기준 자체가 잘못 잡혀 있기 때문이다. 이 글은 두 문장을 척추로 삼는다.

둘의 구분은 무엇을 처리할 수 있느냐가 아니라, 무엇을 책임지느냐다.

그리고 웹 서버와 WAS는 제품 분류가 아니라 역할 분류다. 하나의 프로세스가 두 역할을 겸할 수 있다.

순서는 이렇다 — 책임으로 둘을 가르고(정의) → 왜 이 구분이 생겨났고 왜 앞단을 하나 더 두는지 보고(이유) → 그 역할들이 지금 어디에 놓여 있는지 판단한다.

웹 서버가 받아서 다루는 것이 정확히 무엇인지, 요청·응답 한 쌍이 어떻게 완결되는지는 HTTP — 무엇을 정하는 규약인가에서 다뤘다. 이 글은 그 요청을 누가 받고 누가 처리하는가에 관한 것이다.


1. 정의 — 책임으로 가른다

1-1. 두 역할의 책임

웹 서버 역할WAS 역할
책임HTTP 요청을 받아 정해진 응답을 돌려준다애플리케이션 코드를 실행하고, 그 실행 환경을 관리한다
주로 하는 일연결 수립·유지, 요청 파싱, 파일 서빙, 전달요청을 알맞은 코드로 넘기기, 스레드·객체 생명주기 관리, 자원 풀 유지
무엇을 아는가HTTP와 파일 시스템애플리케이션의 구조와 상태
바뀌는 주기설정 파일 단위배포 단위

마지막 줄이 둘의 성격 차이를 잘 보여준다. 웹 서버 역할은 정해진 규칙대로 처리하는 쪽이고, WAS 역할은 매번 다른 결과를 만들어내는 쪽이다.

1-2. "정적이냐 동적이냐"는 기준이 아니다

여기서 먼저 못 박을 것이 있다. 두 역할을 처리 대상으로 가르면 곧 어긋난다.

WAS도 정적 파일을 준다. 실제로 매일 그렇게 하고 있다.

Spring Boot는 static/ 아래 파일을 그냥 서빙하고, Express도 express.static 한 줄이면 된다. 반대로 웹 서버 쪽도 설정만으로 리다이렉트를 내보내고, 헤더를 바꾸고, 조건에 따라 다른 응답을 준다. 이건 "정해진 규칙대로"의 범위 안에 있는 동적 동작이다.

그러니 "정적/동적"은 결과일 뿐 기준이 아니다. 기준은 이것이다.

응답을 만들기 위해 우리가 짠 코드를 실행해야 하는가.

실행해야 한다면 그 요청은 WAS 역할이 처리한 것이고, 미리 정해둔 규칙만으로 응답이 나온다면 웹 서버 역할이 처리한 것이다. 어떤 프로그램이 그 일을 했는지와는 무관하다.

1-3. 서블릿 컨테이너 — Java 진영에서 규격이 된 WAS 역할

WAS를 이야기할 때 자주 함께 나오는 말이 서블릿 컨테이너다. 톰캣이 대표적인데, 개념은 위의 정의와 같다.

요청을 받아 알맞은 코드로 넘기고, 그 코드가 실행될 환경(스레드, 객체 생명주기)을 관리하는 것.

Java 진영에서는 이 역할을 스펙으로 표준화했다. 애플리케이션은 규격에 맞춰 만들고, 컨테이너는 그것을 실행한다. 그래서 애플리케이션과 실행 환경을 따로 만들고 따로 교체할 수 있다.

다른 언어권에는 이만큼 엄격한 규격이 없는 경우가 많지만, "코드를 실행하고 그 환경을 관리한다"는 책임 자체는 어느 언어에나 있다. 이름과 형태가 다를 뿐이다.

서블릿 스펙의 상세와 DispatcherServlet의 동작은 Spring 파트에서 다룬다. 여기서는 "컨테이너가 무엇을 책임지는가"까지만 본다.


2. 이유 — 왜 이렇게 나뉘었고, 왜 앞에 하나를 더 두는가

2-1. CGI에서 애플리케이션 서버로 — 발상이 한 번 뒤집혔다

처음의 웹 서버는 정말 단순했다. 문서를 꺼내주는 일만 했다. 요청받은 경로를 파일 시스템에서 찾아 그대로 돌려준다. 요청 하나에 파일 하나. 그게 전부였다.

곧 "매번 다른 내용"이 필요해졌다. 방문자 수를 보여주거나, 입력한 검색어에 맞는 결과를 만들어주는 것. 파일 시스템에 미리 만들어 둘 수 없는 응답이다.

첫 번째 답이 CGI였다. 방식은 이랬다.

  1. 요청이 오면 웹 서버가 프로그램을 하나 실행한다 (프로세스 생성)
  2. 요청 정보를 환경 변수와 표준 입력으로 넘긴다
  3. 그 프로그램이 표준 출력으로 뱉은 것을 응답으로 돌려준다
  4. 프로그램은 종료된다

동작은 했다. 그런데 4번이 문제였다. 요청 하나가 곧 프로세스 하나의 일생이었기 때문이다.

  • 요청마다 프로세스를 만들고 없앤다. 프로세스 생성은 싼 일이 아니다. 응답 자체보다 준비 비용이 더 큰 경우도 흔했다
  • DB 연결을 매번 새로 맺는다. 연결 수립에는 TCP 핸드셰이크와 인증이 들어가는데, 그 비용을 요청마다 낸다
  • 아무것도 재사용할 수 없다. 캐시를 만들어도 프로세스와 함께 사라진다. 초기화에 시간이 걸리는 작업은 매번 처음부터 다시 한다
  • 동시 요청이 늘면 프로세스가 그만큼 늘어난다. 메모리가 먼저 바닥난다

여기서 발상이 뒤집힌다.

요청마다 프로그램을 띄우지 말고, 프로그램을 띄워두고 요청을 들여보내자.

<!-- IMAGE: 위쪽은 CGI — 요청마다 프로세스를 생성·실행·종료하고 DB 연결도 매번 새로 맺는 모습과 그 비용(프로세스 생성, 연결 수립, 초기화 반복). 아래쪽은 애플리케이션 서버 — 살아 있는 프로세스 하나 안에 스레드 풀·커넥션 풀·캐시가 유지되고 요청들이 그 안으로 들어왔다 나가는 모습 -->

CGI와 애플리케이션 서버

이 한 줄의 전환에서 지금 우리가 쓰는 거의 모든 것이 나온다. 프로세스가 계속 살아 있다는 사실 하나가 이런 것들을 가능하게 만든다.

  • 스레드 풀 — 요청마다 실행 단위를 새로 만들지 않고 미리 만들어둔 것을 빌려 쓰고 반납한다
  • 커넥션 풀 — DB 연결을 맺어둔 채 재사용한다. 요청마다 핸드셰이크를 하지 않는다 (Connection Pool)
  • 인메모리 캐시 — 계산해둔 것이 요청 사이에 남아 있다
  • 한 번만 하는 초기화 — 설정 로딩, 의존성 구성, 연결 준비를 기동 시 한 번만 한다

그리고 이 "살아 있는 프로세스와 그 안의 자원을 관리하는 일"이 정확히 WAS 역할이다. 1-1의 표에서 WAS의 책임에 "실행 환경 관리"가 들어간 이유가 이것이다. 코드를 실행하는 것만이 아니라, 그 코드가 재사용할 자원들을 살려두는 것까지가 책임이다.

프로세스 생성이 왜 비싼지, 스레드가 왜 그보다 싼지는 프로그램 vs 프로세스 vs 스레드컨텍스트 스위칭에서 다룬다.

2-2. 혼자서도 다 되는데 왜 앞에 하나를 더 두는가

이제 애플리케이션 서버는 HTTP도 직접 받고, 정적 파일도 주고, 코드도 실행한다. 혼자서 전부 된다. 그런데도 실무 구성에서는 앞에 nginx 같은 것을 하나 더 둔다. 왜일까.

핵심은 자원 비용의 비대칭이다.

앞단에서 연결 하나를 붙들고 있는 비용과, 앱에서 연결 하나를 붙들고 있는 비용이 전혀 다르다.

① 느린 클라이언트 — 앞단을 두는 가장 중요한 이유

응답을 만드는 데 5ms가 걸렸다고 하자. 그런데 클라이언트가 지하철에서 접속한 모바일이라 그 응답을 다 받아가는 데 3초가 걸린다. 앱이 직접 응답을 쓰고 있다면 그 3초 동안 앱의 처리 단위 하나가 묶여 있다. 계산은 5ms에 끝났는데 네트워크가 느려서 3초를 기다리는 것이다.

동시 접속이 늘면 이 낭비가 그대로 상한이 된다. 스레드 풀이 200개라면, 느린 클라이언트 200명만으로 실제 일은 아무것도 안 하면서 서버가 가득 찬다.

앞단을 두면 그림이 달라진다.

  1. 앱이 응답을 앞단에 넘긴다 — 여기서 앱의 일은 끝난다. 처리 단위가 즉시 반납된다
  2. 앞단이 그 응답을 버퍼에 들고 있다가 느린 클라이언트에게 천천히 흘려보낸다

앞단은 이런 대기를 아주 싸게 처리하도록 만들어진 프로그램이다. 그래서 느린 연결을 붙드는 일은 앞단이 맡고, 앱은 계산만 하고 빠진다.

같은 논리가 반대 방향에도 적용된다. 요청 버퍼링이다. 큰 파일 업로드가 조금씩 들어오는 동안 앱이 그것을 직접 받고 있으면 역시 처리 단위가 묶인다. 앞단이 다 받아둔 다음 한 번에 넘겨주면 앱은 완성된 요청만 다룬다.

② TLS 종료

암복호화와 인증서 관리를 앞단이 전담한다. 그러면,

  • 앱은 평문 HTTP만 다루면 된다. 인증서 설정이 애플리케이션 코드와 무관해진다
  • 인증서를 갱신해도 앱을 재배포하지 않는다
  • 앱 인스턴스가 열 개여도 인증서는 앞단 한 곳에만 있으면 된다

TLS 핸드셰이크의 절차와 인증서 검증은 HTTPS와 TLS Handshake에서 다룬다.

③ 정적 파일 서빙

nginx는 정적 파일을 보낼 때 sendfile()을 쓴다. 디스크에서 소켓으로 커널 안에서 곧장 보내는 방식이다. 애플리케이션 메모리를 거치지 않는다.

앱이 서빙하면 어떻게 되는가. 파일을 읽어 애플리케이션 메모리로 올리고, 그것을 다시 커널로 내려보낸다. 복사가 두 번 더 일어나고, 그동안 앱의 동시성 슬롯이 이미지 한 장 때문에 묶인다. 로직을 실행하라고 만든 자원을 파일 복사에 쓰는 셈이다.

④ 로드 밸런싱과 무중단 배포

앞단이 뒤에 있는 여러 인스턴스로 요청을 나눠 보낸다. 배포할 때는 한 대씩 트래픽에서 빼고 교체한다. 사용자는 아무것도 눈치채지 못한다.

이게 가능한 이유는 앞단이 똑똑해서가 아니라 HTTP가 무상태이기 때문이다. 어느 인스턴스로 가도 결과가 같으니 마음대로 나눠 보낼 수 있다. 이 이야기는 HTTP의 특징 — 비연결성과 무상태에서 다룬 성질이 실제로 쓰이는 자리다.

⑤ 연결 관리와 보호

Keep-Alive 유지, 동시 연결 수 제한, 각종 타임아웃, 요청 크기 제한. 그리고 응답 압축, 정적 자원 캐싱.

⑥ 보안 경계

앱 프로세스를 외부에 직접 노출하지 않는다. 이상한 헤더를 걸러내고, 요청량을 제한하고, 필요한 경로만 뒤로 넘긴다.

정리하면 이렇게 된다.

앞단을 두는 이유는 그것이 더 빨라서가 아니라, 책임을 나눠 각자 잘하는 일만 하게 만들기 위해서다.

앱은 로직을 실행하는 데 최적화돼 있고, 앞단은 연결을 값싸게 붙들고 바이트를 옮기는 데 최적화돼 있다. 둘을 한 프로세스에 몰아넣으면 비싼 자원이 싼 일에 묶인다. 그 비대칭이 앞단의 존재 이유다.

2-3. 설정으로 보면 이렇다

앞의 이야기가 실제 설정에서는 이렇게 나타난다.

upstream app {
    server 127.0.0.1:3000;
    keepalive 32;                      # 앱과의 연결도 재사용한다
}

server {
    listen 443 ssl;
    server_name api.example.com;

    ssl_certificate     /etc/ssl/example.crt;   # ② TLS는 여기서 끝난다
    ssl_certificate_key /etc/ssl/example.key;

    client_max_body_size 20m;          # ⑤ 요청 크기 제한

    location /static/ {
        root /var/www;                 # ③ 정적 파일은 앱까지 가지 않는다
        sendfile on;                   #    커널 안에서 디스크 → 소켓
        expires 30d;
    }

    location / {
        proxy_pass http://app;         # 나머지만 앱으로 넘긴다
        proxy_http_version 1.1;

        proxy_buffering on;            # ① 응답을 다 받아두고 앱을 놓아준다
        proxy_request_buffering on;    #    요청도 다 받은 뒤에 넘긴다

        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;   # 앱은 평문으로 받으므로
    }                                                 # 원래 정보를 헤더로 넘겨준다
}

proxy_bufferingproxy_request_buffering 두 줄이 2-2 ①의 내용이고, location /static/ 블록이 ③이다. 설정 파일 몇 줄이 곧 책임의 경계선인 셈이다.


3. 판단 — 역할은 어디에 놓이는가

3-1. 제품이 아니라 역할이다

이 글에서 가장 헷갈리는 지점이 여기다. "nginx는 웹 서버, 톰캣은 WAS"처럼 소프트웨어를 두 통에 나눠 담는 것으로 이해하면 반드시 막힌다.

HTTP 요청을 받는 일과 애플리케이션 코드를 실행하는 일은 서로 다른 일이지, 서로 다른 프로그램이어야 하는 것은 아니다.

<!-- IMAGE: 웹 서버 역할(회색)과 WAS 역할(파랑)이 시대에 따라 어디에 놓였는지 3단으로. ① Apache + Tomcat, 톰캣 안에 war 여러 개 ② nginx + 내장 톰캣이나 Node로 이루어진 프로세스 하나 ③ CDN·클라우드 로드밸런서·Ingress로 웹 서버 역할이 흩어지고 뒤에 앱 컨테이너가 여러 개인 배치 -->

웹 서버 역할과 WAS 역할의 배치

이 관점으로 앞의 질문들을 다시 보자.

"Spring Boot는 톰캣을 내장하는데, 그럼 WAS는 어디 있는가?"

내장된 그 톰캣이 WAS다. 그리고 동시에 웹 서버이기도 하다. 톰캣은 HTTP 연결을 직접 받아 파싱하고(웹 서버 역할), 그 요청을 서블릿으로 넘겨 코드를 실행한다(WAS 역할). 한 프로세스가 두 역할을 겸하는 것이다.

여기서 짚어둘 것이 있다. 예전에 아파치를 톰캣 앞에 둔 것은 톰캣이 HTTP를 못 받아서가 아니다. 톰캣은 처음부터 둘 다 할 수 있었다. 다만 웹 서버 역할, 특히 2-2에서 본 연결 관리와 정적 서빙을 아파치가 더 잘했기 때문에 그쪽에 맡긴 것이다. 못 해서 나눈 게 아니라 잘하는 쪽에 맡긴 것이다.

이름 때문에 오해하기 쉬운데, 아파치 HTTP 서버와 아파치 톰캣은 서로 다른 소프트웨어다. 같은 재단의 프로젝트라 이름 앞에 같은 단어가 붙었을 뿐, 하는 일도 만들어진 목적도 다르다.

"Node는 웹 서버인가 WAS인가?"

둘 다다. http 모듈이 연결을 받고 요청을 파싱하는 것은 웹 서버 역할이고, 그 위에서 핸들러를 실행하며 커넥션 풀과 캐시를 살려두는 것은 WAS 역할이다. 코드로 보면 이렇게 한 파일 안에 둘이 같이 있다.

import express from 'express';

const app = express();

// 웹 서버 역할 — 정해진 규칙대로 파일을 돌려준다
app.use('/static', express.static('public'));

// WAS 역할 — 우리가 짠 코드를 실행해서 응답을 만든다
app.get('/orders/:id', async (req, res) => {
  const order = await db.orders.findById(req.params.id);  // 살아 있는 커넥션 풀을 쓴다
  if (!order) return res.status(404).json({ code: 'ORDER_NOT_FOUND' });
  res.json(order);
});

app.listen(3000);

혼자서 다 된다. 그리고 이것이 2-2의 질문을 다시 부른다. 다 되는데도 앞단을 두는 이유는 못 해서가 아니라, 그 일을 하는 동안 이 프로세스가 정작 잘하는 일을 못 하기 때문이다.

정리하면 이렇게 된다.

웹 서버와 WAS는 어떤 프로그램이냐가 아니라, 지금 이 요청에 대해 무엇을 책임지고 있느냐다.

3-2. 그래서 앞단을 항상 둬야 하는가

역할 관점으로 보면 이 질문에도 답할 수 있다. 앞단이라는 물리적 구성이 필요한 게 아니라, 웹 서버 역할이 어디선가 수행되면 된다.

앞단을 따로 두지 않아도 되는 경우

  • 플랫폼이 이미 그 역할을 하고 있을 때 — 관리형 호스팅이나 서버리스 환경에서는 TLS 종료·부하 분산·정적 서빙을 플랫폼이 담당한다. 그 앞에 nginx를 또 두면 계층만 하나 늘어난다
  • 정적 자원을 CDN이 맡고 있을 때 — 2-2 ③의 이유가 이미 해결돼 있다
  • 내부 통신용 서비스 — 외부에 노출되지 않아 느린 클라이언트 문제도, 보안 경계 문제도 성립하지 않는다
  • 트래픽이 작고 운영 인력이 적을 때 — 구성 요소가 하나 늘면 설정과 장애 지점도 하나 는다. 얻는 것보다 관리 비용이 클 수 있다

앞단이 필요한 경우

  • 인스턴스를 직접 운영하고, 그 앞에서 TLS와 부하 분산을 스스로 처리해야 할 때
  • 외부에 직접 노출되어 느린 연결과 악의적 요청을 그대로 받게 될 때
  • 여러 앱을 도메인·경로로 나눠 한 진입점 뒤에 배치할 때
  • 정적 자원이 많고 CDN을 쓰지 않을 때

판단 기준은 하나로 줄어든다.

"nginx를 둘 것인가"가 아니라 "웹 서버 역할을 지금 누가 하고 있는가"를 먼저 묻는다.

아무도 하고 있지 않다면 두는 것이고, 이미 누군가 하고 있다면 굳이 하나 더 얹을 이유가 없다.

3-3. 앞단은 왜 아파치가 아니라 nginx가 됐는가

앞단에 두는 프로그램으로 지금은 대개 nginx 계열을 쓴다. 이유는 연결을 다루는 방식이 다르기 때문이다.

아파치(전통적 구성)nginx
연결 하나당프로세스 또는 스레드 하나별도 실행 단위 없음
처리 방식배정된 실행 단위가 그 연결을 담당프로세스 몇 개가 수많은 연결을 이벤트로 번갈아 처리
연결이 늘면실행 단위가 그만큼 늘어난다크게 늘지 않는다
한계 지점메모리와 컨텍스트 스위칭CPU

차이는 놀고 있는 연결에서 벌어진다. 2-2 ①의 느린 클라이언트를 떠올려보자. 3초 동안 아무 일도 안 하면서 연결만 붙들고 있는 상황이다.

  • 아파치 방식에서는 그 3초 동안 실행 단위 하나가 통째로 묶인다. 연결이 만 개면 실행 단위도 그만큼 필요하고, 메모리가 먼저 바닥난다
  • nginx 방식에서는 연결이 자료구조 하나로 남아 있을 뿐이다. 실제로 보낼 데이터가 생겼을 때만 처리한다

동시 연결이 폭증할 때 전자가 무너지는 문제를 흔히 C10K 문제라고 부른다. 앞단이 nginx 계열로 넘어간 배경이 이것이다.

이벤트 방식이 왜 연결을 싸게 다루는지, 그 밑에서 커널이 무엇을 해주는지는 I/O 멀티플렉싱Node.js와 libuv에서 다룬다. 여기서는 연결 하나의 비용이 다르다는 결론만 쓴다.

이 표를 보면 3-1의 이야기가 한 번 더 확인된다. nginx가 "웹 서버"인 이유는 코드를 실행하지 못해서가 아니다. 연결을 값싸게 붙드는 데 특화된 구조라서, 그 역할을 맡기기에 적합한 것이다.

3-4. 지금 웹 서버 역할은 어디로 갔는가

한 대의 장비에 아파치와 톰캣을 나란히 두던 시절과 지금은 배치가 많이 다르다. 그런데 역할이 사라진 것은 아니고, 흩어졌다.

역할예전지금 자주 놓이는 곳
TLS 종료앞단 웹 서버클라우드 로드밸런서, Ingress Controller
정적 서빙앞단 웹 서버CDN
부하 분산앞단 웹 서버클라우드 로드밸런서, 오케스트레이터의 서비스 계층
연결 관리·버퍼링앞단 웹 서버로드밸런서, 사이드카 프록시
요청 제한·필터링앞단 웹 서버API 게이트웨이
코드 실행WAS앱 컨테이너 (내장 서버 포함)

앞단 하나가 하던 일이 여러 계층으로 나뉘었다. 그래서 요즘 애플리케이션은 내장 서버 하나만 들고 컨테이너 안에 있는 형태가 흔하다. 앞에 nginx가 안 보인다고 웹 서버 역할이 없어진 것이 아니라, 그 역할이 인프라 쪽으로 옮겨간 것이다.

이 표가 이 글의 척추를 다시 보여준다. 제품 이름으로 외웠다면 배치가 바뀔 때마다 다시 외워야 하지만, 역할로 이해하면 어디로 옮겨가든 같은 질문으로 읽을 수 있다. "이 역할은 지금 누가 하고 있는가."

이런 배치에서의 확장과 부하 분산은 수직 확장 vs 수평 확장에서 다룬다.

3-5. 정리

이 글을 다섯 줄로 압축하면 이렇다.

  1. 기준은 "정적이냐 동적이냐"가 아니라 "우리가 짠 코드를 실행해야 하는가"다. WAS도 정적 파일을 준다.
  2. CGI에서 애플리케이션 서버로의 전환은 발상을 뒤집은 것이다. 요청마다 프로그램을 띄우는 대신 프로그램을 띄워두고 요청을 들여보냈고, 거기서 스레드 풀·커넥션 풀·캐시가 전부 따라 나왔다.
  3. 앞단을 두는 이유는 속도가 아니라 자원 비용의 비대칭이다. 느린 연결을 붙드는 일은 앞단이 싸게 하고, 앱은 계산만 하고 빠진다.
  4. 웹 서버와 WAS는 제품 분류가 아니라 역할 분류다. 내장 톰캣도, Node도 두 역할을 겸한다.
  5. 역할은 사라지지 않고 옮겨간다. 지금은 CDN·로드밸런서·게이트웨이로 흩어져 있을 뿐이다.

처음의 질문들에 답하면 이렇게 된다.

Node 앞에 nginx를 두는 것은 Node가 못 해서가 아니라, 그 일을 하는 동안 Node가 잘하는 일을 못 하기 때문이다. 내장 톰캣에서 WAS가 어디 있냐면, 그 톰캣이 두 역할을 함께 하고 있는 것이다. 물어야 할 것은 "이것은 웹 서버인가 WAS인가"가 아니라 "이 역할을 지금 누가 맡고 있는가"다.


관련 글