본문 바로가기
Category/Project

JAVA TCP 소켓을 사용하여 HTTP통신이 가능한 WAS 직접 구현하기 -3

by developer__Y 2024. 9. 24.

 

클라이언트로부터 받은 메시지를 HTTP 형식에 맞는 Request , Response 객체에 담아 파싱하기

 

우선 HTTP 메시지 형식에 대해 간단히 알아보면,

아래와 같이 Start Line / Header / Body(선택) 로 되어있다.

 

출처 : https://deepwelloper.tistory.com/98

 

클라이언트로 부터 TCP 소켓을 사용하여 받아온 메시지를 읽어와 HTTP 형식으로 파싱하기위해

실제 HTTP 메시지를 살펴보자.

 

출처 : https://codingpracticenote.tistory.com/174

 

 

 

- Start Line은 공백(" ")을 기준으로 구분되며 HttpMethod / URL / HTTP protocol 로 나뉘어져있다.

 

- header는 key : value 형식으로 있으며 start line 다음 줄부터 빈줄(blank line)까지  다양한 header들이 있다.


- body는 headers의 빈줄(blank line)이후 나오는 값으로 선택에 따라 없을 수도있으며, header의 content-length를 통해 body값의 길이를 알수있다.

 

 

위의 구분을 토대로 Http 메시지를 구분하여 파싱할수있는 Request 객체를 만들어보자.

 

public class Request {
 	private static final Logger logger = LoggerUtil.getLogger(Request.class);
	private HttpMethod method; 
	private String url;
	private String httpVersion;
	private HashMap<String, String> header;
	private String body;
	private HashMap<String,String> params;
	
	public String getParameter(String name) {
		
		return params.get(name);
	}

	public String toString() {
		return this.method+","+this.url+","+this.httpVersion+","+this.body;
	}
    
	//Getter,Setter 생략
	
}


public enum HttpMethod {
	GET,POST,PUT,DELETE;
}

 

 

Http 메시지의 기본적인 요소들을 멤버로 가지고있는 Request 클래스이다.

Header값들은 해시맵형태로 가진다.

 

우리가 일반적으로 tomcat을 통해 서블릿으로 사용하는 request객체는 HttpServletRequest객체로, 아래와 같은 상속관계를 가진다. 

클라이언트단에서 <form> 태그를 통해 <input name="param"> 값을 가지고

url을 타고 넘어오는 쿼리스트링을 읽어 parameter 값을 파싱하여 request.getParemeter("param") 값을 추출해내는 과정을 바로 HttpServletRequest가 제공해주는 것이다.

 

이러한 기능들을 위 Request에서 모두 해주기위해 Hashmap<String,String> 타입의 params를 넣어주었고, 쿼리스트링을 읽어 파라미터값을 파싱하는 메소드도 직접 구현해볼것이다.

 

 

 

HTTP 메시지 parse 메소드

 

 

