실습 프로젝트 저장소
실습의 경우 처음에 fork 받았는데, 깃 허브 잔디가 심어지지 않아 기술 블로그 참고(링크)하여 저장소 설정을 변경하도록 함
web-application-server (3 ~ 6장)
https://github.com/slipp/web-application-server
4장에서 구현한 HTTP 웹 서버를 리팩토링하면서 설계를 개선하는 경험을 해보자
https://dev-ljw1126.tistory.com/401
절차*
① 요청 데이터를 처리하는 로직을 별도의 클래스로 분리 : HttpRequest
② 응답 데이터를 처리하는 로직을 별도의 클래스로 분리 : HttpResponse
③ 다형성을 활용해 클라이언트 요청 URL에 대한 분기 처리를 제거 : Controller 인터페이스
p166.
객체 지향 설계에서 중요한 연습은 요구사항을 분석해 객체로 추상화 하는 부분이다. 눈으로 보이지 않는 비즈니스 로직의 요구사항을 추상화하는 작업은 생각보다 쉽지 않다. 이 장에서 다루고 있는 HTTP에 대한 추상화는 이미 표준화가 되어 있으며, 데이터를 눈으로 직접 확인할 수 있기 때문에 그나마 쉬울 수 있다. 따라서 객체지향 설계를 처음 연습할 때 요구사항이 명확하지 않은 애플리케이션을 개발하기보다 체스 게임, 지뢰 찾기 게임 등과 같이 이미 요구사항이 명확한 애플리케이션으로 연습할 것을 추천한다.
5-1. 요청 처리 담당하는 HttpRequest 클래스
Hint
① 클라언트 요청 데이터를 담고 있는 InputStream을 생성자로 받아 HTTP 메소드, URL, 헤더, 본문을 분리하는 작업함
② 헤더는 Map<String, String>에 저장해 관리하고 getHeader(”필드명”) 메서도를 통해 접근 가능하도록 구현함
③ GET과 POST 메소드에 따라 전달되는 인자를 MAP<String, String>에 저장해 관리하고 getParameter(”인자 명칭”) 메소드를 통해 접근 가능하도록 구현함
테스트 코드를 기반으로 개발할 경우
① 클래스에 버그가 있는지를 빨리 찾아 구현할 수 있다
② 디버깅하기 쉽다 → 개발 생산성 향상
③ 리팩토링을 할 수 있다 → 테스트 코드가 이미 존재한다면 리팩토링 후 테스트 실행하기만 하면 쉽게 확인 가능
HttpRequest 테스트 작성(p148)
/src/test/resources 디렉토리에 HTTP_GET.txt, HTTP_POST.txt 생성
①. HTTP_GET.txt
GET /user/create?userId=javajigi&password=password&name=JaeSung HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Accept: */*
②. HTTP_POST.txt
POST /user/create HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Content-Length: 46
Content-Type: application/x-www-form-urlencoded
Accept: */*
userId=javajigi&password=password&name=JaeSung
HttpRequestTest 클래스
package webserver;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import static org.assertj.core.api.Assertions.assertThat;
class HttpRequestTest {
private String testDirectory = "./src/test/resources/";
@DisplayName("")
@Test
void fileResource() {
ClassLoader classLoader = getClass().getClassLoader();
File file = new File(classLoader.getResource("HTTP_GET.txt").getFile());
assertThat(file).isNotNull();
file = new File(testDirectory + "HTTP_GET.txt");
assertThat(file).isNotNull();
}
@DisplayName("")
@Test
void requestGET() throws IOException {
InputStream in = new FileInputStream(new File(testDirectory + "HTTP_GET.txt"));
HttpRequest httpRequest = new HttpRequest(in);
assertThat(httpRequest.getMethod()).isEqualTo("GET");
assertThat(httpRequest.getPath()).isEqualTo("/user/create");
assertThat(httpRequest.getHeader("Connection")).isEqualTo("keep-alive");
assertThat(httpRequest.getParameter("userId")).isEqualTo("javajigi");
}
@DisplayName("")
@Test
void requestPOST() throws IOException {
InputStream in = new FileInputStream(new File(testDirectory + "HTTP_POST.txt"));
HttpRequest httpRequest = new HttpRequest(in);
assertThat(httpRequest.getMethod()).isEqualTo("POST");
assertThat(httpRequest.getPath()).isEqualTo("/user/create");
assertThat(httpRequest.getHeader("Connection")).isEqualTo("keep-alive");
assertThat(httpRequest.getParameter("userId")).isEqualTo("javajigi");
}
}
HttpReqeust 클래스
public class HttpRequest {
private static final Logger log = ...
private String method;
private String path;
private Map<String, String> headers = new HashMap<>();
private Map<String, String> params = new HashMap<>();
public HttpRequest(InputStream in) {
BufferReader br = new BufferReader(new IntputStreamReader(in, "UTF-8"));
String line = br.readLine();
if(line == null) return;
processRequestLine(line);
while(!"".equals(line = br.readLine())) {
log.debug("{}", line);
String[] tokens = line.split(":");
headers.put(tokens[0].trim(), tokens[1].trim());
}
if("POST".equals(method) {
String body = IOUtils.readData(br, headers.get("Content-Length"));
params = HttpRequestUtils.parseQueryString(body);
}
}
private vodi processRequestLine(String requestLine) {
log.debug("{}", requestLine);
String[] tokens = requestLine.split(" ");
method = tokens[0];
if("POST".equals(method)) {
path = tokens[1];
return;
}
int idx = tokens[1].indexOf("?");
if(idx == -1) { // POST 요청
path = tokens[1];
return;
} else {
path = tokens[1].subString(0, idx);
params = HttpRequestUtils.parseQueryString(tokens[1].subString(idx + 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 로직에서 요청라인(request line)을 처리하는 processRequestLine() 메소드의 복잡도가 높아 보인다. 신규 RequestLine 클래스를 추가하는 방식으로 리팩토링을 해보자.
RequestLineTest 클래스
import static org.assertj.core.api.Assertions.assertThat;
class RequestLineTest {
@DisplayName("")
@Test
void createMethod() {
RequestLine requestLine = new RequestLine("GET /index.html HTTP/1.1");
assertThat(requestLine.getMethod()).isEqualTo(HttpMethod.GET);
assertThat(requestLine.getPath()).isEqualTo("/index.html");
requestLine = new RequestLine("POST /index.html HTTP/1.1");
assertThat(requestLine.getMethod()).isEqualTo(HttpMethod.POST);
assertThat(requestLine.getPath()).isEqualTo("/index.html");
}
@DisplayName("")
@Test
void createPathAndParams() {
RequestLine requestLine = new RequestLine("GET /user/create?userId=javajigi&password=password&name=JaeSung HTTP/1.1");
assertThat(requestLine.getMethod()).isEqualTo(HttpMethod.GET);
assertThat(requestLine.getPath()).isEqualTo("/user/create");
Map<String, String> params = requestLine.getParams();
assertThat(params.size()).isEqualTo(3);
}
}
RequestLine 클래스
public class RequestLine {
private static final Logger log = ...
private String method;
private String path;
private Map<String, String> params = new HashMap<>();
public RequestLine(String requestLine) {
log.debug("request line : {}", requestLine);
String[] tokens = requestLine.split(" ");
if(tokens.length != 3) {
throw new IllegalArgumentException(requestLine + "이 형식에 맞지 않습니다.");
}
method = tokens[0];
int idx= tokens[1].indexOf("?");
if(idx == -1) {
path = tokens[1];
} else {
path = tokens[1].substring(0, idx);
String queryString = tokens[1].substring(idx + 1);
params = HttpRequestUtils.parseQueryString(queryString);
}
}
public String getMethod() {
return method;
}
public String getPath() {
return path;
}
public Map<String, String> getParams() {
return params;
}
}
HttpReqeust 클래스 변경
HttpRequest에서 요청 라인(request line)을 처리하는 책임을 분리했지만 HttpRequest의 메소드 원형은 바뀌지 않았다
public class HttpRequest {
private static final Logger log = ...
private Map<String, String> headers = new HashMap<>();
private Map<String, String> params = new HashMap<>();
private RequestLine requestLine;
public HttpRequest(InputStream in) {
BufferReader br = new BufferReader(new IntputStreamReader(in, "UTF-8"));
String line = br.readLine();
if(line == null) return;
requestLine = new RequestLine(line); // *
while(!"".equals(line = br.readLine())) {
log.debug("{}", line);
String[] tokens = line.split(":");
headers.put(tokens[0].trim(), tokens[1].trim());
}
if("POST".equals(method)) {
String body = IOUtils.readData(br, headers.get("Content-Length"));
params = HttpRequestUtils.parseQueryString(body);
} else {
params = requestLine.getParams(); // *
}
}
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); }
}
리팩토링②
구현부에서 Http Method가 문자열로 하드코딩 되어 있다. enum으로 변경해보자
enum HttpMethod
public enum HttpMethod {
GET, POST;
public boolean isPost() {
return this == POST;
}
}
RequestLine 클래스
public class RequestLine {
private static final Logger log = ...
private HttpMethod method; // *
private String path;
private Map<String, String> params = new HashMap<>();
public RequestLine(String requestLine) {
// ..
method = HttpMethod.valueOf(tokens[0]); // *
int idx= tokens[1].indexOf("?");
if(idx == -1) {
path = tokens[1];
} else {
path = tokens[1].substring(0, idx);
String queryString = tokens[1].substring(idx + 1);
params = HttpRequestUtils.parseQueryString(queryString);
}
}
public HttpMethod getMethod() { return method; } // *
// ..
}
HttpRequest 클래스
public class HttpRequest {
private static final Logger log = ...
private Map<String, String> headers = new HashMap<>();
private Map<String, String> params = new HashMap<>();
private RequestLine requestLine;
public HttpRequest(InputStream in) {
// ..
if(getMethod().isPost()) { // *
String body = IOUtils.readData(br, headers.get("Content-Length"));
params = HttpRequestUtils.parseQueryString(body);
} else {
params = requestLine.getParams();
}
}
public HttpRequest getMethod() { return requestLine.getMethod(); } // *
// ..
}
리팩토링 ③
RequestHandler 클래스
public class RequestHandler extends Thread {
// 중략..
public void run() {
try (InputStream in = connection.getInputStream(); OutputStream out = connection.getOutputStream()) {
HttpRequest request = new HttpRequest(in); //*
String path = getDefaultPath(request.getPath());
DataOutputStream dos = new DataOutputStream(out);
if(path.startsWith("/user/create")) {
User user = new User(request.getParameter("userId"),
request.getParameter("password"),
request.getParameter("name"),
request.getParameter("email"));
DataBase.addUser(user);
log.debug("User : {}", user);
response302Header(dos);
}
// 중략..
} catch (IOException e) {
log.error(e.getMessage());
}
}
private boolean isLogin() {
Map<String, String> cookies = HttpRequestUtils.parseCookies(cookieValue);
String value = cookies.get("logined");
if(value == null) {
return false;
}
return Boolean.parseBoolean(value);
}
private StringgetDefaultPath(String path) {
if("/".equals(path)) {
return "/index.html";
}
return path;
}
}
p163.
프로그래밍 경험이 많지 않은데 객체의 책임을 분리하고 좋은 설계를 하기는 쉽지 않다. 객체 지향 설계를 잘하려면 많은 연습, 경험, 고민이 필요하다. 경험이 많지 않은 상태에서는 일단 새로운 객체를 추가했으면 객체를 최대한 활용하기 위해 노력해본다. *객체를 최대한 활용하는 연습을 하는 첫 번째는 객체에서 값을 꺼낸 후 로직을 구현하려고 하지 말고 값을 가지고 잇는 객체에 메시지를 보내 일을 시키도록 연습 해보자.
p165.
클라이언트 요청 데이터에 대한 처리(책임)를 모두 HttpRequest로 위임했기 때문에 RequestHandler는 요청 데이터를 처리하는 모든 로직을 제거할 수 있었다. RequestHandler는 HttpRequest가 제공하는 메소드를 이용해 필요한 데이터를 사용하기만 하면 된다. 이와 같이 클라이언트 요청을 HttpRequest라는 객체로 추상화해 구현함으로써 RequestHandler는 요청 데이터를 조작하는 부분을 제거할 수 있었다.
5-2. 응답 처리 담당하는 HttpResponse 클래스
Hint
① RequestHandler 클래스를 보면 응답 데이터 처리를 위한 많은 중복이 있다. 이 중복을 제거해 본다.
② 응답 헤더 정보를 Map<String, String>으로 관리한다.
③ 메서드를 아래와 같이 분리한다
- forward() : 응답을 보낼 때 HTMl, CSS, JavaScript 파일을 직접 읽어 응답으로 보내는 메서드
- sendRedirect() : 다른 URL로 리다이렉트
HttpResponse 테스트 작성(p150)
class HttpResponseTest {
private String testDirectory = "./src/test/resources/";
@DisplayName("")
@Test
void responseForward() throws IOException {
//HTTP_Forward.txt 결과는 응답 body에 index.html이 포함되어 있어야 한다.
HttpResponse response = new HttpResponse(createOutputStream("Http_Forward.txt")); // *해당 위치에 파일 생성함
response.forward("/index.html");
}
@DisplayName("")
@Test
void responseRedirect() throws IOException {
//HTTP_Redirect.txt 결과는 Location: index.html 정보가 포함되어 있어야 한다.
HttpResponse response = new HttpResponse(createOutputStream("HTTP_Redirect.txt"));
response.sendRedirect("/index.html");
}
@DisplayName("")
@Test
void responseCookies() throws IOException {
//HTTP_Cookie.txt 결과는 응답 body에 index.html이 포함되어 있어야 한다.
HttpResponse response = new HttpResponse(createOutputStream("HTTP_Cookies.txt"));
response.addHeader("Set-Cookie", "logined= true");
response.sendRedirect("/index.html");
}
private OutputStream createOutputStream(String fileName) throws FileNotFoundException {
return new FileOutputStream(new File(testDirectory + fileName));
}
}
참고.
HttpResponseTest 실행시 지정된 경로에 아래와 같이 파일이 생성된다
/src/test/resources/HTTP_Cookies.txt
HTTP/1.1 302 Found
Set-Cookie: logined= true
Location: /index.html
HttpResponse 클래스(p167)
public class HttpResponse {
private static final Logger log = ...
private DataOutputStream dos;
private Map<String, String> headers = new HashMap<>();
public HttpResponse(OutputStream out) {
this.dos = new DataOutputStream(out);
}
public void addHeader(String key, String value) {
headers.put(key, value);
}
public void forward(String url) {
try {
byte[] body = Files.readAllBytes(new File("./webapp" + url).toPath());
if(url.endsWith(".css")) {
headers.put("Content-Type", "text/css");
} else if(url.endsWith(".js")) {
headers.put("Content-Type", "application/javascript");
} else if(url.endsWith(".html")) {
headers.put("Content-Type", "text/html;charset=utf-8");
}
headers.put("Content-Length", String.valueOf(body.length));
response200Header();
responseBody(body);
} catch (IOException e) {
log.error(e.getMessage());
}
}
public void forwardBody(String body) {
byte[] contents = body.getBytes();
headers.put("Content-Type", "text/html;charset=utf-8");
headers.put("Content-Length", String.valueOf(contents.length));
response200Header();
responseBody(contents);
}
public void sendRedirect(String redirectUrl) {
try {
dos.writeBytes("HTTP/1.1 302 Found \r\n");
processHeaders();
dos.writeBytes("Location: " + redirectUrl + " \r\n");
dos.writeBytes("\r\n");
} catch (IOException e) {
log.error(e.getMessage());
}
}
private void responseBody(byte[] body) {
try {
dos.write(body, 0, body.length);
dos.writeBytes("\r\n");
dos.flush();
}catch (IOException e) {
log.error(e.getMessage());
}
}
private void response200Header() {
try {
dos.writeBytes("HTTP/1.1 200 OK \r\n");
processHeaders();
dos.writeBytes("\r\n");
} catch (IOException e) {
log.error(e.getMessage());
}
}
private void processHeaders() {
try {
for (String key : headers.keySet()) {
dos.writeBytes(key + ": " + headers.get(key) + " \r\n");
}
} catch (IOException e) {
log.error(e.getMessage());
}
}
}
리팩토링
RequestHandler 클래스
public class RequestHandler extends Thread {
public void run() {
try (InputStream in = connection.getInputStream(); OutputStream out = connection.getOutputStream()) {
HttpRequest request = new HttpRequest(in);
HttpResponse response = new HttpResponse(out); //*
String path = request.getPath();
if(path.startsWith("/user/create") && !path.endsWith(".html")) {
// 중략..
response.sendRedirect("/index.html");
} else if(path.startsWith("/user/login") && !path.endsWith(".html")) {
User user = DataBase.findUserById(request.getParameter("userId"));
if (user == null || !user.getPassword().equals(request.getParameter("password"))) {
response.addHeader("Set-Cookie", "logined=false");
response.forward("/user/login_failed.html");
} else {
response.addHeader("Set-Cookie", "logined=true");
response.sendRedirect("/index.html");
}
} else if(path.startsWith("/user/list") && !path.endsWith(".html")) {
if (isLogin(request.getHeader("Cookie"))) {
// 중략..
response.forwardBody(sb.toString());
} else {
response.sendRedirect("/index.html");
}
} else {
response.forward(path);
}
} catch (IOException e) {
log.error(e.getMessage());
}
}
//중략..
}
응답 데이터 처리에 대한책임을 HttpResponse로위임했더니 RequestHandler에서 응답 헤더와 본문 처리를 담당하던 모든 private 메소드를 제거할 수 있었다.
5-3. Controller 인터페이스와 AbstractControllor 추상 클래스
Hint
① 각 요청과 응답에 대한 처리를 담당하는 부분을 추상화해 인터페이스로 만든다(아래 클래스 다이어그램 참고)
② RequestHandler의 각 분기문을 Controller 인터페이스를 구현하는 클래스를 만들어 분기한다
③ 이렇게 생성한 Controller 구현체를 Map<String, Controller>에 저장한다.
(Map 키에 해당하는 String은 요청 URL, value에는 Controller 구현체)
④ 클라리언트 요청 URL에 해당하는 Controller를 찾아 service() 메소드를 호출한다
⑤ AbstractController 추상클래스를 추가해 service() 메서드에서 HttpMethod에 따라 doGet(), doPost() 호출하도록 한다
p171
run() 메소드의 가장 큰 문제점은 기능이 추가될 때마다 새로운 else if 절이 추가되는 구조로 구현되어 있다는 것이다. 이는 객체지향 설계 원칙 중 요구사항의 변경이나 추가 사항이 발생하더라도, 기존 구성요소는 수정이 일어나지 말아야 하며, 기존 구성 요소를 쉽게 확장해서 재사용할 수 있어야 한다는 OCP(개방 폐쇠의 원칙, Open-Closed Principle)원칙을 위반하고 있다. 새로운 기능이 추가되거나 수정사항이 발생하더라도 변화의 범위를 최소화하도록 설계를 개선해보자.
클래스 다이어그램을 본 후 “AbstractController 의 service()에서 HttpMethod에 따라 분기를 하면 되지 않을까?” 생각을 하고 하니 쉽게 작성할 수 있었다
interface Controller
public interface Controller {
void service(HttpRequest request, HttpResponse response);
}
AbstractController 클래스
public abstract class AbstractController implements Controller {
@Override
public void service(HttpRequest request, HttpResponse response) {
HttpMethod httpMethod = request.getMethod();
if(httpMethod.isPost()) {
doPost(request, response);
} else {
doGet(request, response);
}
}
protected void doGet(HttpRequest request, HttpResponse response) {}
protected void doPost(HttpRequest request, HttpResponse response) {}
}
doGet(), doPost() 메서드를 추상 메서드로 할 경우 상속 받은 자식 클래스에서 모두 구현해줘야 하므로, 위와 같이 하여 오버라이딩 처리하도록 함
CreateUserController 클래스 구현
public class CreateUserController extends AbstractController {
private static final Logger log = LoggerFactory.getLogger(CreateUserController.class);
@Override
protected void doPost(HttpRequest request, HttpResponse response) {
User user = new User(request.getParameter("userId"),
request.getParameter("password"),
request.getParameter("name"),
request.getParameter("email"));
log.debug("User : {}", user);
DataBase.addUser(user);
response.sendRedirect("/index.html");
}
}
나머지 ListUserController와 LoginController도 동일하게 구현하면 된다
RequestHandler 리팩토링(최종)
public class RequestHandler extends Thread {
private static final Logger log = LoggerFactory.getLogger(RequestHandler.class);
private Socket connection;
private static final Map<String, Controller> controllerMap;
static {
controllerMap = new HashMap<>();
controllerMap.put("/user/login", new LoginController());
controllerMap.put("/user/list", new ListUserController());
controllerMap.put("/user/create", new CreateUserController());
}
public RequestHandler(Socket connectionSocket) {
this.connection = connectionSocket;
}
public void run() {
try (InputStream in = connection.getInputStream(); OutputStream out = connection.getOutputStream()) {
HttpRequest request = new HttpRequest(in);
HttpResponse response = new HttpResponse(out);
Controller controller = getController(request.getPath());
if(controller == null) {
String path = getDefaultPath(request.getPath());
response.forward(path);
} else {
controller.service(request, response);
}
} catch (IOException e) {
log.error(e.getMessage());
}
}
private static Controller getController(String requestUrl) {
return controllerMap.get(requestUrl);
}
private String getDefaultPath(String path) {
if("/".equals(path)) {
return "/index.html";
}
return path;
}
}
'독서 > 📚' 카테고리의 다른 글
[Next Step] 7장 DB를 활용해 데이터를 영구적으로 저장하기 (0) | 2023.11.17 |
---|---|
[Next Step] 6장 서블릿/JSP를 활용해 동적인 웹 애플리케이션 개발하기 (0) | 2023.11.16 |
[Next Step] 3~4장 HTTP 웹서버 구현을 통해 HTTP 이해하기(No Framework) (0) | 2023.11.15 |
[Next Step] 12.8 웹서버 도입을 통한 서비스 운영(p458) 정리 (0) | 2023.11.10 |
[Next Step] 10.4 배포 자동화를 위한 쉘 스크립트 개선 (p362) 정리 (0) | 2023.11.10 |
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!