@joristein 게시글 번역 및 내용 정리


Scout Builder instance

::search 함수를 호출하면 반환되는 것이 쿼리 빌더 인스턴스라는 점을 알 수 있습니다.
그러나 일반적으로 사용하는 Illuminate\Database\Eloquent\Builder가 아닌, Laravel\Scout\Builder가 반환됩니다.

$eloquentBuilder = User::where('name', 'LIKE', '%John%');
get_class($eloquentBuilder);
// "Illuminate\Database\Eloquent\Builder"

$scoutBuilder = Project::search('John');
get_class($scoutBuilder);
// "Laravel\Scout\Builder"

Eloquent 빌더는 SQL 쿼리를 통해 복잡한 데이터베이스 작업을 지원하지만
Scout 빌더는 검색 엔진과의 상호작용을 목적으로 설계되어 기능이 제한적입니다.

  • 이 구조는 검색 엔진이 대량의 데이터를 빠르게 처리할 수 있도록 하며, 데이터 검색의 복잡성을 줄입니다.
  • 데이터베이스 자체에 복잡한 필터링을 맡기는 대신, 인덱싱된 데이터로 효율적인 검색을 가능하게 합니다.

검색 엔진을 사용하여 데이터 필터링

Laravel Scout에서 ::search를 사용하면 Scout Builder 인스턴스를 얻습니다. 이 Builder에서 where 메서드를 사용하여 검색 조건을 설정할 수 있습니다.

$builder = User::search('John');
$builder->where('is_admin', true);
$builder->where('salary', 50000);

다음으로 Meilisearch를 사용하는 경우, ‘john’이라는 키워드를 가진 사용자 중 급여가 50,000 이상인 관리자만 검색하도록 지시합니다. 중요한 점은 드라이버가 데이터를 필터링하려면 데이터를 미리 인덱싱해야 합니다.

class User extends Model
{
    use Searchable;

    public function toSearchableArray(): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'is_admin' => $this->is_admin,
            'salary' => $this->salary,
        ];
    }
}
  1. 대규모 데이터셋 처리 개선

    • 데이터베이스가 매우 커져서 MySQL에서도 필터링이 느려지는 경우, 인덱싱된 데이터를 사용하는 것이 유리합니다.
    • 대부분의 기업이 이 정도의 데이터셋에 도달하지는 않지만, 가능성을 염두에 두고 설계할 수 있습니다.
  2. 복잡한 계산 및 쿼리 최적화

    • 인덱싱된 데이터로, 복잡한 쿼리를 피하면서 열 이외의 데이터로 필터링할 수 있습니다.
    • 이는 많은 처리 시간이나 성능이 필요한 데이터를 미리 준비해 두는 방식입니다.
// days_off_count 와 is_admin를 미리 인덱싱
// left join, exist 나 sub 쿼리를 사용하지 않고도 필터링 가능
class Employee extends Model
{
    use Searchable;

    public function toSearchableArray(): array
    {
        return [
            'id' => $this->id,
            'days_off_count' => $this->getDaysOffCount(),
            'is_admin' => $this->premiumOfferActive() && $this->hasPermission('admin_access'),
        ];
    }
}

// invoice 총액을 미리 계산
// 필터를 호출할 때, 데이터베이스에서 각 invoice의 총액을 계산할 필요가 없음
class Invoice extends Model
{
    use Searchable;

    public function toSearchableArray(): array
    {
        return [
            'id' => $this->id,
            'total' => $this->getTotal(withDiscounts: true, withTaxes: true),
        ];
    }
}

마지막 단계

  • 검색 엔진을 통해 데이터를 필터링할 때, 어떤 데이터가 검색에 사용되고 어떤 데이터가 필터링에 사용되는지를 엔진에 명시해야 합니다.
  • Algolia에서는 인덱싱이 생성된 후 인덱스 설정에서 찾을 수 있고, Meilisearch에서는 config/scout.php 파일에서 구성할 수 있습니다.

데이터베이스를 사용하여 데이터 필터링

검색 엔진을 구현하는 방법에 따라 데이터베이스에서 데이터를 필터링하고 싶을 수 있습니다. Scout를 사용하면 기본적으로 Eloquent 인스턴스에 접근할 수 없게 되지만, ->query(…) 메서드를 사용하여 다시 Eloquent 빌더에 접근할 수 있습니다. #

use Illuminate\Database\Eloquent\Builder;

[...]

$builder = User::search('John')->query(function ($query){
    // get_class($query); "Illuminate\Database\Eloquent\Builder"

    return $query->where('is_admin', true);
});
use Illuminate\Database\Eloquent\Builder;

$builder = User::search($request->get('search'))->query(function (Builder $query) use ($request){
    return $query
        ->with(['addresses', 'tags', 'role'])
        ->where('is_admin', true)
        ->isActive() // scope
        ->whereRelation('addresses', 'country', 'France')
        ->when($request->has('role', function ($query) use ($request){
            $query->where('role_id', $request->get('role'));
        }));
});

데이터를 나열하는 동시에 데이터를 검색하고 필터링할 수 있는 옵션을 제공하려는 경우 이 방법이 일반적입니다.

::search(”)가 호출된 이후에만 사용할 수 있는, ->query(…) 함수 안에 필터가 작성되어 있기 때문에
사용자가 요청하지 않아도 항상 검색을 수행하게 됩니다.

대부분의 드라이버는 빈 값(null, ”)을 검색할 때, ‘플레이스홀더’로 간주하여 무시하고 전체 데이터를 반환합니다.
하지만 TNTSearch 드라이버는 예외로, null 값을 받으면 빈 결과를 반환합니다.

데이터 정렬

