[자바 웹 프로그래밍] 3장 ~ 5장. 웹 서버 만들기 + 리팩토링
이 글은 [자바 웹 프로그래밍] 3장에서 5장까지 실습한 내용을 정리한 글입니다.
3장. 웹 서버 실습 요구사항
순수 자바를 사용해서 웹 서버를 만드는 경험을 하였습니다.
요구사항
- localhost:8080/index.html을 입력시 webapp/index.html을 반환하기
- GET, POST방식의 회원가입 + 홈화면 리다이렉트
- 로그인시 쿠키에 logined=true값 설정하고 리다이렉트
- localhost:8080/user/list 요청시 로그인상태 확인 후 유저 리스트 반환 혹은 로그인 페이지 리다이렉트
- 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에 있습니다.