Web/Spring

[Tomcat 구현하기] 1. Tomcat, Connector, Socket

SpringBoot를 사용해 클라이언트-서버 통신을 하게되면 내장된 톰캣을 사용합니다.

Tomcat에 대한 이해가 없더라도 우리는 어렵지 않게 외부와 통신을 할 수 있습니다.

 

그런데 정말 Tomcat을 모르고 웹 어플리케이션 서버를 만들어도 되는 걸까요?

우리는 서버에 요청이 많아져 부하가 생긴다면 Tomcat 설정을 바꿔야할 수도 있습니다.

미리 Tomcat에 대한 이해가 있다면 어느부분에서 문제가 생겼는지, 파악이 가능하고 튜닝까지 가능할 것입니다.

 

 

이번 시간에는 Tomcat을 직접 구현해보며 Tomcat을 알아가보겠습니다.

더 나아가 서블릿을 직접 구현하며 웹서버 통신의 흐름을 다뤄봅니다.

 

 

 

다음과 같이 기능 요구사항이 주어졌습니다.

 

기능요구사항

  1. GET /login 요청에 로그인 페이지를 보여준다.
  2. GET /register 요청에 회원가입 페이지를 보여준다.
  3. POST /register , body를 포함한 요청에 회원가입을 시키고 login 페이지로 redirect 시킨다.
  4. POST /login 요청에 로그인 처리를 한다.
    - 서버에서 세션을 생성해 로그인 정보를 저장한다.
    - 쿠키에 JSESSION 아이디를 담아서 로그인을 유지시킨다.
  5. 로그인 처리가 된 사용자에게는 index.html 페이지를 보여준다.

 

Tomcat을 직접 구현하며 해결해보겠습니다.

 

 

0. 클라이언트와 서버가 통신하기 위해 Connection을 연결하고 Socket을 통해 데이터를 읽고 쓴다.

 

먼저 클라이언트로부터 요청을 받아야겠죠.

 

웹서버에게 요청이 들어오는 방식은 아래와 같습니다.

 

코드로 구현할 내용들

  1. Application이 실행되고 Tomcat이 생성되고 실행됩니다.
  2. Tomcat이 실행되면 Connector가 생성되고 실행됩니다.
  3. Connector가 생성될때 ThreadPool과 ServerSocket을 생성합니다.
  4. 제일 앞단에서 Socket이 클라이언트의 요청을 기다리다가 요청이 들어오고 수락되면, ServerSocket에 요청데이터를 넘깁니다.
  5. Connector는 요청 1개당 ThreadPool에서 Thread 1개를 사용해 요청을 처리합니다.
  6. 클라이언트의 요청을 읽을때는 ServerSocket의 inputStream을 읽습니다.
    (이와같이 Socket에 데이터를 읽고쓰며 클라이언트와 데이터를 주고받을 수 있습니다.)
  7. 요청이 처리되면 다시 ServerSocket을 통해 outputStream을 쓰고 클라이언트에게 데이터를 전달합니다.

 

Socket (oracle docs 번역)

일반적으로 서버는 특정 포트가 바인딩된 소켓을 가지고 특정 컴퓨터 위에서 돌아갑니다.

해당 서버는 클라이언트의 연결 요청을 소켓을 통해 리스닝 하면서 기다립니다.

클라이언트는 서버가 떠 있는 머신의 호스트네임과 서버가 리스닝하고 있는 포트 번호를 통해 연결을 시도합니다.

서버가 연결을 수락하면 서버는 동일한 로컬 포트에 바인딩된 새로운 소켓을 생성하며 클라이언트의 주소와 포트로 세팅된 엔드포인트를 가지게 됩니다.

클라이언트와 서버는 이제 소켓에 데이터를 쓰거나 읽음으로써 통신할 수가 있게됩니다.

 

 

 

 

Connector (tomcat docs 번역)

서버로 들어오는 각 요청에는 요청 기간 동안 쓰레드가 필요합니다.

현재 사용 가능한 요청 처리 쓰레드에서 처리할 수 있는 것보다 많은 동시 요청이 수신되면 구성된 최대값(max-Threads)값까지 추가 쓰레드가 생성됩니다.

더 많은 동시 요청이 수신되면 Connector에 의해 생성된 ServerSocket 내부에 구성된 최대값 (accepCount)값까지 누적됩니다.

더 이상의 추가 동시 요청은 리소스를 처리할 수 있을 때까지 "connection refused" 오류를 수신합니다.

 

 

 

 

그럼 이제 구현 해보겠습니다.

Application

