Java로 HTTP 서버 만들기 — URL 라우팅과 쿼리 스트링 처리

2편까지 만든 서버는 어떤 URL로 요청이 오든 항상 같은 HTML을 응답했다. 실제 웹 서버는 URL 경로에 따라 다른 화면을 보여준다. 이번에는 /, /site1, /site2, /search 각 경로에 맞는 응답을 돌려주는 URL 라우팅을 구현한다

구현할 라우팅 스펙

GET /홈 화면 (site1, site2, 검색 링크 포함)
GET /site1site1 페이지
GET /site2site2 페이지
GET /search?q={검색어}검색 결과 화면
그 외404 Not Found

HttpRequestHandlerV3

HttpServerV2의 구조는 그대로 유지한다. 달라지는 것은 process() 내부의 라우팅 로직뿐이다

public class HttpRequestHandlerV3 implements Runnable {
    private final Socket socket;

    public HttpRequestHandlerV3(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        try {
            process();
        } catch (Exception e) {
            log(e);
        }
    }

    private void process() throws IOException {
        try (socket;
             BufferedReader reader = new BufferedReader(
                     new InputStreamReader(socket.getInputStream(), UTF_8));
             PrintWriter writer = new PrintWriter(socket.getOutputStream(), false, UTF_8)) {

            String requestString = requestToString(reader);
            if (requestString.contains("/favicon.ico")) {
                log("favicon 요청");
                return;
            }

            log("HTTP 요청 정보 출력");
            System.out.println(requestString);

            // URL 라우팅
            if (requestString.startsWith("GET /site1")) {
                site1(writer);
            } else if (requestString.startsWith("GET /site2")) {
                site2(writer);
            } else if (requestString.startsWith("GET /search")) {
                search(writer, requestString);
            } else if (requestString.startsWith("GET / ")) { // '/' 다음 공백 필수!
                home(writer);
            } else {
                notFound(writer);
            }

            log("HTTP 응답 전달 완료");
        }
    }

    private static String requestToString(BufferedReader reader) throws IOException {
        StringBuilder sb = new StringBuilder();
        String line;
        while ((line = reader.readLine()) != null) {
            if (line.isEmpty()) break;
            sb.append(line).append("\n");
        }
        return sb.toString();
    }

    private static void home(PrintWriter writer) {
        writer.println("HTTP/1.1 200 OK");
        writer.println("Content-Type: text/html; charset=UTF-8");
        writer.println();
        writer.println("<h1>home</h1>");
        writer.println("<ul>");
        writer.println("<li><a href='/site1'>site1</a></li>");
        writer.println("<li><a href='/site2'>site2</a></li>");
        writer.println("<li><a href='/search?q=hello'>검색</a></li>");
        writer.println("</ul>");
        writer.flush();
    }

    private static void site1(PrintWriter writer) {
        writer.println("HTTP/1.1 200 OK");
        writer.println("Content-Type: text/html; charset=UTF-8");
        writer.println();
        writer.println("<h1>site1</h1>");
        writer.flush();
    }

    private static void site2(PrintWriter writer) {
        writer.println("HTTP/1.1 200 OK");
        writer.println("Content-Type: text/html; charset=UTF-8");
        writer.println();
        writer.println("<h1>site2</h1>");
        writer.flush();
    }

    private static void notFound(PrintWriter writer) {
        writer.println("HTTP/1.1 404 Not Found");
        writer.println("Content-Type: text/html; charset=UTF-8");
        writer.println();
        writer.println("<h1>404 페이지를 찾을 수 없습니다.</h1>");
        writer.flush();
    }

    private static void search(PrintWriter writer, String requestString) {
        // "GET /search?q=hello HTTP/1.1" 에서 검색어 추출
        int startIndex = requestString.indexOf("q=");
        int endIndex = requestString.indexOf(" ", startIndex + 2);
        String query = requestString.substring(startIndex + 2, endIndex);
        String decoded = URLDecoder.decode(query, UTF_8);

        writer.println("HTTP/1.1 200 OK");
        writer.println("Content-Type: text/html; charset=UTF-8");
        writer.println();
        writer.println("<h1>Search</h1>");
        writer.println("<ul>");
        writer.println("<li>query: " + query + "</li>");
        writer.println("<li>decode: " + decoded + "</li>");
        writer.println("</ul>");
        writer.flush();
    }
}

