Spring Boot 내부 Embedded Tomcat은 무슨 일을 하고 있을까! ? 😼 (1) Tomcat을 구현하며 알아보자

Yunny.Log ·2023년 6월 22일
0

나의 외주일지

목록 보기
16/16
post-thumbnail

어느날 든 호기심 : Tomcat은 일 잘하고 있는거 맞아?! 🤔

  • 외주 프로젝트 성능 개선을 위해 고민하던 중... 프로젝트에 성능에 영향을 미치는 요인들을 생각해보았다
  • DB 인덱스 설정
  • DB에 접근하는 쿼리
  • 코드 내 자료구조 시간복잡도
  • 사용 알고리즘의 효율성
  • 데이터의 양 등등 .....
  • 사이드 프로젝트와 외주 프로젝트가 정말 다르다고 생각이 드는 점은 바로 성능과 데이터의 양적 측면이다.
  • 개인 프로젝트, 학교 과제 프로젝트 등에서 프로젝트 개발을 하다보면, 실제 성능과 데이터 누적 시의 상황을 고려하지 않고 개발을 하는 경우가 많다.
  • 하지만 실제로

  • 그러던 중 내가 그동안 간과하고, 의심조차 하지 않고 있던 녀석이 있음을 깨달은 것이다.

    WAS 역할 중인 Tomcat, 이 자식은 뭘 하고 있는거야?!

    결국 내 서버는 WAS, Servlet Container 역할인 Tomcat을 통해 Servlet 생성 & 응답을 생성한다.

  • 이론적으로 웹 서버는 정적인 것 담당, WAS는 동적인 역할 담당을 한다. (DB 접근, 사용자 역할 담당) 이렇게만 알고 있었다. 더 나아가서 서블릿 컨테이너 역할로서, 서블릿의 생명주기를 담당한다 정도!?

  • 그런데 서버 개발자로서 , 그리고 지금 실제 사용자들이 사용하는 SW 를 담당하는 입장으로서, 내 서버 내의 WAS가 뭘 어떻게 하고 있는지 알아야 할 것 아닌가!? 그래서 파헤쳐보기로 했다.


WAS의 역할

1. TCP 통신을 책임집니다.

1-1. TCP 통신은 Socket으로 구현됩니다.

  • 유명한 개발자 필수 상식 질문인 "WWW.XXX.COM" 접속 시 어떤 일이 일어나는가에 대한 질문을 들어봤을 것이다.

    • 나는 이 질문에 대한 답을 정리하면서 가장 감을 잡기가 어려워던 부분이 각 서버가 TCP 통신이 이루어지는 과정이었다. .
      • tmi : 개발 공부를 하면서 항상 와닿지 않는 부분은 내가 실제로 사용해보지 않은 부분을 마주할 때인 것 같다.
  • 해당 부분에 대해 그림을 통해 살펴보자
    사진 출처

  • 해당 부분에서 감이 안 잡히는 부분은 아래 연두색 코멘트 부분이다. (내가 작성)

  • 추후 깊게 공부하면서 해당 부분은 네트워크 시간에 학습하는 TCP/IP Socket 통신을 통해 구현된다는 것을 학습했다.

  • 클라이언트 프로그램 (요청자) 과 서버 프로그램 (응답자) 은 각각 자신이 포트 (포트 번호는 TCP LAYER 계층에 존재) 를 통해 통신해야 한다. 연결을 할때도 포트를 사용하고 데이터를 교환할때도 포트를 사용한다. 자바 프로그램 안에서 포트를 사용하기 위해서는 소켓을 이용해야 한다. 자바안에서 소켓의 종류에는 서버소켓과 클라이언트 소켓이 있다.

  1. Server Socket ( 서버 소켓 )
  • 서버 소켓은 말그대로 서버 프로그램에서만 사용하는 소켓이다. 서버소켓은 클라이언트로부터 연결 요청이 오기를 기다렸다가 연결 요청이 들어오면 클라이언트와 연결을 맺고 다른 소켓을 만드는 일을 한다.
  1. Client Socket ( 클라이언트 소켓 )
  • 클라이언트 소켓은 기대랄 필요가 없기 때문에 바로 클라이언트 소켓을 생성한다. 클라이언트 프로그램에서 클라이언트 소켓은 서버프로그램으로 연결요청을 하는것과 데이터 전송을 하는 일을 한다.

위 두 소켓에 대한 내용이 정확히 tomcat 내부에서 등장하고, tomcat이 관리해주고 있는 것이다.

1-2. Tomcat 이 Socket 연결을 담당하는 과정

  • 아래는 Tomcat 에서 Socket 통신을 구현하는 과정이 담긴 코드 부분이다.
