한때 그누보드 차세대도 만들고 그누보드를 많이 만졌던 사람이다.
그누보드로된 사이트를 운영하며 사람들이 많이 늘어나면 소규모에서는 눈에 띄지않던 문제점들이 부각된다.
오늘은 개인 사이트를 운영하며 일어난 일들의 코드를 살펴보자.
그누보드는 2003년에 만들어졌는데 그 시절의 MySQL 에는 MyIsam 이라는 트랜젝션을 지원하지 않던 엔진이 있었다.
그이후 InnoDB 엔진에서는 트랜젝션 지원이 되었지만 아마도 하위호환성으로 인해 그누보드에는 트랜젝션 쿼리는 없다.
그누보드는 PHP 가 나온대로 html 과 DB 쿼리, php 함수가 섞여있고 실행순서는 아래와 같다.
사용자 요청 -> 요청한 php 파일 -> common.php -> 테마나 스킨 시작 -> tail.php 이다.
경우에 따라 tail.php는 없을 수 도있다.
common.php 의 흐름을 보면
DB 연결, 외부요청값 함수화, 그누보드 플러그인인 extends 로딩 , 테이블 옵티마이즈 ,방문자, 접속자, html 아웃풋에 필요한 헤더정리
등등 이렇게 있다.
1. 첫번째 문제는 테이블 옵티마이저다.
table optimizer 라는 쿼리는 관리자가 로그인 할때 하루에 한번 실행된다.
저 쿼리의 목적은 MyIsam 엔진으로 되어있을때 파편화된 테이블 조각모음을 한다는것이다.
문제는 요즘 Mysql 을 설정하면 InnoDB 인데 이떄 table optimizer 실행시
테이블을 다시 만들고 데이터를 넣는 동작을 한다는것이다.
그래서 관리자가 로그인할때 사이트가 엄청 느렸었다.
최신 그누보드에서는 새글 테이블의 optimizer 실행여부를 설정하는 상수가 생겼다.
InnoDB 엔진을 사용하면 반드시꺼야하고 MyIsam 에서도 새글 등 테이블이 커지면 성능부하가 있는
득보다 실이 많은 기능이다.
2. 현재접속자, 방문자도 부하가 심했다.
방문자 몇안될때는 문제없던게
분당 2000이 넘어 계속 update 쿼리를 실행하니 난리도 아니였고 결국 사용하지 않게 주석처리했다.
누가 뭘 방문하는지 요즘사람들은 사생활 침해라고 생각하기도해서 필요없는 정보였다.
3. 새글 테이블.
그누보드에는 게시글을 쓸때 새로운 글, 댓글 현황을 알수있는 board_new 라는 테이블에 게시글 정보가 등록되는데
(이름 참 단순하다.)
이 테이블의 문제점을 보자면 테이블 스키마가 비효율 적이라
조회 쿼리가 복잡하다는것이다.
bn_id | int(11) | 새 글 ID |
bo_table | varchar(20) | 게시판 코드 |
wr_id | int(11) | 글 번호 |
wr_parent | int(11) | 부모 글 번호 |
bn_datetime | datetime | 새 글 작성 일시 |
mb_id | varchar(20) | 회원 ID |
4. 그누보드는 XSS 를 방지하기위해 htmlpurifier 를 사용한다
html_purifer 라는 함수가 있는데 매번 객체를 생성하는데
게시글 보기 페이지 기준으로 게시물상단, 게시물, 게시물하단 이렇게 3번 호출한다
상단과 하단은 관리자페이지에서 지정하는데 내용이 없어도 실행되기때문에 무겁다.
그래서 xdebug 로 살펴보면 시간과 메모리를 꽤 사용한다.
그럼 함수를 살펴보자
function html_purifier($html)
{
global $is_admin, $write;
$f = file(G5_PLUGIN_PATH . '/htmlpurifier/safeiframe.txt');
$domains = array();
foreach ($f as $domain) {
// 첫행이 # 이면 주석 처리
if (!preg_match("/^#/", $domain)) {
$domain = trim($domain);
if ($domain) {
array_push($domains, $domain);
}
}
}
// 글쓴이가 관리자인 경우에만 현재 사이트 도메인을 허용
if (isset($write['mb_id']) && $write['mb_id'] && is_admin($write['mb_id'])) {
array_push($domains, $_SERVER['HTTP_HOST'] . '/');
}
$safeiframe = implode('|', run_replace('html_purifier_safeiframes', $domains, $html));
include_once(G5_PLUGIN_PATH . '/htmlpurifier/HTMLPurifier.standalone.php');
include_once(G5_PLUGIN_PATH . '/htmlpurifier/extend.video.php');
$config = HTMLPurifier_Config::createDefault();
// data/cache 디렉토리에 CSS, HTML, URI 디렉토리 등을 만든다.
$config->set('Cache.SerializerPath', G5_DATA_PATH . '/cache');
$config->set('HTML.SafeEmbed', false);
$config->set('HTML.SafeObject', false);
$config->set('Output.FlashCompat', false);
$config->set('HTML.SafeIframe', true);
if ((function_exists('check_html_link_nofollow') && check_html_link_nofollow('html_purifier'))) {
$config->set('HTML.Nofollow', true); // rel=nofollow 으로 스팸유입을 줄임
}
$config->set('URI.SafeIframeRegexp', '%^(https?:)?//(' . preg_replace('/\\\?\./', '\.', $safeiframe) . ')%');
$config->set('Attr.AllowedFrameTargets', array('_blank'));
//유튜브, 비메오 전체화면 가능하게 하기
$config->set('Filter.Custom', array(new HTMLPurifier_Filter_Iframevideo()));
/*
* HTMLPurifier 설정을 변경할 수 있는 Event hook
* 리스너에서는 첫번째 인자($config)로 `HTMLPurifier_Config` 객체를 받을 수 있다
*/
run_event('html_purifier_config', $config, array(
'html' => $html,
'write' => $write,
'is_admin' => $is_admin
)
);
$purifier = new HTMLPurifier($config);
return run_replace('html_purifier_result', $purifier->purify($html), $purifier, $html);
}
purifier 가 있는 standardalone 파일도 여러번 인클루드..
safeiframe.txt 파일 읽기
htmlpurifer 설정 초기화
config 외부주입등 계속 반복된다.
클래스로 바꾸고싶지만 호환성을 지키며 캐시를 하기위해 아래와 같이 바꿔준다.
함수내 정적변수를 두어 객체가 생성되지 않았을때만 초기화하고 현재사이트 도메인 허용을 위해
$is_writer_admin 플래그를 두어 그때만 설정을 다시 주입하게한다.
function html_purifier($html)
{
global $is_admin, $write;
static $purifier_object = null;
static $default_domain = array();
static $domains = array();
if($purifier_object === null) {
$f = file(G5_PLUGIN_PATH . '/htmlpurifier/safeiframe.txt');
foreach ($f as $domain) {
// 첫행이 # 이면 주석 처리
if (!preg_match('/^#/', $domain)) {
$domain = trim($domain);
if ($domain) {
$domains[] = $domain;
}
}
}
include_once(G5_PLUGIN_PATH . '/htmlpurifier/HTMLPurifier.standalone.php');
include_once(G5_PLUGIN_PATH . '/htmlpurifier/extend.video.php');
}
$is_writer_admin = false;
$current_site_domain = $_SERVER['HTTP_HOST'] . '/';
// 글쓴이가 관리자인 경우에만 현재 사이트 도메인을 허용
if (isset($write['mb_id']) && $write['mb_id'] && is_admin($write['mb_id'])) {
if ($domains && !in_array($_SERVER['HTTP_HOST'], $domains)) {
$domains[] = $current_site_domain; // 현재 사이트 도메인 추가
$is_writer_admin = true;
}
} else {
// 글쓴이가 관리자가 아니면 기본 도메인으로 초기화
$domains = $default_domain;
}
$safeiframe = implode('|', run_replace('html_purifier_safeiframes', $domains, $html));
if($purifier_object === null || $is_writer_admin) {
$config = HTMLPurifier_Config::createDefault();
// data/cache 디렉토리에 CSS, HTML, URI 디렉토리 등을 만든다.
$config->set('Cache.SerializerPath', G5_DATA_PATH . '/cache');
$config->set('HTML.SafeEmbed', false);
$config->set('HTML.SafeObject', false);
$config->set('Output.FlashCompat', false);
$config->set('HTML.SafeIframe', true);
if ((function_exists('check_html_link_nofollow') && check_html_link_nofollow('html_purifier'))) {
$config->set('HTML.Nofollow', true); // rel=nofollow 으로 스팸유입을 줄임
}
$config->set('URI.SafeIframeRegexp', '%^(https?:)?//(' . preg_replace('/\\\?\./', '\.', $safeiframe) . ')%');
$config->set('Attr.AllowedFrameTargets', array('_blank'));
//유튜브, 비메오 전체화면 가능하게 하기
$config->set('Filter.Custom', array(new HTMLPurifier_Filter_Iframevideo()));
/*
* HTMLPurifier 설정을 변경할 수 있는 Event hook
* 리스너에서는 첫번째 인자($config)로 `HTMLPurifier_Config` 객체를 받을 수 있다
*/
run_event('html_purifier_config', $config, array(
'html' => $html,
'write' => $write,
'is_admin' => $is_admin
)
);
$purifier_object = new HTMLPurifier($config);
}
return run_replace('html_purifier_result', $purifier_object->purify($html), $purifier_object, $html);
}
이렇게만해도 불필요한 File I/O 와 객체 초기화가 줄어든다.
이건 조만간 그누보드 pr 로 올려야겠다.
5. 유니크 키를 생성하는 함수
그누보드와 영카트에는 유일한 값을 만들어 각각 게시글 임시저장 키와 주문번호에 사용한다.
문제가 일어난 곳은 초당 접속자가 500 명 정도되는 사이트였다.
글쓰기를 자주하는데.
이 함수를 보면 눈치채겠지만 최대 실행횟수가 없고 테이블 전체를 락을 걸어서 느리다는 것이다.
동시에 요청하는 사용자가 많아질수록 중복되는 키가 생기게되고 더 지연되는 구조다.
common.lib.php 에 있는 문제의 함수
function get_uniqid()
{
global $g5;
sql_query(" LOCK TABLE {$g5['uniqid_table']} WRITE ");
while (1) {
// 년월일시분초에 100분의 1초 두자리를 추가함 (1/100 초 앞에 자리가 모자르면 0으로 채움)
$key = date('YmdHis', time()) . str_pad((int)((float)microtime()*100), 2, "0", STR_PAD_LEFT);
$result = sql_query(" insert into {$g5['uniqid_table']} set uq_id = '$key', uq_ip = '{$_SERVER['REMOTE_ADDR']}' ", false);
if ($result) break; // 쿼리가 정상이면 빠진다.
// insert 하지 못했으면 일정시간 쉰다음 다시 유일키를 만든다.
usleep(10000); // 100분의 1초를 쉰다
}
sql_query(" UNLOCK TABLES ");
return $key;
}
이 함수가 이렇게 생긴 이유는 트랜젝션이 없는 MyIsam 엔진을 지원하기 위함이다.
InnoDB 로 옮겨간지 오래인데 참.. 처음 설치할때 체크해서 함수를 두벌 만들어도 될텐데 말이다.
트렌젝션은 row 단위로 락을 걸기때문에 테이블 단위로 락을 거는 지금보다는 DB 에 부담이 덜간다.
그리고 max_try 를 두어 php 최대실행시간이 없는 그누보드에서 데드락에 빠지지않게 방지해야한다.
'영카드 데드락'이 있다는 이야기를 모임에 들었는데 아마 이 함수가 원인이지 싶다.
max_try 를 지정하고 트랜젝션을 적용하면 아래와 같다.
DB 에 문제가 생겨 사이트가 다운되느니 일부 고객들이 주문실패하여 재시도 하게 하는게 낫다.
물론 장기적으로는 저기 키의 고유성을 초당 100 이 아니라 1000 단위로 올려야할것이다.
function get_uniqid()
{
global $g5;
$max_try = 50; // 최대 50번 시도
$is_success = false;
$start_transaction_result = sql_query('START TRANSACTION');
if (!$start_transaction_result) {
return false; // 트랜잭션 시작 실패
}
while ($max_try--) {
// 년월일시분초에 100분의 1초 두자리를 추가함 (1/100 초 앞에 자리가 모자르면 0으로 채움)
$key = date('YmdHis', time()) . str_pad((int)((float)microtime() * 100), 2, '0', STR_PAD_LEFT);
$result = sql_query(" insert into {$g5['uniqid_table']} set uq_id = '$key', uq_ip = '{$_SERVER['REMOTE_ADDR']}' ", false);
if ($result) {
// 쿼리가 성공적으로 실행되었으면, 트랜잭션을 커밋하고 빠져나간다.
$is_success = true;
$commet_result = sql_query('COMMIT');
if (!$commet_result) {
// 커밋 실패시 트랜잭션을 롤백하기 위함
$is_success = false;
}
break; // 쿼리가 정상이면 빠진다.
}
// insert 하지 못했으면 일정시간 쉰다음 다시 유일키를 만든다.
usleep(10000); // 100분의 1초를 쉰다
}
if (!$is_success) {
// 최대 시도 횟수를 초과했으면 트랜잭션을 롤백한다.
sql_query('ROLLBACK');
return false; // 실패
}
return $key;
}
이제 유일키 생성 실패시 false 를 반환하므로 스킨에서 처리가 필요하다. 추가적인 수정이 필요하다.
그 밖에 클라우드에서 그누보드를 실행하면서 스케일 아웃을 하려고 할때 문제된점이있다.
AWS 를 예로 들면 ec2 에서 구동하는데 ebs 저장소의 용량은 S3 보다 훨씬 비싸다.
하지만 그누보드는 data 에 전부 저장하고있어서 s3 을 사용하려면 많이 수정해야한다.
다행이 sir 자료실에는 s3 플러그인이 있는데.
문제는 적용후에 터졌다. 그누보드 게시판 목록을 보면 사용자 아이콘이 출력되는데
20개를 표시하면 20번 30개면 30번 서버 <-> s3 통신횟수가 대폭 늘어난다.
결국은 그누보드를 수정하기에 이르렀는데
* 회원이 아이콘을 업로드했는지 확인하는 컬럼 (업로드 여부체크)
* 회원이 아이콘을 수정했는지 확인하기위한 시간 컬럼
이렇게 두개를 만든다음 어차피 회원 DB 조회할때 같이 체크했더니 네트워크 레이턴시가 대폭줄었다.
클라우드를 사용하려면 참 여러가지 신경쓰는구나 싶었다.
그누보드는 호스팅에 쓰기 적합하고 클라우드에서 외부저장소 사용하기에는 손이 많이간다.
다음 프로젝트를 하게된다면 이런점을 신경써야겠다.
ps1. 흠 진작에 발견했더라면 좋았을텐데 싶고 이제라도 정리해서 홀가분하다.
ps2. 이 글도 ai 가 학습해가겠지.
그누보드도 오픈소스이고 정보는 나눌수록 좋다.
'PHP' 카테고리의 다른 글
PHP 로 된 프로젝트는 왜 유독히... (0) | 2025.02.17 |
---|---|
라라벨의 파사드 찬성/반대 측면 (0) | 2023.06.08 |
php 사용자에게 도움되는 사이트 (0) | 2022.11.20 |
코드이그나이터 4 REST API 주의점 (0) | 2022.04.14 |
그누보드 보안 SQL 인젝션 방어하기 (0) | 2022.03.30 |