HttpServerV3ServerMainV3은 V2와 구조가 동일하다. HttpRequestHandlerV3을 사용하도록 교체하면 된다

설계 포인트

“GET /” – 공백이 필수인 이유

홈 경로로 매칭할 때 startsWith("GET / ")처럼 슬래시 뒤로 공백을 반드시 붙여야 한다

// 잘못된 코드
requestString.startsWith("GET /")
  • 이렇게 하면 /site1, /site2, /search 요청도 모두 홈으로 매칭되어 버린다. startsWith는 접두사 비교이므로 "GET /" 이후에 무엇이 오든 참이 된다. "GET /" 처럼 공백까지 포함해야 정확히 루트 경로만 매칭된다
  • 그리고 home() 분기를 if-else 체인에서 반드시 마지막에 와야 한다. 앞에 오면 /site1 요청이 home()으로 처리될 수 있다

쿼리 스트링(Query String) 파싱

브라우저에서 “검색” 링크를 클릭하면 서버에 다음과 같은 요청이 온다

GET /search?q=hello HTTP/1.1

`?` 이후의 `key=value` 형식이 쿼리 스트링이다. 여러 파라미터를 전달할 때는 `&`로 구분한다.

여기서는 indexOfsubstring으로 직접 파싱한다

int startIndex = requestString.indexOf("q=");          // 'q=' 위치
int endIndex   = requestString.indexOf(" ", startIndex + 2); // 'q=' 이후 첫 공백
String query   = requestString.substring(startIndex + 2, endIndex); // 검색어 추출
  • 실행 예시에서 GET /search?q=hello HTTP/1.1이 오면 startIndexq=의 위치, endIndexhello 뒤의 공백 위치가 된다. substring(startIndex + 2, endIndex)hello를 돌려준다

Content-Length 생략에 대해

  • 원칙적으로는 HTTP 응답 헤더에는 바디의 바이트 크기를 Content-Length로 명시해야 한다. 여기서는 생략했다. 대부분의 현대 브라우저는 연결이 닫히는 시점을 바디의 끝으로 인식하므로 생략해도 동작하지만, 실제 서비스 코드에서는 반드시 명시해야 한다

URL 디코딩 – URLDecoder.decode()

  • 검색어에 한글을 입력하면 URL에 %EA%B0%80처럼 퍼센트 인코딩된 형태로 전달된다. URL은 ASCII 문자만 허용하므로, 비 ASCII 문자는 UTF-8 바이트를 퍼센트 인코딩으로 변환해서 전송된다
  • 한글 “가” → UTF-8 바이트 → %EA%B0%80
  • URLDecoder.decode(query, UTF_8)가 이를 원래 문자열로 복원한다. 그래서 응답에 두 가지를 출력한다
<li>query: %EA%B0%80</li>      ← 인코딩된 원본
<li>decode: 가</li>             ← 디코딩된 결과
  • URL 인코딩은 1990년대 초 인터넷이 설계될 당시 대부분의 시스템이 ASCII만 처리할 수 있었기 때문에 생겨난 규약이다. 시대가 바뀌어도 하위 호환성 때문에 이 방식이 유지되고 있다

실행 결과

[ main] 서버 시작 port: 12345
[pool-1-thread-1] HTTP 요청 정보 출력
GET / HTTP/1.1
Host: localhost:12345
[pool-1-thread-1] HTTP 응답 전달 완료

[pool-1-thread-4] HTTP 요청 정보 출력
GET /site1 HTTP/1.1
[pool-1-thread-4] HTTP 응답 전달 완료

[pool-1-thread-8] HTTP 요청 정보 출력
GET /site2 HTTP/1.1
[pool-1-thread-8] HTTP 응답 전달 완료

[pool-1-thread-2] HTTP 요청 정보 출력
GET /search?q=hello HTTP/1.1
[pool-1-thread-2] HTTP 응답 전달 완료

각 URL 요청이 의도한 핸들러 메서드로 라우팅되는 것을 확인할 수 있다

이제 보이는 문제

  • 기능은 완성됐다. 그런데 process() 내부를 보면 if-else가 다시 늘어서 있다. 새 URL을 추가하려면 이 블록을 열어야 한다. 채팅 서버에서 봤던 그 문제가 여기서도 반복된다. 다음 편에서는 HTTP 요청과 응답을 객체로 추상화하고, 커맨드 패턴을 적용해 URL 라우팅을 깔끔하게 정리한다

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