큐 (Queues)

웹 애플리케이션을 만들다 보면 업로드된 CSV를 파싱하고 저장하는 것처럼 일반 웹 요청 동안 처리하기엔 너무 오래 걸리는 작업이 있어요. Laravel 큐는 이런 시간이 오래 걸리는 작업을 백그라운드에서 처리하는 큐잉 잡(queued job)을 쉽게 만들게 해줘요. 무거운 작업을 큐로 옮기면 애플리케이션이 웹 요청에 훨씬 빠르게 응답해서 더 나은 사용자 경험을 줄 수 있죠. 이 문서에서는 큐의 개념, 잡 생성, 디스패치, 워커 실행, 실패 처리까지 Laravel 12 기준으로 익혀볼게요.

출처: Laravel 공식 문서 — Queues

큐 소개와 기본 개념

Laravel 큐는 Amazon SQS, Redis, 관계형 데이터베이스 등 다양한 큐 백엔드에 걸쳐 통일된 큐잉 API를 제공해요. 설정은 config/queue.php에 있고, 각 드라이버(데이터베이스·SQS·Redis·Beanstalkd·동기·null)의 커넥션 설정이 담겨 있어요. Redis 기반 큐를 쓴다면 Horizon 대시보드도 살펴볼 만해요.

커넥션 vs 큐

config/queue.phpconnections 배열은 Amazon SQS·Beanstalk·Redis 같은 백엔드 큐 서비스로의 커넥션을 정의해요. 그리고 각 커넥션은 여러 개의 "큐(queue)"를 가질 수 있어요. 각 커넥션 설정에는 queue 속성이 있는데, 이게 해당 커넥션으로 잡을 보낼 때 별도로 지정하지 않으면 들어가는 기본 큐예요.

use App\Jobs\ProcessPodcast;

// 커넥션의 기본 큐로 전송됨...
ProcessPodcast::dispatch();

// 커넥션의 "emails" 큐로 전송됨...
ProcessPodcast::dispatch()->onQueue('emails');

잡을 여러 큐로 나누면 처리 우선순위를 부여하거나 분할할 수 있어요. 워커가 우선순위대로 큐를 처리하게 하려면 --queue=high,default처럼 지정해요.

php artisan queue:work --queue=high,default

잡 생성하기 (Creating Jobs)

기본적으로 큐잉 잡은 app/Jobs 디렉터리에 저장돼요. make:job Artisan 커맨드로 생성하면 Illuminate\Contracts\Queue\ShouldQueue 인터페이스를 구현하는데, 이 인터페이스가 Laravel에 잡을 비동기적으로 실행되도록 큐에 넣어야 한다는 걸 알려줘요.

php artisan make:job ProcessPodcast

잡 클래스 구조

잡 클래스는 아주 단순하고, 보통 큐에서 처리될 때 호출되는 handle 메서드만 담고 있어요.

<?php

namespace App\Jobs;

use App\Models\Podcast;
use App\Services\AudioProcessor;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class ProcessPodcast implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public Podcast $podcast,
    ) {}

    public function handle(AudioProcessor $processor): void
    {
        // 업로드된 포드캐스트 처리...
    }
}

잡은 Queueable 트레이트를 쓰므로, 생성자에 Eloquent 모델을 직접 넘길 수 있어요. 큐에 넣을 때는 모델의 식별자(id)만 직렬화되고, 실제 처리될 때 큐 시스템이 모델 인스턴스와 로드된 관계를 DB에서 자동으로 다시 가져와요. 이 방식 덕분에 큐로 보내는 페이로드가 훨씬 작아져요. handle 메서드에는 의존성을 타입 힌트할 수 있고, 서비스 컨테이너가 자동으로 주입해요.

직렬화되는 관계 때문에 잡 문자열이 커질 수 있는데, 관계를 직렬화하지 않으려면 withoutRelations 메서드나 #[WithoutRelations] 속성을 써요.

use Illuminate\Queue\Attributes\WithoutRelations;

public function __construct(
    #[WithoutRelations]
    public Podcast $podcast,
) {}

유니크 잡 (Unique Jobs)