// 패키지 import
package org.apache.catalina.connector;

// IOException 및 UncheckedIOException을 위한 import
import java.io.IOException;
import java.io.UncheckedIOException;
// 네트워크 관련 클래스 import
import java.net.ServerSocket;
import java.net.Socket;
// 동시성 처리를 위한 ExecutorService 및 Executors import
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
//  Tomcat의 Coyote 컴포넌트에 속하는 클래스로, HTTP 1.1 프로토콜을 처리하는 역할을 담당
//  클라이언트의 HTTP 요청을 처리하고, 해당 요청에 대한 응답을 생성
import org.apache.coyote.http11.Http11Processor;
// 로깅을 위한 import
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

// Connector 클래스 선언 및 Runnable 인터페이스 구현
public class Connector implements Runnable {
    // 로깅을 위한 Logger 생성
    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 ExecutorService executorService;
    private final ServerSocket serverSocket;
    private boolean stopped;

    // 기본 생성자
    public Connector() {
        this(DEFAULT_PORT, DEFAULT_ACCEPT_COUNT, DEFAULT_MAX_THREADS);
    }

    // 생성자 오버로딩
    public Connector(final int port, final int acceptCount, final int maxThread) {
        // 고정 크기 스레드 풀 생성
        this.executorService = Executors.newFixedThreadPool(maxThread);
        // 서버 소켓 생성 및 포트 번호, 접속 허용 수 확인
        this.serverSocket = createServerSocket(port, acceptCount);
        this.stopped = false;
    }

    // 서버 소켓 생성 메서드
    private ServerSocket createServerSocket(final int port, final int acceptCount) {
        try {
            // 포트 번호와 접속 허용 수 확인
            final int checkedPort = checkPort(port);
            final int checkedAcceptCount = checkAcceptCount(acceptCount);
            // ServerSocket 생성
            return new ServerSocket(checkedPort, checkedAcceptCount);
        } catch (IOException e) {
            // IOException 을 UncheckedIOException 으로 변환하여 예외 처리
            throw new UncheckedIOException(e);
        }
    }

    // 서버 시작 메서드
    public void start() {
        // 새로운 스레드 생성 및 실행
        var thread = new Thread(this);
        // 백그라운드 스레드로 설정
        thread.setDaemon(true);
        thread.start();
        stopped = false;
    }

    // Runnable 인터페이스의 run 메서드 구현
    @Override
    public void run() {
        // 서버가 정지되지 않은 동안 계속해서 접속 요청 처리
        while (!stopped) {
            connect();
        }
    }

    // 클라이언트의 접속 요청 처리 메서드
    private void connect() {
        try {
            // 클라이언트의 접속 요청을 받아들임
            process(serverSocket.accept());
        } catch (IOException e) {
            // IOException 로깅
            log.error(e.getMessage(), e);
        }
    }

    // 클라이언트 연결 처리 메서드
    private void process(final Socket connection) { //  클라이언트와의 소켓 연결
        if (connection == null) { // 매개변수 connection 이 null 인 경우 메서드를 종료하고 반환합니다.
            return;
        }
        // 클라이언트의 호스트와 포트 정보를 로깅
        log.info("connect host: {}, port: {}", connection.getInetAddress(), connection.getPort());
        // Http11Processor 인스턴스 생성 *클라이언트의 요청을 처리하기 위한 처리기 역할
        var processor = new Http11Processor(connection);
        // ExecutorService :  executorService.submit(processor)를 사용하여 processor 를 처리 작업으로 제출합
        executorService.submit(processor);
        // => executorService 는 스레드 풀에서 사용 가능한 쓰레드를 가져와서 processor 를 실행하게 됩니다.
    }

    // 서버 정지 메서드
    public void stop() {
        stopped = true;
        try {
            // 서버 소켓 닫기
            serverSocket.close();
        } catch (IOException e) {
            // IOException 로깅
            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);
    }
}
  • 코드가 길어서 프로세스를 정리해보면 아래와 같다. Catalina 가 Socket 연결을 클라이언트와 맵핑하고, Thread를 할당하는 과정이다. (내 깃허브 이슈에 정리된 내용)

1. Catalina 가 Socket 연결을 클라이언트와 맵핑하고, Thread를 할당하는 과정

순서메서드명역할
1createServerSocket지정된 포트 번호와 접속 허용 수로 ServerSocket을 생성
2startConnector를 별도의 스레드에서 실행하고 백그라운드로 설정
3run서버가 정지되지 않은 동안 클라이언트의 접속 요청을 처리 , Runnable 인터페이스 구현
4connect클라이언트의 접속 요청을 받아들이고 process 메서드 호출
5process클라이언트와의 소켓 연결을 처리하고 클라이언트의 요청을 Http11Processor로 전달
6stop서버를 정지시키고 ServerSocket을 닫음

