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

# Eloquent의 afterQuery와 조회 결과의 계약

> Eloquent의 afterQuery로 로컬 스코프에 결과 가공을 모으는 방법을 설명합니다. 실행 순서, 반환값, first·pluck·cursor·페이지네이션의 차이를 Laravel 13의 구현에서 확인합니다.

조회한 모델을 ID로 참조할 수 있는 형태로 맞추고 싶을 때나, 로컬 스코프에 결과 가공을 모으고 싶을 때는 `afterQuery()`를 사용할 수 있습니다. SQL 조건을 추가하는 것뿐만 아니라, 조회 결과에 대해 실행할 Closure도 쿼리 빌더에 등록할 수 있습니다.

다만 **"SQL이 1회 실행될 때마다 같은 타입의 결과가 전달되는 훅"은 아닙니다**. 조회 메서드에 따라 전달되는 Collection의 내용이나 호출 횟수가 달라집니다.

> [!NOTE]
>
> 이 페이지는 Laravel 13을 대상으로 합니다. [공식 문서의 After Query Callbacks](https://github.com/laravel/docs/blob/13.x/eloquent.md#after-query-callbacks)를 출발점으로, Eloquent 빌더와 공식 테스트에서 사용 시의 경계를 확인합니다.

## 로컬 스코프에 결과 가공을 모으기

모델을 ID로 참조하는 처리를 반복하는 경우에는 결과의 키를 맞추는 스코프를 준비할 수 있습니다. 다음 예제는 `app/Models/Post.php`에 정의합니다.

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

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    protected $fillable = ['title', 'body'];

    #[Scope]
    protected function keyedById(Builder $query): void
    {
        $query->afterQuery(
            fn (Collection $posts): Collection => $posts->keyBy('id')
        );
    }
}
```

호출하는 쪽은 일반 스코프와 같은 방식으로 체이닝할 수 있습니다.

```php theme={null}
use App\Models\Post;

$posts = Post::keyedById()->orderBy('id')->get();

$post = $posts->get(42);
```

`$posts`는 여전히 Eloquent Collection이며, 요소도 `Post` 그대로입니다. `get(42)`는 키가 `42`인 모델을 반환하고, 존재하지 않으면 `null`을 반환합니다. `select()`로 조회 컬럼을 한정하는 경우에는 키로 사용하는 `id`도 포함하세요.

한 곳에서만 가공한다면 `get()->keyBy('id')`가 더 단순합니다. `afterQuery()`는 스코프나 재사용하는 쿼리에 **조회 후의 처리도 포함하고 싶을 때** 선택합니다. 스코프 자체의 정의 방법은 [Eloquent 스코프](https://kawax.biz/ko/advanced/eloquent-scopes.md)를 참고하세요.

## get의 실행 순서 읽기

`Eloquent\Builder::get()`은 글로벌 스코프 적용, 모델 생성, 지정된 관계의 Eager 로딩을 수행하고, 마지막으로 `afterQuery`의 콜백을 실행합니다.

```mermaid theme={null}
flowchart TD
    A["keyedById()로<br>Closure 등록"] --> B["get() 호출"]
    B --> C["글로벌 스코프 적용"]
    C --> D["SQL을 실행하고<br>모델 생성"]
    D --> E["지정된 관계를<br>Eager 로딩"]
    E --> F["Eloquent Collection 생성"]
    F --> G["afterQuery의 Closure를<br>등록 순서대로 실행"]
    G --> H["가공된 결과 반환"]
