PHP의 output_buffering

Streaming & Buffering

Streaming이란 응답을 한번에 보내지 않고 chunk로 나누어 보내는 것.

이를 downstream(브라우저 같은 client만을 의미하지 않고 upstream에서 만들어진 데이터를 받는 쪽을 의미하는 상대적인 개념)에서 사용하거나 또 다른 downstream으로 전달하기 위해 설정된 크기만큼 모으는 행위를 buffering이라고 한다. (내 생각이고 정확한 정의는 아니다)

브라우저에서는 body 태그가 나타난 이후 스트림으로 받는 컨텐츠를 받는 족족 화면에 추가해준다. 이 때문에 뒤늦게 추가되는 CSS가 있다면 모든 요소의 로드가 끝날 때까지 스타일이 계속 변하는 페이지도 많이 볼 수 있다. 바로 이놈의 블로그 테마처럼! 곧 갈아치우던가 해야지.

(이렇게 잘못 설계하면 불편함을 초래하지만) 이런 매커니즘은 다운로드를 최종 완료하는 시점을 더 늦추는 대신 빠른 반응 속도를 제공할 수 있고, 사용자들은 더 빨리 로드되는 것처럼 느끼게 된다.
그리고 보내는 쪽에서도 유익한 것이, 서버의 메모리 제한에 상관없이 전체 데이터를 메모리에 두지 않고도 거대한 파일을 클라이언트에 보낼 수 있다.
반면 너무 작은 크기로 잘라 계속 보내게 되면, 네트웍으로 전송하는 비용과 chunk로 자르는 비용과 chunk를 분석하는 비용이 추가가 된다.

괜히 있어야 할 말인 것 같아서 쓸데없이 당연한 말 좀 써봤다.

output_buffering 옵션

php.ini에서 많이 만나봤던 그 옵션이다. output buffering이란 PHP 엔진에서 downstream이라고 부를 수 있는 다음 단계인 apache(mod_php), FastCGI, CLI 등에 즉시 응답을 주는 대신 일정 크기의 버퍼에 데이터를 쌓고, 버퍼가 다 채워지거나 PHP 스크립트의 실행이 끝날 때 전달해주는 옵션이다.

이런 느낌.

big_chunk_buffer image

공식 매뉴얼을 읽어보면,

You can enable output buffering for all files by setting this directive to ‘On’. If you wish to limit the size of the buffer to a certain size - you can use a maximum number of bytes instead of ‘On’, as a value for this directive (e.g., output_buffering=4096). As of PHP 4.3.5, this directive is always Off in PHP-CLI.

default가 “0”이고, “1”로 설정하면 “On”과 동일하기 때문에 메모리가 허용하는 선까지 버퍼링하는데, 이는 위험하니 보통 4096으로 설정하는 것 같다. 왜 4096이란 숫자가 나왔는지 궁금한데 잘 모르겠다. 보통 이렇게 기본 설정으로 해놓는 듯. 왜??

4096 bytes(4KB)로 설정하면 PHP는 echo나 print 등의 출력을 하더라도 바로 다음 단계로 넘기지 않고 4KB까지 들고 있는다는 말이다.

중요한 점은 PHP-CLI에서는 PHP 4.3.5 버전 이후로 항상 “Off”란다.

이제 로컬 머신에 어떻게 설정이 되어있는지 확인해보자.

서버를 띄우고 phpinfo()를 찍는다.

1
2
> echo "<?php phpinfo();" > output.php
> php -S localhost:8888

phpinfo_output_buffering option image

내친김에 CLI는 정말 항상 0이 되는지도 확인.

1
2
❯ php -i | grep buff
output_buffering => 0 => 0

buffering 실험 - PHP 엔진

PHP Streaming and Output Buffering Explained라는 글에서 실험 방법과 코드를 가져왔다.

아까 띄웠던 서버는 죽일 필요없이 output.php를 열어 아래와 같이 수정한다.

1
2
3
4
<?php
echo "Hello ";
sleep(5);
echo "World!";

브라우저로 http://localhost:8888/output.php에 접근해보면, 5초후 Hello World!가 동시에 노출된다. “Hello “가 output_buffering 제한보다 짧아서 echo를 했음에도 아직 버퍼에만 쌓여있는 상태이기 때문이다.

아까 CLI는 output_buffering이 항상 꺼져있다고 했는데, CLI에서 돌려보면 어떻게 될까? 당연히 “Hello “가 먼저 화면에 찍힐 것을 예상할 수 있다.

1
❯ php output.php

그럼 이제 output_buffering 설정값인, 4KB를 다 채우는 실험을 해보자.

