우아한테크코스

레벨 4 톰캣 만들기 미션 서버 소켓 코드 분석

더즈 2022. 9. 3. 15:30

레벨 4로 접어들면서 톰캣 만들기 미션을 하게 되었는데 제공받은 초기 코드부터가 흥미로웠다.

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

위 코드만 실행 시켜도 자바 애플리케이션이 꺼지지도 않으면서 localhost:8080으로 오는 요청을 받고 있는 것이 신기해서 소스 코드를 한 번 분석해 보았다.

Application {

final var tomcat = new Tomcat();
tomcat.start();
  • Tomcat 클래스 구동 (start())

Tomcat {

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();
        }
}
  • Connector 객체 생성 및 구동
  • 콘솔에 입력이 들어올 때까지 대기 (애플리케이션을 중단시키지 않기 위해)
  • 예외가 발생하면 Connector 작동 중지

Connector {

public class Connector implements Runnable {

    private static final int DEFAULT_PORT = 8080;
    private static final int DEFAULT_ACCEPT_COUNT = 100;

    private final ServerSocket serverSocket;
    private boolean stopped;

    public Connector() {
        this(DEFAULT_PORT, DEFAULT_ACCEPT_COUNT);
    }

    public Connector(final int port, final int acceptCount) {
        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);
            return new ServerSocket(checkedPort, checkedAcceptCount);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
  • Connector는 Runnable 인터페이스를 구현하고 있다.
    • Runnable 인터페이스를 구현하는 클래스는 run() 메서드를 재정의 해야 한다.
    • Runnable 구현 클래스가 스레드를 생성하는 데 사용되는 경우 스레드를 시작하면 별도의 스레드에서 run() 메서드가 호출된다.
  • ServerSocket과 현재 중단 상태인지를 나타내는 stopped 필드를 가지고 있다.
    • ServerSocket은 서버 소켓의 구현 클래스이다. 서버 소켓은 네트워크로부터 요청이 오기를 기다린다.
    • 요청이 오면 작동되고, 결과를 반환해 준다.
    • 위 코드에서는 기본 port와 acceptCount로 서버 소켓을 생성하고 있다. (기본 생성자를 사용했기 때문)
    • 위 코드로 생성된 소켓은 지정된 포트에 바인딩되며 acceptCount만큼의 최대 큐 길이를 가질 수 있다. 큐가 가득 찼을 때는 연결이 거부된다.

start() {

var thread = new Thread(this);
thread.setDaemon(true);
thread.start();
stopped = false;
  • Tomcat 클래스에서 connector.start() 메서드를 호출했으니 위 코드가 실행된다.
  • Connector 자신으로 스레드를 생성했기에 thread.start() 메서드에 의해 connector.run() 메서드가 호출될 것이다.
  • thread.setDemon(true)로 의해 위 스레드는 데몬 스레드가 된다.
    • 주 스레드의 작업을 돕는 보조적인 역할을 함
    • 일반 스레드와 크게 다른 점은 애플리케이션을 종료할 때 살펴볼 수 있다.
    • 애플리케이션이 종료될 때 일반 스레드 전부가 종료되어야 JVM 프로세스가 종료된다.
    • 하지만 JVM은 데몬 스레드의 종료를 기다리지 않기 때문에 데몬 스레드를 그냥 셧다운 시킨다. 때문에 종료 시 특별한 처리가 필요한 작업이라면 데몬 스레드에서 실행하면 안 된다.

run() {

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

private void connect() {
    try {
        process(serverSocket.accept());
    } catch (IOException e) {
        log.error(e.getMessage(), e);
    }
}
  • 스레드가 시작되었으니 run() 메서드가 호출되고 stopped 상태가 false이니 while 문이 돌면서 연결을 유지하게 된다.
  • ServerSocket의 accept() 메서드를 호출하면 프로그램은 클라이언트와 포트로 연결될 때까지 대기한다.
    • 연결이 이루어지면 Socket 객체를 반환한다.

process(final Socket connection) {

if (connection == null) {
    return;
}
log.info("connect host: {}, port: {}", connection.getInetAddress(), connection.getPort());
var processor = new Http11Processor(connection);
new Thread(processor).start();
  • ServerSocket이 연결을 통해 반환한 Socket connection은 연결을 요청한 클라이언트로부터의 정보가 들어 있다.
  • 이 요청을 토대로 서버는 적절한 결과를 리턴해주면 된다.
  • HTTP request 형식으로 왔을 정보를 알맞은 결과로 반한하기 위해 Runnable을 구현한 Http11Processor에게 connection을 넘겨 주어 새로운 스레드에서 요청을 처리하도록 넘긴다.
  • 그럼 Connector는 다시 while 문으로 돌아가 stopped가 true가 될 때까지 다른 요청을 기다리며 계속 들어오는 새로운 요청을 여러 스레드에게 처리하도록 맡길 것이다.
while (!stopped) {
    connect();
}

Http11Processor {

	@Override
	public void run() {
		process(connection);
	}

	@Override
	public void process(final Socket connection) {
		try (final var inputStream = connection.getInputStream();
			 final var outputStream = connection.getOutputStream();
			 final var requestReader = new BufferedReader(new InputStreamReader(inputStream))
		) {
			HttpRequest request = new HttpRequest(requestReader);
			log.info("REQUEST \r\n{}", request);

			final var response = handleHttpRequest(request);
			log.info("RESPONSE \r\n{}", response);

			outputStream.write(response.getBytes());
			outputStream.flush();
		} catch (IOException | UncheckedServletException e) {
			log.error(e.getMessage(), e);
		}
	}
  • 이제 들어온 요청을 처리하는 로직을 살펴보자
  • 스레드로 생성하여 시작하였으니 똑같이 run(), process() 메서드가 차례로 실행될 것이다.
  • Socket에서 InputStream과 OutputStream을 꺼낼 수 있는데 InputStream으로부터 요청 메시지를 읽어올 수 있고, OutputStream으로 알맞은 결과를 응답 메시지를 쓸 수 있다.
  • BufferedReader를 통해 InputStream을 읽으면서 로직을 처리하여 OutStream.write()를 통해 결과를 반환하면 한 요청에 대한 프로세스가 종료가 된다.
  • 위 코드에 있는 HttpRequest와 HttpResponse 그리고 handleHttpRequest() 메서드는 필자가 설계해 본 구조인데 이런 흐름으로 요청을 처리하는 것이 톰캣 만들기 미션을 하면서 이해한 간단한 웹서버 작동 원리다.