새소식

반응형
Back-end/Spring

[Spring] Part 1-4-1. 웹 애플리케이션 이해 (+계산기 프로그램 실습)

2023.01.13
  • -
반응형

1. 목표

 

이번 시간에는 계산기 프로그램을 웹 애플리케이션으로 만들어보면서 웹 애플리케이션에 대해 이해를 해보도록 하겠습니다.

 

계산기 프로그램을 총 세 단계로 나누어서 개발해 볼 것인데요. 각 단계는 아래와 같습니다.

 

Step1 - 사용자 요청을 메인 Thread가 처리하도록 한다.
Step2 - 사용자 요청이 들어올 때마다 Thread를 새로 생성해서 사용자 요청을 처리하도록 한다.
Step3 - Thread Pool을 적용해 안정적인 서비스가 가능하도록 한다.

 

그렇다면 만들어보기 전에 먼저 HTTP 프로토콜에 대해서 조금 알아보도록 하겠습니다.

 

2. HTTP 프로토콜 이해

HTTP 프로토콜은 서버와 클라이언트가 웹에서 데이터를 주고받기 위한 프로토콜(규약)입니다.

 

HTTP를 통해서는 거의 모든 종류의 데이터 전송이 가능하고 참고로 HTTP/1.1, HTTP/2는 TCP 기반 위에서 동작합니다.

TCP 기반에서 동작한다는 것은 3-way handshake 방식으로 연결을 맺는다는 의미입니다.(추후에 네트워크 카테고리에 관련 내용을 포스팅할 예정입니다.)

 

또한 HTTP/3는 UDP 기반 위에서 동작하기 때문에 3-way handshake로 연결을 맺을 필요가 없습니다.

 

3. HTTP 요청/응답 메시지 구조

  • 요청 메세지
    • Request line
    • Header
    • Blank line(비어있는 long line)
    • Body
  • 응답 메세지
    • Status line
    • Header
    • Blank line
    • Body

이와 같이 클라이언트가 서버가 통신을 할 때에는 정해진 형식에 맞추어서 데이터를 전달해야 합니다. 뒤에서 진행할 실습은 이를 과정으로 진행될 것입니다.

 

 

4. HTTP 특징

HTTP는 다음과 같은 3가지의 특징을 갖습니다.

  • 1. 클라이언트-서버 모델을 따른다.
  • 2. 무상태(stateless) 프로토콜이다.
    • 서버가 클라이언트 상태를 유지하지 않는 것
    • 해결책: Keep-Alive 속성 사용
      • 허나, 이것도 만능 해결책은 아닌게 Keep-Alive 사용 시 클라이언트의 요청이 많아지게 되면, 유지되는 connection도 자연스럽게 많아지게 되고 이로 인해 신규 사용자를 받기 어려워진다.
        즉, 웹 서버 thread가 부족해지는 현상을 야기한다.

왼쪽 부분이 무상태 프로토콜 오른쪽 부분이 Keep-Alive를 사용한 모습

  • 3. 비 연결성(Connectionless)
    • 서버가 클라이언트 요청에 대해 응답을 마치면 맺었던 연결을 끊어 버리는 것
    • 해결책: 쿠키(클라이언트 측에 정보 저장), 세션(서버 측에 정보 저장), JWT(JsonWebToken)

 

Q) 위와 같은 단점에도 왜 HTTP는 stateless와 connectionless 라는 특징을 가지고 있을까?

 

위와 같은 질문에 대한 대답은 물론 각각에 대한 이점이 있기 때문입니다.

 

특히, HTTP는 기본적으로 웹 상에서 불특정 다수와 통신이 가능하도록 설계된 프로토콜인데 이러한 상황에서 서버가 다수의 클라이언트의 상태 또는 연결을 계속해서 유지해야 한다면 resource 낭비가 굉장히 심해질 것입니다.

 

따라서 상태 또는 연결을 유지하지 않는 대신더 많은 연결을 할 수 있게끔 설계가 된 것이 바로 HTTP인 것입니다.

 


  • HTTP 요청 메소드
    • GET, POST, PUT, DELETE
  • HTTP 응답 코드
    • 2xx(성공), 3xx(리다이렉션), 4xx(클라이언트 에러), 5xx(서버 에러) 등
  • HTTP 헤더
    • Content-type, Accept, Cookie, Set-Cookie, Authorization 등 

 

