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가지
파서를 구현하면서 마주했던 가장 중요한 기술적 판단 지점들을 정리해 보았습니다.
Jackson은 JSON 데이터를 파싱하는 도구입니다. 하지만 HTTP 헤더는 단순 텍스트 규약일 뿐 JSON이 아닙니다. 무거운 라이브러리 대신 순수 자바의
Map<String, String>을 사용하는 것이 가볍고 설계상으로도 정석임을 확인했습니다. (Jackson은 나중에 Body의 JSON을 파싱할 때 활약할 예정입니다!)단순히
split(":")을 쓰면 Host: localhost:8080 같은 데이터가 3토막으로 깨집니다. split(":", 2)를 사용하여 아무리 콜론이 많아도 딱 2등분(키와 값)만 되도록 방어 로직을 구축했습니다.바디 데이터 끝에는 줄바꿈 기호(
\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 객체로 완벽하게 변환해 냈습니다. 이 과정에서 라이브러리의 소중함과 동시에, 그 마법 같은 기능 뒤에 숨겨진 텍스트 처리와 예외 방어 로직의 본질을 이해할 수 있었습니다.
다음 포스팅에서는 이렇게 파싱된 요청들을 적절한 비즈니스 로직으로 연결해 주는 '리플렉션 기반의 동적 라우팅'을 다뤄보겠습니다.
'정리 > WAS' 카테고리의 다른 글
| 순수 Java로 WAS 구현 (7) - 톰캣의 정체와 프론트 컨트롤러(Front Controller)의 탄생 (0) | 2026.05.15 |
|---|---|
| 순수 Java로 WAS 구현 (6) - 프레임워크의 마법, 리플렉션(Reflection) 완벽 이해 (0) | 2026.05.15 |
| 순수 Java로 WAS 구현 (4) - 파싱(Parsing)을 위한 HTTP 메시지 구조 완벽 분석 (1) | 2026.05.14 |
| 순수 Java로 WAS 구현 (3) - 완벽한 양방향 통신 구현과 치명적인 삽질의 기록 (0) | 2026.05.13 |
| 순수 Java로 WAS 구현 (2) - 블로킹(Blocking) 해결을 위한 멀티스레드(Multi-Thread)의 이해 (0) | 2026.05.13 |