웹 서버발행일 2025. 10. 2.원본 https://blog.naver.com/jword_/224030039678 ↗

웹 서버 구현 (소켓통신, HTTP/1,1)

웹 서버 구현 (소켓통신, HTTP/1,1) — #웹서버소켓통신 #웹서버TCP #TCP소켓통신 #개발자의 도구들 학습 목표 웹 서버에 필요한 TCP 소...

#webserver#Naver Blog

#웹서버소켓통신 #웹서버TCP #TCP소켓통신 #개발자의 도구들

​

학습 목표

  1. 웹 서버에 필요한 TCP 소켓 통신 공부 범위를 정한다.
  2. TCP 소켓에서 받은 HTTP 요청 패킷을 파싱한다.
  3. TCP 소켓으로 HTTP 응답 패킷을 보낸다.

공부 범위 지정

웹 서버를 만들기 위해 TCP에 대해 모든 것을 알아야하는 것이 아닙니다.

javascript 코드 예제
                                    ┌─────────────────────────────────────────
│  Application Layer (HTTP)                 ← 우리가 직접 구현할 부분
│  - HTTP 요청/응답 파싱
│  - 비즈니스 로직
├─────────────────────────────────────────
|                                           ← Socket(Java Socket API): Socket을 추상화
|─────────────────────────────────────────
│  Transport Layer (TCP)                    ← OS가 추상화(BSD Socket 등)
│  - 연결 관리 (3-way handshake)
│  - 데이터 전송 보장
│  - 포트 번호로 애플리케이션 구분
├─────────────────────────────────────────
│  Internet Layer (IP)                      ← OS가 처리
│  - 라우팅, 주소 지정
├─────────────────────────────────────────
│  Network Access Layer                     ← 하드웨어/드라이버

다행히도 네트워크 레이어가 잘 나눠져 있기 때문에, 우리는 Applcation Layer만 집중적으로 공부하면 된다. 3-way handshake 같은 부분은 Java Socket API가 이미 다 추상화 해두었습니다.

​

해당 레이어에서 공부하게될 대표적인 부분은 바로

  1. HTTP 프로토콜 파싱
  2. 파싱 이후 비즈니스 로직 수행

​

이 중에서도 이번글에서 집중적으로 다루고자 하는 내용은 바로 HTTP 프로토콜 파싱입니다.

Java Socket API

Trnasport Layer는 Java Socket API가 추상화 해두었기 때문에, 이 API에 대해 깊게 알아두면 프로젝트를 진행하는데 있어서 도움이 될거라고 판단했습니다.

​

서버측에서 고려해야할 주요 클래스는 다음 두가지입니다.

  • ServerSocket
  • Socket

​

​

📌 ServerSocket Class

​

소켓 생성 및 연결을 담당하는 Class이다.

javascript 코드 예제
                                    // 포트 바인딩
ServerSocket server = new ServerSocket(8080);

// 연결 종료
server.close()

server는 포트만 지정하면 기본적으로 모든 네트워크 인터페이스(0.0.0.0)에 바인딩됩니다. 즉 어떤 클라이터든 내 컴퓨터 IP:Port로 접근이 가능합니다.

​

Localhost로만 접근이 가능하게 하고 싶다면 아래와 같이 사용하면 됩니다.

javascript 코드 예제
                                    // 포트 바인딩
ServerSocket server = new ServerSocket(8080, 50, InetAddress.getByName("127.0.0.1"));

​


📌 Socket Class

​

데이터 송수신을 담당하는 클래스입니다.

javascript 코드 예제
                                    // client 소켓 연결하기
Socket client = server.accept();

// 데이터 읽기
InputStream in = socket.getInputStream();
BufferedReader reader = new BufferedReader(
    nwe InputStreamReader(in, StandardCharsets.UTF_8)
);

// 쓰기
OutputStream out = socket.getOutputStream();
PrintWriter writer = new PrintWriter(out, true); // auto-flush

기본 메서드

javascript 코드 예제
                                    // 데이터 입출력
