본문 바로가기
Java/Java

HTTP Request 파싱하기

by oneny 2023. 9. 21.

HTTP Request 파싱하기

이전 게시글에서 HTTP Request가 출력되는 것을 확인했다! 그러면 이제 HTTP Request를 파싱해보자. 이렇게 HTTP Request를 파싱하는데에는 이유가 있다. 우리가 사용하는 스프링 프레임워크는 모두가 알다시피 서블릿 스펙을 지킨 서블릿 컨테이너를 구현하고 있다. 다시 말하자면 서블릿 컨테이너의 주요 목표는 서블릿을 동작시키는데 있다고 볼 수 있다. 따라서 서블릿이 어떤 방식으로 동작하는지를 이해하면 서블릿 컨테이너가 제공해야 하는 기능을 역으로 유추할 수 있다.

그리고 이런 서블릿의 목적은 HTTP 프로토콜을 사용해 웹 서비스를 제공하는 것이다. 그럼 이 서블릿 스펙을 구현한 서블릿 컨테이너는 네트워크 통신, 생명주기 관리, 스레드 기반의 병렬처리를 대행한다. 따라서 클라이언트가 웹 브라우저를 사용해 웹 사이트로 접근하면 해당 요청이 웹 브라우저에 의해 HTTP 프로토콜로 변환돼 해당 사이트를 서비스하는 서블릿 컨테이너로 전달되고, 이 HTTP 프로토콜로 전달된 메시지는 서블릿 컨테이너에서 해석되고 재조합돼 개발자가 작성한 서블릿으로 전달되는 과정을 거친다.

복잡하게 얘기했는데 결국 서블릿 컨테이너가 HTTP 프로토콜을 분석하는 과정을 이해하기 위해 HTTP Request를 파싱해보자는 것이다.

 

이전 게시글

 

Socket을 이용해 HTTP Server 만들기

Socket을 이용한 HTTP Server HTTP 통신은 당연히 Socket을 이용하는건데 어떻게 제목이 Socket을 이용한 HTTP Server이다. 이전 게시글 소켓을 활용한 Echo Server 만들기 소켓(Socket) 소켓은 네트워크에서 실행

oneny.tistory.com

이전 Socket을 이용해 HTTP Server를 만드는 것을 이어서 해당 프로젝트에서 HTTP Request를 파싱해볼 예정이다.

 

HttpConnectionWorkerThread 수정

 
int oneInt = -1;
while (-1 != (oneInt = in.read())) {
  System.out.print((char)oneInt);
}

이전 게시글에서는 WorkerThread는 아래와 같은 코드로 작성했을때에는 루프문을 빠져나오지 못한다. InputStream의 read 메서드는 스트림의 끝에 다다를 경우, 즉 소켓에서 얻은 스트림의 연결이 끊어질 경우에 -1을 반환하기 때문에 웹 브라우저의 [x] 버튼을 사용해 연결을 종료하여 종료 조건이 만족돼 while 루프에서 탈출하 수 있지만 그렇지 않은 경우에는 계속 루프문에 머물게 된다.

이와 같은 종료 조건은 양방향 통신이 필요할 때 이미 끊어져 요청에 대한 응답을 보낼 수 없기 때문에 클라이언트-서버 구조에서 사용하기가 어렵다. 따라서 서블릿 컨테이너는 스트림을 사용해 Socket에서 한 바이트씩 읽어들이면서 어느 시점에서 읽기를 중단하고 지금까지 받은 내용을 기반으로 요청 메시지를 구성할지 판단해야 한다. 여담으로 HTTP 프로토콜에 대해 이런 상태를 분석하는 코드를 HTTP 상태 기계라고 한다.

 

HTTP GET/POST 요청 처리

public class HttpConnectionWorkerThread extends Thread {

  private final static byte CR = '\r';
  private final static byte LF = '\n';
  private final static Logger LOGGER = LoggerFactory.getLogger(HttpConnectionWorkerThread.class);
  private final Socket socket;

