연재글 전체 보기
지난 시간에 우리는..
HTTP 메시지를 파싱하는 용도로 HttpMessageParser
라는 역할을 분리하고 테스트를 만들기 시작했다.
Server <-> Request <-> HttpMessageParser
|
HttpMessageParser
는 Request
가 필요로하는 최소한의 데이터 구조로 리턴을 하게 되어있고, 각각의 키가 반드시 존재(set)할 것을 테스트로 보장한다.
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'); } }
|
<?php class HttpMessageParser { private $message = '';
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
라는 응답을 줄 것이다.
그럼 테스트부터 시작하자.
- 예외가 발생했는지 확인하는 테스트를 만들고
- 예외를 발생시킨다
기존 만들었던 테스트는 성공하는 케이스로, 새로 만드는 테스트는 실패하는 케이스로 바꿔준다:
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'); } }
public function testInvalidMessageCausesException() { $badMsg = ''; new HttpMessageParser($badMsg); }
|
결과 배열은 멤버 변수로 옮기고, parse() 메소드를 추가하고, 공백이 넘어오면 Exception 발생:
<?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; } }
|
테스트를 그지같이 짜 놨더니 불안하다. 어서 비정상 메시지를 구분하는 코드를 더 넣어보자.
- CRLF 단위로 문자열을 잘라내고 첫번째 요소만 신경쓴다
- 공백으로 나누면 3개 이상의 요소가 되는지
- 이해할 수 있는 METHOD인지
- URL은 /로 시작하는지
- 프로토콜이 HTTP/정수.정수 형식인지
- …and?
잠깐. 그럼 통과 못하는 문자열 여러개를 준비하고 이것들이 통과하는 테스트를 만들어야 하는데,
Exception 발생을 확인하는 메소드는 그 안의 여러개의 Exception이 발생한다 하더라도 첫번째 Exception 이후 동작하지 않는다.
그럼 비정상 문자열이 생각날 때마다 새로운 메소드를 만들어야 하는가?
꼼수가 있다.
Data Providers
Data Providers는 테스트 메소드의 인자로 다량의 데이터를 밀어넣어줄 수 있는 annotation이다.
자세한 건 매뉴얼을 확인하도록 하고, 우선 Data Providers를 사용하도록 리팩토링, 그리고 실패하는 테스트를 하나 만든다.
public function badMessages() { return [ [''], ['haha'], ]; }
public function testInvalidMessageCausesException($badMsg) { new HttpMessageParser($badMsg); }
|
동작하는 테스트가 갖춰졌으니, 이제 HttpMessageParser
클래스의 parse()
메소드에서 놀면 된다.
parse()
사실 실패하는 테스트를 만들기 전에 CRLF로 나누는 리팩토링 먼저 됐으면 좋았겠지만…
이왕 이렇게 된 거 시작줄이 3개의 요소로 되어 있는지를 확인하는 구문까지 진도를 쫙 빼본다.
private function parse() { $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.'); } $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…
Parameter가 필요하다면 이를 설정할 수도 있지만 이건 단순한 메소드니까.
동일한 코드가 발견되면 같이 발라낼 것인지 물어보기도 한다.
짜잔!
private function parse(): void { $messageSplit = preg_split('/(\r\n|\n)/', $this->message); array_walk($messageSplit, function(&$item){ $item = trim($item); }); if (empty($messageSplit) || empty($messageSplit[0])) { $this->throwInvalidMessageFormat(); } $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.'); }
|
쉽다.
또다시 실패하는 테스트 케이스 추가.
public function badMessages() { return [ [''], ['haha'], ['하나 둘 셋'], ]; }
|
(어떤 바보가 ‘시 작 줄’과 같이 요청하겠냐마는) 이 테스트는 실패한다.
왜냐면 지금까지 구현한 기능은 이를 완벽한 요청 시작줄로 인식하고 Exception을 뱉지 않기 때문이다.
그럼 공백으로 나눈 시작줄 요소가 각각 다음을 만족하는 지 확인하는 코드를 짜야 한다.
- method : 메소드
- uri : URI
- version : HTTP 버전
또다시 실패하는 테스트 케이스 추가.
public function badMessages() { return [ [''], ['haha'], ['GET 둘 셋'], ['POST 둘 셋'], ['HEAD 둘 셋'], ['HEAD / 셋'], ['HEAD /index 셋'], ]; }
|
성공하는 코드 추가.
private function parse(): void { $messageSplit = preg_split('/(\r\n|\n)/', $this->message); array_walk($messageSplit, function(&$item){ $item = trim($item); }); if (empty($messageSplit) || empty($messageSplit[0])) { $this->throwInvalidMessageFormat(); } $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]; if (empty($method) || !in_array($method, $this->supportMethods)) { $this->throwInvalidMessageFormat(); } if (empty($uri) || strpos($uri, '/') !== 0) { $this->throwInvalidMessageFormat(); } 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()
메소드에서 하는 일을 말로 풀어보자.
- 메시지를 줄 단위로 나눈다
- 시작줄을 검사한다
- 시작줄을 공백으로 나눈다
- 메소드를 확인한다
- URI를 확인한다
- 버전을 확인한다
이 걸 그대로 private 메소드로 분리해본다. 당연히 하나하나 분리할 때마다 테스트가 동작하는지는 확인해야 한다.
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.'); }
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(); } $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; } }
|
결론
이제 겨우 최소한으로 허용하는 메시지에 대한 규칙이 정의됐다.
다음 글에서는 나머지 항목을 채우는 과정이 진행될 것 같다.