PHP로 HTTP 서버 구현하기 - 05 - 아주 간단한 HTTP 서버로 첫 요청과 응답

연재글 전체 보기


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

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

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 함수로 대충 때웠으니 했다고 치자)