서적

[자바 웹 프로그래밍] 3장 ~ 5장. 웹 서버 만들기 + 리팩토링

조명인 2023. 8. 11. 19:04

이 글은 [자바 웹 프로그래밍] 3장에서 5장까지 실습한 내용을 정리한 글입니다.

 

3장. 웹 서버 실습 요구사항

순수 자바를 사용해서 웹 서버를 만드는 경험을 하였습니다.

 

요구사항

  1. localhost:8080/index.html을 입력시 webapp/index.html을 반환하기
  2. GET, POST방식의 회원가입 + 홈화면 리다이렉트
  3. 로그인시 쿠키에 logined=true값 설정하고 리다이렉트
  4. localhost:8080/user/list 요청시 로그인상태 확인 후 유저 리스트 반환 혹은 로그인 페이지 리다이렉트
  5. css 적용하기

 

힌트를 봐 가면서 최대한 혼자 요구사항들을 구현하려고 노력하였고 3일정도(약 10시간)동안 구현하였습니다. 책에서 빠르면 4시간, 길면 8시간 정도 걸린다는 이야기가 있었는데 저는 꽤 오래 걸린 편이지만 완성했을 때 큰 보람을 느꼈습니다. 첫번째 요구사항을 충족시키기 위해서 소켓, 쓰레드, HTTP 요청과 응답의 구조를 잘 알아야 했는데, 이것들을 파악하는 데에 반 이상의 시간이 걸린 것 같습니다. 

 

배운 점

  • Thread를 요청마다 생성하고 실행하는 방법
  • 자바의 Input/Output 방법 + BufferedReader 사용방법 - BufferedReader의 readline메서드는 '\n'이나 '\r' 기준으로 읽어들이기 때문에 밑 HTTP 요청 메시지의 마지막 Body 부분은 readline으로 읽을 수 없습니다. 따라서 요청 헤더를 파싱한 다음 Content-Length의 값에 따라 read()메서드를 사용하여 length만큼의 문자를 읽어들였습니다.
POST /user/create HTTP/1.1
Content-Length: 30
...(헤더 부분)

email=myoungin&password=1234
  • HTTP 302 found의 역할 - 로그인 후, 회원가입 후에 홈 화면 리다이렉트를 구현했어야 했습니다. 아래처럼 302 status code를 반환하고 location 헤더에 리다이렉트할 url을 추가한 응답 메시지를 반환하였습니다.
HTTP/1.1 302 FOUND
Location: /index.html
  • 쿠키 저장 방법 - 응답 헤더에 "Set-Cookie: logined=true"를 넣어주어서 클라이언트 웹 브라우저의 쿠키 저장소에 저장시켰습니다.

 

4장. 웹 서버 구현을 통해 HTTP 이해하기

 

책에 있는 마구잡이 구현 과정과 제가 작성한 코드와 비교하였습니다. 거의 비슷한 방식으로 구현하였고, 다른 점이 있다면 저는 HTTP 요청 메시지를 하나의 객체로 만들어 그 안에 메서드, url, 쿠키나 헤더를 확인하는 메서드를 만들었습니다. 응답값을 묶어서 처리하지 않은 점, URL 요청을 RequestHandler 객체 안에서 if/else 분기문으로 처리한 점은 4장의 코드와 거의 비슷했습니다.

 

 

5장. 웹 서버 리팩토링, 서블릿 컨테이너와 서블릿의 관계

 

4장에서 만든 지저분한 코드를 세 관점에서 리팩토링합니다. 

 

1. 요청 메시지를 하나의 객체로 분리하였습니다.

  • 헤더값의 확인, 쿠키 확인 등의 메서드를 정의하였습니다.
  • 요청 메시지 첫번째 줄을 파싱하는 데에 복잡도가 꽤 있었기 때문에 "생성자 안에서 처리 -> private 메서드로 분리하여 처리 -> RequestLine 객체로 분리" 의 순서로 리팩토링을 진행하였습니다.
  • "복잡한 메서드는 테스트가 필요하다 -> private 메서드는 테스트하기 힘들다 -> 별도의 객체로 분리시킨 후 해당 객체에 대한 단위테스트를 작성한다" 라는 논리를 직접 구현해 본 입장에서 통감했습니다.

2. 응답 메시지를 하나의 객체로 분리하였습니다.

  • forward, forwardBody, sendRedirect 메서드를 정의하여 경우에 따라 선택하게 하였습니다.
  • processHeader() 메서드를 만들어 응답 헤더를 한번에 OutputStream에 쓸 수 있도록 하였습니다.

