1. 시작하며: if-else 지옥에서 벗어나기
이전 포스팅에서 우리는 수많은 API 요청을 if-else문으로 처리하는 것의 한계를 깨닫고, 프론트 컨트롤러(Front Controller) 패턴을 도입하기로 결정했습니다. 이번 시간에는 단 하나의 문지기 서블릿(DispatcherServlet)이 리플렉션(Reflection)을 무기로 모든 요청을 알맞은 컨트롤러에 동적으로 분배하는 마법을 직접 코드로 구현해 보겠습니다.
2. 아키텍처 설계 및 HandlerMapping (매핑 사전) 만들기
가장 먼저 프론트 컨트롤러인 DispatcherServlet 클래스를 만듭니다. 이 문지기는 클라이언트가 요청한 URL(예: /user)을 보고, 실제 메모리에 있는 어떤 자바 클래스(예: org.example.controller.UserController)를 실행해야 할지 알아야 합니다. 이를 위해 매핑 사전(Map)을 정의합니다.
public class DispatcherServlet {
// 스프링의 HandlerMapping과 같은 역할을 하는 매핑 사전
private static final Map<String, String> handlerMapping = new HashMap<>();
// 정적 초기화 블록 (Static Initialization Block)
static {
handlerMapping.put("user", "org.example.controller.UserController");
}
// ... servlet 메서드 구현 (아래 참고)
}
서버에 1만 명의 접속자가 몰릴 때마다 매번 사전에
put을 한다면 엄청난 리소스 낭비입니다. static {} 블록을 사용하면 톰캣(WAS)이 켜지면서 이 클래스가 메모리에 올라갈 때 단 한 번만 초기화됩니다. 마치 식당 오픈 전에 메뉴판을 한 번만 걸어두는 것과 같은 이치이며, 실제 스프링 프레임워크 내부에서도 매우 빈번하게 사용되는 기법입니다.
3. 타겟 컨트롤러 생성 및 리턴 타입의 분리
테스트를 위해 간단한 UserController를 만듭니다. 여기서 핵심은 컨트롤러가 HttpResponse 객체를 직접 만지는 것이 아니라, 순수한 데이터(String)만 반환하도록 역할을 철저히 분리(Separation of Concerns)하는 것입니다.
package org.example.controller;
public class UserController {
// 뷰(View)의 이름이나, 클라이언트에게 보낼 순수 데이터를 반환합니다.
public String login() {
return "Login Success!";
}
}
4. 동적 라우팅 구현 (리플렉션의 활용)
이제 DispatcherServlet의 핵심 로직을 구현합니다. 파싱된 URI를 쪼개어 매핑 사전에서 클래스를 찾고, 리플렉션으로 동적 실행을 합니다.
public static HttpResponse servlet(HttpRequest request) throws Exception {
HttpResponse response = new HttpResponse();
response.setVersion(request.getVersion());
// 1. URI 분석 (예: /user/login)
String[] tokens = request.getUri().split("/");
if (tokens.length < 3) return null; // 잘못된 URL 방어
String prefix = tokens[1]; // "user"
String methodName = tokens[2]; // "login"
// 2. 매핑 사전에서 패키지 풀 네임 찾기
String className = handlerMapping.get(prefix);
// 3. 리플렉션(Reflection)을 이용한 동적 실행!
Class<?> clazz = Class.forName(className); // 설계도 훔쳐오기
Object controllerInstance = clazz.getDeclaredConstructor().newInstance(); // 인스턴스 생성
Method method = clazz.getMethod(methodName); // 메서드 찾기
// 4. 실행 후 반환된 String("Login Success!")을 응답 바디에 담기
String resultBody = (String) method.invoke(controllerInstance);
response.setBody(resultBody);
// 5. HTTP 상태 코드 기본 세팅
response.setStatus_code("200");
response.setReason_phrase("OK");
return response;
}
처음에는
Class.forName("user")처럼 입력하면 될 줄 알았으나 에러가 발생했습니다. 리플렉션으로 클래스를 로드할 때는 반드시 org.example.controller.UserController 처럼 패키지 경로가 포함된 풀 네임(FQN)을 명시해야 함을 뼈저리게 배웠습니다.
5. HTTP 응답(Response) 객체 설계와 생성
동적 라우팅을 통해 컨트롤러를 실행하고 나면, 그 결과를 담아 브라우저로 쏴줄 데이터 바구니가 필요합니다. HTTP 응답 규격(Status Line, Header, Body)을 명확하게 매핑할 수 있도록 다음과 같이 HttpResponse 객체를 설계하고 매인 로직에서 생성해 주었습니다.
package org.example.dto;
import java.util.HashMap;
import java.util.Map;
public class HttpResponse {
private String version; // HTTP/1.1 등
private String status_code; // 200, 404 등
private String reason_phrase; // OK, Not Found 등
// 다중 헤더 관리를 위한 Map 구조 데이터 바구니
private Map<String, String> headers = new HashMap<>();
private String body; // 변환된 데이터 본문
public String getVersion() { return version; }
public void setVersion(String version) { this.version = version; }
public String getStatus_code() { return status_code; }
public void setStatus_code(String status_code) { this.status_code = status_code; }
public String getReason_phrase() { return reason_phrase; }
public void setReason_phrase(String reason_phrase) { this.reason_phrase = reason_phrase; }
public Map<String, String> getHeaders() { return headers; }
public void addHeader(String key, String value) { this.headers.put(key, value); }
public String getBody() { return body; }
public void setBody(String body) { this.body = body; }
}
6. HTTP 응답(Response) 포맷 직접 조립하기
컨트롤러에서 데이터 처리가 끝났다고 해서 out.println(response)처럼 자바 객체를 브라우저에 그대로 던지면 안 됩니다. 브라우저는 자바의 주소값을 이해하지 못하며, 오직 HTTP 규약(Start Line + Header + Empty Line + Body)에 맞는 순수 텍스트 문자열만 읽을 수 있기 때문입니다.
따라서 HttpResponse 객체 내부에 스스로의 메타데이터를 HTTP 텍스트 규격 프로토콜에 완벽하게 맞춰 출력 스트림으로 밀어 넣어주는 send() 메서드를 구현했습니다.
// HttpResponse.java 내부
public void send(PrintWriter out) {
// 1. Status Line 조립 (예: HTTP/1.1 200 OK)
out.print(this.version + " " + this.status_code + " " + this.reason_phrase + "\r\n");
// 2. Response Headers 데이터 생성 (한글 깨짐 방지 및 Body 길이 세팅)
out.print("Content-Type: text/plain;charset=UTF-8\r\n");
if (this.body != null) {
out.print("Content-Length: " + this.body.getBytes().length + "\r\n");
}
// 3. 헤더와 바디를 구분하는 빈 줄 (CRLF) 필수!
out.print("\r\n");
// 4. Message Body 발사
if (this.body != null) {
out.print(this.body);
}
out.flush(); // 스트림에 고인 데이터를 브라우저로 완전히 밀어내기!
}
이제 마지막으로 점원인 RequestHandler가 이 기능을 이어받아 response.send(out);을 단 한 줄 호출해 주면, 서버 내부에서 동적으로 뜯고 맛본 비즈니스 데이터 결과물이 프로토콜 텍스트로 탈바꿈하여 정상 송출되는 전체 통신 사이클이 비로소 완성됩니다.
7. 브라우저 개발자 도구(F12)로 결과 확인하기
서버를 띄우고 크롬 주소창에 http://localhost:8080/user/login을 입력하니, 화면에 "Login Success!"가 아주 예쁘게 출력되었습니다.

