fix: 객체 캐시의 타입별 저장소 분리로 값 충돌 방지

기존 public 멤버를 유지하면서 기타 타입의 저장소에 타입 차원을 추가하고 캐시 접근 경로를 공통화한다. API와 clone 동작을 유지하고 직접 멤버 접근 코드의 이전 방법을 문서화한다.

검증: PHP 5.2.17 및 PHP 8.4.22에서 구문 검사와 객체 캐시 회귀 테스트 통과.
This commit is contained in:
whitedot
2026-09-09 07:58:16 +00:00
parent ff8e8741b3
commit e4bd69ab82
3 changed files with 253 additions and 66 deletions
+107
View File
@@ -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
View File
@@ -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;
}
+111
View File
@@ -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";