효과적인 Eloquent

Seung-hae KIM·2022년 12월 23일
0

이 글은 Steve McDougall의 Effective Eloquent를 번역한 글입니다. 원저자에게는 허락받지 않았습니다.


이 Eloquent 쿼리에 관한 튜토리얼을 통해 당신의 라라벨 스킬을 향상시킬 준비가 되었는가? 당신은 이 글을 통해서 Eloquent 쿼리를 능숙하게 사용할 수 있을만한 모든 기술을 터득하게 될 것이다.

시작하기 앞서 한 걸음 뒤로 가 Eloquent가 무엇인지에 대해 생각해보자. Eloquent는 Laravel과 Query Builder를 위한 ORM이다. 당신은 Eloquent Model로 ORM을 다룰 수 있고, 이를 통해 유려하고 효과적으로 당신의 데이터베이스를 쿼리할 수 있다. 당신은 또한 데이터베이스 파사드를 통해 Query Builder로 당신의 쿼리를 직접 구성할 수도 있다.

우리가 오늘 쿼리하게 될 것은 무엇일까? 나는 Laracon EU 연설을 위해 만든 은행 어플리케이션이 있다. 이는 데이터를 쿼리할 때 따라올 수 있는 흥미로운 옵션들을 소개하기 위한 것이었다.

우리는 하나의 사용자(User)가 여러 개의 계좌(Accounts)를 가질 수 있는 조건의 데이터를 다루게 될 것이다. 각각의 계좌는 잔고(running balance)를 보관한다. 각각의 계좌는 하나 이상의 거래(Transactions)를 가지게 될 것이고, 거래는 계좌와 Vendor를 거래 대금과 함께 이어줄 것이다. 만약 시각적 예시가 필요하다면 이 링크를 통해 Sql 다이어그램을 살펴볼 수도 있다.

라라콘에서 했던 연설에서는 API 그 자체를 위해 사용되는 특정쿼리에 대해서만 소개했었다. 어쨌거나 우리가 사용할 수 있는 다른 더 많은 쿼리가 있을 수 있고, 이에 대해 더 살펴볼 것이다.

로그인 한 사용자의 모든 계좌를 조회하게 위해서는 우리는 아래와 같은 구문을 작성할 수 있다:

use App\Models\Account;

$accounts = Account::where('user_id', auth()->id())->get();

일반적으로 우리는 이 쿼리를 우리의 컨트롤러에 작성하고 조회 결과를 그대로 응답할 것이다. 이 튜토리얼에서 api 응답의 기제(mechanism)에 대해서까지 설명할 만큼 당신들에게 많은 걸 줄 생각은 없다. 어쨌건 같은 이유에서 같은 쿼리를 다양한 장소에서 호출해야할 수도 있다. 예를 들어 우리가 내부 대시보드를 만들고 관리자의 관점에서 사용자의 계좌들을 보고자 할 때 말이다.

시작하기 앞서, 내 개인적인 골칫거리는 새로운 각각의 쿼리에 대해 Eloquent Builder를 생성하는 것에 있지는 않다. IDE의 자동완성이 있다면 특별이 다른 파일을 만들 필요도 없다. 당신은 단지 쿼리를 호출 할 때 가장 처음 query라는 정적 메서드를 호출하기만 하면 된다.

use App\Models\Account;

$accounts = Account::query()->where('user_id', auth()->id())->get();

이는 당신의 쿼리에 대한 단순한 추가사항이다. 시간이 들지도 않고 지속적으로 정적 호출을 전달하는 것보다는 낫다. 이는 많은 어플리케이션에서 볼 수 있는 표준적인 쿼리이고, 누군가는 그 자체로 괜찮다고 말할 것이다: 그리고 그 말은 옳다. 이는 당신이 원하는 바를 그 자체로 해낸다.

우리는 ORM을 쓰는 대신에 이 쿼리를 위해 DB 파사드를 사용할 수도 있다. 당연히 메모리 사용량도 더 적고 응답도 빠르다. 당신이 거대한 데이터를 갖고 있다면 속도차이는 굉장히 미미할 수도 있다. 어쨌건 아래 쿼리를 한 번 보자.

use Illuminate\Support\Facades\DB;

$accounts = DB::table('accounts')->where('user_id', auth()->id())->get();

내가 테스트해본 바에 따르면 DB 파사드가 훨씬 적은 메모리를 사용한다. 하지만 이는 객체의 collection을 반환하기 때문이다. ORM 쿼리를 사용한다면 Model들의 collection을 반환하고, 이들은 메모리를 필요로 한다. 즉 우리는 Eloquent Model의 편리함을 위해 비용을 지불하는 것이다.