하지만 여기서 끝낼 수 없죠! 개발자 도구(F12)의 Network 탭을 열어 우리가 조립한 패킷이 잘 도착했는지 뜯어보았습니다.

8. 마무리하며
스프링 프레임워크가 뒤에서 조용히 처리해 주던 DispatcherServlet의 동적 라우팅과, @ResponseBody가 해주는 HTTP 규약 변환 과정을 순수 자바로 밑바닥부터 구현해 냈습니다. 이로써 스프링 MVC 아키텍처의 핵심 심장부를 완벽히 이해하게 되었습니다.
지금까지는 파라미터가 없는 단순한 GET 요청을 처리했습니다. 다음 포스팅에서는 드디어 외부 라이브러리인 Jackson을 도입하여, 클라이언트가 보내는 POST 요청의 JSON 바디 데이터를 자바 객체(DTO)로 변환(데이터 바인딩)하는 과정을 다뤄보겠습니다!
'정리 > WAS' 카테고리의 다른 글
| 순수 Java로 WAS 구현 (9) - 프론트 컨트롤러에 Jackson 도입하기: 역직렬화와 리플렉션의 비밀 (0) | 2026.05.16 |
|---|---|
| 순수 Java로 WAS 구현 (7) - 톰캣의 정체와 프론트 컨트롤러(Front Controller)의 탄생 (0) | 2026.05.15 |
| 순수 Java로 WAS 구현 (6) - 프레임워크의 마법, 리플렉션(Reflection) 완벽 이해 (0) | 2026.05.15 |
| 순수 Java로 WAS 구현 (5) - HTTP 요청 파서(Parser) 구현과 3가지 트러블슈팅 (0) | 2026.05.15 |
| 순수 Java로 WAS 구현 (4) - 파싱(Parsing)을 위한 HTTP 메시지 구조 완벽 분석 (1) | 2026.05.14 |