From e4bd69ab8226dff5e164e16f360f18daed6a19ef Mon Sep 17 00:00:00 2001 From: whitedot Date: Wed, 9 Sep 2026 06:23:33 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B0=9D=EC=B2=B4=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=EC=9D=98=20=ED=83=80=EC=9E=85=EB=B3=84=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EB=B6=84=EB=A6=AC=EB=A1=9C=20=EA=B0=92=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 public 멤버를 유지하면서 기타 타입의 저장소에 타입 차원을 추가하고 캐시 접근 경로를 공통화한다. API와 clone 동작을 유지하고 직접 멤버 접근 코드의 이전 방법을 문서화한다. 검증: PHP 5.2.17 및 PHP 8.4.22에서 구문 검사와 객체 캐시 회귀 테스트 통과. --- docs/object-cache.md | 107 ++++++++++++++++++++++++++++++++++++++ lib/Cache/obj.class.php | 101 +++++++++++++----------------------- tests/object_cache.php | 111 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+), 66 deletions(-) create mode 100644 docs/object-cache.md create mode 100644 tests/object_cache.php diff --git a/docs/object-cache.md b/docs/object-cache.md new file mode 100644 index 000000000..0c86f1a47 --- /dev/null +++ b/docs/object-cache.md @@ -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 호환성을 +검증한 것은 아니다. diff --git a/lib/Cache/obj.class.php b/lib/Cache/obj.class.php index a974fbf03..a2492d155 100644 --- a/lib/Cache/obj.class.php +++ b/lib/Cache/obj.class.php @@ -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; \ No newline at end of file +} diff --git a/tests/object_cache.php b/tests/object_cache.php new file mode 100644 index 000000000..f71e0d617 --- /dev/null +++ b/tests/object_cache.php @@ -0,0 +1,111 @@ +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";