왜 객체로 만드는가
HTTP 메시지는 명확한 규칙을 가진다
GET /search?q=hello HTTP/1.1 ← 시작 라인 (메서드, 경로, 버전)
Host: localhost:12345 ← 헤더 (key: value)
← 빈 라인 (헤더 끝)
← 메시지 바디
이 규칙이 존재한다는 것은 구조화할 수 있다는 의미다. 문자열을 매번 직접 파싱하는 대신, HttpRequest, HttpResponse 객체로 캡슐화하면 코드의 복잡도가 극적으로 줄어든다
HttpRequest – 요청 파싱
public class HttpRequest {
private String method;
private String path;
private final Map<String, String> queryParameters = new HashMap<>();
private final Map<String, String> headers = new HashMap<>();
public HttpRequest(BufferedReader reader) throws IOException {
parseRequestLine(reader);
parseHeaders(reader);
// 메시지 바디는 이후 편에서 처리
}
...
}
생성자에서 BufferedReader를 받아 시작 라인 → 헤더 순서로 파싱한다
시작 라인 파싱
GET /search?q=hello HTTP/1.1 ↑ ↑ ↑ method path+query version
private void parseRequestLine(BufferedReader reader) throws IOException {
String requestLine = reader.readLine();
if (requestLine == null) {
throw new IOException("EOF: No request line received");
}
String[] parts = requestLine.split(" ");
if (parts.length != 3) {
throw new IOException("Invalid request line: " + requestLine);
}
method = parts[0];
String[] pathParts = parts[1].split("\\?"); // ? 는 정규식 문자이므로 이스케이프 필요
path = pathParts[0];
if (pathParts.length > 1) {
parseQueryParameters(pathParts[1]);
}
}
null 체크가 필요한 이유
- 일부 브라우저(크롬 등)는 성능 최적화를 위해 TCP 연결을 미리 여러 개 맺어두는데, 사용하지 않은 연결을 그냥 종료해버리는 경우가 있다. 이때 데이터 없이 연결이 끊기므로
readLine()이null을 반환한다
쿼리 파라미터 파싱
private void parseQueryParameters(String queryString) {
for (String param : queryString.split("&")) {
String[] keyValue = param.split("=");
String key = URLDecoder.decode(keyValue[0], UTF_8);
String value = keyValue.length > 1
? URLDecoder.decode(keyValue[1], UTF_8)
: ""; // q= 처럼 값이 없는 경우 빈 문자열 처리
queryParameters.put(key, value);
}
}
퍼센트 디코딩을 여기서 처리한다는 점이 핵심이다
요청: /search?q=%EA%B0%80
저장: queryParameters = { "q" : "가" }
HttpRequest를 사용하는 쪽에서는 인코딩/디코딩을 전혀 신경 쓰지 않아도 된다. 한글이든 특수 문자든 꺼내면 이미 디코딩된 값이 나온다
헤더 파싱
private void parseHeaders(BufferedReader reader) throws IOException {
String line;
while (!(line = reader.readLine()).isEmpty()) {
String[] headerParts = line.split(":");
headers.put(headerParts[0].trim(), headerParts[1].trim()); // 앞뒤 공백 제거
}
}
HTTP 명세에서 헤더의 끝은 빈 라인(CRLF)으로 구분된다. 빈 라인을 만나면 반복을 종료한다
HttpResponse – 응답 구조화
package was.httpserver;
public class HttpResponse {
private final PrintWriter writer;
private int statusCode = 200;
private final StringBuilder bodyBuilder = new StringBuilder();
private String contentType = "text/html; charset=UTF-8";
public HttpResponse(PrintWriter writer) {
this.writer = writer;
}
public void setStatusCode(int statusCode) { this.statusCode = statusCode; }
public void setContentType(String contentType) { this.contentType = contentType; }
public void writeBody(String body) {
bodyBuilder.append(body);
}
public void flush() {
int contentLength = bodyBuilder.toString().getBytes(UTF_8).length;
writer.println("HTTP/1.1 " + statusCode + " " + getReasonPhrase(statusCode));
writer.println("Content-Type: " + contentType);
writer.println("Content-Length: " + contentLength);
writer.println(); // 헤더와 바디 사이 빈 라인
writer.println(bodyBuilder);
writer.flush(); // 실제 네트워크 전송
}
private String getReasonPhrase(int statusCode) {
switch (statusCode) {
case 200: return "OK";
case 404: return "Not Found";
case 500: return "Internal Server Error";
default: return "Unknown Status";
}
}
}
writeBody()로 내용을 쌓고, 마지막에 flush()를 호출하면 Content-Length를 자동 계산해 완전한 HTTP 응답 메시지로 전송한다
flush() 호출은 필수다. 빠뜨리면 클라이언트에 응답이 전달되지 않는다
HttpRequestHandlerV4 – 얼마나 깔끔해졌나
객체 도입 전후를 비교해보자
Before
// 경로 파싱, 디코딩, Content-Length 계산... 매번 반복
String requestLine = reader.readLine();
String[] parts = requestLine.split(" ");
String path = parts[1].split("\\?")[0];
writer.println("HTTP/1.1 200 OK");
writer.println("Content-Type: text/html; charset=UTF-8");
writer.println("Content-Length: " + body.getBytes(UTF_8).length);
writer.println();
writer.println(body);
After
HttpRequest request = new HttpRequest(reader);
HttpResponse response = new HttpResponse(writer);
if (request.getPath().equals("/search")) {
search(request, response);
}
response.flush();
실제 핸들러 코드 전체를 보자
private void process(Socket socket) throws IOException {
try (socket;
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF_8));
PrintWriter writer = new PrintWriter(socket.getOutputStream(), false, UTF_8)) {
HttpRequest request = new HttpRequest(reader);
HttpResponse response = new HttpResponse(writer);
if (request.getPath().equals("/favicon.ico")) {
log("favicon 요청");
return;
}
log("HTTP 요청 정보 출력");
System.out.println(request);
if (request.getPath().equals("/")) home(response);
else if (request.getPath().equals("/site1")) site1(response);
else if (request.getPath().equals("/site2")) site2(response);
else if (request.getPath().equals("/search")) search(request, response);
else notFound(response);
response.flush();
}
}
private static void home(HttpResponse response) {
response.writeBody("<h1>home</h1>");
response.writeBody("<ul>");
response.writeBody("<li><a href='/site1'>site1</a></li>");
response.writeBody("<li><a href='/site2'>site2</a></li>");
response.writeBody("<li><a href='/search?q=hello'>검색</a></li>");
response.writeBody("</ul>");
}
private static void search(HttpRequest request, HttpResponse response) {
String query = request.getParameter("q"); // 디코딩 완료된 값이 바로 나온다
response.writeBody("<h1>Search</h1>");
response.writeBody("<li>query: " + query + "</li>");
}
private static void notFound(HttpResponse response) {
response.setStatusCode(404);
response.writeBody("<h1>404 페이지를 찾을 수 없습니다.</h1>");
}
search()에서 퍼센트 디코딩 코드가 완전히 사라졌다. HttpRequest가 내부에서 처리했기 때문이다
정리
이번 리팩토링의 핵심은 단순하다. HTTP 메시지의 규칙을 책임으로 옮긴다
| 역할 | 담당 객체 |
| 요청 파싱 (메서드, 경로, 쿼리, 헤더, 디코딩) | HttpRequest |
| 응답 구성 (상태 코드, 헤더, Content-Length, 전송) | HttpResponse |
| 비즈니스 로직 | home(), search(), notFound() … |
전체 코드는 이제 두 영역으로 명확히 나뉜다
- HTTP 인프라:
HttpServer,HttpRequestHandler,HttpRequest,HttpResponse– 재사용 가능 - 서비스 로직:
home(),site1(),search()등 – 기능마다 추가