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

연재글 전체 보기


지난 시간에 우리는..

HTTP 메시지를 파싱하는 용도로 HttpMessageParser라는 역할을 분리하고 테스트를 만들기 시작했다.

1
Server <-> Request <-> HttpMessageParser

HttpMessageParserRequest가 필요로하는 최소한의 데이터 구조로 리턴을 하게 되어있고, 각각의 키가 반드시 존재(set)할 것을 테스트로 보장한다.

  • 소스가 약간 바뀌었으나 동일하게 동작한다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public function testReturnValue()
{
$parser = new HttpMessageParser('');
$result = $parser->getResult();
$this->assertEquals(true, is_array($result));

$requiredFields = [
'start_line',
'method',
'uri',
'version',
'headers',
'body',
];
foreach ($requiredFields as $field) {
$this->assertArrayHasKey($field, $result, 'key "' . $field . '" does not exist');
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php

class HttpMessageParser
{
private $message = '';

/**
* HttpMessageParser constructor.
*/
public function __construct(string $message)
{
$this->message = $message;
}

public function getResult() : array
{
return [
'start_line' => '',
'method' => '',
'uri' => '',
'version' => '',
'headers' => '',
'body' => '',
];
}
}

예외 발생

(이 시리즈 초반에 테스트 용으로 만든 것과 같은) 엉망진창인 클라이언트가 존재하는 것을 감안하여 비정상 요청부터 감지하고자 한다.

어떤 요청 메시지가 비정상인가?

다시 한번 HttpMessageParser의 역할을 상기시키는 겸, 이 녀석이 분석해야할 정상 메시지의 구조는 아래와 같다.

  • start_line : 시작줄
    • method : 메소드
    • uri : URI
    • version : HTTP 버전
  • headers : 헤더는 배열로(0개 이상)
  • body : 본문(없어도 OK)

여기서 다시 HTTP 스펙으로 돌아가야 한다.

사실상 필수 요소라면 시작줄 뿐이지만, CRLF가 두개는 나와야 정상 메시지라고 볼 수 있다.

하지만 우리의 HTTP 프로토콜은 개떡같이 말해도 찰떡같이 이해하는 게 일상인 관대한 생태계가 받들고 있다.

나도 또한 관대하므로 시작줄만 3요소로 멀쩡히 들어온다면 문법 자체는 문제없는 요청이라 생각할 것이다.

하지만 이 조차도 지키지 못한다면 클라이언트에게 400 Bad Request라는 응답을 줄 것이다.

그럼 테스트부터 시작하자.

  1. 예외가 발생했는지 확인하는 테스트를 만들고
  2. 예외를 발생시킨다

기존 만들었던 테스트는 성공하는 케이스로, 새로 만드는 테스트는 실패하는 케이스로 바꿔준다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public function testReturnValue()
{
$goodMsg = 'GET / HTTP/1.1';
$parser = new HttpMessageParser($goodMsg);
$result = $parser->getResult();
$this->assertEquals(true, is_array($result));

$requiredFields = [
'start_line',
'method',
'uri',
'version',
'headers',
'body',
];
foreach ($requiredFields as $field) {
$this->assertArrayHasKey($field, $result, 'key "' . $field . '" does not exist');
}
}

/**
* @expectedException Exception
*/
public function testInvalidMessageCausesException()
{
$badMsg = '';
new HttpMessageParser($badMsg);
}

결과 배열은 멤버 변수로 옮기고, parse() 메소드를 추가하고, 공백이 넘어오면 Exception 발생:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?php

class HttpMessageParser
{
private $message = '';
private $result = [
'start_line' => '',
'method' => '',
'uri' => '',
'version' => '',
'headers' => '',
'body' => '',
];

public function __construct(string $message)
{
$this->message = $message;
$this->parse();
}

private function parse()
{
if ($this->message == '') {
throw new Exception('Invalid message format.');
}
}

public function getResult(): array
{
return $this->result;
}
}

테스트를 그지같이 짜 놨더니 불안하다. 어서 비정상 메시지를 구분하는 코드를 더 넣어보자.

  1. CRLF 단위로 문자열을 잘라내고 첫번째 요소만 신경쓴다
  2. 공백으로 나누면 3개 이상의 요소가 되는지
  3. 이해할 수 있는 METHOD인지
  4. URL은 /로 시작하는지
  5. 프로토콜이 HTTP/정수.정수 형식인지
  6. …and?

잠깐. 그럼 통과 못하는 문자열 여러개를 준비하고 이것들이 통과하는 테스트를 만들어야 하는데,

Exception 발생을 확인하는 메소드는 그 안의 여러개의 Exception이 발생한다 하더라도 첫번째 Exception 이후 동작하지 않는다.

그럼 비정상 문자열이 생각날 때마다 새로운 메소드를 만들어야 하는가?

꼼수가 있다.

Data Providers

Data Providers는 테스트 메소드의 인자로 다량의 데이터를 밀어넣어줄 수 있는 annotation이다.

자세한 건 매뉴얼을 확인하도록 하고, 우선 Data Providers를 사용하도록 리팩토링, 그리고 실패하는 테스트를 하나 만든다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public function badMessages()
{
return [
[''],
['haha'],
];
}

/**
* @dataProvider badMessages
* @expectedException Exception
*/
public function testInvalidMessageCausesException($badMsg)
{
new HttpMessageParser($badMsg);
}

동작하는 테스트가 갖춰졌으니, 이제 HttpMessageParser 클래스의 parse() 메소드에서 놀면 된다.

parse()

사실 실패하는 테스트를 만들기 전에 CRLF로 나누는 리팩토링 먼저 됐으면 좋았겠지만…

이왕 이렇게 된 거 시작줄이 3개의 요소로 되어 있는지를 확인하는 구문까지 진도를 쫙 빼본다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private function parse()
{
//메시지를 \r\n 혹은 \n으로 나누기
$messageSplit = preg_split('/(\r\n|\n)/', $this->message);
//각 줄의 양 끝 공백은 제거한다
array_walk($messageSplit, function(&$item){
$item = trim($item);
});

//시작줄은 있어야 한다
if (empty($messageSplit) || empty($messageSplit[0])) {
throw new Exception('Invalid message format.');
}

//시작줄을 whitespace로 나누기
$startLine = $messageSplit[0];
$startLineSplit = preg_split('/\s/', $startLine);

if (empty($startLineSplit) || count($startLineSplit) !== 3) {
throw new Exception('Invalid message format.');
}
}

TDD의 기본을 되새김해보면

  • Red : 실패하는 테스트를 작성하고, 실패하는 것을 확인
  • Green : 이를 통과하는 최소한의 코드를 작성하고
  • Refactor : 리팩토링하는 과정

지금은 Refactoring 타임.

테스트가 통과하는 코드를 만들었지만 벌써 중복이 발생했다.

Custom Exception을 만들어야 할 것만 같은 충동에 휩싸였지만 이내 정신을 차리고 메소드로 분리하는 전략을 선택했다.

만약, 이건 만약인데,

당신이 PHPStorm을 쓴다면..

Refactor 기능으로 중복 코드를 발라낼 수 있다.

추출할 영역을 선택 > 우클릭 > Refactor > Extract > Method…
add_unittest_1

Parameter가 필요하다면 이를 설정할 수도 있지만 이건 단순한 메소드니까.
add_unittest_2

동일한 코드가 발견되면 같이 발라낼 것인지 물어보기도 한다.
add_unittest_3

짜잔!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private function parse(): void
{
//메시지를 \r\n 혹은 \n으로 나누기
$messageSplit = preg_split('/(\r\n|\n)/', $this->message);
//각 줄의 양 끝 공백은 제거한다
array_walk($messageSplit, function(&$item){
$item = trim($item);
});

//시작줄은 있어야 한다
if (empty($messageSplit) || empty($messageSplit[0])) {
$this->throwInvalidMessageFormat();
}

//시작줄을 whitespace로 나누기
$startLine = $messageSplit[0];
$startLineSplit = preg_split('/\s/', $startLine);

if (empty($startLineSplit) || count($startLineSplit) !== 3) {
$this->throwInvalidMessageFormat();
}
}

private function throwInvalidMessageFormat(): void
{
throw new Exception('Invalid message format.');
}

쉽다.

  • 당신이 PHPStorm을 쓴다면..

또다시 실패하는 테스트 케이스 추가.

1
2
3
4
5
6
7
8
public function badMessages()
{
return [
[''],
['haha'],
['하나 둘 셋'],
];
}

(어떤 바보가 ‘시 작 줄’과 같이 요청하겠냐마는) 이 테스트는 실패한다.

왜냐면 지금까지 구현한 기능은 이를 완벽한 요청 시작줄로 인식하고 Exception을 뱉지 않기 때문이다.

그럼 공백으로 나눈 시작줄 요소가 각각 다음을 만족하는 지 확인하는 코드를 짜야 한다.

  • method : 메소드
  • uri : URI
  • version : HTTP 버전

또다시 실패하는 테스트 케이스 추가.

1
2
3
4
5
6
7
8
9
10
11
12
public function badMessages()
{
return [
[''],
['haha'],
['GET 둘 셋'],
['POST 둘 셋'],
['HEAD 둘 셋'],
['HEAD / 셋'],
['HEAD /index 셋'],
];
}

성공하는 코드 추가.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
private function parse(): void
{
//메시지를 \r\n 혹은 \n으로 나누기
$messageSplit = preg_split('/(\r\n|\n)/', $this->message);
//각 줄의 양 끝 공백은 제거한다
array_walk($messageSplit, function(&$item){
$item = trim($item);
});

//시작줄은 있어야 한다
if (empty($messageSplit) || empty($messageSplit[0])) {
$this->throwInvalidMessageFormat();
}

//시작줄을 whitespace로 나누기
$startLine = $messageSplit[0];
$startLineSplit = preg_split('/\s/', $startLine);

if (empty($startLineSplit) || count($startLineSplit) !== 3) {
$this->throwInvalidMessageFormat();
}

$method = $startLineSplit[0];
$uri = $startLineSplit[1];
$version = $startLineSplit[2];

//check Method
if (empty($method) || !in_array($method, $this->supportMethods)) {
$this->throwInvalidMessageFormat();
}

//check URI
if (empty($uri) || strpos($uri, '/') !== 0) {
$this->throwInvalidMessageFormat();
}

//check version
if (empty($version) || !preg_match('/HTTP\/\d+.\d+/', $version)) {
$this->throwInvalidMessageFormat();
}
}

테스트 케이스는 충분한 것 같은데(그렇다고 하자) 마지막에 추가한 버전 체크가 잘 동작하는 게 맞는지 불안할 수 있다.

나는 badMessages쪽에 살짝 넣어보고 안심하긴 했지만, 정상적인 시작줄은 whitelist에서 다뤄야 할 것 같다.

그 전에 리팩토링할 것이 있나 찾아보자.

또 refactoring

클래스가 바뀌어야 하는 이유는 단 한가지여야 한다는 단일 책임의 원칙을 메소드까지 확장해서 생각해보자.

parse() 메소드가 바뀌는 상황을 상상해보면

  • 우리는 시작줄을 대충 whitespace(\s)로 나누었지만 \s\n도 포함이라서 이를 좀 더 정교하게 만들 수도 있다
  • 지원하는 HTTP 메소드 혹은 표준에서 정의하는 메소드 종류는 계속 늘어날 수 있다
  • URI 규칙을 좀 더 구체적으로 정의한다면? Directory traversal attack은?
  • 지원하는 HTTP 버전에 따른 응답을 처리하게될 지도 모른다

아직 생기지도 않은 요구 사항을 미리 구현하는 건 반대지만, 대비하는 것은 필요하다.

게다가 이미 parse() 메소는 그 안에 다루고 있는 지식이 꽤나 많고, 13인치 맥북에선 한 화면에 볼 수 없을 만큼 길어지고 있다.

따라서 누군가(혹은 6개월 후의 당신이) 유지보수를 위해 server.php에서부터 관련 클래스를 타고타고 들어와 parse()까지 도달했을 때는 이 안에서 어떤 일을 하고 있는지 한눈에 볼 수 없다.

parse() 메소드에서 하는 일을 말로 풀어보자.

  1. 메시지를 줄 단위로 나눈다
  2. 시작줄을 검사한다
    1. 시작줄을 공백으로 나눈다
    2. 메소드를 확인한다
    3. URI를 확인한다
    4. 버전을 확인한다

이 걸 그대로 private 메소드로 분리해본다. 당연히 하나하나 분리할 때마다 테스트가 동작하는지는 확인해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
class HttpMessageParser
{
private $message = '';
private $messageLines = [];
private $result = [
'start_line' => '',
'method' => '',
'uri' => '',
'version' => '',
'headers' => '',
'body' => '',
];
private $supportMethods = ['GET', 'HEAD', 'POST',];

public function __construct(string $message)
{
$this->message = $message;
$this->parse();
}

private function parse(): void
{
$this->splitMessageIntoLines();

$this->verifyStartLine();
}

private function throwInvalidMessageFormat(): void
{
throw new Exception('Invalid message format.');
}

/**
* 메시지를 \r\n 혹은 \n으로 나누기
*/
private function splitMessageIntoLines()
{
$this->messageLines = preg_split('/(\r\n|\n)/', $this->message);
//각 줄의 양 끝 공백은 제거한다
array_walk($this->messageLines, function (&$item) {
$item = trim($item);
});
}

private function verifyStartLine(): void
{
$this->splitStartLineInto3Components();

$this->verifyStartLineMethod();
$this->verifyStartLineURI();
$this->verifyStartLineVersion();
}

private function splitStartLineInto3Components(): void
{
//시작줄은 있어야 한다
if (empty($this->messageLines) || empty($this->messageLines[0])) {
$this->throwInvalidMessageFormat();
}

//시작줄을 whitespace로 나누기
$startLine = $this->messageLines[0];
$startLineSplit = preg_split('/\s/', $startLine);

if (empty($startLineSplit) || count($startLineSplit) !== 3) {
$this->throwInvalidMessageFormat();
}

$this->result['method'] = $startLineSplit[0];
$this->result['uri'] = $startLineSplit[1];
$this->result['version'] = $startLineSplit[2];
}

private function verifyStartLineMethod(): void
{
if (empty($this->result['method']) || !in_array($this->result['method'], $this->supportMethods)) {
$this->throwInvalidMessageFormat();
}
}

private function verifyStartLineURI(): void
{
if (empty($this->result['uri']) || strpos($this->result['uri'], '/') !== 0) {
$this->throwInvalidMessageFormat();
}
}

private function verifyStartLineVersion(): void
{
if (empty($this->result['version']) || !preg_match('/HTTP\/\d+.\d+/', $this->result['version'])) {
$this->throwInvalidMessageFormat();
}
}

public function getResult(): array
{
return $this->result;
}
}

결론

이제 겨우 최소한으로 허용하는 메시지에 대한 규칙이 정의됐다.

다음 글에서는 나머지 항목을 채우는 과정이 진행될 것 같다.