본문 바로가기

Web Sever 개발과 CS 기초/스프링

Java로 직접 구현하는 HTTP Server

 

관련 내용

[백엔드/네트워크 지식] - 리퀘스트 메소드와 HTTP 상태 코드

[백엔드/네트워크 지식] - HTTP 프로토콜 이해와 HTTP 버전 별 특징

개요 목적

이번 시간의 목표는 자바에서 제공하는 httpserver 클래스 사용 없이 HTTP 프로토콜을 사용하는 웹서버를 ServerSocket만 사용하여 구현하는 것이다.

그러기 위해서는 client가 보내오는 Request Message 구조를 이해하고 분석하여 원하는 동작을 얻어야 하며 Response Message 구조에 맞춰서 그에 맞는 데이터를 보내줘야 한다.

HttpServer 클래스를 사용하는 것보다 불편하지만 HTTP 프로토콜을 직접 구현하면서 해당 프로토콜에 대한 이해를 높이는 좋은 시간이었다.

<구현하려는 기능>

  • ServerSocket으로 HTTP WEB 서버 구현
    • Client Request Message 분석하여 원하는 동작 파악
    • Response Message 구조대로 상황에 맞는 상태 코드와 Json 데이터 바디로 전송
  • GET /time -> 현재 시간을 json 에 담아서 알려줌(크롬 브라우저 통신 가능)
  • POST /text/{textid} -> Body로 전달된 문자열을 서버가 저장
  • GET /text/{textid} -> 저장된 문자열을 알려줌
  • DELETE /text/{textid} -> 저장된 문자열을 삭제
  • GET /image -> jpeg 이미지를 다운로드

ServerSocket으로 HTTPServer 열기

ServerSocket을 열고 Client Socket 입장을 while문을 통해 지속적으로 기다린다.

Client Socket이 입장하면 Thread로 등록하여 하나의 서버를 클라이언트가 사용할 수 있도록 한다.

@overrid run() 메소드 안에 Client Request가 들어오면 어떻게 동작하고 어떤 Response Message를 반환한다는 비지니스 로직이 들어있다.

public class HTTPServer {

    private static final int port = 5510;

