본문 바로가기

소켓으로 HTTP 서버 만들기: 동시 처리와 keep-alive

ServerSocket으로 작은 HTTP 서버를 만들어 curl로 확인하고, 느린 요청이 다른 연결을 막지 않게 작업을 나누는 방법과 과부하 때 503으로 거절하는 방법, 한 연결에서 여러 요청을 처리하는 keep-alive의 조건을 배웁니다.

HTTP/1.1 요청과 응답 메시지의 구조에서 메시지의 모양을 익혔으니 이제 서버를 만들 차례입니다. HTTP 서버가 하는 일 자체는 단순합니다. 연결을 수락하고, 요청 메시지를 읽고, 응답 바이트를 쓰고, 연결을 닫거나 같은 연결로 올 다음 요청을 기다립니다. TCP 첫 연결과 다중 클라이언트 서버에서 만든 TCP 서버와 뼈대가 같습니다.

이 단순한 구조 안에는 수명이 다른 두 가지가 겹쳐 있습니다. 하나는 서버 전체의 수명입니다. 포트를 여는 ServerSocket과 작업을 실행하는 스레드들이 여기에 속하고, 서버가 끝날 때까지 살아 있습니다. 다른 하나는 연결의 수명입니다. accept()가 돌려준 Socket 하나와 그 위에서 처리되는 한 개 이상의 요청이 여기에 속합니다. 이 글은 가장 단순한 서버에서 출발해 연결 하나가 서버 전체를 붙잡는 문제를 직접 확인하고, 연결을 별도 작업으로 나누고, 동시에 처리할 양을 제한한 뒤, 한 연결에서 여러 요청을 처리하는 지속 연결까지 다룹니다.

한 번에 한 연결씩 처리하는 서버

첫 서버는 accept()로 연결을 받고, 그 연결을 끝까지 처리한 다음에야 다음 accept()로 돌아갑니다. 응답하기 전에 2초를 쉬게 해서, 데이터베이스 조회나 다른 서버 호출처럼 시간이 걸리는 처리를 흉내 냈습니다.

bad/SequentialHttpAcceptor.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

public final class SequentialHttpAcceptor {
    public static void main(String[] args) throws Exception {
        try (var listener = new ServerSocket(
                8080, 50, InetAddress.getLoopbackAddress())) {
            System.out.println("listening on http://localhost:8080");
            while (true) {
                try (Socket socket = listener.accept()) {
                    handle(socket);
                } catch (IOException error) {
                    System.out.println("connection failed: " + error.getMessage());
                }
            }
        }
    }

    static void handle(Socket socket) throws IOException, InterruptedException {
        var reader = new BufferedReader(new InputStreamReader(
                socket.getInputStream(), StandardCharsets.US_ASCII));
        String requestLine = reader.readLine();
        String line = requestLine;
        while (line != null && !line.isEmpty()) {  // 빈 줄까지 헤더를 건너뛴다
            line = reader.readLine();
        }
        Thread.sleep(2_000);                       // 오래 걸리는 처리를 흉내 낸다

        byte[] body = ("handled " + requestLine + "\n")
                .getBytes(StandardCharsets.UTF_8);
        String head = "HTTP/1.1 200 OK\r\n"
                + "Content-Type: text/plain; charset=UTF-8\r\n"
                + "Content-Length: " + body.length + "\r\n"
                + "Connection: close\r\n"
                + "\r\n";
        OutputStream output = socket.getOutputStream();
        output.write(head.getBytes(StandardCharsets.US_ASCII));
        output.write(body);
        output.flush();
    }
}

ServerSocket 생성자의 세 인수는 포트 번호, 연결 대기열의 길이(backlog), 바인딩할 주소입니다. 루프백 주소에 바인딩했으므로 같은 컴퓨터에서 오는 연결만 받습니다. handle은 요청 줄을 읽고 빈 줄이 나올 때까지 헤더를 건너뛴 뒤, 받은 요청 줄을 그대로 돌려주는 텍스트 응답을 씁니다. 앞 글에서 정리한 대로 본문을 먼저 바이트로 바꿔 길이를 구하고, Connection: close를 알린 뒤 실제로 소켓을 닫습니다. 소켓은 try (Socket socket = listener.accept())가 처리가 끝나는 대로 닫아 줍니다.

마지막의 flush()는 자바 쪽 버퍼에 남은 바이트를 운영체제에 넘길 뿐, 상대가 그 바이트를 받았다는 확인이 아닙니다. write가 정상적으로 끝나도 클라이언트가 응답을 다 읽었는지는 알 수 없습니다. HTTP에서는 클라이언트가 응답을 받았는지를 서버가 확인하는 절차가 따로 없고, 서버는 요청마다 상태 코드로 처리 결과를 알릴 뿐입니다.

헤더를 BufferedReader로 읽은 것은 이 서버가 본문 없는 요청 하나만 받고 연결을 닫기 때문에 괜찮습니다. BufferedReader는 효율을 위해 소켓에서 필요한 것보다 많은 바이트를 미리 읽어 자기 버퍼에 담아 둡니다. 그래서 요청 본문이나 같은 연결로 온 다음 요청의 일부가 그 버퍼에 들어가 버릴 수 있고, 그 뒤 원래 InputStream에서 본문을 읽으면 그 바이트가 보이지 않습니다. 본문과 지속 연결을 다루려면 바이트 단위 판독기 하나로 헤더와 본문을 모두 읽어야 하는데, 이 글 뒤쪽의 지속 연결 예제가 그렇게 합니다.

서버를 실행하면 listening on http://localhost:8080을 출력하고 연결을 기다립니다. 이 서버는 스스로 끝나지 않으므로 확인을 마친 뒤 Ctrl+C로 멈춥니다. 다른 터미널에서 curl에 -i 옵션을 붙여 요청하면 응답 헤더까지 볼 수 있습니다.

curl -i http://localhost:8080/hello

-i를 붙였으므로 상태 줄과 헤더가 본문보다 먼저 나오고, 빈 줄 하나가 둘을 나눕니다. 서버가 응답하기 전에 2초를 쉬므로 다음 응답은 2초 뒤에 옵니다.

HTTP/1.1 200 OK
Content-Type: text/plain; charset=UTF-8
Content-Length: 28
Connection: close

handled GET /hello HTTP/1.1