output.php파일의 내용을 아래 소스로 바꿔준다.

1
2
3
4
5
6
7
8
<?php
$multiplier = 1;
$size = 1024 * $multiplier;
for($i = 1; $i <= $size; $i++) {
echo ".";
}
sleep(5);
echo " Hello World ";

(buffering limit에 한참 모자라는) 1024 byte만 채우고 5초를 쉬기 때문에 이번에도 역시 Hello World!가 동시에 보인다.

$multiplier를 4로 늘려 다시 실행해보자. 이번에는 점(.) 4096개가 먼저 화면에 노출되는 것을 볼 수 있다.

만약 시킨대로 php -S로 서버를 구동하지 않고 웹서버를 통해 서비스했다면, 이런 현상은 못 볼 수도 있다. 아마 8KB로 늘려야 할 지도 모른다. 서버 설정에 관해선 잠시 후에 더 다룰 것이다.

ob_flush(); flush();

ob_flush와 flush는 데이터를 상위 레이어로 보내는 built-in method이다.
버퍼된 데이터는 버퍼가 꽉 차거나 php 실행이 끝나지 않는 이상 비워지지 않는데, 이 메소드가 버퍼를 비우는 일을 한다.

눈으로 확인하기 위해 sleep(5); 전에 ob_flush(); flush(); 를 심자.

1
2
3
4
5
6
7
8
9
<?php
$multiplier = 4;
$size = 1024 * $multiplier - 1;
for($i = 1; $i <= $size; $i++) {
echo ".";
}
ob_flush(); flush();
sleep(5);
echo " Hello World ";

$size = 1024 * $multiplier - 1;를 했기 때문에 output 버퍼에 다 차지 않았지만, flush를 했기 때문에 4095개의 점(.)이 화면에 먼저 뿌려지는 걸 볼 수 있다.

ob_flush(); flush(); 두 메소드를 같이 쓴 이유?

flush

Flushes the system write buffers of PHP and whatever backend PHP is using (CGI, a web server, etc)

ob_flush

This function will send the contents of the output buffer

flush는 PHP를 사용하는 백엔드, 즉 다음 단계로 데이터를 전달하는 역할을 한다.

output_buffering 설정이 되어 있지 않아도 ob_start를 호출하면 자체적으로 output buffering을 사용할 수 있는데(이 때는 output_buffering으로 설정한 크기를 넘어서도 버퍼에 저장할 수 있다), 이때 쌓인 버퍼가 있다면 ob_flush를 해줘야 한다. 따라서 ob_flush는 flush보다 앞서 실행해야 한다.

웹서버 설정

초반에 PHP 엔진에서 버퍼가 다 차면 그 데이터를 다음 단계(downstream)로 전달한다고 이야기 했는데,

그 다음 단계가 웹서버라고 하면, 웹서버에서는 이렇게 받은 걸 바로 클라이언트로 전달해줄까? 미안하지만 웹서버도 자체 버퍼링을 한다. 그 다음에 끼어 있는 프록시는? ISP는? 브라우저는? 모두 나름의 버퍼링 메커니즘을 갖고 있다.

1
클라이언트(브라우저 등) - ISP - Proxy - Web Server - Application Server - DB

웹서버는 보통 다음과 같이 사용하게 될텐데,

1
2
3
4
브라우저 - php 내장서버
브라우저 - Apache - mod_php
브라우저 - nginx - fastcgi + php
...생략

위애서 본 대로 php의 내장서버를 사용하면 자체 버퍼를 두지 않고 바로 클라이언트에 데이터를 보낸다.

Apache + mod_php에서 mod_php에서는 버퍼링을 하지 않지만, Apache는 버퍼링을 한다.

FastCGI는 Apache에 붙여서 사용하든 nginx에서 사용하든 버퍼링 옵션이 있는데,

nginx에서는 4KB(32bit 머신) 혹은 8KB(64bit 머신)의 버피링을 갖고,
Apache에서는 (Apache에서 FastCGI를 물려본 적은 없지만) 문서에 따르면 65536(64KB)이나 된다. (default가 너무 커서 내가 과연 제대로 찾은 건가 싶기도 한데..)

웹서버가 버퍼링하는 모습을 확인하기 위해 아파치를 띄우고 새로운 파일을 만들어 서비스 해보자.

  • 아파치를 띄우는 건 알아서…
  • 나의 실행환경은 PHP Version 7.0.25-0ubuntu0.16.04.1, Apache/2.4.18 (Ubuntu)
