Tomcat 구현하기

Lily·2024년 9월 17일
5
post-thumbnail

이번 미션은 Tomcat 구현하기였는데, 미션 마감일에 쫒겨 코드를 치기 바빴어서 한번 흐름을 정리해보려고 한다.
사실 구현한 게 Tomcat 보다는 WAS 에 가깝다고 한다.

제출한 코드는 여기 있다.

1️⃣ main 메서드부터 들어가보자.

package com.techcourse;

import org.apache.catalina.startup.Tomcat;

public class Application {

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

👩🏻‍💻 Tomcatstart 메서드를 실행시키는 것밖에 없구나

2️⃣ Tomcat 클래스

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() {
        var connector = new Connector();
        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();
        }
    }
}

👩🏻‍💻 여기선 또 Connectorstart() 를 실행시키네

3️⃣ Connector 클래스

헷갈리니까 일단 Connector 클래스 전체 코드를 두고 시작하는 것이 나을 것 같다.

package org.apache.catalina.connector;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.apache.coyote.http11.Http11Processor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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(int port, int acceptCount, int maxThreads) {
        this.serverSocket = createServerSocket(port, acceptCount);
        this.executorService = Executors.newFixedThreadPool(maxThreads);
        this.stopped = false;
    }

    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;
        log.info("Web Application Server started {} port.", serverSocket.getLocalPort());
    }

    @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;
        }
        var processor = new Http11Processor(connection);

        executorService.execute(processor);
    }

    public void stop() {
        stopped = true;
        try {
            serverSocket.close();
            shutdown();
        } 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);
    }

    public void shutdown() {
        executorService.shutdown();
    }
}

3️⃣-1️⃣ 다시 돌아와서, Connectorstart()

 public void start() { //Connector 의 start()
        var thread = new Thread(this); //this 는 Connector
        thread.setDaemon(true);
        thread.start();
        stopped = false;
        log.info("Web Application Server started {} port.", serverSocket.getLocalPort());
    }

👩🏻‍💻 new Thread(this); 는 뭐지?

Image 1 Image 2

👩🏻‍💻 ConnectorRunnable 을 구현하고 있구나.
👩🏻‍💻 new Thread(runnable)Thread 객체를 생성한 것까지 알았다.

그다음 thread.setDaemon(true); 데몬 스레드로 설정하고, thread.start(); 를 한다.

데몬 스레드는 주 스레드가 종료되면 함께 종료되며, 주로 백그라운드 작업을 수행하는 데 사용된다.
즉, 메인 스레드는 별도의 작업을 계속할 수 있고 새로운 스레드는 메인 스레드와는 독립적으로 백그라운드 작업을 처리한다.
(참고) 메인 스레드는 JVM이 애플리케이션을 시작할 때 생성되며, 애플리케이션의 main 메서드를 실행하는 스레드이다.

3️⃣-2️⃣ thread.start();를 들어가보자.

Image 1 Image 2

start0(); 까지 들어가보니 네이티브 메서드이다.

이 네이티브 메서드는 자바 표준 라이브러리 코드 외부에서 구현되고, JVM 구현에 따라 처리하는 방법이 다르다.
어쨌든 이 메서드를 통해 실제 OS 스레드를 생성하고, 이 스레드는 Threadrun() 메서드를 호출한다고 한다.

해당 메서드의 구현이 궁금하다면 JVM 의 소스 코드를 보면 된다. (OpenJDK GitHub Repo)
다만 C++ 로 작성되어있다 🥹

그런데 Runnable 객체를 생성하고 이를 Thread에 전달한 경우, Threadrun() 메서드는 Runnablerun() 메서드를 호출하도록 설정된다. 이 설정은 Thread 클래스의 생성자에서 처리된다.

  • Thread run() 메서드
  • Thread 클래스의 생성자 (너무 길어서 잘렸다.)

생성자가 되게 긴데.. 일단 아래 부분만 봐도 된다.

this.holder = new FieldHolder(g, task, stackSize, priority, parent.isDaemon());

이 스레드의 주요 속성을 저장하는 클래스인 FieldHolderRunnable 이 저장된다.