기본적으로, 검색 결과는 드라이버에 의해 검색 적합도(search relevancy)에 따라 정렬됩니다. (Algolia와 Meilisearch 같은 검색 엔진에서 유용합니다) 하지만 SQL 쿼리에서 설정한 정렬 기준이 무시될 수 있으며, 예기치 않은 결과를 초래할 수 있습니다.

예를 들어 아래 코드에서는 사용자의 salary(급여)를 내림차순으로 정렬하려고 했지만, 검색 적합도 우선순위에 따라 결과가 반환됩니다

$builder = User::search('John')->query(function ($query){
    return $query->orderBy('salary', 'desc');
});

dump($builder->get());

// { [
//    {
//     'id' => 2,
//     'name' => 'John',
//     'salary' => 30000,
//    },
//    {
//     'id' => 1,
//     'name' => 'jonathan',
//     'salary' => 90000,
//    }
// ]}

내림차순으로 정렬했지만 검색 적합성이 우선되어(주어진 검색어와 더 관련성이 높음)
‘John’이 ‘Johnetta’보다 먼저 표시되어 salary 기준 내림차순 정렬이 무시됩니다.

데이터 정렬은 필터링과 같은 방식으로 작동합니다. 먼저 모델에서 데이터를 인덱싱해야 하면 Scout Builder에서 orderBy를 사용할 수 있습니다.

class User extends Model
{
    use Searchable;

    public function toSearchableArray(): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'salary' => $this->salary,
        ];
    }
$builder = User::search('John');

$builder->orderBy('salary', 'desc');

dump($builer->get());

// { [
//    {
//     'id' => 1,
//     'name' => 'Johnetta',
//     'salary' => 90000,
//    },
//    {
//     'id' => 2,
//     'name' => 'John',
//     'salary' => 30000,
//    }
// ]}

모든 것을 결합

글을 마무리하기 위해, Meilisearch를 사용하여 검색, 필터링, 정렬을 구현하는 예를 들어보겠습니다.
Intervention 모델에 Searchable 트레잇을 사용하여 Meilisearch가 인덱싱할 수 있도록 합니다.

class Intervention extends Model
{
    use Searchable;

    public function toSearchableArray(): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'reference' => $this->reference,
            'duration_seconds' => $this->duration_seconds,
            'created_at' => $this->created_at,
            'date' => $this->date,
        ];
    }

Meilisearch가 데이터를 적절히 처리할 수 있도록 config/scout.php 설정 파일을 수정합니다.

'meilisearch' => [
    'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
    'key' => env('MEILISEARCH_KEY', null),
    'index-settings' => [
        \App\Models\Intervention::class => [
            'searchableAttributes' => ['name', 'reference'],
            'sortableAttributes' => ['duration_seconds', 'created_at', 'date',],
        ],
    ],
],

이후, php artisan scout:sync-index-settings 명령어로 설정을 Meilisearch와 동기화합니다.

컨트롤러를 만들기 전에, 재사용할 handleScoutRequest trait을 만들어 검색 쿼리, 정렬 기준, 정렬 방향 등을 처리하는 여러 메서드를 정의합니다.

namespace App\Http\Controllers\Traits;

trait handleScoutRequest
{
    // 검색어 처리
    public function getSearchQuery(Request $request, string $query = 'search'): string
    {
        return $request->str($query)->trim()->toString();
    }

    // 정렬 요청 확인
    public function customOrder(Request $request): bool
    {
        return $request->has('sort');
    }

    // 정렬 기준 컬럼 가져오기
    public function getOrderByColumn(Request $request): ?string
    {
        return $request->str('sort')->trim()->toString();
    }

    // 정렬 방향 결정
    public function getOrderByDirection(Request $request): string
    {
        // It is expected to get "/api/interventions?sort=salary" to sort by salary in an ascending order
        // and "/api/interventions?sort=-salary" to sort by salary in an descending order
        $column = $this->getOrderByColumn($request);

        return str_starts_with($column, '-') ? 'desc' : 'asc';
    }
}

검색, 필터링, 정렬을 모두 처리할 수 있도록 컨트롤러를 구현합니다. (handleScoutRequest 트레잇을 사용하여 검색어와 정렬 옵션을 추출하고, 조건에 따라 client와 vehicle 필터링도 처리합니다)

class InterventionController extend Controller
{
    use handleScoutRequest;

    public function index(Request $request)
    {
        $builder = Intervention
            ::search($this->getSearchQuery($request))
            ->query(function (Builder $query) use ($request) {
                $query
                    ->with(['client','vehicle'])
                    ->when($request->has('client'), function ($query) use ($request) {
                        return $query->where('client_id', $request->get('client'));
                    })
                    ->when($request->has('vehicle'), function ($query) use ($request) {
                        return $query->where('vehicle_id', $request->get('vehicle'));
                    });
            })
            ->when($request->customOrder(), function ($query) use ($request) {
                return $query->orderBy($this->getOrderByColumn($request), $this->getOrderByDirection($request));
            });

        return InterventionResource::collection($builder->paginate());
    }
}

결론

이 글에서 Scout를 사용하여 전체 텍스트 검색을 빠르게 구현하는 방법과 Algolia 및 Meilisearch 같은 강력한 검색 드라이버와 통신하는 방법을 살펴보았습니다. 특히, 검색, 필터링, 정렬과 같은 기능을 결합할 때 코드 작성 방식에 변화를 주어야 하기 때문에 조금 까다로워질 수 있다는 점도 배웠습니다. 구현에 초점을 맞추었지만, 아직 고려해야 할 부분들이 많이 남아있습니다.

  • 데이터 형식 맞추기
  • 검색 결과에서 검색어 하이라이트 표시
  • 여러 모델에서 검색 결과를 보여주는 검색 창 구현
  • 자동 완성 기능
  • Boolean 검색
  • etc.

created

category

laravel