3. 각 URL 요청에 대한 처리를 분리하였습니다.

  • 여기서 스프링에서 배운 Controller라는 개념을 사용합니다.
  • Controller 인터페이스 안에는 void service(request, response) 라는 메서드 시그니쳐가 있습니다.
  • Handler 클래스 안에서 if/else로 처리하는 대신, Controller 인터페이스를 구현하는 클래스가 로직을 처리하도록 하였습니다.
  • RequestMapping 이라는 클래스를 정의하여 서버 시작시 Map<Url, Controller> 안에 구현한 컨트롤러들을 매핑시킨 후, 요청이 들어오면 RequestMapping의 getController 메서드로 컨트롤러를 반환하도록 하였습니다.

최종 RequestHandler의 모습

public class RequestHandlerV5 extends Thread {
    private static final Logger log = LoggerFactory.getLogger(RequestHandlerV5.class);

    private Socket connection;

    public RequestHandlerV5(Socket connectionSocket) {
        this.connection = connectionSocket;
    }

    public void run() {
        log.debug("New Client Connect! Connected IP : {}, Port : {}", connection.getInetAddress(), connection.getPort());

        try (InputStream in = connection.getInputStream();
             OutputStream out = connection.getOutputStream()) {

            // request, response 객체 생성
            HttpRequest httpRequest = new HttpRequest(in);
            HttpResponse httpResponse = new HttpResponse(out);
            String path = httpRequest.getPath();

            Controller controller = RequestMapping.getController(path);
            if (controller == null) {
                path = getDefaultPath(path);
                httpResponse.forward(path);
            } else {
                controller.service(httpRequest, httpResponse);
            }
        } catch (IOException e) {
            log.error(e.getMessage());
        }
    }

    private String getDefaultPath(String path) {
        if (path.equals("/")) {
            return "/index.html";
        }
        return path;
    }
}

 

처음 구현한 RequestHandler의 모습(...)

public class RequestHandler extends Thread {
	private static final Logger log = LoggerFactory.getLogger(RequestHandler.class);
	
	private Socket connection;

	public RequestHandler(Socket connectionSocket) {
		this.connection = connectionSocket;
	}

	public void run() {
		log.debug("New Client Connect! Connected IP : {}, Port : {}", connection.getInetAddress(), connection.getPort());
		
		try (InputStream in = connection.getInputStream();
			 OutputStream out = connection.getOutputStream();
			 BufferedReader br = new BufferedReader(new InputStreamReader(in))) {
			// TODO 사용자 요청에 대한 처리는 이 곳에 구현하면 된다.
			DataOutputStream dos = new DataOutputStream(out);

			// 헤더와 라인 구별
			HttpHeader httpHeader = parseHeader(br);
//			httpHeader.print();

			// body 구현
			BufferedReader brf = null;
			String content;
			byte[] body = new byte[0];

			if (httpHeader.getUriString().equals("/favicon.ico")) {
				return;
			}
			else if (httpHeader.getHeaderMap().get("Accept").startsWith("text/css") && httpHeader.getUriString().startsWith("/stylesheets")) {
				body = Files.readAllBytes(Path.of("./webapp" + httpHeader.getUriString()));
				response200CSS(dos, body.length);
				responseBody(dos, body);
				return;
			}
			else if (httpHeader.getUriString().equals("/index.html")) {
				body = Files.readAllBytes(Path.of("./webapp/index.html"));
			}
			else if (httpHeader.getUriString().equals("/form.html")) {
				body = Files.readAllBytes(Path.of("./webapp/form.html"));
			}
			else if (httpHeader.getUriString().equals("/login.html")) {
				body = Files.readAllBytes(Path.of("./webapp/login.html"));
			}
			else if (httpHeader.getHttpMethod().equals("GET") && httpHeader.getUriString().equals("/login")) {
				if (WebServer.userRepository.tryLogin(httpHeader.getQueryStringMap().get("userId"),
						httpHeader.getQueryStringMap().get("password"))) {
					response302HeaderWithLogin(dos, "/index.html", true);
					return;
				} else {
					response302HeaderWithLogin(dos, "/user/login_failed.html", false);
					return;
				}
			}
			else if (httpHeader.getHttpMethod().equals("GET") && httpHeader.getUriString().equals("/create")) {
				User user = User.from(httpHeader.getQueryStringMap());
				WebServer.userRepository.save(user);
				response302Header(dos, "/index.html");
				return;
			}
			else if (httpHeader.getHttpMethod().equals("POST") && httpHeader.getUriString().equals("/create")) {
				User user = User.from(httpHeader.getQueryStringMap());
				WebServer.userRepository.save(user);
				response302Header(dos, "/index.html");
				return;
			}
			else if (httpHeader.getHttpMethod().equals("GET") && httpHeader.getUriString().equals("/user/list")) {
				if (httpHeader.checkLogin()) {
					Set<User> users = WebServer.userRepository.getUsers();
					StringBuilder sb = new StringBuilder();
					sb.append("<html>");
					sb.append("<body>");
					for (User user : users) {
						sb.append(user.toString());
					}
					sb.append("</body>");
					sb.append("</html>");

					body = sb.toString().getBytes();
				} else {
					response302Header(dos, "/login.html");
					return;
				}
			}



			response200Header(dos, body.length);
			responseBody(dos, body);
		} catch (IOException e) {
			log.error(e.getMessage());
		}
	}