특정 잡의 인스턴스가 큐에 한 번만 존재해야 할 때 ShouldBeUnique 인터페이스를 구현해요. 이미 처리 중인 같은 잡이 있으면 새 디스패치는 무시돼요. uniqueId로 유니크 키를 정하고 uniqueFor로 유니크 락이 해제되는 시간을 지정할 수 있어요. 웹 서버가 여러 대라면 정확한 판정을 위해 모두 같은 중앙 캐시 서버를 바라봐야 해요. 처리가 시작되기 전에 바로 락을 풀려면 ShouldBeUniqueUntilProcessing을 구현해요.

class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique
{
    public $product;

    public $uniqueFor = 3600;

    public function uniqueId(): string
    {
        return $this->product->id;
    }
}

암호화된 잡

잡 데이터의 프라이버시와 무결성을 보장하려면 ShouldBeEncrypted 인터페이스를 추가해요. Laravel이 큐에 넣기 전에 잡을 자동으로 암호화해요.

잡 미들웨어 (Job Middleware)

잡 미들웨어는 큐잉 잡 실행 주위에 사용자 정의 로직을 감싸서, 잡 자체의 보일러플레이트를 줄여줘요. 라우트 미들웨어처럼 처리 중인 잡과 계속 진행을 위한 콜백을 받아요. make:job-middleware로 생성하고, 잡의 middleware 메서드에서 반환해 연결해요.

use App\Jobs\Middleware\RateLimited;

public function middleware(): array
{
    return [new RateLimited];
}

Laravel에는 레이트 리밋 미들웨어(RateLimited)가 내장돼 있어서, 잡에 RateLimiter를 정의하고 RateLimited('backups')처럼 연결하면 잡이 레이트 리밋을 초과할 때 적절한 지연과 함께 큐로 다시 풀려나요. 같은 잡이 겹쳐 실행되는 걸 막는 WithoutOverlapping 미들웨어도 있어요. 리소스를 동시에 하나의 잡만 수정해야 하는 경우에 new WithoutOverlapping($this->user->id)처럼 키를 기준으로 겹침을 방지해요.

잡 디스패치하기 (Dispatching Jobs)

작성한 잡 클래스는 잡 자신의 dispatch 메서드로 디스패치해요. 전달한 인자는 잡의 생성자로 들어가요. 조건에 따라 디스패치하려면 dispatchIf·dispatchUnless를 써요.

ProcessPodcast::dispatch($podcast);

지연·동기 디스패치

delay 메서드로 워커가 즉시 처리하지 못하게 지연시킬 수 있어요. 반대로 dispatchSync로 현재 프로세스 안에서 즉시(동기적으로) 실행할 수도 있어요. deferred 커넥션으로 디스패치하면 HTTP 응답을 보낸 뒤 현재 프로세스에서, background 커넥션은 응답 후 별도 PHP 프로세스에서 처리해요.

ProcessPodcast::dispatch($podcast)
    ->delay(now()->plus(minutes: 10));

ProcessPodcast::dispatchSync($podcast);

RecordDelivery::dispatch($order)->onConnection('deferred');

잡과 DB 트랜잭션

트랜잭션 안에서 잡을 디스패치하면, 부모 트랜잭션이 커밋되기 전에 워커가 잡을 처리해 테이블 변경이 아직 반영되지 않았을 수 있어요. 커넥션 설정의 after_committrue로 하면 열려 있는 트랜잭션이 모두 커밋될 때까지 디스패치를 기다려요. 트랜잭션별로 조절하려면 afterCommit()·beforeCommit()을 체이닝해요.

'redis' => [
    'driver' => 'redis',
    // ...
    'after_commit' => true,
],

잡 체이닝 (Job Chaining)

잡 체이닝은 기본 잡이 성공적으로 실행된 뒤 순서대로 실행될 잡 목록을 지정해요. 목록 중 하나라도 실패하면 나머지 잡은 실행되지 않아요. Bus 파사드의 chain 메서드를 쓰고, 실패 시 동작은 catch로 지정할 수 있어요.

use Illuminate\Support\Facades\Bus;

Bus::chain([
    new ProcessPodcast,
    new OptimizePodcast,
    new ReleasePodcast,
])->catch(function (Throwable $e) {
    // 체인 안의 잡이 실패했음...
})->dispatch();