우리가 웹 애플리케이션 개발자로 살아가기 위해선 대부분 HTTP 프로토콜 기반 위에서 통신을 해야하는데 그렇기 때문에 해당 부분의 규약에 대해서 자세히 알 필요가 있으며 관련된 내용으로 추후에 더 자세한 포스팅을 할 것입니다.

 


5. 계산기 프로그램 실습

그렇다면 이제 계산기 프로그램을 직접 만들어보면서 HTTP에 대해 더 피부에 와닿게끔 해보겠습니다.

계산기 프로그램을 앞서 말했드 세 단계에 나누어서 진행을 할 것입니다.

 

Step1 - 사용자 요청을 메인 Thread가 처리하도록 한다.
Step2 - 사용자 요청이 들어올 때마다 Thread를 새로 생성해서 사용자 요청을 처리하도록 한다.
Step3 - Thread Pool을 적용해 안정적인 서비스가 가능하도록 한다.

 

Step 1

실습에 앞서 assertJ, logback 의존성 추가해 줍니다.

implementation 'ch.qos.logback:logback-classic:1.2.3'

testImplementation 'org.assertj:assertj-core:3.23.1'

 

또한 이전 시간에 만들었던 계산기 역할을 하는 코드를 가져오겠습니다.

https://github.com/speardragon/oop-practice

 

GitHub - speardragon/oop-practice: 객체지향 실습

객체지향 실습. Contribute to speardragon/oop-practice development by creating an account on GitHub.

github.com

 

이제 계산기 프로그램을 웹 애플리케이션으로 만들어 볼 것인데요. 그에 앞서 약속을 하나 해 보겠습니다.

GET /calculate?operand1=11&operator=*&operand2=55

위에 작성한 것의 의미는 "GET 메소드로 /calculate 라는 경로에 요청이 들어오면 계산을 한 결과를 응답할 것"입니다. 이에 더하여 parameter는 operand1, operator, operand2를 받겠다는 의미입니다.

 

일단, CustomWebApplicationServer라는 클래스를 하나 만들도록 합니다.

 

 

이 클래스는 public void start()라는 메소드를 호출하면 웹 애플리케이션 서버가 동작하는 형태로 인터페이스를 설계한것입니다.

// CustomWebApplicationServer
package org.example;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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

public class CustomWebApplicationServer {
    private final int port;

    private static final Logger logger = LoggerFactory.getLogger(CustomWebApplicationServer.class);

    public CustomWebApplicationServer(int port) {
        this.port = port;
    }

    public void start() throws IOException {
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            logger.info("[CustomWebApplicationServer] started at {} port.", port);

            Socket clientSocket;
            logger.info("[CustomWebApplicationServer] waiting for client");

            while ((clientSocket = serverSocket.accept()) != null) {
                logger.info("[CustomWebApplicationServer] client connected!");
            }
        }
    }
}

 

코드의 의미는 다음과 같습니다.

port 변수는 final 변수로 인스턴스가 생성될 때 인자로 받아오도록 하기 위해 생성자와 한 세트로 이루어져 있고 logger 객체는 로그를 보기 위해 선언했기 때문에 인스턴스의 생성과 관계없는 static 변수로 선언합니다.

 

start() 메소드의 동작은 다음과 같습니다.

1. 지정한 포트로 serverSocket을 만든다.

2. clientSocket을 선언하고

3. serverSocket이 클라이언트를 기다리다가 도착한 클리이언트를 accept 하고 해당 클라이언트에게 clientSocket이 주어진다.

 

서버가 동작하는 클래스를 만들었다면 이를 한번 main 메소드에서 실행해 보도록 하겠습니다.

그러기 위해서 main 메소드는 다음 코드와 같이 구성해야 합니다.

//main
package org.example;

import java.io.IOException;

// GET /calculate?operand1=11&operator=*&operand2=55
public class Main {
    public static void main(String[] args) throws IOException {
        new CustomWebApplicationServer(8080).start();
    }
}