InputStream getInputStream();
OutputStream getOutputStream();

// 연결 관리
void close();                  // 연결 종료
boolean isClosed();            // 종료 여부
boolean isConnected();         // 연결 여부

// 옵션
void setSoTimeout(int ms)      // 읽기 타임아웃
void setTcpNoDeply(boolean on) // Nagle 알고리즘 on/off
void setKeepAlive(boolean on)  // TCP Keep-Alive

// 정보 조회
InetAddress getInetAddress()   // 클라이언트 IP
int getPort()                  // 클라이언트 포트

소켓 통신 테스트 코드

javascript 코드 예제
                                    @NoArgsConstructor
public class Server {

    public void start() {
        int port = 8080;

        try (ServerSocket serverSocket = new ServerSocket(port, 50, InetAddress.getByName("127.0.0.1"))) {
            System.out.println("서버가 포트 " + port + "에서 대기 중...");

            while (true) {
                Socket client = serverSocket.accept();

                System.out.println("클라이언트가 연결됨: " + client.getInetAddress().getHostName());

                client.setSoTimeout(5000);
                System.out.println("클라이언트 연결을 닫습니다");
                client.close();
                System.out.println("클라이언트 연결 종료?: " + client.isClosed());
            }
        } catch (IOException e) {
            System.err.println("IOException: " + e.getMessage());
        }
    }
}

웹 브라우저나 Postman으로 테스트 해보면 커넥션이 잘되는 것을 볼 수 있습니다.

javascript 코드 예제
                                    서버가 포트 8080에서 대기 중...
클라이언트가 연결됨: 127.0.0.1
클라이언트 연결을 닫습니다
클라이언트 연결 종료?: true

HTTP Request Parsing: Essential

소켓 통신에서는 스트림을 사용하여 데이터를 바이트 단위의 연속된 흐름으로 처리합니다. HTTP/1.1 요청 메세지가 들어오면 일련의 바이트를 읽어들여 파싱하는 과정이 필요합니다.

​

HTTP/1.1 요청은 아래와 같이 3부분으로 구성되어있습니디.

javascript 코드 예제
                                    GET /api/data HTTP/1.1           ← Request Line
HOST: localhost:8080             ← Header
Content-Type: application/json   ← Header
Content-Length: 27               ← Header
       ...
                                 ← 빈 줄 (CRLF)  🎯 필수적으로 헤더 종결자를 인식해야함!
{"name":"test", "value":123}     ← Body (optional)

⚠️ HTTP/1,1 주의 사항

  • 라인 끝: \\r\\n
  • 헤더 끝: \\r\\n\\r\\n

​

javascript 코드 예제
                                    // line 인식 처리
BufferedReader reader = new BufferedReader(
    new InputStreamReader(inputStream, StandardCharsets.UTF_8)
);

String line = reader.readline(); // String 형태로 만들어주는 편리한 기능

요청 메세지는 클라이언트에서 만들어서 보내기 때문에, 서버 측에서는 형식(rule)에 맞게 파싱하고 필요한 정보만 캐치하면 됩니다.

​

위에 요약된 HTTP/1.1 요청 예시는 가장 중요하게 파싱해야할 요소들만 포함하고 있습니다.

​

​

Request Line

javascript 코드 예제
                                    GET /api/data HTTP/1.1           ← Request Line

가장 첫번째 라인이자 근본 라인입니다. 여기서 HTTP/1.1 프로토콜 확인, 메서드 확인, URI를 확인하기 때문에 없다면 HTTP/1.1이 아니라 간주하고 처리하지 않아도 됩니다.

​

​

Header Line

javascript 코드 예제
                                    ----------- Request Line -----------------
HOST: localhost:8080             ← Header
Content-Type: application/json   ← Header
Content-Length: 27               ← Header
                                 ← empty(🚀essential)

두번째 줄부터 마지막 공백 줄까지 모두 Header 라인입니다. 파싱해서 key-value 형태로 저정해두면 참고하기 좋습니다.

​

