Backends PHP

PHP에서 데이터를 파일로 캐싱(Caching)하기

성능좋은 인프라와 빠른 속도의 DB Query로 아무리 구성하고자 노력한다 하더라도, 캐싱은 여전히 웹프로그램의 성능을 향상시키기 위한 가장 고전적이면서 가장 가성비 높은 해법입니다. PHP에서는 APC와 같은 모듈을 활용하는 것이 일반적이지만, 특정 웹페이지에서 사용하는 데이터를 핀포인트로 캐싱할 때는 이를 직접 구현해보는 것도 선택가능한 대안입니다.

이번 포스팅에서는 일반적으로 PHP에서 변수에 담긴 데이터를 파일로 캐싱하는 방법을 다양한 예제코드를 통해 설명합니다. 이 과정에서 실무에 적용가능한 수준의 예제코드를 몇 가지 선보이고자 합니다. 마지막 단계에서는 PHP의 파일시스템 함수들을 조합하여, 충분한 시간이 지나면 캐싱이 만료되는 TTL(Time to Live)의 구현방법을 모색해볼 것입니다.

 

핵심 아이디어 : PHP로 PHP 소스코드가 담긴 PHP 파일을 만들기

금번에 소개할 PHP 연관배열 캐싱의 핵심 아이디어는, PHP 소스코드가 담긴 PHP 파일을 PHP로 작성할 수 있다는 데에 있습니다. 이 아이디어를 예제코드로 구현한다면 아래와 같습니다. 이 소스코드를 실행하면 실행한 PHP 파일과 같은 디렉토리에 print.php라는 파일이 생기고, 화면에는 “PRINT”라는 문구가 표시될 것입니다.

$filename = 'print.php';

$fp = fopen($filename, 'w+');
fwrite($fp, '<?php echo \'PRINT\'; ?>');
fclose($fp);

include($filename);

원리는 소스코드의 간결함만큼이나 이해하기 쉽습니다. 이 예제코드는 fopen()으로 print.php라는 파일을 생성하여, 화면에 “PRINT”라는 PHP 소스코드를 fwrite()로 담습니다. 그 후에 생성한 print.php을 다시 include 함으로써 파일에 담았던 코드를 실행하게 합니다.

 

Step #1 : 1차원 배열을 PHP 파일로 캐싱하기

조금 전 살펴본 원리를 이용한다면, 배열을 문법 그대로 PHP 파일을 작성해놓고 include하는 방법도 생각해볼 수 있습니다. 벌써 쉽고 간결한 캐싱방법에 한 걸음 다가선 셈입니다. 아래는 4개의 지역명이 들어간 인덱스 배열을 $dat라는 변수에 캐싱한 파일을 만드는 간단한 코드입니다.

$filename = 'dataCache.php';

// Targeted Data for Caching
$arr = array('서울', '익산', '영동', '울산');

// Generating a Cache File
$fp = fopen($filename, 'w+');
fwrite($fp, '<?php ');
fwrite($fp, '$dat = array();');

foreach($arr as $k => $v)
{
    fwrite($fp, '$dat[' . $k . '] = \'' . $v . '\';');
}

fwrite($fp, ' ?>');
fclose($fp);

// Loading the Cache File
include($filename);
var_dump($dat);

그러나 위 방법은 현실적으로 실무에서 사용하기에는 무리가 있습니다. 일단 캐싱해야 할 변수의 값이 배열인지 확신할 수 없고, 배열이라 하더라도 몇 차원일지 예측할 수 없으며, 캐싱해야 할 자료형이 다양할 수도 있기 때문에 위 코드처럼 문자열로 간주하고 캐싱할 경우에는 데이터 변조의 가능성을 피할 수 없습니다.

 

Step #2-1 : 직렬화(Serialize)된 변수를 PHP 파일로 캐싱하기

위에서 직면한 문제에 대한 좋은 해법 중의 하나는 바로 PHP 특유의 사양인 직렬화(Serialize)를 활용하는 것입니다. 직렬화 함수는 인자로 받은 변수를 하나의 문자열로 만들어주는 함수로, 이 문자열은 역직렬화 함수에 인자로 넣어 실행하면 원래의 변수를 온전히 되찾을 수 있습니다. 아래는 그 활용방법을 잘 보여주는 예제코드입니다.