    public static void main(String[] args) {
        try {
            //Server Socket을 통해 포트 번호를 지정하여 client socket입장을 기다린다.
            ServerSocket serverSocket = new ServerSocket(port);
            Socket socket;
            while ((socket = serverSocket.accept()) != null) {
                System.out.println("browser connection");
                RequestHandler requestHandler = new RequestHandler(socket);
                Thread thread = new Thread(requestHandler);
                thread.start();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
//http server로 입장한 client 소켓을 Thread로 등록하여
//하나의 서버를 여러 Client가 사용할 수 있도록 코딩한다. 
public class RequestHandler implements Runnable {

    private final Socket socket;
        public RequestHandler(Socket socket) {
        this.socket = socket;
    }
		
    @Override
    public void run() { 
        //웹서버 비지니스 로직
        }
}

Request를 분석하고 적절한 Response를 브라우저에 전달하는 방법 

HTTP 프로토콜 Request Message 분석하여 원하는 정보를 얻고, 적절한 Response Message를 브라우저에 전달하는 방법에 대해서 알아본다.

HTTP 프로토콜의 Request Message, Response Message의 형식은 HTTP 프로토콜 이해와 HTTP 버전 별 특징에서 확인할 수 있다.

1. Request Line 분석하기

HTTP Method와 URL패턴에 따라서 들어오는 Request Line이 다르다. 해당 Request Line을 잘 파악하고 분리 해서(Method와 URL) 어떤 동작을 원하는 지 확인해야 한다.

Request Line을 분석해서 어떤 동작을 할 것인지 정보를 담는RequestMethod클래스로 변환하는 코드 과정을 알아보자.

public class ResponseService {
	 
    public  RequestMethod readFirstLIneAndImplementRequestMethod(BufferedReader bufferedReader)
        throws IOException {
        RequestMethod requestMethod = new RequestMethod();
        //buffered.readline을 통해 가장 처음에 들어온 줄을 읽는다.
        //ex) GET /time
        String firstLine = bufferedReader.readLine();
				
        //HTTP 양식에 따라 " " 구분을 통해 Method와 URL 패턴을 분리한다.
        String[] firstLineArray = firstLine.split(" ");
        //처음은 method 정보를 집어넣고
        requestMethod.setMethod(firstLineArray[0]);
				
        //다음은 URL 패턴을 "/"로 나눈다.
        //Type= text, time, image
        //Type중 text라면 마지막 "/"뒤에 위치한 숫자를 setTextId에 넣어 정보를 저장한다.
        String requestTypeAndTextId = firstLineArray[1].substring(1, firstLineArray[1].length());
        if (requestTypeAndTextId.contains("/")) {
            String[] requestTypeAndTextIdArray = requestTypeAndTextId.split("/");
            requestMethod.setRequestType(requestTypeAndTextIdArray[0]);
            requestMethod.setTextId(requestTypeAndTextIdArray[1]);
        } else {
            requestMethod.setRequestType(requestTypeAndTextId);
        }
        return requestMethod;
    }

2. Request Headesr 분석하기

해당 서버에서는 POST Method - message body로 들어오는 text에 저장할 데이터를 받기 위해 Request Header 정보가 반드시 필요하다. Header에 message body로 들어오는 데이터 길이가 입력되어 있기 때문에 해당 정보를 받아서 MessageBody 획득에 사용한다.

Client 요청에 여러 줄에 Header 정보가 들어온다. 이 중에서 우리가 필요한 콘텐츠 길이 정보를 획득하는 코딩을 알아보자.

public class RequestTool {
		
    public Map<String, String> readHeader(BufferedReader br) throws IOException {
        Map<String, String> headerInformation = new HashMap<>();
        //header는 여러 줄에 거쳐서 나오기 때문에while을 통해서 지속적으로 header 정보를 받는다 
        while (true) {
            String headerLine = br.readLine();
            System.out.println("headerLine= "+headerLine);
            if (!headerLine.equals("")) {
                //header 형식 키: 밸류 형식으로 되어있기 때문에 그형식에 맞춰서
                //map데이터를 넣는다.
                String key = headerLine.split(":")[0];
                String value = headerLine.split(":")[1].trim();
                headerInformation.put(key, value);
                //만약 빈줄이 나오면 그것은 header와 body를 가르는 개행 문자이기 때문에
                //빈줄이 나오면 header 받기를 종료하면 된다.
            } else {
                break;
            }
        }
        return headerInformation;
    }
}

HTTP 프로토콜 헤더 형식은 key:value 형식이기 때문에 그에 맞춰서 헤더 정보를 map에 넣을 수 있다. 그리고 헤더와 메세지 바디 사이에는 빈 한 줄 규칙으로 헤더를 받는 라인을 어디서 멈춰야 할지 알 수 있다.

얻어온 map 정보를 통해 우리가 필요한 map.get("Content-Length")로 메세지 바디에 있는 데이터 길이를 획득할 수 있다.

3.Reqeust Message Body 분석하기

header에서 획득한 데이터 길이와 bufferedReader의 read를 통해 Stirng 형식의 Message Body 데이터를 얻을 수 있다.

헤더를 얻는 메소드에서 개행 문자까지 출력을 했기 때문에 바로 bufferedReader로 데이터를 얻어오면 message body 데이터에 접근할 수 있다.

public class RequestTool {

    public String readDate(BufferedReader br, int contentLength) throws IOException {
        char[] messageBody = new char[contentLength];
        br.read(messageBody, 0, contentLength);
        System.out.println("messageBody="+ messageBody);
        return String.copyValueOf(messageBody);
    }
}

4. Response Message

먼저 enum으로 비지니스 로직 결과에 해당하는 Response status 문자열을 구성한다. 문자열 뒤에 두개의 개행문자 중 하나는 빈 헤더를 나타내고 하나는 헤더와 메세지 바디 구분을 나타낸다.  

public enum ResponseStateCode {
    response200OK("HTTP/1.1 200 OK\r\n\r\n"),
    response201Created("HTTP/1.1 201 Created\r\n\r\n") ,
    response204NoContent("HTTP/1.1 204 No Content\r\n\r\n"),
    response404NotFound("HTTP/1.1 404 Not Found\r\n\r\n") ;

    private final String stateMessage;
}

이제 비지니스 로직 결과에 맞게 DataOutputStream으로 상태 코드와 메세지 바디를 웹브라우저에 전달한다.

상황에 맞는 상태코드 정보는 리퀘스트 메소드와 HTTP 상태 코드에서 확인할 수 있다.

public class ResponseService {

    public void sendResponseFromGETAndTime(DataOutputStream dataOutputStream, byte[] timeJsonBytes)
        throws IOException {
        //200 OK이라는 상태코드와
        dataOutputStream.writeBytes(ResponseStateCode.response200OK.getStateMessage());
        //현재 시간 json 값을 dataOutputStream을 통해 Client에게 전달한다.
        dataOutputStream.write(timeJsonBytes, 0, timeJsonBytes.length);
        dataOutputStream.flush();
    }
}

 

서비스 구현하기

1. GET /time -> 현재 시간을 json 에 담아서 알려줌

위에서 배운 request line 분석을 통해 어떤 메소드인지 어떤 URL 패턴인지 파악 후에 GET/time일 경우 현재 시간을 objectMapper를 사용해 byte[]에 담는다.

해당 데이터를 snedResponseFromGETAndTime 메소드를 통해서 상태에 맞는 상태 코드와 Response Message Body를 작성하여 Client에게 보낸다.

public class RequestHandler implements Runnable {

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

            BufferedReader bufferedReader = new BufferedReader(
                new InputStreamReader(inputStream, StandardCharsets.UTF_8));
            DataOutputStream dataOutputStream = new DataOutputStream(outputStream);
	        //위에서 배운 request line 분석을 통해 어떤 메소드인지
            //어떤 URL 패턴인지 파악 후에
            RequestMethod requestMethod = responseService.readFirstLIneAndImplementRequestMethod(
                bufferedReader);
            switch (requestMethod.getMethod()) {
                case "GET":
                    switch (requestMethod.getRequestType()) {
                        case "time":
                            //get time일 경우 현재 시간을 objectMapper를 사용해 byte[]에 담는다.
                            byte[] timeJsonBytes = responseService.responseFromGETAndTime();
                            //아래 추가 설명.
                            responseService.sendResponseFromGETAndTime(dataOutputStream,
                                timeJsonBytes);
                            break;
                        //나머지 코드 생략...
                        case "image":
                            break;
                        case "text":
                            break;
                    }
                    break;
                case "POST":
                    break;
                case "DELETE":  
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

200 OK 상태 코드와 Json으로 직렬화한 MessageBody를 보낸다.

HTTP Response 상태 코드 형식에 맞춰서 String을 저장한 후에 Client에게 전송한다.

2. POST /text/{textid} -> Body로 전달된 문자열을 서버가 저장

Request LIne에서 textId를 얻어오고, Reqeust Header, Message Body 를 통해서 text 문자열을 얻어와 map 데이터를 저장한다.

public class RequestHandler implements Runnable {

    @Override
    public void run() {
        try (InputStream inputStream = socket.getInputStream();
            OutputStream outputStream = socket.getOutputStream()) {
            //중복된 코드 생략...
            switch (requestMethod.getMethod()) {
                case "GET":
                    break;
                case "POST":
                    switch (requestMethod.getRequestType()) {
                        case "text":
                            //Request Line에서 얻어온 textId와
                            String textId = requestMethod.getTextId();
                            String messageBody;
                            if (textId != null) {
                                //헤더 정보와 메세지 바디를 통해 text에 저장할 문자를 가져온다.
                                Map<String, String> headerInformation = requestTool.readHeader(
                                    bufferedReader);
                                int messageBodyLength
                                    = Integer.parseInt(
                                    headerInformation.get("Content-Length").trim());
                                messageBody = requestTool.readDate(bufferedReader,
                                    messageBodyLength);
                            } else {
                                messageBody = null;
                            }
                             //메모리 데이터에 textId와 저장할 문자를 저장하고 실패 성공 결과를 반환한다.
                            TreatStateCode treatStateCode =
                                responseService.treatFromPostAndText(messageBody, requestMethod);
                            //상태코드를 전송
                            //아래 설명...
                            responseService.responseFromPostAndText(treatStateCode,dataOutputStream);
                            break;
                    }
                    break;
                case "DELETE":
                    switch (requestMethod.getRequestType()) {
                        case "text":
                            TreatStateCode treatStateCode = responseService.responseFromDELETEAndText(
                                requestMethod);
                            responseService.sendResponseFromDELETEAndText(treatStateCode,
                                dataOutputStream);
                            break;
                    }
            }
            System.out.println("checking StringStorage: " + responseService.stringStorage);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

텍스트 저장 성공 실패에 따라 상태 코드를 다르게 클라이언트에게 전송한다.

저장 성공 시, 201 CREATED 상태 코드 전송

저장 실패 시, 404 NOTFOUND 상태 코드 전송을 한다.

public class ResponseService {
    
    public void responseFromPostAndText(TreatStateCode treatStateCode,
        DataOutputStream dataOutputStream) throws IOException {
        if (treatStateCode == TreatStateCode.SUCCESS) {
            dataOutputStream.writeBytes(ResponseStateCode.response201Created.getStateMessage());
        } else if (treatStateCode == TreatStateCode.FAIL) {
            dataOutputStream.writeBytes(ResponseStateCode.response404NotFound.getStateMessage());
        }
    }
}

3. GET /text/{textid} -> 저장된 문자열을 알려줌

RequestLIne에서 획득한 TextId로 저장된 데이터를 얻어온다.

public class ResponseService {
  
    protected static ConcurrentHashMap<String, String> stringStorage = new ConcurrentHashMap<>();

    public String responseFromGETAndText(RequestMethod requestMethod){
        String textId = requestMethod.getTextId();
        if (textId != null) {
            String storageString = stringStorage.get(textId);
            if (storageString != null) {
                return storageString;
            }
        }
        return null;
    }
}

4. DELETE /text/{textid} -> 저장된 문자열을 삭제

RequestLIne에서 획득한 TextId로 저장된 데이터를 삭제한다.

삭제 결과에 따라서,

성공이라면 204 NOCONTENT를 반환

실패라면 404 NOTFOUND를 반환한다.

public class ResponseService {

    protected static ConcurrentHashMap<String, String> stringStorage = new ConcurrentHashMap<>();

    public TreatStateCode responseFromDELETEAndText(RequestMethod requestMethod) throws IOException {
        String textId = requestMethod.getTextId();
        if (textId != null) {
            String getString = stringStorage.get(textId);
            if (getString != null) {
                stringStorage.remove(textId);
                return TreatStateCode.SUCCESS;
            } else {
                return TreatStateCode.FAIL;
            }
        } else {
            return TreatStateCode.FAIL;
        }
    }
		
    public void sendResponseFromDELETEAndText(TreatStateCode treatStateCode, DataOutputStream dataOutputStream)
        throws IOException {
        if (treatStateCode == TreatStateCode.SUCCESS) {
            dataOutputStream.writeBytes(ResponseStateCode.response204NoContent.getStateMessage());
            dataOutputStream.flush();
        } else if (treatStateCode == TreatStateCode.FAIL) {
            dataOutputStream.writeBytes(ResponseStateCode.response404NotFound.getStateMessage());
            dataOutputStream.flush();
        }
    }
 }

5. GET /image -> jpeg 이미지를 다운로드

responseFromGETAndImage 메소드를 통해서 저장되어 있는 이미지 파일을 byte[]로 생성한다.

sendResponseFromGETAndImage 메소드를 통해서 200 OK 이라는 상태 코드와 byte[] 이미지 파일을 클라이언트에게 전송한다.

public class ResponseService {
    
    public byte[] responseFromGETAndImage() throws IOException {
        BufferedImage image = ImageIO.read(getClass().getResourceAsStream("sea.jpg"));
        System.out.println(image);
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        ImageIO.write(image, "jpg", byteArrayOutputStream);
        byteArrayOutputStream.flush();
        byte[] imageInByte = byteArrayOutputStream.toByteArray();
        return imageInByte;

    public void sendResponseFromGETAndImage(DataOutputStream dataOutputStream, byte[] imageInByte)
        throws IOException {
        dataOutputStream.writeBytes(ResponseStateCode.response200OK.getStateMessage());
        dataOutputStream.write(imageInByte,0,imageInByte.length);
        dataOutputStream.flush();
    }
}