코드의 의미는 만든 클래스 객체를 8080 포트를 가지는 인스턴스를 하나 생성하여 앞서 만든 start() 메소드를 실행한 것입니다.

 

main 메소드를 실행하면 아래와 같은 사진처럼 성공적으로 로그가 뜨는 것을 확인하실 수 있습니다.

사진을 보시면 아시겠지만 아직 클라이언트를 기다리고 있습니다. 이는 서버에 접근한 클라이언트가 아직 없다는 의미인데, 만약 해당 포트로 접근하는 클라이언트가 있다면 클라이언트가 연결되었다는 로그도 뜰 것입니다.

 

여기서 클라이언트 역할은 프로젝트 루트 디렉터리에 test.http 라는 http request 파일을 만들어 그 역할을 하도록 할 수 있습니다! (해당 부분은 IntelliJ에서 제공하는 HTTP 툴입니다.)

test.http

해당 파일에 위와 같이 작성을 한 후 다시 로그를 살펴보면, 

이와 같이 client connected! 라는 문구가 새로 출력된 것을 확인할 수 있습니다.

 

 

 

이제 서버를 만드는 데 성공했습니다! 그렇다면 이제부터는 하나하나씩 프로그램의 요구사항에 맞게 추가해 보도록 하겠습니다.

 

먼저 Step1이었던 '사용자 요청을 메인 Thread가 처리하도록 한다.'라는 요구 사항을 만족시키기 위해서 try-catch 문 안에 input 스트림과 output 스트림을 연결하는 작업을 하도록 하겠습니다.

 

여기서 InputStream을 Buffer로 바꿔서 읽고 싶었는데 그 이유는 line-by-line(한줄씩)으로 읽고 싶었기 때문입니다.

 

line이 어떤 형태로 나오는지 알아보기 위해 표준 출력으로 출력해 보면 다음과 같이 나오게 됩니다.

위에서 배웠던 내용이 나오는 것을 확인할 수 있습니다. 이것이 바로 HTTP 프로토콜의 생김새입니다. 즉, 우리가 spring을 이용해서 개발을 할 때, 어떠한 무언가는 이 내용을 parsing 하여 그 요청에 맞게 기능 내지 동작을 수행하게 될 것인데 여기서 말하는 무언가가 바로 지난 시간에 나왔던 Tomcat 입니다. 

 

따라서 우리가 여기서 만들고 있었던 것은 사실 Tomcat 이었던 것입니다.

 

계속 이어서 어떤 방식으로 parsing 하여 어떻게 처리를 할 지에 대한 부분을 진행해 보도록 하겠습니다.

 


먼저 요청을 보낼 path는 아까 지정했듯이 /calculate?operand1=11&operator=*&operand2=55 이기 때문에 test.http 에서 해당 경로로 GET 메소드 요청을 날려보겠습니다.

그러면 아까 전과 똑같지만 path만 바뀌어 요청이 날라오게 됩니다. 그러면 우리는 저 path 부분만을 parsing 하고 split하여 사용하면 되는 것입니다.

 

즉, HTTP 프로토콜에서 첫번째에 위치하였던 'request line'을 split 한 다음에 parameter 값을 뽑아내어 계산을 하는 것입니다. 

그러기 위해선 HttpRequest 라는 클래스가 필요할 것 같고 해당 부분에서 request 내용을 분석하는 과정이 필요할 것 같습니다.

 

따라서 아래와 같은 구조 생김새입니다.

HttpRequest

- ReqeustLine(Get /calculate?operand1=11&operator=*&operand2=55 HTTP/1.1)
  - HTTPMethod
  - path
  - queryString
- Header
- Body

위에서 RequestLine에 대한 부분을 구현해 볼 것입니다.

 

TDD 방식으로 이를 구현해 보기 위해 먼저 test 디렉터리 쪽에 test 파일을 하나 만들어 주도록 하겠습니다.

 

void create() {
    RequestLine requestLine = new RequestLine("Get /calculate?operand1=11&operator=*&operand2=55 HTTP/1.1");
    assertThat(requestLine).isNotNull();
    assertThat(requestLine).isEqualTo(new RequestLine("Get", "/calculate?operand1=11&operator=*&operand2=55", "HTTP/1.1"));
}