```

일반적인 `get()`에서는 콜백에서 Eager 로딩된 관계를 사용할 수 있습니다. 지정하지 않은 관계에 접근하면 추가 SQL이 발생할 수 있으므로, [Eager 로딩](https://kawax.biz/ko/eloquent-relationships.md)과 함께 설계합니다.

`retrieved` 모델 이벤트와는 타이밍이 다릅니다. `retrieved`는 `Model::newFromBuilder()`에서 각 모델을 생성할 때 발생하며, `get()`의 Eager 로딩보다 앞섭니다. 조회한 모델의 묶음을 다루는 `afterQuery()`와 각 모델의 이벤트는 같은 역할이 아닙니다.

> [!WARNING]
>
> 이것은 `Eloquent\Builder::get()`의 흐름입니다. `cursor()`는 관계를 Eager 로딩하지 않으며, 콜백도 모델 1건씩에 대해 실행합니다. `get()`과 같은 배치 처리를 기대하지 마세요.

## 반환값은 다음 콜백으로 전달된다

`afterQuery()`는 등록 순서대로 Closure를 추가합니다. 실행 부분인 `applyAfterQueryCallbacks()`는 다음과 같이 구현되어 있습니다.

```php theme={null}
public function applyAfterQueryCallbacks($result)
{
    foreach ($this->afterQueryCallbacks as $afterQueryCallback) {
        $result = $afterQueryCallback($result) ?: $result;
    }

    return $result;
}
```

여기서 사용되는 것은 `??`가 아니라 PHP의 truthy 판정을 사용하는 `?:`입니다.

| 콜백의 반환값 | 결과에 대한 반영 |
| - | - |
| Collection 등의 객체 | 반환한 객체가 다음 콜백과 호출한 쪽으로 전달된다 |
| `null`, `false`, `0`, 빈 문자열, 빈 배열 | 그때까지의 결과를 유지한다 |
| 빈 Collection | 객체이므로 빈 Collection으로 대체된다 |

Collection을 직접 변경하고 아무것도 반환하지 않는 `void` 콜백도 사용할 수 있습니다. 이 경우 변경한 같은 객체를 유지합니다. 반면 새로운 Collection을 만든 경우에는 그 Collection을 반환해야 합니다.

내부의 인수와 반환값은 `mixed`이지만, 자유로운 타입 변환을 공개 API로 만드는 것은 피합니다. 예를 들어 `get()`의 결과를 문자열이나 배열로 바꾸면, `first()`가 이어서 호출하는 `->first()` 등의 처리가 깨집니다. 스코프에서는 모델·Collection의 타입·건수·정렬 순서를 유지하는 가공을 기본으로 하세요.

## 조회 메서드별 차이

다음 표는 **Eloquent 빌더에 등록한** `afterQuery()`의 처리입니다.

| 호출하는 메서드 | 콜백에 전달되는 결과 | 주의점 |
| - | - | - |
| `get()` | Eloquent Collection | 0건이어도 빈 Collection으로 실행한다 |
| `first()` / `firstOrFail()` | Eloquent Collection | 모델 자체가 아니라 0건 또는 1건의 Collection. 가공 후에 첫 번째를 꺼낸다 |
| `paginate()` | 페이지의 모델을 포함하는 Eloquent Collection | Paginator가 아니다. 총 건수가 0이면 `get()`을 호출하지 않으며 콜백도 실행하지 않는다 |
| `simplePaginate()` / `cursorPaginate()` | Eloquent Collection | 다음 페이지 확인용 1건이 포함될 수 있어, 최대 `perPage + 1`건을 가공한다 |
| `cursor()` | 모델 1건을 포함하는 Eloquent Collection | LazyCollection을 소비할 때 각 행에서 실행하고, 가공 결과의 첫 번째를 반환한다 |
| `lazy()` / `chunk()` / `chunkById()` | 청크별 Eloquent Collection | 내부의 `get()`마다 실행한다. 종료 판정용 빈 결과에도 실행되는 경우가 있다 |
| `pluck()` | 값을 포함하는 Support Collection | 모델이 아니다. 대상 컬럼의 Cast나 Mutator 처리 후에 실행한다 |
| `count()` / `exists()` | 전달되지 않음 | Eloquent의 콜백을 거치지 않고, 기반 쿼리 빌더로 처리를 위임한다 |
| `toBase()->get()` | 전달되지 않음 | Eloquent 측의 등록은 기반 빌더로 옮겨지지 않는다. 기반 빌더에는 별도의 `afterQuery()`가 있다 |

`first()`는 내부에서 `limit(1)->get()->first()`를 호출합니다. `first()`를 사용한다고 해서 Closure의 인수를 `Post`로 변경해서는 안 됩니다.

> [!WARNING]
>
> 이 페이지의 `keyedById()`는 모델을 조회하는 용도의 스코프입니다. `Post::keyedById()->pluck('id')`에는 사용할 수 없습니다. `pluck()`는 `Illuminate\Support\Collection`을 전달하므로 예제의 `Illuminate\Database\Eloquent\Collection` 타입과 일치하지 않습니다. 타입 선언을 완화하는 것만으로는 값에서 모델의 속성을 참조하는 처리가 안전해지지 않습니다.

값만 필요하다면 스코프를 사용하지 말고 `Post::query()->pluck('id')`로 합니다. 모델을 가공한 후에 값을 꺼내려면 `Post::keyedById()->get()->pluck('id')`로 쓸 수 있습니다. 후자의 `pluck()`는 쿼리 빌더가 아니라 조회한 Collection에 대한 조작입니다.

### 페이지 건수나 다음 조회 위치를 바꾸지 않기

`paginate()`의 총 건수는 SQL로 집계하고, 그 후에 페이지의 모델을 조회합니다. 조회 후에 Collection을 필터링해도 총 건수는 다시 계산되지 않습니다.

또한 `simplePaginate()`와 `cursorPaginate()`는 가공 후의 결과를 사용해 다음 페이지를 판단합니다. `chunkById()`는 청크 끝의 ID를 다음 조회 위치로 사용합니다. 콜백 안에서 건수나 순서를 바꾸면 페이지 판정이나 계속 위치가 원래 쿼리와 일치하지 않게 될 수 있습니다.

필터링·정렬·조회 컬럼은 SQL 측에서 지정하고, `afterQuery()`에서는 조회 결과의 의미를 바꾸지 않는 가공에 그칩니다. API의 표시 형식만 바꾸는 것이라면 [API 리소스](https://kawax.biz/ko/eloquent-resources.md)를 사용한 변환도 검토하세요.

공식 문서에는 모델에 검색 결과의 발췌 속성을 추가하는 예제가 있습니다. 모델 속성에 대한 대입은 저장 대상의 변경이 될 수도 있습니다. DB에 존재하지 않는 표시용 속성을 붙인 모델을 그대로 `save()`하는 설계는 피하세요.

## 등록은 쿼리 빌더에 남는다

`afterQuery()`를 호출할 때마다 Closure가 추가되며, 실행해도 등록은 사라지지 않습니다. 같은 빌더로 다시 조회하면 다시 실행되고, 같은 스코프를 두 번 호출하면 콜백도 두 번 등록됩니다. 빌더를 clone한 경우에도 등록된 콜백을 이어받습니다.

조회를 여러 번 수행하는 처리나 장수명 워커에서는 용도마다 새로운 쿼리를 만들고, 콜백은 부작용 없는 결과 가공으로 합니다. 메일 발송이나 영속화를 두면, 청크나 커서에 의한 여러 번의 실행을 업무상 1회의 처리로 착각하기 쉬워집니다.

패키지에서 재사용할 스코프를 제공하는 경우에도, 지원하는 조회 메서드를 공개 API로 명시하고 타입과 호출 횟수의 계약을 테스트하세요.

## 조회 경로의 차이를 테스트하기

사용하는 쪽의 Laravel 13 애플리케이션에서는 키 가공뿐만 아니라 `first()`·`cursor()`·빈 페이지에서의 호출을 확인합니다.

다음 예제는 앞서 본 `Post` 모델과, `id`·`title`·`body`·`created_at`·`updated_at`을 가진 `posts` 테이블의 마이그레이션이 있다는 것을 전제로 합니다. [RefreshDatabase](https://kawax.biz/ko/database-testing.md)로 테스트 간의 레코드를 분리합니다.

`tests/Feature/AfterQueryTest.php`에 테스트를 작성합니다.

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

namespace Tests\Feature;

use App\Models\Post;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class AfterQueryTest extends TestCase
{
    use RefreshDatabase;

    public function test_get_preserves_models_and_keys_them_by_id(): void
    {
        $first = Post::create(['title' => 'First', 'body' => 'First body']);
        $second = Post::create(['title' => 'Second', 'body' => 'Second body']);

        $posts = Post::keyedById()->orderBy('id')->get();

        $this->assertInstanceOf(Collection::class, $posts);
        $this->assertSame([$first->getKey(), $second->getKey()], $posts->keys()->all());
        $this->assertTrue($first->is($posts->get($first->getKey())));
    }

    public function test_first_passes_a_collection_to_the_callback(): void
    {
        $expected = Post::create(['title' => 'First', 'body' => 'First body']);
        $sizes = [];

        $post = Post::query()
            ->afterQuery(function (Collection $posts) use (&$sizes): void {
                $sizes[] = $posts->count();
            })
            ->first();

        $this->assertSame([1], $sizes);
        $this->assertTrue($expected->is($post));
    }

    public function test_cursor_runs_callbacks_when_each_model_is_consumed(): void
    {
        $first = Post::create(['title' => 'First', 'body' => 'First body']);
        $second = Post::create(['title' => 'Second', 'body' => 'Second body']);
        $sizes = [];

        $cursor = Post::keyedById()->orderBy('id')
            ->afterQuery(function (Collection $posts) use (&$sizes): void {
                $sizes[] = $posts->count();
            })
            ->cursor();

        $this->assertSame([], $sizes);
        $this->assertSame([$first->getKey(), $second->getKey()], $cursor->pluck('id')->all());
        $this->assertSame([1, 1], $sizes);
    }

    public function test_empty_get_runs_a_callback_but_zero_total_pagination_does_not(): void
    {
        $sizes = [];
        $query = Post::query()->whereKey(-1)
            ->afterQuery(function (Collection $posts) use (&$sizes): void {
                $sizes[] = $posts->count();
            });

        $this->assertCount(0, $query->get());
        $this->assertSame([0], $sizes);

        $page = $query->paginate(perPage: 15, page: 1);

        $this->assertSame(0, $page->total());
        $this->assertSame([0], $sizes);
    }
}
```