Host의 경우 HTTP/1.1에서 필수로 지정하라고 약속된 헤더이기 때문에 반드시 클라이언트에서 포함시켜야 합니다.

  • 없다면 400 Bad Request 반환

​

프로토콜에 Body가 들어있다면, 반드시 Content-Type 및 Content-Length를 요청측에서 보내야 합니다. 특히나 Content-Length를 입력하지 않는다면, 데이터 스트림의 특성상 어디까지 Body인지 알지 못합니다.

​

​

🔥 기타 권장 헤더

javascript 코드 예제
                                    Connection: keep-alive         ← 연결 유지할지 결정: 성능 튜닝
Accept: application/json       ← 클라이언트가 원하는 응답 형식
User-Agent: Mozilla/5.0        ← 특정 브라우저별 대응이 가능

🚀 심화

javascript 코드 예제
                                    Authorization: Bearer {token}           ← 회원 기능
Cookie: sessionId=abc123; userId=42     ← 세션기반 인증, 사용자 추적
Origin: https://frontend.example.com    ← CORS 정책 적용하기 위해서
Transfer-Encoding: chunked              ← Content-Lenght를 모를 때 (스트리밍, 동적 생성)

한번에 모든걸 다하기는 어려우니, 가장 기본적인 파싱과정 부터 실행해 봅시다.

​

​

🎯 Body

패킷에서 Body를 읽기 위한 방법

  1. Content-Length를 읽어들인다.
  2. 해당 길이 만큼 InputStream에서 데이터를 읽는다.

​

Body는 우선 String으로 읽어들인 후 Json으로 이후에 변환할 예정입니다.

​


HTTP Request Parsing: Essential Code

특수한 경우가 아니라면 parsing에 필요한 알고리즘을 직접 개발할 필요가 없습니다. MVP 원칙에 따라 이미 유명한 알고리즘은 웹 검색 혹은 GPT와 같은 GenAI를 통해서 빠르게 개발을 진행할 수 있습니다.

​

그보단 서버를 안정적으로 설계하는데에 집중합시다.

​

Server

javascript 코드 예제
                                    @AllArgsConstructor
public class Server {
    private final HttpRequestParser httpRequestParser;

    public void start() {
        int port = 8080;
        try (ServerSocket serverSocket = new ServerSocket(port, 50, InetAddress.getByName("127.0.0.1"))) {
            System.out.println("서버가 포트 " + port + "에서 대기 중...");

            while (true) {
                Socket client = serverSocket.accept();
                InputStream in = client.getInputStream();
                System.out.println("클라이언트가 연결됨: " + client.getInetAddress().getHostName());

                // input
                BufferedReader reader = new BufferedReader(
                        new InputStreamReader(in, StandardCharsets.UTF_8)
                );
                String requestLine = reader.readLine();

                // get requestLine parts
                List<String> requestParts = httpRequestParser.parseRequestLine(requestLine);

                // parse URI

                // Read header lines
                List<String> headerLines = new ArrayList<>();
                String line;
                while ((line = reader.readLine()) != null && !line.isEmpty()) {
                    headerLines.add(line);
                }
                HashMap<String, String> headers = httpRequestParser.parseHeaders(headerLines);

                // ⚠️ missing error handling
                String contentLengthStr = headers.get("Content-Length");
                if (contentLengthStr != null) {
                    int contentLength = Integer.parseInt(contentLengthStr);
                    char[] bodyBuf = new char[contentLength];
                    int totalRead = 0;

                    while (totalRead < contentLength) {
                        int charsRead = reader.read(bodyBuf, totalRead, contentLength - totalRead);
                        if (charsRead == -1) break;
                        totalRead += charsRead;
                    }

                    String body = new String(bodyBuf, 0, totalRead);
                    System.out.println("Body: " + body);
                }

                System.out.println("requestLine: " + requestParts.toString());
                System.out.println("Headers: " + headers);
                client.setSoTimeout(5000);
                System.out.println("클라이언트 연결을 닫습니다");
                client.close();
                System.out.println("클라이언트 연결 종료?: " + client.isClosed());
            }
        } catch (IOException e) {
            System.err.println("IOException: " + e.getMessage());
        }
    }
}