위 테스트 코드에서는 요청 path를 통째로 인자로 준 것과 3부분으로 나누어 준 RequestLine 인스턴스를 생성했을 때 각각의 인스턴스가 성공적으로 생성되는지를 테스트합니다.

 

위 테스트 코드를 통해 만들어야 될 것이 분명해 졌습니다. 그렇다면 RequestLine 클래스를 바로 만들어보겠습니다.

 

public class RequestLine {

    private final String urlPath; // /calculate
    private final String method; // GET
    private String queryString; // operand1=11&operator=*&operand2=55

    public RequestLine(String method, String urlPath, String queryString) {
        this.method = method;
        this.urlPath = urlPath;
        this.queryStrings = new QueryStrings(queryString);
    }

    public RequestLine(String requestLine) {
        // Get     /calculate?operand1=11&operator=*&operand2=55      HTTP/1.1

        String[] tokens = requestLine.split(" ");
        this.method = tokens[0]; // GET
        String[] urlPathTokens = tokens[1].split("\\?");
        this.urlPath = urlPathTokens[0]; // /calculate..........

        if (urlPathTokens.length == 2) {
            this.queryStrings = new QueryStrings(urlPathTokens[1]);
        }
    }

해당 클래스에는 당연히 두 개의 생성자가 존재하고 하나는 요청 경로를 통째로 넣은 것과 하나는 각 부분에 해당하는 것을 따로 따로 준 것입니다.

 

따로 따로 주었을 때는 그저 멤버변수에 종속시키기만 하면 되지만 통째로 받은 경우는 이를 parsing 해 주어서 각 변수에 대해 각각을 멤버변수에 넣어주도록 합니다.

 

이제 테스트코드를 한 번 돌려보면 RequestLine 인스턴스를 만든 것과 비교하는 부분에서 같지 않다고 에러가 날 것입니다. 왜 같지 않을까요? 저번 시간에 굉장히 중요하게 소개를 드렸던 객체끼리 비교할 때는 단순 isEqualTo가 아닌  eqauls() and hashCode()를 만들어주어야 하기 때문입니다.

 

해당 부분을 수정하고 다시 테스트 코드를 돌려보면 테스트에 성공하게 됩니다.

 

아직 끝이 아닙니다. 현재 우리는 요청한 urlPath에서 다음과 같은 정보를 얻어내는 데 까지는 성공했습니다.

  • urlPath -> /calculate
  • method -> GET
  • queryString -> operand1=11&operator=*&operand2=55

여기서 더 진행해야 할 과정은 당연히 눈에 보이는 저 queryString 부분입니다. 두 개의 operand와 하나의 operator를 얻어내는 것을 해보도록 하겠습니다.

 

그러면 가장 먼저 해야 할 단계가 무엇일까요? TDD 개발 방식이기 때문에 당연히 QueryString에 대한 테스트 코드를 먼저 작성하는 것입니다. 해당 테스트 코드에서는 queryString이 제대로 분리가 되는지를 테스트 해보면 되겠습니다.

public class QueryStringTest {
    @Test
    void createTest() {
        QueryString queryString = new QueryString("operand1", "11");

        assertThat(queryString).isNotNull();
    }
}

가장 간단한 테스트 형태를 만들고 이제 QueryString 클래스를 만들어줍니다.

 

 

그런데 QueryString은 key, value 한 쌍을 가지는 객체 하나입니다. 그렇다면 여러 QueryString에 대해 요청이 온 경우는 어떻게 받아야 할까요?

public class QueryString {
    private final String key;
    private final String value;

