목적
박재성의 자바 웹 프로그래밍 Next Step 3장 HTTP 웹 서버 구현은 HTTP를 이해하기 위한 과정이다. 책에 있는 단계를 하나씩 연습하고 적용한 내용과 부딪힌 문제를 readme에 적었는데, 몰랐던 부분, 더 공부하고 싶었던 부분, 생각했던 지식을 적어보면서 잘 알면서도 잘 몰랐던 등 나중에 그 부분을 다시 찾아 공부할 수 있는 기회를 주기 위해 이 글을 썼다. 이 기사의 코드는 아래 github에서 찾을 수 있습니다.
https://github.com/Nuouung/web-application-server
GitHub – Nuouung/web-application-server: 웹 애플리케이션 서버 실습을 위한 프레임워크
웹 애플리케이션 서버를 실습하기 위한 프레임워크입니다. GitHub에서 계정을 생성하여 Nuouung/Web-Application-Server 개발에 기여하십시오.
github.com
운동 기록
요구 사항 1 – http://localhost:8080/index.html에 연결할 때 응답
- 먼저 inputStream에 포함된 데이터의 형태를 확인하고 싶어서 다음과 같은 코드를 작성했습니다. 그리고 코드를 실행하여 inputStream에 포함된 데이터가 HTTP 요청 메시지임을 확인했습니다.
int available = in.available();
byte() request = new byte(in.available());
for (int i = 0; i < request.length; i++) {
request(i) = (byte) in.read();
}
String requestString = new String(request);
System.out.println("======================");
System.out.println(requestString);
System.out.println("======================");
- 다음으로 나는 HttpRequest라는 별도의 클래스를 만들고 HTTP 요청에 대한 데이터를 해당 클래스에 바인딩하는 것을 생각했습니다. http 메소드와 url 같은 정보를 객체에 묶어서 사용하는 것이 훨씬 더 효율적일 것이라고 판단했습니다. 이 프로세스에서 BufferedReader가 사용되었지만 서버가 비정상적으로 작동하기 시작했습니다. 브라우저에서 요청을 보냈을 때 응답을 받지 못하고 계속 기다렸습니다.
- 디버깅 등을 통해 원인을 파악한 결과 데이터 바인딩에 사용되는 BufferedReader의 readLine 메소드에 문제가 있음을 발견했습니다. 아래와 같이 코드를 작성했는데 버퍼링된 리더가 HTTP 요청 메시지에 EOF(End of File) 라인이 없어서 멈추지 않고 다음 메시지를 기다리다가 대기 현상이 발생했습니다.
String line;
while ((line = br.readLine()) != null) { // br.readLine()에서 다음 인풋을 받기 위해 무한대기한다.
// http 메시지를 클래스 필드에 바인딩하는 내부 로직
}
- 결국 HttpRequest 클래스를 주석 처리한 후 다음 코드를 작성하여 GET 메소드로 /index.html에 접근할 때 index.html 파일을 전달하였다.
BufferedReader br = new BufferedReader(new InputStreamReader(in));
DataOutputStream dos = new DataOutputStream(out);
String firstLine = br.readLine();
if (firstLine.split(" ")(1).equals("/index.html") && firstLine.split(" ")(0).equals("GET")) {
File file = new File("(경로)\\webapp\\index.html");
byte() body = Files.readAllBytes(file.toPath());
response200Header(dos, body.length);
responseBody(dos, body);
return;
}
요구 사항 2 – get 메소드를 사용하여 회원으로 등록
- 요구 사항 1에는 몇 가지 개선 사항이 있습니다. 서버에 대한 요청 사양이 증가함에 따라 예를 들어 B. 인덱스 페이지에 요청하는 정보와 회원가입 데이터에 요청하는 정보가 달라서 기존 RequestHandler에 요청 응답 로직을 작성하는 방식이 너무 지저분하게 느껴졌습니다. Controller라는 새 패키지를 만든 후 각 기능을 담당하는 별도의 Controller 개체가 만들어졌습니다. 그리고 RequestHandler에서는 다음과 같이 Controller 객체를 호출하여 적용한다.
private final IndexController indexController = new IndexController();
private final SignupController signupController = new SignupController();
...
// 위에서부터 우선적으로 적용 (ex. 라우팅 경로가 겹치는 경우 위의 것이 우선적으로 적용됨)
indexController.route(httpRequest, dos);
signupController.route(httpRequest, dos);
- 두 번째 개선점은 GET 타입 요청에서 질의 매개변수에 대한 신규 규격 생성 시 HttpRequest 객체가 변경된 부분이다. HttpRequest 개체에서 HTTP 요청 메시지를 구문 분석할 때 첫 번째 줄은 쿼리 매개 변수를 식별하고 개체의 필드로 바인딩합니다. 이를 위해 queryStringMap이라는 HashMap 필드를 적용했는데, 질의 문자열을 객체에 삽입하는 과정은 간단하지만 디버깅 도구를 사용하지 않고는 HttpRequest에 어떤 질의 문자열이 포함되어 있는지 특정 시점에 알아내기 어렵다. . 이 부분은 코드가 확장되면서 개선의 여지가 필요한 것 같습니다.
private Map<String, String> queryStringMap;
...
queryStringMap = parseQueryString(sArray);
private Map<String, String> parseQueryString(String() sArray) {
return (sArray(1).split("\\?").length > 1) ?
parseQueryString(sArray(1).split("\\?")(1)) :
new HashMap<>();
}
private Map<String, String> parseQueryString(String target) {
Map<String, String> queryStringMap = new HashMap<>();
String() queryStrings = target.split("&");
for (String queryString : queryStrings)
queryStringMap.put(queryString.split("=")(0), queryString.split("=")(1));
return queryStringMap;
}
```
* 위와 같이 코드를 개선하고 새로 만든 signupController에서 회원가입에 필요한 로직을 작성했다. 회원가입 form 페이지로 이동하는 메소드와, 회원가입을 수행하는 메소드 두 개를 작성했고 회원가입 수행 시에는 RequestHandler에서 생성한 HttpRequest 객체 내부의 회원 정보를 queryStringMap에서 추출해 도메인 객체에 바인딩 하는 작업을 해주었다. 이후 도메인 객체는 DataBase 클래스에 의해 메모리상에 저장되는 방식으로 회원가입을 완료하는 방식으로 진행되었다.
```
private void signupPageGet(HttpRequest request, DataOutputStream dos) {
try {
File file = new File("./webapp" + request.getRequestURI());
byte() body = Files.readAllBytes(file.toPath());
HttpResponseUtils.response200Header(dos, body.length, log);
HttpResponseUtils.responseBody(dos, body, log);
} catch (IOException e) {
log.error(e.getMessage());
}
}
private void signupPost(HttpRequest request, DataOutputStream dos) {
Map<String, String> queryStringMap = request.getQueryStringMap();
User user = new User(queryStringMap.get("userId"), queryStringMap.get("password"), queryStringMap.get("name"), queryStringMap.get("email"));
DataBase.addUser(user);
}
요건 3 – 우편으로 회원가입
- 요구 사항 #1을 작업하는 동안 br.readLine() 관련 문제가 다시 발생했습니다. while 문에 입력된 readLine()은 다음 명령이 도착할 때까지 무기한 대기합니다. 나는 다시 인터넷을 서핑했고 이번에는 chatGPT에 해결책을 요청하여 문제를 해결하려고했습니다. 그러나 실패했습니다.
- 결국 책에 있는 힌트 코드를 보고 코드 리팩토링을 시도하게 되었습니다. while(!line.equals(“”)) 하고 while 문 안에 br.readLine 을 업데이트했는데도 무한 대기에 들어가지 않는 걸 확인했지만 불안한 마음을 감출 수 없었다. 그런데 그럴 방법이 없어서 그냥 놔뒀어요.
- HttpRequest 클래스에 Http 요청 메시지의 주요 데이터를 기록하는 로직을 추가했습니다. 콘텐츠 길이가 있을 때만 바인딩 작업을 해봤는데, 데이터가 json이나 raw로 들어오는 상황에서 nullPointerException이 발생할 확률이 매우 높다(아마도 폼타입이 아닐 때 모든 오류가 발생한다). 첫째, 실력이 부족해서 감히 리모델링을 못했는데… 이것도 합격.
- 다음 코드를 추가했습니다.
public HttpRequest(InputStream in) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
String line = br.readLine();
parseFirstHeader(line);
parseHeaders(br, line);
if (headers.get("Content-Length") != null) {
body = IOUtils.readData(br, Integer.parseInt(headers.get("Content-Length")));
modelAttributes = parseValues(body);
}
}
...
private void parseHeaders(BufferedReader br,String line) throws IOException {
headers = new HashMap<>();
while (!line.equals("")) {
if (line.split(": ").length > 1)
headers.put(line.split(": ")(0), line.split(": ")(1));
else if (line.split(":").length > 1)
headers.put(line.split(":")(0), line.split(":")(1));
line = br.readLine();
}
}
...
private Map<String, String> parseValues(String target) { // 여기에서 아마 에러가 날거다.
if (target == null || target.equals(""))
return Maps.newHashMap();
Map<String, String> queryStringMap = new HashMap<>();
String() tokens = target.split("&");
for (String value : tokens)
queryStringMap.put(value.split("=")(0), value.split("=")(1));
return queryStringMap;
}
- 나는 어떻게든 HttpRequest를 작성했고, 정신적 피로의 너덜너덜한 상태에서 HTML 양식 부분을 get에서 post로 변경하고 SignupController 부분을 수정했습니다.
private void signupPost(HttpRequest request, DataOutputStream dos) {
Map<String, String> modelAttributes = request.getModelAttributes();
User user = new User(modelAttributes.get("userId"), modelAttributes.get("password"), modelAttributes.get("name"), modelAttributes.get("email"));
DataBase.addUser(user);
}
- 느낀 점) 멀티 스레드 환경으로 인해 정적 코드(로그를 보면 여러 스레드가 무작위로 로그를 선택하여 여러 스레드가 앞뒤로 이동함)의 동시성 문제와 내가 하지 않은 이상한 네트워크 요청도 걱정됩니다. 이해하지 못함(값이 비어 있는 요청이 계속 반환됨)이 옵니다. 나는 그것이 무엇인지 모른다. 또한 브라우저를 통해 액세스할 때 요청 값과 우편 배달부를 통해 요청할 때 요청 값은? 브라우저에 접속할 때 HttpRequest에는 오류가 없는데 Postman을 통해 요청할 때는 내가 알 수 없는 백오류가 있어서 가볍게 건드릴 수가 없고 작은 오류 하나라도 가볍게 건드릴 수가 없다. 시간을 보내는 것이 매우 답답했습니다. 왜 이렇게 실수가 많고 알아내기 힘든지… 내가 한없이 작아진 기분이었다.
요구 사항 4 – 리디렉션 방법으로 이동
- 솔직히 빨리 할 수 있을 거라 생각했다. 3번 요구사항에 컨트롤러를 작성했고, 그냥 302 redirect 명령어를 넣고 응답으로 보내면 브라우저가 자동으로 리다이렉트 해주기 때문에 코드 몇 줄만 작성하면 될 것 같았습니다.
- 그러나 예상보다 시간이 오래 걸리고 여전히 이해하지 못하는 문제가 발생했습니다. Java의 IO 처리에 대한 지식이 부족해서인지 네트워크, 특히 HTTP에 대한 지식이 부족해서인지 잘 모르겠지만 정말 어려웠습니다.
- 먼저 작성한 코드는 다음과 같으며 “HttpResponseUtils”에 “response302Header”라는 메소드를 생성한 후 “SignupController”에 있는 메소드를 실행하였다.
// SignupController 로직
private void signupPost(HttpRequest request, DataOutputStream dos) {
Map<String, String> modelAttributes = request.getModelAttributes();
User user = new User(modelAttributes.get("userId"), modelAttributes.get("password"), modelAttributes.get("name"), modelAttributes.get("email"));
DataBase.addUser(user);
// String location = request.getHeaders().get("Host") + "/index.html";
HttpResponseUtils.response302Header(dos, "/index.html", log);
}
// HttpResponseUtils 로직
public static void response302Header(DataOutputStream dos, String location, Logger log) {
try {
dos.writeBytes("HTTP/1.1 302 Found \r\n");
dos.writeBytes("Location: " + location + "\r\n");
dos.writeBytes("\r\n");
} catch (IOException e) {
log.error(e.getMessage());
}
}
- SignupController에 위치를 생성하고 리다이렉트할 URL을 적어둔 부분이 좀 문제가 있었는데 먼저 리다이렉트 위치를 localhost:8080/index.html로 설정했습니다. 즉, 응답은 Location: localhost:8080/index.html이었습니다. 문제는 리다이렉트가 그렇게 되지 않고 심지어 서버에서 소켓 오류까지 발생했다는 것입니다. 정말 이유를 모르겠습니다.
- 그래서 위치를 /index.html로 변경했을 때 리다이렉션도 정상적으로 실행되고 모든 코드도 정상적으로 실행되었습니다. 네트워크…. IO…. 정말 모르겠습니다.