2. 코드에 등장한 Server vs Server Socket

클래스역할
ServerSocket- 서버 프로그램에서 사용되는 소켓
- 지정한 포트를 통해 연결 요청이 오기를 대기
- 요청이 오면 클라이언트와 연결을 맺고 해당 클라이언트와 통신하는 새 소켓을 생성
Socket- 서버 프로그램으로 연결 요청하는 클라이언트 소켓
- 서버와의 데이터 전송을 담당

3. 소켓 연결 과정

서버방향클라이언트
클라이언트의 요청을 받기 위한 준비를 한다.(ServerSocket)  
클라이언트의 요청을 받아 들인다. (accept)<-서버에 접속 요청을 한다. (Socket)
클라이언트가 보낸 데이터를 출력 한다. (BufferedReader)<-서버에 메시지를 보낸다. ( BufferedWriter )
클라이언트에 메시지를 보낸다. ( BufferedWriter )->서버가 보낸 메시지를 출력한다. ( BufferedReader )
종료 한다. ( socket.close() ) 종료 한다. ( socket.close() )


  • 여기서 Buffered Stream 이라는 부분도 흥미로운 부분이다.
  • 하나하나 요청에 대해 전송하는 게 아니라, 버퍼에 담아서 보내는 부분인데, 일반 Input Stream 이 아닌 Buffer를 사용해 IO 를 관리하는 이유에 대해서는 이 글 ( JAVA-IO-Stream-BufferedStream-vs-InputStream )에서 추가적으로 알아봅시다. ㅎㅎ

2. Thread Pool 관리를 책임집니다.

  • 제가 사용하는 Spring Boot 에서는 한 유저의 요청에 대해 쓰레드를 할당해줍니다.
    // 클라이언트 연결 처리 메서드
    private void process(final Socket connection) { //  클라이언트와의 소켓 연결
        if (connection == null) { // 매개변수 connection 이 null 인 경우 메서드를 종료하고 반환합니다.
            return;
        } 
        // Http11Processor 인스턴스 생성 *클라이언트의 요청을 처리하기 위한 처리기 역할
        var processor = new Http11Processor(connection);
        // ExecutorService :  executorService.submit(processor)를 사용하여 processor 를 처리 작업으로 제출합
        executorService.submit(processor);
        // => executorService 는 스레드 풀에서 사용 가능한 쓰레드를 가져와서 processor 를 실행하게 됩니다.
    }
  • Connection 이 생성되면, 해당 Connection을 ExecutorService에 넘겨줍니다!

private final ExecutorService executorService 의 스레드 생성 시 역할

  • ExecutorService에 Task만 지정해주면 ThreadPool을 이용해서 Task를 실행하고 관리한다.

Q ) Task는 뭐로 관리가 되나?

  • Queue로 관리된다.
  • ThreadPool에 있는 Thread수보다 Task가 많으면, 미실행된 Task는 Queue에 저장되고,
  • 실행을 마친 Thread로 할당되어 순차적으로 수행된다.

  • ExecutorService에 작업을 submit하면, 내부에서 해당 작업을 스케쥴링 하면서 적절하게 일을 처리한다.
    ( submit() : Task를 할당하고 Future 타입의 결과값을 받는다. 결과 리턴이 되어야해서 Callable을 구현한 Task를 인자로 준다.)
  • ThreadPool에 있는 쓰레드들이 각자 본인의 Task를 가지고 작업을 처리하여, 개발자 입장에서는 쓰레드들의 생명주기를 따로 관리할 필요가 없다.

톰캣의 역할과 행위에 대해 살펴본 후 ,,

  • 서버 개발자로서 내장 톰캣이 스프링부트에서 수행해주는 역할에 대해 살펴보니 그동안 외면했었던 부분들이 시원하게 해결된 것 같아 속 시원하게 느껴졌다 ㅎㅎ
  • 대규모 테크 기업에서는 톰캣 튜닝을 통해서 보다 효율적으로 서버 자원 관리를 진행한다고 들었는데, 나 또한 해당 기회가 주어진다면 어렵지 않게 내용도 빠르게 이해하고 학습하며 업무에 투입될 수 있지 않을까 생각한다.

Github 레포지토리

톰캣 구현 & 궁금증들을 이슈에 등록해놓은 깃허브 레포지토리


Reference

TCP 소켓 관련 설명 블로그

0개의 댓글