👩🏻‍💻 그럼 이제 Connectorrun() 메서드가 호출되겠구나!

3️⃣-3️⃣ Connectorrun()

@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;
    }
    var processor = new Http11Processor(connection);

    executorService.execute(processor);
}

run();connect();process(serverSocket.accept());

👩🏻‍💻 process 메서드가 Socket 클래스를 인자로 받고 있다. 이게 뭐지?

serverSocket.accept() 로 전달하고 있으니, serverSocket 이 초기화 되는 Connector 클래스의 생성자 부분을 살펴보자.

3️⃣-4️⃣ ServerSocketExecutorService

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(int port, int acceptCount, int maxThreads) {
    this.serverSocket = createServerSocket(port, acceptCount);
    this.executorService = Executors.newFixedThreadPool(maxThreads);
    this.stopped = false;
}

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);
    }
}

생성자에서 ServerSocketExecutorService 를 만들고 있다.

ServerSocket 이 뭔지 이해하려면 클라이언트와 서버가 데이터를 어떻게 주고 받는지 부터 이해해야 한다.

  1. 클라이언트가 Socket을 생성해서 서버에 연결을 요청한다.
    1. 웹 브라우저가 서버에 HTTP 요청을 보내기 위해 Socket을 생성한다.
    2. 웹 브라우저가 직접 소켓을 생성하는 건 아니고, 소켓을 생성해달라는 요청을 운영체제에 보낸다.
    3. 소켓 생성 요청은 Socket API 를 호출한다. (Java에 Socket 클래스가 있다.)
    4. 운영체제는 소켓을 생성하고, 서버와의 TCP 연결을 설정한다.
  2. 서버의 ServerSocket이 클라이언트의 연결 요청을 기다리고, 그 요청을 받으면 accept()로 새로운 Socket을 생성한다.
  3. 이 새로운 Socket을 통해 클라이언트와 서버가 데이터를 주고받는다.

👩🏻‍💻 아하 그럼 ServerSocket 이 클라이언트의 연결 요청을 기다리다가, 요청이 오면 accept() 로 새로운 Socket이 생성되는구나!

👩🏻‍💻 근데 위에서 Connectorrun() 메서드가 호출이 되면 while 문의 connect() 가 반복적으로 호출이 되는 것 같은데, 그럼 connect()serverSocket.accept() 도 계속 호출이 되네.

여기서 accept() 는 블로킹 메서드이기 때문에 새로운 연결 요청이 올 때까지 실행을 멈추고 대기한다.

  • 반복적으로 connect() 가 호출되면서, 내부적으로 accept() 메서드를 호출하게 된다.
  • accept() 는 새로운 클라이언트 연결 요청이 들어올 때까지 대기하는 상태로, 호출은 완료되었지만 클라이언트가 연결을 시도하기 전까지 아무런 동작을 하지 않는다. (블로킹 상태)
  • 클라이언트가 연결 요청을 보내면, 그때 accept() 가 새로운 Socket
    을 반환하고, 연결이 수립된다.
    출처) ServerSocket (Java Platform SE 8 )

👩🏻‍💻 그럼 클라이언트에게 연결 요청을 받았을 때 새로운 Socket 과 함께 process 가 실행되는구나!

다시 3️⃣-3️⃣ 으로 돌아와서,

private void process(final Socket connection) {
    if (connection == null) {
        return;
    }
    var processor = new Http11Processor(connection);

    executorService.execute(processor);
}

👩🏻‍💻 그런데 Http11ProcessorexecutorService 가 실행시키네.
👩🏻‍💻 executorService 는 또 뭐지?

아까 생성자에서 ExecutorService 를 만들고 있다고 했다. (참고 3️⃣-4️⃣)

this.executorService = Executors.newFixedThreadPool(maxThreads);

executorService 는 스레드 풀을 관리하는 클래스이다.
스레드 풀은 미리 여러 개의 스레드를 생성해 두고, 필요한 작업이 들어오면 그 작업을 풀에 있는 미리 준비된 스레드에 할당한다.
newFixedThreadPool() 메서드를 사용하여 고정된 개수의 스레드 풀을 만들 수 있다.
클라이언트 요청이 들어오면, 스레드 풀에서 빈 스레드를 할당하여 그 요청을 처리한다.