  public HttpConnectionWorkerThread(Socket socket) {
    this.socket = socket;
  }

  @Override
  public void run() {
    try (socket;
         InputStream inputStream = socket.getInputStream();
         OutputStream outputStream = socket.getOutputStream()) {

      int oneInt = -1;
      byte oldByte = (byte) -1;
      StringBuilder sb = new StringBuilder();
      int lineNumber = 0;
      boolean bodyFlag = false;
      String method = null;
      String requestUrl = null;
      String httpVersion = null;
      int contentLength = -1;
      int bodyRead = 0;
      List<Byte> bodyByteList = null;
      Map<String, String> headerMap = new HashMap<>();

      while ((oneInt = inputStream.read()) != -1) {
        byte thisByte = (byte) oneInt;

        if (bodyFlag) { // 메시지 바디 파싱
          bodyRead++;
          bodyByteList.add(thisByte);
          if (bodyRead >= contentLength) { // 메시지 바디까지 읽으면 파싱 종료
            break;
          }
        } else { // RequestLine과 Headers 정보
          if (thisByte == LF && oldByte == CR) {
            String oneLine = sb.substring(0, sb.length() - 1);
            lineNumber++;
            if (lineNumber == 1) {
              // 요청의 첫 행
              // HTTP 메섲, 요청 URL, 버전을 알아낸다.
              int firstBlank = oneLine.indexOf(" ");
              int secondBlank = oneLine.lastIndexOf(" ");
              method = oneLine.substring(0, firstBlank);
              requestUrl = oneLine.substring(firstBlank + 1, secondBlank);
              httpVersion = oneLine.substring(secondBlank + 1);
            } else { // RequestLine이 끝나면
              if (oneLine.length() <= 0) {
                bodyFlag = true; // 헤더 끝

                if (method.equals("GET")) { // GET 방식이면 메시지 바디 없음
                  break;
                }

                String contentLengthValue = headerMap.get("Content-Length");
                if (contentLengthValue != null) {
                  contentLength = Integer.parseInt(contentLengthValue.trim());
                  bodyByteList = new ArrayList<>();
                }
                continue;
              }

              int indexOfColon = oneLine.indexOf(":");
              String headerName = oneLine.substring(0, indexOfColon);
              String headerValue = oneLine.substring(indexOfColon + 1);
              headerMap.put(headerName, headerValue);
            }

            sb.setLength(0);
          } else {
            sb.append((char) thisByte);
          }
        }

        oldByte = (byte) oneInt;
      }

      String html = "<html><head><title>Simple Java HTTP Server</title></head><body><h1>This page was served using my Simple Java HTTP Server</h1></body></html>";

      final String CRLF = "\n\r"; // 13, 10

      String response =
              "HTTP/1.1 200 OK" + CRLF + // Status Line  :  HTTP VERSION RESPONSE_CODE RESPONSE_MESSAGE
                      "Content-Length: " + html.getBytes().length + CRLF + // HEADER
                      CRLF +
                      html +
                      CRLF + CRLF;

      outputStream.write(response.getBytes());

      LOGGER.info(" * Connection Processing Finished.");
    } catch (IOException e) {
      LOGGER.error("Problem with communication", e);
    }
  }
}

HTTP 메서지는 CRLF로 행을 구분하고, 첫 번째 행은 특별한 구조를 가지며, 빈 내용의 행이 나오기 전까지 콜론으로 구분되는 메시지 헤더가 존재한다. 메시지 바디가 없는 GET 요청의 경우, HTTP 메시지의 종료 시점을 결정하는데 있어 이런 행 구분자의 위치를 파악하는 것이 무엇보다 중요하다.