대상 테스트만 실행할 수 있습니다.

```bash theme={null}
php artisan test --filter=AfterQueryTest
```

## 유지보수 시 체크리스트

* 스코프가 지원하는 조회 메서드와 Closure의 인수·반환값 타입을 명시한다.
* `get()`의 빈 결과와 총 건수 0인 `paginate()`를 구별하여 검증한다.
* 청크나 커서에서는 호출 횟수와 조회 순서가 바뀌지 않는지 확인한다.
* 콜백의 이중 등록이나 빌더 재사용으로 업무상의 부작용이 발생하지 않도록 설계한다.
* Laravel 업데이트 시에는 `get()`·`pluck()`·`cursor()`와 `BuildsQueries`의 호출 경로를 다시 확인한다.

## 관련 페이지

> [card] [Eloquent 스코프](https://kawax.biz/ko/advanced/eloquent-scopes.md)
>
> SQL 제약을 로컬 스코프와 글로벌 스코프로 모으는 기본을 확인합니다.

> [card] [Eloquent 컬렉션](https://kawax.biz/ko/eloquent-collections.md)
>
> 모델을 다루는 Collection과 Support Collection의 차이를 확인합니다.

> [card] [Eloquent Observer와 모델 이벤트](https://kawax.biz/ko/advanced/eloquent-observers.md)
>
> 조회 결과의 가공과는 다른, 모델 단위의 이벤트를 확인합니다.

> [card] [API 리소스](https://kawax.biz/ko/eloquent-resources.md)
>
> API로 반환하는 표시 데이터의 변환을 모델의 저장 처리에서 분리합니다.

## 참고한 1차 정보

* [Laravel 공식 문서: After Query Callbacks](https://github.com/laravel/docs/blob/f7a20c401a30827991659174d48d958f92517a9e/eloquent.md#after-query-callbacks)
* [Laravel Framework v13.40.0: Eloquent Builder](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Database/Eloquent/Builder.php)
* [Laravel Framework v13.40.0: BuildsQueries](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Database/Concerns/BuildsQueries.php)
* [Laravel Framework v13.40.0: Model](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Database/Eloquent/Model.php)
* [Laravel 공식 통합 테스트: AfterQueryTest](https://github.com/laravel/framework/blob/v13.40.0/tests/Integration/Database/AfterQueryTest.php)


## Related topics

- [Eloquent의 스코프](https://kawax.biz/ko/advanced/eloquent-scopes.md)
- [고급 주제](https://kawax.biz/ko/advanced/index.md)
- [Eloquent의 커스텀 캐스트](https://kawax.biz/ko/advanced/eloquent-casts.md)
- [패키지의 커스텀 캐시 스토어와 계약](https://kawax.biz/ko/advanced/package-cache-drivers.md)
- [Eloquent API 리소스](https://kawax.biz/ko/eloquent-resources.md)


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