왜 분리가 필요한가?
이전 버전의 HttpRequestHandler를 보면 문제가 바로 보인다
// v4의 HttpRequestHandler — 비즈니스 로직이 뒤섞여 있다
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 if (request.getPath().equals("/")) {
home(response);
} else {
notFound(response);
}
home(), site1(), site2(), search() 같은 서비스 로직이 서버 코드 안에 직접 박혀 있다. 다른 프로젝트에서 이 서버를 재사용하려면? 이 로직들을 전부 갈아엎어야 한다. 재사용이 불가능하다.
해결책은 명확하다. 서버가 해야 할 일(소켓 수락, HTTP 파싱, 응답 전송)과 서비스가 해야 할 일(홈 화면, 검색, 404 처리)을 인터페이스로 격리하면 된다. 이것이 커맨드 패턴의 핵심이다
목표 패키지 구조
was/
├── httpserver/ ← 공용 서버 모듈 (변경 없이 재사용)
│ ├── HttpRequest.java
│ ├── HttpResponse.java
│ ├── HttpServlet.java ← 커맨드 인터페이스
│ ├── HttpRequestHandler.java
│ ├── HttpServer.java
│ ├── ServletManager.java ← 커맨드 관리자 (인보커)
│ ├── PageNotFoundException.java
│ └── servlet/ ← 공용 서블릿
│ ├── NotFoundServlet.java
│ ├── InternalErrorServlet.java
│ └── DiscardServlet.java
└── v5/
├── ServerMainV5.java ← 조립 지점 (진입점)
└── servlet/ ← 이 프로젝트만의 비즈니스 로직
├── HomeServlet.java
├── Site1Servlet.java
├── Site2Servlet.java
└── SearchServlet.java
was.httpserver 패키지는 라이브러리처럼 동작한다. 프로젝트가 바뀌어도 이 패키지는 손댈 필요가 없다
커맨드 인터페이스 – HttpServlet
커맨드 패턴의 시작은 커맨드를 표현하는 인터페이스를 정의하는 것이다
package was.httpserver;
import java.io.IOException;
public interface HttpServlet {
void service(HttpRequest request, HttpResponse response) throws IOException;
}
이름에서 눈치챌 수 있듯, HttpServlet은 HTTP + Server + Applet의 줄임말이다. “HTTP 서버에서 실행되는 작은 자바 프로그램”이라는 의미로, 실제 Jakarta EE의 HttpServlet과 같은 설계 철학을 직접 구현해보는 것이다.service() 메서드가 받는 두 파라미터로 모든 것이 해결 된다
- HttpRequest: 클라이언트가 보낸 모든 정보(경로, 쿼리 파라미터, 헤더)
- HttpResponse: 클라이언트에게 보낼 응답 구성
서비스 서블릿 구현 – was.v5.servlet
이 서블릿들은 이 프로젝트에서만 쓰이는 비즈니스 로직이다. was.v5.servlet 패키지에 둔다
// HomeServlet.java
public class HomeServlet implements HttpServlet {
@Override
public void service(HttpRequest request, 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>");
}
}
// SearchServlet.java
public class SearchServlet implements HttpServlet {
@Override
public void service(HttpRequest request, HttpResponse response) {
String query = request.getParameter("q");
response.writeBody("<h1>Search</h1>");
response.writeBody("<ul>");
response.writeBody("<li>query: " + query + "</li>");
response.writeBody("</ul>");
}
}
Site1Servlet, Site2Servlet도 같은 방식으로 구현한다. 각 서블릿은 자기가 담당하는 페이지 하나만 책임진다. 단일 책임 원칙(SRP)이 자연스럽게 지켜진다
공용 서블릿 – was.httpserver.servlet
에러 처리처럼 어느 프로젝트에서도 필요한 서블릿은 공용 패키지에 둔다
// NotFoundServlet.java — 404 처리
public class NotFoundServlet implements HttpServlet {
@Override
public void service(HttpRequest request, HttpResponse response) {
response.setStatusCode(404);
response.writeBody("<h1>404 페이지를 찾을 수 없습니다.</h1>");
}
}
// InternalErrorServlet.java — 500 처리
public class InternalErrorServlet implements HttpServlet {
@Override
public void service(HttpRequest request, HttpResponse response) {
response.setStatusCode(500);
response.writeBody("<h1>Internal Error</h1>");
}
}
// DiscardServlet.java — 요청을 조용히 무시
public class DiscardServlet implements HttpServlet {
@Override
public void service(HttpRequest request, HttpResponse response) {
// 아무것도 하지 않는다
}
}
DiscardServlet은 /favicon.ico처럼 처리할 필요 없는 요청을 버릴 때 쓴다. 아무 로직도 없는 이 서블릿이 꽤 유용하다.
그리고 404 상황을 명시적으로 표현하기 위한 예외도 추가한다
// PageNotFoundException.java
public class PageNotFoundException extends RuntimeException {
public PageNotFoundException(String message) {
super(message);
}
}
커맨드 관리자 – ServletManager
public class ServletManager {
private final Map<String, HttpServlet> servletMap = new HashMap<>();
private HttpServlet defaultServlet;
private HttpServlet notFoundErrorServlet = new NotFoundServlet();
private HttpServlet internalErrorServlet = new InternalErrorServlet();
public void add(String path, HttpServlet servlet) {
servletMap.put(path, servlet);
}
// defaultServlet, notFoundErrorServlet, internalErrorServlet 세터 생략
public void execute(HttpRequest request, HttpResponse response) throws IOException {
try {
HttpServlet servlet = servletMap.getOrDefault(request.getPath(), defaultServlet);
if (servlet == null) {
throw new PageNotFoundException("request url= " + request.getPath());
}
servlet.service(request, response);
} catch (PageNotFoundException e) {
e.printStackTrace();
notFoundErrorServlet.service(request, response);
} catch (Exception e) {
e.printStackTrace();
internalErrorServlet.service(request, response);
}
}
}
execute() 메서드의 흐름을 짚어보자
servletMap에서 요청 경로에 맞는 서블릿을 찾는다- 없으면 defaultServlet을 사용한다
- defaultServlet 마저 없으면
PageNotFoundException을 던진다 PageNotFoundException은notFoundErrorServlet이, 그 외 예외는internalErrorServlet이 처리한다
notFoundErrorServlet과 internalErrorServlet은 기본값이 제공되지만, 세터를 통해 언제든 교체할 수 있다. 유연성과 기본 동작을 동시에 갖춘 설계다
HttpRequestHandler 단순화
서블릿 매니저가 생기면서 핸들러의 역할이 극도로 단순해진다
public class HttpRequestHandler implements Runnable {
private final Socket socket;
private final ServletManager servletManager;
public HttpRequestHandler(Socket socket, ServletManager servletManager) {
this.socket = socket;
this.servletManager = servletManager;
}
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)) {
HttpRequest request = new HttpRequest(reader);
HttpResponse response = new HttpResponse(writer);
log("HTTP 요청: " + request);
servletManager.execute(request, response); // 나머지는 매니저에게 위임
response.flush();
log("HTTP 응답 완료");
}
}
}
HttpRequestHandler의 역할은 딱 두 가지다
HttpRequest,HttpResponse객체를 만든다servletManager.execute()에 넘기고 끝낸다
비즈니스 로직은 단 한 줄도 없다. 이제 클래스는 was.httpserver 패키지로 옮겨 공용으로 사용할 수 있다
HttpServer
public class HttpServer {
private final ExecutorService es = Executors.newFixedThreadPool(10);
private final int port;
private final ServletManager servletManager;
public HttpServer(int port, ServletManager servletManager) {
this.port = port;
this.servletManager = servletManager;
}
public void start() throws IOException {
ServerSocket serverSocket = new ServerSocket(port);
log("서버 시작 port: " + port);
while (true) {
Socket socket = serverSocket.accept();
es.submit(new HttpRequestHandler(socket, servletManager));
}
}
}
스레드 풀로 동시 요청을 처리하고, 소켓이 들어올 때마다 HttpRequestHandler를 제출한다. 이 클래스 역시 was.httpserver 패키지의 공용 코드다
조립 – ServerMainV5
public class ServerMainV5 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
ServletManager servletManager = new ServletManager();
servletManager.add("/", new HomeServlet());
servletManager.add("/site1", new Site1Servlet());
servletManager.add("/site2", new Site2Servlet());
servletManager.add("/search", new SearchServlet());
servletManager.add("/favicon.ico", new DiscardServlet());
HttpServer server = new HttpServer(PORT, servletManager);
server.start();
}
}
메인에서 하는 일 전부다. 서블릿을 만들고, 매니저에 등록하고, 서버에 넘긴다. v5의 비즈니스로 로직 전체가 이 조립 코드와 was.v5.servlet 패키지로 완전히 격리됐다.
v4와 v5의 결정적 차이
| 구분 | v4 | v5 |
| 비즈니스 로직 위치 | HttpRequestHandler 내부 | was.v5.servlet 패키지 |
HttpRequestHandler 재사용 | 불가(로직이 뒤섞임) | 가능(공용 패키지) |
| 새 기능 추가 방법 | 핸들러 직접 수정 | HttpServlet 구현 후 등록 |
| OCP 준수 | X | O |
커맨드 패턴 덕분에 얻은 것은 단순한 확장성만이 아니다. 서버 코드(was.httpserver)와 서비스 코드(was.v5.servlet)의 완전한 분리다
새 HTTP 프로젝트를 시작한다면?
was.httpserver패키지를 그대로 복사(혹은 라이브러리로 추가)한다- 필요한 서블릿을
HttpServlet인터페이로 구현한다 main()에서ServletManager에 등록하고HttpServer를 시작한다
서버 코드는 단 한 줄도 건드리지 않는다. 이것은 좋은 추상화가 주는 힘이다