PHP로 HTTP 서버 구현하기 - 08 - 요청 메시지 파싱 1

연재글 전체 보기


이제 요청 들어온 메시지를 적당히 분리하는 작업을 하려고 한다.

지난 시간에 우리는..

마지막으로 커밋된 소스는 여기에서 볼 수 있다.

요청 분석 클래스인 Request.php만 보자면

<?php
class Request
{
private $requestMessage;

public function __construct(string $requestMessage)
{
//하지만 아직 쓰진 않지
$this->requestMessage = $requestMessage;
}

public function getResourcePath()
{
return '/index.html';
}
}

테스트 코드는

//RequestTest.php

// ...생략...
public function testGetResourcePath(): void
{
$request = new Request('');
$this->assertEquals('/index.html', $request->getResourcePath());
}

고백하자면 Request 클래스의 생성자에 string 형식만 올 수 있게 조금 고쳐놨다.

요구 사항 정리

이제 생성자에 다양한 패턴의 문자열을 던지고 이를 적당한(?) 구조에 넣어줄 것이다.

개발을 시작하기 전에 요구 사항(HTTP의 문법)부터 면밀히 살펴보자.

(그런데 사실 이미 HTTP 메시지 파서는 널리고 널려서 굳이 직접 구현할 필요는 없다. 공부나 하자는 것이지..)

HTTP 메시지에 관한 표준은 RFC 7230, HTTP/1.1: Message Syntax and Routing에 정의되어 있다.

  • 아직도 RFC2616으로 설명하는 글이 많은데, 바뀐지 3년 지났다

귀찮다면 뭐…이전에 정리한 3장 정리 자료를 봐도 된다.

기본적인 HTTP 메시지의 문법을 다시 한번 살펴보자.

HTTP-message   = start-line
*( header-field CRLF )
CRLF
[ message-body ]

HTTP 메시지

  • 시작줄
  • 0개 이상의 헤더
  • (헤더의 끝을 인식하기 위해) 아무 것도 없는 빈 줄
  • (생략 가능한) 본문

각 항목은 CRLF로 구분하도록 정의되어 있다.

  • 하지만 받는 쪽에서는 CR은 무시하고 LF만 인식할 수도 있다.
  • CRLF를 제외하고 앞뒤로 붙는 공백은 제거한다

시작줄에는 공백(8bit 문자)으로 구분되는 세가지 정보가 들어간다.

  • 요청 시 : HTTP 메소드, URI, HTTP 버전
  • 응답 시 : HTTP 버전, 상태 코드, 상태 메시지

요청 메시지에서, HTTP는 URI(Uniform Resource Identifier)를 표현하는데 RFC3986​​표준을 사용한다.

  • 그런데 이 표준을 보고 올바른 URI인지 판별할 생각은 없으므로 상식으로만 알아두자

이정도를 기본 문법으로 정의하고, 이외의 문법으로 요청이 들어오면 서버가 이해하지 못한다는 의미로 400 Bad Request로 응답한다.

이를 기초로 기능을 간단히 정리해보면

CRLF로 문자열을 나눈다
- 첫 요소를 시작줄로
- 이후 빈 문자열이 들어올 때까지 헤더로
- 그 다음 요소가 존재하면 본문으로 인식한다
- 각 요소를 저장할 땐 trim
시작줄은 공백으로 3파트로 나뉘어야 한다
- 메소드
- URI
- HTTP 버전
이 파싱 기준을 조금이라도 벗어나면 가차없이 400!!

parseMessage() 메소드 추가

파싱을 시작하라고 요청하는 메소드는 parseMessage()라는 이름이 적당할 것 같고, 이제 테스트를 만들어 나갈 타이밍인데 바로 고민이 생긴다.

생성자에 문자열을 넣는 동시에 파싱을 할 것인가?

public function __construct(string $requestMessage)
{
$this->requestMessage = $requestMessage;
$this->parseMessage();
}

아니면 getResourcePath()를 실행할 때 파싱을 실행할 것인가?

public function getResourcePath()
{
$this->parseMessage();
return $this->resourcePath;
}

아니면 parseMessage() 메소드를 public으로 만들어 외부에서 명시적으로 호출하고, 이어 getResourcePath()로 리소스 경로를 얻어와야 하나?

//server.php
$request = new Request($clientSentData);
$request->parseMessage();
$response = (new Response())->getResponse($request->getResourcePath());

아직 server.php 이외에 이 Request 클래스를 사용하는 곳이 없으므로 파싱 기능을 굳이 외부에 노출 시킬 필요가 없기 때문에, 우선은 parseMessage() 메소드를 private으로 묶어두기로 했다.

외부에 노출할 경우, 이 분석 클래스를 바라보는 클라이언트가 늘어갈수록 수정하는데 눈치가 보인다. 공개 API가 덕지덕지 버전을 붙여가는 이유랄까.

외부 노출은 최소한으로 줄이고, 내부 구현을 마음껏 리팩토링하자.

getResourcePath() 안에서 parseMessage() 메소드를 호출하도록 바꿔보자.

server.php 입장에선 Request 클래스를 사용하는 방법이 바뀐 게 아니므로 추가 테스트는 작성 안한다.

//Request.php
public function getResourcePath()
{
$this->parseMessage();
return $this->resourcePath;
}

테스트는 바로 실패하고

Call to undefined method Request::parseMessage()

테스트를 통과하도록 수정한다.