import org.apache.catalina.startup.Tomcat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Application {

    private static final Logger log = LoggerFactory.getLogger(Application.class);

    public static void main(String[] args) {
        log.info("web server start.");
        final var tomcat = new Tomcat();
        tomcat.start();
    }
}

 

 

Tomcat

원래는 max-connections 에 따라 connection 수를 늘릴 수 있지만 현재 구현에선 Connection을 1개로 고정하겠습니다.

package org.apache.catalina.startup;

import org.apache.catalina.connector.Connector;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;

public class Tomcat {

    private static final Logger log = LoggerFactory.getLogger(Tomcat.class);

    public void start() {
        final Connector connector = new Connector(1, 10);
        connector.start();

        try {
            // make the application wait until we press any key.
            System.in.read();
        } catch (IOException e) {
            log.error(e.getMessage(), e);
        } finally {
            log.info("web server stop.");
            connector.stop();
        }
    }
}

 

 

Connector

maxThread에 따라 newFixedThreadPool 를 생성합니다.

acceptCount에 따라 ServerSocket을 생성합니다.

 

Connector가 생성될때 ServerSocket을 생성합니다.

요청 1개당 1개의 쓰레드에 요청을 처리합니다.

package org.apache.catalina.connector;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.apache.coyote.http11.Http11Processor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.ServerSocket;
import java.net.Socket;

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 final ExecutorService executorService;
    private final ServerSocket serverSocket;
    private boolean stopped;

    public Connector(final int port, final int acceptCount, final ExecutorService executorService) {
        this.executorService = executorService;
        this.serverSocket = createServerSocket(port, acceptCount);
        this.stopped = false;
    }

    public Connector(final int acceptCount, final int maxThreads) {
        this(DEFAULT_PORT, acceptCount, Executors.newFixedThreadPool(maxThreads));
    }

    public Connector() {
        this(DEFAULT_PORT, DEFAULT_ACCEPT_COUNT, Executors.newCachedThreadPool());
    }

    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 (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    public void start() {
        var thread = new Thread(this);
        thread.setDaemon(true);
        thread.start();
        stopped = false;
    }

    @Override
    public void run() {
        while (!stopped) {
            connect();
        }
    }

    private void connect() {
        try {
            process(serverSocket.accept());
        } catch (IOException e) {
            log.error(e.getMessage(), e);
        }
    }

    private void process(final Socket connection) {
        if (connection == null) {
            return;
        }
        log.info("connect host: {}, port: {}", connection.getInetAddress(), connection.getPort());
        var processor = new Http11Processor(connection);
        executorService.execute(processor);
    }

    public void stop() {
        stopped = true;
        try {
            serverSocket.close();
        } catch (IOException e) {
            log.error(e.getMessage(), e);
        }
    }

    private int checkPort(final int port) {
        final var MIN_PORT = 1;
        final var MAX_PORT = 65535;

        if (port < MIN_PORT || MAX_PORT < port) {
            return DEFAULT_PORT;
        }
        return port;
    }

    private int checkAcceptCount(final int acceptCount) {
        return Math.max(acceptCount, DEFAULT_ACCEPT_COUNT);
    }
}

 

 

Http11Processor

Connector로부터 받은 Socket의 InputStream을 읽고 데이터를 처리한 후 OutputStream에 데이터를 담아 클라이언트에게 전달합니다.

package org.apache.coyote.http11;

import static java.nio.charset.StandardCharsets.UTF_8;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;
import nextstep.jwp.exception.UncheckedServletException;
import org.apache.coyote.Processor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Http11Processor implements Runnable, Processor {

    private static final Logger log = LoggerFactory.getLogger(Http11Processor.class);

    private final Socket connection;

    public Http11Processor(final Socket connection) {
        this.connection = connection;
    }

    @Override
    public void run() {
        try {
            log.info("connect host: {}, port: {}", connection.getInetAddress(), connection.getPort());
            process(connection);
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            e.printStackTrace();
        }
    }

    @Override
    public void process(final Socket connection) {
        try (final var inputStream = connection.getInputStream();
             final var outputStream = connection.getOutputStream()) {

            final InputStreamReader inputStreamReader = new InputStreamReader(inputStream, UTF_8);
            final BufferedReader bufferedReader = new BufferedReader(inputStreamReader);

			...

            outputStream.write(response.getBytes());
            outputStream.flush();
        } catch (IOException | UncheckedServletException e) {
            log.error(e.getMessage(), e);
        }
    }
}

 

 

1. GET /login 요청에 로그인 페이지를 보여준다.

는 다음 시간에..

2022.09.15 - [Web/Spring] - [Tomcat 구현하기] 2. HttpRequest, HttpResponse, RequestMapper, Controller

 

 

 

 

 

참고자료