    public QueryString(String key, String value) {
        this.key = key;
        this.value = value;

    }
}

 

List로 받으면 된다는 생각이 가장 먼저 들기 때문에 List<QueryString> 으로 진행하면 될 것 입니다.

 

그러기 위해 지난 시간 배웠던 일급컬렉션을 이번에 또 한번 만들어주도록 하겠습니다.

public class QueryStrings {
    private List<QueryString> queryStrings = new ArrayList<>(); // 1.
    public QueryStrings(String queryStringLine) {
        String[] queryStringTokens = queryStringLine.split("&"); // 2. 
        
        Arrays.stream(queryStringTokens) // 3. 
                .forEach(queryString -> {
                    String[] values = queryString.split("=");
                    if (values.length != 2) {
                        throw new IllegalArgumentException("잘못된 QueryString을 가진 문자열입니다.");
                    }
                    queryStrings.add(new QueryString(values[0], values[1]));
                });
    }
}

1. 먼저 각각의 queryString의 key, value 쌍으로 저장될 리스트를 하나 선언하고

2. queryStringLine을 넘겨 받은 생성자에서는 첫번째로 "&"를 기준으로 parsing 하여 세 뭉텅이의 queryString을 나누어 줍니다.

3. 이 부분은 리스트를 사용하는 전형적인 패턴으로 잘 알아놓으셔야 하는 부분입니다.

  • array안의 요소를 하나씩 다루기 위해 Arrays.stream({리스트})를 사용하였고 각각에 대해서 forEach 안의 함수를 실행하도록 합니다.
  • 그 함수의 내용은 각 뭉텅이에서 또 다시 "="을 기준으로 하여 split 하고 key, value를 가진 하나의 QueryString을 생성해 1번에서 만들어 놓은 결과를 담을 리스트에 add 합니다.

 

 

그렇다면 이제 우리의 최종 목적이었던 HttpRequest를 만들어보도록 하겠습니다.

public class HttpRequest {
    private final RequestLine requestLine;
    // private final HttpRequestHeaders httpRequestHeaders;
    // private final Body body;

    public HttpRequest(BufferedReader br) throws IOException {
        this.requestLine = new RequestLine(br.readLine());
    }

    public boolean isGetRequest() {
        return requestLine.isGetRequest();
    }
    public QueryStrings getQueryString() {
        return requestLine.getQueryString();
    }


    public boolean matchPath(String path) {
        return requestLine.matchPath(path);
    }
}

이 클래스에서는 bufferedReader 를 받아 버퍼를 읽어 들여 requestLine에 이를 추가하는 생성자와 GET 메소드인지 판단해 주는 메소드, QueryString을 가져오는 메소드, path 가 존재하는지의 여부를 알려주는 메소드로 구성됩니다.

 

중요한 점은 각각의 메소드는 requestLine 인스턴스의 멤버 함수를 통해 알 수 있는 것들이기 때문에 RequestLine 클래스에 해당 부분을 리턴 해 주는 메소드를 다시 만들어 해당하는 값들을 리턴해 줍니다.

public boolean isGetRequest() {
    return "GET".equals(this.method);
}

public boolean matchPath(String path) {
    return urlPath.equals(path);
}

public QueryStrings getQueryString() {
    return this.queryStrings;
}

 

이제 다시 CustomWebApplicationServer로 돌아와 아까 연결한 clientsocket에 대한 부분을 추가적으로 작성해 줍니다.

클라이언트가 연결에 성공했다는 로그를 출력한 이후 부분에 추가를 합니다.

public void start() throws IOException {
    try (ServerSocket serverSocket = new ServerSocket(port)) {
        logger.info("[CustomWebApplicationServer] started at {} port.", port);

        Socket clientSocket;
        logger.info("[CustomWebApplicationServer] waiting for client");

        while ((clientSocket = serverSocket.accept()) != null) {
            logger.info("[CustomWebApplicationServer] client connected!");

            /**
             * Step1 - 사용자 요청을 메인 Thread가 처리하도록 한다
             */

            try (InputStream in = clientSocket.getInputStream(); OutputStream out = clientSocket.getOutputStream()) {
                BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
                DataOutputStream dos = new DataOutputStream(out);

                HttpRequest httpRequest = new HttpRequest(br);

                // GET /calculate?operand1=11&operator=*&operand2=55
                if (httpRequest.isGetRequest() && httpRequest.matchPath("/calculate")) { // GET 메소드 이면서 /calculate 요청인가?
                    QueryStrings queryString = httpRequest.getQueryString();

                    int operand1 = Integer.parseInt(queryString.getValue("operand1"));
                    String operator = queryString.getValue("operator");
                    int operand2 = Integer.parseInt(queryString.getValue("operand2"));

                    int result = Calculator.calculate(new PositiveNumber(operand1), operator, new PositiveNumber(operand2));
                    byte[] body = String.valueOf(result).getBytes(); // body 가져오기

                    HttpResponse response = new HttpResponse(dos);
                    response.response200Header("application/json", body.length);
                    response.responseBody(body);
                }
            }
        }
    }
}

먼저 Input Stream과 Output Stream을 연결시켜 주고 BufferedReader를 통해 Input Stream을 먼저 받아들이고 해당 부분을 HttpRequest 인스턴스의 인자로 하여 인스턴스를 생성합니다.

 

생성된 httpRequest 객체에 대해서 GET 메소드이면서, path가 유효하다면 queryString을 받아와 operand1, operator, operand2 를 각각 초기화하여 Calculator 인스턴스를 통해 계산된 결과를 가공하여 HttpRespose를 통해 응답해 주면 됩니다.

 

 

public String getValue(String key) {
    return this.queryStrings.stream()
            .filter(queryString -> queryString.exists(key))
            .map(QueryString::getValue)
            .findFirst()
            .orElse(null);
}

(QueryString 클래스)여러 개의 QueryString이 있는 경우 각각의 key에 대해 존재하는지 filter를 통해 먼저 거르고 존재한다면 Value값을 가져와 첫번째 것을 반환하도록 하는 코드가 추가시켰습니다. 없다면 null을 반환하도록 합니다.

 

 

이제 HttpRequest를 통해 결과값을 만들 수 있게 되었으니 이를 응답해 주는 HttpResponse를 만들어줍니다.

 
public class HttpResponse {
    private static final Logger logger = LoggerFactory.getLogger(HttpResponse.class);

