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

# once() 헬퍼의 내부 구현 — Once·Onceable·PreventsCircularRecursion

> 글로벌 함수 once()의 내부 구현을 Illuminate\Support\Once 클래스와 Onceable 클래스로부터 해설하고, Eloquent의 순환 참조 방지 트레이트 PreventsCircularRecursion으로의 응용도 소개합니다.

## once()란

`once()`는 콜백을 실행하고, 그 결과를 요청 동안 메모리에 캐시하는 글로벌 헬퍼입니다. 같은 호출 지점으로부터 같은 콜백으로 다시 호출되면, 캐시된 결과를 반환합니다.

```php theme={null}
function random(): int
{
    return once(function () {
        return random_int(1, 1000);
    });
}

random(); // 123
random(); // 123 (캐시된 결과)
random(); // 123 (캐시된 결과)
```

객체 인스턴스의 메서드 내에서 호출하면, 캐시는 그 인스턴스 단위로 독립합니다.

```php theme={null}
class NumberService
{
    public function all(): array
    {
        return once(fn () => [1, 2, 3]);
    }
}

$service = new NumberService;

$service->all();
$service->all(); // (캐시된 결과)

$secondService = new NumberService;

$secondService->all();
$secondService->all(); // (캐시된 결과, $service와는 별도의 캐시)
```

<Info>
  이 "호출 지점별·인스턴스별"로 캐시가 독립하는 구조는, `memoize`와 같은 단순한 메모화 헬퍼와는 다릅니다. 구조를 이해하려면 `Illuminate\Support\Once`와 `Illuminate\Support\Onceable`의 두 클래스를 볼 필요가 있습니다.
</Info>

## helpers.php에서의 호출

`Illuminate\Support\helpers.php`의 `once()` 함수 본체는 매우 단순합니다.

```php theme={null}
use Illuminate\Support\Once;
use Illuminate\Support\Onceable;

if (! function_exists('once')) {
    function once(callable $callback)
    {
        $onceable = Onceable::tryFromTrace(
            debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2),
            $callback,
        );

        return $onceable
            ? Once::instance()->value($onceable)
            : call_user_func($callback);
    }
}
```

포인트는, `once()` 자체는 캐시를 갖지 않고, `debug_backtrace()`로 취득한 호출 원본의 정보로부터 `Onceable` 인스턴스를 조립하여 `Once` 클래스에 처리를 위임하고 있는 점입니다.

```mermaid theme={null}
flowchart TD
    A["once(callback) 호출"] --> B["debug_backtrace()로 호출 원본을 취득"]
    B --> C["Onceable::tryFromTrace()"]
    C --> D{"해시를 계산할 수 있었는가?"}
    D -- 아니오 --> E["callback을 그대로 실행"]
    D -- 예 --> F["Once::instance()->value(onceable)"]
    F --> G{"WeakMap에 캐시 완료?"}
    G -- 예 --> H["캐시 값을 반환"]
    G -- 아니오 --> I["callback을 실행하여 캐시에 저장"]
```

## Onceable — 호출 지점을 특정하는 해시 계산

`Onceable` 클래스는 "어느 호출 지점인가"를 유일하게 특정하는 해시를, 백트레이스로부터 계산하는 역할을 가집니다.

```php theme={null}
class Onceable
{
    public function __construct(
        public string $hash,
        public ?object $object,
        public $callable,
    ) {
        //
    }

    public static function tryFromTrace(array $trace, callable $callable)
    {
        if (! is_null($hash = static::hashFromTrace($trace, $callable))) {
            $object = static::objectFromTrace($trace);

            return new static($hash, $object, $callable);
        }
    }

    protected static function objectFromTrace(array $trace)
    {
        return $trace[1]['object'] ?? null;
    }

    protected static function hashFromTrace(array $trace, callable $callable)
    {
        if (str_contains($trace[0]['file'] ?? '', 'eval()\'d code')) {
            return null;
        }

        $uses = array_map(
            static function (mixed $argument) {
                if ($argument instanceof HasOnceHash) {
                    return $argument->onceHash();
                }

                if (is_object($argument)) {
                    return spl_object_id($argument);
                }

                return $argument;
            },
            $callable instanceof Closure
                ? (new ReflectionClosure($callable))->getClosureUsedVariables()
                : [],
        );

        $class = $callable instanceof Closure
            ? (new ReflectionClosure($callable))->getClosureCalledClass()?->getName()
            : null;

        $class ??= $trace[1]['class'] ?? null;

        return hash('xxh128', sprintf(
            '%s@%s%s:%s (%s)',
            $trace[0]['file'],
            $class ? $class.'@' : '',
            $trace[1]['function'],
            $trace[0]['line'],
            serialize($uses),
        ));
    }
}
```