​

Request Line

javascript 코드 예제
                                    // GET /api/data HTTP/1.1           ← Request Line

public List<String> parseRequestLine(String requestLine) {
        // BufferedReader => String으로 바로 읽어들임
        // requestLine example:
        // GET /index.html HTTP/1.1\r\n

        String[] requestLineParts = requestLine.split(" ");
        if (requestLineParts.length != 3) {
            return null;
        }

        // requestLineParts[0] = method (GET, POST, etc.)
        // requestLineParts[1] = path (/index.html)
        // requestLineParts[2] = version (HTTP/1.1)
        return List.of(requestLineParts[0], requestLineParts[1], requestLineParts[2].trim());
    }

​

​

Headere Parsing

​

javascript 코드 예제
                                    /*
* ----------- Request Line -----------------
* HOST: localhost:8080             ← Headers
* Content-Type: application/json   ← Headers
* Content-Length: 27               ← Headers
                                   ← empty(🚀essential)
*/

public HashMap<String, String> parseHeaders(List<String> headerLines) {
        // headerLine example:
        // Host: localhost:8080
        // User-Agent: Mozilla/5.0
        // Accept: text/html

        HashMap<String, String> headers = new HashMap<>();

        for (String line : headerLines) {
            if (line == null || line.trim().isEmpty()) {
                continue;
            }

            int colonIndex = line.indexOf(":");
            if (colonIndex > 0) {
                String key = line.substring(0, colonIndex).trim();
                String value = line.substring(colonIndex + 1).trim();
                headers.put(key, value);
            }
        }

        return headers;
    }

Body

javascript 코드 예제
                                    // ⚠️ missing error handling
String contentLengthStr = headers.get("Content-Length");
if (contentLengthStr != null) {
    int contentLength = Integer.parseInt(contentLengthStr);
    char[] bodyBuf = new char[contentLength];
    int totalRead = 0;

    while (totalRead < contentLength) {
        int charsRead = reader.read(bodyBuf, totalRead, contentLength - totalRead);
        if (charsRead == -1) break;
        totalRead += charsRead;
    }

    String body = new String(bodyBuf, 0, totalRead);
    System.out.println("Body: " + body);
}
  • ⚠️ 다른 InputStream으로 읽을 필요없음: 아래 오류 참고
  • BufferedReader가 스트림에 들어온 데이터를 이미 읽어버림
  • 8KB(기본 버퍼 크기) 단위로 미리 내부 버퍼에 저장해두고 있다.

​

들어온 Body는 현재 String인데 이후 적절한 형식으로 변환 후 처리해줘야 합니다.

​

테스트 결과

javascript 코드 예제
                                    클라이언트가 연결됨: 127.0.0.1
Body: {
    "id": "hello"
}
requestLine: [GET, /, HTTP/1.1]
Headers: {User-Agent=PostmanRuntime/7.48.0, Connection=keep-alive, Postman-Token=9a3cc659-5e95-46a2-8f25-b299a39e7a7c, Host=localhost:8080, Accept-Encoding=gzip, deflate, br, Content-Length=23, Content-Type=application/json}
클라이언트 연결을 닫습니다
클라이언트 연결 종료?: true

​

​


HTTP/1.1 Response Packet

간단하게 HTTP/1.1 패킷을 만들어 응답하는 코드를 작성하겠습니다.

​

다음사항을 주목하여 주세요.

  1. HTTP/1.1 응답 기본 형식
  2. 필수헤더

​

​

javascript 코드 예제
                                    // 🎯 심플응답
HTTP/1.1 200 OK                       ← Status Line
Content-Type: text/plain              ← Header
Date: Sun, 05 Oct 2025 10:30:00 GMT   ← Header
Connection: keep-alive                ← Header
Content-Length: 12                    ← Header
                                      ← empty(🚀 essential)
Hello World!                          ← Body

Status-Line

요청과 마찬가지로 가장 첫줄에 무조건 필수적으로 포함시켜야 하는 라인입니다.