	private HttpHeader parseHeader(BufferedReader br) throws IOException {
		List<String> headerStrings = new ArrayList<>();
		String line = "";
		while (true) {
			line = br.readLine();
			System.out.println(line);
			if (line == null) break;
			if (line.length() == 0) break;
			headerStrings.add(line);
		}
		HttpHeader httpHeader = HttpHeader.from(headerStrings);
		int bodyLength = httpHeader.getContainBodyLength();
		if (bodyLength > 0) {
			String body = IOUtils.readData(br, bodyLength);
			httpHeader.setBody(body);
		}
		if (httpHeader.getHeaderMap().containsKey("Cookie")) {
			httpHeader.setCookies();
		}
		return httpHeader;
	}

	private void response200Header(DataOutputStream dos, int lengthOfBodyContent) {
		try {
			dos.writeBytes("HTTP/1.1 200 OK \r\n");
			dos.writeBytes("Content-Type: text/html;charset=utf-8\r\n");
			dos.writeBytes("Content-Length: " + lengthOfBodyContent + "\r\n");
			dos.writeBytes("\r\n");
		} catch (IOException e) {
			log.error(e.getMessage());
		}
	}

	private void response200CSS(DataOutputStream dos, int lengthOfBodyContent) {
		try {
			dos.writeBytes("HTTP/1.1 200 OK \r\n");
			dos.writeBytes("Content-Type: text/css;charset=utf-8\r\n");
			dos.writeBytes("Content-Length: " + lengthOfBodyContent + "\r\n");
			dos.writeBytes("\r\n");
		} catch (IOException e) {
			log.error(e.getMessage());
		}
	}

	private void response302Header(DataOutputStream dos, String location) {
		try {
			dos.writeBytes("HTTP/1.1 302 Found \r\n");
			dos.writeBytes("Content-Type: text/html;charset=utf-8\r\n");
			dos.writeBytes("Location: " + location);
			dos.writeBytes("\r\n");
		} catch (IOException e) {
			log.error(e.getMessage());
		}
	}

	private void response302HeaderWithLogin(DataOutputStream dos, String location, boolean isLogin) {
		try {
			dos.writeBytes("HTTP/1.1 302 Found \r\n");
			dos.writeBytes("Content-Type: text/html;charset=utf-8\r\n");
			dos.writeBytes("Location: " + location + "\r\n");
			dos.writeBytes("Set-Cookie: logined=" + isLogin + "\r\n");
			dos.writeBytes("\r\n");
		} catch (IOException e) {
			log.error(e.getMessage());
		}
	}

	private void responseBody(DataOutputStream dos, byte[] body) {
		try {
			dos.write(body, 0, body.length);
			dos.writeBytes("\r\n");
			dos.flush();
		} catch (IOException e) {
			log.error(e.getMessage());
		}
	}
}

딱 봐도 핸들러가 무슨 일을 하는지 알 수 있다/없다 로 나뉘는 것 같습니다.

또한 새로운 기능을 추가할 때 핸들러를 고칠 필요 없이 Controller 추가/RequestMapping에 한줄 추가하면 됩니다.

 

[느낀점] : 나는 아무것도 몰랐구나

바퀴를 새로만드는 경험을 해 보면서 HTTP 메시지가 어떻게 들어오고 나가는지, css요청은 어떤식으로 들어오는지 등을 전혀 모르고 있었다는 것을 알게 되었습니다. 이번 기회에 웹에 대한 이해가 올라간 느낌이 들어서 좋았습니다. 서블릿, 서블릿 컨테이너의 개념이 왜 생겼는지도 알 수 있었습니다. 빈 등록, 어노테이션같은 당연하게 생각했던 기능들이 없다면 얼마나 불편할지 느낄 수 있었습니다. 또 리팩토링을 진행하는 과정에서 객체지향적인 설계의 좋은 예시를 볼 수 있었습니다. 테스트코드의 필요성까지 거의 뭐 한상 차림으로 배부르게 먹었습니다. 다음 장들이 기대됩니다.

 

코드는 https://github.com/myoungincho729/web-application-server/tree/refactoring1에 있습니다.