HTTP는 결국 TCP 위에서 동작하는 텍스트 프로토콜이다. 즉 다음과 같은 흐름이다
웹 브라우저 → TCP 연결 → HTTP 요청 메시지(텍스트) 전송 서버 → HTTP 응답 메시지(텍스트) 전송 → 웹 브라우저가 HTML 렌더링
소켓을 열고, 텍스트를 읽고, HTTP 규약에 맞춰 응답을 쓰면 된다
HTTPServerV1
public class HttpServerV1 {
private final int port;
public HttpServerV1(int port) {
this.port = port;
}
public void start() throws IOException {
ServerSocket serverSocket = new ServerSocket(port);
log("서버 시작 port: " + port);
while (true) {
Socket socket = serverSocket.accept(); // 연결 대기
process(socket);
}
}
private void process(Socket socket) 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);
}
}
}
public class ServerMainV1 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
HttpServerV1 server = new HttpServerV1(PORT);
server.start();
}
}
서버를 실행한 후 브라우저에서 http://localhost:12345에 접속하면 된다
설계 포인트
BufferedReader와 PrintWriter를 선택한 이유
HTTP는 텍스트 기반 프로토콜이다. 바이트 스트림(InputStream, OutputStream)보다 문자 스트림(Reader, Writer)이 훨씬 다루기 편하다. BufferedReader는 readLine()으로 한 줄씩 읽을 수 있고, PrintWriter는 문자열 출력 메서드들이 잘 갖춰져 있다. 스트림을 리더/라이터로 변환할 때는 반드시 인코딩(UTF-8)을 명시해야 한다
autoFlush를 false로 설정한 이유
new PrintWriter(socket.getOutputStream(), false, UTF_8) // ^^^^^ // autoFlush = false
autoFlush = true로 설정하면 println()을 호출할 때마다 즉시 네트워크로 데이터가 전송된다. 응답 헤더가 한 줄씩 여러 번 전송되는 셈이다. false로 설정하면 flush()를 직접 호출하는 시점에 버퍼에 쌓인 데이터가 한 번에 나간다. 전송 횟수를 줄이고, 한 패킷에 더 많은 데이터를 담을 수 있다. 단, 마지막에 writer.flush()를 반드시 호출해야 한다
HTTP 메시지 파싱 – requestToString()
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) {
break; // 빈 줄 = 헤더 끝
}
sb.append(line).append("\n");
}
HTTP 메시지의 구조는 다음과 같다
시작 라인 헤더1 헤더2 (빈 줄) 바디
빈 줄(/r/n/r/n)이 헤더와 바디를 구분한다. readLine()이 빈 문자열을 반환하는 순간이 바로 헤더가 끝나는 지점이다. 여기서는 바디를 사용하지 않으므로 헤더까지만 읽고 멈춘다
HTTP 응답 메시지 직접 조립 – responseToClient()
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);
- HTTP 응답도 동일한 구조다.
\r\n(CRLF)은 HTTP 공식 스펙에서 정의한 줄바꿈 표기다. 역사적으로 타자기에서 캐리지 리턴(커서를 맨 앞으로)과 라인 피드(다음 줄로)를 각각 신호로 보냈던 것에서 유래했다. 현대 브라우저는\n만 사용해도 잘 동작하지만, 스펙을 따르는 것이 올바르다 Content-Length에는 바이트 단위 길이를 명시한다. 문자열의 길이가 아니라body.getBytes(UTF-8).length를 사용하는 이유다. 한글처럼 멀티바이트 문자가 포함되면 문자 수와 바이트 수가 다르다
실제 요청/응답 메시지 확인
서버 로그에서 실제 HTTP 메시지를 눈으로 확인할 수 있다
브라우저가 보낸 요청
GET / HTTP/1.1 Host: localhost:12345 User-Agent: Mozilla/5.0 ... Chrome/128.0.0.0 ... Accept: text/html,application/xhtml+xml,... Accept-Encoding: gzip, deflate, br, zstd Accept-Language: ko,en;q=0.9
시작 라인(GET / HTTP/1.1)만 봐도 많은 정보가 담겨 있다. GET은 조회 메서드, /는 별도 경로가 없을 때의 기본 경로, HTTP/1.1은 프로토콜 버전이다. 이어지는 헤더들 중 Accept-Language: ko는 국제화 처리에 쓰이고, Accept-Encoding은 압축 방식을 협상하는 데 쓰인다
서버가 보낸 응답
HTTP/1.1 200 OK Content-Type: text/html Content-Length: 20 <h1>Hello World</h1>
200은 성공 상태 코드다. 400번대는 클라이언트 오류, 500번대는 서버 오류다
favicon.ico는 왜 오는가
브라우저 탭에 표시되는 작은 아이콘을 파비콘(favicon)이라 한다. 브라우저는 페이지를 로드할 때 /favicon.ico 경로로 아이콘을 별도로 요청한다.
한계 – 단일 스레드의 문제
sleep(5000)은 서버 처리에 5초가 걸린다는 상황을 시뮬레이션한다. 실제로 브라우저 두 개를 동시에 접속시켜 보면 문제가 드러난다
크롬 접속 → process() 실행 중 (5초)
브레이브 접속 → accept()에서 대기 중...
→ 크롬 완료 (5초 경과)
→ process() 실행 (5초 더)
브레이브 응답 완료 → 총 10초 소요
현재 서버는 main 스레드 하나가 요청을 순차적으로 처리한다. 첫 번째 요청이 끝나야 두 번째 요청을 처리한다. 실제 서비스라면 수천 명이 동시에 접속할 수 있다. 이 구조로는 절대 버틸 수 없다