fix: 객체 캐시의 타입별 저장소 분리로 값 충돌 방지
기존 public 멤버를 유지하면서 기타 타입의 저장소에 타입 차원을 추가하고 캐시 접근 경로를 공통화한다. API와 clone 동작을 유지하고 직접 멤버 접근 코드의 이전 방법을 문서화한다. 검증: PHP 5.2.17 및 PHP 8.4.22에서 구문 검사와 객체 캐시 회귀 테스트 통과.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
# 객체 캐시의 타입 격리와 호환성
|
||||
|
||||
`G5_object_cache`는 한 요청 안에서 사용하는 메모리 캐시다. `common.php`에서
|
||||
요청마다 객체를 생성하며 요청 간 값 공유, TTL, Redis/Memcached 연동은 제공하지 않는다.
|
||||
|
||||
## 메서드 계약
|
||||
|
||||
캐시 주소는 `(type, group, key)`로 결정된다. 타입, 그룹, 키 중 하나라도 다르면
|
||||
별도의 값을 저장한다. `bbs`, `content`, `shop`과 플러그인에서 정한 타입 모두에 적용된다.
|
||||
인자 순서와 기본값은 기존과 같다.
|
||||
|
||||
```php
|
||||
$cache->set($type, $key, $data = array(), $group = 'default');
|
||||
$cache->get($type, $key, $group = 'default');
|
||||
$cache->exists($type, $key, $group = 'default');
|
||||
$cache->delete($type, $key, $group = 'default');
|
||||
```
|
||||
|
||||
- `type`, `group`, `key`는 문자열 또는 정수 식별자를 사용한다. 배열과 객체를
|
||||
식별자로 사용하는 것은 지원하지 않는다. PHP 배열 키 규칙에 따라 정수 `123`과
|
||||
문자열 `'123'`은 같은 키다. 타입에도 같은 규칙이 적용된다.
|
||||
- 빈 문자열은 각 차원에서 유효하다. 빈 그룹 `''`은 생략 시 사용하는 `'default'`와 다르다.
|
||||
식별자에 별도의 정규화나 구분자 연결을 하지 않는다.
|
||||
- `set()`은 값을 저장하고 명시적인 반환값 없이 `NULL`을 반환한다.
|
||||
- `get()`은 저장한 값을 반환하며, 없는 값은 `false`를 반환한다.
|
||||
저장한 `false`와 cache miss는 `exists()`로 구분한다.
|
||||
- `exists()`는 `NULL`, `false`, `0`, `'0'`, `''`, 빈 배열을 저장한 경우에도 `true`다.
|
||||
- `delete()`는 해당 주소만 제거하며 성공 시 `true`, 없는 값이면 `false`를 반환한다.
|
||||
- 객체는 저장 시와 조회 시 각각 `clone`한다. 기존과 같은 얕은 복사이며,
|
||||
객체 내부의 중첩 객체나 배열 내부 객체까지 재귀적으로 복사하지 않는다.
|
||||
- 빈 타입·그룹 버킷은 정리하지 않는다. 기타 타입을 처음 조회할 때도 빈 타입 버킷이
|
||||
만들어질 수 있으며 요청 종료 시 함께 해제된다.
|
||||
|
||||
```php
|
||||
$cache->set('shop', 'same-key', 'shop-value', 'same-group');
|
||||
$cache->set('plugin', 'same-key', 'plugin-value', 'same-group');
|
||||
|
||||
$cache->get('shop', 'same-key', 'same-group'); // 'shop-value'
|
||||
$cache->delete('plugin', 'same-key', 'same-group');
|
||||
$cache->exists('shop', 'same-key', 'same-group'); // true
|
||||
```
|
||||
|
||||
## public 멤버 호환성과 업그레이드
|
||||
|
||||
세 public 멤버는 제거하지 않고 직접 접근을 deprecated로 표시했다.
|
||||
`$writes[$group][$key]`와 `$contents[$group][$key]` 구조는 유지한다.
|
||||
기타 타입의 충돌을 해결하기 위해 `$etcs`에는 타입 차원을 추가했다.
|
||||
|
||||
| 타입 | 기존 저장 구조 | 변경 후 저장 구조 |
|
||||
| --- | --- | --- |
|
||||
| `bbs` | `$writes[$group][$key]` | 동일 |
|
||||
| `content` | `$contents[$group][$key]` | 동일 |
|
||||
| 그 외 | `$etcs[$group][$key]` | `$etcs[$type][$group][$key]` |
|
||||
|
||||
메서드와 public 멤버는 같은 저장소를 사용한다. 호환용 데이터를 별도로 복제하거나
|
||||
기존 `$etcs` 주소로 fallback하지 않는다. 기존 주소에는 타입 정보가 없어 어느 타입의
|
||||
값인지 복원할 수 없기 때문이다. 메서드만 사용하는 호출부는 변경할 필요가 없다.
|
||||
|
||||
외부 테마·플러그인이 `$etcs`를 직접 읽거나 쓰거나 삭제한다면 다음과 같이 수정해야 한다.
|
||||
기존의 서로 다른 타입 간 캐시 공유에 의존했다면 공유할 호출부에서 동일한 타입을
|
||||
명시적으로 사용해야 한다.
|
||||
|
||||
```php
|
||||
// 변경 전: shop 등 모든 기타 타입이 같은 주소를 사용했다.
|
||||
$cache->etcs[$group][$key] = $item;
|
||||
$item = $cache->etcs[$group][$key];
|
||||
unset($cache->etcs[$group][$key]);
|
||||
|
||||
// 변경 후 권장: 메서드로 타입과 존재 여부를 명시한다.
|
||||
$cache->set('shop', $key, $item, $group);
|
||||
if ($cache->exists('shop', $key, $group)) {
|
||||
$item = $cache->get('shop', $key, $group);
|
||||
}
|
||||
$cache->delete('shop', $key, $group);
|
||||
|
||||
// 직접 접근이 불가피한 기존 코드의 새 주소 (deprecated)
|
||||
$cache->etcs['shop'][$group][$key] = $item;
|
||||
```
|
||||
|
||||
2026-09-09 저장소 PHP 코드 검색에서는 클래스 외부의 캐시 public 멤버 직접 접근을
|
||||
확인하지 못했다. 코어의 `bbs`, `content`, `shop` 호출은 메서드를 사용한다.
|
||||
[공개 구현](https://github.com/gnuboard/gnuboard5/blob/master/lib/Cache/obj.class.php)과
|
||||
[원 제보](https://sir.kr/boards/g5_issues/3)도 검토했다. 공개 웹에서
|
||||
`g5_object->writes`, `g5_object->contents`, `g5_object->etcs`를 검색했으나
|
||||
외부 플러그인의 직접 접근 사례는 확인하지 못했다. 검색에 잡히지 않는 코드의
|
||||
호환성까지 보장하는 결과는 아니므로 public 멤버 제거는 이번 변경에 포함하지 않는다.
|
||||
|
||||
## 검증
|
||||
|
||||
```sh
|
||||
php -l lib/Cache/obj.class.php
|
||||
php -l tests/object_cache.php
|
||||
php tests/object_cache.php
|
||||
```
|
||||
|
||||
회귀 테스트는 타입·그룹·키 격리, 삭제 범위, 빈 값과 기본값, 객체 clone,
|
||||
public 멤버와 메서드의 저장소 일치, 인스턴스 간 격리를 확인한다.
|
||||
DB 대역을 사용해 실제 `get_shop_item()`의 빈 그룹 및 `shop_{query hash}` 그룹,
|
||||
`get_content_by_field()`의 `bbs`/`content` 적재와 `get_content_db()`의 재사용도 확인한다.
|
||||
실제 DB나 웹 세션은 필요하지 않다.
|
||||
|
||||
구현과 테스트는 PHP 5.2.17에서 지원하는 문법으로 작성했다. PHP 8.4.22와 PHP 5.2.17
|
||||
CLI에서 변경 파일의 구문 검사와 회귀 테스트가 모두 통과했다. PHP 5.2.17은
|
||||
[공식 소스 배포본](https://museum.php.net/php5/php-5.2.17.tar.gz)을 임시 디렉터리에서
|
||||
CLI와 PCRE를 포함하도록 빌드해 검증했다. 시스템 PHP는 변경하지 않았다.
|
||||
이 검증은 객체 캐시와 위 코어 호출부에 한정하며, 사이트 전체의 PHP 5.2 호환성을
|
||||
검증한 것은 아니다.
|
||||
+35
-66
@@ -2,98 +2,67 @@
|
||||
if (!defined('_GNUBOARD_')) exit;
|
||||
|
||||
Class G5_object_cache {
|
||||
/** @deprecated 직접 접근 대신 get/set/exists/delete 메서드를 사용한다. */
|
||||
public $writes = array();
|
||||
/** @deprecated 직접 접근 대신 get/set/exists/delete 메서드를 사용한다. */
|
||||
public $contents = array();
|
||||
/** @deprecated 타입별 $etcs[$type][$group][$key] 구조. 메서드 사용 권장. */
|
||||
public $etcs = array();
|
||||
|
||||
function get($type, $key, $group ='default') {
|
||||
|
||||
switch ($type) {
|
||||
case 'bbs':
|
||||
$datas = $this->writes;
|
||||
break;
|
||||
case 'content' :
|
||||
$datas = $this->contents;
|
||||
break;
|
||||
default :
|
||||
$datas = $this->etcs;
|
||||
break;
|
||||
// 각 타입의 유일한 저장소를 참조로 반환한다. 빈 타입 버킷은 요청 종료까지 유지한다.
|
||||
private function &get_type_cache($type) {
|
||||
if ($type === 'bbs') {
|
||||
return $this->writes;
|
||||
}
|
||||
|
||||
if( $this->exists($type, $key, $group) ){
|
||||
if ( is_object($datas[$group][$key]) )
|
||||
return clone $datas[$group][$key];
|
||||
else
|
||||
return $datas[$group][$key];
|
||||
if ($type === 'content') {
|
||||
return $this->contents;
|
||||
}
|
||||
|
||||
return false;
|
||||
if (!isset($this->etcs[$type])) {
|
||||
$this->etcs[$type] = array();
|
||||
}
|
||||
return $this->etcs[$type];
|
||||
}
|
||||
|
||||
function exists($type, $key, $group = 'default' ) {
|
||||
|
||||
$return_data = '';
|
||||
|
||||
switch ($type) {
|
||||
case 'bbs':
|
||||
$datas = $this->writes;
|
||||
break;
|
||||
case 'content':
|
||||
$datas = $this->contents;
|
||||
break;
|
||||
default :
|
||||
$datas = $this->etcs;
|
||||
break;
|
||||
function get($type, $key, $group = 'default') {
|
||||
if (!$this->exists($type, $key, $group)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isset($datas[$group]) && ( isset($datas[$group][$key]) || array_key_exists($key, $datas[$group]) );
|
||||
$datas = &$this->get_type_cache($type);
|
||||
if (is_object($datas[$group][$key])) {
|
||||
return clone $datas[$group][$key];
|
||||
}
|
||||
return $datas[$group][$key];
|
||||
}
|
||||
|
||||
function set($type, $key, $data=array(), $group='default') {
|
||||
if ( is_object( $data ) )
|
||||
function exists($type, $key, $group = 'default') {
|
||||
$datas = &$this->get_type_cache($type);
|
||||
return isset($datas[$group]) && (isset($datas[$group][$key]) || array_key_exists($key, $datas[$group]));
|
||||
}
|
||||
|
||||
function set($type, $key, $data = array(), $group = 'default') {
|
||||
if (is_object($data)) {
|
||||
$data = clone $data;
|
||||
|
||||
switch ($type) {
|
||||
case 'bbs':
|
||||
$this->writes[$group][$key] = $data;
|
||||
break;
|
||||
case 'content':
|
||||
$this->contents[$group][$key] = $data;
|
||||
break;
|
||||
default :
|
||||
$this->etcs[$group][$key] = $data;
|
||||
break;
|
||||
}
|
||||
|
||||
$datas = &$this->get_type_cache($type);
|
||||
$datas[$group][$key] = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* cache 데이터 제거
|
||||
* 지정한 타입, 그룹, 키의 캐시 데이터만 제거한다.
|
||||
* @param string $type
|
||||
* @param string $key
|
||||
* @param string $group
|
||||
* @return bool
|
||||
*/
|
||||
function delete($type, $key, $group = 'default')
|
||||
{
|
||||
function delete($type, $key, $group = 'default') {
|
||||
if (!$this->exists($type, $key, $group)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch ($type) {
|
||||
case 'bbs':
|
||||
$datas = &$this->writes;
|
||||
break;
|
||||
case 'content':
|
||||
$datas = &$this->contents;
|
||||
break;
|
||||
default:
|
||||
$datas = &$this->etcs;
|
||||
break;
|
||||
}
|
||||
|
||||
$datas = &$this->get_type_cache($type);
|
||||
unset($datas[$group][$key]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} //end Class;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
// 실행: php tests/object_cache.php (DB 없이 실행, PHP 5.2 문법 사용)
|
||||
if (PHP_SAPI !== 'cli') exit;
|
||||
define('_GNUBOARD_', true);
|
||||
require dirname(dirname(__FILE__)).'/lib/Cache/obj.class.php';
|
||||
|
||||
function expect_cache($condition, $message) {
|
||||
if (!$condition) {
|
||||
fwrite(STDERR, "실패: ".$message."\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$cache = new G5_object_cache();
|
||||
$types = array('bbs', 'content', 'shop', 'plugin', '', '0', 'bbs:plugin', 'plugin/group');
|
||||
foreach ($types as $type) {
|
||||
expect_cache($cache->get($type, 'missing') === false, 'cache miss');
|
||||
expect_cache($cache->exists($type, 'missing') === false, '없는 키 존재 여부');
|
||||
expect_cache($cache->delete($type, 'missing') === false, '없는 키 삭제');
|
||||
expect_cache($cache->set($type, 'same-key', $type, 'same-group') === null, 'set 반환값');
|
||||
$cache->set($type, 'same-key', 'other-group', 'other-group');
|
||||
$cache->set($type, 'other-key', 'other-key', 'same-group');
|
||||
}
|
||||
foreach ($types as $type) {
|
||||
expect_cache($cache->get($type, 'same-key', 'same-group') === $type, '타입 격리: '.$type);
|
||||
expect_cache($cache->get($type, 'same-key', 'other-group') === 'other-group', '그룹 격리');
|
||||
expect_cache($cache->get($type, 'other-key', 'same-group') === 'other-key', '키 격리');
|
||||
}
|
||||
foreach ($types as $index => $type) {
|
||||
expect_cache($cache->delete($type, 'same-key', 'same-group') === true, '정상 삭제');
|
||||
expect_cache($cache->delete($type, 'same-key', 'same-group') === false, '중복 삭제');
|
||||
expect_cache(!$cache->exists($type, 'same-key', 'same-group'), '삭제한 값 없음');
|
||||
foreach ($types as $other_index => $other_type) {
|
||||
if ($other_index > $index) {
|
||||
expect_cache($cache->get($other_type, 'same-key', 'same-group') === $other_type, '삭제 시 다른 타입 유지');
|
||||
}
|
||||
}
|
||||
expect_cache($cache->get($type, 'same-key', 'other-group') === 'other-group', '삭제 시 다른 그룹 유지');
|
||||
expect_cache($cache->get($type, 'other-key', 'same-group') === 'other-key', '삭제 시 다른 키 유지');
|
||||
}
|
||||
foreach ($types as $type) {
|
||||
foreach (array(null, false, 0, '0', '', array()) as $value) {
|
||||
$cache->set($type, 'value', $value);
|
||||
expect_cache($cache->exists($type, 'value'), '빈 값도 존재함');
|
||||
expect_cache($cache->get($type, 'value') === $value, '값과 자료형 보존');
|
||||
expect_cache($cache->delete($type, 'value') === true, '빈 값 삭제');
|
||||
}
|
||||
$cache->set($type, 'default-data');
|
||||
expect_cache($cache->get($type, 'default-data', 'default') === array(), '기본 데이터와 기본 그룹');
|
||||
$cache->set($type, '', 'empty-group', '');
|
||||
$cache->set($type, '', 'default-group');
|
||||
expect_cache($cache->get($type, '', '') === 'empty-group', '빈 그룹과 빈 키');
|
||||
expect_cache($cache->get($type, '') === 'default-group', '빈 그룹과 기본 그룹 격리');
|
||||
$cache->set($type, 123, 'numeric', 456);
|
||||
expect_cache($cache->get($type, '123', '456') === 'numeric', 'PHP 배열의 정수 키 호환');
|
||||
|
||||
$original = new stdClass();
|
||||
$original->value = 'stored';
|
||||
$cache->set($type, 'object', $original);
|
||||
$original->value = 'changed';
|
||||
$read = $cache->get($type, 'object');
|
||||
expect_cache($read !== $original && $read->value === 'stored', '저장 시 clone');
|
||||
$read->value = 'changed again';
|
||||
expect_cache($cache->get($type, 'object')->value === 'stored', '조회 시 clone');
|
||||
}
|
||||
|
||||
// public 멤버와 메서드는 같은 저장소를 사용한다.
|
||||
$cache->writes['direct']['key'] = 'bbs';
|
||||
$cache->contents['direct']['key'] = 'content';
|
||||
$cache->etcs['shop']['direct']['key'] = 'shop';
|
||||
foreach (array('bbs', 'content', 'shop') as $type) {
|
||||
expect_cache($cache->get($type, 'key', 'direct') === $type, 'public 멤버에서 메서드로 조회');
|
||||
$cache->set($type, 'key', 'updated', 'direct');
|
||||
}
|
||||
expect_cache($cache->writes['direct']['key'] === 'updated', 'writes 단일 저장소');
|
||||
expect_cache($cache->contents['direct']['key'] === 'updated', 'contents 단일 저장소');
|
||||
expect_cache($cache->etcs['shop']['direct']['key'] === 'updated', 'etcs 단일 저장소');
|
||||
$fresh = new G5_object_cache();
|
||||
expect_cache(!$fresh->exists('shop', 'key', 'direct'), '인스턴스 간 공유 안 함');
|
||||
|
||||
// 실제 코어 호출부를 실행하고 DB 조회만 대역으로 교체한다.
|
||||
require dirname(dirname(__FILE__)).'/lib/get_data.lib.php';
|
||||
require dirname(dirname(__FILE__)).'/lib/shop.data.lib.php';
|
||||
function sql_fetch($sql) {
|
||||
$GLOBALS['cache_test_queries'][] = $sql;
|
||||
return $GLOBALS['cache_test_row'];
|
||||
}
|
||||
function sql_real_escape_string($value) { return addslashes($value); }
|
||||
$g5 = array('write_prefix' => 'g5_write_', 'g5_shop_item_table' => 'g5_shop_item');
|
||||
$g5_object = new G5_object_cache();
|
||||
$cache_test_queries = array();
|
||||
$cache_test_row = array('it_id' => 'item1');
|
||||
foreach (array('', 'and it_use = 1') as $query) {
|
||||
$group = $query ? 'shop_'.md5($query) : '';
|
||||
$g5_object->set('plugin', 'item1', array('it_id' => 'plugin'), $group);
|
||||
$before = count($cache_test_queries);
|
||||
expect_cache(get_shop_item('item1', true, $query) === $cache_test_row, 'shop 최초 조회');
|
||||
expect_cache(get_shop_item('item1', true, $query) === $cache_test_row, 'shop 캐시 조회');
|
||||
expect_cache(count($cache_test_queries) === $before + 1, 'shop 그룹별 DB 조회 1회');
|
||||
expect_cache($g5_object->get('plugin', 'item1', $group) === array('it_id' => 'plugin'), 'shop 적재 시 plugin 유지');
|
||||
}
|
||||
$cache_test_row = array('co_id' => 'about', 'co_subject' => '소개');
|
||||
expect_cache(get_content_by_field('g5_content', 'content', 'co_id', 'about') === $cache_test_row, 'content 분기');
|
||||
expect_cache($g5_object->get('content', 'about', 'content') === $cache_test_row, 'content 캐시 적재');
|
||||
$before = count($cache_test_queries);
|
||||
expect_cache(get_content_db('about', true) === $cache_test_row, 'content 캐시 재사용');
|
||||
expect_cache(count($cache_test_queries) === $before, 'content 추가 DB 조회 없음');
|
||||
$cache_test_row = array('wr_id' => 7, 'wr_subject' => '게시글');
|
||||
expect_cache(get_content_by_field('g5_write_free', 'bbs', 'wr_id', '7') === $cache_test_row, 'bbs 분기');
|
||||
expect_cache($g5_object->get('bbs', 7, 'free') === $cache_test_row, 'bbs 게시판별 캐시 적재');
|
||||
echo "객체 캐시 회귀 테스트 통과\n";
|
||||
Reference in New Issue
Block a user