보다 한 걸음 나아가보자. 내 예시에 의하면 나는 이 쿼리 구문을 작동시키고 결과를 얻기 위해 컨트롤러 하나를 사용하고 있다. 앞서 말했듯 이 쿼리는 다른 어플리케이션 영역에서도 사용될 것이다. 이 쿼리들을 보다 광역적으로 사용하기 위해 내가 제어해야 할 것들은 무엇인가. 재사용을 위한 Query classes 말이다.

이는 내가 아주 자주 사용하고 당신이 아직 도입하지 않았다면 한 번 쯤은 소개할만한 패턴이다. 이는 내가 CQRS(Command and Query Responsibility Segregation) 계에서 배운 트릭 중 하나이다. 읽기 기능은 Queries로, 쓰기 기능은 Commands로 클래스화 하는 것이다. 내가 CQRS에서 좋아하는 부분은 컨트롤러가 알아야 할 것과 단순히 데이터를 조회하는 것에 전념하는 클래스 사이의 논리를 분리하는 능력이다. 아래 클래스를 한 번 살펴보자.

final class FetchAccountsForUser implements FetchAccountsForUserContract
{
    public function handle(string $user): Collection
    {
        return Account::query()
            ->where('user_id', $user)
            ->get();
    }
}

이는 (스티븐이 그토록 몰두해왔었던) 하나의 일만 수행하는 쿼리이다. container 사이를 이동할 수 있고 내가 원하는 곳에서 쿼리를 사용하기 위해 contract/interface를 사용하고 있다. 지금은, 즉 우리의 컨트롤러에서는 단지 아래와 같이 호출하기만 하면 된다.

$accounts = $this->query->handle(
    user: auth()->id(),
);

이런 방식을 도입했을 때의 이점은 무엇인가? 첫째로 우리는 하나의 로직을 그를 위한 클래스 하나로 분리할 수 있게된다. 만약 우리가 사용자를 위해 불러와야 할 Account의 범위가 달라진다면 이 클래스를 수정하는 것으로 코드베이스 전체를 업데이트 할 수 있다.

때문에 당신이 어플리케이션에서 데이터를 조회하고자 한다면, 심지어 자주 조회하고자 한다면 당신은 이 쿼리가 다소 동적이지 못하다는 것을 알아차릴 것이다. 그렇다. 사용자의 입력에 대해 당신이 전달하고자 하는 값은 동적일 것이다. 어쨌든 쿼리 자체는 아주 가끔만 변경된다. 뭐 이건 가끔 사실이다. 예를 들어 한 API 엔드포인트의 옵션이 관계성, 필터링, 결과 정렬 등을 필요로 할 때가 있는 것처럼 말이다.

우리는 우리의 어플리케이션에 대한 새로운 문제점을 알게 되었다. 그렇다면 어떻게 우리는 작업흐름의 변화 없이 동적 쿼리와 정적 쿼리(non-dynamic queries)를 지우너할 수 있을까? 지금까지 우리는 쿼리 클래스를 쿼리를 작동하고 결과를 받기 위해 리팩토링했다.

나는 동적인 부분을 보다 정적으로 다룰 수 있게 하기 위해 쿼리빌더를 쿼리 클래스에 전달하는 것으로 타파했다. 이에 어떻게 접근할 수 있는지 살펴보자.

final class FetchTransactionForAccount implements FetchTransactionForAccountContract
{
    public function handle(Builder $query, string $account): Builder
    {
        return $query->where('account_id', $account);
    }
}

그리고 우리는 컨트롤러 내부에서 다음과 같이 호출하면 된다.

public function __invoke(Request $request, string $account): JsonResponse
{
    $transactions = $this->query->handle(
        query: Transaction::query(),
        account: $account,
    )->get();
}

우리는 컨트롤러에서 Transaction::query()를 전달하고 Account를 위한 참조 ID를 전달하는 것으로 문제를 해결했다. 쿼리 클래스는 우리가 반환받길 원하는 쿼리빌더 인스턴스를 반환한다. 이 간단한 예시는 장점을 특별히 돋보이게 하는 것 같지는 않으므로 다른 예시를 들어보겠다.

상상해보자 우리가 반환될 Relationship을 선택할 수 있고 범위를 지정할 수 있는 쿼리에 대해 상상해보자. 이를테면 사용자를 위해 가장 최근에 사용된 계좌와 거래의 총 개수를 보여주고자 할 때 아래와 같이 작성할 수 있다.

$accounts = Account::query()
    ->withCount('transactions')
    ->whereHas('transactions', function (Builder $query) {
        $query->where(
            'created_at',
            now()->subDays(7),
        )
    })->latest()->get();

이는 충분히 납득할만한 쿼리다. 하지만 우리가 이를 다른 몇몇 장소에서 재사용하고자 한다면, 그리고 이를 확장해 범위를 지정하거나 30일 내에 거래가 있었던 계좌만 보여주고자 한다면... 당신은 위 쿼리가 어떻게 빠르게 비대해질 수 있을지 상상할 수 있을 것이다.

