자바 - HTTP 서버 개발

V1

간단하게 웹 브라우저에 다음 HTML을 응답하는 HTTP 서버를 직접 만들어보자.

<h1>Hello World</h1>
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;

import static java.nio.charset.StandardCharsets.UTF_8;
import static util.MyLogger.log;

/**
 * HTTP 서버 개발
 * @version 1
 */
public class HttpServerV1 {

    private final int port;

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

    public void start() throws IOException {
        ServerSocket serverSocket = new ServerSocket(port);
        log("서버 시작 port: " + port);

        while (true) {
            Socket socket = serverSocket.accept();
            process(socket);
        }
    }

    private void process(Socket socket) throws IOException {

        try (socket;
             BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF_8));
             PrintWriter writer = new PrintWriter(socket.getOutputStream(), false, UTF_8)) { //autoFlush=false

            //HTTP 요청을 String으로 반환
            String requestString = requestToString(reader);

            if (requestString.contains("/favicon.ico")) {
                log("favicon 요청");
                return;
            }

            log("HTTP 요청 정보 출력");
            System.out.println(requestString);

            log("HTTP 응답 생성중...");
            sleep(5000); //서버 처리 시간 가정
            responseToClient(writer);
            log("HTTP 응답 전달 완료");
        }
    }

    private String requestToString(BufferedReader reader) throws IOException {

        StringBuilder sb = new StringBuilder();
        String line;

        while ((line = reader.readLine()) != null) {

            //줄바꿈, HTTP 헤더의 마지막으로 인식
            //빈 라인 이후에는 메시지 바디가 나온다.
            if (line.isEmpty()) break;

            sb.append(line)
              .append("\n");
        }

        return sb.toString();
    }

    private void responseToClient(PrintWriter writer) {

        //웹 브라우저에 전달하는 내용
        String body = "<h1>Hello World</h1>";
        int length = body.getBytes(UTF_8).length;

        StringBuilder sb = new StringBuilder();
        // \r\n = 줄바꿈 처리
        sb.append("HTTP/1.1 200 OK\r\n")
          .append("Content-Type: text/html\r\n")
          .append("Content-Length: ").append(length).append("\r\n")
          .append("\r\n") //header, body 구분 라인
          .append(body);

        log("HTTP 응답 정보 출력");
        System.out.println(sb);

        writer.println(sb);
        writer.flush();
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
  • HTTP 메시지의 주요 내용들을 문자로 읽고 쓴다.

  • 보조 스트림으로 BufferedReaderPrintWriter를 사용한다.

    • 스트림을 Reader, Writer로 변경할 때는 항상 인코딩을 확인해야 한다.

  • new PrintWriter() 생성자 두 번째 인자로 autoFlush 여부를 받는다.

    • trueprintln()으로 출력할 때마다 자동으로 플러시된다. 첫 내용을 빠르게 전송할 수 있지만 네트워크 전송이 자주 발생한다.

    • falseflush()를 직접 호출해주어야 데이터를 전송한다. 데이터를 모아서 전송하므로 네트워크 전송 횟수를 효과적으로 줄일 수 있다. 한 패킷에 많은 양의 데이터를 담아서 전송할 수 있다.

http://localhost:12345를 요청하면 웹 브라우저가 HTTP 요청 메시지를 만들어서 서버에 전달한다.

현재 서버는 한 번에 하나의 요청만 처리할 수 있다는 문제가 있다. 서버는 동시에 수 많은 사용자의 요청을 처리할 수 있어야 한다.

서로 다른 웹 브라우저 2개를 열어서 동시에 http://localhost:12345를 요청하면 첫 번째 요청이 모두 처리되고 나서 두 번째 요청이 처리된다.

하나의 메인 스레드에서 serverSocket.accept()에서 블로킹이 되기 때문이다.


V2

스레드를 사용해서 동시에 여러 요청을 처리할 수 있도록 해보자.

img.png

동시에 요청한 수 만큼 별도의 스레드에서 HttpRequestHandler가 수행된다.


V3

HTTP 서버들은 URL 경로를 사용해서 각각의 기능을 제공한다. URL에 따른 다양한 기능을 제공하는 서버를 만들어보자.

img_1.png
img_2.png
img_3.png
img_4.png

퍼센트(%) 인코딩

HTTP 메시지에서 시작 라인(URL 포함)과 HTTP 헤더의 이름은 항상 ASCII를 사용해야 한다. 반면 HTTP 메시지 바디는 UTF-8과 같은 다른 인코딩을 사용할 수 있다.

그렇다면 /search?q=가나다처럼 URL에 한글을 전달하려면 어떻게 해야할까? 우선 http://localhost:12345/search?q=가나다 를 요청하면 결과는 다음과 같다.

img_5.png
  • 한글을 UTF-8 인코딩으로 표현하면 한 글자에 3byte의 데이터를 사용한다.

  • 가, 나, 다를 UTF-8의 16진수로 표현하면 다음과 같다.

    • : EA, B0, 80 (3byte)

    • : EB, 82, 98 (3byte)

    • : EB, 8B, A4 (3byte)

  • URL은 ASCII 문자만 표현할 수 있으므로 UTF-8 문자를 표현할 수 없다.

  • 그래서 예를 들어 "가"를 UTF-8 16진수로 표현한 각각의 바이트 문자 앞에 퍼센트(%)를 붙이는 것이다.

    • q=%EA%B0%80

  • 이렇게 하면 억지로라도 ASCII 문자를 사용해서 16진수로 표현된 UTF-8을 표현할 수 있다. 그리고 퍼센트(%)를 포함해서 모두 ASCII에 포함되는 문자이다.

  • 이렇게 각각의 16진수 byte를 문자로 표현하고, 해당 문자 앞에 %를 붙이는 것을 퍼센트 인코딩이라 한다.

퍼센트 인코딩 후에 클라이언트에서 서버로 데이터를 전달하면 서버는 각각의 %를 제거하고 각 문자(EA, B0, 80)를 얻는다. 그리고 이렇게 얻은 문자를 16진수 byte로 변경한다. 이 3개의 byte를 모아서 UTF-8로 인코딩하면 "가"라는 글자를 얻을 수 있는 것이다.

자바에서는 자바가 제공하는 URLEncoderURLDecoder를 사용해서 퍼센트 인코딩을 처리할 수 있다.

퍼센트 인코딩은 데이터 크기에서 보면 효율이 떨어진다. 문자 "가"는 3byte만 필요한데, 퍼센트 인코딩을 사용하면 %를 포함해 9byte나 사용된다.


V4

HTTP 요청 메시지와 응답 메시지는 HTTP 메서드, URL, 헤더, HTTP 버전, Content-Type 등과 같이 정해진 규칙이 있다.

각 규칙에 맞추어 객체로 만들어서 코드를 리팩토링 해보자.

퍼센트 디코딩도 URLDecoder.decode()를 사용해서 처리했기 때문에 HttpRequest를 사용하는 쪽에서는 퍼센트 디코딩을 고민하지 않아도 된다.

시작 라인의 다양한 정보와 헤더를 객체로 구조화했다.

HTTP 응답을 객체로 만들어 시작 라인, 응답 헤더를 구성하는 내용을 반복하지 않고 편리하게 사용할 수 있게 되었다.

  • 클라이언트의 요청이 들어오면 요청 정보를 기반으로 HttpRequest 객체를 만들어두고, HttpRequest를 통해서 필요한 정보를 편리하게 찾을 수 있다.

  • 응답의 경우도 HttpResponse를 사용하여 HTTP 메시지 바디에 출력할 부분만 적어주면 된다. 나머지는 HttpResponse객체가 대신 처리해준다.

HttpRequest, HttpResponse 객체가 HTTP 요청과 응답을 잘 구조화한 덕분에 많은 중복을 제거하고 코드도 효과적으로 리팩토링 할 수 있었다.

위 코드들을 보면 전체적인 코드가 크게 두 가지로 분류되는 것을 알 수 있다.

  • HTTP 서버와 관련된 부분

    • HttpServer, HttpRequestHandler, HttpRequst, HttpResponse

  • 서비스 개발을 위한 로직

    • home(), site1(), site2(), search(), notFound()

만약 웹 회원 관리 프로그램 같은 서비스를 만들어야 한다면 기존 코드에서 HTTP 서버와 관련된 부분은 거의 재사용하고, 서비스 개발을 위한 로직만 추가하면 될 것 같다.


V5

커맨드 패턴으로 코드를 리팩토링 해보자.

HTTP 서버와 관련된 부분을 구조화하여 서비스 개발을 위한 로직과 명확하게 분리해야 한다. 여기서 핵심은 HTTP 서버와 관련된 부분은 코드의 변경 없이 재사용 가능해야 한다는 점이다.

커맨드 패턴을 사용하면 확장성 외에도 HTTP 서버와 관련된 부분과 서비스 개발을 위한 로직을 분리하는데도 도움이 된다.

img_6.png

ServletManager는 설정을 쉽게 변경할 수 있도록 유연하게 설계되어 있다.

HttpRequestHandler의 역할이 단순해졌다. HttpRequest, HttpResponse를 만들고 ServletManger에게 전달하면 된다.

필요한 서블릿(HttpServlet)들을 서블릿 매니저에 등록하는 부분이 서비스 개발을 위한 로직들이다. HttpServer를 생성하면서 서블릿 매니저를 전달하면 된다.

이제 HTTP 서버와 서비스 개발을 위한 로직이 명확하게 분리되어 있다.

  • HTTP 서버와 관련된 부분

    • HttpServer, HttpRequestHandler, HttpRequest, HttpResponse

    • HttpServlet, HttpServletManager

    • InternalErrorServlet, NotFoundServlet, DiscardServlet

  • 서비스 개발을 위한 로직

    • HomeServlet, Site1Servlet, Site2Servlet, SearchServlet

이후에 다른 HTTP 기반의 프로젝트를 시작한다면, HTTP 서버와 관련된 부분들은 코드를 그대로 재사용하면 된다. 그리고 해당 서비스에 필요한 서블릿을 구현하고 서블릿 매니저에 등록한 다음에 서버를 실행하면 된다.

여기서 중요한 부분은 새로운 HTTP 서비스(프로젝트)를 만들어도 HTTP 서버와 관련된 부분의 코드를 그대로 재사용할 수 있고, 전혀 변경하지 않아도 된다는 점이다.


이전 ↩️ - 채팅 프로그램 개발

메인 ⏫

다음 ↪️ - 리플렉션

Last updated