ha-ah

로그, 게으른 로그

연재글 전체 보기


테스트 주도로 시작하기

지금까지의 Request 분석 클래스는 아래와 같다.

<?php

class Request
{
private $requestMessage;

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

public function getResponse()
{
$filename = PROJECT_ROOT . '/public/index.html';
if (!($fp = fopen($filename, 'r'))) {
return 'HTTP/1.0 404 파일 없음' . PHP_EOL;
}
$responseBody = fread($fp, filesize($filename));

$response = 'HTTP/1.0 200 OK' . PHP_EOL;
$response .= 'Content-Type: text/html' . PHP_EOL;
$response .= PHP_EOL;
$response .= $responseBody . PHP_EOL;

return $response;
}
}

첫 테스트

의미 없는 코드지만 테스트가 존재하면 성공하는지나 살펴보자.

<?php

use PHPUnit\Framework\TestCase;

class RequestTest extends TestCase
{
public function testCanBeCreated(): void
{
$this->assertInstanceOf(
Request::class,
new Request([])
);
}
}

결과는,

Testing started at PM 7:57 ...
PHPUnit 6.4.3 by Sebastian Bergmann and contributors.

Time: 59 ms, Memory: 4.00MB

OK (1 test, 1 assertion)

이건 너무 당연한 테스트 코드라서 낭비다. 지워버리고 의미있는 테스트를 만들어보자.

다시 첫 테스트

server.php에서 Request.php으로 기능을 가져왔으니 뭔가 테스트할 거리가 있을 것 같다.

우선 __construct.

하는 일이라곤 생성자로 전달된 값을 private 변수에 담는 것 뿐인데, 이걸 테스트로 만들 필요가 있을까?

물론 테스트는 만들 수 있다.

굳이 굳이 Request 클래스에 private 변수를 꺼내올 수 있는 public method를 만들 수도 있고.

Request.php :

<?php
public function getRequestMessage()
{
return $this->requestMessage;
}

RequestTest.php :

public function testConstructByGetter() : void
{
$requestMessage = ['testKey' => 'testVal'];
$request = new Request($requestMessage);

$valueByGetter = $request->getRequestMessage();

$this->assertEquals(
serialize($requestMessage),
serialize($valueByGetter)
);
}

(하긴 막 짜려면 gettter도 필요없이 requestMessage 변수를 public으로 만들고 $request->requestMessage;처럼 막 갖다 써도..)

또한, 굳이 굳이 Reflection으로 private 변수를 들여다 볼 수 도 있다.

RequestTest.php :

public function testConstructByReflection() : void
{
$requestMessage = ['testKey' => 'testVal'];
$request = new Request($requestMessage);
$reflectionClass = new \ReflectionClass($request);

$reflectionProperty = $reflectionClass->getProperty('requestMessage');
$reflectionProperty->setAccessible(true);
$privateVal = $reflectionProperty->getValue($request);

$this->assertEquals(
serialize($requestMessage),
serialize($privateVal)
);
}

다시 질문. 이걸 테스트로 만들 필요가 있을까?

생성자에 받은 녀석을 그대로 멤버 변수에 넣었는데 이게 잘 들어갔는지 확인할 필요는 없어보인다. 그 과정에서 이에 간섭하는 코드가 없기 때문이다.

그리고 private 변수의 접근 제한 수준을 억지로 공개해서 은닉성을 포기할 만큼 가치있는 일인가?

비슷한 관점으로 (그러나 명확하게 설명된) 이 글을 참고해도 좋겠다.

비공개 메서드를 테스트 해야 하는가?

테스트 케이스는 클라이언트 코드다

억지로 만든 테스트는 다 지워버리자.

역할 나누기

계속된 첫 테스트 작성 실패에 주눅들 것 없이 찬찬히 코드를 돌아보자.

진짜 진짜 첫 테스트를 작성 하려고 보니, Request 클래스 안에 getResponse() 메소드만 덩그러니 있는 것이 마음에 걸린다.

server.php에서 기능을 분리한 것은 좋았지만, 클래스 명이 Request인데 여기서 응답 문자열을 만들어주는 모양이 어색하다.

자연스럽게(서로 짝이 맞게) Response라는 클래스를 만들어야겠다는 생각이 든다.

어떤 기준으로 나눠야 할지 현재의 Request 클래스의 코드를 기준으로 각각의 임무를 적어보자.

Request

  • 요청된 문자열을 파싱해서 시작줄, 헤더(여러줄), 본문 등 필요한 포맷으로 정리한다
  • 해석할 수 없는 요청이라면 예외를 발생시킨다
  • 분석 결과, 클라이언트가 요청한 리소스 위치를 리턴한다
  • ‘/‘라는 요청이 오면 ‘index.html’이라는 파일을 읽어야 하는 것을 알고 있다

Response

  • 도큐먼트 root의 절대 경로를 알고 있다
  • 파일이 존재하는지 확인하고 없으면 예외를 발생한다
  • 파일을 읽는다
  • 처리 결과를 정리한다
  • 클라이언트에 전달할 HTTP 응답 메시지를 리턴한다

나중에 여기서 더 더 분리가 되겠지만, 현재 코드에서 예상할 수 있는 기능을 크게 둘로 나오면 이 정도라고 생각한다.

그럼 이런 흐름이 될 것이다.

  1. server.php에선 클라이언트가 보낸 raw 메시지를 Request 클래스에 전달하고,
  2. Request 클래스는 이를 파싱해서 내부에 저장하고,
  3. Response 클래스는 여기서 리소스 경로를 받아 읽고 server.php에 최종 결과물을 리턴한다.

이제 Request 클래스는 raw 메시지를 받아 특정 경로를 반환하도록 고치고

