2편까지 만든 서버는 어떤 URL로 요청이 오든 항상 같은 HTML을 응답했다. 실제 웹 서버는 URL 경로에 따라 다른 화면을 보여준다. 이번에는 /, /site1, /site2, /search 각 경로에 맞는 응답을 돌려주는 URL 라우팅을 구현한다
구현할 라우팅 스펙
| GET / | 홈 화면 (site1, site2, 검색 링크 포함) |
| GET /site1 | site1 페이지 |
| GET /site2 | site2 페이지 |
| 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();
}
}
HttpServerV3과 ServerMainV3은 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` 형식이 쿼리 스트링이다. 여러 파라미터를 전달할 때는 `&`로 구분한다.
여기서는 indexOf와 substring으로 직접 파싱한다
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이 오면startIndex는q=의 위치,endIndex는hello뒤의 공백 위치가 된다.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 라우팅을 깔끔하게 정리한다