function setCacheFile($fileName, $valName, $target)
{
	// Serializing Targeted Data
	$target = str_replace('"', '\"', serialize($target));
	
	// Writing to Cache File
	$fp = fopen($fileName, 'w+');
	fwrite($fp, '<?php ');
	fwrite($fp, '$' . $valName . ' = unserialize("' . $target . '");');
	 
	fwrite($fp, ' ?>');
	fclose($fp);
}

// Name of Cache File
$fileName = 'dataCache.php';

// Generating a Cache File
$arr = array('서울', 2.62, null, false, array(1, 3));
setCacheFile($fileName, 'dat', $arr);

// Loading the Cache File
include($fileName);
var_dump($dat);

소스코드의 핵심을 이루고 있는 함수는 setCacheFile()로, 앞에서부터 차례로 캐시파일의 이름, 변수명, 캐싱할 데이터를 담은 변수를 인자로 받고 있습니다. 함수는 먼저 캐싱할 데이터를 직렬화하고, 다시 직렬화된 문자열에 포함된 쌍따옴표를 Escape합니다. 그 후에 실제 파일에 이 데이터를 캐싱할 때는 직렬화된 문자열을 unserialize() 함수로 감싸서, 캐시파일이 include 되었을 때에는 역직렬화된 원래의 값이 변수에 담기도록 합니다.

위 코드에서 예시로 캐싱한 배열 $arr에는 문자열과 정수, 실수, null, 불리언 자료형까지 저마다 다른 자료형의 값이 담겨 있습니다. 배열 안에 배열이 다시 들어가 있기도 합니다. 그럼에도 실제로 위 코드를 실행해보면, 캐시파일을 불러온 후에도 각 값들이 그 고유의 구조와 자료형을 간직한 채 살아있는 것을 확인할 수 있습니다.

만약 위 함수를 이용해 윈도우 OS의 스케줄러나 리눅스 OS의 Crontab을 이용하여 정해진 시간 간격마다 원하는 데이터를 캐시파일로 만들고, 그 데이터를 실제 사용하는 비즈니스 로직에서는 캐시파일을 include만 하여 사용한다면, 그 자체 만으로도 실무에서 활용가능한 훌륭한 캐시전략이 됩니다.

 

Step #2-2 : DB에서 가져온 데이터를 캐싱하기

실제 적용예제로 게시판에서 최근에 작성된 5개의 글의 제목을 캐싱하는 로직을 소개하고자 합니다. 게시글은 board라는 MySQL 테이블에 저장되어 있고, 글 제목을 가진 컬럼은 title이며, 글번호가 담긴 no라는 컬럼의 값이 클수록 최근 글이라고 가정할 것입니다.

먼저 데이터를 캐시파일로 만드는 코드는 아래와 같이 작성하고, 윈도우 OS의 스케줄러나 리눅스 OS의 Crontab을 이용하여 10분마다 실행을 하도록 합니다. setCacheFile() 함수는 앞서의 예제에 있었던 함수와 동일하므로 이 예제코드에서는 생략하였습니다.

/* 매 10분마다 실행되어 캐시파일을 만드는 소스코드 */

// Getting Caching Data
$arr = array();
$res = mysql_query('SELECT * FROM board ORDER BY no DESC LIMIT 5');

while($v = mysql_fetch_assoc($res))
{
	$arr[] = $v;
}

// Generating a Cache File
// (Require the function setCacheFile() on the previous sample code)
setCacheFile('dataCache.php', 'datRecentPost', $arr);

이렇게 10분마다 만들어진 캐시파일을, 실제 최근 작성된 5개 글의 제목을 표시하는 코드에서는 아래와 같이 활용할 수 있습니다. 캐시파일에 캐싱된 변수 $datRecentPost를 파일을 include함으로써 가져와 사용할 수 있는 것입니다.

