Java로 HTTP 서버 만들기 — 요청과 응답을 객체로 구조화하기

왜 객체로 만드는가

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() 등 – 기능마다 추가

출처 – 김영한 님의 강의 중 김영한의 실전 자바 – 고급 2편, I/O, 네트워크, 리플렉션