👩🏻‍💻 아하 그럼 클라이언트의 요청이 들어올 때마다 Http11Processor 같은 작업을 스레드 풀에서 처리하는구나
👩🏻‍💻 process 메서드는 클라이언트와의 연결(Socket)을 받아서 해당 연결을 처리할 Http11Processor에 넘기는 부분이겠다.

👩🏻‍💻 new Thread() 도 있고 newFixedThreadPool() 도 있네

  1. 연결을 기다리는 스레드: 클라이언트의 요청을 계속 기다리는 스레드.
  2. 연결이 들어오면 작업을 처리하는 스레드: 새로운 연결이 들어오면 이를 처리하는 스레드.
  1. start()에서 실행되는 스레드
  • Connector 클래스의 start() 메서드는 애플리케이션에서 단 한 번 호출된다. 이 메서드가 호출되면 새로운 스레드를 생성하여 이 스레드가 클라이언트의 연결 요청을 기다리게 된다.
  1. 클라이언트 연결 수락
  2. 연결 처리: 스레드 풀로 비동기 작업
  • 새로운 연결이 생성되면 그 연결을 처리하는 작업은 executorService 를 이용해 스레드 풀에서 처리한다.
  • 즉, 새로운 클라이언트가 들어올 때마다 스레드 풀에서 새로운 스레드를 할당하여 해당 연결을 처리한다.

4️⃣ Http11Processor 클래스

Http11Processor 는 HTTP 요청을 처리하는 클래스이다.

해당 부분부터는 직접 구현했기 때문에, 간략하게 설명만 남겨두려고 한다!

@Override
public void process(Socket connection) {
    try (InputStream inputStream = connection.getInputStream();
         OutputStream outputStream = connection.getOutputStream()) {
        BufferedReader request = new BufferedReader(new InputStreamReader(inputStream));

        HttpRequest httpRequest = HttpRequest.from(request);
        HttpResponse httpResponse = new HttpResponse();

        requestMapping.getController(httpRequest)
                .service(httpRequest, httpResponse);

        outputStream.write(httpResponse.getBytes());
        outputStream.flush();
    } catch (IOException | UncheckedServletException e) {
        log.error(e.getMessage(), e);
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}
  • 입출력 스트림 설정
    try (InputStream inputStream = connection.getInputStream();
         OutputStream outputStream = connection.getOutputStream()) {
    • InputStreamOutputStream 을 사용하여 클라이언트와의 데이터 입출력을 처리한다.
    • connection.getInputStream() 으로 클라이언트의 요청을 읽고, connection.getOutputStream()으로 응답을 클라이언트에 보낸다.
  • HTTP 요청 처리
    BufferedReader request = new BufferedReader(new InputStreamReader(inputStream));
    HttpRequest httpRequest = HttpRequest.from(request);
    • BufferedReader 를 통해 요청 스트림을 읽어들인다.
    • HttpRequest.from(request) 를 사용해 HTTP 요청 객체를 생성한다.
  • 응답 준비
    HttpResponse httpResponse = new HttpResponse();
    requestMapping.getController(httpRequest).service(httpRequest, httpResponse);
    • HttpResponse 객체를 생성하여 응답을 준비한다.
    • requestMapping.getController(httpRequest) 를 통해 요청에 대한 적절한 컨트롤러를 찾고, 해당 컨트롤러의 service 메서드를 호출하여 요청을 처리하고 응답을 만든다.
  • 응답 전송
    outputStream.write(httpResponse.getBytes());
    outputStream.flush();
    • HttpResponse.getBytes() 로 변환된 응답을 OutputStream 을 통해 클라이언트로 전송한다.
    • flush() 를 호출하여 응답이 클라이언트에게 전달되도록 한다.
profile
내가 하고 싶은 거

2개의 댓글

comment-user-thumbnail
2024년 10월 11일

저 오늘 바뀐거 없나요?

1개의 답글

관련 채용 정보