<?php
Request.php
class Request
{
private $requestMessage;
private $resourcePath;

public function __construct(string $requestMessage)
{
//하지만 아직 쓰진 않지
$this->requestMessage = $requestMessage;
}

public function getResourcePath()
{
$this->parseMessage();
return $this->resourcePath;
}

private function parseMessage()
{
$this->resourcePath = '/index.html';
}
}

테스트는 성공한다.

중간 점검

앞으로는 뭘 해야할까?

parseMessage() 안에서 resourcePath를 CRLF로 잘라 분리해놓고,

/라는 URI로 요청이 왔으면 /index.html을 리턴하는 로직도 들어가야 하고,

이해 안되는 구문이면 외부에 알려야 한다.

  • “이건 Bad Bad Request야”

그런데 그 과정에서 몇개의 private 메소드가 더 생길 것이고(메소드 하나에는 하나의 기능을 담는 것이 좋으니)

모두 완료될 때까지 외부(server.php)에 노출되는 건 계속 parseMessage() 뿐일 것이다.

바로 이전 글에서 비공개(private) 메소드 테스트에 관한 글을 링크했었다.

비공개 메서드의 모든 코드를 비공개 메서드에 독립적인 방법으로 테스트 하라

어떻게든 다양한 문자열을 던져주면서 parseMessage()의 모든 코드를 거쳐갈 수 있게 테스트를 만들면 될 것이다.

그런데 또 다른 관점으로도 생각해보자.

단일 책임 원칙

단일 책임 원칙은 한 클래스에서 하나의 책임을 갖는다는 것(…정도로 얼버무리고).

Request 클래스를 구현해나가다 보니,

  • 앞서 언급한 룰에 따라 HTTP 메시지의 각 요소로 나누는 역할이 하나.
  • 그 나뉜 요소를 읽고 어떤 판단을 하는 로직(예를 들어 //index.html)이 하나.
  • 아직 어떻게 구현할지는 생각 안했지만 외부에 비정상 요청임을 알리는 기능 하나.

이쯤되면 Request 클래스의 역할을 분명히 하고, 하나의 독립된 책임으로 캡슐화할 수 있는 건 별도의 클래스로 빼는 걸 고려할 법 하다.

우리는 파싱을 다루고 있었으니

Server <-> Request <-> HttpMessageParser

정도로 우선 분리해본다.

서버는 클라이언트로부터 HTTP 메시지를 수신한 후,

이 문자열을 Request에 전달하면 그 내부에서 뭘 어쩌는 지 서버는 관심없고 단지 그 분석 결과(정상 여부, 리소스 경로 등)을 받아온다.

(나중에 어찌 변할 지 몰라도) 서버 입장에서 Request 클래스의 역할은 문자열을 받아 분석 결과를 리턴한다로 정하겠다.

Request 클래스는 HttpMessageParser에게 HTTP 메시지의 각 요소로 나눠달라고 요청한다.

그럼 HttpMessageParser를 분리하기 위해 테스트부터.

HttpMessageParser 클래스

HttpMessageParser 클래스는 두 군데서 사용하게 된다. Request 클래스와 단위 테스트.

이 클래스의 역할은 문자열을 받아, 특정 구조로 데이터를 리턴하는 것이다. 우선 연관 배열을 리턴하는 것부터 시작해볼까?

첫 단위 테스트에서는 리턴된 배열에서 각 항목이 set되어 있는지만 확인한다.

PHPStorm을 사용한다면 손쉽게 테스트 파일을 추가할 수 있다.

libs 디렉토리에 HttpMessageParser.php를 생성하고,

우클릭 > New > PHPUnit > HttpMessageParserTest 선택

add_unittest_1

테스트 대상 클래스, 테스트 파일명과 위치할 디렉토리를 써주면

add_unittest_2

테스트가 생성된다.

add_unittest_3

리턴되는 값에는 기본적으로 아래와 같은 항목이 필요할 것 같다.

  • start_line : 시작줄 전체
  • method : 메소드
  • uri : URI
  • version : HTTP 버전
  • headers : 헤더는 배열로
  • body : 본문

테스트를 만들자.

테스트 클래스(tests/HttpMessageParserTest.php) 안에서도 (PHPStorm을 쓴다면!) Generate라는 기능을 메소드를 빨리 추가할 수 있다. 약간의 타이핑을 줄여주는 수준이지만 이런 게 생산성을 높여주는 법이다.

<?php

use PHPUnit\Framework\TestCase;

class HttpMessageParserTest extends TestCase
{

public function testReturnValue()
{
$parser = new HttpMessageParser('');
$result = $parser->getResult();
$this->assertEquals(true, is_array($result));

$this->assertArrayHasKey('start_line', $result, 'key start_line not exists');
$this->assertArrayHasKey('method', $result, 'key method not exists');
$this->assertArrayHasKey('uri', $result, 'key uri not exists');
$this->assertArrayHasKey('version', $result, 'key version not exists');
$this->assertArrayHasKey('headers', $result, 'key headers not exists');
$this->assertArrayHasKey('body', $result, 'key body not exists');
}
}

Github에서 커밋 로그를 보면 알겠지만, 하나하나 테스트부터 만들고 이를 통과하는 기능을 구현했다.

add_unittest_3

  • 당신이 PHPStorm을 쓴다면..

결론

다음 글에서는 Request 클래스에서 HttpMessageParser를 가져다 쓰기 전에, 기본 파싱에 필요한 기능을 테스트와 함께 작성하려고 한다.

결론은 당신이 PHPStorm을 쓴다면 좀 더 편하게 할 수 있다.
(이제 그만 말할까…)