[ java 웹 서버] HTTP 프로토콜 parsing하기
[ java 웹 서버] HTTP 프로토콜 parsing하기 — #HTTP프로토콜 #HTTPparsing #parsing #java웹서버 #javawebserver #개발자의도구들 AI스쿨 msa...
#HTTP프로토콜 #HTTPparsing #parsing #java웹서버 #javawebserver #개발자의도구들
AI스쿨 msa기반 java 백엔드 코스 중에 공부한 내용을 작성하였습니다
\* 현재 웹 서버 프로젝트를 진행중입니다. 전체적인 목차를 보시려면 여기에서 참고하세요
\* 전체코드: https://github.com/keyveloper/HTTP\_server.git
웹 서버 만들기
이전글을 통해 웹에서 사용되는 프로토콜에 대해 알아봤는데요. 웹에서 사용되는 프로토콜은 HTTP인데, 프로토콜은 하나의 통신 규약이라고 들었었죠? 그럼 이 프로토콜을 사용하는 모든 서버는 웹 서버가 되겠네요!
그럼 웹 서버는 HTTP 프로토콜을 해석(parsing0하고 HTTP 프로토콜 packet을 만들어서 요청 Client에게 넘겨주면 되겠네요!??
그럼 HTTP 패킷을 어떻게 해석할 것인지, 그리고 어떻게 만들 것인지에 대해 Java를 통해 알아봅시다.
HTTP 프로토콜 parsing하기
이전글에서 HTTP 프로토콜은 혼합형태의 프로토콜이라고 이야기 한 거 기억이 나시나요? 헤더 부분은 줄바꿈으로 구분하고, body의 경우는 header필드의 "Content-Length"부분을 참고한다고 했었습니다.
TCP소켓 통신에서와 달리 직접 프로토콜을 custom하는 것이 아니기 때문에, 이미 표준화된 format을 다라줘야 한답니다.
keyPont:
empty Line 찾기
HTTP Request의 간단한 예시를 볼게요. 우리는 서버만 구현할거라 Client는 Postman 을 사용할거에요. Postman의 기본 headerField 정보는 아래와 같습니다.(body는 없다고 가정할게요)
| GET /time HTTP/1.1 User-Agent: PostmanRuntime/7.39.0Accept: \/\Cache-Control: no-cachePostman-Token: 8abd753f-a41f-4c1a-9c7b-f45ed6135f72Host: localhost:8080Accept-Encoding: gzip, deflate, brConnection: keep-alive(empty Line) |
|---|
그럼 server에서 보낸 패킷의 emptyLine까지 쭉쭉 읽어 나가면 되는데요. 이를 Java에서 구현하려면 몇가지 모듈을 알아야합니다.
| BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); |
|---|
TCP는 기본적으로 byte\[\]로 통신을 하기 때문에, HTTP 패킷도 Byte로 들어옵니다. 그럼 이 Byte를 일단 String형태로 바꿔주는 것이 좋겠죠? 이를 위해 InputStreamReader를 사용해주면 됩니다. InputStreamReader를 효율적으로 읽기 위해서 BufferedReader를 사용하는데, 이 BufferedReader는 줄바꿈 문자까지 끊어서 한 줄 한줄 읽을 수 있습니다.
BufferedReader.readLine(): "/n", "/r/n" 등 줄바꿈 문자를 발견하면, 줄바꿈 문자 이전까지의 문자열을 반환합니다.
| @AllArgsConstructorpublic class RequestReader { private final Socket clientSocket; public Request readRequest() { // Read the request (optional, for logging or processing) } |
|---|
저는 사용자의 Request를 따로 분석하여 하나의 객체로 만들어내고자 하였습니다. 이를 위해 RequestReader객체를 따로 만들었고, readRequest를 하면 Request객체를 반환하도록 할 것입니다.
@Data@NoArgsConstructorpublic class Request { private String method; private String uri; private String protocol; private Map<String, String> headerMap; private String body;} |
|---|
Request 객체는 Request line을 해독하여, method, uri, protocl로 나눠서 각각 따로 받습니다. 이후에 추가되는 header정보는 headerMap에 전부 넣어둡니다. 마지막으로 body가 있다면, body도 넣을 수 있도록 하였습니다. 현재는 String 형태로만 데이터를 parsing합니다.
| package org.example;import lombok.AllArgsConstructor;import java.io.BufferedReader;import java.io.IOException;import java.io.InputStreamReader;import java.net.Socket;import java.util.HashMap;import java.util.Map;@AllArgsConstructorpublic class RequestReader { private final Socket clientSocket; public Request readRequest() { // Read the request (optional, for logging or processing) try { BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); String line; Map<String, String> headerMap = new HashMap<>(); Request request = new Request(); while (true) { line = reader.readLine(); if (line == null || line.isEmpty()) { break; } int pivot = line.indexOf(": "); if (pivot != -1) { String headerKey = line.substring(0, pivot).trim().toLowerCase(); String headerValue = line.substring(pivot + 1).trim(); headerMap.put(headerKey, headerValue); request.setHeaderMap(headerMap); } else { String\[\] requestLine = line.split(" "); request.setMethod(requestLine\[0\]); request.setUri(requestLine\[1\]); request.setProtocol(requestLine\[2\]); } } if (headerMap.containsKey("content-length")) { System.out.println(headerMap.get("content-length")); int contentLength = Integer.parseInt(headerMap.get("content-length")); char\[\] body = new char\[contentLength\]; reader.read(body, 0, contentLength); System.out.println("Body: " + new String(body) + "\n"); request.setBody(new String(body)); } return request; } catch (IOException ex) { throw new RuntimeException(ex); } }} |
|---|
코드가 복잡해 보이지만 알고나서 다시보면 심플합니다. 우선 bufferReader.readLine() 메서드를 사용하여 버퍼에 담긴 데이터를 한줄 한줄 읽으면 됩니다.
엎서 readLine()은 "/r/n"을 기준으로 한줄 한줄 씩 읽어들이기 때문에, HTTP Request 패킷을 parsing하기에 매우 유리합니다.
한줄 한줄 읽는데 또 고려해야할 사항이 하나 있습니다. 바로 header와 requestLine을 구분하는 것인데요. 헤더의 경우 "fieldName: value"로 항상 이렇게 오기 때문에, (": ")을 기준으로 index를 나누어 fileName을 key로, value를 value값으로 headerMap에 저장시키면 됩니다.
이는 헤더의 끝을 알리는 empryLine을 읽어들일 때까지 하면됩니다. 그래서 while문에 isEmpty를 체크합니다.
emptyLine 이후에는 headerMap에 Key로 "Content-Length"가 포함되어 있는지 확인하면됩니다. 하지만, HTTP 헤더 필드는 대소문자를 구분하지 않기떄문에, 대문자든, 소문자든 하나로 통일해서 key값을 넣어줘야 합니다. 그래서 저는 toLoweCase()를 사용하여 key로 넣어두었습니다.
content-length가 포함되어있다면, 헤당 키의 value값은 body의 길이가 될 것 입니다. 이제 Stream에서 이 길이만큼 읽어 들이면됩니다. 참고로 InputStream의 데이터는 모두 BufferedReade에 들어가 있기 때문에, bufferedReader에서 길이만큼 읽어줘야합니다.
Response 패킷 보내기
이제 parsing된 정보는 서버에서 마음대로 이용하면 됩니다. 하지만, Client가 요청한 만큼, 가능하다면 원하는 정보를 줘야 겠지요? 서버가 Response를 생성하는 기준은 여러가지가 있겠지만, 우선적으로 Request Line의 method, uri, protocolVersion을 확인해야 합니다.
method의 경우 GET이면 클라이언트에게 데이터를 제공하고, Post의 경우 Client가 보낸 body를 참고하여 동작을 수행하고, Delete면 또 다른 로직을 수행하고 등 등. 어떤 것을 할지는 사실 서버에서 구현하기 나름입니다. GET으로 요청했는데 로직의 논리적 흐름은 DELETE의 역할이 되게끔 할 수도 있습니다.
확인 후 형식에 맞지않는 Request가 아닌 경우는 모두 무시하거나, 오류 코드를 보내여 응답할 수 있습니다. 하지만, 서버의 과부화를 줄이기 위해서는 그냥 무시해보리는게 가장 좋겠습니다.
하지만 HTTP는 웹상에서 사용되는 규칙이니 만큼, method에 맞는 동작을 서버에서 제공할 필요는 있습니다.
| package org.example;import com.fasterxml.jackson.core.JsonProcessingException;import com.fasterxml.jackson.databind.ObjectMapper;import lombok.Data;import java.time.LocalDateTime;import java.time.format.DateTimeFormatter;import java.util.HashMap;import java.util.Map;@Datapublic class RequestHandler { private final TextManager textManager = new TextManager(); public Response handleRequest(Request request) { System.out.println("start to handleRequest!!"); switch (request.getMethod()) { case "GET" -> { return handleGet(request.getUri()); } case "POST" -> { return null; }; case "DELETE" -> { return null; }; default -> { return null; } } // send error status! return null; } private Response handleGet(String uri) { if (uri.startsWith("/time")) { return responseGetTime(); } if (uri.startsWith("/text")) { return null; } if (uri.startsWith("/textall")) { return null; } if (uri.startsWith("/image")) { return null; } return null; } private Response responseGetTime() { }} |
|---|
저는 이를 RequestHanlder가 처리하도록 코드를 작성하였습니다. RequestHandler는 method별로 분리하고, 이후 uri 별로 분리하여 로직을 처리합니다.
현재는 /time uri에 대해 현재시간을 응답하도록 만들어서, responseGetTime 메서드를 코드로 작성하여, Response 객체를 만들도록 구현할 것입니다.
Response 객체 만들기
저는 Request 객체와 마찬가지로 Response객체 역시 만들어 두었습니다.
| package org.example;import lombok.Data;@Datapublic class Response { private final String protocol = "HTTP/1.1"; private final StatusCode statusCode; private int bodyLength; private String bodyType; private String body;} |
|---|
우선 Status line에 필요한 protocol과 StatusCode를 필수로 등록하고, body가 있는 경우 추가적으로 정보를 넣도록 구현하였습니다.
| package org.example;import lombok.Getter;@Getterpublic enum StatusCode { OK(200, "OK"), NOT\_FOUND(404, "Not found"), BAD\_REQUEST(400, "Bad Request"), INTERNAL\_SERVER\_ERROR(500, "Internal Server Error"), SERVICE\_UNAVAILABLE(503, "Service Unavailable"); private final int code; private final String reasonPhrase; StatusCode(int code, String reasonPhrase) { this.code = code; this.reasonPhrase = reasonPhrase; }} |
|---|
StatusCode는 enum으로 미리 등록해두어 언제 어디서든 code와 phrase를 쉽게 얻을 수 있도록 만들었습니다.
HTTP Response 패킷 만들기
Server
| HTTP/1.1 statusCode(200) statusPhrase(ok) - (status Line)~~Server: Apache~~ (보안 이슈로 명시 X~~)~~Content-type: apllication/JsonDate: Content-Length: 200(empty Line)body |
|---|
Server의 경우도 가장 첫줄에는 필수적으로 Status Line을 붙여서 보내야 합니다. 이 Status Line은 클라이언트가 요청한 Request를 서버에서 어떻게 처리했는지를 나타내고 있습니다. 기본적으로 웹서버는 HTTP 프로토콜이 아닌 모든 요청에 대해서 무시하거나, 잘못된 요청이라는 응답코드를 보내야합니다.
위에서 생성된 Response 객체를 HTTP Response 패킷 형태로 만들어 전송하고자 합니다.
| package org.example;public class HttpResponseMaker { public static byte\[\] makePacket(Response response) { StringBuilder responseString = new StringBuilder(); // status Line responseString.append(response.getProtocol()).append(" ").append(response.getStatusCode().getCode()).append(" ") .append(response.getStatusCode().getReasonPhrase()).append("\r\n") // essential Body .append("Date: ").append(new java.util.Date()).append("\r\n"); if (response.getBody() != null){ responseString.append("Content-Type: ").append(response.getBodyType()).append("\r\n") .append("Content-Length: ").append(response.getBodyLength()).append("\r\n") .append("Connection: close\r\n") .append("\r\n"); responseString.append(response.getBody()); return responseString.toString().getBytes(); } responseString.append("Connection: close\r\n").append("\r\n"); return responseString.toString().getBytes(); }} |
|---|
현재 패킷은 StringBuilder를 사용하여 builder후 마지막에 byte로 전환하고 있습니다. 최종적으로 DataOutputStream을 통해 Byte\[\] 형태로 전달될 것이기 때문입니다.
모든 정보는 Response객체에서로 부터 얻어오며, Date의 경우 발행시간으로 추가적으로 헤더 정보에 포함하여 넣어주었습니다.