javascript 코드 예제
                                    /*
* HTTP-Version Status-Code Response-Phrase
*/
HTTP/1.1 200 OK

​

Response-Header

요청과 마찬가지로 \\r\\n으로 구분되며 헤더의 끝은 \\r\\r입니다.

javascript 코드 예제
                                    ----- Status-Line -------
Content-Type: text/plain                ← Header
Date: Sun, 05 Oct 2025 10:30:00 GMT     ← Header
Connection: keep-alive                  ← Header
Content-Type: text/html; charset=UTF-8  ← Header
Content-Length: 12                      ← Header
                                        ← empty(🚀 essential)
Hello World!    👉 body

🔥 필수 헤더

  • 스택상 반드시 포함해야 하는 헤더들
javascript 코드 예제
                                    Date: Sun, 05 Oct 2025 10:30:00 GMT      ← 응답이 생성된 시각
Content-Length                           ← Body가 존재하면 반드시 포함

🚀 강력 추천 헤더

  • 실무에서 거의 항상 포함해야하는 헤더들

​

  1. Content-type
javascript 코드 예제
                                    Content-Type: text/html; charset=UTF-8   ← 응답 본문의 MIME타입

/* 주요 타입
* text/html; charset=UTF-8
* text/plain; charset=UTF-8
* application/json
* application/xml
* application/octet-stream  (바이너리 파일)
* image/jpeg, image/png, image/gif
* text/css
* application/javascript
*/

2. Connection

javascript 코드 예제
                                    Connection: keep-alive
Connection: close

HTTP/1.1에서는 keep-alive가 기본입니다.

​

3. Server

javascript 코드 예제
                                    Server: MyWebServer/1.0
Server: Apache/2.4.41

서버 정보

​

나머지 헤더들은 기능에 맞게 조사해서 추가해주면 됩니다.

​

HTTP/1.1 Response (Labs)

이번글은 아주 간단한 HTTP/1.1 패킷을 만들어 클라이언트에게 보내겠습니다.

​

​

핵심 API

​

📌 OutputStream

javascript 코드 예제
                                    Socket client = serverSocket.accept();
OutputStream out = client.getOutputStream();
  • InputStream과 마찬가지로 Socket별로 OutputStream을 열어줍니다.
  • Byte로 응답을 전송해줘야 합니다.

​

📌 입력을 하는 여러가지 방법

​

  1. OutputStream에 직접하기
  1. OutputStreamWriter 사용하기
  1. PrintWriter 사용하기 (🔥 추천!)

​

​

👉. OutputStream에 직접 바이너리 데이터 쓰기

javascript 코드 예제

String response = "HTTP/1.1 200 OK\r\n\r\nHello From My Server!";
byte[] bytes = response.getBytes(StandardCharsets.UTF_8);
out.write(bytes);
out.flush(); // 버퍼를 꼭 비워줘야 한다.

👉 OutputStreamWriter 사용

javascript 코드 예제
                                    OutputStreamWriter writer = new OutputStreamWriter(out, StandardCharsets.UTF_8);

writer.write("HTTP/1.1 200 OK\r\n");
writer.write("Content-Type: text/plain\r\n");
writer.write("\r\n");
writer.write("Hello World");
writer.flush();
  • 문자열을 자동으로 바이트로 변환해준다.
  • 편하다
  • 인코딩도 지정할 수 있다.

​

👉 PrinterWrtier 사용(🔥)

javascript 코드 예제
                                    PrintWriter writer = new PrintWriter(
    new OutputStreamWriter(out, StandardCharset.UTF_8),
    true  // autoFlush
);

writer.print("HTTP/1.1 200 OK\r\n");
writer.print("Content-Type: text/html\r\n");
writer.print("\r\n");
writer.print("<h1>Hello World</h1>");
  • pirnt(), println() 메서드 제공
  • printf() 형식 지원
  • autoFlush 기능 등

​

javascript 코드 예제
                                    OutputStream out = client.getOutputStream();

