Post

Tomcat: 커넥터, 서블릿 컨테이너, 스레드 풀

Tomcat의 HTTP 요청/응답 처리 흐름 구현해보기

Tomcat: 커넥터, 서블릿 컨테이너, 스레드 풀

Tomcat

웹 페이지 처리하기

웹 페이지는 콘텐츠의 종류에 따라 처리 방식이 나뉜다. 정적 콘텐츠(Static Content)인 HTML, CSS 등은 Web Server가 처리하고, 동적 콘텐츠(Dynamic Content)인 JSP, Servlet은 Servlet Container가 처리한다. 여기서 Servlet이란 ‘서버(Server)’에서 실행되는 작은 ‘애플릿(Applet)’의 합성어로, 사용자의 요청에 따라 Java를 사용해 웹 페이지를 동적으로 생성하는 서버 측 프로그램이다. Spring Boot에 내장된 Tomcat은 이러한 Servlet Container의 대표적인 구현체 중 하나이다.


Tomcat, Servlet Container, WAS

Tomcat은 Servlet Container의 구현체이지만 종종 WAS(Web Application Server)라고도 불린다. 이 둘은 엄연히 다른 개념인데도 불구하고 왜 혼용되는 것일까? 본래 WAS는 웹 애플리케이션 실행에 필요한 종합 환경을 의미하며, Servlet Container는 Servlet 실행을 담당하는 WAS의 핵심 부품이다. 즉 전통적인 WAS는 Servlet Container 기능은 물론 트랜잭션 관리, DB Connection Pool 등 기업용 애플리케이션에 필요한 무거운 기능들을 모두 제공하는 종합 서버의 역할을 맡았다. 반면 순수한 Servlet Container는 JSP와 Servlet의 생명주기를 관리하며 동적 콘텐츠를 생성하는 역할에 집중했다.

그러나 Tomcat은 Servlet Container의 핵심 기능인 동적 콘텐츠 처리뿐만 아니라, DB 접속이나 트랜잭션 관리와 같이 비즈니스 로직 수행에 필수적인 환경까지 제공한다. 이처럼 순수한 Servlet Container를 넘어 WAS의 핵심적인 역할을 일부 수행하기에 Tomcat을경량 WAS라고 부르는 것이다. 이로 인해 Tomcat, Servlet Container, WAS라는 용어에 혼동이 생기곤 한다. 비유하자면 WAS가 ‘자동차’라는 개념이고 Servlet Container가 ‘엔진’이라면, Tomcat은 가장 대표적인 ‘자동차 엔진’에 바퀴와 운전대를 덧붙여 만든 ‘초소형차’에 빗대어 표현할 수 있다.

추가로 Tomcat은 정적 콘텐츠를 처리하는 Web Server 기능도 갖추고 있다. Tomcat 내부에서 정적 콘텐츠 처리코요테(Coyote)라는 커넥터(Connector)가, 동적 콘텐츠 처리카탈리나(Catalina)라는 Servlet Container가 담당한다. 하지만 성능상의 이유로 실제 운영 환경에서는 정적 콘텐츠 처리를 위해 Nginx나 Apache HTTP Server 같은 전문 Web Server를 Tomcat 앞에 두고 연동하는 방식을 보편적으로 사용한다.


Tomcat 동작 원리

Tomcat은 클라이언트의 요청을 받아 처리하고 응답하기 위해 클라이언트의 요청을 받아주는 커넥터와 실제 로직을 처리하는 Servlet Container이 협력한다.


Tomcat 동작 단계

1단계: 사용자 요청 발생 및 전송

