큐
업데이트됨번역일: 2026년 7월 22일
이 페이지는 원문이 업데이트되어 번역이 갱신되었습니다.
- 원문 수정
- 2026년 7월 22일
- 번역 갱신
- 2026년 7월 22일
큐
- 소개
- Job 생성
- Job 미들웨어
- Job 디스패치
- Job 배치
- 클로저 큐 처리
- 큐 워커 실행
- Supervisor 설정
- 실패한 Job 처리
- 큐에서 Job 삭제
- 큐 모니터링
- 테스트
- Job 이벤트
큐
소개
웹 애플리케이션을 개발하다 보면, 업로드된 CSV 파일을 파싱하거나 저장하는 것처럼 일반적인 웹 요청 처리 시간 안에 끝내기 어려운 작업들을 만나게 됩니다. Laravel은 이런 작업들을 백그라운드에서 처리할 수 있도록 큐잉된 Job을 손쉽게 만들 수 있는 방법을 제공합니다. 시간이 오래 걸리는 작업을 큐로 넘기면, 애플리케이션은 웹 요청에 훨씬 빠르게 응답할 수 있고 사용자 경험도 크게 개선됩니다.
Laravel의 큐는 Amazon SQS, Redis, 관계형 데이터베이스 등 다양한 큐 백엔드를 하나의 통합된 API로 사용할 수 있도록 지원합니다.
큐 관련 설정은 config/queue.php 파일에 저장됩니다. 이 파일에는 프레임워크에서 기본 제공하는 각 큐 드라이버(database, Amazon SQS, Redis, Beanstalkd)의 커넥션 설정이 담겨 있습니다. 개발이나 테스트 목적으로 Job을 즉시 동기 실행하는 sync 드라이버, 그리고 큐에 등록된 Job을 무시하는 null 드라이버도 포함되어 있습니다.
NOTE
Redis 기반 큐를 사용한다면 Laravel Horizon을 함께 살펴보세요. 큐 모니터링과 설정을 위한 직관적인 대시보드를 제공합니다. 자세한 내용은 Horizon 문서를 참고하세요.
커넥션과 큐의 차이
Laravel 큐를 시작하기 전에, 커넥션(connection) 과 큐(queue) 의 차이를 명확히 이해하는 것이 중요합니다.
config/queue.php 파일의 connections 배열은 Amazon SQS, Beanstalkd, Redis 같은 백엔드 큐 서비스와의 연결 정보를 정의합니다. 그리고 하나의 커넥션 안에는 여러 개의 큐가 존재할 수 있습니다. 큐는 Job들이 쌓이는 서로 다른 작업 더미라고 생각하면 됩니다.
각 커넥션 설정에는 queue 속성이 있는데, 이것이 해당 커넥션의 기본 큐입니다. 명시적으로 큐 이름을 지정하지 않고 Job을 디스패치하면, 이 기본 큐로 전달됩니다.
use App\Jobs\ProcessPodcast;
// 기본 커넥션의 기본 큐로 전달됩니다.
ProcessPodcast::dispatch();
// 기본 커넥션의 "emails" 큐로 전달됩니다.
ProcessPodcast::dispatch()->onQueue('emails');단일 큐만으로도 충분한 애플리케이션도 많지만, 여러 큐를 활용하면 Job의 우선순위를 지정하거나 처리 방식을 세분화할 수 있습니다. 예를 들어 high 큐에 중요한 Job을 넣고, 워커를 아래와 같이 실행하면 해당 큐를 우선적으로 처리합니다.
php artisan queue:work --queue=high,default드라이버 별 참고사항 및 사전 준비
Database
database 큐 드라이버를 사용하려면 Job을 저장할 데이터베이스 테이블이 필요합니다. 보통 Laravel의 기본 마이그레이션 파일인 0001_01_01_000002_create_jobs_table.php가 이미 포함되어 있습니다. 만약 해당 마이그레이션 파일이 없다면, 아래 Artisan 명령어로 생성할 수 있습니다.
php artisan make:queue-tablephp artisan migrateRedis
redis 큐 드라이버를 사용하려면 config/database.php에서 Redis 데이터베이스 커넥션을 설정해야 합니다.
WARNING
Redis의 serializer 및 compression 옵션은 redis 큐 드라이버에서 지원되지 않습니다.
Redis 클러스터
Redis 클러스터를 큐 커넥션으로 사용하는 경우, 큐 이름에 반드시 키 해시 태그를 포함해야 합니다. 이는 특정 큐에 속한 모든 Redis 키가 동일한 해시 슬롯에 저장되도록 보장하기 위한 것입니다.
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', '{default}'),
'retry_after' => env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],블로킹(Blocking)
Redis 큐를 사용할 때 block_for 옵션을 설정하면, Job이 들어올 때까지 드라이버가 대기하는 시간(초)을 지정할 수 있습니다. 이 시간 동안 새 Job이 없으면 워커 루프를 다시 돌며 Redis를 재조회합니다.
큐 부하에 따라 이 값을 적절히 조절하면, Redis를 지속적으로 폴링하는 것보다 효율적으로 동작합니다. 예를 들어 5로 설정하면 Job이 생길 때까지 최대 5초 동안 블로킹 대기합니다.
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => 5,
'after_commit' => false,
],WARNING
block_for를 0으로 설정하면 큐 워커가 Job이 들어올 때까지 무한정 대기합니다. 이 경우 다음 Job이 처리될 때까지 SIGTERM 같은 시그널도 처리되지 않으므로 주의하세요.
SQS 오버플로우 스토리지
Amazon SQS는 큐 메시지 페이로드의 최대 크기를 제한합니다. 이 한도를 초과하는 Job을 디스패치해야 하는 경우, 초과 페이로드를 캐시 스토어에 저장하고 SQS에는 포인터만 전송하도록 Laravel을 설정할 수 있습니다. 이 기능을 활성화하려면 SQS 큐 커넥션 설정에 overflow 배열을 추가하세요.
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
'overflow' => [
'enabled' => env('SQS_OVERFLOW_ENABLED', false),
'store' => env('SQS_OVERFLOW_STORE'),
'always' => false,
'delete_after_processing' => true,
'flush_on_clear' => env('SQS_OVERFLOW_FLUSH_ON_CLEAR', false),
],
],오버플로우 스토리지가 활성화되면, Laravel은 1MB 이상의 페이로드를 설정된 캐시 스토어에 저장합니다. always 옵션을 true로 설정하면 크기에 관계없이 모든 SQS 페이로드를 캐시 스토어에 저장합니다. 큐잉된 Job은 처리 시 캐시 스토어에서 페이로드를 가져오므로, 워커가 처리할 때까지 데이터가 유지되는 스토어를 선택해야 합니다. 기본적으로 저장된 페이로드는 Job이 성공적으로 처리되고 SQS에서 삭제된 후 함께 삭제됩니다.
flush_on_clear 옵션이 true이면 queue:clear 명령으로 SQS 큐를 비울 때 오버플로우 캐시 스토어도 함께 비워집니다. 캐시 스토어를 플러시하면 해당 스토어의 모든 항목이 삭제될 수 있으므로, 이 옵션을 사용할 때는 SQS 오버플로우 전용 캐시 스토어를 별도로 설정하는 것을 권장합니다.
기타 드라이버 사전 요구사항
아래 큐 드라이버를 사용하려면 Composer를 통해 해당 패키지를 설치해야 합니다.
- Amazon SQS:
aws/aws-sdk-php ~3.0 - Beanstalkd:
pda/pheanstalk ~5.0 - Redis:
predis/predis ~3.0또는 phpredis PHP 확장 - MongoDB:
mongodb/laravel-mongodb
Job 생성하기
Job 클래스 생성
큐에 올릴 수 있는 Job 클래스는 기본적으로 app/Jobs 디렉토리에 저장됩니다. 해당 디렉토리가 없더라도 make:job Artisan 명령을 실행하면 자동으로 생성됩니다.
php artisan make:job ProcessPodcast생성된 클래스는 Illuminate\Contracts\Queue\ShouldQueue 인터페이스를 구현합니다. 이 인터페이스가 있으면 Laravel이 해당 Job을 큐에 넣어 비동기적으로 처리합니다.
NOTE
Job 스텁은 스텁 커스터마이징을 통해 원하는 형태로 변경할 수 있습니다.
클래스 구조
Job 클래스는 대체로 단순한 구조를 가집니다. 핵심은 큐 워커가 Job을 처리할 때 호출하는 handle 메서드입니다. 아래는 팟캐스트 서비스를 운영한다고 가정한 예시입니다. 업로드된 팟캐스트 파일을 게시 전에 오디오 처리 서비스로 가공하는 Job입니다.
<?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;
/**
* 새 Job 인스턴스를 생성합니다.
*/
public function __construct(
public Podcast $podcast,
) {}
/**
* Job을 실행합니다.
*/
public function handle(AudioProcessor $processor): void
{
// 업로드된 팟캐스트를 처리합니다...
}
}이 예시에서 주목할 점은 Eloquent 모델을 생성자에 직접 전달하고 있다는 것입니다. Queueable 트레이트 덕분에 Eloquent 모델과 로드된 관계(relations)는 Job이 직렬화·역직렬화될 때 올바르게 처리됩니다.
생성자에서 Eloquent 모델을 받으면, 큐에는 모델의 식별자(primary key)만 저장됩니다. Job이 실제로 실행되는 시점에 큐 시스템이 데이터베이스에서 전체 모델과 관계를 다시 조회합니다. 이 방식 덕분에 큐 드라이버에 전달되는 Job 페이로드 크기를 최소화할 수 있습니다.
`handle` 메서드 의존성 주입
handle 메서드는 큐 워커가 Job을 처리할 때 호출됩니다. 이 메서드의 파라미터에 타입힌트를 선언하면 Laravel 서비스 컨테이너가 자동으로 의존성을 주입합니다.
의존성 주입 방식을 직접 제어하고 싶다면, 컨테이너의 bindMethod 메서드를 사용할 수 있습니다. 이 메서드는 Job과 컨테이너를 인자로 받는 콜백을 등록하며, 콜백 안에서 원하는 방식으로 handle을 호출하면 됩니다. 일반적으로 App\Providers\AppServiceProvider의 boot 메서드에서 등록합니다.
use App\Jobs\ProcessPodcast;
use App\Services\AudioProcessor;
use Illuminate\Contracts\Foundation\Application;
$this->app->bindMethod([ProcessPodcast::class, 'handle'], function (ProcessPodcast $job, Application $app) {
return $job->handle($app->make(AudioProcessor::class));
});WARNING
이미지 파일의 원시 바이너리 데이터처럼 바이너리 형태의 데이터를 Job에 전달해야 할 경우, 반드시 base64_encode로 인코딩한 후 전달하세요. 그렇지 않으면 JSON 직렬화가 올바르게 이루어지지 않을 수 있습니다.
큐에 올릴 때의 관계(Relations) 처리
Eloquent 모델에 로드된 관계도 함께 직렬화되므로, 직렬화된 Job 문자열이 상당히 커질 수 있습니다. 또한 Job이 역직렬화될 때 관계는 데이터베이스에서 전체가 다시 조회되며, 큐에 넣기 전에 적용했던 관계 제약 조건(constraint)은 복원되지 않습니다. 따라서 특정 관계의 일부만 사용해야 한다면, Job 내부에서 다시 제약 조건을 적용해야 합니다.
관계가 직렬화되지 않도록 하려면, 속성 값을 설정할 때 모델에서 withoutRelations 메서드를 호출하면 됩니다. 이 메서드는 로드된 관계가 제거된 모델 인스턴스를 반환합니다.
/**
* 새 Job 인스턴스를 생성합니다.
*/
public function __construct(
Podcast $podcast,
) {
$this->podcast = $podcast->withoutRelations();
}특정 관계만 제거하고 나머지는 유지하려면 withoutRelation 메서드를 사용하세요.
$this->podcast = $podcast->withoutRelation('comments');PHP 생성자 프로퍼티 프로모션 문법을 사용하는 경우, WithoutRelations 어트리뷰트를 선언하면 해당 모델의 관계가 직렬화되지 않습니다.
use Illuminate\Queue\Attributes\WithoutRelations;
/**
* 새 Job 인스턴스를 생성합니다.
*/
public function __construct(
#[WithoutRelations]
public Podcast $podcast,
) {}모든 모델의 관계를 일괄적으로 직렬화에서 제외하려면, 개별 프로퍼티마다 어트리뷰트를 붙이는 대신 클래스 전체에 WithoutRelations 어트리뷰트를 적용할 수 있습니다.
<?php
namespace App\Jobs;
use App\Models\DistributionPlatform;
use App\Models\Podcast;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\WithoutRelations;
#[WithoutRelations]
class ProcessPodcast implements ShouldQueue
{
use Queueable;
/**
* 새 Job 인스턴스를 생성합니다.
*/
public function __construct(
public Podcast $podcast,
public DistributionPlatform $platform,
) {}
}단일 모델 대신 Eloquent 모델의 컬렉션이나 배열을 Job에 전달하는 경우, 해당 컬렉션 내 모델의 관계는 역직렬화 후 복원되지 않습니다. 대량의 모델을 다루는 Job에서 과도한 리소스 사용을 방지하기 위한 동작입니다.
고유 Job (Unique Jobs)
WARNING
고유 Job은 원자적 잠금(atomic lock)을 지원하는 캐시 드라이버가 필요합니다. 현재 memcached, redis, dynamodb, database, file, array 캐시 드라이버가 원자적 잠금을 지원합니다.
WARNING
고유 Job 제약 조건은 배치(batch) 내의 Job에는 적용되지 않습니다.
동일한 Job이 큐에 중복으로 쌓이지 않도록, 즉 특정 시점에 단 하나의 인스턴스만 큐에 존재하게 하려면 ShouldBeUnique 인터페이스를 구현하면 됩니다. 별도로 정의해야 할 메서드는 없습니다.
<?php
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUnique;
class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique
{
// ...
}위 예시에서 UpdateSearchIndex Job은 고유하게 유지됩니다. 동일한 Job이 이미 큐에 존재하고 아직 처리가 완료되지 않은 상태라면, 새로운 디스패치는 무시됩니다.
고유성을 판별하는 "키"를 직접 지정하거나, 고유 잠금이 유지되는 시간을 설정하려면 UniqueFor 어트리뷰트와 uniqueId 메서드를 함께 활용하세요.
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Queue\Attributes\UniqueFor;
#[UniqueFor(3600)]
class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique
{
/**
* 상품 인스턴스
*
* @var \App\Models\Product
*/
public $product;
/**
* Job의 고유 ID를 반환합니다.
*/
public function uniqueId(): string
{
return $this->product->id;
}
}위 예시에서 UpdateSearchIndex Job은 상품 ID를 기준으로 고유성이 유지됩니다. 동일한 상품 ID로 새 Job을 디스패치하더라도 기존 Job이 처리를 마칠 때까지 무시됩니다. 또한 기존 Job이 1시간 안에 처리되지 않으면 잠금이 해제되어, 동일한 고유 키로 새 Job을 다시 디스패치할 수 있습니다.
WARNING
여러 웹 서버 또는 컨테이너에서 Job을 디스패치하는 경우, 모든 서버가 동일한 중앙 캐시 서버에 연결되어 있어야 Laravel이 고유 Job 여부를 정확하게 판단할 수 있습니다.
처리 시작 전까지만 고유 잠금 유지하기
기본적으로 고유 Job의 잠금은 처리가 완료되거나 모든 재시도가 실패한 후에 해제됩니다. 그런데 경우에 따라 Job이 처리를 시작하기 직전에 즉시 잠금을 해제해야 할 수도 있습니다. 이 경우 ShouldBeUnique 대신 ShouldBeUniqueUntilProcessing 인터페이스를 구현하세요.
<?php
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
// ...
}고유 Job 잠금 방식
내부적으로 ShouldBeUnique Job이 디스패치되면, Laravel은 uniqueId 키로 잠금(lock) 획득을 시도합니다. 잠금을 이미 보유하고 있다면 Job은 디스패치되지 않습니다. 잠금은 Job 처리가 완료되거나 모든 재시도가 실패한 후 해제됩니다. 기본적으로 Laravel은 기본 캐시 드라이버를 사용하여 잠금을 획득합니다. 다른 드라이버를 사용하려면 uniqueVia 메서드를 정의하세요.
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Support\Facades\Cache;
class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique
{
// ...
/**
* 고유 Job 잠금에 사용할 캐시 드라이버를 반환합니다.
*/
public function uniqueVia(): Repository
{
return Cache::driver('redis');
}
}NOTE
Job의 동시 처리 수만 제한하면 된다면, 고유 Job 대신 WithoutOverlapping 미들웨어를 사용하세요.
디바운스 Job (Debounced Jobs)
짧은 시간 안에 동일한 Job이 여러 번 디스패치될 때 가장 마지막 디스패치만 실행되도록 하려면, DebounceFor 어트리뷰트를 사용하세요. 이는 프론트엔드의 디바운스 개념과 같습니다. 일정 시간 내에 연속으로 호출되면 타이머가 리셋되고, 마지막 호출 이후 지정된 시간이 지난 뒤에야 실제로 실행됩니다.
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\DebounceFor;
#[DebounceFor(30)]
class UpdateSearchIndex implements ShouldQueue
{
use Queueable;
/**
* 새 Job 인스턴스를 생성합니다.
*/
public function __construct(public int $productId)
{
}
/**
* 디바운스 ID를 반환합니다.
*/
public function debounceId(): string
{
return (string) $this->productId;
}
}위 예시에서 동일한 상품에 대해 UpdateSearchIndex Job을 30초 안에 반복 디스패치하면, 가장 마지막으로 디스패치된 Job만 실제로 실행됩니다.
자주 재디스패치되는 Job이 무한정 지연되는 것을 방지하려면, maxWait 인자로 최대 대기 시간을 지정할 수 있습니다.
#[DebounceFor(30, maxWait: 120)]
class UpdateSearchIndex implements ShouldQueue
{
use Queueable;
// ...
}디바운스 추적에 사용할 캐시 스토어는 debounceVia 메서드로 커스터마이징할 수 있습니다.
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Support\Facades\Cache;
public function debounceVia(): Repository
{
return Cache::driver('redis');
}디바운스 중인 Job이 더 새로운 디스패치로 대체되면, Laravel은 Illuminate\Queue\Events\JobDebounced 이벤트를 발생시키고 기존 Job을 큐에서 제거합니다.
WARNING
디바운스 Job과 고유 Job은 함께 사용할 수 없습니다. DebounceFor 어트리뷰트를 사용하는 Job은 ShouldBeUnique를 구현해서는 안 됩니다.
WARNING
여러 웹 서버 또는 컨테이너에서 디바운스 Job을 디스패치하는 경우, 모든 서버가 동일한 중앙 캐시 서버에 연결되어 있어야 합니다.
암호화된 Job (Encrypted Jobs)
Job 데이터의 프라이버시와 무결성을 보장하기 위해 암호화 기능을 활용할 수 있습니다. Job 클래스에 ShouldBeEncrypted 인터페이스를 추가하기만 하면, Laravel이 Job을 큐에 넣기 전에 자동으로 암호화합니다.
<?php
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
class UpdateSearchIndex implements ShouldQueue, ShouldBeEncrypted
{
// ...
}Job 미들웨어
Job 미들웨어를 사용하면 큐 Job 실행 전후에 공통 로직을 추가할 수 있습니다. 덕분에 개별 Job의 handle 메서드가 간결해지고, 반복적인 코드를 줄일 수 있습니다.
예를 들어, Redis의 속도 제한 기능을 이용해 5초마다 하나의 Job만 처리하도록 handle 메서드에 직접 작성하면 다음과 같습니다.
use Illuminate\Support\Facades\Redis;
/**
* Job을 실행합니다.
*/
public function handle(): void
{
Redis::throttle('key')->block(0)->allow(1)->every(5)->then(function () {
info('락 획득...');
// Job 처리...
}, function () {
// 락 획득 실패...
return $this->release(5);
});
}이 코드는 동작하지만, handle 메서드 안에 속도 제한 로직이 섞여 가독성이 떨어집니다. 또한 같은 속도 제한이 필요한 Job이 생길 때마다 이 코드를 복사해야 하는 문제가 있습니다.
이런 경우, 속도 제한 로직을 Job 미들웨어로 분리하면 훨씬 깔끔해집니다.
<?php
namespace App\Jobs\Middleware;
use Closure;
use Illuminate\Support\Facades\Redis;
class RateLimited
{
/**
* 큐 Job을 처리합니다.
*
* @param \Closure(object): void $next
*/
public function handle(object $job, Closure $next): void
{
Redis::throttle('key')
->block(0)->allow(1)->every(5)
->then(function () use ($job, $next) {
// 락 획득...
$next($job);
}, function () use ($job) {
// 락 획득 실패...
$job->release(5);
});
}
}라우트 미들웨어와 구조가 거의 동일합니다. Job 미들웨어는 처리 중인 Job 객체와, 다음 단계로 넘어가기 위해 호출할 클로저($next)를 인자로 받습니다.
make:job-middleware Artisan 명령어로 새 Job 미들웨어 클래스를 생성할 수 있습니다. 생성 후에는 Job 클래스의 middleware 메서드에서 해당 미들웨어를 반환하면 됩니다. make:job 명령어로 생성된 Job에는 이 메서드가 없으므로, 직접 추가해야 합니다.
use App\Jobs\Middleware\RateLimited;
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new RateLimited];
}속도 제한 (Rate Limiting)
위에서 직접 속도 제한 미들웨어를 작성하는 방법을 살펴봤지만, Laravel에는 이미 속도 제한 미들웨어가 내장되어 있습니다. 라우트 속도 제한과 마찬가지로, RateLimiter 파사드의 for 메서드로 Job 속도 제한을 정의합니다.
예를 들어, 일반 사용자는 데이터 백업을 시간당 1회로 제한하고, VIP 고객은 무제한으로 허용하고 싶다면 AppServiceProvider의 boot 메서드에 다음과 같이 정의합니다.
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
/**
* 애플리케이션 서비스를 부트스트랩합니다.
*/
public function boot(): void
{
RateLimiter::for('backups', function (object $job) {
return $job->user->vipCustomer()
? Limit::none()
: Limit::perHour(1)->by($job->user->id);
});
}분 단위 제한이 필요하다면 perMinute 메서드를 사용하고, by 메서드에는 사용자 ID 같은 고유 값을 넘겨 개별 제한을 적용합니다.
return Limit::perMinute(50)->by($job->user->id);속도 제한을 정의했으면, Illuminate\Queue\Middleware\RateLimited 미들웨어를 Job에 적용합니다. 속도 제한을 초과하면 미들웨어가 Job을 큐에 다시 반환(release)하며, 대기 시간은 설정한 제한 기간에 따라 자동으로 결정됩니다.
use Illuminate\Queue\Middleware\RateLimited;
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new RateLimited('backups')];
}NOTE
속도 제한으로 인해 Job이 큐에 다시 반환되더라도, Job의 총 시도 횟수(attempts)는 증가합니다. Job 클래스의 $tries 및 $maxExceptions 속성을 적절히 조정하거나, retryUntil 메서드로 재시도 기한을 지정하는 것을 권장합니다.
releaseAfter 메서드를 사용하면, 큐에 반환된 Job이 몇 초 후에 다시 시도될지를 직접 지정할 수 있습니다.
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('backups'))->releaseAfter(60)];
}속도 제한 시 Job을 큐에 반환하지 않고 그냥 버리려면 dontRelease 메서드를 사용합니다.
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('backups'))->dontRelease()];
}Redis를 활용한 속도 제한
Redis를 사용하는 환경이라면, 기본 속도 제한 미들웨어보다 Redis에 최적화된 Illuminate\Queue\Middleware\RateLimitedWithRedis 미들웨어를 사용하는 것이 더 효율적입니다.
use Illuminate\Queue\Middleware\RateLimitedWithRedis;
public function middleware(): array
{
return [new RateLimitedWithRedis('backups')];
}connection 메서드로 사용할 Redis 연결을 지정할 수도 있습니다.
return [(new RateLimitedWithRedis('backups'))->connection('limiter')];Job 중복 실행 방지
Illuminate\Queue\Middleware\WithoutOverlapping 미들웨어를 사용하면 특정 키를 기준으로 동일한 Job이 동시에 실행되는 것을 막을 수 있습니다. 하나의 리소스를 한 번에 하나의 Job만 수정해야 하는 경우에 유용합니다.
예를 들어, 사용자 신용 점수를 업데이트하는 Job이 있을 때 같은 사용자 ID에 대한 Job이 동시에 실행되지 않도록 하려면 다음과 같이 작성합니다.
use Illuminate\Queue\Middleware\WithoutOverlapping;
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new WithoutOverlapping($this->user->id)];
}NOTE
중복으로 인해 큐에 반환된 Job도 시도 횟수가 증가합니다. $tries 속성을 기본값 1로 두면 중복 Job은 재시도 없이 폐기됩니다. 상황에 맞게 $tries와 $maxExceptions를 조정하세요.
releaseAfter 메서드로 반환된 Job의 재시도 대기 시간을 초 단위로 지정할 수 있습니다.
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping($this->order->id))->releaseAfter(60)];
}중복 Job을 큐에 반환하지 않고 즉시 삭제하려면 dontRelease 메서드를 사용합니다.
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping($this->order->id))->dontRelease()];
}WithoutOverlapping 미들웨어는 내부적으로 Laravel의 원자적 락(atomic lock)을 사용합니다. Job이 예기치 않게 실패하거나 타임아웃이 발생하면 락이 해제되지 않을 수 있습니다. 이런 경우를 대비해 expireAfter 메서드로 락 만료 시간을 명시적으로 지정할 수 있습니다. 아래 예시는 Job 처리 시작 후 3분이 지나면 락을 자동으로 해제합니다.
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping($this->order->id))->expireAfter(180)];
}WARNING
WithoutOverlapping 미들웨어는 원자적 락을 지원하는 캐시 드라이버가 필요합니다. 현재 memcached, redis, dynamodb, database, file, array 캐시 드라이버가 원자적 락을 지원합니다.
Job 클래스 간 락 키 공유
기본적으로 WithoutOverlapping 미들웨어는 동일한 클래스의 Job끼리만 중복을 방지합니다. 서로 다른 두 Job 클래스가 같은 락 키를 사용하더라도, 기본 설정에서는 중복이 방지되지 않습니다. 클래스에 관계없이 동일한 키로 중복을 방지하려면 shared 메서드를 사용합니다.
use Illuminate\Queue\Middleware\WithoutOverlapping;
class ProviderIsDown
{
// ...
public function middleware(): array
{
return [
(new WithoutOverlapping("status:{$this->provider}"))->shared(),
];
}
}
class ProviderIsUp
{
// ...
public function middleware(): array
{
return [
(new WithoutOverlapping("status:{$this->provider}"))->shared(),
];
}
}예외 스로틀링 (Throttling Exceptions)
Illuminate\Queue\Middleware\ThrottlesExceptions 미들웨어를 사용하면 Job에서 예외가 일정 횟수 이상 발생했을 때 다음 시도를 지연시킬 수 있습니다. 불안정한 외부 서비스나 써드파티 API를 호출하는 Job에 특히 유용합니다.
예를 들어, 써드파티 API를 호출하다가 예외가 반복적으로 발생하는 경우를 생각해봅시다. 이때 ThrottlesExceptions 미들웨어를 사용하고, 시간 기반 재시도와 함께 쓰는 것이 일반적입니다.
use DateTime;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new ThrottlesExceptions(10, 5 * 60)];
}
/**
* Job의 타임아웃 시각을 반환합니다.
*/
public function retryUntil(): DateTime
{
return now()->plus(minutes: 30);
}첫 번째 인자는 스로틀링이 적용되기 전까지 허용되는 예외 횟수이고, 두 번째 인자는 스로틀링 적용 후 재시도까지 기다릴 시간(초)입니다. 위 예시에서는 10번 연속 예외가 발생하면 5분 대기 후 재시도하며, 전체 재시도는 30분 이내로 제한됩니다.
예외가 발생했지만 아직 임계 횟수에 도달하지 않은 경우, 기본적으로 Job은 즉시 재시도됩니다. backoff 메서드로 이 경우의 대기 시간(분)을 지정할 수 있습니다.
use Illuminate\Queue\Middleware\ThrottlesExceptions;
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new ThrottlesExceptions(10, 5 * 60))->backoff(5)];
}backoff에 클로저를 전달하면 발생한 예외 종류에 따라 대기 시간을 동적으로 결정할 수 있습니다.
use App\Exceptions\RateLimitedException;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
use Throwable;
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new ThrottlesExceptions(10, 5 * 60))->backoff(
fn (Throwable $throwable) => $throwable instanceof RateLimitedException
? $throwable->retryAfterMinutes()
: 5
)];
}이 미들웨어는 내부적으로 캐시를 활용하며, Job 클래스 이름을 캐시 키로 사용합니다. 동일한 써드파티 서비스를 호출하는 여러 Job이 하나의 스로틀링 버킷을 공유하게 하려면 by 메서드로 키를 직접 지정하면 됩니다.
use Illuminate\Queue\Middleware\ThrottlesExceptions;
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new ThrottlesExceptions(10, 10 * 60))->by('key')];
}기본적으로 모든 예외에 스로틀링이 적용됩니다. when 메서드를 사용하면 특정 조건을 만족하는 예외에만 스로틀링을 적용할 수 있습니다.
use Illuminate\Http\Client\HttpClientException;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new ThrottlesExceptions(10, 10 * 60))->when(
fn (Throwable $throwable) => $throwable instanceof HttpClientException
)];
}when은 조건에 맞지 않는 예외를 큐에 반환하거나 예외를 다시 던지는 반면, deleteWhen은 특정 예외가 발생했을 때 Job을 완전히 삭제합니다.
use App\Exceptions\CustomerDeletedException;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new ThrottlesExceptions(2, 10 * 60))->deleteWhen(CustomerDeletedException::class)];
}스로틀링된 예외를 애플리케이션의 예외 핸들러에 보고하려면 report 메서드를 사용합니다. 클로저를 전달하면 조건에 맞는 예외만 보고할 수 있습니다.
use Illuminate\Http\Client\HttpClientException;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new ThrottlesExceptions(10, 10 * 60))->report(
fn (Throwable $throwable) => $throwable instanceof HttpClientException
)];
}Redis를 활용한 예외 스로틀링
Redis를 사용하는 환경이라면, 기본 예외 스로틀링 미들웨어보다 Redis에 최적화된 Illuminate\Queue\Middleware\ThrottlesExceptionsWithRedis 미들웨어를 사용하는 것이 더 효율적입니다.
use Illuminate\Queue\Middleware\ThrottlesExceptionsWithRedis;
public function middleware(): array
{
return [new ThrottlesExceptionsWithRedis(10, 10 * 60)];
}connection 메서드로 사용할 Redis 연결을 지정할 수 있습니다.
return [(new ThrottlesExceptionsWithRedis(10, 10 * 60))->connection('limiter')];Job 반환 (Releasing Jobs)
Release 미들웨어를 사용하면 Job을 실행하지 않고 큐에 다시 반환할 수 있습니다. Release::when은 조건이 true일 때, Release::unless는 조건이 false일 때 Job을 반환합니다.
use Illuminate\Queue\Middleware\Release;
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*/
public function middleware(): array
{
return [
Release::when($condition, releaseAfter: 60),
];
}NOTE
Job이 반환되면 시도 횟수가 증가합니다. $tries와 $maxExceptions를 적절히 조정하세요.
when과 unless에 클로저를 전달하면 더 복잡한 조건을 처리할 수 있습니다.
use Illuminate\Queue\Middleware\Release;
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*/
public function middleware(): array
{
return [
Release::when(function (): bool {
return ! $this->order->isPaid();
}, releaseAfter: 60),
];
}Job 건너뛰기 (Skipping Jobs)
Skip 미들웨어를 사용하면 Job의 로직을 수정하지 않고도 특정 조건에서 Job을 건너뛰고 삭제할 수 있습니다. Skip::when은 조건이 true일 때, Skip::unless는 조건이 false일 때 Job을 삭제합니다.
use Illuminate\Queue\Middleware\Skip;
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*/
public function middleware(): array
{
return [
Skip::when($condition),
];
}클로저를 전달해 더 복잡한 조건을 처리할 수도 있습니다.
use Illuminate\Queue\Middleware\Skip;
/**
* Job이 통과해야 할 미들웨어 목록을 반환합니다.
*/
public function middleware(): array
{
return [
Skip::when(function (): bool {
return $this->shouldSkip();
}),
];
}큐 - Job 디스패치
Job 디스패치
Job 클래스를 작성했다면, Job 클래스의 dispatch 메서드를 사용해 디스패치할 수 있습니다. dispatch에 전달한 인수는 Job의 생성자로 그대로 전달됩니다.
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessPodcast;
use App\Models\Podcast;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class PodcastController extends Controller
{
/**
* 새로운 팟캐스트를 저장합니다.
*/
public function store(Request $request): RedirectResponse
{
$podcast = Podcast::create(/* ... */);
// ...
ProcessPodcast::dispatch($podcast);
return redirect('/podcasts');
}
}조건에 따라 디스패치해야 한다면 dispatchIf와 dispatchUnless 메서드를 사용하세요.
ProcessPodcast::dispatchIf($accountActive, $podcast);
ProcessPodcast::dispatchUnless($accountSuspended, $podcast);새 Laravel 애플리케이션에서 기본 큐 커넥션은 database로 설정되어 있습니다. .env 파일의 QUEUE_CONNECTION 환경 변수를 수정해 기본 커넥션을 변경할 수 있습니다.
지연 디스패치
디스패치 후 즉시 처리되지 않도록 지연 시간을 지정하려면 delay 메서드를 사용하세요. 예를 들어, 디스패치 후 10분이 지나야 처리되도록 설정하는 방법입니다.
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessPodcast;
use App\Models\Podcast;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class PodcastController extends Controller
{
/**
* 새로운 팟캐스트를 저장합니다.
*/
public function store(Request $request): RedirectResponse
{
$podcast = Podcast::create(/* ... */);
// ...
ProcessPodcast::dispatch($podcast)
->delay(now()->plus(minutes: 10));
return redirect('/podcasts');
}
}Job 클래스에 기본 지연 시간이 설정되어 있어도, 즉시 처리가 필요하다면 withoutDelay 메서드로 지연을 건너뛸 수 있습니다.
ProcessPodcast::dispatch($podcast)->withoutDelay();WARNING
Amazon SQS 큐 서비스의 최대 지연 시간은 15분입니다.
동기 디스패치
Job을 큐에 넣지 않고 즉시 동기적으로 실행하려면 dispatchSync 메서드를 사용하세요. 이 경우 Job은 현재 프로세스 내에서 바로 실행됩니다.
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessPodcast;
use App\Models\Podcast;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class PodcastController extends Controller
{
/**
* 새로운 팟캐스트를 저장합니다.
*/
public function store(Request $request): RedirectResponse
{
$podcast = Podcast::create(/* ... */);
// 팟캐스트 생성...
ProcessPodcast::dispatchSync($podcast);
return redirect('/podcasts');
}
}지연 동기 디스패치
지연 동기 디스패치(Deferred Synchronous Dispatching)를 사용하면 HTTP 응답을 사용자에게 먼저 전송한 뒤, 현재 프로세스 내에서 Job을 처리할 수 있습니다. 큐 워커 없이도 사용자 응답 속도를 유지하면서 후속 처리를 수행할 수 있는 방법입니다. deferred 커넥션으로 Job을 디스패치하면 됩니다.
RecordDelivery::dispatch($order)->onConnection('deferred');deferred 커넥션은 기본 페일오버 큐로도 동작합니다.
유사하게, background 커넥션도 HTTP 응답 전송 후에 Job을 처리합니다. 단, deferred와 달리 별도로 생성된 PHP 프로세스에서 실행되기 때문에, PHP-FPM / 애플리케이션 워커가 즉시 다음 HTTP 요청을 처리할 수 있게 됩니다.
RecordDelivery::dispatch($order)->onConnection('background');벌크 디스패치
여러 개의 독립적인 Job을 한 번에 디스패치해야 하고 배치 추적이나 콜백이 필요 없다면, Bus 파사드의 bulk 메서드를 사용하세요. Laravel은 Job들을 커넥션과 큐 이름별로 그룹화하여 한 번에 큐에 추가합니다.
use App\Jobs\ProcessUser;
use Illuminate\Support\Facades\Bus;
Bus::bulk(
$users->map(fn ($user) => new ProcessUser($user))
);디스패치 전 Job 준비
Job이 큐에 추가되기 전에 상태를 확인하거나 준비 작업이 필요하다면, Illuminate\Contracts\Queue\PreparesForDispatch 인터페이스를 구현하세요. Laravel은 디스패치 전에 prepareForDispatch 메서드를 호출합니다. 이 메서드가 false를 반환하면 Job은 디스패치되지 않습니다.
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\PreparesForDispatch;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Cache;
class SyncPodcasts implements PreparesForDispatch, ShouldQueue
{
use Queueable;
/**
* 새로운 Job 인스턴스를 생성합니다.
*/
public function __construct(
public array $podcastIds,
) {}
/**
* 디스패치 전 Job을 준비합니다.
*/
public function prepareForDispatch(): bool
{
return collect($this->podcastIds)
->reject(fn (int $id) => Cache::has("podcast-syncing:{$id}"))
->isNotEmpty();
}
}Job과 데이터베이스 트랜잭션
데이터베이스 트랜잭션 내에서 Job을 디스패치하는 것 자체는 문제가 없지만, 주의할 점이 있습니다. 트랜잭션 안에서 Job을 디스패치하면, 트랜잭션이 커밋되기 전에 워커가 해당 Job을 집어 들어 실행할 수 있습니다. 이 경우 트랜잭션 내에서 변경하거나 생성한 데이터가 아직 데이터베이스에 반영되지 않아 Job 실행이 실패할 수 있습니다.
이 문제를 해결하는 방법 중 하나는 큐 커넥션 설정에서 after_commit 옵션을 true로 설정하는 것입니다.
'redis' => [
'driver' => 'redis',
// ...
'after_commit' => true,
],after_commit 옵션이 true이면, 열려 있는 모든 트랜잭션이 커밋된 후에야 실제로 Job이 디스패치됩니다. 물론 현재 열려 있는 트랜잭션이 없다면 Job은 즉시 디스패치됩니다.
트랜잭션 도중 예외가 발생해 롤백되면, 그 트랜잭션 내에서 디스패치된 Job들은 모두 폐기됩니다.
NOTE
after_commit 옵션을 true로 설정하면 큐에 등록된 이벤트 리스너, Mailable, 알림, 브로드캐스트 이벤트도 모든 트랜잭션이 커밋된 후 디스패치됩니다.
인라인으로 커밋 후 디스패치 지정
after_commit 설정을 전역으로 바꾸지 않고, 특정 Job만 커밋 후 디스패치되도록 설정하려면 afterCommit 메서드를 체이닝하세요.
use App\Jobs\ProcessPodcast;
ProcessPodcast::dispatch($podcast)->afterCommit();반대로 after_commit이 true로 설정된 상황에서도 특정 Job만 즉시 디스패치하고 싶다면 beforeCommit 메서드를 사용하세요.
ProcessPodcast::dispatch($podcast)->beforeCommit();Job 체이닝
Job 체이닝을 사용하면 여러 Job을 순서대로 실행할 수 있습니다. 앞선 Job이 성공해야만 다음 Job이 실행되며, 하나라도 실패하면 이후 Job은 모두 실행되지 않습니다. Bus 파사드의 chain 메서드를 사용하세요.
use App\Jobs\OptimizePodcast;
use App\Jobs\ProcessPodcast;
use App\Jobs\ReleasePodcast;
use Illuminate\Support\Facades\Bus;
Bus::chain([
new ProcessPodcast,
new OptimizePodcast,
new ReleasePodcast,
])->dispatch();Job 클래스 인스턴스 외에 클로저도 체이닝할 수 있습니다.
Bus::chain([
new ProcessPodcast,
new OptimizePodcast,
function () {
Podcast::update(/* ... */);
},
])->dispatch();WARNING
Job 내에서 $this->delete()로 Job을 삭제해도 체이닝된 다음 Job의 실행은 막을 수 없습니다. 체인 실행이 중단되는 것은 오직 체인 내 Job이 실패했을 때뿐입니다.
체인 커넥션 및 큐 지정
체이닝된 Job들이 사용할 커넥션과 큐를 지정하려면 onConnection과 onQueue 메서드를 사용하세요. Job 자체에 별도 커넥션이나 큐가 지정되어 있지 않으면 여기서 설정한 값이 적용됩니다.
Bus::chain([
new ProcessPodcast,
new OptimizePodcast,
new ReleasePodcast,
])->onConnection('redis')->onQueue('podcasts')->dispatch();체인에 Job 추가
실행 중인 Job 내부에서 체인에 Job을 동적으로 추가해야 할 때는 prependToChain(현재 Job 바로 다음에 삽입)과 appendToChain(체인 끝에 추가) 메서드를 사용하세요.
/**
* Job을 실행합니다.
*/
public function handle(): void
{
// ...
// 현재 Job 직후에 실행되도록 체인 앞에 삽입
$this->prependToChain(new TranscribePodcast);
// 체인 맨 끝에 추가
$this->appendToChain(new TranscribePodcast);
}체인 실패 처리
체인 내 Job이 실패했을 때 실행할 콜백을 catch 메서드로 지정할 수 있습니다. 콜백은 실패 원인이 된 Throwable 인스턴스를 인수로 받습니다.
use Illuminate\Support\Facades\Bus;
use Throwable;
Bus::chain([
new ProcessPodcast,
new OptimizePodcast,
new ReleasePodcast,
])->catch(function (Throwable $e) {
// 체인 내 Job 실패 시 처리...
})->dispatch();WARNING
체인 콜백은 직렬화되어 나중에 Laravel 큐에 의해 실행됩니다. 따라서 체인 콜백 내에서 $this 변수를 사용하면 안 됩니다.
큐 및 커넥션 커스터마이징
특정 큐로 디스패치
Job을 서로 다른 큐로 분산하면 Job을 종류별로 분류하거나, 큐별로 워커 수를 조정해 우선순위를 제어할 수 있습니다. 이는 큐 커넥션이 아닌, 동일 커넥션 내에서 논리적으로 구분된 큐 이름을 지정하는 것입니다. onQueue 메서드를 사용하세요.
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessPodcast;
use App\Models\Podcast;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class PodcastController extends Controller
{
/**
* 새로운 팟캐스트를 저장합니다.
*/
public function store(Request $request): RedirectResponse
{
$podcast = Podcast::create(/* ... */);
// 팟캐스트 생성...
ProcessPodcast::dispatch($podcast)->onQueue('processing');
return redirect('/podcasts');
}
}Job의 생성자에서 onQueue를 호출해 기본 큐를 지정할 수도 있습니다.
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessPodcast implements ShouldQueue
{
use Queueable;
/**
* 새로운 Job 인스턴스를 생성합니다.
*/
public function __construct()
{
$this->onQueue('processing');
}
}특정 커넥션으로 디스패치
애플리케이션이 여러 큐 커넥션을 사용한다면, onConnection 메서드로 Job을 보낼 커넥션을 지정할 수 있습니다.
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessPodcast;
use App\Models\Podcast;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class PodcastController extends Controller
{
/**
* 새로운 팟캐스트를 저장합니다.
*/
public function store(Request $request): RedirectResponse
{
$podcast = Podcast::create(/* ... */);
// 팟캐스트 생성...
ProcessPodcast::dispatch($podcast)->onConnection('sqs');
return redirect('/podcasts');
}
}onConnection과 onQueue를 함께 체이닝해 커넥션과 큐를 동시에 지정할 수도 있습니다.
ProcessPodcast::dispatch($podcast)
->onConnection('sqs')
->onQueue('processing');마찬가지로 Job 생성자에서 onConnection을 호출해 기본 커넥션을 지정할 수 있습니다.
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessPodcast implements ShouldQueue
{
use Queueable;
/**
* 새로운 Job 인스턴스를 생성합니다.
*/
public function __construct()
{
$this->onConnection('sqs');
}
}큐 라우팅
Queue 파사드의 route 메서드를 사용하면 특정 Job 클래스에 대한 기본 커넥션과 큐를 한 곳에서 정의할 수 있습니다. Job을 디스패치할 때마다 매번 onConnection이나 onQueue를 지정하지 않아도 됩니다.
특정 클래스뿐 아니라 인터페이스, 트레이트, 부모 클래스도 route 메서드에 전달할 수 있습니다. 해당 인터페이스를 구현하거나, 트레이트를 사용하거나, 부모 클래스를 상속한 모든 Job에 자동으로 라우팅이 적용됩니다.
일반적으로 서비스 프로바이더의 boot 메서드에서 호출하는 것이 좋습니다.
use App\Concerns\RequiresVideo;
use App\Jobs\ProcessPodcast;
use App\Jobs\ProcessVideo;
use Illuminate\Support\Facades\Queue;
/**
* 애플리케이션 서비스를 부트스트랩합니다.
*/
public function boot(): void
{
Queue::route(ProcessPodcast::class, connection: 'redis', queue: 'podcasts');
Queue::route(RequiresVideo::class, queue: 'video');
}큐 없이 커넥션만 지정하면 해당 커넥션의 기본 큐로 전송됩니다.
Queue::route(ProcessPodcast::class, connection: 'redis');배열을 전달해 여러 Job 클래스를 한 번에 라우팅할 수도 있습니다.
Queue::route([
ProcessPodcast::class => ['podcasts', 'redis'], // 큐 및 커넥션
ProcessVideo::class => 'videos', // 큐만 지정 (기본 커넥션 사용)
]);NOTE
큐 라우팅은 Job 클래스 내부에서 직접 지정한 값이 있으면 그 값이 우선 적용됩니다.
최대 시도 횟수 / 타임아웃 설정
최대 시도 횟수
Laravel 큐 시스템에서 "시도(attempt)"는 핵심 개념입니다. 시도는 단순히 handle 메서드가 실행되는 것만을 의미하지 않습니다. 다음 상황 모두 시도 횟수를 소비합니다.
- 실행 중 처리되지 않은 예외 발생
$this->release()로 큐에 다시 반환WithoutOverlapping이나RateLimited미들웨어가 락을 획득하지 못하고 Job 반환- 타임아웃 초과
handle메서드가 예외 없이 정상 완료
Job이 무한히 재시도되는 것을 방지하기 위해 Laravel은 시도 횟수를 제한하는 여러 방법을 제공합니다.
NOTE
기본적으로 Laravel은 Job을 단 한 번만 시도합니다. WithoutOverlapping이나 RateLimited 미들웨어를 사용하거나, 수동으로 Job을 반환하는 경우에는 tries 값을 늘려야 합니다.
Artisan 커맨드의 --tries 옵션으로 워커 전체에 적용되는 최대 시도 횟수를 지정할 수 있습니다.
php artisan queue:work --tries=3최대 시도 횟수를 초과한 Job은 "실패한 Job"으로 처리됩니다. --tries=0을 지정하면 무한 재시도합니다.
Job 클래스에 Tries 속성(attribute)을 직접 정의하면 커맨드라인 설정보다 우선 적용됩니다.
<?php
namespace App\Jobs;
use Illuminate\Queue\Attributes\Tries;
#[Tries(5)]
class ProcessPodcast implements ShouldQueue
{
// ...
}Job마다 동적으로 시도 횟수를 제어해야 한다면 tries 메서드를 정의하세요.
/**
* Job 최대 시도 횟수를 반환합니다.
*/
public function tries(): int
{
return 5;
}시간 기반 재시도
횟수 대신 일정 시간 동안만 재시도하도록 설정할 수도 있습니다. retryUntil 메서드를 정의해 재시도를 중단할 시점을 DateTime 인스턴스로 반환하세요.
use DateTime;
/**
* Job 재시도를 중단할 시각을 반환합니다.
*/
public function retryUntil(): DateTime
{
return now()->plus(minutes: 10);
}retryUntil과 tries가 함께 정의된 경우, retryUntil이 우선 적용됩니다.
NOTE
큐에 등록된 이벤트 리스너와 큐 알림에도 Tries 속성이나 retryUntil 메서드를 정의할 수 있습니다.
최대 예외 횟수
여러 번 시도는 허용하되, 처리되지 않은 예외가 일정 횟수 이상 발생하면 Job을 실패 처리하고 싶을 때 Tries와 MaxExceptions 속성을 함께 사용하세요. release로 반환되는 것과 달리, 예외 횟수를 별도로 카운트합니다.
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Support\Facades\Redis;
#[Tries(25)]
#[MaxExceptions(3)]
class ProcessPodcast implements ShouldQueue
{
use Queueable;
/**
* Job을 실행합니다.
*/
public function handle(): void
{
Redis::throttle('key')->allow(10)->every(60)->then(function () {
// 락 획득 성공, 팟캐스트 처리...
}, function () {
// 락 획득 실패...
return $this->release(10);
});
}
}이 예시에서 Redis 락을 획득하지 못하면 10초 후 재시도하며, 최대 25번까지 시도합니다. 단, 처리되지 않은 예외가 3번 발생하면 그 즉시 실패 처리됩니다.
특정 예외 발생 시 재시도 중단
특정 예외가 발생했을 때 재시도 없이 즉시 실패 처리하고 싶다면, bootstrap/app.php의 dontRetry 메서드로 해당 예외 타입을 등록하세요.
use App\Exceptions\InvalidPodcastSourceException;
use Illuminate\Foundation\Configuration\Exceptions;
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->dontRetry([
InvalidPodcastSourceException::class,
]);
})더 세밀한 제어가 필요하다면 dontRetryWhen 메서드에 클로저를 전달하세요. 클로저가 true를 반환하면 해당 Job은 즉시 실패 처리됩니다.
use App\Exceptions\PodcastProcessingException;
use Illuminate\Foundation\Configuration\Exceptions;
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->dontRetryWhen(function (PodcastProcessingException $e) {
return $e->reason() === '구독이 만료되었습니다';
});
})타임아웃
Job이 실행에 걸리는 시간이 대략 예측 가능하다면 타임아웃 값을 지정하세요. 기본값은 60초이며, 이를 초과하면 워커가 오류와 함께 종료됩니다. 워커는 보통 프로세스 매니저에 의해 자동으로 재시작됩니다.
php artisan queue:work --timeout=30타임아웃으로 인해 최대 시도 횟수를 모두 소진하면 Job은 실패 처리됩니다.
Job 클래스에 Timeout 속성을 정의하면 커맨드라인 설정보다 우선 적용됩니다.
<?php
namespace App\Jobs;
use Illuminate\Queue\Attributes\Timeout;
#[Timeout(120)]
class ProcessPodcast implements ShouldQueue
{
// ...
}소켓이나 외부 HTTP 요청 같은 IO 블로킹 작업은 PHP 타임아웃 설정을 무시할 수 있습니다. 예를 들어 Guzzle을 사용할 때는 커넥션 타임아웃과 요청 타임아웃을 별도로 지정하는 것이 좋습니다.
WARNING
Job 타임아웃을 사용하려면 PCNTL PHP 확장이 설치되어 있어야 합니다. 또한 타임아웃 값은 반드시 "retry after" 값보다 작아야 합니다. 그렇지 않으면 Job이 실제로 완료되거나 타임아웃되기 전에 재시도될 수 있습니다. queue:work 커맨드를 --once 옵션과 함께 사용할 때는 --timeout 옵션이 적용되지 않습니다.
타임아웃 시 실패 처리
타임아웃 발생 시 Job을 실패 처리하도록 하려면 FailOnTimeout 속성을 사용하세요.
<?php
namespace App\Jobs;
use Illuminate\Queue\Attributes\FailOnTimeout;
#[FailOnTimeout]
class ProcessPodcast implements ShouldQueue
{
// ...
}NOTE
기본적으로 타임아웃이 발생하면 시도 횟수를 하나 소비하고 (재시도 가능한 경우) 다시 큐에 반환됩니다. 그러나 FailOnTimeout을 설정하면 타임아웃 시 재시도 없이 즉시 실패 처리됩니다.
SQS FIFO 큐와 공정 큐
Laravel은 Amazon SQS FIFO(First-In-First-Out) 큐와 공정(fair) 큐를 지원합니다. FIFO 큐는 Job이 전송된 순서대로 처리되며, 메시지 중복 제거를 통해 정확히 한 번만 처리되도록 보장합니다.
FIFO 큐에서는 메시지 그룹 ID를 지정해 병렬 처리 가능한 Job을 구분합니다. 같은 그룹 ID를 가진 Job은 순차 처리되고, 다른 그룹 ID를 가진 Job은 병렬로 처리됩니다.
Job 디스패치 시 onGroup 메서드로 메시지 그룹 ID를 지정하세요.
ProcessOrder::dispatch($order)
->onGroup("customer-{$order->customer_id}");중복 처리를 방지하려면 Job 클래스에 deduplicationId 메서드를 구현하세요.
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessSubscriptionRenewal implements ShouldQueue
{
use Queueable;
// ...
/**
* Job의 중복 제거 ID를 반환합니다.
*/
public function deduplicationId(): string
{
return "renewal-{$this->subscription->id}";
}
}공정 큐
SQS 표준 큐에서 메시지 그룹을 설정하면 공정 큐를 활성화할 수 있습니다. SQS가 그룹을 기반으로 테넌트 / 워크로드 간 공정한 배분을 유지합니다. 별도의 Laravel 설정은 필요 없습니다.
디스패치 시점에 onGroup을 호출하는 대신, Job 클래스에 messageGroup 메서드를 정의할 수도 있습니다.
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessOrder implements ShouldQueue
{
use Queueable;
// ...
/**
* Job의 메시지 그룹을 반환합니다.
*/
public function messageGroup(): string
{
return "customer-{$this->order->customer_id}";
}
}FIFO 리스너, Mailable, 알림
FIFO 큐를 사용할 때는 리스너, Mailable, 알림에도 메시지 그룹을 정의해야 합니다. 또는 이 객체들을 비-FIFO 큐로 디스패치하는 방법도 있습니다.
큐에 등록된 이벤트 리스너에 메시지 그룹을 지정하려면 리스너에 messageGroup 메서드를 정의하세요. 필요에 따라 deduplicationId 메서드도 추가할 수 있습니다.
<?php
namespace App\Listeners;
class SendShipmentNotification
{
// ...
/**
* Job의 메시지 그룹을 반환합니다.
*/
public function messageGroup(): string
{
return 'shipments';
}
/**
* Job의 중복 제거 ID를 반환합니다.
*/
public function deduplicationId(): string
{
return "shipment-notification-{$this->shipment->id}";
}
}FIFO 큐에 메일 메시지를 큐로 전송할 때는 onGroup과 선택적으로 withDeduplicator를 호출하세요.
use App\Mail\InvoicePaid;
use Illuminate\Support\Facades\Mail;
$invoicePaid = (new InvoicePaid($invoice))
->onGroup('invoices')
->withDeduplicator(fn () => 'invoices-'.$invoice->id);
Mail::to($request->user())->send($invoicePaid);FIFO 큐에 알림을 큐로 전송할 때도 동일하게 적용합니다.
use App\Notifications\InvoicePaid;
$invoicePaid = (new InvoicePaid($invoice))
->onGroup('invoices')
->withDeduplicator(fn () => 'invoices-'.$invoice->id);
$user->notify($invoicePaid);큐 페일오버
failover 큐 드라이버는 Job을 큐에 추가할 때 자동 페일오버 기능을 제공합니다. 기본 커넥션에 문제가 생기면 목록의 다음 커넥션으로 자동 전환하여 큐의 고가용성을 보장합니다.
페일오버 큐 커넥션을 설정하려면 config/queue.php에 failover 드라이버를 설정하고, 순서대로 시도할 커넥션 이름 배열을 지정하세요.
'failover' => [
'driver' => 'failover',
'connections' => [
'redis',
'database',
'sync',
],
],페일오버를 기본 큐로 사용하려면 .env 파일을 수정하세요.
QUEUE_CONNECTION=failover그런 다음 페일오버 목록의 각 커넥션마다 최소 하나씩 워커를 실행하세요.
php artisan queue:work redisphp artisan queue:work databaseNOTE
sync, background, deferred 드라이버는 현재 PHP 프로세스 내에서 Job을 처리하므로 별도의 워커가 필요 없습니다.
큐 커넥션 오류로 페일오버가 활성화되면 Illuminate\Queue\Events\QueueFailedOver 이벤트가 발생합니다. 이 이벤트를 리스닝해 오류를 로깅하거나 알림을 보낼 수 있습니다.
NOTE
Laravel Horizon을 사용하는 경우, Horizon은 Redis 큐만 관리합니다. 페일오버 목록에 database가 포함되어 있다면 Horizon과 별도로 php artisan queue:work database 프로세스를 실행해야 합니다.
오류 처리
Job 실행 중 예외가 발생하면 Job은 자동으로 큐에 반환되어 재시도됩니다. 최대 시도 횟수에 도달할 때까지 계속 재시도됩니다. 최대 시도 횟수는 queue:work 커맨드의 --tries 옵션 또는 Job 클래스 자체에서 정의할 수 있습니다.
Job 수동 반환
특정 시점에 Job을 큐로 직접 돌려보내고 싶다면 release 메서드를 호출하세요.
/**
* Job을 실행합니다.
*/
public function handle(): void
{
// ...
$this->release();
}기본적으로 release는 Job을 즉시 처리 가능한 상태로 큐에 반환합니다. 정수 또는 날짜 인스턴스를 전달하면 지정한 시간 후에 처리되도록 지연할 수 있습니다.
$this->release(10);
$this->release(now()->plus(seconds: 10));Job 수동 실패 처리
Job을 직접 실패 처리해야 할 때는 fail 메서드를 호출하세요.
/**
* Job을 실행합니다.
*/
public function handle(): void
{
// ...
$this->fail();
}예외 인스턴스나 오류 메시지 문자열을 전달할 수도 있습니다.
$this->fail($exception);
$this->fail('처리 중 오류가 발생했습니다.');NOTE
실패한 Job 처리에 대한 자세한 내용은 실패한 Job 처리 문서를 참고하세요.
특정 예외 발생 시 Job 즉시 실패 처리
FailOnException Job 미들웨어를 사용하면 특정 예외가 발생했을 때 재시도 없이 즉시 실패 처리할 수 있습니다. 외부 API 오류 같은 일시적인 예외는 재시도하고, 권한 취소 같은 영구적인 예외는 즉시 실패 처리하는 용도로 유용합니다.
<?php
namespace App\Jobs;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\Middleware\FailOnException;
use Illuminate\Support\Facades\Http;
#[Tries(3)]
class SyncChatHistory implements ShouldQueue
{
use Queueable;
/**
* 새로운 Job 인스턴스를 생성합니다.
*/
public function __construct(
public User $user,
) {}
/**
* Job을 실행합니다.
*/
public function handle(): void
{
$this->user->authorize('sync-chat-history');
$response = Http::throw()->get(
"https://chat.laravel.test/?user={$this->user->uuid}"
);
// ...
}
/**
* Job이 통과할 미들웨어를 반환합니다.
*/
public function middleware(): array
{
return [
new FailOnException([AuthorizationException::class])
];
}
}Job 배치(Batching)
Laravel의 Job 배치 기능을 사용하면 여러 Job을 병렬로 실행하고, 모든 Job이 완료되었을 때 특정 동작을 수행할 수 있습니다.
시작하기 전에, Job 배치의 메타 정보(완료율 등)를 저장할 데이터베이스 테이블을 생성해야 합니다. make:queue-batches-table Artisan 명령으로 마이그레이션 파일을 생성한 뒤 실행하세요:
php artisan make:queue-batches-tablephp artisan migrate배치 가능한 Job 정의하기
배치에서 실행될 Job을 정의하려면 일반적인 큐 Job 생성 방법과 동일하게 Job 클래스를 만들되, Illuminate\Bus\Batchable 트레이트를 추가해야 합니다. 이 트레이트가 제공하는 batch() 메서드를 통해 현재 Job이 속한 배치 인스턴스에 접근할 수 있습니다:
<?php
namespace App\Jobs;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ImportCsv implements ShouldQueue
{
use Batchable, Queueable;
/**
* Job 실행
*/
public function handle(): void
{
if ($this->batch()->cancelled()) {
// 배치가 취소된 경우 처리 중단
return;
}
// CSV 파일의 일부 데이터 가져오기 처리...
}
}배치 디스패치하기
배치를 디스패치하려면 Bus 파사드의 batch 메서드를 사용합니다. 배치 기능은 완료 콜백과 함께 사용할 때 특히 유용합니다. then, catch, finally 메서드로 각 상황에 대한 콜백을 정의할 수 있으며, 각 콜백은 호출 시 Illuminate\Bus\Batch 인스턴스를 인자로 받습니다.
여러 큐 워커가 실행 중일 때 배치 내 Job들은 병렬로 처리되므로, Job이 완료되는 순서가 배치에 추가된 순서와 다를 수 있습니다. 순서를 보장해야 한다면 Job 체인과 배치를 함께 활용하세요.
아래 예시는 CSV 파일을 여러 구간으로 나누어 병렬로 가져오는 배치 디스패치 코드입니다:
use App\Jobs\ImportCsv;
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
use Throwable;
$batch = Bus::batch([
new ImportCsv(1, 100),
new ImportCsv(101, 200),
new ImportCsv(201, 300),
new ImportCsv(301, 400),
new ImportCsv(401, 500),
])->before(function (Batch $batch) {
// 배치가 생성되었지만 아직 Job이 실행되지 않은 시점...
})->progress(function (Batch $batch) {
// 개별 Job이 성공적으로 완료될 때마다 호출...
})->then(function (Batch $batch) {
// 모든 Job이 성공적으로 완료되었을 때...
})->catch(function (Batch $batch, Throwable $e) {
// 배치 내 Job 실패가 처음 감지되었을 때...
})->finally(function (Batch $batch) {
// 배치 실행이 완전히 종료되었을 때...
})->dispatch();
return $batch->id;디스패치 후에는 $batch->id로 배치 ID를 확인할 수 있으며, 이 ID로 배치 상태를 조회할 수 있습니다.
WARNING
배치 콜백은 직렬화되어 나중에 큐에서 실행되므로, 콜백 내부에서 $this 변수를 사용하면 안 됩니다. 또한 배치된 Job은 데이터베이스 트랜잭션으로 감싸지므로, 암묵적 커밋을 유발하는 데이터베이스 구문은 Job 내에서 실행하지 않아야 합니다.
배치 이름 지정
Laravel Horizon이나 Laravel Telescope 같은 도구에서 배치를 더 쉽게 식별할 수 있도록 배치에 이름을 붙일 수 있습니다. name 메서드를 체이닝하면 됩니다:
$batch = Bus::batch([
// ...
])->then(function (Batch $batch) {
// 모든 Job이 성공적으로 완료되었을 때...
})->name('CSV 가져오기')->dispatch();배치 커넥션 및 큐 지정
배치 Job에 사용할 커넥션과 큐를 지정하려면 onConnection과 onQueue 메서드를 사용합니다. 배치 내 모든 Job은 동일한 커넥션과 큐에서 실행되어야 합니다:
$batch = Bus::batch([
// ...
])->then(function (Batch $batch) {
// 모든 Job이 성공적으로 완료되었을 때...
})->onConnection('redis')->onQueue('imports')->dispatch();체인과 배치 조합하기
배치 안에 체인 Job을 넣을 수도 있습니다. 배열 안에 배열을 중첩하면 각 내부 배열이 하나의 체인으로 처리됩니다. 아래 예시는 두 개의 Job 체인을 병렬로 실행하고, 두 체인이 모두 완료되면 콜백을 실행합니다:
use App\Jobs\ReleasePodcast;
use App\Jobs\SendPodcastReleaseNotification;
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
Bus::batch([
[
new ReleasePodcast(1),
new SendPodcastReleaseNotification(1),
],
[
new ReleasePodcast(2),
new SendPodcastReleaseNotification(2),
],
])->then(function (Batch $batch) {
// 모든 Job이 성공적으로 완료되었을 때...
})->dispatch();반대로, 체인 안에 배치를 넣을 수도 있습니다. 예를 들어, 여러 팟캐스트를 먼저 일괄 배포한 뒤, 이어서 릴리즈 알림을 일괄 발송하는 흐름을 표현할 수 있습니다:
use App\Jobs\FlushPodcastCache;
use App\Jobs\ReleasePodcast;
use App\Jobs\SendPodcastReleaseNotification;
use Illuminate\Support\Facades\Bus;
Bus::chain([
new FlushPodcastCache,
Bus::batch([
new ReleasePodcast(1),
new ReleasePodcast(2),
]),
Bus::batch([
new SendPodcastReleaseNotification(1),
new SendPodcastReleaseNotification(2),
]),
])->dispatch();배치에 Job 동적으로 추가하기
수천 개의 Job을 배치로 처리해야 할 때, 웹 요청 중에 모든 Job을 한꺼번에 디스패치하면 응답이 지연될 수 있습니다. 이런 경우, 먼저 "로더" Job들만 배치로 디스패치하고, 각 로더 Job이 실행될 때 배치에 실제 Job을 추가하는 패턴을 활용할 수 있습니다:
$batch = Bus::batch([
new LoadImportBatch,
new LoadImportBatch,
new LoadImportBatch,
])->then(function (Batch $batch) {
// 모든 Job이 성공적으로 완료되었을 때...
})->name('연락처 가져오기')->dispatch();이 예시에서 LoadImportBatch Job은 실행 시 배치에 추가 Job을 삽입합니다. Job의 batch() 메서드로 배치 인스턴스에 접근한 뒤 add 메서드를 사용하세요:
use App\Jobs\ImportContacts;
use Illuminate\Support\Collection;
/**
* Job 실행
*/
public function handle(): void
{
if ($this->batch()->cancelled()) {
return;
}
$this->batch()->add(Collection::times(1000, function () {
return new ImportContacts;
}));
}WARNING
같은 배치에 속한 Job에서만 해당 배치에 Job을 추가할 수 있습니다.
배치 상태 확인하기
배치 콜백에 전달되는 Illuminate\Bus\Batch 인스턴스는 배치 상태를 조회하고 제어하는 다양한 프로퍼티와 메서드를 제공합니다:
// 배치의 UUID...
$batch->id;
// 배치 이름 (설정된 경우)...
$batch->name;
// 배치에 등록된 전체 Job 수...
$batch->totalJobs;
// 아직 처리되지 않은 Job 수...
$batch->pendingJobs;
// 실패한 Job 수...
$batch->failedJobs;
// 지금까지 처리된 Job 수...
$batch->processedJobs();
// 배치 완료 퍼센트 (0-100)...
$batch->progress();
// 배치 실행이 완료되었는지 여부...
$batch->finished();
// 배치 실행 취소...
$batch->cancel();
// 배치가 취소되었는지 여부...
$batch->cancelled();라우트에서 배치 반환하기
Illuminate\Bus\Batch 인스턴스는 JSON 직렬화가 가능하므로, 라우트에서 직접 반환하면 배치의 완료 진행률 등의 정보를 JSON으로 응답할 수 있습니다. 이를 활용하면 프론트엔드에서 진행 상황 표시 UI를 쉽게 구현할 수 있습니다.
배치 ID로 배치를 조회하려면 Bus 파사드의 findBatch 메서드를 사용하세요:
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Route;
Route::get('/batch/{batchId}', function (string $batchId) {
return Bus::findBatch($batchId);
});배치 취소하기
특정 조건에서 배치 실행을 중단해야 할 때는 Illuminate\Bus\Batch 인스턴스의 cancel 메서드를 호출합니다:
/**
* Job 실행
*/
public function handle(): void
{
if ($this->user->exceedsImportLimit()) {
$this->batch()->cancel();
return;
}
if ($this->batch()->cancelled()) {
return;
}
}앞선 예시처럼 배치된 Job마다 직접 취소 여부를 확인하는 대신, SkipIfBatchCancelled 미들웨어를 활용하면 더 간결하게 처리할 수 있습니다. 이름 그대로, 이 미들웨어는 배치가 취소된 경우 해당 Job을 자동으로 건너뜁니다:
use Illuminate\Queue\Middleware\SkipIfBatchCancelled;
/**
* Job이 통과할 미들웨어 목록
*/
public function middleware(): array
{
return [new SkipIfBatchCancelled];
}배치 실패 처리
배치 내 Job이 실패하면, 등록된 catch 콜백이 호출됩니다. 이 콜백은 배치 내에서 처음으로 실패한 Job에 대해서만 한 번 호출됩니다.
실패 허용하기
기본적으로 배치 내 Job이 하나라도 실패하면 Laravel은 해당 배치를 자동으로 "취소됨" 상태로 표시합니다. 이 동작을 비활성화하여 일부 Job이 실패하더라도 나머지 Job이 계속 실행되도록 하려면 allowFailures 메서드를 사용하세요:
$batch = Bus::batch([
// ...
])->then(function (Batch $batch) {
// 모든 Job이 성공적으로 완료되었을 때...
})->allowFailures()->dispatch();allowFailures 메서드에 클로저를 전달하면 개별 Job 실패마다 해당 클로저가 실행됩니다:
$batch = Bus::batch([
// ...
])->allowFailures(function (Batch $batch, $exception) {
// 개별 Job 실패 처리...
})->dispatch();실패한 배치 Job 재시도하기
실패한 배치 Job을 일괄 재시도하려면 queue:retry-batch Artisan 명령을 사용합니다. 재시도할 배치의 UUID를 인자로 전달하세요:
php artisan queue:retry-batch 32dbc76c-4f82-4749-b610-a639fe0099b5오래된 배치 레코드 정리(Pruning)
별도로 정리하지 않으면 job_batches 테이블에 레코드가 빠르게 쌓입니다. 이를 방지하기 위해 queue:prune-batches Artisan 명령을 스케줄에 등록하여 매일 실행하세요:
use Illuminate\Support\Facades\Schedule;
Schedule::command('queue:prune-batches')->daily();기본적으로 완료 후 24시간이 지난 배치가 삭제됩니다. hours 옵션으로 보존 기간을 조정할 수 있습니다. 아래 예시는 완료 후 48시간이 지난 배치를 삭제합니다:
use Illuminate\Support\Facades\Schedule;
Schedule::command('queue:prune-batches --hours=48')->daily();실패 후 재시도 없이 종료된 배치처럼 완료되지 못한 배치 레코드가 쌓이는 경우, unfinished 옵션으로 이를 함께 정리할 수 있습니다:
use Illuminate\Support\Facades\Schedule;
Schedule::command('queue:prune-batches --hours=48 --unfinished=72')->daily();마찬가지로, 취소된 배치 레코드도 cancelled 옵션으로 정리할 수 있습니다:
use Illuminate\Support\Facades\Schedule;
Schedule::command('queue:prune-batches --hours=48 --cancelled=72')->daily();DynamoDB에 배치 정보 저장하기
Laravel은 관계형 데이터베이스 대신 DynamoDB에 배치 메타 정보를 저장하는 것도 지원합니다. 단, DynamoDB 테이블은 직접 생성해야 합니다.
테이블 이름은 기본적으로 job_batches를 사용하되, 애플리케이션의 queue 설정 파일에서 queue.batching.table 값을 확인하여 맞춰주세요.
DynamoDB 테이블 구성
job_batches 테이블에는 다음 키 구조가 필요합니다:
- 파티션 키(Partition Key):
application(문자열) —app설정 파일의name값이 저장됩니다. - 정렬 키(Sort Key):
id(문자열)
애플리케이션 이름이 키의 일부이므로, 여러 Laravel 애플리케이션에서 동일한 DynamoDB 테이블을 공유할 수 있습니다.
자동 배치 레코드 정리를 활용하려면 테이블에 ttl 속성도 정의하세요.
DynamoDB 설정
먼저 AWS SDK를 설치합니다:
composer require aws/aws-sdk-php이후 queue.batching.driver 설정 값을 dynamodb로 지정하고, batching 배열에 AWS 인증 정보를 추가합니다. dynamodb 드라이버를 사용할 때는 queue.batching.database 설정이 필요하지 않습니다:
'batching' => [
'driver' => env('QUEUE_BATCHING_DRIVER', 'dynamodb'),
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => 'job_batches',
],DynamoDB에서 배치 레코드 정리하기
DynamoDB를 사용하는 경우, 관계형 데이터베이스용 queue:prune-batches 명령은 동작하지 않습니다. 대신 DynamoDB의 TTL(Time to Live) 기능을 활용하여 오래된 레코드를 자동으로 삭제할 수 있습니다.
테이블에 ttl 속성을 정의했다면, 아래와 같이 TTL 관련 설정을 추가하세요. ttl_attribute는 TTL 값을 저장할 속성 이름이고, ttl은 레코드가 마지막으로 업데이트된 후 삭제될 때까지의 유지 시간(초)입니다:
'batching' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'dynamodb'),
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => 'job_batches',
'ttl_attribute' => 'ttl',
'ttl' => 60 * 60 * 24 * 7, // 7일...
],클로저 큐잉
Job 클래스를 디스패치하는 대신, 클로저(익명 함수)를 직접 큐에 디스패치할 수도 있습니다. 현재 요청 사이클 밖에서 처리해야 하는 간단한 작업에 적합한 방식입니다. 클로저를 큐에 디스패치하면, 전송 중 변조를 방지하기 위해 클로저의 코드 내용이 암호학적으로 서명됩니다.
use App\Models\Podcast;
$podcast = Podcast::find(1);
dispatch(function () use ($podcast) {
$podcast->publish();
});큐 모니터링 대시보드나 queue:work 명령어에서 식별하기 쉽도록 클로저에 이름을 지정하려면 name 메서드를 사용하세요.
dispatch(function () {
// ...
})->name('Publish Podcast');catch 메서드를 사용하면, 큐에 등록된 클로저가 설정된 최대 재시도 횟수를 모두 소진한 뒤에도 실패했을 때 실행할 콜백을 지정할 수 있습니다.
use Throwable;
dispatch(function () use ($podcast) {
$podcast->publish();
})->catch(function (Throwable $e) {
// 클로저 실행이 실패했을 때 처리합니다...
});WARNING
catch 콜백은 직렬화(serialize)되어 나중에 Laravel 큐 워커가 실행합니다. 따라서 catch 콜백 내부에서는 $this 변수를 사용할 수 없습니다.
큐 워커 실행하기
`queue:work` 명령어
Laravel은 큐 워커를 시작하고, 큐에 새로운 Job이 들어올 때마다 이를 처리하는 Artisan 명령어를 제공합니다. queue:work 명령어로 워커를 시작할 수 있습니다. 한 번 시작된 워커는 직접 중지하거나 터미널을 닫기 전까지 계속 실행됩니다.
php artisan queue:workNOTE
queue:work 프로세스를 백그라운드에서 영구적으로 실행하려면 Supervisor 같은 프로세스 관리자를 사용해야 합니다. 이를 통해 워커가 예기치 않게 종료되더라도 자동으로 재시작됩니다.
처리 중인 Job ID, 커넥션 이름, 큐 이름을 출력에 포함하려면 -v 플래그를 사용하세요.
php artisan queue:work -v큐 워커는 장기 실행 프로세스입니다. 시작 시점의 애플리케이션 상태를 메모리에 올려두고 계속 실행되기 때문에, 이후 코드를 수정해도 실행 중인 워커에는 반영되지 않습니다. 따라서 배포 시에는 반드시 큐 워커를 재시작해야 합니다. 또한, 애플리케이션이 생성하거나 변경한 정적 상태(static state)는 Job 간에 자동으로 초기화되지 않는다는 점도 주의하세요.
queue:listen 명령어를 사용하는 방법도 있습니다. 이 명령어는 코드 변경 시 워커를 직접 재시작하지 않아도 되는 편의를 제공하지만, queue:work에 비해 성능이 낮습니다.
php artisan queue:listen여러 큐 워커 실행하기
하나의 큐에 여러 워커를 할당해 Job을 동시에 처리하려면, queue:work 프로세스를 여러 개 시작하면 됩니다. 로컬에서는 터미널 탭을 여러 개 열어 실행할 수 있고, 운영 환경에서는 프로세스 관리자 설정을 활용합니다. Supervisor를 사용하는 경우 numprocs 설정값으로 워커 수를 지정할 수 있습니다.
커넥션과 큐 지정하기
워커가 사용할 큐 커넥션을 직접 지정할 수 있습니다. 지정하는 커넥션 이름은 config/queue.php에 정의된 커넥션 이름과 일치해야 합니다.
php artisan queue:work redis기본적으로 queue:work 명령어는 해당 커넥션의 기본 큐만 처리합니다. 특정 큐만 처리하도록 제한하려면 --queue 옵션을 사용하세요. 예를 들어 redis 커넥션의 emails 큐만 처리하는 워커를 실행하려면 다음과 같이 입력합니다.
php artisan queue:work redis --queue=emails처리할 Job 수 지정하기
--once 옵션을 사용하면 큐에서 Job을 딱 하나만 처리하고 종료합니다.
php artisan queue:work --once--max-jobs 옵션을 사용하면 지정한 수만큼 Job을 처리한 후 워커가 종료됩니다. Supervisor와 함께 사용하면 일정 Job을 처리한 뒤 워커를 자동으로 재시작할 수 있어, 메모리 누적 문제를 예방할 수 있습니다.
php artisan queue:work --max-jobs=1000큐의 모든 Job 처리 후 종료하기
--stop-when-empty 옵션을 사용하면 현재 큐에 쌓인 Job을 모두 처리한 뒤 워커가 gracefully하게 종료됩니다. Docker 컨테이너 환경에서 큐가 비었을 때 컨테이너를 종료하고 싶을 때 유용합니다.
php artisan queue:work --stop-when-empty지정 시간 동안만 Job 처리하기
--max-time 옵션을 사용하면 지정한 시간(초) 동안 Job을 처리한 후 워커가 종료됩니다. Supervisor와 함께 사용하면 일정 시간마다 워커를 재시작해 메모리 누적을 방지할 수 있습니다.
<h1 id="maintenance-mode-queues">1시간 동안 Job을 처리한 후 종료</h1>
php artisan queue:work --max-time=3600워커 대기(슬립) 시간 설정
처리할 Job이 있을 때 워커는 쉬지 않고 Job을 계속 처리합니다. 반면 큐가 비어 있을 때는 --sleep 옵션에 지정한 시간(초)만큼 대기합니다. 대기 중에는 새로운 Job을 처리하지 않습니다.
php artisan queue:work --sleep=3점검 모드와 큐
애플리케이션이 점검 모드(maintenance mode) 상태일 때는 큐에 쌓인 Job이 처리되지 않습니다. 점검 모드가 해제되면 자동으로 정상 처리가 재개됩니다.
점검 모드 중에도 강제로 Job을 처리하려면 --force 옵션을 사용하세요.
php artisan queue:work --force리소스 관리 주의 사항
데몬 큐 워커는 각 Job을 처리하기 전에 프레임워크를 재부팅하지 않습니다. 따라서 무거운 리소스는 각 Job이 완료된 후 반드시 해제해야 합니다. 예를 들어 GD 라이브러리로 이미지 처리를 하는 경우, 처리가 끝나면 imagedestroy를 호출해 메모리를 해제해야 합니다.
큐 우선순위
큐의 처리 순서에 우선순위를 부여하고 싶을 때가 있습니다. 예를 들어 config/queue.php에서 redis 커넥션의 기본 큐를 low로 설정해 두고, 특정 Job은 더 높은 우선순위의 high 큐로 디스패치할 수 있습니다.
dispatch((new Job)->onQueue('high'));low 큐 Job보다 high 큐 Job이 먼저 처리되도록 워커를 시작하려면, 큐 이름을 쉼표로 구분해서 work 명령어에 전달합니다.
php artisan queue:work --queue=high,low큐 워커와 배포
큐 워커는 장기 실행 프로세스이기 때문에, 재시작 없이는 코드 변경 사항을 감지하지 못합니다. 가장 간단한 해결책은 배포 과정에서 워커를 재시작하는 것입니다. queue:restart 명령어를 실행하면 모든 워커에 graceful 재시작 신호가 전달됩니다.
php artisan queue:restart이 명령어를 실행하면 각 워커는 현재 처리 중인 Job을 완료한 후 종료됩니다. 기존에 처리 중이던 Job은 손실되지 않습니다. 워커가 종료된 이후 Supervisor 같은 프로세스 관리자가 자동으로 새 워커를 시작합니다.
NOTE
재시작 신호는 캐시를 통해 전달됩니다. 이 기능을 사용하기 전에 캐시 드라이버가 올바르게 설정되어 있는지 확인하세요.
워커 시그널 처리
큐 워커는 Job 처리 중에 SIGQUIT, SIGTERM, SIGINT 같은 종료 시그널을 수신하면, 현재 Job을 완료한 후 종료됩니다. 그런데 일부 Job, 예를 들어 대용량 데이터를 임포트하는 장시간 Job의 경우, 서버나 컨테이너 오케스트레이터가 프로세스를 중단하기 전에 현재까지의 진행 상황을 저장해야 할 수 있습니다.
이런 경우 Illuminate\Contracts\Queue\Interruptible 인터페이스를 구현하고 interrupted 메서드를 정의하면 됩니다. 워커가 수신한 시그널 번호가 interrupted 메서드에 전달됩니다.
<?php
namespace App\Jobs;
use App\Models\Import;
use Illuminate\Contracts\Queue\Interruptible;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ImportProducts implements ShouldQueue, Interruptible
{
use Queueable;
protected bool $shouldStop = false;
/**
* 새 Job 인스턴스 생성
*/
public function __construct(
public Import $import,
) {}
/**
* Job 실행
*/
public function handle(): void
{
foreach ($this->import->pendingRows() as $row) {
if ($this->shouldStop) {
break;
}
// 상품 데이터 임포트 처리...
}
$this->import->saveProgress();
}
/**
* 큐 워커가 시그널을 수신했을 때 처리
*/
public function interrupted(int $signal): void
{
$this->shouldStop = true;
}
}interrupted 메서드는 Job이 실행 중일 때 워커가 프로세스 시그널을 수신한 경우에만 호출됩니다. 타임아웃이나 Job의 failed 메서드를 대체하는 기능이 아닙니다.
Job 만료와 타임아웃
Job 만료 (`retry_after`)
config/queue.php의 각 큐 커넥션에는 retry_after 옵션이 있습니다. 이 옵션은 처리 중인 Job이 완료 또는 삭제되지 않은 채 지정한 시간(초)이 지나면, 해당 Job을 다시 큐에 넣는 대기 시간을 의미합니다. 예를 들어 retry_after가 90이면, 90초 안에 완료 또는 삭제되지 않은 Job은 큐에 다시 투입됩니다. 일반적으로 retry_after 값은 Job이 정상적으로 완료될 수 있는 최대 예상 시간으로 설정하는 것이 좋습니다.
WARNING
Amazon SQS는 retry_after 옵션을 지원하지 않습니다. SQS는 AWS 콘솔에서 관리하는 Default Visibility Timeout을 기준으로 Job을 재시도합니다.
워커 타임아웃 (`--timeout`)
queue:work 명령어는 --timeout 옵션을 제공합니다. 기본값은 60초입니다. Job이 지정한 타임아웃 시간을 초과하면, 해당 워커는 에러와 함께 종료됩니다. 일반적으로 프로세스 관리자가 워커를 자동으로 재시작합니다.
php artisan queue:work --timeout=60retry_after 설정과 --timeout 옵션은 서로 다른 역할을 하지만, 함께 동작하여 Job 손실을 방지하고 중복 처리를 막습니다.
WARNING
--timeout 값은 retry_after 값보다 항상 몇 초 이상 짧게 설정해야 합니다. 이렇게 해야 멈춰버린 Job을 처리 중인 워커가 재시도 전에 반드시 종료됩니다. --timeout이 retry_after보다 길 경우, 동일한 Job이 두 번 처리될 수 있습니다.
큐 워커 일시 정지 및 재개
워커 프로세스 자체를 종료하지 않고, 새로운 Job 처리만 일시적으로 중단하고 싶을 때가 있습니다. 예를 들어 시스템 점검 중 Job 처리를 잠시 멈추고 싶을 때 유용합니다. Laravel은 이를 위한 queue:pause와 queue:continue 명령어를 제공합니다.
특정 큐를 일시 정지하려면 커넥션 이름과 큐 이름을 함께 지정합니다.
php artisan queue:pause database:default이 예시에서 database는 커넥션 이름이고 default는 큐 이름입니다. 큐가 일시 정지되면, 해당 큐의 Job을 처리 중인 워커는 현재 Job을 마친 후 새로운 Job을 가져오지 않습니다.
일시 정지된 큐를 재개하려면 queue:continue 명령어를 사용합니다.
php artisan queue:continue database:default재개하면 워커는 즉시 새로운 Job 처리를 시작합니다. 일시 정지는 워커 프로세스 자체를 멈추는 것이 아니라, 지정된 큐에서 새 Job을 가져오지 못하도록 막는 것임을 기억하세요.
워커 재시작·일시 정지 시그널 폴링 설정
기본적으로 큐 워커는 매 Job 반복마다 캐시 드라이버에서 재시작 및 일시 정지 시그널을 확인(폴링)합니다. 이 폴링은 queue:restart와 queue:pause 명령어에 응답하기 위해 필요하지만, 약간의 성능 오버헤드가 발생합니다.
이 기능이 필요 없을 때 성능을 최적화하려면, Queue 파사드의 withoutInterruptionPolling 메서드를 호출해 폴링을 전역적으로 비활성화할 수 있습니다. 일반적으로 AppServiceProvider의 boot 메서드에서 설정합니다.
use Illuminate\Support\Facades\Queue;
/**
* 애플리케이션 서비스 부트스트랩
*/
public function boot(): void
{
Queue::withoutInterruptionPolling();
}재시작과 일시 정지 폴링을 개별적으로 비활성화하려면 Illuminate\Queue\Worker 클래스의 정적 프로퍼티를 설정하세요.
use Illuminate\Queue\Worker;
/**
* 애플리케이션 서비스 부트스트랩
*/
public function boot(): void
{
Worker::$restartable = false;
Worker::$pausable = false;
}WARNING
인터럽션 폴링을 비활성화하면, 비활성화된 기능에 해당하는 queue:restart 또는 queue:pause 명령어에 워커가 반응하지 않습니다.
큐
Supervisor 설정
프로덕션 환경에서는 queue:work 프로세스가 항상 실행 중인 상태를 유지할 방법이 필요합니다. queue:work 프로세스는 워커 타임아웃 초과, queue:restart 명령 실행 등 다양한 이유로 종료될 수 있습니다.
따라서 queue:work 프로세스가 종료되는 것을 감지하고 자동으로 재시작해 주는 프로세스 모니터를 설정해야 합니다. 프로세스 모니터는 동시에 실행할 queue:work 프로세스의 수도 지정할 수 있습니다. Linux 환경에서 가장 널리 사용되는 프로세스 모니터는 Supervisor이며, 아래에서 설정 방법을 설명합니다.
Supervisor 설치
Supervisor는 Linux 운영체제용 프로세스 모니터로, queue:work 프로세스가 예기치 않게 종료되면 자동으로 재시작해 줍니다. Ubuntu에서는 다음 명령으로 설치할 수 있습니다.
sudo apt-get install supervisorNOTE
Supervisor를 직접 설정하고 관리하는 것이 부담스럽다면 Laravel Cloud를 고려해 보세요. Laravel Cloud는 큐 워커 실행을 포함한 완전 관리형 플랫폼을 제공합니다.
Supervisor 설정
Supervisor의 설정 파일은 일반적으로 /etc/supervisor/conf.d 디렉터리에 저장됩니다. 이 디렉터리 안에 원하는 만큼 설정 파일을 생성하여 각 프로세스를 어떻게 모니터링할지 지정할 수 있습니다. 예를 들어, queue:work 프로세스를 시작하고 모니터링하는 laravel-worker.conf 파일을 다음과 같이 작성합니다.
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /home/forge/app.com/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=forge
numprocs=8
redirect_stderr=true
stdout_logfile=/home/forge/app.com/worker.log
stopwaitsecs=3600이 예시에서 numprocs 옵션은 Supervisor가 queue:work 프로세스를 8개 실행하고 모두 모니터링하도록 지시합니다. 프로세스가 종료되면 자동으로 재시작됩니다. command 옵션은 실제 프로젝트의 경로와 원하는 큐 연결 및 워커 옵션에 맞게 수정하세요.
WARNING
stopwaitsecs 값은 반드시 가장 오래 실행되는 Job의 처리 시간(초)보다 크게 설정해야 합니다. 그렇지 않으면 Supervisor가 Job 처리가 완료되기 전에 프로세스를 강제 종료할 수 있습니다.
Supervisor 시작
설정 파일을 작성했다면, 다음 명령으로 Supervisor 설정을 갱신하고 프로세스를 시작합니다.
sudo supervisorctl rereadsudo supervisorctl updatesudo supervisorctl start "laravel-worker:*"Supervisor에 대한 더 자세한 내용은 Supervisor 공식 문서를 참고하세요.
실패한 Job 처리하기
큐에 등록된 Job이 항상 성공하리라는 보장은 없습니다. Laravel은 Job의 최대 시도 횟수를 지정하는 편리한 방법을 제공합니다. 비동기 Job이 허용된 시도 횟수를 초과하면, 해당 Job은 failed_jobs 데이터베이스 테이블에 기록됩니다. 반면, 동기적으로 디스패치된 Job이 실패하는 경우에는 이 테이블에 저장되지 않으며, 예외가 즉시 애플리케이션에서 처리됩니다.
새로운 Laravel 애플리케이션에는 failed_jobs 테이블을 생성하는 마이그레이션이 기본적으로 포함되어 있습니다. 만약 해당 마이그레이션이 없다면 다음 명령어로 생성할 수 있습니다:
php artisan make:queue-failed-tablephp artisan migrate큐 워커를 실행할 때 --tries 옵션으로 최대 시도 횟수를 지정할 수 있습니다. 이 옵션을 지정하지 않으면, Job 클래스의 Tries 속성에 정의된 횟수만큼(또는 해당 속성이 없으면 1회) 시도합니다:
php artisan queue:work redis --tries=3--backoff 옵션을 사용하면 예외 발생 후 재시도 전에 대기할 시간(초)을 지정할 수 있습니다. 기본적으로는 대기 없이 즉시 큐에 반환되어 재시도됩니다:
php artisan queue:work redis --tries=3 --backoff=3Job별로 재시도 대기 시간을 다르게 설정하려면 Job 클래스에 Backoff 속성을 사용합니다:
<?php
namespace App\Jobs;
use Illuminate\Queue\Attributes\Backoff;
#[Backoff(3)]
class ProcessPodcast implements ShouldQueue
{
// ...
}더 복잡한 재시도 대기 로직이 필요하다면 Job 클래스에 backoff 메서드를 정의할 수 있습니다:
/**
* 재시도 전 대기할 시간(초)을 계산합니다.
*/
public function backoff(): int
{
return 3;
}배열 값을 사용하면 "지수 백오프(exponential backoff)"를 쉽게 구성할 수 있습니다. 아래 예시에서는 1차 재시도 시 1초, 2차 재시도 시 5초, 3차 이후 재시도 시 10초를 대기합니다:
<?php
namespace App\Jobs;
use Illuminate\Queue\Attributes\Backoff;
#[Backoff([1, 5, 10])]
class ProcessPodcast implements ShouldQueue
{
// ...
}실패한 Job 정리하기
Job이 실패했을 때 사용자에게 알림을 보내거나, 부분적으로 완료된 작업을 되돌려야 할 수 있습니다. 이를 위해 Job 클래스에 failed 메서드를 정의할 수 있습니다. 실패를 유발한 Throwable 인스턴스가 이 메서드에 전달됩니다:
<?php
namespace App\Jobs;
use App\Models\Podcast;
use App\Services\AudioProcessor;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
class ProcessPodcast implements ShouldQueue
{
use Queueable;
/**
* Job 인스턴스를 생성합니다.
*/
public function __construct(
public Podcast $podcast,
) {}
/**
* Job을 실행합니다.
*/
public function handle(AudioProcessor $processor): void
{
// 업로드된 팟캐스트 처리...
}
/**
* Job 실패를 처리합니다.
*/
public function failed(?Throwable $exception): void
{
// 사용자에게 실패 알림 전송 등...
}
}WARNING
failed 메서드가 호출되기 전에 Job의 새 인스턴스가 생성됩니다. 따라서 handle 메서드 내에서 변경된 클래스 프로퍼티 값은 failed 메서드에서 반영되지 않습니다.
Job이 "실패"로 처리되는 경우는 단순히 예외가 발생하는 상황 외에도 여러 가지가 있습니다:
- Job이 타임아웃된 경우
- 실행 중 처리되지 않은 예외가 발생한 경우
- 미들웨어 또는 수동으로 Job이 큐에 반환(release)된 후 허용 시도 횟수를 초과한 경우
실행 중 예외로 인해 마지막 시도가 실패한 경우, 해당 예외가 failed 메서드에 전달됩니다. 최대 시도 횟수 초과로 실패한 경우에는 $exception이 Illuminate\Queue\MaxAttemptsExceededException 인스턴스로 전달되며, 타임아웃으로 인한 실패의 경우에는 Illuminate\Queue\TimeoutExceededException 인스턴스로 전달됩니다.
실패한 Job 재시도하기
failed_jobs 테이블에 기록된 모든 실패한 Job 목록을 확인하려면 queue:failed Artisan 명령어를 사용합니다:
php artisan queue:failed이 명령어는 Job ID, 커넥션, 큐 이름, 실패 시각 등의 정보를 출력합니다. Job ID를 사용하여 특정 Job을 재시도할 수 있습니다. 예를 들어, ID가 ce7bb17c-cdd8-41f0-a8ec-7b4fef4e5ece인 Job을 재시도하려면:
php artisan queue:retry ce7bb17c-cdd8-41f0-a8ec-7b4fef4e5ece여러 ID를 한 번에 전달하는 것도 가능합니다:
php artisan queue:retry ce7bb17c-cdd8-41f0-a8ec-7b4fef4e5ece 91401d2c-0784-4f43-824c-34f94a33c24d특정 큐의 실패한 Job을 모두 재시도하려면:
php artisan queue:retry --queue=name모든 실패한 Job을 한꺼번에 재시도하려면 all을 ID로 전달합니다:
php artisan queue:retry all특정 실패한 Job을 삭제하려면 queue:forget 명령어를 사용합니다:
php artisan queue:forget 91401d2c-0784-4f43-824c-34f94a33c24dNOTE
Horizon을 사용하는 경우, queue:forget 대신 horizon:forget 명령어로 실패한 Job을 삭제해야 합니다.
failed_jobs 테이블의 모든 레코드를 삭제하려면 queue:flush 명령어를 사용합니다:
php artisan queue:flushqueue:flush 명령어는 실패 시각에 관계없이 모든 실패 Job 레코드를 삭제합니다. --hours 옵션을 사용하면 지정한 시간(시) 이전에 실패한 Job만 삭제할 수 있습니다:
php artisan queue:flush --hours=48존재하지 않는 모델 무시하기
Job에 Eloquent 모델을 주입하면, 큐에 등록될 때 모델이 자동으로 직렬화되고 Job이 처리될 때 데이터베이스에서 다시 조회됩니다. 그런데 워커가 Job을 처리하기 전에 해당 모델이 삭제되었다면 ModelNotFoundException이 발생하며 Job이 실패할 수 있습니다.
이런 경우를 간단히 처리하려면 Job 클래스에 DeleteWhenMissingModels 속성을 추가하세요. 이 속성이 있으면 모델이 없을 때 예외를 발생시키지 않고 Job을 자동으로 폐기합니다:
<?php
namespace App\Jobs;
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;
#[DeleteWhenMissingModels]
class ProcessPodcast implements ShouldQueue
{
// ...
}실패한 Job 레코드 정리하기
queue:prune-failed Artisan 명령어로 failed_jobs 테이블의 오래된 레코드를 정리할 수 있습니다:
php artisan queue:prune-failed기본적으로 24시간이 지난 레코드가 모두 삭제됩니다. --hours 옵션을 사용하면 보존 기간을 조정할 수 있습니다. 아래 예시는 48시간 이전에 기록된 실패 Job을 모두 삭제합니다:
php artisan queue:prune-failed --hours=48실패한 Job을 DynamoDB에 저장하기
Laravel은 관계형 데이터베이스 대신 DynamoDB에 실패한 Job을 저장하는 것도 지원합니다. 단, DynamoDB 테이블은 수동으로 생성해야 합니다. 테이블 이름은 일반적으로 failed_jobs를 사용하며, queue 설정 파일의 queue.failed.table 값과 일치해야 합니다.
테이블 구조는 다음과 같이 설정합니다:
- 파티션 키(Partition Key):
application(문자열) - 정렬 키(Sort Key):
uuid(문자열)
application 값은 app 설정 파일의 name 값을 사용합니다. 애플리케이션 이름이 키의 일부이므로, 동일한 DynamoDB 테이블을 여러 Laravel 애플리케이션이 공유할 수 있습니다.
먼저 AWS SDK를 설치합니다:
composer require aws/aws-sdk-php그런 다음, queue.failed.driver 설정 값을 dynamodb로 변경하고, 인증에 필요한 key, secret, region을 설정합니다. dynamodb 드라이버를 사용할 경우 queue.failed.database 설정은 필요하지 않습니다:
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'dynamodb'),
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => 'failed_jobs',
],실패한 Job 저장 비활성화하기
실패한 Job을 저장하지 않고 폐기하도록 설정하려면 queue.failed.driver 값을 null로 지정합니다. 보통 .env 파일의 환경 변수로 설정합니다:
QUEUE_FAILED_DRIVER=null실패한 Job 이벤트
Job이 실패할 때 호출될 이벤트 리스너를 등록하려면 Queue 파사드의 failing 메서드를 사용합니다. 예를 들어, AppServiceProvider의 boot 메서드에서 클로저를 등록할 수 있습니다:
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\ServiceProvider;
use Illuminate\Queue\Events\JobFailed;
class AppServiceProvider extends ServiceProvider
{
/**
* 애플리케이션 서비스를 등록합니다.
*/
public function register(): void
{
// ...
}
/**
* 애플리케이션 서비스를 부트스트랩합니다.
*/
public function boot(): void
{
Queue::failing(function (JobFailed $event) {
// $event->connectionName
// $event->job
// $event->exception
});
}
}큐에서 Job 삭제하기
NOTE
Horizon을 사용하는 경우, queue:clear 명령어 대신 horizon:clear 명령어로 큐의 Job을 삭제해야 합니다.
기본 연결의 기본 큐에 있는 모든 Job을 삭제하려면 queue:clear Artisan 명령어를 사용하세요:
php artisan queue:clear특정 연결과 큐를 지정해서 삭제하고 싶다면 connection 인수와 --queue 옵션을 함께 사용할 수 있습니다:
php artisan queue:clear redis --queue=emailsWARNING
큐에서 Job을 일괄 삭제하는 기능은 SQS, Redis, 데이터베이스 큐 드라이버에서만 지원됩니다. 또한 SQS의 메시지 삭제는 처리에 최대 60초가 소요될 수 있으므로, queue:clear 명령어 실행 후 60초 이내에 SQS 큐로 전송된 Job도 함께 삭제될 수 있습니다.
큐 모니터링
큐에 Job이 갑자기 대량으로 유입되면 처리가 밀려 대기 시간이 길어질 수 있습니다. Laravel은 큐의 Job 수가 지정한 임계값을 초과할 때 알림을 보내는 기능을 제공합니다.
시작하려면 queue:monitor 명령어를 1분마다 실행되도록 스케줄에 등록하세요. 모니터링할 큐 이름과 최대 Job 수 임계값을 다음과 같이 지정합니다.
php artisan queue:monitor redis:default,redis:deployments --max=100단, 이 명령어를 스케줄에 등록하는 것만으로는 알림이 전송되지 않습니다. 명령어 실행 시 Job 수가 임계값을 초과한 큐가 발견되면 Illuminate\Queue\Events\QueueBusy 이벤트가 발생합니다. 애플리케이션의 AppServiceProvider에서 이 이벤트를 수신하여 개발팀에 알림을 보내도록 구현하세요.
use App\Notifications\QueueHasLongWaitTime;
use Illuminate\Queue\Events\QueueBusy;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
/**
* 애플리케이션 서비스를 부트스트랩합니다.
*/
public function boot(): void
{
Event::listen(function (QueueBusy $event) {
Notification::route('mail', 'dev@example.com')
->notify(new QueueHasLongWaitTime(
$event->connectionName,
$event->queue,
$event->size
));
});
}NOTE
$event->connectionName은 큐 커넥션 이름(예: redis), $event->queue는 큐 이름(예: default), $event->size는 현재 대기 중인 Job 수를 나타냅니다. 알림 메시지에 이 값들을 포함하면 어느 큐가 밀려 있는지 빠르게 파악할 수 있습니다.
테스트
Job을 디스패치하는 코드를 테스트할 때, 실제로 Job이 실행되지 않도록 Laravel에 지시하는 것이 좋습니다. Job 자체의 로직은 별도로 인스턴스를 생성해 handle 메서드를 직접 호출하는 방식으로 독립적으로 테스트할 수 있기 때문입니다.
Queue 파사드의 fake 메서드를 사용하면, 큐에 Job이 실제로 추가되는 것을 막고 디스패치 여부만 검증할 수 있습니다:
Pest
<?php
use App\Jobs\AnotherJob;
use App\Jobs\ShipOrder;
use Illuminate\Support\Facades\Queue;
test('주문을 배송할 수 있다', function () {
Queue::fake();
// 주문 배송 처리...
// 아무 Job도 푸시되지 않았는지 검증
Queue::assertNothingPushed();
// 특정 큐에 Job이 푸시됐는지 검증
Queue::assertPushedOn('queue-name', ShipOrder::class);
// Job이 푸시됐는지 검증
Queue::assertPushed(ShipOrder::class);
// Job이 정확히 한 번 푸시됐는지 검증
Queue::assertPushedOnce(ShipOrder::class);
// Job이 두 번 푸시됐는지 검증
Queue::assertPushedTimes(ShipOrder::class, 2);
// 특정 Job이 푸시되지 않았는지 검증
Queue::assertNotPushed(AnotherJob::class);
// 클로저 Job이 푸시됐는지 검증
Queue::assertClosurePushed();
// 클로저 Job이 푸시되지 않았는지 검증
Queue::assertClosureNotPushed();
// 총 푸시된 Job의 수 검증
Queue::assertCount(3);
});PHPUnit
<?php
namespace Tests\Feature;
use App\Jobs\AnotherJob;
use App\Jobs\ShipOrder;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class ExampleTest extends TestCase
{
public function test_orders_can_be_shipped(): void
{
Queue::fake();
// 주문 배송 처리...
// 아무 Job도 푸시되지 않았는지 검증
Queue::assertNothingPushed();
// 특정 큐에 Job이 푸시됐는지 검증
Queue::assertPushedOn('queue-name', ShipOrder::class);
// Job이 푸시됐는지 검증
Queue::assertPushed(ShipOrder::class);
// Job이 정확히 한 번 푸시됐는지 검증
Queue::assertPushedOnce(ShipOrder::class);
// Job이 두 번 푸시됐는지 검증
Queue::assertPushedTimes(ShipOrder::class, 2);
// 특정 Job이 푸시되지 않았는지 검증
Queue::assertNotPushed(AnotherJob::class);
// 클로저 Job이 푸시됐는지 검증
Queue::assertClosurePushed();
// 클로저 Job이 푸시되지 않았는지 검증
Queue::assertClosureNotPushed();
// 총 푸시된 Job의 수 검증
Queue::assertCount(3);
}
}assertPushed, assertNotPushed, assertClosurePushed, assertClosureNotPushed 메서드에 클로저를 전달해, 특정 조건을 만족하는 Job이 푸시됐는지 검증할 수 있습니다. 조건을 만족하는 Job이 하나 이상 존재하면 검증이 성공합니다:
use Illuminate\Queue\CallQueuedClosure;
Queue::assertPushed(function (ShipOrder $job) use ($order) {
return $job->order->id === $order->id;
});
Queue::assertClosurePushed(function (CallQueuedClosure $job) {
return $job->name === 'validate-order';
});일부 Job만 Fake 처리하기
특정 Job만 Fake 처리하고 나머지 Job은 실제로 실행되도록 하려면, fake 메서드에 Fake 처리할 Job의 클래스 이름을 배열로 전달하면 됩니다:
Pest
test('주문을 배송할 수 있다', function () {
Queue::fake([
ShipOrder::class,
]);
// 주문 배송 처리...
// Job이 두 번 푸시됐는지 검증
Queue::assertPushedTimes(ShipOrder::class, 2);
});PHPUnit
public function test_orders_can_be_shipped(): void
{
Queue::fake([
ShipOrder::class,
]);
// 주문 배송 처리...
// Job이 두 번 푸시됐는지 검증
Queue::assertPushedTimes(ShipOrder::class, 2);
}반대로, 특정 Job을 제외한 나머지를 모두 Fake 처리하려면 except 메서드를 사용합니다:
Queue::fake()->except([
ShipOrder::class,
]);Job 체인 테스트
Job 체인을 테스트할 때는 Queue 파사드 대신 Bus 파사드의 Fake 기능을 활용해야 합니다. Bus 파사드의 assertChained 메서드를 사용하면 Job 체인이 올바르게 디스패치됐는지 검증할 수 있습니다. 첫 번째 인자로 체인을 구성하는 Job 배열을 전달합니다:
use App\Jobs\RecordShipment;
use App\Jobs\ShipOrder;
use App\Jobs\UpdateInventory;
use Illuminate\Support\Facades\Bus;
Bus::fake();
// ...
Bus::assertChained([
ShipOrder::class,
RecordShipment::class,
UpdateInventory::class
]);위 예시처럼 클래스 이름 배열을 사용할 수도 있고, 실제 Job 인스턴스 배열을 전달할 수도 있습니다. 인스턴스를 사용하면 Laravel은 클래스 타입뿐만 아니라 프로퍼티 값까지 일치하는지 함께 검증합니다:
Bus::assertChained([
new ShipOrder,
new RecordShipment,
new UpdateInventory,
]);체인 없이 단독으로 디스패치된 Job을 검증하려면 assertDispatchedWithoutChain 메서드를 사용합니다:
Bus::assertDispatchedWithoutChain(ShipOrder::class);체인 변경 테스트
체인 내의 Job이 실행 중에 체인에 Job을 추가하거나 앞에 삽입하는 경우, Job 인스턴스의 assertHasChain 메서드로 남은 체인이 예상과 일치하는지 검증할 수 있습니다:
$job = new ProcessPodcast;
$job->handle();
$job->assertHasChain([
new TranscribePodcast,
new OptimizePodcast,
new ReleasePodcast,
]);남은 체인이 비어 있는지 확인하려면 assertDoesntHaveChain 메서드를 사용합니다:
$job->assertDoesntHaveChain();체인 내 배치 테스트
Job 체인이 배치를 포함하는 경우, 체인 검증 배열 안에 Bus::chainedBatch 정의를 삽입해 배치의 내용이 예상과 일치하는지 검증할 수 있습니다:
use App\Jobs\ShipOrder;
use App\Jobs\UpdateInventory;
use Illuminate\Bus\PendingBatch;
use Illuminate\Support\Facades\Bus;
Bus::assertChained([
new ShipOrder,
Bus::chainedBatch(function (PendingBatch $batch) {
return $batch->jobs->count() === 3;
}),
new UpdateInventory,
]);Job 배치 테스트
Bus 파사드의 assertBatched 메서드를 사용하면 Job 배치가 디스패치됐는지 검증할 수 있습니다. 전달하는 클로저는 Illuminate\Bus\PendingBatch 인스턴스를 받으며, 이를 통해 배치 내의 Job을 검사할 수 있습니다:
use Illuminate\Bus\PendingBatch;
use Illuminate\Support\Facades\Bus;
Bus::fake();
// ...
Bus::assertBatched(function (PendingBatch $batch) {
return $batch->name == 'Import CSV' &&
$batch->jobs->count() === 10;
});hasJobs 메서드를 사용하면 배치에 특정 Job이 포함되어 있는지 확인할 수 있습니다. Job 인스턴스 배열, 클래스 이름 배열, 또는 클로저 배열을 인자로 전달할 수 있습니다:
Bus::assertBatched(function (PendingBatch $batch) {
return $batch->hasJobs([
new ProcessCsvRow(row: 1),
new ProcessCsvRow(row: 2),
new ProcessCsvRow(row: 3),
]);
});클로저를 사용하는 경우, 클로저는 Job 인스턴스를 인자로 받으며 타입 힌트를 통해 대상 Job 타입이 자동으로 추론됩니다:
Bus::assertBatched(function (PendingBatch $batch) {
return $batch->hasJobs([
fn (ProcessCsvRow $job) => $job->row === 1,
fn (ProcessCsvRow $job) => $job->row === 2,
fn (ProcessCsvRow $job) => $job->row === 3,
]);
});디스패치된 배치의 수를 검증하려면 assertBatchCount 메서드를 사용합니다:
Bus::assertBatchCount(3);배치가 전혀 디스패치되지 않았는지 확인하려면 assertNothingBatched를 사용합니다:
Bus::assertNothingBatched();Job과 배치 간 상호작용 테스트
개별 Job이 자신이 속한 배치와 어떻게 상호작용하는지 테스트해야 할 때도 있습니다. 예를 들어, Job이 배치의 이후 처리를 취소했는지 확인하는 경우가 그에 해당합니다. 이럴 때는 withFakeBatch 메서드로 Job에 가짜 배치를 할당합니다. withFakeBatch 메서드는 Job 인스턴스와 가짜 배치를 담은 튜플을 반환합니다:
[$job, $batch] = (new ShipOrder)->withFakeBatch();
$job->handle();
$this->assertTrue($batch->cancelled());
$this->assertEmpty($batch->added);Job과 큐 간 상호작용 테스트
큐에 등록된 Job이 스스로 큐로 다시 반환하거나 자신을 삭제하는 동작을 테스트해야 할 때가 있습니다. 이런 큐 상호작용은 Job 인스턴스를 생성한 뒤 withFakeQueueInteractions 메서드를 호출해 테스트할 수 있습니다.
큐 상호작용을 Fake 처리한 후 handle 메서드를 호출하면, 다양한 검증 메서드를 통해 Job의 큐 동작을 확인할 수 있습니다:
use App\Exceptions\CorruptedAudioException;
use App\Jobs\ProcessPodcast;
$job = (new ProcessPodcast)->withFakeQueueInteractions();
$job->handle();
// 지정된 지연 시간(초) 후 큐로 반환됐는지 검증
$job->assertReleased(delay: 30);
// Job이 삭제됐는지 검증
$job->assertDeleted();
// Job이 삭제되지 않았는지 검증
$job->assertNotDeleted();
// Job이 실패 처리됐는지 검증
$job->assertFailed();
// 특정 예외와 함께 실패 처리됐는지 검증
$job->assertFailedWith(CorruptedAudioException::class);
// Job이 실패하지 않았는지 검증
$job->assertNotFailed();Job 이벤트
Queue 파사드의 before와 after 메서드를 사용하면, 큐에 등록된 Job이 처리되기 전·후에 실행될 콜백을 등록할 수 있습니다. 이를 활용하면 추가 로깅을 남기거나 대시보드용 통계를 집계하는 데 유용합니다. 일반적으로 이 메서드들은 서비스 프로바이더의 boot 메서드에서 호출합니다. 아래는 Laravel에 기본 포함된 AppServiceProvider를 활용한 예시입니다.
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\ServiceProvider;
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobProcessing;
class AppServiceProvider extends ServiceProvider
{
/**
* 애플리케이션 서비스를 등록합니다.
*/
public function register(): void
{
// ...
}
/**
* 애플리케이션 서비스를 부트스트랩합니다.
*/
public function boot(): void
{
Queue::before(function (JobProcessing $event) {
// $event->connectionName (큐 커넥션 이름)
// $event->job (Job 인스턴스)
// $event->job->payload() (Job 페이로드)
});
Queue::after(function (JobProcessed $event) {
// $event->connectionName
// $event->job
// $event->job->payload()
});
}
}Queue 파사드의 looping 메서드를 사용하면, 워커가 큐에서 Job을 가져오기 전에 실행될 콜백을 등록할 수 있습니다. 예를 들어, 이전에 실패한 Job이 미처 닫지 못한 데이터베이스 트랜잭션을 롤백하는 처리를 여기서 수행할 수 있습니다.
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
Queue::looping(function () {
while (DB::transactionLevel() > 0) {
DB::rollBack();
}
});또한 큐 워커가 큐에서 처리할 Job을 찾지 못한 경우, Laravel은 Illuminate\Queue\Events\WorkerIdle 이벤트를 디스패치합니다. 이 이벤트를 리스닝하면 워커가 유휴 상태일 때 원하는 작업을 수행할 수 있습니다.
use Illuminate\Queue\Events\WorkerIdle;
use Illuminate\Support\Facades\Event;
Event::listen(function (WorkerIdle $event) {
// $event->connectionName (큐 커넥션 이름)
// $event->queue (큐 이름)
// $event->workerOptions (워커 옵션)
});