Java로 HTTP 서버 만들기 — 스레드 풀로 동시 요청 처리

1편에서 만든 HttpServerV1은 요청을 순차적으로 처리했다. 첫 번째 요청이 5초 걸리면 두 번째 요청은 5초를 기다려야 했다. 실제 서비스라면 절대 용납할 수 없는 구조다. 해법은 단순하다. 요청마다 별도의 스레드를 할당하면 된다

구조 변경

V1의 구조는 메인 스레드가 연결 수락과 요청 처리를 모두 담당했다

main 스레드: accept() → process() → accept() → process() → ...

V2의 구조는 역할을 명확히 분리한다

main 스레드:  accept() → accept() → accept() → ...
                ↓          ↓          ↓
pool 스레드1: process()
pool 스레드2:           process()
pool 스레드3:                       process()

메인 스레드는 연결만 수락하고, 실제 처리는 스레드 풀의 스레드들이 병렬로 담당한다

HttpRequestHandlerV2 – 요청 처리 단위

public class HttpRequestHandlerV2 implements Runnable {
    private final Socket socket;

    public HttpRequestHandlerV2(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);

            log("HTTP 응답 생성중...");
            sleep(5000);
            responseToClient(writer);
            log("HTTP 응답 전달 완료");
        }
    }

    private 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 void responseToClient(PrintWriter writer) {
        String body = "<h1>Hello World</h1>";
        int length = body.getBytes(UTF_8).length;

        StringBuilder sb = new StringBuilder();
        sb.append("HTTP/1.1 200 OK\r\n");
        sb.append("Content-Type: text/html\r\n");
        sb.append("Content-Length: ").append(length).append("\r\n");
        sb.append("\r\n");
        sb.append(body);

        log("HTTP 응답 정보 출력");
        System.out.println(sb);

        writer.println(sb);
        writer.flush();
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

V1의 process()와 거의 동일하다. 달라진 것은 클래스가 Runnable을 구현해 스레드 풀에서 실행될 수 있는 작업 단위가 됐다는 점이다

run()에서 예외를 직접 잡는 이유: Runnable.run()은 체크 예외를 던질 수 없다. 또한 스레드 풀의 스레드에서 예외가 전파되면 해당 스레드가 종료될 수 있다. 스레드를 보호하기 위해 run() 내부에서 Exception을 잡아 로그를 남기고 조용히 처리한다

HttpServerV2 — 스레드 풀 도입

public class HttpServerV2 {
    private final ExecutorService es = Executors.newFixedThreadPool(10);
    private final int port;

    public HttpServerV2(int port) {
        this.port = port;
    }

    public void start() throws IOException {
        ServerSocket serverSocket = new ServerSocket(port);
        log("서버 시작 port: " + port);

        while (true) {
            Socket socket = serverSocket.accept();
            es.submit(new HttpRequestHandlerV2(socket)); // 스레드 풀에 작업 제출
        }
    }
}
public class ServerMainV2 {
    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {
        HttpServerV2 server = new HttpServerV2(PORT);
        server.start();
    }
}

V1과 비교해 start() 내부에서 바뀐 것은 단 한 줄이다

// V1
process(socket);  // 메인 스레드가 직접 처리 → 블로킹

// V2
es.submit(new HttpRequestHandlerV2(socket));  // 스레드 풀에 위임 → 즉시 반환

es.submit()은 작업을 큐에 넣고 즉시 반환한다. 스레드 풀의 유휴 스레드가 꺼내서 실행한다. 메인 스레드는 즉시 다음 accept()로 돌아간다

스레드 풀 크기 선택

여기서는 newFixedThreadPool(10)으로 최대 10개의 스레드를 사용한다. 동시에 10개의 요청을 처리할 수 있다는 뜻이다. 실무에서는 보통 수백 개 수준으로 설정한다. 스레드 수를 너무 적게 잡으면 대기 요청이 쌓이고, 너무 크게 잡으면 컨텍스트 스위칭 비용과 메모리 사용량이 증가한다. 적절한 값은 CPU 코어 수, 작업의 I/O 비율, 예상 동시 요청 수를 종합적으로 고려해 결정한다

실행 결과 확인

[ main] 서버 시작 port: 12345
[pool-1-thread-1] HTTP 요청 정보 출력
GET / HTTP/1.1
Host: localhost:12345
[pool-1-thread-1] HTTP 응답 생성중...
[pool-1-thread-3] HTTP 요청 정보 출력   ← thread-1이 완료되기 전에 thread-3 시작
GET / HTTP/1.1
Host: localhost:12345
[pool-1-thread-3] HTTP 응답 생성중...
[pool-1-thread-1] HTTP 응답 전달 완료
[pool-1-thread-2] favicon 요청
[pool-1-thread-4] favicon 요청
[pool-1-thread-3] HTTP 응답 전달 완료
  • pool-1-thread-1이 응답을 완료하기 전에 pool-1-thread-3이 이미 요청을 처리하기 시작했다. 두 요청이 병렬로 처리된다는 증거다. V1이라면 thread-1이 완료된 후에야 다음 요청이 처리됐을 것이다.
  • thread-2thread-4가 파비콘 요청을 처리하느라 중간에 끼어들었다. 브라우저가 페이지 요청과 별개로 파비콘을 자동 요청하기 때문이다

두 버전 비교 요약

V1V2
처리 방식순차(단일 스레드)병렬(스레드 풀)
2개 요청 소요 시간10초~5초
스레드 수1개최대 10개
구조accept → process → acceptaccept → submit → accept

HTTP 서버가 어떻게 동시 요청을 처리하는지 이해했다. 실제로 Tomcat, Jetty 같은 서블릿 컨테이너도 이 구조를 기반으로 동작한다. 물론 훨씬 정교하게 최적화되어 있지만, 핵심 아이디어는 같다

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