HTTP 서버와 연결 수명
서버 소켓 기반 HTTP 서버에서 순차 처리 병목, 유한 실행기 포화, 요청별 자원 범위, 연결 유지와 유휴 시간 제한을 완결된 루프백 예제로 검증합니다.
최소 HTTP 서버는 리스너가 연결을 수락하고, 연결별 작업이 요청 메시지를 읽고, 응답 바이트를 쓴 뒤 연결 정책에 따라 소켓을 닫는 구조입니다.
기능은 짧지만 두 종류의 수명이 겹칩니다.
서버 전체 수명은 ServerSocket과 실행기를 소유하고, 연결 수명은 승인된 Socket과 그 안에서 처리되는 한 개 이상의 HTTP 요청을 소유합니다.
학습 예제에서 흔히 보는 while (true) { process(accept()); }는 첫 요청 처리가 끝날 때까지 다음 연결을 수락하지 못합니다.
느린 클라이언트가 헤더 한 줄을 보내고 멈추면 리스너 역할까지 붙잡힙니다.
accept와 연결 처리를 분리하되, 요청마다 무제한 플랫폼 스레드를 생성하는 반대 극단도 피해야 합니다.
accept 스레드 병목
다음 코드는 연결 하나를 완전히 처리한 뒤에야 다음 accept()로 돌아갑니다.
브라우저 두 개가 동시에 접속해도 두 번째 연결은 운영체제 대기열에서 기다립니다.
첫 클라이언트가 요청을 끝내지 않으면 서버 전체가 새 요청을 진행하지 못합니다.
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public final class SequentialHttpAcceptor {
static void serve(ServerSocket listener) throws Exception {
while (!listener.isClosed()) {
try (Socket socket = listener.accept();
var reader = new BufferedReader(new InputStreamReader(
socket.getInputStream(),
StandardCharsets.US_ASCII))) {
while (true) {
String line = reader.readLine();
if (line == null || line.isEmpty()) {
break;
}
}
Thread.sleep(5_000);
}
}
}
public static void main(String[] args) {
System.out.println("compile-only sequential server counterexample");
}
}분리 후 연결 수락기는 세션을 만드는 짧은 작업만 수행하고 실행기에 제출합니다.
제출이 성공하면 연결 소유권이 작업으로 넘어가고, 거부되면 연결 수락기가 즉시 오류 응답 또는 close를 수행합니다.
실행기의 작업 스레드 수는 CPU 개수만 보고 정하지 않습니다.
블로킹 I/O 비율, 하위 시스템 연결 상한, 메모리, 허용 큐 지연을 함께 봅니다.
가상 스레드는 연결당 작업 하나 코드를 단순하게 유지하면서 많은 블로킹을 다룰 수 있지만 무한 자원은 아닙니다. 요청 본문과 출력 버퍼, 데이터베이스 풀, 외부 API 처리율 제한은 그대로 제한해야 합니다. 가상 스레드를 사용해도 수용 세마포어나 유한 세션 수를 둘 수 있습니다.
연결 단위 실행
네트워크 예제는 외부 포트나 브라우저에 의존하지 않고 루프백과 임시 포트를 사용하면 재현하기 쉽습니다.
다음 클래스는 서버가 정확히 한 연결만 받고, 빈 줄까지 요청 헤더를 읽은 다음 정확한 UTF-8 길이의 응답을 전송합니다.
Connection: close를 선언하고 실제 소켓도 닫으므로 클라이언트의 readAllBytes()가 종료됩니다.
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public final class OneShotLoopbackHttpServer {
static String readHead(Socket socket) throws Exception {
var reader = new BufferedReader(new InputStreamReader(
socket.getInputStream(),
StandardCharsets.US_ASCII));
StringBuilder head = new StringBuilder();
while (true) {
String line = reader.readLine();
if (line == null || line.isEmpty()) {
return head.toString();
}
head.append(line).append('\n');
}
}
static void respond(Socket socket, String body) throws Exception {
byte[] bodyBytes = 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: " + bodyBytes.length + "\r\n"
+ "Connection: close\r\n"
+ "\r\n";
socket.getOutputStream().write(
head.getBytes(StandardCharsets.US_ASCII));
socket.getOutputStream().write(bodyBytes);
socket.getOutputStream().flush();
}
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 handled = tasks.submit(() -> {
try (Socket socket = listener.accept()) {
String head = readHead(socket);
respond(socket, "안녕 HTTP");
return head.lines().findFirst().orElseThrow();
}
});
try (var client = new Socket(
InetAddress.getLoopbackAddress(),
listener.getLocalPort())) {
client.getOutputStream().write((
"GET /hello HTTP/1.1\r\n"
+ "Host: localhost\r\n"
+ "Connection: close\r\n"
+ "\r\n")
.getBytes(StandardCharsets.US_ASCII));
client.getOutputStream().flush();
byte[] response = client.getInputStream().readAllBytes();
System.out.println(new String(response, StandardCharsets.UTF_8));
}
System.out.println("request=" + handled.get());
}
}
}이 예제는 한 요청만 처리하므로 BufferedReader가 소켓 입력을 미리 읽는 문제를 드러내지 않습니다.
keep-alive에서 본문 바이트와 다음 시작 줄을 함께 다룰 때 문자 판독기와 원시 스트림을 섞으면 내부 버퍼에 다음 요청의 일부가 남을 수 있습니다.
실제 파서는 한 개의 바이트 수준 입력 경계에서 헤더와 본문을 모두 읽고 필요한 부분만 문자 집합으로 변환해야 합니다.
flush()는 애플리케이션 버퍼의 바이트를 운영체제에 전달할 뿐 상대가 받았다는 확인이 아닙니다.
응답 전달 성공을 write 호출의 예외 없음만으로 정의하고, 더 강한 처리가 필요하면 애플리케이션 응답이나 별도 프로토콜이 필요합니다.
HTTP 서버는 보통 요청 단위 상태 코드로 결과를 표현합니다.
실행기 포화와 HTTP 응답
고정 작업 스레드와 유한 큐를 사용하면 동시에 처리할 연결 수와 대기 수를 제한할 수 있습니다.
포화가 발생했을 때 RejectedExecutionException을 accept 루프 밖으로 던져 리스너를 죽이면 안 됩니다.
거부된 연결에는 가능한 범위에서 503 Service Unavailable과 Connection: close를 보내고 닫거나, 즉시 close해 수용 실패를 기록합니다.
import java.time.Duration;
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() throws InterruptedException {
executor.shutdown();
if (!executor.awaitTermination(1, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
}
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();
}
}
}첫 작업은 실행 중이고 두 번째는 큐에 있으므로 뒤 요청은 OVERLOADED가 됩니다.
실제 순간 스케줄링에 따라 제출 직전 작업 스레드가 큐에서 항목을 꺼낼 수 있으므로 포화 테스트는 래치처럼 작업을 확실히 붙잡는 장치를 사용합니다.
운영에서는 거부율, 활성 작업 스레드, 큐 깊이, 요청 지연을 같은 시계열로 관찰합니다.
CallerRunsPolicy를 HTTP 연결 수락기에 사용하면 포화 시 accept 스레드가 요청을 직접 처리해 자연스러운 역압력을 만들 수 있습니다.
그러나 느린 클라이언트가 리스너 역할을 오래 멈추고 대기열이 차는 결과도 생깁니다.
명시적인 503이 서비스와 로드밸런서에 더 이해하기 쉬운 경우가 많습니다.
keep-alive 종료 조건
HTTP/1.1 지속 연결은 응답 하나를 보낸 뒤 같은 소켓에서 다음 시작 줄을 읽습니다.
요청별 객체와 헤더 map은 매번 새로 만들어야 하며, 이전 본문을 정확히 소비한 뒤에만 다음 반복으로 넘어갑니다.
연결을 영원히 유지하지 않도록 유휴 시간 제한, 연결당 최대 요청 수, 전체 연결 나이, 서버 종료를 종료 조건으로 둡니다.
| 종료 조건 | 목적 | 응답 헤더 | 소켓 행동 |
|---|---|---|---|
클라이언트 Connection: close | 명시적 재사용 중단 | Connection: close | 응답 뒤 close |
| 유휴 시간 제한 | 조용한 연결의 자원 회수 | 가능하면 408 | 즉시 close 가능 |
| 최대 요청 수 도달 | 장수 연결 편향 제한 | 마지막 응답에 close | 정상 종료 |
| 파싱·프레이밍 오류 | 다음 경계 신뢰 불가 | 400과 close | 재사용 금지 |
| 서버 종료 | 배포 비우기 | 마지막 응답 또는 close | 기한 내 해제 |
HTTP/1.1에서 Connection 헤더는 홉 단위이므로 프록시를 통과해 종단 의미로 저장하지 않습니다.
단순 서버는 Connection: close만 명확히 지원하고 keep-alive를 뒤 단계로 미루는 것도 올바른 범위 설정입니다.
지원한다고 선언했다면 파이프라인 요청 바이트가 도착하더라도 순서대로 응답하고 경계를 보존해야 합니다.
연습 문제
루프백 연결 하나로 GET /one과 GET /two를 연속 전송합니다.
서버는 각 요청의 빈 줄까지 읽고 본문이 없는 GET만 허용하며, 첫 응답은 keep-alive, 두 번째 응답은 Connection: close로 보냅니다.
클라이언트는 두 응답의 Content-Length만큼 본문을 각각 읽어야 합니다.
요청 수 상한과 응답 파서를 포함한 풀이
예제는 바이트 수준 readLine을 사용해 다음 요청을 미리 당겨 읽는 별도 문자 버퍼를 만들지 않습니다.
CRLF가 아닌 고립 LF는 거절하고, 줄 길이는 4096바이트로 제한합니다.
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());
}
}
}두 요청 대상이 각각 맞는 본문에 나타나고 서버 결과가 2이면 메시지 경계를 유지한 것입니다.
확장 과제에서는 첫 요청에 작은 POST 본문을 넣고 Content-Length만큼 소비한 뒤 두 번째 시작 줄이 정상적으로 읽히는지 확인합니다.
동시 서버 검증
느린 연결 하나가 있어도 별도 정상 연결이 응답을 받는지 확인합니다.
실행기 큐가 찼을 때 리스너가 종료되지 않고 거부 수가 증가해야 합니다.
연결 종료 후 활성 작업과 승인된 소켓 수가 0으로 돌아와야 하며 종료는 리스너 close, 세션 close, 실행기 대기 순서로 진행합니다.
keep-alive 테스트는 두 번째 요청까지 성공하는 경우뿐 아니라 첫 본문이 짧거나 길이 헤더가 충돌한 경우를 포함해야 합니다.
프레이밍을 신뢰할 수 없게 된 순간 400 응답 뒤 연결을 닫고 다음 요청을 해석하지 않습니다.
다음 문서에서는 요청 대상을 경로와 쿼리로 나누고, UTF-8 데이터를 ASCII 전송 형식 형태로 옮기는 퍼센트 인코딩을 엄격하게 처리합니다.