    private final DataOutputStream dos;

    public HttpResponse(DataOutputStream dos) {
        this.dos = dos;
    }

    public void response200Header(String contentType, int lengthOfBodyContent) {
        try {
            dos.writeBytes("HTTP/1.1 200 OK \r\n");
            dos.writeBytes("Content-Type: " + contentType + ";charset=utf-8\r\n");
            dos.writeBytes("Content-Length: " + lengthOfBodyContent + "\r\n");
            dos.writeBytes("\r\n");
        } catch (IOException e) {
            logger.error(e.getMessage());
        }
    }

    public void responseBody(byte[] body) {
        try {
            dos.write(body, 0, body.length);
            dos.flush();
        } catch (IOException e) {
            logger.error(e.getMessage());
        }
    }
}

이 코드는 여기서 당장은 중요하지 않기 때문에 설명은 생략하도록 하겠습니다.

 

 

결과를 확인해 보면

10 + 5가 전달 되어 결과값인 15가 제대로 나온 모습을 볼 수 있습니다.


이번 과정을 통해서 우리는 톰캣(Tomcat)을 직접 만들어 본 셈입니다. 굳이 Tomcat을 왜 만들어보았냐면, 웹 어플리케이션서버를 만들어봤으면 했던 이유 중 하나는 HTTP 프로토콜이 어떻게 생겼는지 보기 위함이었습니다. 처음에 우리가 HTTP 프로토콜이 어떻게 생겼는지 보았고 그것을 통해서 HTTP 프로토콜에 맞게 requestLine을 setting을 해주었습니다.

 

사실 HTTP request 에는 requestLine 뿐 아니라 header, body 값도 있었어야 하는데요. 즉, 프로토콜을 통해 들어온 bufferedReader를 전달 해서 그 프로토콜에 맞게 객체를 초기화했어야 합니다.

 

그럼에도 불구하고 이 계산기 프로그램에서 Body값과 header 값을 만들지 않은 이유는 단순히 그닥 필요하지 않아서 입니다.

 

이번 포스팅에서 중요한 점은 http request 규약에 맞게 프로토콜을 직접 만들어봄으로써 requestLine을 직접 parsing 해보고 어떤 메소드가 들어왔을 때 어떤 queryString이 들어왔는지 확인하고 그에 맞는 처리를 해줌으로써 이해를 할 수 있다는데 의의가 있다는 것이었습니다.

 

이렇게 함으로써 우리는 Step1이었던, 하나의 요청이 들어왔을 때, 그 요청을 하나의 메인 스레드가 처리하도록 구현할 수 있었습니다.

 

 


그런데 사실 위 방식에는 한계가 존재하며 그를 해결하기 위한 단계인 Step2를 또 다시 구현해 보면서 설명드리겠습니다.

