정리/WAS

순수 Java로 WAS 구현 (5) - HTTP 요청 파서(Parser) 구현과 3가지 트러블슈팅

baby-t 2026. 5. 15. 13:47

1. 아키텍처 설계: 점원과 번역가의 역할 분리

지난번 HTTP 스펙 분석을 통해, 클라이언트가 보내는 요청이 결국 '정해진 규칙을 가진 긴 문자열'이라는 것을 알게 되었습니다. 이제 이 문자열을 잘라서 의미 있는 자바 객체로 변환해 주는 파서(Parser)를 만들어야 합니다.

본격적인 코딩에 앞서, 객체지향적인 책임 분배를 위해 클래스 구조를 다음과 같이 설계했습니다.

  • WebApplicationServer: 가게 입구(Port 8080)를 지키며 소켓 연결을 무한 대기합니다.
  • RequestHandler: 손님이 오면 할당되는 '점원(Thread)'입니다. 입출력 스트림을 관리합니다.
  • HttpRequestParser: 문자열을 잘라주는 '번역가'입니다. (유틸리티 클래스로 활용)
  • HttpRequest: 파싱된 결과(Method, URI, Header, Body)를 예쁘게 담을 '바구니(DTO)'입니다.

 

2. 구현 1단계: Start Line (시작 줄) 파싱

브라우저가 보낸 첫 줄인 Start Line(예: GET /index.html HTTP/1.1)을 읽어 들여 메서드, URI, 버전을 추출합니다. 문자열의 첫 번째 줄만 읽으면 되므로 readLine()을 사용합니다.


// 1. Start Line 읽기
String startLine = in.readLine();
if (startLine == null) return null; // 비정상 종료 방어

// 공백을 기준으로 3토막 내기
String[] tokens = startLine.split(" ");

HttpRequest request = new HttpRequest();
request.setMethod(tokens[0].trim());
request.setUri(tokens[1].trim());
request.setVersion(tokens[2].trim());

 

3. 구현 2단계: Header (헤더) 파싱

헤더는 빈 줄(Empty Line)이 나올 때까지 여러 줄이 이어집니다. while 문을 돌며 필드명: 값 형태를 추출하여 Map에 저장합니다.


// 2. Header 읽기
String headerLine;
// 빈 줄("")이 나올 때까지 무한 반복
while ((headerLine = in.readLine()) != null && !headerLine.isEmpty()) {
    // 콜론을 기준으로 분리
    String[] headerTokens = headerLine.split(":", 2);
    request.addHeader(headerTokens[0].trim(), headerTokens[1].trim()); 
}

 

4. 구현 3단계: Message Body (바디) 파싱

바디는 헤더와 달리 끝을 알리는 기호가 없습니다. 따라서 헤더의 Content-Length를 확인한 뒤, 정확히 그 길이만큼만 바이트 단위로 읽어와야 합니다.


// 3. Body 읽기 (Content-Length가 있는 경우만)
if (request.getHeaders().containsKey("Content-Length")) {
    int contentLength = Integer.parseInt(request.getHeaders().get("Content-Length"));
    char[] bodyBuffer = new char[contentLength];
    
    // 정확히 contentLength만큼만 읽어서 배열에 저장
    in.read(bodyBuffer, 0, contentLength);
    request.setBody(new String(bodyBuffer));
}

 

5. 핵심 트러블슈팅: 구현 시 반드시 고려해야 할 3가지

파서를 구현하면서 마주했던 가장 중요한 기술적 판단 지점들을 정리해 보았습니다.

🤔 고민 1. 헤더 파싱에도 Jackson(JSON) 라이브러리를 써야 할까?
Jackson은 JSON 데이터를 파싱하는 도구입니다. 하지만 HTTP 헤더는 단순 텍스트 규약일 뿐 JSON이 아닙니다. 무거운 라이브러리 대신 순수 자바의 Map<String, String>을 사용하는 것이 가볍고 설계상으로도 정석임을 확인했습니다. (Jackson은 나중에 Body의 JSON을 파싱할 때 활약할 예정입니다!)
🚨 문제 2. split(":")의 치명적인 함정
단순히 split(":")을 쓰면 Host: localhost:8080 같은 데이터가 3토막으로 깨집니다. split(":", 2)를 사용하여 아무리 콜론이 많아도 딱 2등분(키와 값)만 되도록 방어 로직을 구축했습니다.
🚨 문제 3. 바디를 readLine()으로 읽으면 안 되는 이유
바디 데이터 끝에는 줄바꿈 기호(\r\n)가 없을 수 있습니다. 이때 readLine()을 쓰면 줄바꿈이 올 때까지 스레드가 영원히 대기하는 무한 블로킹(Blocking)에 빠집니다. 반드시 in.read()를 써서 정확한 길이만큼만 읽어야 합니다.

 

6. 실전 테스트: 브라우저 연동 및 Ghost Connection 문제 해결

이제 진짜 크롬(Chrome) 브라우저를 열어 주소창에 http://localhost:8080/test를 입력해 보았습니다. 콘솔에 브라우저가 보낸 날것의 데이터가 예쁘게 찍힙니다!


서버 소켓 작동 중... 손님을 기다립니다.
/0:0:0:0:0:0:0:1와 연결 성공!
Start Line: GET /test HTTP/1.1
추출된 메서드: GET
추출된 URI: /test
--- 파싱된 헤더 목록 ---
Key: [User-Agent], Value: [Mozilla/5.0 ...]
Key: [Host], Value: [localhost:8080]
... (중략) ...
----------------------

🚨 돌발 에러 발생: NullPointerException
테스트 도중 간헐적으로 다음과 같은 에러 로그가 찍혔습니다.


Exception in thread "Thread-1" java.lang.NullPointerException: Cannot invoke "HttpRequest.getMethod()" because "request" is null

 

이는 현대 브라우저가 성능 최적화를 위해 데이터 없이 빈 연결만 미리 맺어보는 'Ghost Connection' 특성 때문이었습니다. 파서가 null을 반환할 때의 방어 로직을 RequestHandler에 추가하여 해결했습니다.


// RequestHandler.java 버그 픽스
HttpRequest request = HttpRequestParser.parse(in);

if (request == null) return; // 의미 없는 빈 연결은 즉시 종료!

System.out.println("추출된 메서드: " + request.getMethod());

 

7. 마무리하며

순수 자바만으로 복잡한 HTTP 요청을 HttpRequest 객체로 완벽하게 변환해 냈습니다. 이 과정에서 라이브러리의 소중함과 동시에, 그 마법 같은 기능 뒤에 숨겨진 텍스트 처리와 예외 방어 로직의 본질을 이해할 수 있었습니다.

다음 포스팅에서는 이렇게 파싱된 요청들을 적절한 비즈니스 로직으로 연결해 주는 '리플렉션 기반의 동적 라우팅'을 다뤄보겠습니다.