사용자가 웹 브라우저에 URL을 입력하면, 브라우저는 서버에 보낼 HTTP 요청 메시지를 생성한다. 이 요청은 인터넷을 통해 Tomcat 서버의 IP 주소와 포트(기본 8080)로 전송된다.

  • HTTP 요청 메시지 구조: HTTP 요청은 크게 세 부분으로 구성된다.

    1. 시작 라인(Start Line): 요청의 종류, 경로, HTTP 버전을 나타낸다. (예: GET /users/1 HTTP/1.1)
    2. 헤더(Headers): 요청에 대한 부가 정보를 담고 있다. (예: Host: example.com, User-Agent: Chrome)
    3. 본문(Body): POSTPUT 요청 시 서버로 전송할 데이터를 담는다. (예: JSON, 폼 데이터)
  • 요청 메시지 예시

    1
    2
    3
    4
    5
    
    GET /index.html HTTP/1.1
    Host: www.example.com
    User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...
    Accept: text/html,*/*
    Accept-Language: ko-KR,ko;q=0.9
    

2단계: 연결 수립 및 요청 객체 생성 (Connector - Coyote)

Tomcat의 가장 앞단에 있는 커넥터(Connector), 즉 코요테(Coyote)가 사용자의 요청을 기다리고 있다. 코요테는 클라이언트와 TCP/IP 연결을 맺고, 네트워크를 통해 들어온 HTTP 메시지를 파싱하여 HttpServletRequestHttpServletResponse 객체로 변환한다.

  • 스레드 풀 & server.xml

    코요테는 매 요청마다 스레드를 생성하는 비효율을 막기 위해 스레드 풀을 사용한다. 미리 여러 개의 스레드를 생성해두고, 요청이 들어오면 대기 중인 스레드를 할당하여 처리한다. 이와 관련된 설정은 conf/server.xml 파일에서 관리한다.

  • server.xml의 커넥터 설정

    1
    2
    3
    4
    
      <Connector port="8080" protocol="HTTP/1.1"
              connectionTimeout="20000"
              maxThreads="150"
              redirectPort="8443" />
    

3단계: 요청 처리 위임 (Engine - Catalina)

코요테는 생성한 요청/응답 객체를 Tomcat의 핵심 엔진인 Servlet Container, 즉 카탈리나(Catalina)에게 전달한다. 여기서부터 실제 웹 애플리케이션의 로직 처리 과정이 시작된다.

  • Tomcat 컨테이너 계층 구조

    카탈리나는 요청을 처리할 웹 애플리케이션을 찾기 위해 계층 구조를 사용한다.

    1. Engine: Tomcat 전체를 대표하는 최상위 컨테이너.
    2. Host: www.example.com과 같은 가상 호스트(도메인)를 나타낸다.
    3. Context: 하나의 웹 애플리케이션을 의미한다. (/ 또는 /my-app)
  • server.xml의 컨테이너 구조

    1
    2
    3
    4
    5
    
      <Engine name="Catalina" defaultHost="localhost">
      <Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true">
          <Context path="" docBase="ROOT" />
      </Host>
      </Engine>
    

4단계: 담당 Servlet 탐색 및 실행

카탈리나는 URL과 경로 정보를 분석하여 요청을 처리할 담당 Servlet을 찾는다. 이때 웹 애플리케이션의 배포 서술자(web.xml)나 Java 어노테이션(@WebServlet)에 정의된 매핑 정보를 참고한다. Servlet 실행 전, 필터 체인(Filter Chain)이 먼저 동작하여 공통 로직(인코딩, 보안 등)을 처리할 수 있다.

  • Servlet 매핑

    URL 패턴과 Servlet 클래스를 연결하는 과정이다. 과거에는 web.xml에 정의했지만, 현재는 어노테이션으로 정의한다.

  • @WebServlet 어노테이션을 이용한 Servlet 매핑

    1
    2
    3
    4
    5
    
    // "/hello" 라는 URL 요청이 오면 이 Servlet을 실행하도록 매핑한다.
    @WebServlet(name = "helloServlet", value = "/hello")
    public class HelloServlet extends HttpServlet {
      // ...
    }
    

5단계: 비즈니스 로직 수행

호출된 Servlet은 doGet() 또는 doPost() 메서드 안에서 개발자가 작성한 비즈니스 로직을 처리하고 결과를 생성한다. 이 과정은 스레드 풀의 스레드에 의해 실행되므로, 여러 사용자의 요청을 동시에 처리할 수 있다.

  • Servlet 생명주기 및 스레드 안전성

    Servlet 객체는 기본적으로 싱글턴으로 관리되며, 컨테이너가 생명주기(init(), service(), destroy())를 제어한다. 하나의 Servlet 인스턴스를 여러 스레드가 공유하므로 멤버 변수 사용 시 스레드 안전성을 반드시 고려해야 한다.

  • doGet 메서드 내 로직 처리

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    public class HelloServlet extends HttpServlet {
      public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
          // 1. 요청 파라미터 확인
          String name = request.getParameter("name");
          if (name == null) {
              name = "Guest";
          }
          // 2. 비즈니스 로직 수행 (DB 조회 등)
          String message = "Hello, " + name;
          // 3. 결과를 request 객체에 담아 View(JSP)로 전달
          request.setAttribute("message", message);
          // 4. JSP로 포워딩
          request.getRequestDispatcher("/WEB-INF/views/hello.jsp").forward(request, response);
      }
    }
    

6단계: 동적 HTML 페이지 생성 (JSP)

Servlet은 처리 결과를 JSP(Java Server Pages) 파일에 전달하여 화면을 그리도록 위임하는 것이 일반적이다. JSP는 전달받은 데이터를 이용해 동적인 HTML 페이지를 완성하고, 그 내용을 HttpServletResponse 객체에 담는다.

  • JSP 처리 과정 및 MVC 패턴

    JSP 파일은 최초 요청 시 Tomcat의 재스퍼(Jasper) 엔진에 의해 Servlet Java 코드로 변환된 후 컴파일된다. 이후에는 컴파일된 Servlet 클래스가 직접 실행되어 성능상 이점이 있다. 이러한 구조는 로직(Servlet: Controller), 데이터(JavaBean: Model), 화면(JSP: View)을 분리하는 MVC 디자인 패턴에 해당한다.

  • JSP에서 데이터 출력

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
      <%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
      <!DOCTYPE html>
      <html>
      <head>
          <title>Hello Page</title>
      </head>
      <body>
          <%-- Servlet에서 request.setAttribute("message", ...)로 넘겨준 값을 출력 --%>
          <h1>${requestScope.message}</h1>
      </body>
      </html>
    

7단계: 응답 전송 및 연결 종료

완성된 HttpServletResponse 객체는 다시 커넥터(Coyote)에게 반환된다. 코요테는 이 Java 객체의 내용을 실제 HTTP 응답 메시지로 변환하여 웹 브라우저에게 전송한다. 응답이 완료되면 연결을 종료하고, 브라우저는 수신한 HTML을 화면에 렌더링한다.

  • HTTP 응답 메시지 구조: HTTP 응답은 HTTP 요청과 유사하게 세 부분으로 구성된다.

    1. 상태 라인(Status Line): HTTP 버전, 상태 코드, 상태 텍스트를 포함한다. (예: HTTP/1.1 200 OK)
    2. 헤더(Headers): 응답에 대한 부가 정보를 담는다. (예: Content-Type: text/html, Content-Length: 123)
    3. 본문(Body): 실제 브라우저에 표시될 내용(HTML 등)이다.
  • 응답 메시지 예시

    1
    2
    3
    4
    5
    
    HTTP/1.1 200 OK
    Content-Type: text/html;charset=UTF-8
    Content-Length: 154
    Date: Fri, 10 Oct 2025 04:40:00 GMT
    html content
    

Tomcat Connector 동작 단계

Tomcat은 클라이언트 연결이 수립될 때마다 스레드 풀에서 가용 스레드를 할당받아 작업을 처리하는 방식으로 동작한다. Executors를 활용해 이러한 스레드 풀 기반의 요청 처리 과정을 구현해봤다.

https://github.com/yeonnhuu/java-http/blob/main/tomcat/src/main/java/org/apache/catalina/connector/Connector.java

  • Connector.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    
    public class Connector implements Runnable {
    
      private static final Logger log = LoggerFactory.getLogger(Connector.class);
    
      private static final int DEFAULT_PORT = 8080;
      private static final int DEFAULT_ACCEPT_COUNT = 100;
      private static final int DEFAULT_MAX_THREADS = 250;
    
      private final ServerSocket serverSocket;
      private final ExecutorService executorService;
      private boolean stopped;
    
      public Connector() {
          this(DEFAULT_PORT, DEFAULT_ACCEPT_COUNT, DEFAULT_MAX_THREADS);
      }
    
      public Connector(final int port, final int acceptCount, final int maxThreads) {
          this.serverSocket = createServerSocket(port, acceptCount);
          this.executorService = Executors.newFixedThreadPool(maxThreads);
          this.stopped = false;
          log.info("Connector configured with port: {}, acceptCount: {}, maxThreads: {}",
                  serverSocket.getLocalPort(), acceptCount, maxThreads);
      }
    
      private ServerSocket createServerSocket(final int port, final int acceptCount) {
          try {
              final int checkedPort = checkPort(port);
              final int checkedAcceptCount = checkAcceptCount(acceptCount);
              return new ServerSocket(checkedPort, checkedAcceptCount);
          } catch (final IOException e) {
              log.error("Could not create server socker on port {}", port, e);
              throw new UncheckedIOException(e);
          }
      }
    
      public void start() {
          final var thread = new Thread(this);
          thread.setDaemon(true);
          thread.start();
          stopped = false;
          log.info("Connector started and listening on port {}", serverSocket.getLocalPort());
      }
    
      @Override
      public void run() {
          // 클라이언트가 연결될때까지 대기한다.
          log.info("Ready to accept connections...");
          while (!stopped) {
              connect();
          }
          log.info("Connector run loop finished.");
      }
    
      private void connect() {
          try {
              final Socket connection = serverSocket.accept();
              log.debug("Accepted connection from: {}", connection.getRemoteSocketAddress());
              process(connection);
          } catch (final IOException e) {
              log.error("Error accepting connection", e);
          }
      }
    
      private void process(final Socket connection) {
          if (connection == null) {
              return;
          }
    
          log.debug("Submitting connection to processor worker thread.");
          final var processor = new Http11Processor(connection);
          executorService.execute(processor);
      }
        
      public void stop() {
          log.info("Stopping connector...");
          stopped = true;
          try {
              serverSocket.close();
              shutdownExecutorService();
          } catch (final IOException e) {
              log.error("Error while closing server socket", e);
          }
          log.info("Connector stopped successfully.");
      }
    
      private void shutdownExecutorService() {
          log.info("Attempting to shut down thread pool...");
          executorService.shutdown();
          try {
              if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) {
                  log.warn("Thread pool did not terminate in 5 seconds. Forcing shutdown...");
                  executorService.shutdownNow();
              }
          } catch (final InterruptedException e) {
              log.error("Thread pool shutdown was interrupted. Forcing shutdown.", e);
              executorService.shutdownNow();
              Thread.currentThread().interrupt();
          }
          log.info("Thread pool has been shut down.");
      }
    
      private int checkPort(final int port) {
          final var MIN_PORT = 1;
          final var MAX_PORT = 65535;
    
          if (port < MIN_PORT || MAX_PORT < port) {
              log.warn("Invalid port number: {}. Using default port: {}", port, DEFAULT_PORT);
              return DEFAULT_PORT;
          }
          return port;
      }
    
      private int checkAcceptCount(final int acceptCount) {
          if (acceptCount <= 0) {
              log.warn("Invalid acceptCount: {}. Using default value: {}", acceptCount, DEFAULT_ACCEPT_COUNT);
              return DEFAULT_ACCEPT_COUNT;
          }
          return Math.max(acceptCount, DEFAULT_ACCEPT_COUNT);
      }
    }
    

1단계: 서버 준비 및 시작

Connector 객체 생성 시 서버 동작에 필요한 핵심 컴포넌트들을 초기화하고, start() 메서드를 통해 클라이언트 연결을 수신할 준비를 마친다.

  1. ServerSocket 생성: new Connector(...)가 호출되면, 지정된 포트에서 클라이언트의 연결 요청을 수신할 ServerSocket을 연다. acceptCount는 운영체제 레벨에서 처리되지 않은 연결 요청을 대기시킬 큐의 최대 크기를 의미한다.
  2. ExecutorService 생성: Executors.newFixedThreadPool(maxThreads)를 통해 실제 요청을 처리할 작업자 스레드(Worker Thread)들로 구성된 스레드 풀을 생성한다. 이 스레드들은 생성 직후 작업을 기다리는 대기 상태가 된다.
  3. Connection Accepter Thread 시작: connector.start()Connector 인스턴스 자신을 실행할 단일 스레드를 생성하여 시작한다. “Connection Accepter Thread”라고 불리는 이 스레드의 유일한 임무는 ServerSocket에서 클라이언트의 접속을 계속해서 기다리는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Connector implements Runnable {
    // ...
    public Connector(final int port, final int acceptCount, final int maxThreads) {
        // 1. ServerSocket 생성
        this.serverSocket = createServerSocket(port, acceptCount);
        // 2. ExecutorService (스레드 풀) 생성
        this.executorService = Executors.newFixedThreadPool(maxThreads);
        this.stopped = false;
    }

    public void start() {
        // 3. Connection Accepter Thread 생성 및 시작
        final var thread = new Thread(this);
        thread.setDaemon(true);
        thread.start();
        stopped = false;
        log.info("Connector started and listening on port {}", serverSocket.getLocalPort());
    }
    // ...
}

2단계: 클라이언트 연결 대기 및 수락

“Connection Accepter Thread”는 run() 메서드 안의 루프를 돌며 클라이언트의 접속을 받아들이는 역할만을 전담한다.

  1. run() 메서드 진입: “Connection Accepter Thread”는 while (!stopped) 루프에 진입하여 connect() 메서드를 반복적으로 호출한다.
  2. serverSocket.accept() 호출: accept() 메서드는 클라이언트가 접속할 때까지 실행을 멈추고 대기하는 블로킹(Blocking) 호출이다. 이 상태에서는 CPU 자원을 소모하지 않는다.
  3. Socket 생성 및 위임: 클라이언트가 접속하면, accept()는 해당 클라이언트와 1:1 통신을 위한 Socket 객체를 반환한다. “Connection Accepter Thread”는 이 Socket을 직접 처리하지 않고, 즉시 다음 단계로 위임한 뒤 루프의 처음으로 돌아가 또 다른 접속을 기다린다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Connector implements Runnable {
    // ...
    @Override
    public void run() {
        log.info("Ready to accept connections...");
        while (!stopped) {
            connect();
        }
    }

    private void connect() {
        try {
            // 클라이언트가 접속할 때까지 여기서 대기 (Blocking)
            final Socket connection = serverSocket.accept();
            log.debug("Accepted connection from: {}", connection.getRemoteSocketAddress());
            // 연결된 소켓을 다음 단계로 위임
            process(connection);
        } catch (final IOException e) {
            log.error("Error accepting connection", e);
        }
    }
    // ...
}

3단계: 작업 생성 및 스레드 풀에 위임

연결이 수립되면, “Connection Accepter Thread”는 해당 연결에 대한 처리 작업을 생성하여 스레드 풀에 제출한다.

  1. process(connection) 호출: “Connection Accepter Thread”는 생성된 Socket을 인자로 process() 메서드를 호출한다.
  2. Http11Processor 생성: 클라이언트의 요청을 처리하는 데 필요한 모든 로직을 캡슐화한 Runnable 작업(task), 즉 Http11Processor 객체를 생성한다.
  3. executorService.execute(processor) 호출: 생성된 Http11Processor 작업을 스레드 풀(ExecutorService)에 제출한다. 이 시점에서 “Connection Accepter Thread”의 역할은 끝나며, 다시 2단계로 돌아가 다음 연결을 수락하는 작업에만 집중한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Connector implements Runnable {
    // ...
    private void process(final Socket connection) {
        if (connection == null) {
            return;
        }

        log.debug("Submitting connection to processor worker thread.");
        // 1. 처리할 작업을 생성
        final var processor = new Http11Processor(connection);
        // 2. 생성된 작업을 스레드 풀에 제출
        executorService.execute(processor);
    }
    // ...
}

4단계: 실제 요청 처리

스레드 풀에 제출된 작업은 대기 중인 작업자 스레드(Worker Thread)에 의해 실행된다.

  1. Worker Thread 배정: ExecutorService는 내부 작업 큐(Queue)에서 Http11Processor 작업을 꺼내, 유휴 상태인 Worker Thread에게 할당한다. 만약 모든 스레드가 다른 작업을 처리 중이라면, 해당 작업은 큐에서 대기한다.
  2. Http11Processor.run() 실행: 작업을 할당받은 Worker ThreadHttp11Processorrun() 메서드를 실행한다. 이 메서드 안에서 요청 파싱, 컨트롤러 탐색 및 실행, 응답 생성 및 전송 등 실제 처리의 전 과정이 일어난다.
  3. Worker Thread 복귀: run() 메서드의 모든 작업을 마친 Worker Thread는 소멸하지 않고, 다시 ExecutorService의 스레드 풀로 복귀하여 다음 작업을 기다리는 대기 상태가 된다. 이것이 스레드를 재사용하는 스레드 풀의 핵심 원리이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Http11Processor implements Runnable {
    private final Socket socket;
    // ...

    @Override
    public void run() {
        try (final var inputStream = socket.getInputStream();
             final var outputStream = socket.getOutputStream()) {
            
            // 1. 요청 파싱 -> HttpRequest 객체 생성
            final HttpRequest httpRequest = HttpRequestParser.parse(inputStream);
            final HttpResponse httpResponse = new HttpResponse(outputStream);

            // 2. 요청에 맞는 Controller 탐색
            final Controller controller = RequestMapping.getController(httpRequest.getPath());

            // 3. Controller 비즈니스 로직 실행
            final ControllerResult controllerResult = controller.execute(httpRequest, httpResponse);
            
            // 4. 결과에 따라 HttpResponse 객체 완성 및 전송
            controllerResult.render(httpResponse);

        } catch (final IOException e) {
            log.error("Error processing request", e);
        }
        // try-with-resources에 의해 socket과 stream은 자동으로 닫힌다.
    }
}

Tomcat 구현하기

Tomcat 서버가 클라이언트로부터 HTTP 요청을 받아 처리하고 응답을 보내기까지의 과정은 여러 컴포넌트의 유기적인 상호작용으로 이루어진다. 서버 시작부터 커넥터의 연결 수락, 요청 파싱, 컨트롤러 매핑 및 실행, 최종 응답 생성에 이르기까지, 실제로 동작 방식과 유사하게 Tomcat을 구현해봤다.

Tomcat 구현 GitHub Repo


1단계: 서버 시작 및 연결 수립

모든 요청 처리는 Tomcat 인스턴스를 생성하고 시작한다. Application.javamain 메서드는 Tomcat 객체를 생성하고 start()를 호출하여 서버를 부팅한다.

1
2
3
4
5
6
public class Application {
    public static void main(String[] args) {
        final var tomcat = new Tomcat();
        tomcat.start();
    }
}

Tomcat.start() 메서드는 내부적으로 Connector 컴포넌트를 초기화하고 실행한다. Connector는 지정된 포트(기본값 8080)에서 ServerSocket을 생성한 후, 무한 루프(while(true))에 진입하여 클라이언트의 연결을 지속적으로 대기한다 (serverSocket.accept()). 클라이언트 연결이 수락되면, 생성된 Socket 객체는 다음 단계의 처리를 위해 Http11Processor와 같은 핸들러에게 전달된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Connector implements Runnable {
    // ...
    public void connect() throws IOException {
        serverSocket = new ServerSocket(port); // 8080 포트로 서버 소켓 생성
        run();
    }

    @Override
    public void run() {
        while (true) {
            final var socket = serverSocket.accept(); // 클라이언트 연결 대기 및 수락
            handler.process(socket); // 연결된 소켓을 처리기에 전달
        }
    }
}

2단계: HTTP 요청 파싱

Http11Processor는 전달받은 Socket으로부터 InputStream을 얻어, 네트워크를 통해 들어온 원시 바이트 스트림(raw byte stream)을 읽는다. 이 단계의 핵심 목표는 텍스트 형태의 HTTP 메시지를 구조화된 Java 객체인 HttpRequest로 변환하는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Http11Processor implements Runnable, Processor {
    // ...
    @Override
    public void process(final Socket socket) {
        try (final var inputStream = socket.getInputStream();
             final var outputStream = socket.getOutputStream()) {

            // 1. 요청 파싱
            final HttpRequest httpRequest = HttpRequestParser.parse(inputStream);
            final HttpResponse httpResponse = new HttpResponse(outputStream);

            // ... 이후 로직 ...

        } catch (final IOException | UncheckedServletException e) {
            // ...
        }
    }
}

HttpRequestParser.parse() 메서드는 내부적으로 요청의 각 부분을 파싱해 분석한다. 요청 라인(GET /index.html HTTP/1.1), 헤더, 쿠키, 그리고 POST 요청의 경우 본문(body)까지 파싱하여 HttpRequest 객체를 완성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class HttpRequestParser {
    public static HttpRequest parse(final InputStream inputStream) {
        final var reader = new BufferedReader(new InputStreamReader(inputStream));
        try {
            // 요청의 각 부분을 순차적으로 파싱
            final RequestLine requestLine = RequestLineParser.parse(reader);
            final RequestHeader requestHeader = RequestHeaderParser.parse(reader);
            final Cookie cookie = CookieParser.parse(requestHeader.getCookie());
            final RequestBody requestBody = RequestBodyParser.parse(requestHeader, reader);

            return new HttpRequest(requestLine, requestHeader, requestBody, cookie);
        } catch (final IOException e) {
            // ...
        }
    }
}

3단계: 컨트롤러 매핑

구조화된 HttpRequest 객체가 생성되면, 서버는 이 요청을 실제로 처리할 핸들러(컨트롤러)를 매핑한다. Http11ProcessorRequestMapping에 요청 경로(httpRequest.getPath())를 전달하여 적절한 Controller를 찾는다.

1
2
3
4
5
// Http11Processor.java
// ... process 메서드 내부 ...
// 2. 요청 URI에 맞는 컨트롤러 탐색
final Controller controller = RequestMapping.getController(httpRequest.getPath());
// ...

RequestMapping은 내부에 Map 형태의 라우팅 테이블을 가지고 있으며, HTTP 메서드와 경로에 따라 미리 등록된 Controller 구현체를 반환한다. 만약 일치하는 컨트롤러가 없다면, 정적 리소스(HTML, CSS 등)를 처리하기 위한 StaticResourceController가 기본값으로 사용된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class RequestMapping {
    private static final Map<RequestPath, Controller> controllers = new HashMap<>();

    static {
        controllers.put(new RequestPath(HttpMethod.GET, "/"), new DefaultController());
        controllers.put(new RequestPath(HttpMethod.POST, "/register"), new RegisterController());
        controllers.put(new RequestPath(HttpMethod.POST, "/login"), new LoginController());
    }

    public static Controller getController(final RequestPath requestPath) {
        return controllers.getOrDefault(requestPath, new StaticResourceController());
    }
}

4단계: 비즈니스 로직 수행

매핑된 Controllerexecute 메서드가 호출되어 애플리케이션의 핵심 비즈니스 로직이 수행된다. 예를 들어, /register 경로로 POST 요청이 오면 RegisterControllerhttpRequest로부터 사용자 정보를 추출하여 저장하는 로직을 실행한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class RegisterController implements Controller {
    @Override
    public ControllerResult execute(final HttpRequest httpRequest, final HttpResponse httpResponse) {
        final var user = new User(
                httpRequest.getBodyValue("account"),
                httpRequest.getBodyValue("password"),
                httpRequest.getBodyValue("email")
        );
        InMemoryUserRepository.save(user); // 사용자 정보 저장

        return ControllerResult.redirect("/index.html"); // 결과로 리다이렉트 응답 반환
    }
}

컨트롤러는 로직 수행 후 HTML을 직접 생성하는 대신, ControllerResult 객체를 반환한다. 이 객체는 뷰(View)를 렌더링할지, 혹은 다른 페이지로 리다이렉트할지 등 다음 행동을 지시하는 역할을 한다.


5단계: 응답 생성 및 전송

마지막으로 Http11ProcessorController로부터 받은 ControllerResult를 사용하여 클라이언트에게 보낼 최종 응답을 생성한다. controllerResult.render() 메서드가 호출되면, 그 결과에 따라 HttpResponse 객체의 상태 코드, 헤더, 본문이 채워진다.

1
2
3
4
5
6
7
8
9
10
11
12
public class ControllerResult {
    // ...
    public void render(final HttpResponse httpResponse) throws IOException {
        if (isRedirect()) {
            httpResponse.sendRedirect(viewName); // 리다이렉트 응답 생성
            return;
        }
        // ...
        final String responseBody = new String(Files.readAllBytes(Paths.get(/* ... */)));
        httpResponse.ok(responseBody); // 200 OK 응답 생성
    }
}

HttpResponse 객체는 내부적으로 DataOutputStream을 사용하여 응답 라인, 헤더, 본문을 순서대로 소켓에 쓴다. dos.flush()가 호출되면 완성된 HTTP 응답 메시지가 네트워크를 통해 클라이언트의 웹 브라우저로 전송되며, 이로써 하나의 요청-응답 주기가 완료된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class HttpResponse {
    // ...
    private final DataOutputStream dos;

    public void ok(final String body) throws IOException {
        response(Status.OK, body);
    }

    private void response(final Status status, final String body) throws IOException {
        // 응답 라인 (e.g., HTTP/1.1 200 OK)
        dos.writeBytes(ResponseLine.from(status).generate());
        // 응답 헤더
        dos.writeBytes(ResponseHeader.of(body.getBytes().length, ContentType.from("")).generate());
        // 응답 본문
        dos.writeBytes(body);
        dos.flush();
    }
}
This post is licensed under CC BY 4.0 by the author.