  • Step2: 사용자 요청이 들어올 때마다 Thread를 새로 생성해서 사용자 요청을 처리하도록 한다.

 

Step 1의 문제점은 클라이언트의 요청이 들어왔을 때, 해당 부분을 메인스레드에서 처리한다는 점입니다. 만약 메인스레드가 해당 작업을 수행하다가 blocking이 걸린다면 다음 클라이언트의 요청은 앞선 요청이 끝날때까지 무한정 기다려야한다는 문제가 생기는 것입니다.

 

그래서 각 요청이 들어올때마다 메인 스레드가 아니라 별도의 스레드에서 실행할 수 있도록 하는 작업을 하는 것입니다.

 

그렇기 때문에 Step 1의 CustomWebApplicationServer에서 중요한 부분을 스레드 상속 클래스를 하나 만들어 구현해 보도록 하겠습니다.

public class ClientRequestHandler implements Runnable {
    private static final Logger logger = LoggerFactory.getLogger(ClientRequestHandler.class);
    
    private final Socket clientSocket;

    public ClientRequestHandler(Socket clientSocket) {
        this.clientSocket = clientSocket;
    }

    @Override
    public void run() {
        /**
         * Step1 - 사용자 요청을 메인 Thread가 처리하도록 한다
         * Step2: 사용자 요청이 들어올 때마다 Thread를 새로 생성해서 사용자 요청을 처리하도록 한다.
         */
        
        logger.info("[ClientRequestHandler] new client {} started.", Thread.currentThread().getName());

        try (InputStream in = clientSocket.getInputStream(); OutputStream out = clientSocket.getOutputStream()) {
            BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
            DataOutputStream dos = new DataOutputStream(out);

            HttpRequest httpRequest = new HttpRequest(br);

            // GET /calculate?operand1=11&operator=*&operand2=55
            if (httpRequest.isGetRequest() && httpRequest.matchPath("/calculate")) { // GET 메소드 이면서 /calculate 요청인가?
                QueryStrings queryString = httpRequest.getQueryString();

                int operand1 = Integer.parseInt(queryString.getValue("operand1"));
                String operator = queryString.getValue("operator");
                int operand2 = Integer.parseInt(queryString.getValue("operand2"));

                int result = Calculator.calculate(new PositiveNumber(operand1), operator, new PositiveNumber(operand2));
                byte[] body = String.valueOf(result).getBytes(); // body 가져오기

                HttpResponse response = new HttpResponse(dos);
                response.response200Header("application/json", body.length);
                response.responseBody(body);
            }
        } catch (IOException e) {
            logger.error(e.getMessage());
        }
    }
}

스레드는 Runnable이라는 클래스를 상속하여 만들 수 있습니다. 해당 클래스의 run이라는 메소드에 코드를 붙여넣기 해 주면 됩니다.

 

그러면 CustomWebapplicationServer 부분의 start메소드는 다음과 같이 스레드를 하나 생성해 주기만 하면 되기 때문에 코드가 굉장히 단순해 진 것을 보실 수 있습니다.

public void start() throws IOException {
    try (ServerSocket serverSocket = new ServerSocket(port)) {
        logger.info("[CustomWebApplicationServer] started at {} port.", port);

        Socket clientSocket;
        logger.info("[CustomWebApplicationServer] waiting for client");

        while ((clientSocket = serverSocket.accept()) != null) {
            logger.info("[CustomWebApplicationServer] client connected!");

            new Thread(new ClientRequestHandler(clientSocket)).start();            }
    }
}

 

그러고 나서 서버를 실행시키고 클라이언트에서 요청을 날리면 아래 사진과 같이 성공적으로 결과값을 응답하는 걸 볼 수 있습니다.

 

 

여기서 주목해야 할 점은 바로 로그 부분입니다.

위와 같이 Thread-0에서 시작되었다고 로그가 출력되었는데 해당 부분의 코드는 아래와 같습니다.

logger.info("[ClientRequestHandler] new client {} started.", Thread.currentThread().getName());

이는 메인스레드가 아님을 말하는 것이고 따라서 해당 스레드가 blocking 되어도 다른 클라이언트로부터 또 다른 요청이 왔을 때, 그 요청에 대한 스레드를 또 하나 만들어 처리를 해 주는 것이 가능해 졌습니다.

 

우리가 사용할 톰캣 역시 해당 부분이 별도의 스레드를 만들어내는 것으로 구현이 되어있을 것입니다.


여기까지 왔는데도 아직 하나의 step이 더 남아 있습니다. 위와 같은 step 2 역시 문제가 있기 때문인데요. 어떤 문제일까요?

 

사용자로부터 요청이 들어올때마다 스레드를 새로 생성합니다. 그런데 스레드는 생성될 때마다 독립적인 스택 메모리 공간을 할당 받습니다. 이렇게 메모리를 할당 받는 작업은 굉장히 expensive한 작업이라고 볼 수 있는데요. 따라서 사용자의 요청에 대해 계속해서 스레드를 만들어내게 된다면 성능이 급격하게 떨어지게 되는 것입니다.

 

즉, 트래픽이 몰릴 때마다 비싼 작업을 계속 해야만 하는 것입니다. 다시말해서, 동시접속자가 많아지는 것과 같은 경우에 스레드가 그에 맞게 많이 생성되게 되면 CPU context switching 횟수 역시 증가되고 CPU와 메모리 사용량의 증가로 이어질 것입니다.

최악의 경우에는 서버에 감당하지 못할 리소스로 인해 서버가 다운될 가능성까지 생기는 것입니다.

 

그래서 스레드를 계속해서 생성해 내는 것이 아니라 만들 스레드의 최대 갯수를 미리 지정해 두고 이를 재활용하여 사용하는 Thread Pool 개념을 적용하여 안정적인 서비스가 가능케 합니다.

 

