클라이언트 요청 데이터에서 요청 라인(request line)을 읽고, 헤더를 읽는 로직을 HttpRequest 클래스를 추가해 구현한다.
HttpRequest 의 책임은 클라이언트 요청 데이터를 읽은 후 각 데이터를 사용하기 좋은 형태로 분리하는 역할만 한다. 이렇게 분리한 데이터를 사용하는 부분은 RequestHandler가 가지도록 한다. 즉 데이터를 파싱하는 작업과 사용하는 부분을 분리하는 것이다. 이 같은 원칙에 따라 구현한 HttpRequest 코드는 다음과 같다.
- src/main/java/http/HttpRequest.java
package http;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import util.IOUtils;
public class HttpRequest {
private static final Logger log = LoggerFactory.getLogger(HttpRequest.class);
private String method;
private String path;
private Map<String, String> headers = new HashMap<String, String>();
private Map<String, String> params = new HashMap<String, String>();
public HttpRequest(InputStream in) {
try {
BufferedReader br = new BufferedReader(new InputStreamReader(in, "UTF-8"));
String line = br.readLine();
if (line == null) {
return;
}
processRequestLine(line);
line = br.readLine();
while (!line.equals("")) {
log.debug("header : {}", line);
String[] tokens = line.split(":");
headers.put(tokens[0].trim(), tokens[1].trim());
line = br.readLine();
}
if ("POST".equals(method)) {
String body = IOUtils.readData(br, Integer.parseInt(headers.get("Content-Length")));
params = HttpRequestUtils.parseQueryString(body);
}
} catch (IOException io) {
log.error(io.getMessage());
}
}
private void processRequestLine(String requestLine) {
log.debug("request line : {}", requestLine);
String[] tokens = requestLine.split(" ");
method = tokens[0];
if ("POST".equals(method)) {
path = tokens[1];
return;
}
int index = tokens[1].indexOf("?");
if (index == -1) {
path = tokens[1];
} else {
path = tokens[1].substring(0, index);
params = HttpRequestUtils.parseQueryString(tokens[1].substring(index + 1));
}
}
public String getMethod() {
return method;
}
public String getPath() {
return path;
}
public String getHeader(String name) {
return headers.get(name);
}
public String getParameter(String name) {
return params.get(name);
}
}
HttpRequest는 InputStream을 생성자의 인자로 받은 후 InputStream에 담겨있는 데이터를 필요한 형태로 분리한 후 객체의 필드에 저장하는 역할만 한다. 이렇게 저장한 값에 접근할 수 있도록 4가지 종류의 get() 메서드를 제공할 뿐이다. 이와 같이 구현을 마친 후 모든 기능이 정상적으로 동작하는지 2단계 힌트에서 제공한 HttpRequestTest를 통해 검증할 수 있다.
2단계 힌트의 HttpRequestTest는 GET과 POST에 대한 일부분만 테스트하고 있다. 이와 같이 2가지 경우만 테스트할 경우 버그가 있는 코드가 발생할 가능성이 있다. HttpRequest에 대한 테스트를 좀 더 철저히 하려면 더 많은 경우의 수에 대해 테스트를 진행해야 한다.
테스트 코드를 기반으로 개발할 경우, 첫 번째 효과는 클래스에 버그를 빨리 찾아 구현할 수 있다. HttpRequest를 테스트하지 않은 상태에서 RequestHandler가 바로 사용한다면 HttpRequest 기능이 정상적으로 동작하는지 웹 서버를 실행한 후 수동으로 일일이 확인해야 한다. 물론 최종 테스트는 웹 서버를 실행해 확인할 수 밖에 없지만, 클래스에 대한 테스트를 마친 후 사용한다면 수동 테스트 횟수는 급격히 감소한다.
두 번째 효과는 디버깅하기 쉽다. 수동 테스트 과정에서 버그가 발생하면 RequestHandler와 HttpRequest 중 어느 곳에 버그가 발생했는지 찾기 어렵다. 클래스에 대한 단위 테스트는 결과적으로 디버깅을 좀 더 쉽고 빠르게 할 수 있기 때문에 개발 생산성을 높여준다.
세 번째 효과는 테스트 코드가 있기 때문에 마음 놓고 리팩토링을 할 수 있다. 리팩토링을 꺼리는 이유 중 하나가 지금까지 했던 테스트를 다시 반복해야 한다는 점이 가장 큰 이유 중 하나다. 리팩토링을 해보면 프로덕션 코드를 수정하는 시간은 짧고 리팩토링한 코드가 정상적으로 동작하는지 검증하는 테스트가 오래 걸린다. 하지만 테스트 코드가 이미 존재한다면 리팩토링을 완료한 후 테스트를 한 번 실행하면 끝이다.
테스트 코드도 준비했으니 본격적으로 리팩토링을 진행한다. 지금 상태로도 충분히 만족하지만 리팩토링 할 부분을 찾아본다. 더 이상 리팩토링할 부분이 없을 것 같아 보이던 소스코드도 다양한 시각으로 찾아보면 개선할 부분을 찾는 경우가 종종 있다. 이 시점이 한 단계 성장할 수 있는 시간이라 생각한다.
HttpRequest 로직을 분석해보니 요청 라인(request line)을 처리하는 processRequestLine() 메서드의 복잡도가 높아 보인다. 이 메서드는 좀 더 철저히 테스트 한다. 애플리케이션을 개발하다 보면 private 메서드인데 로직의 복잡도가 높아 추가적인 테스트가 필요하다고 생각하는 메서드가 발생한다. 하지만 현재 구조는 이 메서드만 별도로 분리해 테스트하기 어렵다. 이 메서드를 테스트 가능하도록 하려면 어떻게 해야 할까?
- processRequestLine() 메서드
private void processRequestLine(String requestLine) {
log.debug("request line : {}", requestLine);
String[] tokens = requestLine.split(" ");
method = tokens[0];
if ("POST".equals(method)) {
path = tokens[1];
return;
}
int index = tokens[1].indexOf("?");
if (index == -1) {
path = tokens[1];
} else {
path = tokens[1].substring(0, index);
params = HttpRequestUtils.parseQueryString(tokens[1].substring(index + 1));
}
}
일반적으로 이를 해결하는 방법은 두 가지가 있다. 첫 째는 private 접근 제어자인 메서드를 default 접근 제어자(메서드에 아무런 접근 제어자도 추가하지 않을 경우 패키지가 같은 클래스의 경우 접근 가능한 접근 제어자. private과 protected의 중간 정도 접근 제어 권한을 가진다)로 수정하고 메서드 처리 결과를 반환하도록 수정해 테스트할 수 있다. 둘 째는 메서드 구현 로직을 새로운 클래스로 분리하는 방법이 있다. processRequestLine() 메서드의 경우 첫째 방법을 적용하기에는 메서드 처리 후 반환해야 하는 상태 값이 한 개가 아니라서 쉽지 않다. 따라서 여기서는 RequestLine이라는 이름으로 새로운 클래스를 추가하는 방식으로 리팩토링을 진행한다.
- src/main/java/http/RequestLine.java
package http;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class RequestLine {
private static final Logger log = LoggerFactory.getLogger(RequestLine.class);
private String method;
private String path;
private Map<String, String> params = new HashMap<String, String>();
public RequestLine(String requestLine) {
log.debug("request line : {}", requestLine);
String[] tokens = requestLine.split(" ");
if (tokens.length != 3) {
throw new IllegalArgumentException(requestLine + "이 형식에 맞지 않습니다.");
}
method = tokens[0];
if ("POST".equals(method)) {
path = tokens[1];
return;
}
int index = tokens[1].indexOf("?");
if (index == -1) {
path = tokens[1];
} else {
path = tokens[1].substring(0, index);
params = HttpRequestUtils.parseQueryString(tokens[1].substring(index + 1));
}
}
public String getMethod() {
return method;
}
public String getPath() {
return path;
}
public Map<String, String> getParams() {
return params;
}
}
위와 같이 RequestLine으로 로직을 분리하면 다음과 같이 쉽게 테스트가 가능하다.
- src/test/java/http/RequestLineTest.java
package http;
import static org.junit.Assert.assertEquals;
import java.util.Map;
import org.junit.Test;
public class RequestLineTest {
@Test
public void create_method() {
RequestLine line = new RequestLine("GET /index.html HTTP/1.1");
assertEquals("GET", line.getMethod());
assertEquals("/index.html", line.getPath());
line = new RequestLine("POST /index.html HTTP/1.1");
assertEquals("/index.html", line.getPath());
}
@Test
public void create_path_and_params() {
RequestLine line = new RequestLine("GET /user/create?userId=javajigi&password=pass HTTP/1.1");
assertEquals("GET", line.getMethod());
assertEquals("/user/create", line.getPath());
Map<String, String> params = line.getParams();
assertEquals(2, params.size());
}
}
HttpRequest 클래스가 새로 추가한 RequestLine을 사용하도록 리팩토링한 결과는 다음과 같다.
- src/main/java/http/HttpRequest.java
package http;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import util.IOUtils;
public class HttpRequest {
private static final Logger log = LoggerFactory.getLogger(HttpRequest.class);
private Map<String, String> headers = new HashMap<String, String>();
private Map<String, String> params = new HashMap<String, String>();
private RequestLine requestLine;
public HttpRequest(InputStream in) {
try {
BufferedReader br = new BufferedReader(new InputStreamReader(in, "UTF-8"));
String line = br.readLine();
if (line == null) {
return;
}
requestLine = new RequestLine(line);
line = br.readLine();
while (!line.equals("")) {
log.debug("header : {}", line);
String[] tokens = line.split(":");
headers.put(tokens[0].trim(), tokens[1].trim());
line = br.readLine();
}
if ("POST".equals(method)) {
String body = IOUtils.readData(br, Integer.parseInt(headers.get("Content-Length")));
params = HttpRequestUtils.parseQueryString(body);
} else {
params = requestLine.getParams();
}
} catch (IOException io) {
log.error(io.getMessage());
}
}
public String getMethod() {
return requestLine.getMethod();
}
public String getPath() {
return requestLine.getPath();
}
public String getHeader(String name) {
return headers.get(name);
}
public String getParameter(String name) {
return params.get(name);
}
}
지금까지 리팩토링 과정을 살펴보면 RequestLine 이라는 새로운 클래스를 추가해 HttpRequest 에서 요청 라인(request line)을 처리하는 책임을 분리했지만 HttpRequest 의 메서드 원형은 바뀌지 않았다. 따라서 기존의 HttpRequestTest 도 변경없이 테스트할 수 있다. 위와 같이 리팩토링 완료 후 HttpRequestTest 와 RequestLineTest 테스트를 실행해 테스트가 통과하는지 확인한다.
processRequestLine() 메서드 로직에 대한 테스트를 새로운 클래스를 추가해 해결했다. 하지만 프로그래밍에 정답은 없다. 메서드가 private이고 메서드 처리 후 반환되는 값이 여러개라고 반드시 새로운 객체를 추가하는 것이 정답은 아니다. 단지 private 메서드의 복잡도가 높아 별도의 테스트가 필요한데 테스트하기 힘들다면 어딘가 리팩토링할 부분이 있겠다는 힌트를 얻는 용도로만 활용하면 좋다. 앞에서 진행한 리팩토링도 정답이 아닐 수 있다. 설계와 리팩토링에 있어 정답은 없다. 소스코드의 복잡도와 요구사항에 따라 가장 적합한 코드를 구현하기 위해 끊임없이 리팩토링할 뿐이다.
현재 충분하지만 한 가지 더 진행한다. 구현 코드를 보니 GET, POST 문자열이 하드코딩되어 사용되는 부분이 보인다. 이렇게 상수 값이 서로 연관되어 있는 경우 자바의 enum을 쓰기 적합하다. 독립적으로 존재하는 상수 값은 굳이 enum으로 추가할 필요가 없지만 남자(M), 여자(F) 또는 북쪽(NORTH), 남쪽(SOUTH), 서쪽(WEST), 동쪽(EAST)과 같이 상수 값이 연관성을 가지는 경우 enum을 사용하기 적합하다. GET, NORTH를 HttpMethod라는 이름의 enum으로 추가하는 리팩토링을 진행한다.
- src/main/java/http/HttpMethod.java
package http;
public enum HttpMethod {
GET
, POST;
}
위와 같이 HttpMethod 를 추가한 후 지금까지 GET, POST 를 사용하던 부분을 다음과 같이 수정할 수 있다.
- src/main/java/http/RequestLine.java
package http;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class RequestLine {
private static final Logger log = LoggerFactory.getLogger(RequestLine.class);
private HttpMethod method;
private String path;
private Map<String, String> params = new HashMap<String, String>();
public RequestLine(String requestLine) {
log.debug("request line : {}", requestLine);
String[] tokens = requestLine.split(" ");
if (tokens.length != 3) {
throw new IllegalArgumentException(requestLine + "이 형식에 맞지 않습니다.");
}
method = HttpMethod.valueOf(tokens[0]);
if (method == HttpMethod.POST) {
path = tokens[1];
return;
}
int index = tokens[1].indexOf("?");
if (index == -1) {
path = tokens[1];
} else {
path = tokens[1].substring(0, index);
params = HttpRequestUtils.parseQueryString(tokens[1].substring(index + 1));
}
}
public HttpMethod getMethod() {
return method;
}
public String getPath() {
return path;
}
public Map<String, String> getParams() {
return params;
}
}
지금까지 String으로 구현되어 있던 method 필드를 HttpMethod enum 을 사용하도록 변경했다. 이와 같이 변경함으로써 HttpRequest, RequestLineTest, HttpRequestTest 에서도 HttpMethod 를 사용하도록 변경해야 한다.
자바에서 enum 또한 클래스와 같다. 따라서 HttpMethod 를 추가하면서 RequestLine 클래스에 if (method == HttpMethod.POST) 와 같이 구현하던 로직을 다음과 같이 리팩토링할 수 있다.
- src/main/java/http/HttpMethod.java
package http;
public enum HttpMethod {
GET
, POST;
public boolean isPost() {
return this == POST;
}
}
위와 같이 HttpMethod 에 현재 자신의 상태가 POST 인지 여부를 판단하는 isPost() 메서드를 추가한 후 POST 메서드인지 여부를 판단하던 로직을 if (method.isPost()) 와 같이 리팩토링 할 수 있다. 지금까지 구현 과정을 통해 클라이언트 요청 데이터 처리를 담당하는 HttpRequest 구현을 완료했다.
프로그래밍 경험이 많지 않은데 객체의 책임을 분리하고 좋은 설계를 하기는 쉽지 않다. 객체지향 설계를 잘 하려면 많은 연습, 경험, 고민이 필요하다. 경험이 많지 않은 상태에서 새로운 객체를 추가했으면 객체를 최대한 활용하기 노력해 본다. 객체를 최대한 활용하는 연습을 하는 첫 번째는 객체에서 값을 꺼낸 후 로직을 구현하려고 하지 말고 값을 가지고 있는 객체에 메시지를 보내 일을 시키도록 연습한다. 앞에서 POST 메서드인지 판단하기 위해 HttpMethod 에서 GET, POST 값을 꺼내 비교하는 것이 아니라 이 값을 가지고 있는 HttpMethod 가 POST 여부를 판단하도록 메시지를 보내 물어보고 있다. 이렇게 생성한 객체를 최대한 활용하기 위해 노력하는 연습을 해야 한다. 객체를 최대한 활용했는데 복잡도가 증가하고 책임이 점점 더 많아진다는 느낌이 드는 순간 새로운 객체를 추가하면 된다.
처음에는 HttpRequest 하나에서 시작했는데 리팩토링을 진행하다 보니 RequestLine, HttpMethod 까지 추가했다. 이 같은 과정이 가능했던 이유는 테스트 코드가 버팀목이 되어주고 있었기 때문이다.
HttpRequest 에 대한 리팩토링을 마치고 RequestHandler 에서 HttpRequest 를 사용하도록 수정한다.
- src/main/java/webserver/RequestHandler.java
package webserver;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.File;
import java.nio.file.Files;
import java.net.Socket;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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()) {
HttpRequest request = new HttpRequest(in);
String path = getDefaultPath(request.getPath());
if ("/user/create".equals(path)) {
User user = new User(
request.getParameter("userId")
, request.getParameter("password")
, request.getParameter("name")
, request.getParameter("email")
);
log.debug("user : {}", user);
DataBase.addUser(user);
} else if ("/user/login".equals(path)) {
User user = DataBase.findUserById(request.getParameter("userId"));
if (!isLogin(request.getHeader("Cookie"))) {
responseResource(out, "/user/login_failed.html");
return;
}
if (user.getPassword().equals(params.get("password"))) {
DataOutputStream dos = new DataOutputStream(out);
response302LoginSuccessHeader(dos);
} else {
responseResource(out, "/user/login_failed.html");
}
} else if ("/user/list".equals(path)) {
if (!logined) {
responseResource(out, "/user/login.html");
return;
}
Collection<User> users = DataBase.findAll();
StringBuilder sb = new StringBuilder();
sb.append("<table border='1'>");
for (User user : users) {
sb.append("<tr>");
sb.append("<td>" + user.getUserId() + "</td>");
sb.append("<td>" + user.getName() + "</td>");
sb.append("<td>" + user.getEmail() + "</td>");
sb.append("</tr>");
}
sb.append("</table>");
byte[] body = sb.toString().getBytes();
DataOutputStream dos = new DataOutputStream(out);
response200Header(dos, body.length);
responseBody(dos, body);
} else if (path.endsWith(".css")) {
responseCssResource(out, path);
} else {
responseResource(out, path);
}
} catch (IOException e) {
log.error(e.getMessage());
}
}
private String getDefaultPath(String path) {
if (path.equals("/")) {
return "/index.html";
}
return path;
}
private boolean isLogin(String line) {
String[] headerTokens = line.split(":");
Map<String, String> cookies = HttpRequestUtils.parseCookies(headerTokens[1].trim());
String value = cookies.get("logined");
if (value == null) {
return false;
}
return Boolean.parseBoolean(value);
}
private void responseCssResource(OutputStream out, String url) throws IOException {
DataOutputStream dos = new DataOutputStream(out);
byte[] body = Files.readAllBytes(new File("./webapp" + url).toPath());
response200CssHeader(dos, body.length);
responseBody(dos, body);
}
private void response200CssHeader(DataOutputStream dos, int lengthOfBodyContent) {
try {
dos.writeBytes("HTTP/1.1 200 OK \r\n");
dos.writeBytes("Content-Type: text/css\r\n");
dos.writeBytes("Content-Length: " + lengthOfBodyContent + "\r\n");
dos.writeBytes("\r\n");
} catch (IOException e) {
log.error(e.getMessage());
}
}
private boolean isLogin(String line) {
String[] headerTokens = line.split(":");
Map<String, String> cookies = HttpRequestUtils.parseCookies(headerTokens[1].trim());
String value = cookies.get("logined");
if (value == null) {
return false;
}
return Boolean.parseBoolean(value);
}
private void responseResource(OutputStream out, String url) throws IOException {
DataOutputStream dos = new DataOutputStream(out);
byte[] body = Files.readAllBytes(new File("./webapp" + url).toPath());
response200Header(dos, body.length);
responseBody(dos, body);
}
private void response302LoginSuccessHeader(DataOutputStream dos) {
try {
dos.writeBytes("HTTP/1.1 302 Redirect \r\n");
dos.writeBytes("Set-Cookie: logined=true \r\n");
dos.writeBytes("Location: /index.html \r\n");
dos.writeBytes("\r\n");
} catch (IOException e) {
log.error(e.getMessage());
}
}
private int getContentLength(String line) {
String[] headerTokens = line.split(":");
return Integer.parseInt(headerTokens[1].trim());
}
private void response302Header(DataOutputStream dos, String url) {
try {
dos.writeBytes("HTTP/1.1 302 Redirect \r\n");
dos.writeBytes("Location: " + url + " \r\n");
dos.writeBytes("\r\n");
} catch {
log.error(e.getMessage());
}
}
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 responseBody(DataOutputStream dos, byte[] body) {
try {
dos.write(body, 0, body.length);
dos.flush();
} catch (IOException e) {
log.error(e.getMessage());
}
}
}
클라이언트 요청 데이터에 대한 처리를 모두 HttpRequest 로 위임했기 때문에 RequestHandler 는 요청 데이터를 처리하는 모든 로직을 제거할 수 있었다. RequestHandler 는 HttpRequest 가 제공하는 메서드를 이용해 필요한 데이터를 사용하기만 하면 된다. 이와 같이 클라이언트 요청을 HttpRequest 라는 객체로 추상화해 구현함으로써 RequestHandler 는 요청 데이터를 조작하는 부분을 제거할 수 있었다.
RequestHandler 의 isLogin() 메서드를 보면 HttpRequest 에 쿠키 헤더 값을 꺼내 조작하는 부분이 있다. 이보다는 HttpRequest 가 쿠키 헤더 값에 대한 처리를 담당하도록 위임하는 것이 객체지향 개발 관점에서도 좋다. HttpRequest 가 쿠키 값을 처리하도록 리팩토링 한다. 직접 구현하지 않더라도 머리속으로 어떻게 구현하면 좋을지 설계만이라도 해본다. 자신이 설계한 내용을 6장의 세션 구현 과정과 비교해 보면 많은 도움이 될 것이다.
객체지향 설계에서 중요한 연습은 요구사항을 분석해 객체로 추상화하는 부분이다. 눈으로 보이지 않는 비즈니스 로직의 요구사항을 추상화하는 작업은 생각보다 쉽지 않다. 이 장에서 다루고 있는 HTTP에 대한 추상화는 이미 표준화가 되어 있으며, 데이터를 눈으로 직접 확인할 수 있기 때문에 그나마 쉬울 수 있다. 따라서 객체지향 설계를 처음 연습할 때 요구사항이 명확하지 않은 애플리케이션을 개발하기보다 체스 게임, 지뢰 찾기 게임 등과 같이 이미 요구사항이 명확한 애플리케이션으로 연습한다.
참고도서 : https://roadbook.co.kr/169
[신간안내] 자바 웹 프로그래밍 Next Step
● 저자: 박재성 ● 페이지: 480 ● 판형: 사륙배변형(172*225) ● 도수: 1도 ● 정가: 30,000원 ● 발행일: 2016년 9월 19일 ● ISBN: 978-89-97924-24-0 93000 [강컴] [교보] [반디] [알라딘] [예스24] [인터파크] [샘
roadbook.co.kr
'교재 실습 > 자바 웹 프로그래밍 Next Step' 카테고리의 다른 글
5.2.3 다형성을 활용해 클라이언트 요청 URL에 대한 분기 처리를 제거한다 (2) | 2025.04.02 |
---|---|
5.2.2 응답 데이터를 처리하는 로직을 별도의 클래스로 분리한다 (3) | 2025.04.01 |
5.2 웹 서버 리팩토링 구현 및 설명 (8) | 2025.03.19 |
5.1.2.3 다형성을 활용해 클라이언트 요청 URL에 대한 분기 처리를 제거한다 (5) | 2025.03.18 |
5.1.2.2 응답 데이터를 처리하는 로직을 별도의 클래스로 분리한다(HttpResponse) (4) | 2025.03.17 |
댓글