POST 요청의 경우에는 메시지 바디를 가진다. 따라서 현재 읽는 HTTP 메시지가 메시지 바디인지 아닌지를 표시하는 bodyFlag라는 boolean 값을 사용해 시작행과 메시지 헤더의 처리와 메시지 바디 처리 부분을 분리했다. 메시지 헤더는 문자열을 기반으로 하나의 행이라는 개념으로 각각 구분할 수 있으나 메시지 바디는 이런 행 기반 구조를 사용하지 않으므로 메시지 바디의 길이를 Content-Length 값으로 메시지 헤더에 지정하는 방식을 통해 메시지 바디를 처리할 수 있도록 작성했다.

 

포스트맨으로 위처럼 보내게 되면 Content-Length 헤더와 Message Body가 파싱된 것을 확인할 수 있다.

 

HttpParser 생성을 위한 HttpConnectionWorkerThread 수정

public class HttpConnectionWorkerThread extends Thread {

  private final static byte CR = '\r';
  private final static byte LF = '\n';
  private final static Logger LOGGER = LoggerFactory.getLogger(HttpConnectionWorkerThread.class);
  private final Socket socket;

  public HttpConnectionWorkerThread(Socket socket) {
    this.socket = socket;
  }

  @Override
  public void run() {
    try (socket;
         InputStream inputStream = socket.getInputStream();
         OutputStream outputStream = socket.getOutputStream()) {

      HttpParser httpParser = new HttpParser();
      HttpRequest httpRequest = httpParser.parseHttpRequest(inputStream);
      
      String html = "<html><head><title>Simple Java HTTP Server</title></head><body><h1>This page was served using my Simple Java HTTP Server</h1></body></html>";

      final String CRLF = "\n\r"; // 13, 10

      String response =
              "HTTP/1.1 200 OK" + CRLF + // Status Line  :  HTTP VERSION RESPONSE_CODE RESPONSE_MESSAGE
                      "Content-Length: " + html.getBytes().length + CRLF + // HEADER
                      CRLF +
                      html +
                      CRLF + CRLF;

      outputStream.write(response.getBytes());

      LOGGER.info(" * Connection Processing Finished.");
    } catch (IOException e) {
      LOGGER.error("Problem with Communication", e);
    } catch (HttpParsingException e) {
      LOGGER.error("Problem with HttpParsing", e);
    }
  }
}

위의 수정된 내용을 보면 HttpParser 클래스로 따로 분리하여 parseHttpRequest() 메서드를 통해 파싱을 수행하는 것을 확인할 수 있다. 

 

HttpParser 생성

public class HttpParser {

  private static final Logger LOGGER = LoggerFactory.getLogger(HttpParser.class);
  private static final int SP = 0x20; // 32
  private static final int CR = 0x0D; // 13
  private static final int LF = 0x0A; // 10

  public HttpRequest parseHttpRequest(InputStream inputStream) throws HttpParsingException {
    // UTF-8 유니코드는 아스키 코드와 영문 영역에서는 100% 호환
    InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);

    HttpRequest request = new HttpRequest();

    try {
      parseRequestLine(reader, request);
      parseHeaders(reader, request);
    } catch (IOException e) {
      throw new RuntimeException(e);
    }

    parseBody(reader, request);