큐 워커 실행하기 (Running the Queue Worker)

queue:work Artisan 커맨드로 큐 워커를 시작하고 큐에 들어오는 새 잡을 처리해요. 시작하면 수동으로 멈추거나 터미널을 닫을 때까지 계속 돌아요. 큐 워커는 장수(long-lived) 프로세스라 시작 후 코드 변경을 알아차리지 못하므로, 배포 과정에서 재시작해야 해요. 코드 변경 후 재시작 없이 반영하려면 queue:listen을 쓸 수 있지만 queue:work보다 상당히 비효율적이에요.

php artisan queue:work

커넥션과 큐를 지정하려면 queue:work redis --queue=emails처럼 해요. --once는 단일 잡만, --max-jobs=1000은 정해진 수만큼 처리하고 종료, --stop-when-empty는 모든 잡을 처리하고 종료, --max-time=3600은 정해진 초만큼 처리하고 종료해요. 워커를 상시 구동하려면 Supervisor 같은 프로세스 매니저로 관리하는 게 좋아요.

잡 만료와 타임아웃

각 큐 커넥션의 retry_after 옵션은 처리 중인 잡을 몇 초 후에 재시도할지 지정해요. 워커 배포 시 주의할 점이 있는데, --timeout 값은 항상 retry_after보다 최소 몇 초는 짧아야 해요. 그래야 얼어붙은 잡을 처리하던 워커가 잡이 재시도되기 전에 종료돼서, 잡이 두 번 처리되는 일이 없어요.

php artisan queue:work --timeout=60

큐 우선순위와 재시작

큐를 우선순위대로 처리하려면 --queue=high,low처럼 쉼표 구분 목록을 넘겨, high 큐의 잡을 모두 처리한 뒤 low 큐를 처리하게 해요. 배포 때 모든 워커를 우아하게 재시작하려면 queue:restart 커맨드를 써요. 이 커맨드는 워커가 현재 잡을 마친 뒤 정상 종료하도록 지시하므로, Supervisor가 자동으로 재시작하게 돼 있어야 해요. queue:pause·queue:continue 커맨드로 워커를 멈추지 않고 특정 큐의 처리를 일시 중지·재개할 수도 있어요.

php artisan queue:restart

실패한 잡 다루기 (Dealing With Failed Jobs)

잡이 정해진 시도 횟수를 넘기면 failed_jobs 테이블에 들어가요. queue:work--tries로 최대 시도 횟수를, --backoff로 재시도 전 대기 시간을 지정해요. 잡마다 $backoff 프로퍼티나 backoff 메서드로 지정할 수도 있고, 메서드에서 배열을 반환하면 지수 백오프([1, 5, 10])를 만들 수 있어요.

php artisan queue:work redis --tries=3 --backoff=3

잡이 실패했을 때 사용자에게 알림을 보내거나 부분 완료된 작업을 되돌리려면 잡에 failed 메서드를 정의해요. 실패를 일으킨 Throwable 인스턴스가 전달돼요. 최대 시도 횟수에 도달해 실패한 경우엔 MaxAttemptsExceededException, 타임아웃 초과 시엔 TimeoutExceededException이 전달돼요.

public function failed(?Throwable $exception): void
{
    // 사용자에게 실패 알림 보내기 등...
}

실패한 잡 목록은 queue:failed, 재시도는 queue:retry <id>, 삭제는 queue:forget <id> 커맨드로 다뤄요. queue:retry all로 모든 실패 잡을 재시도할 수도 있어요.

php artisan queue:failed
php artisan queue:retry ce7bb17c-cdd8-41f0-a8ec-7b4fef4e5ece
php artisan queue:forget 91401d2c-0784-4f43-824c-34f94a33c24d

더 알아보기 (Learn more)

  • Laravel 공식 문서의 Queues 페이지
  • Horizon 문서에서 Redis 기반 큐 대시보드와 설정 익히기
  • Eloquent 문서에서 잡에 전달하는 모델 직렬화 원리 이해하기
  • Notifications 문서에서 큐와 함께 쓰는 비동기 알림 살펴보기