해시의 근거가 되는 정보는, `파일 경로 + 클래스명 + 함수명 + 행 번호 + 클로저가 use하고 있는 변수의 값`입니다. 즉, **같은 행으로부터 호출된 `once()`라도, `use`하고 있는 변수의 값이 바뀌면 별도의 캐시 취급이 됩니다**.

```php theme={null}
function greet(string $name)
{
    return once(fn () => "Hello, {$name}!");
}

greet('Alice'); // "Hello, Alice!"를 계산하여 캐시
greet('Bob');   // $name의 값이 다르므로 해시도 바뀌어, 재계산된다
```

<Warning>
  `eval()`된 코드 내에서 `once()`를 호출하면 `hashFromTrace()`가 `null`을 반환하여, 캐시는 일절 이루어지지 않습니다. Blade의 컴파일된 뷰 등 eval 경유의 실행을 상정한 안전 장치입니다.
</Warning>

`object`는 "호출 원본이 인스턴스 메서드인지 어떤지"를 나타냅니다. `$trace[1]['object']`는 `debug_backtrace()`의 1계층 위(`once()`를 호출한 쪽)의 객체이므로, 인스턴스 메서드 내라면 `$this`가 들어가고, 정적 메서드나 글로벌 함수라면 `null`이 됩니다.

## Once — WeakMap에 의한 캐시 본체

실제 캐시 보유는 `Illuminate\Support\Once`가 담당합니다.

```php theme={null}
class Once
{
    protected static ?self $instance = null;

    protected static bool $enabled = true;

    protected function __construct(protected WeakMap $values)
    {
        //
    }

    public static function instance()
    {
        return static::$instance ??= new static(new WeakMap);
    }

    public function value(Onceable $onceable)
    {
        if (! static::$enabled) {
            return call_user_func($onceable->callable);
        }

        $object = $onceable->object ?: $this;

        $hash = $onceable->hash;

        if (! isset($this->values[$object])) {
            $this->values[$object] = [];
        }

        if (array_key_exists($hash, $this->values[$object])) {
            return $this->values[$object][$hash];
        }

        return $this->values[$object][$hash] = call_user_func($onceable->callable);
    }

    public static function enable()
    {
        static::$enabled = true;
    }

    public static function disable()
    {
        static::$enabled = false;
    }

    public static function flush()
    {
        static::$instance = null;
    }
}
```

핵심이 되는 기술은 `WeakMap`입니다. `$onceable->object`(호출 원본 인스턴스)를 키로 하여, 값으로서 해시→결과의 연상 배열을 유지합니다. `object`가 없는 경우(글로벌 함수나 정적 메서드)는 `Once` 자신의 `$this`가 키가 되어, 사실상 프로세스 전체에서 공유되는 단일 캐시 영역이 됩니다.

<Info>
  `WeakMap`을 사용하고 있기 때문에, 키가 되는 객체로의 참조가 다른 곳에서 없어지면, 그 객체에 연결된 캐시도 가비지 컬렉션의 대상이 됩니다. `once()`를 대량의 객체로부터 호출해도 메모리 누수가 되기 어려운 설계입니다.
</Info>

### 테스트에서의 활용 — enable / disable / flush

`Once::disable()`을 호출하면 캐시가 완전히 무효화되어, `once()`는 매번 콜백을 실행하도록 됩니다. 테스트에서 "매번 새로운 값이 필요"한 경우에 편리합니다.

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

beforeEach(function () {
    Once::disable();
});

afterEach(function () {
    Once::enable();
    Once::flush();
});
```

`Once::flush()`는 캐시 전체를 파기하고, 다음 접근 시에 새로운 `WeakMap`을 다시 만듭니다. Artisan 명령어의 테스트나, Octane처럼 프로세스가 재사용되는 환경에서 요청을 넘어 캐시를 이어가고 싶지 않은 경우에 사용합니다.

## PreventsCircularRecursion — Onceable의 응용 예

`Onceable::tryFromTrace()`는 `once()` 전용이 아니라, Eloquent의 `Illuminate\Database\Eloquent\Concerns\PreventsCircularRecursion` 트레이트에서도 재이용되고 있습니다. 이쪽은 "같은 객체의 같은 호출 지점을, 콜 스택 내에서 재진입시키지 않는" 것을 위한 트레이트입니다.

```php theme={null}
trait PreventsCircularRecursion
{
    protected static $recursionCache;

