TCP는 경계를 모른다? 그럼 HTTP는 어떻게 메시지를 구분할까?

guts·2025년 3월 25일

1. 궁금증의 시작

Java로 HTTP 웹 서버를 구현하기 위해 HTTP 메시지들을 살펴보고 있었다.

GET /index.html HTTP/1.1\r\n
Host: example.com\r\n
Content-Length: 11\r\n
\r\n
Hello World

(참고로 GET인데 body가 있는 이유는 그냥 예시일 뿐이다.)
문득 이런 규칙을 발견했다.

Content-Length: 11
\r\n\r\n
  • 헤더 끝에 등장하는 \r\n\r\n
  • 메시지 본문의 길이를 알려주는 Content-Length

처음에는 단순한 포맷 규칙 정도로 생각했는데,
무든 "굳이 왜 이런 구조를 사용하지?"라는 의문이 들었다.
뭔가 기술적으로 필요한 이유가 있을 것 같았고,
한 단계 아래인 TCP의 동작 방식까지 살펴보게 되었다.

2. TCP와 HTTP

웹에서 사용하는 HTTP는 Application layer 프로토콜이고, 그 아래에서 HTTP 데이터를 실어 나르는 것은 TCP(Transport layer)이다.

이 둘은 엄연히 역할이 다르다.

계층프로토콜책임
Application LayerHTTP메시지의 구조, 의미 해석
Transport LayerTCP데이터 전송, 신뢰성 유지

그리고 TCP는 Byte Stream-Oriented 라는 특징을 가지고 있다.

TCP는 메시지를 chunk(덩어리) 단위로 처리하지 않는다. 바이트 스트림을 연속적으로 전송할 뿐이다. (참고로 UDP는 덩어리 단위로 전송한다.)

예를 들어, 클라이언트가 두 번에 나눠서 메시지를 보냈더라도, 서버는 하나의 연속된 바이트 스트림으로 받을 뿐이다.

즉, TCP는 절대로 말해주지 않는다.

  • "이게 첫 번째 메시지야"
  • "여기가 메시지의 끝이야"

그렇다면..! 메시지의 경계는 누가 판단할까?!

3. 메시지 경계를 스스로 구분하는 HTTP

바로 여기서 HTTP 구조의 진짜 의미가 드러난다.

TCP가 바이트 스트림만 전달하기 때문에, HTTP는 스스로 메시지의 시작과 끝을 명확하게 정의해야 한다.

그래서 사용하는 게 바로

  • \r\n\r\n -> 헤더와 바디를 구분하는 경계
  • Content-Length -> 바디의 길이를 알려주는 명시적인 수치

이 규칙이 없으면, 수신 측은 모를 것이다.

  • 헤더가 어디까지이지?
  • 바디는 몇 바이트이지?
  • 다음 메시지는 어디서 시작하지?

HTTP는 말 그대로 바이트를 읽으면서 문법적으로 메시지를 인식하는 구조이다. 그래서 규칙이 무너지면 파싱 자체가 불가능해진다. (프로토콜이 약속인 이유..)

4. 직접 파싱해보는 HTTP 메시지 (Java 예시)

웹 서버를 만들기 위한 기본 작업 중 하나는 TCP 소켓으로 들어오는 HTTP 요청을 파싱하는 것이다.

아래는 내가 파싱했던 코드이다.
참고로 inputStream을 reader로 감싼 후, bufferedReader로 읽는 것이다.

// startline 파싱
 public static StartLine from(BufferedReader br) throws IOException {
        String startLine = br.readLine();
        if (startLine == null || startLine.isEmpty()) {
            throw new IllegalArgumentException("Start line cannot be null or empty");
        }

        String[] parts = startLine.split(START_LINE_DELIMITER);
        if (parts.length != 3) {
            throw new IllegalArgumentException("Invalid HTTP start line format: " + startLine);
        }

        HttpMethod method;
        try {
            method = HttpMethod.valueOf(parts[0]);
        } catch (IllegalArgumentException e) {
            throw new IllegalArgumentException("Invalid HTTP method: " + parts[0]);
        }

        Path path = Path.from(parts[1]);

        HttpVersion version = HttpVersion.from(parts[2]);

        return new StartLine(method, path, version);
    }
// 헤더 파싱
public static Map<String, String> parseHeader(BufferedReader br) throws IOException {
        HashMap<String, String> parsedHeader = new HashMap<>();

        String line;
        while ((line = br.readLine()) != null && !line.isEmpty()) {
            int idx = line.indexOf(":");
            if (idx != -1) {
                String key = line.substring(0, idx).trim();
                String value = line.substring(idx + 1).trim();
                parsedHeader.put(key, value);
            }
        }
        return parsedHeader;
    }
// 바디 파싱
public static RequestBody from(BufferedReader br, int contentLength) throws IOException {
        if (contentLength == 0) {
            return empty();
        }

        char[] buffer = new char[contentLength];
        br.read(buffer, 0, contentLength);

        String requestBody = new String(buffer).trim();
        Map<String, String> parsedBody = RequestParser.parseQuery(requestBody);

        return new RequestBody(parsedBody);
    }

바디를 파싱할 때는 Content-Length를 기반으로 파싱하는 것을 확인할 수 있다.

5. 결론

공부하면서 발견했던 \r\n, Content-Length 같은 규칙들은
단순한 포맷이 아니라 TCP의 stream-oriented 특성 때문에 반드시 필요한 메시지 구분 장치였다.
TCP는 데이터를 전송만 하고,
메시지를 식별하고 해석하는 건 전적으로 Application Layer의 책임이다.

왜 이 구조를 가지고 있는지 처음 추측했을 때는 reliable을 보장하기 위한 간접적인 방법이 아닐까 생각을 했었는데, 공부를 하다보니 HTTP 말고도 다른 Application Layer의 프로토콜들도 범용적으로 cover할 수 있게 이런 구조를 가지고 있는 것이 아닐까 생각하게 됐다. 자세한건 더 알아봐야할 것 같다.

참고 자료

https://stackoverflow.com/questions/3017633/difference-between-message-oriented-protocols-versus-stream-oriented-protocols

https://stackoverflow.com/questions/17446491/tcp-stream-vs-udp-message

profile
가자

0개의 댓글