<?php
class Request
{
private $requestMessage;

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

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

서버는 이걸 받아 Response에게 던지고

// ... 생략 ...
while (true) {
$client = @stream_socket_accept($server);

if ($client) {
$clientSentData = fread($client, 1024);

$resourcePath = (new Request($clientSentData))->getResourcePath();
$response = (new Response())->getResponse($resourcePath);

fwrite($client, $response, strlen($response));
fclose($client);
}
}

Response는 클라이언트에 보낼 메시지를 조합한다.

<?php
class Response
{
private $docRoot = PROJECT_ROOT . '/public';

public function getResponse($resourcePath)
{
$filename = $this->docRoot . $resourcePath;
if (!($fp = fopen($filename, 'r'))) {
return 'HTTP/1.0 404 파일 없음' . PHP_EOL;
}
$responseBody = fread($fp, filesize($filename));

$response = 'HTTP/1.0 200 OK' . PHP_EOL;
$response .= 'Content-Type: text/html' . PHP_EOL;
$response .= PHP_EOL;
$response .= $responseBody . PHP_EOL;

return $response;
}
}

이제 브라우저에서 http://127.0.0.1:1337/에 접근해보고 문제없이 동작하는 것을 확인한다.

드디어 진짜 진짜 첫 테스트를 작성할 차례다.

진짜 진짜 첫 테스트

진짜 진짜 첫 테스트의 대상은 RequestgetResourcePath() 메소드로 정했다.

사실 TDD의 정석대로 하자면, 메소드를 추가하기 전에

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

을 겪어야 했지만, 이미 잘 돌아가는 코드가 존재했고 Request-Response로 분리하는 과정에서 믿을 수 있는 건 수동 테스트 밖에 없었기 때문에,

수동 테스트가 성공하는 범위 안에서 최소한의 변경으로 코드로 분리하고자 했다.

좀 늦긴 했지만 getResourcePath() 메소드에 대한 테스트를 작성해보자.

tests/RequestTest.php :

<?php
use PHPUnit\Framework\TestCase;

class RequestTest extends TestCase
{
public function testGetResourcePath(): void
{
$request = new Request([]);
$this->assertEquals('/index.html', $request->getResourcePath());
}
}
OK (1 test, 1 assertion)

결론

7화 만에 진짜 진짜 첫 테스트를 만들다니…

다음 시간에는 Request를 파싱해서 실제 동작하는(잊지 않았겠지! 클라이언트는 html과 image와 favicon을 요청할 것이다) HTTP 요청 메시지 파서를 만들 것이다. 당연히 최소한으로 동작하는..

연재글 전체 보기


Request 분리

autoload

우선 클라이언트로부터 받은 request 메시를 분석하는 클래스부터 만들기로 했다.

Project root에 libs 디렉토리를 만들어 놓고, 여기에 Request라는 클래스를 하나 만들어야겠다.

그럼 autoload도 적용해야 하니 composer를 이용해야겠다.

대충 composer init해서 파일을 만들고, libs 디렉토리를 psr-4 기반으로 autoload 하도록 설정해주자.

{
"name": "youngiggy/phttp",
"type": "project",
"license": "MIT",
"authors": [
{
"name": "youngiggy",
"email": "youngiggy@gmail.com"
}
],
"require": {},
"autoload": {
"psr-4": { "": "libs/" }
}
}

이제 libs 디렉토리 아래 Request 클래스를 하나 만들고

<?php

class Request
{

}

after composer init

server.php 상단에 autoload용 코드를 심고, Request를 로드해보자.

<?php
require __DIR__ . '/../vendor/autoload.php';

//todo 지울 것 s
$req = new Request();
echo ($req instanceof Request);
exit;
//todo 지울 것 e

$server = stream_socket_server("tcp://127.0.0.1:1337", $errno, $errorMessage);

// ...생략...

일단 autoload는 잘 동작한다. OK.

server.php

테스트용으로 넣었던 쓸데 없는 코드는 이제 지우고,

Request 클래스에 사용자 입력을 분석하고 응답을 만들어내는 역할을 맡기려고 한다.

서버 소스는 아래와 같이 바뀌었다.

<?php
require __DIR__ . '/../vendor/autoload.php';

defined('PROJECT_ROOT') or define('PROJECT_ROOT', __DIR__ . '/..');

$server = stream_socket_server("tcp://127.0.0.1:1337", $errno, $errorMessage);

if ($server === false) {
throw new UnexpectedValueException("Could not bind to socket: $errorMessage");
}

while (true) {
$client = @stream_socket_accept($server);

if ($client) {
$clientSentData = fread($client, 1024);

$response = (new Request($clientSentData))->getResponse();

fwrite($client, $response, strlen($response));
fclose($client);
}
}

이전 소스에서 index.html을 읽기 위해 아래와 같이 상대 위치로 하드코딩해서 썼었다.

//...생략...
$filename = '../public/index.html';
if (!($fp = fopen($filename, 'r'))) {
//...생략...

처리 로직이 서버 소스를 떠나면 index.html 파일을 여는 fopen을 어디서 처리할 지 아직 확신이 없다. 계속 바뀔지 모른다.

그래서 프로젝트의 최상위 경로를 PROJECT_ROOT라는 상수로 지정했다.

그리고 주요 처리 로직을 들어내고 Request 클래스에 클라이언트가 보낸 문자열을 전달하고,

getResponse() 메소드로 받은 문자열을 그대로 클라이언트에 전달한다.

이제 당분간 이쪽 소스를 건드릴 일은 없어 보인다.

Request.php

이 클래스를 사용하는 곳(server.php)을 보면 Request 클래스의 역할은 크게 두가지다.

  1. 클라이언트가 보낸 문자열을 생성자로 받는 것
  2. getResponse() 메소드로 처리 결과를 리턴하는 것

server.php의 소스를 가능한 건드리지 않고 조심히 들고와보자.

<?php

class Request
{
private $requestMessage;

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

public function getResponse()
{
$filename = PROJECT_ROOT . '/public/index.html';
if (!($fp = fopen($filename, 'r'))) {
return 'HTTP/1.0 404 파일 없음' . PHP_EOL;
}
$responseBody = fread($fp, filesize($filename));

$response = 'HTTP/1.0 200 OK' . PHP_EOL;
$response .= 'Content-Type: text/html' . PHP_EOL;
$response .= PHP_EOL;
$response .= $responseBody . PHP_EOL;

return $response;
}
}

거의 동일하다.

어려울 게 없으니 서버 실행!

테스트

이전의 파일 하나짜리 서버와 동일하게 동작한다.

잘 돌아는 가는데 어쩐지 마음이 헛헛하다.
(가을인가)

계속 이렇게 테스트를 해야하나?

매번 뭔가 수정하고 php ./app/server.php를 다시 실행해야 하나?

이제 개발 시작할 분석 로직은 그냥 스트링을 던져주고 결과만 잘 나오면 되는데.

소파에 반쯤 누워 맥주 한잔 들이키며 이 막장 드라마를 구경하는 중급 개발자라면 ‘유닛 테스트를 걸라고, 멍청아’라고 소리칠 것만 같다.

넣어주지 뭐.

PHPUnit

PHPUnit을 가져와야 할텐데, composer.json에 추가하기 보다는 composer require을 사용하기로 한다.

콘솔을 열고

composer require --dev phpunit/phpunit ^6.4

  • “개발에서 쓰려고 하는데 대충 6.4 정도면 될 것 같아요”

최신 버전이 6.4라서 명시했고, 7.0 전까지는 써도 별 탈 없을 것이다.

업데이트가 완료되면 PHPStorm에 실행 환경을 설정한다.

테스트 범위 설정이나 그룹화를 위해 XML로 설정하기로 한다.

프로젝트 root에 phpunit.xml 파일을 만들고 아래와 같이 입력.

<phpunit bootstrap="vendor/autoload.php">
<testsuites>
<testsuite name="Request">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>

테스트를 돌리기 전 특별히 bootstrap할 게 없으므로 vendor/autoload.php를 bootstrap에 명시했고,

testsuite 명은 나중에 바뀌겠지만 일단 대충 넣고,

tests라는 디렉토리에 있는 Test.php로 끝나는 파일(예: parseTest.php)을 모두 검사하도록 했다.

PHPUnit with PHPStorm

다른 에디터를 쓰는 분들은 다른 각자 알아서 찾아보면 될 것이고, 여기서는 간단히 PHPStorm에서 설정하는 법만 적어본다.

Run | Edit Configurations 메뉴에서 + 버튼을 눌러 PHPUnit 선택.

Run > Edit Configurations

Test Runner > Test Scope을 `Definded in the configuration file’을 선택한다.

바로 아랫줄 제일 오른쪽에 보이는 설정 버튼을 누르면 Test Frameworks 설정 창이 나온다.
(만약 처음 설정/실행한다면 창 아래쪽에서 configuration file에 문제가 있으니 수정하라고 Fix 버튼이 보일 수도 있다. 이걸 눌러도 같은 창이 나온다.)

Run > Edit Configurations > Test Frameworks

+ 버튼을 눌러 Configuration type을 PHPUnit Local로 추가한 후,

PHPUnit library > Use Composer autoloader 선택하고 Path to script는 버튼을 눌러 자신의 vendor/autoload.php을 선택하면 된다.

그 아래 Test Runner 옵션에서는 Default configuration file을 체크하고 앞서 만든 phpunit.xml을 선택한다.

그리고 실행!

테스트를 작성하지 않았으므로 No tests executed! 같은 메시지가 보이면 정상이다.

실행창에서 Toggle auto-test를 눌러 놓으면 뭔가 수정이 될 때마다 테스트가 실행된다.

하지만 개발하다보면, 주석을 넣는다거나 공백을 삽입하는데도 test가 실행되므로 종종 눈엣가시가 된다.

이럴 때는 AutoTest Delay값을 늘려주면 좀 낫다.

Run > Set AutoTest Delay

나중에 테스트가 엄청 많아지면 현재 개발하는 모듈 이외의 클래스도 단위테스트가 돌게 될텐데, 이 때는 phpunit.xml에서 테스트 단위를 나누면 된다. 하지만 예상컨대 이 프로젝트에선 그렇게까지 테스트가 많아질 것 같지 않다.

결론

이제부터는 뭔가 추가/수정할 때마다 미리 테스트를 만들고 실패하는 것을 확인하고 이를 만족하는 기능을 개발하게 될 것이다.

현재 추가/수정하는 코드가 시스템을 망가뜨리지 않는다는 최소한의 보장을 받을 수 있다.

하지만 서버 코드의 많은 부분이 file이나 directory를 읽어야 하는데, 이런 부분은 unit test 만으로 해결 안될 수 있다.

뭐가 어려운지는 이런 글을 보고 예습을 하는 것도 좋다.

참고

http://www.php-fig.org/psr/psr-4/
https://getcomposer.org/doc/01-basic-usage.md
http://xpressengine.github.io/Composer-korean-docs/
https://getcomposer.org/doc/articles/versions.md#caret-version-range-
https://phpunit.de/index.html
https://www.jetbrains.com/help/phpstorm/testing-with-phpunit.html
http://jwchung.github.io/testing-oh-my

연재글 전체 보기


과연 스트리밍이 필요한가?

스트리밍 처리를 위해 이리저리 알아보다가 괜찮은 글이 있어 소개한다.

https://gist.github.com/CMCDragonkai/6bfade6431e9ffb7fe88

  • 스트리밍을 구현할 때 chunk 데이터를 어떻게 보내고, 이를 어떻게 처리해야 하는지
  • HTTP 트랜잭션 안의 각 요소(component) 간 buffer는 어떻게 처리되는지
  • 등등

잘 나와있다.

예전에 댓글로 아웃 버퍼링에 관한 글도 소개했었는데,

같은 맥락으로 읽어보면 좋다.

내가 만들 서버는 아주 가벼운 HTML 파일과 이미지 정도를 보내주기 때문에 굳이 (chunk로 나눠 보내주는) 스트리밍은 필요없다.

마찬가지로 클라이언트에서도 multi-part chunk 데이터가 들어올 것을 고려할 필요가 없다.

그럼 이제 HTTP 요청을 받아서 HTTP 응답을 반환해주는 최소한의 구성으로 서버를 하나 만들어보겠다.

아주 간단한 HTTP 서버

이전 시간에 만들었던 서버 소스를 조금 수정해서, 클라이언트가 보낸 내용을 읽고 HTTP 응답 메시지로 리턴한다.

아래는 전체 소스다.

<?php
$server = stream_socket_server("tcp://127.0.0.1:1337", $errno, $errorMessage);

if ($server === false) {
throw new UnexpectedValueException("Could not bind to socket: $errorMessage");
}

while (true) {
$client = @stream_socket_accept($server);

if ($client) {
$request= fread($client, 1024);

$response = 'HTTP/1.0 200 OK' . PHP_EOL;
$response .= 'Content-Type: text/html' . PHP_EOL;
$response .= PHP_EOL;
$response .= 'you sent :' . PHP_EOL . $request . PHP_EOL;

fwrite($client, $response, strlen($response));
fclose($client);
}
}

클라이언트와 연결되면 해당 스트림에서 1024 바이트만큼 읽어온다.

더 많이 보낼 수 있지 않냐고? 물론이다. 그 문제는 그 때 생각하자.

그 다음 HTTP 메시지를 만들어 준다.

  • 말했지만, HTTP 메시지는 시작줄, 헤더, 공백, 본문(message body)으로 이뤄졌다.

요청할 때는 시작줄에 GET / HTTP/1.1와 같이 메소드, URL, HTTP 버전 순으로 보냈지만

응답할 때의 시작줄에는 HTTP/1.0 200 OK와 같이 HTTP 버전과 상태 코드 그리고 상태 텍스트를 넣어준다.

여기에 헤더를 추가해줘야 하니 Content-Type을 text/html으로 우선 하나 넣어주고

메시지 본문과의 구분을 위해 CRLF를 두 개 추가한다.

첫 HTTP 요청과 응답

테스트 용으로 만들었던 클라이언트 코드는 이제 버리고,

진짜 HTTP 클라이언트를 사용해보자.

브라우저에서 접속해도 괜찮지만 아직은 HTML 파일을 내려보낼 것이 아니므로

Postman같은 클라이언트가 테스트하기 편할 것이다.

GET 메소드로 127.0.0.1:1337에 접근해보자.

Postman으로 실행한 화면

(만약 서버에서 적절한 시작줄을 안 보내주면 Postman은 Could not get any response라며 응답이 오지 않은 것으로 간주한다.)

Postman에서 받은 응답 메시지 본문은 아래와 같다.

you sent :
GET / HTTP/1.1
Host: 127.0.0.1:1337
Connection: keep-alive
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36
Postman-Token: df3ef030-2b6f-76c3-30d9-304ebb426a41
Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: ko-KR,ko;q=0.8,en-US;q=0.6,en;q=0.4

서버에서 응답 본문 앞에 붙여둔 you sent : 한 주를 제외하면 나머지는 HTTP 클라이언트(여기서는 Postman)가 요청 시 붙여 보낸 것이다.

크롬에서 보내면?

you sent :
GET / HTTP/1.1
Host: 127.0.0.1:1337
Connection: keep-alive
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36
Upgrade-Insecure-Requests: 1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Accept-Language: ko-KR,ko;q=0.8,en-US;q=0.6,en;q=0.4

다시 서버 소스를 돌아보자.

지금은 무조건 HTTP/1.0 200 OK라고 응답하게 되어있다.

클라이언트는 HTTP/1.1을 지원하기 때문에 이 프로토콜 버전을 기준으로 헤더를 붙여 요청했지만

우리 서버는 ‘난 HTTP/1.0까지만 지원하는 서버라서 1.0 기준의 헤더를 보낼 거야’라는 의미로 응답에 HTTP/1.0 200 OK를 리턴한다.

브라우저에서 index.html 확인하기

저기 저 아주 간단한 서버 소스에선 메시지 본문을 클라이언트가 보낸 데이터를 그대로 돌려줬는데,

$response .= 'you sent :' . PHP_EOL . $request . PHP_EOL;

이제 예시로 만들어 두었던 index.html 파일을 읽어 클라이언트로 보내기로 한다.

드디어 브라우저에서 확인해볼 차례다.

Request를 그대로 돌려주는 서버에서 무조건 index.html을 떨궈주는 서버로 진화해보자.

// 생략

while (true) {
$client = @stream_socket_accept($server);

if ($client) {
//todo 요청(request) 처리 모듈로 분리하기
$request= fread($client, 1024);

$filename = '../public/index.html';
if (!($fp = fopen($filename, 'r'))) {
$response = 'HTTP/1.0 404 파일 없음' . PHP_EOL;
fwrite($client, $response, strlen($response));
fclose($client);
}
$responseBody = fread($fp, filesize($filename));

//todo 응답(response) 처리 모듈로 분리하기
$response = 'HTTP/1.0 200 OK' . PHP_EOL;
$response .= 'Content-Type: text/html' . PHP_EOL;
$response .= PHP_EOL;
$response .= $responseBody . PHP_EOL;

fwrite($client, $response, strlen($response));
fclose($client);
}
}

todo가 먼저 눈에 띌 것 같은데,

우리의 아주 간단한 서버가 조금씩 복잡해지기 시작했다(냄새가 난다, 냄새가).

크게 보면 응답을 받아서 뭔가 하는 역할과 응답을 만들어주는 역할이다.

리팩토링은 나중에 하기로 하고, 맥주를 한잔 따르고, 성공을 축하하자.

크롬으로 접속!

index.html 성공

만약 없는 경로의 파일을 찾으려고 하면 404로 응답해주면 된다.

그 유명한 404 not found다.

그런데 소스를 보면 HTTP/1.0 404 파일 없음이라고 되어있는데 잘못 쓴 게 아니다.

클라이언트에게는 404라는 응답코드가 중요하지 메시지는 중요하지 않다.

200 OK200 NOT OK나 모두 성공을 의미한다.

(물론 서비스 운영 시에는 이런 짓은 하지 않는다)

404 not found

404 처리도 완벽하게(!?) 됐다.

이미지 서빙

첫 요청으로 index.html을 서빙하는 것 까지는 성공했는데, 중간에 이미지가 빠져있는 게 보일 것이다.

이미지를 노출하려면 몇 가지 더 수정을 해야한다.

첫째, 두 가지의 요청을 처리할 수 있어야 한다.

즉, HTML을 요청하는 것과 이미지를 요청하는 것.

첫 요청으로 브라우저는 index.html이라는 HTML을 얻었다.

이 HTML 문서를 파싱하다가 img 태그를 만났고 src가 beer_server.jpg라는 상대 링크이기 때문에 우리의 서버에 다시 요청을 보낸다.

첫 요청이 GET / HTTP/1.1라면 이어서 GET /beer_server.jpg HTTP/1.1라는 요청이 이어질 것이다.

개발자 도구 > 네트워크를 열어 확인해보자.

index.html의 네트워크

(contents_min.css 저 놈은 크롬 확장 프로그램 때문에 날아간 것이니 신경 쓰지 말자 😭)

favicon.ico는 브라우저 탭에 표시해주는 작은 이미지인데, 최신 브라우저에서는 favicon이 등록되어 있지 않은 사이트일 경우 서버에 /favicon.ico가 있는지 한번 찔러본다.
(사실 계속 찔러보기는 하는데..)

따라서 예상치 못하게 이미지 하나를 더 서비스 해야할 상황이다.

  • index.html
  • beer_server.png
  • favicon.ico

이제 클라이언트가 보낸 요청을 분석해서 뭘 원하는 지 알아내야 할 판이다.

둘째, 이미지를 읽고 써야한다.

위 소스에서 index.html을 어떻게 찾았는지 살펴보자.

server.php가 실행된 위치에서 ..로 상위 디렉토리로 올리가 public이라는 디렉토리에 있는 파일을 서빙했다.

이러다 서버 프로그램 위치가 이동한달지 webroot를 변경하면 서버 코드를 수정할 판이다.

결론

냄새가 난다, 냄새가.

index.html 하나 서비스했을 뿐인데 많은 구조적인 문제가 폭발했다.

클라이언트 요청(request)를 분석하고 html을 줄지 이미지를 줄지 결정해야하고,

DOCUMENT_ROOT, WEB_ROOT 등 config 설정도 해야할 것 같다.

200과 404 응답을 만들어주는 코드 모두에 fwrite/fclose 로직이 중복되어 있기도 하다.

이 모든 걸 담기에는 이미 서버 프로그램이 너무 복잡하다.

다음 시간부터 이를 하나씩 분리해서 HTTP 완벽가이드 5장에 소개된 ‘진짜 웹 서버가 하는 일’을 구현해볼 생각이다.

  1. 커넥션을 맺는다
  2. 요청을 받는다
  3. 요청을 처리한다
  4. 리소스에 접근한다
  5. 응답을 만든다
  6. 응답을 보낸다
  7. 트랜잭션을 로그로 남긴다

(1번, 2번 정도는 PHP 함수로 대충 때웠으니 했다고 치자)

연재글 전체 보기


Updated (2017.10.18)

이번 글과 관련된 내용이라 조금 더 추가해봤습니다.

하단 Updated 참고.

가장 간단한 서버

이제부터 하나씩 기능을 붙여가며 commit & push를 할 예정이다.

PHP7.1/mac 환경에서 공부한 결과를 적는 거라서, 윈도 머신에선 안 통하는 설명도 있으리라.

소스 위치는 https://github.com/youngiggy/phttp

PHP로 HTTP 서버 구현하기 - 01에서 언급한 가장 간단한 서버부터 시작해볼까?

<?php
$server = stream_socket_server("tcp://127.0.0.1:1337", $errno, $errorMessage);

if ($server === false) {
throw new UnexpectedValueException("Could not bind to socket: $errorMessage");
}

while (true) {
$client = @stream_socket_accept($server);

if ($client) {
stream_copy_to_stream($client, $client);
fclose($client);
}
}

딱 봐도 어려울 것 없는 코드다.

127.0.0.1이란 IP에 1337 포트를 열었다.

왜 하필 1337이냐고? 그냥! 저 블로그 쓴 사람이 그렇게 써서 따라했다.

사용하는 프로그램에 따라 이미 포트가 선점되었을 수도 있다.

  • 기본으로 이 포트를 쓰는 프로그램이 있는지 이런 곳에서 찾아봐도 좋고
  • 쉘에서 lsof -PiTCP -sTCP:LISTEN 포트가 열려있는지 확인해봐도 좋다

그 다음 while 루프로 뱅글뱅글 돌면서 연결이 들어오는 지 확인한다.

stream_socket_accept 함수가 말을 못하게 @로 입을 막자.

  • &#9834; No alarms and no surprises
  • 하지만 안 막는다고 뭐 대단한 걸 볼 일은 없을 것이다

연결이 된 클라이언트가 있다면, stream_copy_to_stream를 통해 클라이언트가 보낸 스트림을 고스란히 돌려주자.

고스란히…

주고 받기

가장 간단한 클라이언트

가장 간단한 서버가 완성됐으니 터미널을 열어 php ./app/server.php로 일을 시키고 돌아오자.

새로운 터미널을 열어 echo "hello hello" | nc 127.0.0.1 1337를 입력하면 hello hello라고 반갑게 인사하는 것이 보일 것이다.

너무 시시하니까 가장 간단한 클라이언트를 만들어 잘 돌아가는 지 구경해보자.

아직 브라우저에서 HTTP 프로토콜로 붙을 상황은 아니므로 간단한 TCP 연결 클라이언트로 테스트 한다.

역시 PHP Socket Programming, done the Right Way에서 예시로 보여준 것을 기본으로 시작한다.

<?php
$client = stream_socket_client("tcp://127.0.0.1:1337", $errno, $errorMessage);
if ($client === false) {
throw new UnexpectedValueException("Failed to connect: $errno - $errorMessage");
}

/*
* generate a message
*/
$startLine = 'GET / HTTP/1.0';

$headers = [
'Host: localhost',
'Accept: */*',
];
$header = implode(PHP_EOL, $headers);

$emptyLine = PHP_EOL;

$body = '';

/*
* request
*/
$message = implode(PHP_EOL, [
$startLine,
$header,
$emptyLine,
$body,
]);
fwrite($client, $message);

/*
* response
*/
echo stream_get_contents($client);

/*
* bye bye
*/
fclose($client);

역시 별 거 없는 코드다.

stream_socket_client으로 연결을 시도하고,

HTTP 메시지를 만들어 준다.

  • HTTP 메시지는 시작줄, 헤더, 공백, 바디로 이뤄졌다. 참고

이 메시지를 fwrite를 통해 스트림에 써 준다.

02편에서 얘기한 것처럼, stream_socket_client이 리턴하는 stream resource에는 fopen, fread같은 filesystem 함수를 사용할 수 있다.

실행

터미널을 하나 열고 php ./app/client.php로 클라이언트를 실행해보자.

아무 반응이 없을 것이다.

하지만 조금만 더 기다려보면, 1분 후 timeout으로 client가 종료되고 자신이 서버에 전송한 값이 화면에 찍한다.

왜 그럴까?

다르게 재현해보자.

서버를 다시 실행하고 클라이언트를 다시 실행한 후, 이번엔 서버를 죽여보자.

이때도 클라이언트와 연결이 바로 끊어지고 서버에 전송한 값이 되돌아와 화면에 찍힌다.

우선 timeout 1분은 환경마다 다를 수 있는데, php.ini에 default_socket_timeout에 정의되어 있다.

client 소스의 response 부분을 아래와 같이 바꿔보자.

/*
* response
*/
while (true) {
if (!($response = stream_get_contents($client, 4))) {
break;
}
echo $response . '|';
}

그럼 아래와 같이 나오던 것이

GET / HTTP/1.0
Host: localhost
Accept: *

아래와 같이 바로 찍힌다.

GET |/ HT|TP/1|.0
H|ost:| loc|alho|st
A|ccep|t: *|/*

stream_get_contents의 두번째 인자는 maxlength라서 지정한 만큼만 스트림에서 읽어온다.

그래서 읽어온 만큼 바로바로 화면에 echo 하는 것이다.

  • 보기 좋으라고(?)뒤에 |를 붙여봤다.

하지만 이렇게 해도 클라이언트는 종료되지 않는다.

stream_get_contents가 계속 스트림에 뭔가 들어오는지 지켜보고 있어서 while loop가 끝나지 않는다.

이 악순환의 고리를 끊으려면 서버에서 연결을 끊어버리면 간단한데, 우리 코드에선 클라이언트로 들어오는 스트림을 바로 돌려주기 때문에 쉽지 않다.

클라이언트에서 loop를 종료하려면 어디가 끝인지를 확인해야만 한다.

서버가 Content-Length를 돌려주게끔 해주면 좋겠지만 우리 서버는 아직 바보니까 클라이언트가 좀 더 고생을 하자.

$messageLenth = strlen($message);
$totalLenth = 0;
while (true) {
$response = stream_get_contents($client, 1);
echo $response;
$totalLenth += strlen($response);
if ($messageLenth <= $totalLenth) {
break;
}
}

Updated

이 글 이후로 좀 더 진행해보다가 meta 정보를 이용하는 방법까지는 여기에 묻어두는 것이 좋을 것 같아서 내용을 추가한다.

stream_get_contents으로 데이터를 가져온 다음 현재 스트림의 상태를 보려면 stream_get_meta_data를 통해 확인할 수 있다.

여기에는 다음과 같은 정보가 리턴된다.

Array
(
[timed_out] =>
[blocked] => 1
[eof] =>
[stream_type] => tcp_socket/ssl
[mode] => r+
[unread_bytes] => 1
[seekable] =>
)

어쩐지 eof나 unread_bytes를 쓰면 될 것 같다.

/*
* response
*/
while (true) {
$response = stream_get_contents($client, 1);
echo $response;
$info = stream_get_meta_data($client);
if ($info['eof'] || $info['unread_bytes'] === 0) {
break;
}
}

이렇게 하니 소중한 고객님의 연결이 끊겼습니다.

그러나,

문서를 보면 unread_bytes가 의미하는 것은,

unread_bytes (int) - the number of bytes currently contained in the PHP's own internal buffer.

즉, PHP 자체의 내부 버퍼의 내용을 보여주는 것이라서 스크립트에서 활용하지 않을 것을 추천하고 있다.

Note: You shouldn’t use this value in a script.

그럼 포기하고 다른 방법을 찾아보자.

우선 client 쪽 소스에서 unread_bytes를 검사하는 부분만 제거한 다음,

/*
* response
*/
while (true) {
$response = stream_get_contents($client, 1);
echo $response;
$info = stream_get_meta_data($client);
if ($info['eof'] || $info['unread_bytes'] === 0) {
break;
}
}

연결이 안 끊어지는 것을 반드시 확인하자.

그 다음,

서버쪽 소스에서 stream_set_blocking으로 non-blocking 모드로 바꿔보자.

stream_set_blocking($client, false);

즉, 서버의 전체 소스는 아래와 같이 변할 것이다.

<?php
$server = stream_socket_server("tcp://127.0.0.1:1337", $errno, $errorMessage);

if ($server === false) {
throw new UnexpectedValueException("Could not bind to socket: $errorMessage");
}

while (true) {
$client = @stream_socket_accept($server);

if ($client) {
stream_set_blocking($client, false);// <-- 여기요 여기
stream_copy_to_stream($client, $client);
fclose($client);
}
}

클라이언트를 실행하면 바로 연결이 끊긴다.

stream_set_blocking 함수의 두번째 인자 mode가 false로 들어가면 non-blocking 모드로 진입하며,

클라이언트 쪽에서는 스트림으로 더이상의 데이터가 들어오는 것을 기다리지 않는다.

이렇게 하면 굳이 스트림을 쓸 이유가 없기 때문에 이 역시도 좋은 방법은 아닐 것이다.

결론

아주 간단한 서버와 아주 간단한 클라이언트를 아주 간단히 살펴봤다.

도저히 못 써먹겠으니 다음 글에서는 이를 좀 더 개선해보자.
(뭘 어떻게 바꿀 지는 아직 계획없음)

내 문장이 그렇게 이상한가요?

개발자들 사이에서 꽤 화제가 된 책이라 한번 가볍게 읽어봤다.

가볍게 읽을 수 있고 책 무게도 매우 가벼우나, 내용은 가볍지만은 않다.

읽고 난 후에는 계속 내 글을 돌아보게 하기도 하고,

남이 쓴 글이 거슬리기도 하고.

우리가 평소에 많이 쓰는 표현이 왜 이상한지에 집중한 책이다.

왜 이상한지 들었지만 계속 그대로 쓸 것 같은 문장도 있긴 하다. 직장 생활에서는 어울리는 그런 말.

이 책은 얼마 전에 읽은 ‘이와 손톱‘처럼 두 개의 이야기를 교차해 보여준다.

이상한 문장 표현을 건조하게 알려주는 하나의 트랙이 있고, 교정에 관한 감성적인 소설이 또 하나의 트랙을 맡고 있다.

이 책을 선뜻 마초 성향의 뭇 남성들에게 추천하기 어려운 이유기도 하다.

문장 다듬는 것에만 관심이 있는 자라면 필요한 부분만 훑어봐도 좋을 것이다.

여러분도 하나 장만하시라.

종종 돌아보고 잊지 않기 위해 (나만 알 수 있게) 요약해둔다.

요약

적·의를 보이는 것·들

굳이 있다고 쓰지 않아도 어차피 있는

  • 있는
  • ㅡ관계에 있는
  • ㅡ에게 있어
  • ㅡ하는 데 있어
  • ㅡ함에 있어
  • ㅡ있음에 틀림없다

지적으로 게을러 보이게 만드는 표현

  • ㅡ에 대한
  • ㅡ들 중 하나/한 사람/무엇
  • ㅡ같은 경우
  • ㅡ에 의한, ㅡ으로 인한

ㅡ에 vs ㅡ에는

내 문장은 대체 어디에서 와서 어디로 가는 걸까

  • ㅡ에 vs ㅡ으로
  • ㅡ에 vs ㅡ을(를)
  • ㅡ로의, ㅡ에게로
  • ㅡ에, ㅡ에게, ㅡ에게서
  • ㅡ로부터

당하고 시키는 말로 뒤덮힌 문장

  • 설레임
  • 두번 당하는 말
  • 시키다
  • 시켜주다
  • 커피 나오셨습니다

사랑을 할 때와 사랑할 때의 차이

  • ㅡ을 하다 vs ㅡ하다

될 수 있는지 없는지

  • 될 수 있는, 할 수 있는

문장은 손가락이 아니다

  • 그, 이, 저, 그렇게, 이렇게, 저렇게
  • 여기, 저기, 거기
  • 그 어느, 그 어떤, 그 누구, 그 무엇

과거형을 써야 하는지 안 써도 되는지

  • ㅡ었던
  • ㅡ는가

시작할 수 있는 것과 없는 것

문장 다듬기

  • 왼쪽에서 오른쪽

연재글 전체 보기


지난번에 이어 CGIStream class(HTTPServer 참고)를 살펴보는 시간.

말했지만, 좀 옛스러운 코드니 결벽증이 있는 분은 다음 장으로…

왜 요즘 버전의 소스를 분석하지 않냐는 질문도 할 법 하다.

못 구했다.

일단 이건 정말 공부를 위한 뻘짓이기 때문에 굳이 구현하고 싶은 사람이 없었을 것으로 보인다.

게다가 PHP5.4부터 내장 서버를 지원하는데 뭐하러 굳이…

다시 집중!

CGIStream class

주석을 보면 CGIStream class는 CGI 프로세스가 완료될 때까지 버퍼 기능도 하고, 헤더를 조작하는 것도 가능하다고 한다.

외부에서 이 클래스를 어떻게 사용하는 지 찾아보면 눈에 익은 문법이 보일 것이다.

stream_wrapper_register("cgi", "CGIStream");

HTTPServer class(httpserver.php)에서 서버를 띄우기 전에 등록하는 코드다.

코드에서 보듯이 CGIStream class는 Stream wrapper class라고 할 수 있고,

Stream wrapper class의 메소드 중 stream_cast, stream_open, stream_read, stream_eof, stream_close만 구현했다.

이 중에 이해하기 쉬운 애들은 빼고 두가지 메소드만 설명해보겠다.

stream_read

stream_read는 BUFFERING/BUFFERED/EOF 상태에 따라 해야할 일을 구현해놓았다.

BUFFERING 단계에선 버퍼에 request 메시지를 담고, server 쪽 클래스를 통해 request 구문을 분석하고, response를 위한 문자열을 만들고, 이를 data type의 스트림에 쌓아둔다. 그리고 현재 상태를 BUFFERED로 업데이트 한다.

BUFFERED 단계에선 앞서 data 스트림에 쌓아두었던 값을 리턴한다. 그리고 현재 상태를 EOF로.

EOF 단계에선 return false;.

내가 상상했던 코드는 이 스트림쪽에선 버퍼링 정도만 처리하고 나머지는 server 쪽 코드에 위임하는 것이었는데,

\r\n\r\n(HTTP 메시지의 header와 body 구분자)로 메시지를 검사한다거나 HTTPServer::parse_headers 결과를 분석한다거나 하는 일도 한다.

이런 부분은 역할 분리가 덜 된 것 같다는 생각이 든다.

stream_open

stream_open 메소드가 호출되는 시점은 php.net에서 이렇게 설명한다.

This method is called immediately after the wrapper is initialized

wrapper가 초기화 될 때, 그러니까

fopen("cgi://PHP파일"...

이런 식이다.

stream_open에서 cgi로 php파일을 열고, stream_read에서 그 실행한 결과를 스트림으로 받아 처리하는 것이다.

이 예제에선 stream_context_get_options로 현재 설정된 stdin, env, server, response 등의 context를 얻어오고

이를 활용해 proc_open으로 스트림을 연다.

CGIStream는 여기서 마무리

이제 실제 서버를 띄우는 HTTPServer class에서 이 Stream wrapper(CGIStream class)를 등록하고 활용하는 부분을 보면 이 동네는 대략 이해가 될 것이다.

여기까지 분석해보니 결국 CGI용 Stream wrapper는 PHP 스크립트를 실행하기 위한 것이었다.

http://app.dev/test.php 같은 요청을 받는 일 말이다.

즉, (일단은) HTML과 이미지 정도의 static한 응답만 처리할 나에게는 php-cgi binary 같은 건 필요없다!! (오예)

HTTPServer class

이왕 본 김에 이 CGI 스트림 wrapper를 어떻게 등록하는 지만 살펴보고 (쓸 데 없었던) 2, 3장을 마무리 해야겠다.

HTTPServer class는 PHP 요청과 이미지 같은 static 리소스를 별도로 라우팅한다.

우선 서버를 실행하면

stream_wrapper_register("cgi", "CGIStream");

//중략

$sock = @stream_socket_server("tcp://$addr_port", $errno, $errstr);

CGI용 스트림 wrapper를 등록하고 tcp 소켓을 하나 열어둔다.

Client가 요청한 리소스가 PHP 스크립트일 경우, HTTPServer의 get_php_response 메소드로 라우팅된다.

앞서 CGIStream의 stream_open을 설명하면서 stream_context_get_options으로 컨텍스트 정보를 가져온다고 했는데,

바로 여기에서 컨텍스트 정보를 만들어 주는 것이다.

$context = stream_context_create(array(
'cgi' => array(
'env' => array_merge($_ENV, $this->cgi_env, $cgi_env),
'stdin' => $request->content_stream,
'server' => $this,
'response' => $response,
)
));
$cgi_stream = fopen("cgi://{$this->php_cgi}", 'rb', false, $context);

결론

예상대로(?) php-cgi binary 같은 건 필요없다는 결론을 얻기 위해 긴 시간을 허비했다.

이 시리즈의 다음 글부터는 최소한의 구성으로 서버를 띄우고, 기능을 하나씩 입혀 나갈 예정이다.

그러니까…

예정이다.

연재글 전체 보기


Stream wrapper class

시행착오를 최대한 줄이기 위해 앞에서 소개했던 HTTPServer부터 분석을 해보기로 했다.

composer.json에 있는 php-cgi binary에 대한 의존성 때문인데(HTTPServer requires the php-cgi binary), 이 놈을 왜 넣었을까 궁금했다.

이 서버는 4개의 파일로 구성됐다.

  • cgistream.php
  • httprequest.php
  • httpresponse.php
  • httpserver.php

http가 prefix로 붙은 파일은 뭐하는 놈인지 이름만 봐도 알 것 같다. cgistream.php을 열어봤다.

오래전에 만든 클래스라서 좀 옛스러운 맛이 있다.

CGIStream class 주석

주석부터 살펴볼까?

/*
* CGIStream is a PHP stream wrapper (http://www.php.net/manual/en/class.streamwrapper.php)
* that wraps the stdout pipe from a CGI process. It buffers the output until the CGI process is
* complete, and then rewrites some HTTP headers (Content-Length, Status, Server) and sets the HTTP status code
* before returning the output stream from fread().
*
* This allows the server to be notified via stream_select() when the CGI output is ready, rather than waiting
* until the CGI process completes.
*/

CGIStream is a PHP stream wrapper that wraps the stdout pipe from a CGI process.

CGI process로부터 흘러나오는 stdout stream을 wrapping한다. - 이건 뭐 해석한 것도 아니고 안 한 것도 아니고…

참고로, 앞으로 스트림과 stream을 섞어서 쓸텐데(한글 혹은 영어로), 여기엔 큰 의미를 부여하지 말고 그냥 읽으시면 된다.

Stream wrapper class

php.net에 들어가보면,

Allows you to implement your own protocol handlers and streams for use with all the other filesystem functions (such as fopen(), fread() etc.).

나만의 프로토콜 핸들러와 fopen, fread같은 filesystem 함수를 사용할 수 있는 스트림을 구현할 수 있다고 한다.

php.net의 VariableStream을 만드는 예제부터 알아봐야겠다.

우선 커스텀 stream wrapper를 어떻게 사용하는지부터.

Using stream wrapper

<?php
$existed = in_array("var", stream_get_wrappers());
if ($existed) {
stream_wrapper_unregister("var");
}
stream_wrapper_register("var", "VariableStream");

“var”로 이미 등록된 wrapper가 있다면 먼저 제거한다.

stream_get_wrappers는 등록된 stream wrapper 배열을 리턴하는데, 보통 이런 리스트가 나올 것이다.

Array
(
[0] => https
[1] => ftps
[2] => compress.zlib
[3] => compress.bzip2
[4] => php
[5] => file
[6] => glob
[7] => data
[8] => http
[9] => ftp
[10] => phar
[11] => zip
)

이렇게 unregister하더라도 built-in wrapper일 경우는 나중에 stream_wrapper_restore로 복원할 수 있다.

stream_wrapper_register함수의 두번째 인자는 첫번째 인자(protocol)에 해당하는 스트림을 처리할 클래스명이다.

이런 클래스를 어떻게 구현할 것인지는 잠시 후에 다룬다.

$myvar = "";

$fp = fopen("var://myvar", "r+");

fwrite($fp, "line1\n");
fwrite($fp, "line2\n");
fwrite($fp, "line3\n");

위에서 언급한 대로 stream wrapper를 구현하면 fopen, fread같은 filesystem 함수를 사용할 수 있다.

fopen 함수로 스트림을 r+(초기화가 없는 read+write) 모드로 열고, 몇 줄 써준다.

URL에는 아까 stream_wrapper_register함수에 첫번째 인자로 등록한 protocol을 URL scheme으로 사용하고 입력받을 myvar라는 전역 변수를 사용한다.

물론 URL을 어떻게 쓰고 처리할 지는 wrapper 구현에 달려있다.

rewind($fp);
while (!feof($fp)) {
echo fgets($fp);
}
fclose($fp);
var_dump($myvar);

파일 포인터를 맨 앞으로 돌리고, 파일의 내용을 한줄 한줄 써준다.

if ($existed) {
stream_wrapper_restore("var");
}

다 썼으면 초기화 한다. 만약,

stream_wrapper_register("var", "VariableStream");
stream_wrapper_unregister("var");
stream_wrapper_restore("var");

이러면 뭐 달라질까? 방금 등록한 var wrapper는 built-in이 아니기 때문에 restore해도 아무 의미 없다.

사용하는 방법은 특별할 게 없으므로, VariableStream이란 클래스를 어떻게 구현하면 될지 살펴보자.

Stream wrapper

Stream wrapper class는 넓은 의미의 인터페이스지만,

implements 할 수 있는 interface는 아니다. 실제 존재하는 클래스가 아니라 어떻게 동작하는 지를 보여주기 위한 prototype일 뿐이다.

Note:
This is NOT a real class, only a prototype of how a class defining its own protocol should be.

php.net에 누가 댓글로 interface로 만들어 올렸지만, 그건 거 쓰지 말라는 댓글도 보인다(필요하지 않은 메소드도 무조건 구현해놔야 하기 때문에).

필요한 메소드만 구현하는 게 정석이라고 보면 되겠다.

댓글로 올라온 VariableStream 클래스를 참고해서 위 스크립트가 돌아갈 만한 간단한 커스텀 wrapper를 실행해봤다.

몇가지 문법 오류와 변수명을 좀 더 알기 쉽게 바꿨다.

class VariableStream
{
private $position;
private $varname;

public function stream_open($path, $mode, $options, &$opened_path)
{
$url = parse_url($path);
$this->varname = $url["host"];
$this->position = 0;
return true;
}

public function stream_read($count)
{
$position =& $this->position;
$ret = substr($GLOBALS[$this->varname], $position, $count);
$position += strlen($ret);
return $ret;
}

public function stream_write($data)
{
$variable =& $GLOBALS[$this->varname];
$len = strlen($data);
$position =& $this->position;
$variable = substr($variable, 0, $position) . $data . substr($variable, $position += $len);
return $len;
}

public function stream_tell()
{
return $this->position;
}

public function stream_eof()
{
return $this->position >= strlen($GLOBALS[$this->varname]);
}

public function stream_seek($offset, $whence)
{
$len = strlen($GLOBALS[$this->varname]);
$position =& $this->position;
switch ($whence) {
case SEEK_SET:
$newPos = $offset;
break;
case SEEK_CUR:
$newPos = $position + $offset;
break;
case SEEK_END:
$newPos = $len + $offset;
break;
default:
return false;
}
$ret = ($newPos >= 0 && $newPos <= $len);
if ($ret) {
$position = $newPos;
}
return $ret;
}
}

여기선 stream_open, stream_read, stream_write, stream_tell, stream_eof, stream_seek 만을 구현했는데,

그 (가상) 인터페이스의 많은 메소드 중에 뭘 구현해야 할 지 어떻게 알 수 있을까?

그건 사용처에서 어떤 filesystem 함수를 쓰는가에 따라 다르다.

이 예제에서 stream_tell 함수를 구현하지 않았다면, 아래와 같은 warning이 뜰 것이다.

PHP Warning:  rewind(): VariableStream::stream_tell is not implemented! in .....

공용 클래스라면 좀 더 충실히 구현해야겠지만, 이 예제는 이만하면 됐다.

많이 돌아왔는데, 맨 처음에 언급한 CGIStream 클래스로 돌아갈 차례다(이제 겨우 CGIStream 클래스의 주석 한줄 읽었을 뿐이다).

다음 글에 계속..

연재글 전체 보기


HTTP 완벽가이드 5장을 가볍게 읽다가, Perl로 만든 간단한 서버 구현을 보고 이걸 PHP로 구현해보면 좋겠다는 생각을 했다.

그 예제는 (대충 봤는데) HTTP 서버라기 보다 소켓 연결해서 응답해주는 수준의 간단한 구현이었던 것 같고, HTTP 서버가 되려면 뭘 해야하는지는 설명으로만 보여준다.

이응준님은 “웹 프로그래머를 위한 HTTP 완벽 가이드 읽는 법“에서

5장 “웹 서버”는 웹 서버가 어떻게 동작하는지 설명한다. 웹 프로그래머라면 반드시 이해해야 할 것이다.

라고 하셨지만, 사실 책에는 뭐 그리 많은 내용이 있는 건 아니다.

HTTP 완벽가이드 5장의 내용을 더 알고 싶다면, 남이 정리한 HTTP 완벽가이드 5장 자료를 찾아봐도 좋겠다.

구현 목표

몇부작이 될 지는 모르겠으나 시간날 때마나 기능을 하나씩 추가해볼까 한다.

단일 쓰레드로 하나의 요청만 처리하는 것부터 시작하기 때문에,

나중에 ReactPHP나 멀티쓰레딩 처리(PHP 7.2이상 + CLI 모드에선 가능한 듯) 혹은 멀티쓰레딩 흉내를 내는 것까지 갈 것 같다.

물론 올 해 안을 목표로 하는 프로젝트도 있기 때문에…한두번 하고 그만둘 수도 있다!
(중요/강조/미리죄송)

지금은 뭔가 신났지만…시작이나 하면 다행.

1차로 구현하고자 하는 기능은 ‘이미지가 하나 포함된 HTML 파일을 서비스한다’.

즉, HTML 파일 하나와 이미지 하나 정도 서비스 할 수 있는 능력이면 됐다.

예를 들어 이런 식.

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>PHTTP</title>
</head>
<body>
<img src="https://www.engadget.com/media/2006/02/beer_server.jpg" alt="beer_server">
</body>
</html>

가장 간단하게 시작하기

php.net의 예제

  • 클래식한 소켓 연결 예제 프로그램
  • 5년 전 기억을 더듬어 보면 이런 식으로 짰던 것 같기도 하다

PHP Socket Programming, done the Right Way

  • 여기서는 PHP5.0 부터 사용할 수 있는 stream_socket_* 친구들을 소개한다
  • 존 레식 닯은 청년이 아주 친절하게 설명해주기 때문에, 누구든 국경을 초월한 지식 습득이 가능하다
  • 안 쓸 이유가 없다

찾아보니 OOP 방식의 간단한 구현체도 보임

  • A simple HTTP server
  • 아마도 위의 저 간단한 서버로 운영을 하다보면 겪을 수많은 고민이 담겨 있다(간단하게 구현 했음에도!)
  • 하지만 내 수준에서 보면 너무 많이 구현해놓았으니, 간간히 안 풀릴 때 참고만 해보자

기본 기능

socket create/bind/listen까지는 가장 간단하게 구현된 놈을 쓰자.

최신 스타일로 하자면 stream_socket_server/stream_socket_accept.

이제 main 스크립트 하나 돌리면서, 들어오는 스트림을 앞으로 만들 클래스 인스턴스로 넘겨주는 작업부터 본격 개발 시작이다.

구현할 기능

  • 요청 메시지 읽어들이기
  • 메시지 파싱
  • 리소스 접근
    • config파일 활용
      • document root, virtual host
  • 응답 만들기
    • 응답 코드 / 헤더 / 본문을 어떻게 구성할 지 정한다
  • 응답/로그

예상되는 변화

앞에서 언급한 대로 다중커넥션 처리

400/500번 대 일부 구현

  • 400, 404, 500 정도 구현할까 싶은데
  • 에러 페이지는 사치

경로 조회 공격 방어 [?]

directory index

저장소

GitHub : https://github.com/youngiggy/phttp

이전 유니코드를 다뤘던 글을 올렸을 때 모던 PHP 그룹에서 댓글로 잠시 언급되었던,

NFD 정규화 문제.

맥에서 올린 파일이 다운로드 안된다는 VOC(고객의 소리)가 들어와서 원인 파악 겸 정리해봤다.

맥에서 ‘한글.txt’ 파일을 올리고 윈도에서 다운로드 받으면 ‘ㅎㅏㄴㄱㅡㄹ.txt’로 보이게된다.

유니코드 상에서 한글을 표현하는 방법은 첫가끝(처음가운데끝)이라고도 하는 한글 자모를 조합하는 방식과 완성형으로 표시하는 방법 등이 있다.

맥에서는 파일명을 저장할 때 NFD 방식. 즉, 한글 자모를 따로따로 받아 조합하는 방식으로 정규화를 하는데,

이 현상에 관해선 다른 분들이 열심히 글을 써주셨으니 더이상 설명하진 않겠다.

Normalizer Class 설치

PHP 5.3부터 Normalizer Class를 사용할 수 있는데,

Internationalization Functions에 포함되어 이 확장 모듈을 설치해야 한다.

  • 이건 ICU 라이브러리의 wrapper인 관계로 ICU를 먼저 설치해야할 수 있다(libicu-devel , libicu 등)

  • Windows 시스템에선 php_intl.dll을 구해야하고, XAMPP를 쓴다면 모듈은 이미 ext에 존재할 것이다.

  • Linux 기반 시스템이라면

    • apt-get install php-intl (for ubuntu-based linux)
    • yum install php-intl (for CentOS)
    • php7.x-intl 등 php 버전에 따라 다를 수 있으니 알맞게 설치할 것
  • Mac에서 brew를 사용한다면 brew install php71-intl

이후 php.ini에서 extension=php_intl.(dll|so)을 활성화 시켜야 한다. 즉, 맨 앞의; 제거하기.

처리 방법

잘 설치됐는지 확인해보자.

<?php

function testNormalizer($str)
{
echo '-------------------------' . PHP_EOL;
$urlencodedInitialStr = urlencode($str);

echo $str . PHP_EOL;

echo ( Normalizer::isNormalized($str, Normalizer::FORM_C) ) ? "normalized : FORM_C" : "not normalized : FORM_C";
echo PHP_EOL;
echo ( Normalizer::isNormalized($str, Normalizer::FORM_D) ) ? "normalized : FORM_D" : "not normalized : FORM_D";
echo PHP_EOL;

$str = Normalizer::normalize($str, Normalizer::FORM_C);
echo 'normalize to FORM_C : ' . $str . PHP_EOL;

echo 'urlencoded before: ' . $urlencodedInitialStr . PHP_EOL;
echo 'urlencoded after~: ' . urlencode($str) . PHP_EOL;
echo PHP_EOL;
}

$name1='recruit/recruit/201709/21/owlupr_58qh-1meg1s6_recruit.pdf';//일반 ASCII 문자열
$name2=urldecode('%E1%84%86%E1%85%A2%E1%86%A8%E1%84%8B%E1%85%A6%E1%84%89%E1%85%A5%E1%84%90%E1%85%A6%E1%84%89%E1%85%B3%E1%84%90%E1%85%B3_%E1%84%8C%E1%85%AE%E1%86%BC%E1%84%8B%E1%85%B5%E1%84%82%E1%85%A6.pdf');//NFD로 정규화된 파일명을 urlencode해서 받을 경우
$name3='테스트.pdf';//일반 한글 문자열

testNormalizer($name1);
testNormalizer($name2);
testNormalizer($name3);

결과는

-------------------------
recruit/recruit/201709/21/owlupr_58qh-1meg1s6_recruit.pdf
normalized : FORM_C
normalized : FORM_D
normalize to FORM_C : recruit/recruit/201709/21/owlupr_58qh-1meg1s6_recruit.pdf
urlencoded before: recruit%2Frecruit%2F201709%2F21%2Fowlupr_58qh-1meg1s6_recruit.pdf
urlencoded after~: recruit%2Frecruit%2F201709%2F21%2Fowlupr_58qh-1meg1s6_recruit.pdf

-------------------------
맥에서테스트_중이네.pdf
not normalized : FORM_C
normalized : FORM_D
normalize to FORM_C : 맥에서테스트_중이네.pdf
urlencoded before: %E1%84%86%E1%85%A2%E1%86%A8%E1%84%8B%E1%85%A6%E1%84%89%E1%85%A5%E1%84%90%E1%85%A6%E1%84%89%E1%85%B3%E1%84%90%E1%85%B3_%E1%84%8C%E1%85%AE%E1%86%BC%E1%84%8B%E1%85%B5%E1%84%82%E1%85%A6.pdf
urlencoded after~: %EB%A7%A5%EC%97%90%EC%84%9C%ED%85%8C%EC%8A%A4%ED%8A%B8_%EC%A4%91%EC%9D%B4%EB%84%A4.pdf

-------------------------
테스트.pdf
normalized : FORM_C
not normalized : FORM_D
normalize to FORM_C : 테스트.pdf
urlencoded before: %ED%85%8C%EC%8A%A4%ED%8A%B8.pdf
urlencoded after~: %ED%85%8C%EC%8A%A4%ED%8A%B8.pdf

소스에는 아래와 같이 적용 해놓으면 된다.

if (class_exists('Normalizer')) {
if (Normalizer::isNormalized($filename, Normalizer::FORM_D)) {
$filename = Normalizer::normalize($filename, Normalizer::FORM_C);
}
}

파일명을 DB에 입력할 때 NFC로 넣는 것이 깔끔하겠지만,
이미 NFD 정규화된 파일명이 들어가있다면, 다운로드 시에도 파일명을 바꿔주면 된다.

한숨

이 밖에 파일 다운로드 관련 코드를 보면 별별 예외처리가 많이 들어가 세월의 흔적을 느낄 수 있다.

IE라면 파일명을 또 MS949로 변경해줘야 해야만 한다.
OS 별로 파일명에 들어갈 수 있는 특수문자도 다르다.
기타 등등.

특정 OS는 점유율이 낮아서, 시스템이 낡아서, 인력이 부족해서, 개발자 역량이 부족해서, 지금은 더 급한 게 있어서…
cross-platform 대응은 항상 어렵다.

참고

클린 소프트웨어

이 책에 인쇄된 문자를 거의 다 읽긴 했으나,
내가 이 책을 읽었다고 할 수 있을까 생각이 드는 책이다.

목차

  • PART 1 애자일 개발
  • PART 2 애자일 설계
  • PART 3 급여 관리 사례 연구
  • PART 4 급여 관리 시스템 패키징
  • PART 5 기상 관측기 사례 연구
  • PART 6 ETS 사례 연구
  • APPENDIX A UML 표기법 I: CGI 예제
  • APPENDIX B UML 표기법 II: 스태트먹스
  • APPENDIX C 두 기업에 대한 풍자
  • APPENDIX D 소스 코드는 곧 설계다

먼 나라 이야기라고 생각이 드는 PART 1에서 작은 고민을 얻어왔다면

PART 2에서는 SOLID 원칙을 제대로 이해하기 위한 색다른 관점이 좋았고(그런데 10여 년 전에 출간된 이 책을 두고 색다르다고 해도 실례가 안 되려나).

PART 3부터 익숙치 않은 C++ 문법과 더 익숙치 않은 UML 문법 때문에 눈이 뻑뻑해지기 시작했는데, 결국 부록의 UML 표기법을 먼저 보고올 수 밖에 없었다. 그런데 이 책을 읽는데 별로 도움 안되는 것 같고, 그냥 간단한 규칙 정도만 찾아보고 책을 한번 더 읽는 것이 좋겠다는 생각이 들었다. 아무래도 밥아저씨의 UML 책을 읽어야겠구나 싶을 정도로 UML로부터 설계를 해나가는 모습이 꽤 매력적으로 느껴졌다(현실은 시궁창일 걸 알면서 말이다).

PART 4는 방금 늪에서 빠져나온 여우마냥 숨 좀 고를 수 있는 챕터였지만, PHP/JavaScript를 쓰는 상황에서 패키징에 대한 글을 읽자니 남일 같아서 마음이 편했다.

PART 5에선 PART 3의 급여 관리 사례와 비슷한 포맷이긴 한데, 일단 급여 관리에서 빠져나와 새 인생을 시작한 기분이었으므로 나름 재미있게 읽었던 것 같다.

PART 6도 완벽하게 이해는 못했지만 다시 읽으면 그럭저럭 이해는 될 것 같긴 하다.

APPENDIX B는 전혀 모르겠고..

APPENDIX C가 꽤 인상적이었다. 두 기업에 대한 풍자라고 하는데, 왼쪽엔 그지같은 기업, 오른쪽엔 이상적인 기업을 나란히 덧대어 놓았다. 그동안 왼쪽같은 문화에서만 살았고(그리고 아마 10월부터 또 겪어야 할텐데..), 오른쪽같은 문화를 꿈꾸지만 경험은 없는 상태. 이 책을 힘겹게 읽으면서 좀 지쳤는데, 이 부록을 읽으며 다시 전의가 불타올랐다(하지만 이제 곧 잘 시간). 조금이라도 시도해 보리라 다짐을 해봤다(현실은 시궁창일 걸 알면서 말이다).

APPENDIX D도 너무 좋았다. 10년 전에 쓴 이 책에 10년 전에 썼다는 글을 소개한 내용인데, 공학에서의 최종 결과물은 문서가 되어야 하고, 소프트웨어 공학에서의 문서라 함은 코드 자체라는 주장이다. 그냥 자기계발서 같은 글인가 싶었는데, 이 양반은 진심이었고 다 읽고 나선 진심 공감이 됐다.

결론

다시 읽어야 한다.

읽기 시작하면서부터 몇 번을 다시 읽어야 할까 생각이 들었던 책이다. 지금 바로 다시 읽어야 이 책을 읽기나 했다고 어디가서 얘기라도 할텐데…

내년에 다시 읽어보기로 했다. 누구 안 주고 책장에 잘 모셔두고 내년 초에 다시 읽어보겠다.

실무에서 좀 더 부딪혀보고, 실습해보고, 다시 보면 이해하는 폭이 더 넓어지리라 생각이 든다(현실은 시궁창일 걸 알면서 말이다).

원몰띵

이런 책을 읽으면서 항상 느끼는 거지만…

테스트가 주도하지 않고도 제대로된 소프트웨어를 만드는 게 가능이나 한가…뭐 그런 생각이 든다.

0%