Java로 HTTP 서버 만들기 — 커맨드 패턴으로 서버와 비즈니스 로직 분리하기

왜 분리가 필요한가?

이전 버전의 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;
}

이름에서 눈치챌 수 있듯, HttpServletHTTP + 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을 던진다
  • PageNotFoundExceptionnotFoundErrorServlet이, 그 외 예외는 internalErrorServlet이 처리한다

notFoundErrorServletinternalErrorServlet은 기본값이 제공되지만, 세터를 통해 언제든 교체할 수 있다. 유연성과 기본 동작을 동시에 갖춘 설계다

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의 결정적 차이

구분v4v5
비즈니스 로직 위치HttpRequestHandler 내부was.v5.servlet 패키지
HttpRequestHandler 재사용불가(로직이 뒤섞임)가능(공용 패키지)
새 기능 추가 방법핸들러 직접 수정HttpServlet 구현 후 등록
OCP 준수XO

커맨드 패턴 덕분에 얻은 것은 단순한 확장성만이 아니다. 서버 코드(was.httpserver)와 서비스 코드(was.v5.servlet)의 완전한 분리
새 HTTP 프로젝트를 시작한다면?

  • was.httpserver 패키지를 그대로 복사(혹은 라이브러리로 추가)한다
  • 필요한 서블릿을 HttpServlet 인터페이로 구현한다
  • main()에서 ServletManager에 등록하고 HttpServer를 시작한다

서버 코드는 단 한 줄도 건드리지 않는다. 이것은 좋은 추상화가 주는 힘이다

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