1
2
cd 웹서버띄운디렉토리
vim webserver.php
1
2
3
4
5
6
7
8
9
<?php
$multiplier = 1;
$size = 1024 * $multiplier;
for($i = 1; $i <= $size; $i++) {
echo ".";
}
flush();
sleep(5);
echo " Hello World ";

이제 브라우저에서 열어본다.

output_buffering이 4096으로 설정된 상태이고 echo “.”;을 1024번 했으나, flush()를 실행했기 때문에 바로 화면에 점(.) 1024개가 찍힐 것으로 기대했을 것이다. 그런데 점(.)과 “ Hello World “는 동시에 나타난다. $multiplier를 4로 늘려 4096개의 점을 찍으면 이번에는 점(.)부터 화면에 노출된다.

즉, 아파치에서 클라이언트로 결과를 보내기 전 4096의 버퍼가 존재한다.

gzip 같은 압축 전송의 경우 클라이언트로 보낼 데이터를 모두 모은 후 압축해서 한번에 보낼 수도 있기 때문에 꺼둬야 한다.

gzip을 설정 파일에서도 끌 수 있고, PHP 스크립트에서 @apache_setenv('no-gzip', 1);로 끌 수도 있다.

nginx + FastCGI를 사용할 때는, 아래 설정을 추가해야 가능하다.

1
2
3
4
fastcgi_buffer_size   1k;                              
fastcgi_buffers 128 1k; # up to 1k + 128 * 1k
fastcgi_max_temp_file_size 0;
gzip off;

buffer는 PHP 엔진에서 넘어오는 것보다 작게 두고, 이를 넘어가면 파일에 쓰지 않도록 fastcgi_max_temp_file_size를 0으로 설정한다.

주의

압축 전송 모드를 꺼버리는 것은 버퍼링 테스트를 할 때만 참고하자. 실제 서비스에서는 서비스의 성격에 따라 결정해야 한다.

nginx에서는 header('X-Accel-Buffering: no’);를 통해 특정 response에만 적용할 수 있다. Vanish 같은 데선 header('Surrogate-Control: BigPipe/1.0');. (아파치도 이런게 있었던 것 같은데…)

데이터를 만들어지는 대로 flush하고 chunk로 나누어 보내기 시작하면, 클라이언트에 200번대 응답을 주고 전송하기 시작할 것이다. 그런데 한번 200 code 응답을 보냈음에도 중간에 오류가 발생해서 보내지 못할 수가 있다. 각오하자.

데이터가 쌓이는대로 클라이언트에 전달해주면, 보낼 컨텐츠의 전체 길이를 알지 못하게 된다. 전체 길이를 알면 부분 다운로드나 이어받기가 가능해지므로, 상황에 따라 미리 컨텐츠의 데이터 크기를 알아두는 것도 도움이 된다. 여기서 또 주의할 점은 Content-Length는 Content-Encoding(gzip, deflate 등)을 한 이후의 길이를 의미한다는 것이다.

동적으로 생성될 경우 전형적인 압축 스트림은 아래와 같은 헤더를 갖는다.

1
2
3
Content-Type: text/html
Content-Encoding: gzip
Transfer-Encoding: chunked

또 어떤 경우에는 chunk의 크기를 결정해야할 수도 있다. 컨텐츠의 크기나 네트웍 상황에 따라 작은 chunk로 여러번 보내거나 큰 chunk로 적게 보내는 걸 결정해야 하는데, 버퍼 크기를 늘리면 커넥션마다 메모리를 더 많이 사용하게 되고, OS의 메모리 한계에 다다르면 out of memory나 disk에 쓰기 시작할 것이다. chunk가 작으면 chunk를 만들고 분석하는 비용이 더 들 수도 있다.

온갖 곳에 버퍼가 있기 때문에 chunk를 작게 자른다 하더라도 한 트랜잭션 내 어딘가에서 전체 데이터를 버퍼링하고 있다면 앞서 자른 의미가 없어지기도 한다.

브라우저

브라우저에서도 서버가 주는 데이터를 바로바로 화면에 표시하지 않고 어느 정도 모아서 주곤 했는데, 2013년도 stackoverflow의 글에 비해 많이 달라진 것 같다.

여기에 따르면 FF는 1024라고 되어 있지만 현재는 FF도 매우 낮다.

현재의 크롬(65.0.3325.181)은 2byte(= 2byte가 넘으면 렌더링 시작).
FF(58.0.2)는 1byte(= 1byte가 넘으면 렌더링 시작).
curl은 여전히 4096.

결론

버퍼는 어디에나 있다.





참고 :

이미지 출처 :