    return request;
  }

  /**
   * 아래 Request-Line을 파싱
   * Method SP Request-Target SP Http-Version CRLF
   * ex) GET /oneny HTTP/1.1
   */
  private void parseRequestLine(InputStreamReader reader, HttpRequest request) throws IOException, HttpParsingException {
    StringBuilder processingDataBuffer = new StringBuilder();
    int _byte;

    boolean methodParsed = false;
    boolean requestTargetParsed = false;

    while ((_byte = reader.read()) != -1) {
      if (_byte == CR) {
        _byte = reader.read();

        if (_byte == LF) { // RequestLine의 CRLF까지 오면 종
          LOGGER.debug("Request Line VERSION to Process : {}", processingDataBuffer);

          if (!methodParsed || !requestTargetParsed) { // 모두 파싱되지 않았는데 CRLF를 만나면
            throw new HttpParsingException(HttpStatusCode.CLIENT_ERROR_400_BAD_REQUEST);
          }

          try {
            request.setHttpVersion(processingDataBuffer.toString());
          } catch (BadHttpVersionException e) {
            throw new HttpParsingException(HttpStatusCode.CLIENT_ERROR_400_BAD_REQUEST);
          }

          return; // Request Line이 모두 파싱되면 종료
        } else {
          throw new HttpParsingException(HttpStatusCode.CLIENT_ERROR_400_BAD_REQUEST);
        }
      }

      if (_byte == SP) {
        if (!methodParsed) {
          LOGGER.debug("Request Line METHOD to Process : {}", processingDataBuffer);
          request.setMethod(processingDataBuffer.toString());
          methodParsed = true;
        } else if (!requestTargetParsed) {
          LOGGER.debug("Request Line Req Target to Process : {}", processingDataBuffer);
          request.setRequestTarget(processingDataBuffer.toString());
          requestTargetParsed = true;
        } else { // RequestLine의 마지막이 CRLF가 아닌 경우(onlyCRnoLF) or 파싱에서 필요한 것들 외에 추가로 있는 경우(InvalidItems)
          throw new HttpParsingException(HttpStatusCode.CLIENT_ERROR_400_BAD_REQUEST);
        }

        processingDataBuffer.delete(0, processingDataBuffer.length());
        continue;
      }

      processingDataBuffer.append((char) _byte);

      if (!methodParsed && processingDataBuffer.length() > HttpMethod.MAX_LENGTH) {
        throw new HttpParsingException(HttpStatusCode.SERVER_ERROR_501_NOT_IMPLEMENTED);
      }
    }
  }

  private void parseHeaders(InputStreamReader reader, HttpRequest request) throws IOException {
    StringBuilder processingDataBuffer = new StringBuilder();
    int _byte;

    while ((_byte = reader.read()) != -1) {
      if (_byte == CR) {
        _byte = reader.read();

        if (_byte == LF) {
          if (processingDataBuffer.isEmpty()) {
            break;
          }

          int indexOfColon = processingDataBuffer.indexOf(":");
          String headerName = processingDataBuffer.substring(0, indexOfColon);
          String headerValue = processingDataBuffer.substring(indexOfColon + 2);
          request.setHeader(headerName, headerValue);
          processingDataBuffer.delete(0, processingDataBuffer.length());
        }

        continue;
      }

      processingDataBuffer.append((char) _byte);
    }

    for (Map.Entry<String, String> entry : request.getHeaderMap().entrySet()) {
      LOGGER.info("{}: {}", entry.getKey(), entry.getValue());
    }
  }

  private void parseBody(InputStreamReader reader, HttpRequest request) {
  }
}

현재 RequestLine까지 파싱한 메서드가 구현된 상태이다. 데이터를 바이트 형식으로 처리하는 InputStream에서 문자 형식으로 처리하는 InputStreamReader로 만들어 사용하고, parseRequestLine, parseHeaders, parseBody 메서드 세 부분으로 나누어 처리하도록 작성했다.

parseRequestLine 메서드는 이름에서도 알 수 있듯이 HTTP Request Line을 파싱하는 메서드로 HTTP 메서드, 경로(URI), HTTP 버전을 나누어 HttpRequest에 저장한다.

parseHeaders 메서드는 요청 라인을 모두 파싱한 후  "이름: 값" 형식으로 되어 있기 때문에 콜론(:)을 기준으로 이름과 값을 추출하여 HttpRequest 객체에 정보를 추가한다.

 

전체 코드

 

GitHub - oneny/http-server

Contribute to oneny/http-server development by creating an account on GitHub.

github.com

블로그 게시글에 대한 내용은 위 레포지토리에서 확인할 수 있다.