> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# 패키지의 커스텀 캐시 스토어와 계약

> Laravel 13의 CacheManager와 Store 계약을 분석해 드라이버의 등록 시점, Repository에 의한 TTL 변환, 인스턴스 보관, 락 등의 지원 범위를 검증합니다.

패키지에서 독자적인 캐시 백엔드를 제공하려면 **저장 처리를 구현하는 Store**와 **Laravel의 API를 제공하는 Repository**를 분리합니다. `Cache::extend()`에 등록하는 것은 생성 방법이며, 반환한 Store를 Laravel이 자동으로 Repository로 감싸 주는 것은 아닙니다.

이 페이지는 Laravel Framework `v13.39.0`의 구현을 확인한 내용입니다. 기본 조작은 [캐시](https://kawax.biz/ko/cache.md)를, 프로바이더 준비는 [패키지 개발](https://kawax.biz/ko/advanced/package-development.md)을 참조하세요.

## 드라이버 이름과 스토어 이름 구분하기

| 요소 | 이 페이지의 예 | 역할 |
| - | - | - |
| 드라이버 이름 | `acme-memory` | `extend()`로 등록하는 생성 방법 |
| 스토어 이름 | `catalog` | `cache.stores`에 정의하는 설정과 생성된 Repository의 식별자 |
| Store | `Illuminate\Cache\ArrayStore` | 키의 저장, 조회, 만료 관리 |
| Repository | `Illuminate\Cache\Repository` | `remember()`, TTL 변환, 캐시 이벤트 등 |

`CacheManager::build()`는 설정의 `driver`에 대응하는 커스텀 생성 처리가 있으면 내장 드라이버보다 먼저 호출합니다. `redis`나 `array` 같은 이름을 재사용하지 말고 패키지 고유의 이름을 사용하세요.

```mermaid theme={null}
flowchart TD
    A["Cache::store('catalog')"] --> B{"catalog는 해석되었는가?"}
    B -->|예| C["보관된 Repository를 반환"]
    B -->|아니요| D["cache.stores.catalog를 읽어들임"]
    D --> E["driver: acme-memory의 생성 처리"]
    E --> F["Store를 생성하고<br>repository()로 감쌈"]
    F --> G["catalog라는 이름으로 보관"]
```

## 다른 프로바이더의 boot보다 먼저 등록하기

다른 패키지가 `boot()`에서 캐시를 사용하는 경우, 자신의 `boot()`에서 드라이버를 등록하면 순서에 따라 제때 등록되지 않을 수 있습니다. 공식 문서에 따라 `register()`에서 **애플리케이션의 `booting` 콜백**을 등록합니다.

다음은 외부 서비스 없이 등록 경로를 확인하기 위해 표준 `ArrayStore`를 사용하는 최소 예시입니다. `Acme\Memo` 패키지의 `src/MemoServiceProvider.php`에 배치합니다.

```php theme={null}
<?php

namespace Acme\Memo;

use Illuminate\Cache\ArrayStore;
use Illuminate\Cache\Repository;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;

class MemoServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->booting(static function (Application $app): void {
            $app['cache']->extend('acme-memory', static function (
                Application $app,
                array $config,
            ): Repository {
                $store = new ArrayStore(
                    $config['serialize'] ?? false,
                    $app['config']->get('cache.serializable_classes'),
                );

                return $app['cache']->repository($store, $config);
            });
        });
    }
}
```

프로바이더는 [패키지 자동 검색](https://kawax.biz/ko/advanced/package-discovery.md) 등으로 로드합니다. 확장을 등록하기만 하는 이 프로바이더는 지연 프로바이더로 만들지 말고, HTTP와 Artisan 양쪽에서 로드되도록 합니다.

* 애플리케이션의 `booting`은 일반적인 부팅에서 각 프로바이더의 `register()`가 완료된 후, 각 `boot()`보다 먼저 실행됩니다. 프로바이더 자신의 `$this->booting()`과는 별개입니다.
* `extend()`의 콜백에는 애플리케이션과 스토어 설정 배열이 전달됩니다. Store 생성이나 외부 연결은 이 생성용 콜백 쪽에 둡니다.
* 이 등록 시점은 다른 프로바이더의 **`register()` 안에서의 캐시 사용**까지 보장하지 않습니다. 서비스 등록과 실제 사용을 분리하세요.

> [!TIP]
>
> 표준 인메모리 캐시만 필요하다면 `array` 드라이버로 충분합니다. 이 예시는 등록과 계약을 설명하기 위한 것으로, 프로세스 간 공유나 영속화에는 사용할 수 없습니다. 독자적인 백엔드에서는 `ArrayStore`의 생성 부분을 `Illuminate\Contracts\Cache\Store`를 구현한 클래스로 교체합니다.

`CacheManager::extend()`는 익명 클로저의 스코프를 Manager로 다시 바인딩합니다. non-static 클로저의 `$this`를 프로바이더라고 생각하지 말고, 예시처럼 static 클로저와 인수 `$app`을 사용하세요.

### 사용하는 애플리케이션에 스토어 추가하기

`config/cache.php`의 기존 `stores` 배열에 추가합니다. 패키지가 사용하는 쪽의 `default`나 `stores` 전체를 덮어쓰는 설계는 피합니다.

```php theme={null}
'catalog' => [
    'driver' => 'acme-memory',
    'serialize' => true,
],
```

```php theme={null}
use Illuminate\Support\Facades\Cache;

$value = Cache::store('catalog')->remember('product:42', 60, function () {
    return ['id' => 42, 'name' => 'Sample'];
});
```

기본값으로 사용하고 싶을 때 `CACHE_STORE`에 지정하는 것도 드라이버 이름이 아니라 **`catalog`라는 스토어 이름**입니다. 같은 드라이버의 설정을 여러 개 만들면 스토어 이름마다 별도의 Repository가 생성됩니다.

## Repository는 직접 구성하기

`CacheManager::callCustomCreator()`는 콜백의 반환값을 그대로 반환합니다. Store만 반환하면 `get()`이나 `put()`이 동작하더라도 Repository의 `remember()` 등은 사용할 수 없고, TTL 해석도 달라집니다.

예시의 `repository($store, $config)`는 다음을 담당합니다.

* `Illuminate\Cache\Repository`로 Store를 감싼다.
* Manager가 설정에 추가한 `store` 이름을 Repository에 전달한다.
* 이벤트 Dispatcher가 등록되어 있고 `events` 설정이 비활성화되어 있지 않으면 Repository에 Dispatcher를 설정한다.

이것은 독자적인 설정을 모두 Store에 적용하는 처리가 아닙니다. 연결 대상, prefix, 직렬화 등은 Store 구현과 생성 처리 쪽에서 반영합니다. 예시에서 설정을 읽고 있는 `serialize`도 마찬가지입니다.

## Store 계약을 현재 구현과 대조하기

Laravel 13의 `Illuminate\Contracts\Cache\Store`가 요구하는 메서드를 구현합니다. 과거 샘플의 빈 메서드 목록을 그대로 동작하는 드라이버로 배포하지 마세요.

| 메서드 | 확인할 동작 |
| - | - |
| `get()` / `many()` | 미존재·만료 시 `null`. `many()`는 요청한 키를 키로 갖는 배열을 반환하며, 미존재분도 `null`로 한다 |
| `put()` / `putMany()` | 초 단위 TTL로 저장하고 성공·실패를 반환한다 |
| `forever()` | 만료 없이 저장한다. 백엔드에 의한 축출·삭제까지 막는다는 의미는 아니다 |
| `touch()` | 존재하는 키의 만료 시간을 변경한다. 미존재·만료 시 `false`를 반환한다 |
| `increment()` / `decrement()` | 갱신 후의 값 또는 실패를 반환한다. 동시 갱신과 TTL 유지도 검증한다 |
| `forget()` | 키를 삭제하고 결과를 반환한다 |
| `flush()` | 캐시 전체를 삭제한다. 실제로 삭제되는 범위를 명시한다 |
| `getPrefix()` | Store가 사용하는 키 프리픽스를 반환한다 |

`null`은 Repository에서 캐시 미스로 취급됩니다. `false`나 `0`까지 미존재로 취급하지 않도록 구현하세요. `null`을 저장해도 `remember()`의 재계산을 막는 표시가 되지는 않습니다.

### TTL은 Repository를 통해 검증하기

`Repository::put()`은 Store에 전달하기 전에 TTL을 처리합니다.

| Repository 호출 | Store에 대한 처리 |
| - | - |
| `put('key', $value)` | `forever()` |
| `put('key', $value, 60)` | 초 수를 전달한 `put()` |
| `put('key', $value, $expiresAt)` | 일시 등을 초 수로 변환하고, 양수이면 `put()` |
| `put('key', $value, 0)` 또는 음수 TTL | `forget()` |
| `touch('key', 60)` | `touch()` |
| `touch('key', 0)` 또는 음수 TTL | `forget()` |

> [!WARNING]
>
> 예시의 `ArrayStore`는 직접 `put()`에 `0`초를 전달하면 무기한으로 저장합니다. 반면 Repository의 `put(..., 0)`은 삭제입니다. `getStore()`로 꺼낸 저수준 Store만 테스트해서는 사용자가 호출하는 API의 동작을 확인할 수 없습니다.

### add·락·태그는 별도로 지원 여부 확인하기

Store 계약을 구현하는 것만으로 모든 캐시 기능의 지원이 보장되지는 않습니다.

* **원자적 `add()`**: Store 계약에는 포함되지 않습니다. 양수 TTL과 함께 호출되고 Store에 `add()`가 있으면 Repository는 그것을 사용합니다. 없으면 `get()`과 `put()`에 의한 폴백이므로, Repository로 감싸는 것만으로는 원자성을 보장할 수 없습니다. 공유 백엔드에서는 해당 백엔드의 원자적 연산으로 구현하고 동시 실행도 검증합니다.
* **락**: `Illuminate\Contracts\Cache\LockProvider`의 `lock()`과 `restoreLock()`은 별도의 계약입니다. 획득, 소유자 일치, 만료, 다른 소유자의 락을 해제하지 않는 것까지 검증합니다. 예시의 `ArrayStore` 락은 별도 프로세스 간의 배타 제어가 되지 않습니다.
* **태그**: Repository는 Store에 `tags()`가 있는지 확인합니다. 일반적으로 `TaggableStore` 등을 사용한 구현이 필요하며, Store 계약만으로 자동으로 활성화되지는 않습니다.

`flush()`를 설정의 prefix를 존중하는 부분 삭제라고 생각하지 마세요. 공유 백엔드에서의 삭제 범위를 확인하고, `php artisan cache:clear`를 포함해 사용자에게 설명합니다.

## 인스턴스 보관과 데이터 삭제 구분하기

`CacheManager`는 singleton이며, `store('catalog')`의 결과도 스토어 이름별로 보관합니다. 설정 변경이나 `extend()`의 재등록만으로는 생성된 Repository가 갱신되지 않습니다.

```php theme={null}
use Illuminate\Support\Facades\Cache;

$before = Cache::store('catalog');

config(['cache.stores.catalog.serialize' => false]);

$same = Cache::store('catalog');

Cache::forgetDriver('catalog');

$after = Cache::store('catalog');
```

`$same`은 `$before`와 같은 객체입니다. `forgetDriver()` 이후의 조회에서 새 설정으로 `$after`가 생성됩니다.

| 조작 | 대상 |
| - | - |
| `Cache::forgetDriver('catalog')` | Manager가 보관하는 Repository 참조를 제거한다 |
| `Cache::store('catalog')->forget('key')` | 저장된 키 1개를 삭제한다 |
| `Cache::store('catalog')->flush()` | Store의 캐시 데이터를 모두 삭제한다 |

`forgetDriver()`는 백엔드의 전체 삭제가 아닙니다. 다만 예시의 `ArrayStore`는 인스턴스 내에 데이터를 갖고 있으므로 새 Store에서는 이전 데이터가 보이지 않습니다. 또한 사용하는 쪽의 `$before`나, 해석된 `cache.store` singleton이 보관하는 이전 Repository 참조는 교체되지 않습니다.

프로덕션에서는 일반적인 배포 절차에 따라 설정 캐시를 갱신하고 장기 실행 워커를 재시작합니다. [서비스의 라이프사이클](https://kawax.biz/ko/advanced/package-service-lifetimes.md)도 고려해, 재해석을 모든 프로세스의 일괄 갱신으로 취급하지 마세요.

## 파사드 모킹뿐만 아니라 생성 경로 테스트하기

[패키지 테스트 환경](https://kawax.biz/ko/advanced/package-testing.md)에서 실제 Manager와 Repository를 거치게 합니다. 다음 Testbench 예시는 먼저 등록한 다른 프로바이더의 `boot()`에서도 드라이버를 사용할 수 있는지 확인합니다. 예시에서는 `cache.serializable_classes`의 복원 제한을 변경하지 않고 배열을 저장하며, `ArrayStore::all(false)`로 원시 저장값도 확인합니다.

```php theme={null}
<?php

namespace Acme\Memo\Tests;

use Acme\Memo\MemoServiceProvider;
use Illuminate\Cache\ArrayStore;
use Illuminate\Cache\Repository;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider;
use Orchestra\Testbench\TestCase;

class CacheConsumerProvider extends ServiceProvider
{
    public function boot(): void
    {
        Cache::store('catalog')->forever('boot-marker', 'ready');
    }
}

class PackageCacheTest extends TestCase
{
    protected function getPackageProviders($app): array
    {
        return [CacheConsumerProvider::class, MemoServiceProvider::class];
    }

    protected function defineEnvironment($app): void
    {
        $app['config']->set('cache.default', 'array');
        $app['config']->set('cache.stores.catalog', [
            'driver' => 'acme-memory',
            'serialize' => true,
        ]);
    }

    public function test_the_driver_is_available_during_another_providers_boot(): void
    {
        $cache = Cache::store('catalog');

        $this->assertInstanceOf(Repository::class, $cache);
        $this->assertInstanceOf(ArrayStore::class, $cache->getStore());
        $this->assertSame('catalog', $cache->getName());
        $this->assertSame('ready', $cache->get('boot-marker'));
        $this->assertNotNull($cache->getEventDispatcher());
    }

    public function test_serialization_is_applied_from_the_store_configuration(): void
    {
        $cache = Cache::store('catalog');
        $value = ['status' => 'queued'];

        $cache->put('job', $value, 60);

        $this->assertSame($value, $cache->get('job'));
        $this->assertSame(serialize($value), $cache->getStore()->all(false)['job']['value']);
    }

    public function test_a_zero_ttl_deletes_the_item_through_the_repository(): void
    {
        $cache = Cache::store('catalog');
        $cache->forever('product:42', 'old');

        $this->assertTrue($cache->put('product:42', 'new', 0));
        $this->assertNull($cache->get('product:42'));
    }

    public function test_forgetting_a_driver_rebuilds_only_the_manager_reference(): void
    {
        $before = Cache::store('catalog');

        config(['cache.stores.catalog.serialize' => false]);
        $this->assertSame($before, Cache::store('catalog'));

        Cache::forgetDriver('catalog');
        $after = Cache::store('catalog');

        $this->assertNotSame($before, $after);
        $this->assertSame('ready', $before->get('boot-marker'));
        $this->assertNull($after->get('boot-marker'));

        $value = ['status' => 'queued'];
        $after->forever('job', $value);

        $this->assertSame($value, $after->get('job'));
        $this->assertSame($value, $after->getStore()->all(false)['job']['value']);
    }
}
```

독자적인 백엔드에서는 여기에 더해 만료, `many()`의 미존재 키, `false`·`0`의 왕복, `touch()`, 연결 장애, 동시 갱신, prefix와 삭제 범위를 검증합니다. 단순한 `Cache::shouldReceive('get')`의 성공을 드라이버 등록이나 외부 서비스와의 호환성 증거로 삼지 마세요.

## 장기 유지보수 체크리스트

* 드라이버 이름, 스토어 설정, 직렬화 형식, 예외와 실패 시 반환값을 공개 사양으로 한다.
* Store 계약의 메서드와 태그·락 등의 선택 기능을 지원하는 Laravel 버전별로 확인한다.
* 설정 캐시가 있는 새 부팅에서도 등록할 수 있고, 다른 프로바이더의 `boot()`에서 사용할 수 있는지 검증한다.
* 저장 데이터의 호환성 변경은 기존 키의 마이그레이션이나 폐기 방침까지 포함해 공지한다.
* 프로세스 내 인스턴스 보관, 백엔드 데이터, 설정 캐시를 구분한다.

## 참조한 1차 자료

* [Laravel 공식 문서: 커스텀 캐시 드라이버](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/cache.md#adding-custom-cache-drivers)
* [CacheManager: build·repository·extend·forgetDriver](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Cache/CacheManager.php)
* [Repository: TTL 변환·add·tags·이벤트](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Cache/Repository.php)
* [Store 계약](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Contracts/Cache/Store.php) / [LockProvider 계약](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Contracts/Cache/LockProvider.php)
* [ArrayStore: 저장·만료·직렬화](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Cache/ArrayStore.php)
* [CacheServiceProvider: Manager와 cache.store의 singleton 등록](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Cache/CacheServiceProvider.php)
* [Application: 애플리케이션의 booting과 프로바이더의 boot](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Foundation/Application.php)
* [Laravel 공식 테스트: CacheManagerTest](https://github.com/laravel/framework/blob/v13.39.0/tests/Cache/CacheManagerTest.php) / [CacheRepositoryTest](https://github.com/laravel/framework/blob/v13.39.0/tests/Cache/CacheRepositoryTest.php)


## Related topics

- [고급 주제](https://kawax.biz/ko/advanced/index.md)
- [캐시](https://kawax.biz/ko/cache.md)
- [패키지 캐시와 optimize 통합](https://kawax.biz/ko/advanced/package-optimization.md)
- [패키지의 Blade 확장과 컴파일 캐시](https://kawax.biz/ko/advanced/package-blade-extensions.md)
- [패키지의 라우트 등록과 캐시](https://kawax.biz/ko/advanced/package-routes.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.