//	ClientSocket으로 부터 받은 HTTP 메시지 파싱
	public void parse(Socket clientSocket) {
		 BufferedReader in;
			try {
				in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
				
				String message = in.readLine(); // 클라이언트로부터 메시지 읽기
				logger.info("Client로 부터 받은 message : {}",message);
				String[] firstHeader = message.split(" ");
				
				
				this.method = HttpMethod.valueOf(firstHeader[0]);
				String[] mappingUrl = firstHeader[1].split("\\?");
				this.url = mappingUrl[0];
				parseQueryString(firstHeader[1]); // param parse

				this.httpVersion = firstHeader[2];
				
				this.header = new HashMap<>();
				  String headerLine;
		            int contentLength = 0;

		            while ((headerLine = in.readLine()) != null && !headerLine.isEmpty()) {
		               
		            	logger.info("Header: {}", headerLine);
		            	String[] part = headerLine.split(":");
		            	header.put(part[0].trim(), part[1].trim());
		            	
		            }
		            
		            if(header.containsKey("content-length")) {
		            contentLength = Integer.parseInt(header.get("content-length"));
		            
		            char[] body = new char[contentLength];
		            in.read(body, 0, contentLength);
		            String bodyStr = new String(body);
		            header.put("body", bodyStr);
		            this.body = bodyStr;
		            logger.info("Body Message : {}",header.get("body"));
		            
		            }
		            logger.info("요청 Request : {}",toString());
		            logger.info("Thread 이름 : {}",Thread.currentThread());
				
			} catch (Exception e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
	         
	}

 

parse()는 clientSocket을 매개변수로 받아 InputStream을 통해 값을 읽어들인다.

 

여기서 주의할점은 HTTP Protocol의 공백(" ")과 빈줄에 따라 나뉘어지는 HTTP 요소들을 구분해주는 것이다.

 

readline() 메소드는 한 줄을 읽는다. 따라서 맨 처음 inputStream이 읽는 첫줄은 HTTP 메시지의 StartLine이므로,

이에따라 HttpMethod / url / Http version을 파싱한다.

 

그리고 다음 줄부터 공백줄까지는 Headers 영역이므로 while구문을 사용해 HashMap<String,String> header에 파싱하여 저장해준다.

 

이때, 만약 Body가 존재한다면 header값에 content-length key가 있을것이므로 조건문을 통해 Body값이 존재할경우

body값또한 파싱해준다.

Body영역은 byte값을 String으로 인코딩하여 얻어낸다.

 

 

request.getParameter("name") 구현하기

 

public void parseQueryString(String url) {
		params = new HashMap<>();

        // 쿼리 스트링 추출 정규식
        String regex = "\\?([^#]*)";
        Pattern pattern = Pattern.compile(regex);
        Matcher matcher = pattern.matcher(url);

        if (matcher.find()) {
            String queryString = matcher.group(1);
            String[] pairs = queryString.split("&");

            // 각 쿼리 파라미터를 HashMap에 추가
            for (String pair : pairs) {
                String[] keyValue = pair.split("=");
                String key = keyValue[0];
                String value = keyValue.length > 1 ? keyValue[1] : ""; // 값이 없을 경우 빈 문자열
                params.put(key, value);
            }
        }

        
    }

 

 

예를들어, 쿼리스트링을 통해 넘어오는 url값이 localhost:8080/home?name=tom&age=21 이라면

url중 파라미터값은 ?부터 시작하여 &로 구분되는 key=value값일것이다.

 

이러한 구분을 정규식 regex를 통해 구분하여 얻어낸 쿼리스트링의 파라미터 key=value값을 Hashmap<String,String> params에 파싱해주면, getParameter(param.get("key")) 를 통해 얻어낼수있다.

 

 

 

Response 객체 만들기

 

 

HTTP 응답메시지는 위와같이 생겼다.

Response 객체는 간단하게 필요한부분들을 클래스로 만들어준다.

public class Response {
	private int status;
	private String statusMsg;
	private HashMap<String, String> header;
	private String body;
    
    // getter,setter 생략
    
    }

 

 

RequestHandler 리팩토링

 

 

public class RequestHandler implements Runnable {
	private static final Logger logger = LoggerUtil.getLogger(RequestHandler.class); 
	private final Socket clientSocket;
	private static MappingServlet servletHandler;
	 
	  public RequestHandler(Socket socket) {
	        this.clientSocket = socket;
	    }
	  
	
	  public void run() {
		
		Request request = new Request();
		request.parse(clientSocket);
		
		
	}

}

 

 

이전편에 만들어두었던 RequestHandler 클래스는 Connector이 소켓을 생성하고 클라이언트로 부터 요청이 들어오면,

ThreadPool에서 새로운 스레드를 생성하여 RequestHandler 객체에 ClientSocket을 담아 넘겨주었다.

 

이 RequestHandler의 실행부인 run() 메소드에서 Request객체를 생성해준뒤, parse() 메소드를 통해 해당 요청을 파싱해주면 된다.

 

 

이제, Request와 Response 객체에 HTTP 메시지를 담아 파싱했으니 해당 요청을 읽고 처리해줄 Servlet을 구현해보자!