// Headers
String body = "Hello From Server!";
byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8);
writer.print("Content-Type: text/html; charset=UTF-8\r\n");
writer.print("Content-Length: " + bodyBytes.length + "\r\n");
writer.print("Connection: close\r\n");
writer.print("\r\n");  // 빈 줄
writer.flush(); // Header 먼저 보내기

out.write(bodyBytes);
out.flush();

​

📌결과

이미지

이미지

서버에서 생성한 응답 패킷이 잘 도착한 것을 볼 수 있습니다.

​

마치며

지금껏 Socket 통신으로 HTTP/1.1 요청 파싱, 응답 생성을 하는 방법에 대해 알아보았습니다. 개발하고 느낀 핵심 내용을 아래 요약했습니다.

​

  1. 프로토콜은 결곡 서버와 클라이언트의 소통도구
  • 헤더, Body 등 필요한 정보를 건내는 것일 뿐
  1. 프로토콜 정보를 조작하면 커스터마이징한 서버 완성

-

  • 커스텀 헤더 추가하여 원하는 로직을 만들어 낼 수 있지 않을까? 라는 기대감이 생김

​

결과 코드: https://github.com/keyveloper/markdown\_web\_server/tree/basic

Trouble Shooting

오류 발생 코드

javascript 코드 예제
                                    @AllArgsConstructor
public class Server {
    private final HttpRequestParser httpRequestParser;

    public void start() {
        int port = 8080;
        try (ServerSocket serverSocket = new ServerSocket(port, 50, InetAddress.getByName("127.0.0.1"))) {
            System.out.println("서버가 포트 " + port + "에서 대기 중...");

            while (true) {
                Socket client = serverSocket.accept();
                InputStream in = client.getInputStream();
                System.out.println("클라이언트가 연결됨: " + client.getInetAddress().getHostName());

                // input
                BufferedReader reader = new BufferedReader(
                        new InputStreamReader(in, StandardCharsets.UTF_8)
                );
                String requestLine = reader.readLine();

                // get requestLine parts
                List<String> requestParts = httpRequestParser.parseRequestLine(requestLine);

                // parse URI

                // Read header lines
                List<String> headerLines = new ArrayList<>();
                String line;
                while ((line = reader.readLine()) != null && !line.isEmpty()) {
                    headerLines.add(line);
                }
                HashMap<String, String> headers = httpRequestParser.parseHeaders(headerLines);

                // ⚠️ missing error handling
                String contentLengthStr = headers.get("Content-Length");
                if (contentLengthStr != null) {
                    int contentLength = Integer.parseInt(headers.get("Content-Length"));
                    byte[] bodyBuf = new byte[contentLength];
                    int totalRead = 0;
                    while (totalRead < contentLength) {
                        int bytesRead = in.read(bodyBuf, totalRead, contentLength - totalRead);
                        if (bytesRead == -1) break;
                        totalRead += bytesRead;
                    }

                    String body = new String(bodyBuf, 0, totalRead, StandardCharsets.UTF_8);
                    System.out.println("Body: " + body);
                }

                System.out.println("requestLine: " + requestParts.toString());
                System.out.println("Headers: " + headers);
                client.setSoTimeout(5000);
                System.out.println("클라이언트 연결을 닫습니다");
                client.close();
                System.out.println("클라이언트 연결 종료?: " + client.isClosed());
            }
        } catch (IOException e) {
            System.err.println("IOException: " + e.getMessage());
        }
    }
}

오류 발생 상황: 클라이언트에서 body를 보내면 무한 대기한다.

​

오류 원인: 한 클라이언트 내에서 InputStream read를 각각 사용해서

  • BufferedReader
  • body 파싱의 in.read()

​

발생 원인:

BuffredReader에서 inputStream을 사용하는데 이 버퍼는 기본단위인 8KB를 한번에 읽어들인 후 가지고 있다.

​

그런 상황에서 in.read()를 따로 호출하게 되면, 스트림의 모든 데이터가 소진되었으므로 읽을게 없이 무한 로딩상황이 발생한다.