  • Thread Pool을 적용해 안정적인 서비스가 가능하도록 한다.

 

그렇다면 이제 customWebApplicationServer로 넘어가 아래와 같이 Thread Pool을 하나 만듭니다.

private final ExecutorService executorService = Executors.newFixedThreadPool(10);

ThreadPool에 들어갈 수 있는 스레드 갯수는 10개로 지정한 것입니다.

 

 

// new Thread(new ClientRequestHandler(clientSocket)).start();
executorService.execute(new ClientRequestHandler(clientSocket));

그리고나서 원래는 주석처리한 것과 같이 스레드를 매번 생성했다면 이제는 만든 executorService 객체를 통해 실행합니다.

 

이제 서버를 실행해 보고 요청을 날려보면,

이와 같이 스레드 이름을 찍는 로그에서 pool-1-thread-1 으로 찍힌 것으로 보아 풀에서 스레드를 꺼내 실행했다라는 사실을 이끌어 낼 수 있겠습니다.

 

6. 정리

이번 시간에는 우리가 계산기 프로그램을 웹 애플리케이션으로 만들어보았는데요. 

 

첫번째로는 메인스레드가 처리하도록 했었는데, 해당 경우 메인 스레드에서 작업이 오래 걸리면 그 뒤에 기다리고 있는 클라이언트 요청들은 메인 스레드가 완전히 끝날 때까지 기다려야만 한다는 단점이 있었습니다.

 

두번째로는 앞선 문제를 해결하기 위해 클라이언트 요청이 들어오면 사용자의 요청이 들어올 때마다 스레드를 새로 만들어 요청을 처리하는 방식이었는데요. 이 또한 계속해서 스레드를 만들어내다 보면 동시접속사가 많아지는 경우 스레드가 넘치게 많아지게 되고 그렇게 되면 CPU context switching 횟수 증가와 더불어 CPU, RAM의 사용량이 증가함에 따라 최악의 경우 서버의 리소스 한계로 인해 서버가 다운되는 초유의 사태까지 벌어질 수 있는 것입니다.

 

이러한 단점을 해결하고자 세번째로 Thread Pool 개념을 도입하였고, 이는 스레드 생성 개수를 제한하여 안정적 서비스를 제공하도록 구현하였습니다.

 

이번에 배운 내용은 훗날 포스팅하게 될 DispatcherServlet과 관련된 내용이므로 잘 알아두면 좋을 것 같습니다.

 

반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.