Content-Length: 28은 handled GET /hello HTTP/1.1 27바이트에 끝의 줄바꿈 1바이트를 더한 값입니다. 브라우저 주소창에 같은 주소를 입력해도 이 본문이 보입니다.

동시에 요청 세 개를 보내 보기

이제 요청 세 개를 동시에 보내 보겠습니다. 다음 명령은 macOS나 Linux의 셸(또는 WSL, Git Bash)에서 curl 세 개를 동시에 실행하고, 각 요청이 끝나기까지 걸린 시간을 출력합니다.

for path in a b c; do
  curl -s -o /dev/null -w "$path %{time_total}s\n" http://localhost:8080/$path &
done
wait
b 2.003741s
a 4.001806s
c 5.999683s

세 요청은 동시에 출발했지만 응답은 약 2초, 4초, 6초에 하나씩 도착했습니다. 어느 요청이 먼저 처리되는지는 실행할 때마다 다르지만, 2초 간격으로 줄을 서는 모양은 같습니다. 서버에는 스레드가 하나뿐이고, 그 스레드가 handle을 실행하는 동안에는 accept()를 부르지 않기 때문입니다.

그렇다고 두 번째와 세 번째 클라이언트의 연결이 거절된 것은 아닙니다. TCP 연결은 운영체제가 맺어 주기 때문에, 서버 프로그램이 accept()를 부르지 않아도 연결은 성립해 대기열에 쌓이고 클라이언트는 요청까지 보내 둡니다. 그 요청을 읽어 줄 스레드가 없을 뿐입니다. 대기열마저 가득 차면 그 뒤에 오는 연결은 거절되거나 응답 없이 기다리게 되는데, 어느 쪽이 될지는 운영체제에 따라 다릅니다.

더 심각한 문제는 느린 클라이언트입니다. 어떤 클라이언트가 연결한 뒤 요청 줄만 보내고 멈추면, readLine()은 다음 줄이 올 때까지 기다리고 그동안 서버 전체가 멈춥니다. 소켓 연결·읽기 시간 제한에서 본 setSoTimeout으로 읽기 대기에 상한을 둘 수는 있지만, 그 시간 동안에는 여전히 다른 모든 연결이 기다려야 합니다. 연결 하나의 속도가 서버 전체의 속도를 정하는 구조 자체를 바꿔야 합니다.

accept 스레드와 처리 작업 나누기

해결의 핵심은 연결을 수락하는 일과 연결을 처리하는 일을 서로 다른 스레드에 맡기는 것입니다. 수락하는 스레드는 accept()로 소켓을 받자마자 그 소켓을 처리할 작업을 실행기에 넘기고, 곧바로 다음 accept()로 돌아갑니다. 다음 코드는 앞의 handle을 그대로 쓰면서 연결마다 가상 스레드를 하나씩 배정합니다. 두 파일을 같은 폴더에 두고 함께 컴파일하세요.

src/VirtualThreadHttpAcceptor.java
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.Executors;

public final class VirtualThreadHttpAcceptor {
    public static void main(String[] args) throws Exception {
        try (var listener = new ServerSocket(
                     8080, 50, InetAddress.getLoopbackAddress());
             var workers = Executors.newVirtualThreadPerTaskExecutor()) {
            System.out.println("listening on http://localhost:8080");
            while (true) {
                Socket socket = listener.accept();
                workers.execute(() -> {
                    try (socket) {
                        SequentialHttpAcceptor.handle(socket);
                    } catch (IOException error) {
                        System.out.println("connection failed: " + error.getMessage());
                    } catch (InterruptedException error) {
                        Thread.currentThread().interrupt();
                    }
                });
            }
        }
    }
}

Executors.newVirtualThreadPerTaskExecutor()는 작업마다 새 가상 스레드를 만드는 실행기로, JDK 21부터 정식 기능입니다. 가상 스레드는 소켓 읽기처럼 블로킹되는 작업을 기다리는 동안 운영체제 스레드를 붙잡지 않으므로, 연결마다 하나씩 만들어도 플랫폼 스레드보다 훨씬 가볍습니다. execute에 넘기는 람다는 Runnable이라 검사 예외를 던질 수 없어서, handle이 던지는 IOException과 InterruptedException을 작업 안에서 처리했습니다.

try (socket)은 이미 선언된 변수를 자원으로 쓰는 try-with-resources 문법이고, 작업이 끝날 때 소켓을 닫습니다. 이 한 줄이 소켓의 소유권이 어디에 있는지를 정합니다. 작업을 넘긴 뒤로는 수락 스레드가 소켓을 건드리지 않고, 소켓을 닫는 일도 작업이 맡습니다. 앞의 셸 명령으로 다시 요청 세 개를 동시에 보내면 결과가 이렇게 바뀝니다.

a 2.030644s
c 2.032329s
b 2.036758s

세 요청이 모두 약 2초 만에 끝났습니다. 각 연결이 자기 가상 스레드에서 2초를 기다리는 동안, 수락 스레드는 쉬지 않고 다음 연결을 받았기 때문입니다.

스레드 수와 자원의 상한

연결마다 스레드를 하나씩 만드는 방식은 단순하지만, 그 수에 제한이 없다는 점은 따져 봐야 합니다. 플랫폼 스레드는 하나마다 큰 스택 메모리를 예약하고 운영체제가 직접 관리합니다. 그래서 연결이 몰릴 때마다 플랫폼 스레드를 무제한으로 만들면 메모리와 스케줄링 비용 때문에 서버가 먼저 무너지고, 플랫폼 스레드를 쓸 때는 스레드 풀로 수를 정해 둡니다. 이때 풀 크기는 CPU 개수만 보고 정하지 않습니다. 작업이 I/O를 기다리는 비율, 메모리, 그리고 데이터베이스 연결 수처럼 작업이 쓰는 다른 자원의 상한을 함께 봐야 합니다.

가상 스레드를 쓰면 스레드 자체의 비용은 거의 문제가 되지 않지만, 그렇다고 자원이 무한해지는 것은 아닙니다. 요청 본문과 응답 버퍼는 여전히 메모리를 쓰고, 데이터베이스 연결 풀의 크기와 외부 API의 호출 한도도 그대로입니다. 그래서 가상 스레드를 쓰더라도 세마포어 등으로 동시에 처리할 요청 수에 상한을 두는 경우가 많습니다. 다음 절에서는 스레드 수와 대기열 길이가 모두 정해진 실행기로 이 상한을 직접 다룹니다.