    protected function withoutRecursion($callback, $default = null)
    {
        $trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 2);

        $onceable = Onceable::tryFromTrace($trace, $callback);

        if (is_null($onceable)) {
            return call_user_func($callback);
        }

        $stack = static::getRecursiveCallStack($this);

        if (array_key_exists($onceable->hash, $stack)) {
            return is_callable($stack[$onceable->hash])
                ? static::setRecursiveCallValue($this, $onceable->hash, call_user_func($stack[$onceable->hash]))
                : $stack[$onceable->hash];
        }

        try {
            static::setRecursiveCallValue($this, $onceable->hash, $default);

            return call_user_func($onceable->callable);
        } finally {
            static::clearRecursiveCallValue($this, $onceable->hash);
        }
    }

    // getRecursiveCallStack() / getRecursionCache() / setRecursiveCallValue()
    // / clearRecursiveCallValue()는 모두 WeakMap 베이스의 구현
}
```

`Once`와의 결정적인 차이는, `finally` 블록에서 **호출 완료 후에 캐시를 클리어하고 있다**는 점입니다. `Once`는 "요청 중은 영속적으로 캐시한다"인 반면, `PreventsCircularRecursion`은 "지금 실행 중인 콜 스택 동안만 캐시(실질적으로는 락)한다"를 위해서 같은 `Onceable`의 구조를 유용하고 있습니다.

Eloquent 모델에서의 전형적인 사용 예는, 모델의 `toArray()`나 액세서가 자기 자신을 재귀적으로 참조해 버리는 케이스의 방지입니다.

```php theme={null}
use Illuminate\Database\Eloquent\Concerns\PreventsCircularRecursion;
use Illuminate\Database\Eloquent\Model;

class Category extends Model
{
    use PreventsCircularRecursion;

    public function getPathAttribute(): string
    {
        return $this->withoutRecursion(function () {
            // parent의 path를 참조하는 처리에서
            // 순환 참조가 발생해도 여기서 무한 루프를 방지한다
            return $this->parent
                ? $this->parent->path.' > '.$this->name
                : $this->name;
        }, default: $this->name);
    }
}
```

## 패키지 개발에서의 응용

자작 패키지에서 "같은 인스턴스의 같은 메서드 호출을 1요청 중에는 1회만 실행하고 싶다"는 경우, `once()`를 그대로 사용하는 것이 가장 간단합니다. `Onceable`/`Once`를 직접 사용할 기회는 적지만, 이하와 같은 상황에서는 내부 구현의 이해가 도움이 됩니다.

* Artisan 명령어나 테스트에서 `Once::disable()` / `Once::flush()`를 사용해 캐시 동작을 제어하고 싶을 때
* 자작 트레이트에서 "호출 지점 단위"의 캐시나 재진입 방지를 구현하고 싶을 때(`PreventsCircularRecursion`과 같은 패턴을 사용할 수 있습니다)
* `once()`의 결과가 예상과 달라 디버그가 필요할 때, 해시의 근거가 되는 요소(파일·클래스·함수·행·use 변수)를 이해해 두면 원인을 특정하기 쉬워집니다

<Warning>
  `once()`는 클로저가 `use`하고 있는 변수까지 해시에 포함하므로, 루프 안에서 매번 다른 값을 캡처하는 클로저를 전달하면, 의도치 않게 매번 다른 캐시가 되어 실질적으로 캐시되지 않는 경우가 있습니다. 루프 내에서 사용할 경우에는, 캐시 키로서 의도한 입도가 되어 있는지 확인해 주세요.
</Warning>

## 관련 페이지

<Columns cols={2}>
  <Card title="tap() 헬퍼와 Tappable 트레이트" icon="hand-point-right" href="/ko/advanced/tap">
    부수 효과를 끼워 넣으면서 값을 반환하는 tap() 헬퍼의 구현 패턴.
  </Card>

  <Card title="Eloquent Observers와 모델 이벤트" icon="eye" href="/ko/advanced/eloquent-observers">
    Eloquent 모델의 이벤트와 옵저버에 의한 일원 관리.
  </Card>
</Columns>


## Related topics

- [고급 주제](/ko/advanced/index.md)
- [進階主題](/zh-TW/advanced/index.md)
- [tap() 헬퍼와 Tappable 트레이트](/ko/advanced/tap.md)
- [进阶主题](/zh-CN/advanced/index.md)
- [커스텀 인증 가드 구현](/ko/advanced/custom-auth-guard.md)