/* 최근 작성된 5개 글의 제목을 캐싱파일에서 가져와 출력하는 소스코드 */

// Loading the Cache File
include('dataCache.php');

// Export HTML
echo '<ul>';
foreach($datRecentPost as $v)
{
	echo '<li>' . $v['title'] . '</li>';
}
echo '</ul>';

 

Step #3 : 파일시스템 함수를 이용해 TTL이 적용된 캐싱 구현하기

앞서 우리는 매 10분마다 생성한 캐시파일을 활용하는 예제를 살펴보았습니다. 그런데 주요 데이터를 이렇게 스케쥴링으로 캐싱하게 되면, 끝내는 캐싱 스케쥴러가 대단히 많이 늘어나 관리이슈가 발생하기 마련입니다. 이러한 고민이 있다면 스케쥴링이 아닌 사용자 접속시에 캐싱을 하는 방법을 고려해볼 수 있습니다.

기본적인 흐름은 이와 같습니다. 먼저 한 번 캐싱한 값을 얼마나 오랫동안 사용할지 TTL(Time to Live)을 정합니다. 그 다음 매번 캐싱한 값을 꺼내오기 전에, 마지막으로 캐싱된 시점에서부터 TTL이 경과되었는지를 확인합니다. 만약 TTL이 경과되지 않았다면 캐싱한 값을 꺼내어 로직을 실행하고, TTL이 경과되었다면 다시 데이터를 수집·집계하여 다시 캐싱을 하게 됩니다.

아래의 예제는 이러한 흐름을 getCacheData()라는 함수 하나에 정리한 소스코드입니다. 함수 정의에서 특별히 주목할 점은 마지막 인자인 $ttl로, 이 인자에는 TTL을 초 단위로 입력받을 것입니다.

function getCacheData($fileName, $valName, $ttl = 600)
{
	if(!file_exist($fileName) || filemtime($fileName) + $ttl < time())
	{
		// Getting Caching Data
		$arr = array();
		$res = mysql_query('SELECT * FROM board ORDER BY no DESC LIMIT 5');

		while($v = mysql_fetch_assoc($res))
		{
			$arr[] = $v;
		}

		// Generating a Cache File
		// (Require setCacheFile() on the previous sample code)
		setCacheFile($fileName, $arr, $target);

		// Return Data
		return $arr;
	}
	else
	{
		// Loading the Cache File
		include($fileName);
		return $$valName;
	}
}

// Getting Data
$dat = getCacheData('dataCache.php', 'dat');
var_dump($dat);

로직 자체는 평이하지만, 유의해서 보아야 할 부분은 함수 첫 줄의 조건문입니다. 이 조건문은 캐시파일이 존재하는지, 존재한다면 그 캐시파일이 마지막 수정된 시간을 filemtime() 함수로 가져와 $ttl 변수의 값만큼 시간이 경과하였는지를 확인합니다.

만약 캐시파일이 존재하지 않거나, 존재하더라도 마지막 수정된 시간이 $ttl 변수의 값보다 더 오래되었다면, 새로 데이터를 캐싱하는 if문 안의 로직이 실행됩니다. 그러나 그 외의 상황에서는 캐싱된 값을 그대로 사용할 수 있으므로, else문 안의 로직이 실행될 것입니다.

이처럼 TTL을 적용하여 캐싱을 구현한다면 스케쥴링을 따로 설정하고 관리하지 않더라도 캐싱의 효과를 누릴 수 있습니다. 그러나 이 방법은 사용자가 접속하여 이 로직을 실행헀을 때 비로소 캐싱을 하므로, 캐싱에 사용되는 쿼리나 로직이 오래 걸릴 때에는 사용하기 어렵습니다. 캐싱이 만료된 시점에 접속한 사용자는 하릴없이 캐싱되는 시간을 기다려야 하기 때문입니다. 더 나아가 한 사용자가 접속해 캐싱이 완료되기 전에 다음 사용자가 접속해온다면, 캐싱 로직이 중복실행될 수도 있으니 유의가 필요할 것입니다.

Leave a Reply

Your email address will not be published. Required fields are marked *