넘치는 연결은 503으로 거절하기

ThreadPoolExecutor에 정해진 수의 작업자 스레드와 길이가 정해진 대기열을 주면, 동시에 처리할 연결 수와 기다릴 수 있는 연결 수를 모두 제한할 수 있습니다. 둘 다 가득 찬 상태에서 새 작업이 오면 실행기는 거부 정책에 따라 그 작업을 거절합니다. 스레드 풀의 작업 거부 정책에서 본 AbortPolicy는 이때 RejectedExecutionException을 던집니다.

이 예외를 수락 반복문 밖으로 그냥 내보내면 수락 스레드가 끝나 버리고, 서버는 더 이상 아무 연결도 받지 못합니다. 과부하는 잠깐 지나가는 상황인데 서버 전체가 멈추는 것입니다. 다음 BoundedHttpDispatcher는 거절을 예외 대신 Admission이라는 결과 값으로 바꿔 돌려줍니다.

src/BoundedHttpDispatcher.java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public final class BoundedHttpDispatcher implements AutoCloseable {
    enum Admission {
        ACCEPTED,
        OVERLOADED,
        CLOSED
    }

    private final ThreadPoolExecutor executor;

    BoundedHttpDispatcher(int workers, int queueCapacity) {
        executor = new ThreadPoolExecutor(
                workers,
                workers,
                0,
                TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(queueCapacity),
                new ThreadPoolExecutor.AbortPolicy());
    }

    Admission submit(Runnable request) {
        if (executor.isShutdown()) {
            return Admission.CLOSED;
        }
        try {
            executor.execute(request);
            return Admission.ACCEPTED;
        } catch (RejectedExecutionException error) {
            return executor.isShutdown()
                    ? Admission.CLOSED
                    : Admission.OVERLOADED;
        }
    }

    record Snapshot(int active, int queued, long completed) {
    }

    Snapshot snapshot() {
        return new Snapshot(
                executor.getActiveCount(),
                executor.getQueue().size(),
                executor.getCompletedTaskCount());
    }

    @Override
    public void close() {
        executor.shutdown();
        try {
            if (!executor.awaitTermination(1, TimeUnit.SECONDS)) {
                executor.shutdownNow();
            }
        } catch (InterruptedException error) {
            executor.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }

    public static void main(String[] args) throws Exception {
        var gate = new CountDownLatch(1);
        List<Admission> results = new ArrayList<>();

        try (var dispatcher = new BoundedHttpDispatcher(1, 1)) {
            for (int index = 0; index < 4; index += 1) {
                results.add(dispatcher.submit(() -> {
                    try {
                        gate.await();
                    } catch (InterruptedException error) {
                        Thread.currentThread().interrupt();
                    }
                }));
            }
            System.out.println(results);
            System.out.println(dispatcher.snapshot());
            gate.countDown();
        }
    }
}

submit이 돌려주는 값은 세 가지입니다. ACCEPTED는 실행기가 작업을 거절하지 않고 받았다는 뜻이고, OVERLOADED는 작업자 스레드와 대기열이 모두 차서 거절됐다는 뜻이며, CLOSED는 실행기가 이미 종료 중이라 받지 않았다는 뜻입니다. 거절 예외를 잡은 뒤 isShutdown()을 다시 확인하는 이유는, AbortPolicy가 과부하일 때뿐 아니라 종료된 실행기에 들어온 작업에도 같은 예외를 던지기 때문입니다.

main은 래치(CountDownLatch)로 작업들이 끝나지 못하게 붙잡아 둔 채, 작업자 스레드 1개와 대기열 1칸으로 만든 실행기에 작업 네 개를 제출합니다. 소켓이나 HTTP 응답은 만들지 않고, 제출 결과만 확인합니다.

[ACCEPTED, ACCEPTED, OVERLOADED, OVERLOADED]
Snapshot[active=1, queued=1, completed=0]

네 번의 제출에서 실행기 안에서 일어난 일을 차례로 따라가면 다음과 같습니다.

제출실행기가 하는 일결과
1번째작업자 스레드가 아직 없으므로 새로 만들어 바로 실행한다. 작업은 래치에서 멈춘다ACCEPTED
2번째작업자 스레드가 이미 최대인 1개이므로 대기열에 넣는다ACCEPTED
3번째대기열이 찼고 스레드를 더 만들 수 없으므로 거부 정책을 실행한다OVERLOADED
4번째3번째와 같다OVERLOADED

두 번째 줄의 Snapshot은 실행 중인 작업 1개, 대기열의 작업 1개, 완료된 작업 0개를 보여 줍니다. 다만 active는 그 순간의 근삿값이라, 첫 작업이 아직 실행을 시작하지 않은 시점이라면 0으로 나올 수도 있습니다. 세 값을 각각 따로 읽기 때문에 한 시점의 정확한 모습이라고 볼 수도 없습니다. main이 래치를 풀면 두 작업이 끝나고, try-with-resources가 부르는 close()가 그것을 기다립니다.

수용 결과는 실행 완료나 HTTP 전송 결과가 아니다

BoundedHttpDispatcher의 반환값을 해당 분기와 연결합니다.

HTTP 연결 수용 · 실행 완료와 구분

수용 결과는 실행 완료나 HTTP 전송 결과가 아니다

ACCEPTED는 실행기 제출이 거부되지 않았다는 뜻입니다. 세 반환값 모두 작업 완료나 HTTP 응답 전송을 보장하지 않습니다.

ACCEPTED

판단 시점
execute가 거부 예외 없이 반환
이 값만으로 알 수 없는 것
작업의 시작·완료·성공 또는 503 전송 여부

OVERLOADED

판단 시점
거부를 잡은 뒤 isShutdown이 false
이 값만으로 알 수 없는 것
실패한 제출 순간의 종료 상태를 원자적으로 기록한 값은 아님

CLOSED

판단 시점
제출 전 또는 거부 처리 중 종료 상태 확인
이 값만으로 알 수 없는 것
모든 기존 작업의 종료 완료

main은 래치로 작업을 붙잡고 worker 1개·큐 1칸에 네 번 제출합니다. 소켓이나 HTTP 응답을 만들지 않으며, snapshot의 세 값도 하나의 원자적 관측이 아닙니다.

그림이 강조하듯 ACCEPTED는 실행기가 작업을 받았다는 뜻일 뿐, 작업이 끝났다거나 HTTP 응답이 전송됐다는 뜻이 아닙니다. 받아들여진 작업도 처리 중에 실패할 수 있고, 클라이언트가 먼저 연결을 끊을 수도 있습니다. 반대로 OVERLOADED를 받았다고 해서 503 응답이 이미 나간 것도 아닙니다. 응답은 이 값을 받은 쪽에서 따로 써야 합니다.

close()는 shutdown()으로 새 작업을 막고, 기존 작업이 끝나기를 1초 동안 기다립니다. 그래도 끝나지 않으면 shutdownNow()로 실행 중인 작업에 인터럽트를 보내고 대기열의 작업을 꺼냅니다. 기다리는 도중 현재 스레드가 인터럽트되면 역시 shutdownNow()를 부르고 인터럽트 상태를 되살립니다. close()가 InterruptedException을 던지지 않게 만든 것은 AutoCloseable 문서의 권고를 따른 것입니다. try-with-resources에서 이 예외가 억제된 예외(suppressed exception)로 묻히면 인터럽트 상태가 처리되지 않은 채 사라지기 쉽습니다. 실제 서버라면 shutdownNow()가 돌려주는 '시작하지 못한 작업'들이 각자 소켓을 쥐고 있으므로, 그 소켓도 닫아 주어야 합니다.

서버에 연결해 503 보내기

이제 이 실행기를 서버에 붙여 보겠습니다. 수락 스레드는 연결을 받을 때마다 처리 작업을 제출하고, 결과가 ACCEPTED가 아니면 503 Service Unavailable 응답을 직접 써서 연결을 닫습니다. 처리 자체는 앞의 SequentialHttpAcceptor.handle을 그대로 씁니다.

src/BoundedHttpServer.java
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

public final class BoundedHttpServer {
    public static void main(String[] args) throws Exception {
        try (var listener = new ServerSocket(
                     8080, 50, InetAddress.getLoopbackAddress());
             var dispatcher = new BoundedHttpDispatcher(1, 1)) {
            System.out.println("listening on http://localhost:8080");
            while (true) {
                Socket socket = listener.accept();
                var admission = dispatcher.submit(() -> serve(socket));
                if (admission != BoundedHttpDispatcher.Admission.ACCEPTED) {
                    reject(socket);
                }
            }
        }
    }

    static void serve(Socket socket) {
        try (socket) {
            SequentialHttpAcceptor.handle(socket);
        } catch (IOException error) {
            System.out.println("connection failed: " + error.getMessage());
        } catch (InterruptedException error) {
            Thread.currentThread().interrupt();
        }
    }

    static void reject(Socket socket) {
        byte[] body = "server busy\n".getBytes(StandardCharsets.US_ASCII);
        String head = "HTTP/1.1 503 Service Unavailable\r\n"
                + "Content-Type: text/plain; charset=UTF-8\r\n"
                + "Content-Length: " + body.length + "\r\n"
                + "Connection: close\r\n"
                + "\r\n";
        try (socket) {
            OutputStream output = socket.getOutputStream();
            output.write(head.getBytes(StandardCharsets.US_ASCII));
            output.write(body);
            output.flush();
        } catch (IOException error) {
            System.out.println("reject failed: " + error.getMessage());
        }
    }
}

여기서도 소켓을 누가 닫는지가 중요합니다. 제출이 받아들여지면 소켓은 serve 작업의 것이 되고, 작업이 끝날 때 try (socket)이 닫습니다. 거절되면 그 작업은 끝내 실행되지 않으므로 수락 스레드가 직접 응답을 쓰고 닫아야 합니다. 어느 쪽도 닫지 않으면 클라이언트는 응답 없이 기다리고, 서버에는 열린 소켓이 쌓여 갑니다.

작업자 스레드 1개, 대기열 1칸인 이 서버에 요청 네 개를 동시에 보내 보겠습니다. 앞의 셸 명령에서 경로를 네 개로 늘리고, -w 형식에 상태 코드를 출력하는 %{http_code}를 더했습니다.

for path in a b c d; do
  curl -s -o /dev/null -w "$path %{http_code} %{time_total}s\n" http://localhost:8080/$path &
done
wait
b 503 0.011452s
c 503 0.012112s
a 200 2.013135s
d 200 4.006997s

먼저 도착한 두 연결 가운데 하나는 바로 실행되고, 하나는 대기열에서 기다립니다. 나머지 두 연결은 받아 줄 자리가 없으므로 0.01초 만에 503을 받습니다. 대기열에 있던 요청은 앞 작업의 2초가 끝난 뒤에 실행되어 약 4초에 응답을 받습니다. 어느 요청이 거절될지는 연결이 도착한 순서에 따라 실행할 때마다 달라집니다.

503은 클라이언트와 로드밸런서가 이해하기 쉬운 신호입니다. 일시적인 과부하를 뜻하므로 클라이언트는 잠시 뒤 다시 시도할 수 있고, 로드밸런서는 이 서버로 보내는 요청을 줄일 수 있습니다. 필요하면 Retry-After 헤더로 몇 초 뒤에 다시 시도하라고 알려 줄 수도 있습니다. 운영 환경에서는 거절 횟수, 실행 중인 작업 수, 대기열 길이, 요청 지연 시간을 함께 기록해 두어야 과부하가 언제 왜 생겼는지 알 수 있습니다.

거부 정책을 CallerRunsPolicy로 바꾸면, 거절하는 대신 작업을 제출한 스레드가 그 작업을 직접 실행합니다. 수락 스레드가 요청을 처리하는 동안에는 새 연결을 받지 않으므로 유입 속도가 저절로 줄어드는 효과가 있습니다. 하지만 그 요청이 느린 클라이언트의 것이라면 수락 스레드가 오랫동안 묶이고, 그사이 운영체제의 연결 대기열이 차 버립니다. 첫 절에서 본 '연결 하나가 서버 전체를 붙잡는' 문제가 되살아나는 셈이라서, HTTP 서버에서는 503으로 빨리 거절하는 편이 대개 더 낫습니다.

더 깊이: 바로 닫으면 503이 사라질 수 있다

reject는 클라이언트가 보낸 요청을 읽지 않은 채 응답을 쓰고 소켓을 닫습니다. 읽지 않은 데이터가 수신 버퍼에 남은 상태로 소켓을 닫으면, 운영체제는 정상 종료를 알리는 FIN 대신 연결을 강제로 끊는 RST를 보낼 수 있습니다. 위의 실험은 같은 컴퓨터 안의 루프백이라 503이 매번 도착했지만, 실제 네트워크에서는 RST가 먼저 처리되어 클라이언트가 503을 읽기 전에 연결 재설정 오류를 받을 수 있습니다.

RFC 9112는 이 문제와 함께 서버들이 흔히 쓰는 단계적 종료 방법을 설명합니다. 먼저 shutdownOutput()으로 쓰기 방향만 닫고, 클라이언트가 연결을 닫거나 일정 시간이 지날 때까지 남은 입력을 읽어 버린 뒤 소켓을 완전히 닫는 것입니다. FIN과 RST의 차이는 TCP 연결 종료: EOF, 반쪽 닫기, RST에서 다뤘습니다.

한 연결에서 요청 여러 개를 처리하는 keep-alive

지금까지의 서버는 응답 하나를 보내고 연결을 닫았습니다. HTTP/1.1의 지속 연결을 지원하려면, 응답을 보낸 뒤 같은 소켓에서 다음 요청의 시작 줄을 읽어야 합니다. 이때 앞 요청의 본문을 정확히 다 읽은 상태여야 다음 시작 줄을 제자리에서 읽을 수 있습니다. 요청마다 만드는 객체와 헤더 맵도 매번 새로 만들어서, 앞 요청의 상태가 다음 요청에 섞이지 않게 해야 합니다.

연결을 영원히 붙잡아 두지 않으려면 언제 끝낼지도 정해야 합니다. 서버가 연결을 끝내는 대표적인 조건은 다음과 같습니다.

끝내는 조건이유보내는 응답소켓
클라이언트가 Connection: close를 보냄클라이언트가 재사용을 원하지 않는다응답에 Connection: close응답 뒤 닫는다
유휴 시간 초과조용한 연결의 자원을 회수한다요청을 받던 중이었다면 408 Request Timeout, 요청 사이였다면 없음닫는다
연결당 최대 요청 수 도달연결 하나가 서버를 오래 차지하지 않게 한다마지막 응답에 Connection: close응답 뒤 닫는다
요청 해석 오류, 길이 판단 불가다음 요청이 어디서 시작하는지 믿을 수 없다400 Bad Request와 Connection: close다시 쓰지 않고 닫는다
서버 종료배포 등으로 서버를 내린다마지막 응답에 Connection: close정한 기한 안에 닫는다

이 가운데 네 번째 줄이 특히 중요합니다. 요청의 길이를 판단할 수 없게 된 순간, 그 연결에서 다음에 읽을 바이트가 새 요청의 시작인지 앞 요청의 나머지인지 알 수 없습니다. 그 상태로 연결을 계속 쓰는 것은 앞 글에서 본 어긋남과 요청 스머글링에 문을 열어 주는 일이므로, 400으로 응답하고 연결을 닫아야 합니다.

클라이언트가 앞 응답을 기다리지 않고 요청 여러 개를 한꺼번에 보내는 경우도 있습니다. 이를 파이프라이닝(pipelining)이라고 하며, 서버는 받은 순서대로 응답해야 합니다. 요청을 하나씩 순서대로 읽고 응답하는 구조라면 이 조건은 저절로 지켜집니다. 지속 연결을 제대로 지원할 준비가 안 된 단순한 서버라면, 모든 응답에 Connection: close를 보내고 실제로 닫는 것도 올바른 선택입니다.

요청 두 개를 한 연결로 처리하는 예제

다음 예제는 한 연결로 GET /one과 GET /two를 이어서 받습니다. 첫 응답은 연결을 유지한 채 보내고, 두 번째 응답에서 Connection: close를 보낸 뒤 연결을 닫습니다. 앞의 서버들과 달리 서버와 클라이언트를 한 프로그램에 넣었습니다. 포트 번호로 0을 주면 운영체제가 비어 있는 포트를 골라 주므로 다른 프로그램과 포트가 겹치지 않고, 서버 쪽 처리는 가상 스레드에서, 클라이언트 쪽은 main 스레드에서 실행됩니다.

src/TwoRequestKeepAliveSolution.java
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

public final class TwoRequestKeepAliveSolution {
    static String readLine(InputStream input) throws Exception {
        var bytes = new ByteArrayOutputStream();
        while (bytes.size() <= 4_096) {
            int value = input.read();
            if (value == -1) {
                if (bytes.size() == 0) {
                    return null;
                }
                throw new EOFException("line truncated");
            }
            if (value == '\r') {
                if (input.read() != '\n') {
                    throw new IllegalArgumentException("CR without LF");
                }
                return bytes.toString(StandardCharsets.US_ASCII);
            }
            if (value == '\n') {
                throw new IllegalArgumentException("bare LF");
            }
            bytes.write(value);
        }
        throw new IllegalArgumentException("line too long");
    }

    static String readRequestHead(InputStream input) throws Exception {
        String start = readLine(input);
        if (start == null) {
            return null;
        }
        String line;
        while (!(line = readLine(input)).isEmpty()) {
            if (line == null) {
                throw new EOFException("headers truncated");
            }
        }
        return start;
    }

    static void writeResponse(Socket socket, String body, boolean close)
            throws Exception {
        byte[] content = body.getBytes(StandardCharsets.UTF_8);
        String head = "HTTP/1.1 200 OK\r\n"
                + "Content-Type: text/plain; charset=UTF-8\r\n"
                + "Content-Length: " + content.length + "\r\n"
                + "Connection: " + (close ? "close" : "keep-alive") + "\r\n"
                + "\r\n";
        socket.getOutputStream().write(
                head.getBytes(StandardCharsets.US_ASCII));
        socket.getOutputStream().write(content);
        socket.getOutputStream().flush();
    }

    static String readResponse(InputStream input) throws Exception {
        String status = readLine(input);
        int length = -1;
        String line;
        while (!(line = readLine(input)).isEmpty()) {
            if (line.toLowerCase().startsWith("content-length:")) {
                length = Integer.parseInt(line.split(":", 2)[1].trim());
            }
        }
        if (length < 0) {
            throw new IllegalArgumentException("length missing");
        }
        byte[] body = input.readNBytes(length);
        if (body.length != length) {
            throw new EOFException("body truncated");
        }
        return status + " body="
                + new String(body, StandardCharsets.UTF_8);
    }

    public static void main(String[] args) throws Exception {
        try (var listener = new ServerSocket(
                0,
                8,
                InetAddress.getLoopbackAddress());
             var tasks = java.util.concurrent.Executors
                     .newVirtualThreadPerTaskExecutor()) {
            var server = tasks.submit(() -> {
                try (Socket socket = listener.accept()) {
                    socket.setSoTimeout(1_000);
                    for (int number = 1; number <= 2; number += 1) {
                        String request = readRequestHead(socket.getInputStream());
                        if (request == null) {
                            throw new EOFException("request missing");
                        }
                        writeResponse(socket, "handled=" + request, number == 2);
                    }
                    return 2;
                }
            });

            try (var client = new Socket(
                    InetAddress.getLoopbackAddress(),
                    listener.getLocalPort())) {
                String requests = "GET /one HTTP/1.1\r\nHost: local\r\n\r\n"
                        + "GET /two HTTP/1.1\r\nHost: local\r\n\r\n";
                client.getOutputStream().write(
                        requests.getBytes(StandardCharsets.US_ASCII));
                client.getOutputStream().flush();

                System.out.println(readResponse(client.getInputStream()));
                System.out.println(readResponse(client.getInputStream()));
            }
            System.out.println("handled=" + server.get());
        }
    }
}

클라이언트는 두 요청을 한 번에 쓴 뒤 readResponse로 응답을 하나씩 읽고, 상태 줄과 본문을 한 줄로 묶어 찍습니다. 마지막 줄은 서버 작업이 두 요청을 모두 처리하고 돌려준 값을 server.get()으로 받은 것입니다.

HTTP/1.1 200 OK body=handled=GET /one HTTP/1.1
HTTP/1.1 200 OK body=handled=GET /two HTTP/1.1
handled=2

readLine은 소켓 스트림에서 한 바이트씩 읽고, LF를 만나는 순간 멈춥니다. 필요한 것보다 한 바이트도 더 읽지 않으므로 다음 요청의 바이트는 스트림에 그대로 남고, BufferedReader처럼 미리 읽어 버리는 문제가 생기지 않습니다. CR 뒤에 LF가 오지 않거나 CR 없이 LF만 오면 거절하고, 한 줄이 4096바이트를 넘어도 거절합니다. 줄을 시작하기도 전에 스트림이 끝나면 null을 돌려주는데, readRequestHead는 이것을 '클라이언트가 요청 사이에서 연결을 닫았다'는 정상적인 끝으로 해석합니다.

서버는 setSoTimeout(1_000)으로 한 번의 읽기가 1초 넘게 기다리지 않게 하고, 요청 두 개를 차례로 읽어 응답합니다. 첫 응답의 Connection: keep-alive는 HTTP/1.1에서는 없어도 되는 헤더지만 연결이 유지된다는 것을 눈으로 보이려고 넣었습니다. 클라이언트는 두 요청을 한 번에 보낸 뒤, 각 응답의 Content-Length를 찾아 정확히 그만큼만 readNBytes로 읽습니다. 그래서 첫 응답의 본문이 끝난 바로 다음 바이트에서 두 번째 응답의 상태 줄을 읽을 수 있고, 출력의 두 줄이 각자 맞는 요청 대상을 보여 줍니다. 마지막 줄은 서버 작업이 요청 두 개를 처리하고 돌려준 값입니다.

이 예제가 아직 검사하지 않는 것

이 예제는 '본문 없는 GET 두 개'라는 정해진 입력에 맞춰 만든 것이라, 다른 입력에 대해서는 빈틈이 있습니다.

두 요청 예제가 확인하는 경계와 아직 검사하지 않는 입력

TwoRequestKeepAliveSolution의 고정 입력과 판독기 구현 범위를 비교합니다.

지속 연결 · 고정 입력의 검증 범위

두 요청 예제가 확인하는 경계와 아직 검사하지 않는 입력

본문 없는 GET 두 개의 정상 경계를 읽는 예제입니다. 메서드·본문 문법, 전체 기한과 헤더 중간 EOF 처리는 별도 보완이 필요합니다.

서버 요청 읽기

원문의 동작
시작 줄과 빈 줄까지 읽고 두 번 응답
남은 조건
GET·HTTP 버전·본문 길이를 검증하지 않음

서버 읽기 시간

원문의 동작
Socket의 읽기 대기에 1초 제한
남은 조건
전체 연결 기한이 아니며 client 읽기·Future.get에는 별도 기한 없음

클라이언트 응답 읽기

원문의 동작
찾은 Content-Length만큼 읽고 짧으면 거부
남은 조건
중복 길이·상태 의미·헤더 총량은 검증하지 않음

헤더 중간 EOF

원문의 동작
readLine이 null을 반환할 수 있음
남은 조건
isEmpty가 먼저 호출되어 의도한 EOF 예외 대신 null 참조 오류 가능

main은 본문 없는 GET 두 개를 미리 정해 보냅니다. 정상 왕복과 오류 경로를 실행한 기록은 아닙니다.

먼저 서버는 시작 줄과 빈 줄만 확인하고, 메서드와 HTTP 버전과 본문 길이는 검사하지 않습니다. 첫 요청에 본문이 있었다면 그 본문이 두 번째 요청의 시작 줄로 읽히는데, 이 상황은 연습 문제 2에서 직접 확인합니다. setSoTimeout(1_000)은 한 번의 읽기 대기에 대한 상한일 뿐 연결 전체의 기한이 아니고, 클라이언트의 읽기와 server.get()에는 기한이 없습니다. 클라이언트도 Content-Length만큼 읽고 모자라면 거부할 뿐, 길이 헤더가 중복됐는지, 상태 코드가 본문 없는 응답을 뜻하는지, 헤더 전체가 너무 크지 않은지는 따지지 않습니다.

그리고 헤더를 읽는 반복문에는 결함이 하나 있습니다. while (!(line = readLine(input)).isEmpty())는 조건을 검사할 때 isEmpty()를 먼저 호출합니다. 그래서 헤더 중간에 연결이 끊겨 readLine이 null을 돌려주면, 반복문 안의 null 검사에 닿기도 전에 NullPointerException이 납니다. 실제로 헤더 끝의 빈 줄 없이 GET /one HTTP/1.1\r\nHost: local\r\n만 담은 ByteArrayInputStream으로 readRequestHead를 호출하면 의도한 EOFException 대신 다음 예외가 납니다.

java.lang.NullPointerException: Cannot invoke "String.isEmpty()" because the return value of "TwoRequestKeepAliveSolution.readLine(java.io.InputStream)" is null

'연결이 끊겼다'는 뜻의 null과 '헤더가 끝났다'는 뜻의 빈 문자열은 서로 다른 값인데, 이 코드는 둘을 가르기 전에 메서드를 호출해 버린 것입니다. 고치는 방법은 연습 문제 1에서 다룹니다.

더 깊이: 줄 끝을 얼마나 엄격하게 볼 것인가

RFC 9112는 보내는 쪽에 CRLF를 요구하면서도, 받는 쪽은 LF 하나만 와도 줄 끝으로 인정하고 앞의 CR을 무시해도 된다(MAY)고 허용합니다. 반면 LF가 뒤따르지 않는 CR 하나는 잘못된 입력으로 보거나 공백으로 바꿔 처리해야 한다고 정합니다. 위의 readLine은 LF 하나도 거절하는 엄격한 쪽을 골랐습니다.

엄격한 쪽을 고르는 이유는 요청 스머글링과 같습니다. 서버 앞에 프록시가 있을 때, 한쪽은 LF 하나를 줄 끝으로 보고 다른 쪽은 보지 않는다면 두 프로그램이 헤더가 끝나는 위치를 다르게 판단합니다. 해석이 갈릴 수 있는 입력은 너그럽게 받아 주기보다 거절하는 편이 안전합니다. 또 이 예제처럼 소켓에서 한 바이트씩 read()를 부르면 호출마다 비용이 들어 느립니다. 실제 판독기는 자기 버퍼에 한꺼번에 읽어 오고, 버퍼 안에서 어디까지 소비했는지를 요청이 바뀌어도 이어서 기억합니다. 그런 바이트 판독기는 HTTP 본문 읽기: Content-Length와 chunked에서 만듭니다.

서버를 완성할 때 확인할 것

이 글의 예제들은 각각 한 가지 문제만 보여 주도록 나뉘어 있고, 예제들의 main이 아래 조건을 모두 시험한 것은 아닙니다. 예제들을 실제 서버 하나로 합칠 때는 다음을 확인해야 합니다.

  • 느린 연결이 하나 있어도 다른 정상 연결은 응답을 받습니다.
  • 실행기가 가득 차도 수락 스레드는 멈추지 않고, 거절된 연결은 503을 받으며 거절 횟수가 기록됩니다.
  • 모든 연결이 끝나면 실행 중인 작업과 열린 소켓의 수가 0으로 돌아옵니다.
  • 지속 연결은 두 번째 요청이 성공하는 경우뿐 아니라 첫 본문이 선언보다 짧은 경우, 길이 헤더가 충돌하는 경우에도 400으로 응답하고 연결을 닫습니다.

서버를 끝낼 때는 순서도 중요합니다. 먼저 ServerSocket을 닫아 새 연결을 받지 않게 하고, 처리 중인 연결들을 닫거나 끝나기를 기다린 뒤, 마지막으로 실행기가 종료되기를 정한 시간만큼 기다립니다. 이 순서는 accept 루프 멈추기와 단계적 서버 종료에서 TCP 서버로 다룬 것과 같습니다.

정리

  • 한 스레드가 accept()와 요청 처리를 번갈아 하면, 요청 하나의 처리 시간만큼 다른 모든 연결이 기다립니다. 느린 클라이언트 하나가 서버 전체를 멈출 수도 있습니다.
  • 수락 스레드는 소켓을 받아 실행기에 넘기기만 하고, 소켓을 닫는 일은 작업이 맡습니다. 가상 스레드를 쓰면 연결마다 스레드를 하나씩 배정해도 가볍습니다.
  • 스레드가 가벼워져도 메모리, 데이터베이스 연결 같은 자원은 유한하므로 동시에 처리할 양에 상한을 둡니다. 넘치는 연결은 수락 스레드를 멈추지 않고 503으로 거절합니다.
  • 지속 연결은 앞 요청의 본문을 정확히 소비한 뒤 같은 소켓에서 다음 요청을 읽습니다. 유휴 시간, 최대 요청 수, 해석 오류, 서버 종료 같은 끝내는 조건을 함께 정해야 합니다.
  • 연결이 끊겼다는 null과 헤더 끝을 뜻하는 빈 줄은 구별해서 처리해야 합니다.

연습 문제

  1. TwoRequestKeepAliveSolution의 readRequestHead가 null을 먼저 검사하도록 고치세요. 그리고 고친 메서드에 다음 세 입력을 넣었을 때 각각 어떤 결과가 나올지 먼저 예상한 다음, 실행해서 확인하세요.
    • (가) GET /one HTTP/1.1\r\nHost: local\r\n\r\n
    • (나) GET /one HTTP/1.1\r\nHost: local\r\n (헤더 끝의 빈 줄이 없음)
    • (다) GET /one HTTP/1.1\r\nHost: local (Host 줄의 CRLF도 없음)
  2. TwoRequestKeepAliveSolution의 첫 요청을 Content-Length: 5와 본문 hello를 가진 POST /one으로 바꾸면, 원래 코드에서 두 번째 응답의 본문은 무엇이 될까요? 그다음 Content-Length만큼 본문을 읽어 버리도록 readRequestHead를 고치세요.
  3. BoundedHttpDispatcher의 main에서 실행기를 new BoundedHttpDispatcher(2, 1)로 만들고 작업을 다섯 개 제출하면 결과 목록은 어떻게 될까요? 제출마다 실행기가 하는 일을 표로 적어 보세요.
1번 풀이: 끊긴 연결과 헤더 끝 구별하기

readLine의 결과를 변수에 받은 뒤 null인지부터 확인하고, 그다음에 빈 줄인지 확인하면 됩니다. 아래 예제는 소켓 대신 ByteArrayInputStream으로 세 입력을 넣어 봅니다. 요청 줄이 아예 없는 경우도 오류로 처리하도록 조금 더 엄격하게 만들었습니다.

extra/HeaderEndVsEofDemo.java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

public final class HeaderEndVsEofDemo {
    static String readHead(InputStream input) throws Exception {
        String start = readLine(input);
        if (start == null || start.isEmpty()) {
            throw new EOFException("request line missing");
        }
        while (true) {
            String line = readLine(input);
            if (line == null) {
                throw new EOFException("headers truncated");
            }
            if (line.isEmpty()) {
                return start;
            }
        }
    }

    private static String readLine(InputStream input) throws Exception {
        var bytes = new ByteArrayOutputStream();
        while (bytes.size() <= 4_096) {
            int value = input.read();
            if (value == -1) {
                if (bytes.size() == 0) return null;
                throw new EOFException("line truncated");
            }
            if (value == '\r') {
                if (input.read() != '\n') {
                    throw new IllegalArgumentException("CR without LF");
                }
                return bytes.toString(StandardCharsets.US_ASCII);
            }
            if (value == '\n') {
                throw new IllegalArgumentException("bare LF");
            }
            bytes.write(value);
        }
        throw new IllegalArgumentException("line too long");
    }

    public static void main(String[] args) throws Exception {
        for (String wire : java.util.List.of(
                "GET /one HTTP/1.1\r\nHost: local\r\n\r\n",
                "GET /one HTTP/1.1\r\nHost: local\r\n",
                "GET /one HTTP/1.1\r\nHost: local")) {
            try {
                System.out.println(readHead(new ByteArrayInputStream(
                        wire.getBytes(StandardCharsets.US_ASCII))));
            } catch (EOFException error) {
                System.out.println(error.getMessage());
            }
        }
    }
}

실행 결과:

GET /one HTTP/1.1
headers truncated
line truncated

(가)는 빈 줄까지 정상적으로 읽혀 요청 줄을 돌려줍니다. (나)는 Host 줄까지는 온전하지만, 빈 줄을 읽으려는 순간 스트림이 끝나 readLine이 null을 돌려주므로 headers truncated가 됩니다. (다)는 Host 줄을 읽는 도중에 스트림이 끝납니다. 이미 몇 바이트를 읽은 뒤라 readLine 안에서 line truncated가 먼저 납니다. 이 예제는 헤더가 어디서 끝나는지만 확인합니다. 실제 포트, 지속 연결, 본문 길이, 읽기 기한은 시험하지 않습니다.

2번 풀이: 본문을 읽지 않으면 다음 요청이 망가진다

원래 코드는 빈 줄까지만 읽고 본문을 건드리지 않습니다. 그래서 두 번째 요청을 읽을 때 남아 있던 본문 hello가 다음 시작 줄 앞에 붙고, 출력은 다음과 같습니다.

HTTP/1.1 200 OK body=handled=POST /one HTTP/1.1
HTTP/1.1 200 OK body=handled=helloGET /two HTTP/1.1
handled=2

두 번째 요청 줄이 helloGET /two HTTP/1.1로 읽혔습니다. 앞 글에서 응답 쪽으로 본 어긋남이 요청 쪽에서 그대로 일어난 것입니다. 이 예제는 요청 줄을 검사하지 않아서 그냥 지나갔지만, 메서드를 검사하는 서버라면 helloGET을 알 수 없는 메서드로 보고 400을 돌려줬을 것입니다.

고친 readRequestHead는 헤더를 읽으면서 Content-Length 값을 기억했다가, 빈 줄을 만나면 정확히 그만큼을 readNBytes로 읽어 버립니다. 1번 풀이처럼 null도 먼저 검사합니다. Locale을 쓰므로 import java.util.Locale;을 추가합니다.

readRequestHead 고친 버전
static String readRequestHead(InputStream input) throws Exception {
    String start = readLine(input);
    if (start == null) {
        return null;
    }
    int length = 0;
    while (true) {
        String line = readLine(input);
        if (line == null) {
            throw new EOFException("headers truncated");
        }
        if (line.isEmpty()) {
            break;
        }
        if (line.toLowerCase(Locale.ROOT).startsWith("content-length:")) {
            length = Integer.parseInt(line.substring(15).trim());
        }
    }
    byte[] body = input.readNBytes(length);  // 본문을 끝까지 소비한다
    if (body.length != length) {
        throw new EOFException("body truncated");
    }
    return start + " bodyBytes=" + length;
}

클라이언트가 보내는 요청 문자열은 다음처럼 바꿉니다.

main의 요청 문자열
String requests = "POST /one HTTP/1.1\r\nHost: local\r\n"
        + "Content-Length: 5\r\n\r\nhello"
        + "GET /two HTTP/1.1\r\nHost: local\r\n\r\n";

실행하면 두 요청이 모두 제자리에서 읽힙니다.

HTTP/1.1 200 OK body=handled=POST /one HTTP/1.1 bodyBytes=5
HTTP/1.1 200 OK body=handled=GET /two HTTP/1.1 bodyBytes=0
handled=2

이 풀이도 학습용으로 단순화한 것입니다. Content-Length가 숫자가 아니거나 음수인 경우, 서로 다른 값으로 두 번 온 경우, Transfer-Encoding과 함께 온 경우, 본문이 지나치게 큰 경우를 검사하지 않습니다. 이런 검사는 HTTP 본문 읽기: Content-Length와 chunked에서 다룹니다.

3번 풀이: 스레드 2개, 대기열 1칸

결과는 [ACCEPTED, ACCEPTED, ACCEPTED, OVERLOADED, OVERLOADED]이고, 실행 중 2개, 대기 1개가 됩니다. 실제로 실행하면 Snapshot[active=2, queued=1, completed=0]이 함께 출력됩니다.

제출실행기가 하는 일결과
1번째작업자 스레드가 2개보다 적으므로 새 스레드를 만들어 실행한다ACCEPTED
2번째아직 2개보다 적으므로 두 번째 스레드를 만들어 실행한다ACCEPTED
3번째스레드가 최대인 2개이므로 대기열에 넣는다ACCEPTED
4번째대기열이 찼고 스레드를 더 만들 수 없어 거절한다OVERLOADED
5번째4번째와 같다OVERLOADED

ThreadPoolExecutor는 기본 스레드 수(core pool size)에 이를 때까지는 대기열보다 새 스레드 만들기를 먼저 합니다. 이 예제처럼 기본 스레드 수와 최대 스레드 수가 같으면, 그 수만큼 스레드가 찬 뒤에야 대기열을 쓰고, 대기열까지 차면 거절합니다.

다음 글

지금까지의 서버는 요청 대상을 그대로 문자열로 다뤘습니다. 다음 글 요청 경로와 쿼리 문자열 파싱에서는 /search?q=java 같은 요청 대상을 경로와 쿼리로 나누고, 퍼센트 인코딩된 UTF-8 값을 올바르게 복원하는 방법을 다룹니다.