그럼 쿼리 클래스에서 이 문제를 어떻게 접근할 수 있을지 살펴보자.

final class RecentAccountsForUser implements RecentAccountsForUserContract
{
    public function handle(Builder $query, int $days = 7): Builder
    {
        $query
            ->withCount('transactions')
            ->whereHas('transactions', function (Builder $query) {
                $query->where(
                    'created_at',
                    now()->subDays($days),
                )
            });
    }
}

이를 구현할 때는 아래와 같이 하면 된다:

public function __invoke(Request $request): JsonResponse
{
    $accounts = $this->query->handle(
        query: Account::query()->where('user_id', auth()->id()),
    )->latest()->get();

    // handle the return.
}

훨씬 깨끗해졌다. 그리고 쿼리의 반복적이고 핵심적인 부분을 분리해냈다.

그럼에도 이런 작업이 반드시 필요한걸까? 나는 이를 모델의 특정한 메서드로 추가하고 마는 사람들을 많이 봤다. 뭐 그것도 나쁘지 않다.

하지만 우리는 변경 요청이 올 때마다 우리의 모델이 더 커지는 것을 보게 된다. 우리 모두 알다시피 우리는 헬퍼 메서드를 추가하는 것으로 끝내지, 그 메서드를 대체하지는 않는다. 이 방법으로 접근해보면 당신은 이 방법으로 당신이 가진 것들을 확장하는 것의 이점을 가늠할 수 있게 된다. 당신이 이걸 알기 전엔 당신의 모델마다 컬렉션을 반환하는 30여 개의 핼퍼 메서드가 있었을 것이다.

만약 우리가 우리의 어플리케이션을 가로질러 DB 파사드를 사용하는 것으로 관심사를 돌려본다면? 갑자기 우리는 로직이 변경되어야 할 수많은 지점들이 생길 것이다. 그리고 그 비용은 예측조차 불가능할 것이다. DB 파사드를 사용한 아래 쿼리를 살펴보자.

$latestAccounts = DB::table(
    'transactions'
)->join(
    'accounts',
    'transactions.account_id', '=', 'accounts.id'
)->select(
    'accounts.*',
    DB::raw(
        'COUNT(transactions.id) as total_transactions')
)->groupBy(
    'transactions.account_id'
)->orderBy('transactions.created_at', 'desc')->get();

이를 쿼리 클래스로 이동한다면?

final class RecentAccountsForUser implements RecentAccountsForUserContract
{
    public function handle(Builder $query, int $days = 7): Builder
    {
        return $query->select(
            'accounts.*',
            DB::raw('COUNT(transactions.id) as total_transactions')
        )->groupBy('transactions.account_id');
    }
}

그리고 아래와 같이 구현하면 된다.

public function __invoke(Request $request): JsonResponse
{
    $accounts = $this->query->handle(
        query: DB::table(
            'transactions'
        )->join(
            'accounts',
            'transactions.account_id', '=', 'accounts.id'
        )->where('accounts.user_id', auth()->id()),
    )->orderBy('transactions.created_at', 'desc')->get();
 
    // handle the return.
}

이는 유의미한 변화이다. 어쨌든 우리는 이를 단계적으로 수행하고 각각의 작은 요소들을 테스트할 수 있다. 이것의 이점은 한 사용자든 선택된 사용자든 모든 사용자든지 같은 쿼리를 사용할 수 있다는 것이고, 쿼리 클래스는 변경될 필요가 없다는 것이다.

전반적으로 우리가 만들어낸 것은 scope와 비슷하지만 Eloquent Builder와는 약하게 결합된 것이다.

이것이 내가 내 어플리케이션에서 Eloquent 쿼리를 관리하는 방법이다. 이는 내가 반복적인 부분들을 분리해 테스트할 수 있도록 해준다. 나는 이를 쿼리를 작성하는 효과적인 방법이라고도 생각한다. 하지만 이는 모두를 위한 것은 아니다. Matt Stauffer와 쓴 내 최근 글이 이를 증명한다. 내가 해온 모든 일들은 모델에 헬퍼 메서드를 사용하거나 쿼리 스코프를 사용하는 것으로도 해낼 수 있는 일이다. 하지만 나는 나의 모델이 가볍길 원하고, 스코프는 가볍고 훨씬 특정적인 것을 좋아한다. 하나의 스코프에 너무 많은 로직을 추가하는 것은 내겐 뭔가 잘못된 느낌을 준다. 여기 있어선 안될 것 같다는 느낌이다. 물론 내가 틀렸을 수도 있다. 다만 내 방법이 유일한 방법이 아니라는 점을 받아들여 언제나 행복하다.

0개의 댓글

관련 채용 정보