Java로 HTTP 서버 만들기 — 소켓 위에 HTTP 올리기

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)이 훨씬 다루기 편하다. BufferedReaderreadLine()으로 한 줄씩 읽을 수 있고, 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 스레드 하나가 요청을 순차적으로 처리한다. 첫 번째 요청이 끝나야 두 번째 요청을 처리한다. 실제 서비스라면 수천 명이 동시에 접속할 수 있다. 이 구조로는 절대 버틸 수 없다

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