번역일: 2026년 6월 27일

소개

웹 애플리케이션을 개발하다 보면, 업로드된 CSV 파일을 파싱하거나 대용량 이메일을 발송하는 것처럼 HTTP 요청 중에 처리하기에는 너무 오래 걸리는 작업이 생깁니다. 이런 경우 Laravel은 백그라운드에서 처리할 수 있는 큐(Queue) 시스템을 제공합니다. 시간이 오래 걸리는 작업을 큐에 넣어두면 사용자에게는 빠른 응답을 돌려주고, 실제 처리는 워커가 백그라운드에서 담당합니다.

Laravel의 큐 API는 Amazon SQS, Redis, 관계형 데이터베이스 등 다양한 백엔드를 동일한 인터페이스로 지원하므로, 필요에 따라 드라이버를 자유롭게 교체할 수 있습니다.

큐 관련 설정은 config/queue.php 파일에서 관리합니다. 이 파일에는 데이터베이스, Amazon SQS, Redis, Beanstalkd 등 각 드라이버별 커넥션 설정이 포함되어 있으며, 큐에 넣은 Job을 즉시 동기적으로 실행하는 sync 드라이버와 Job을 완전히 버리는 null 드라이버도 제공합니다.

NOTE

Laravel은 이제 Redis 기반 큐를 위한 아름다운 대시보드와 설정 시스템인 Horizon을 제공합니다. 자세한 내용은 Horizon 문서를 참고하세요.

커넥션 vs. 큐

처음 Laravel 큐를 접하면 커넥션(connection)큐(queue) 의 차이가 헷갈릴 수 있습니다.

  • 커넥션: 큐 백엔드 서비스와의 연결 정보입니다. config/queue.phpconnections 항목에 정의하며, 예를 들어 redis, sqs 등이 해당됩니다.
  • : 하나의 커넥션 안에서 Job을 분류하는 통로(채널)입니다. 하나의 커넥션에 default, emails, high 같은 여러 큐를 운영할 수 있습니다.

config/queue.php의 각 커넥션 설정에는 queue 항목이 있습니다. 이 값은 해당 커넥션으로 디스패치되는 Job의 기본 큐를 지정합니다. 즉, 큐를 명시하지 않고 Job을 보내면 커넥션 설정의 queue 값이 사용됩니다.

use App\Jobs\ProcessPodcast; // 기본 커넥션의 기본 큐로 디스패치됩니다. ProcessPodcast::dispatch(); // 기본 커넥션의 "emails" 큐로 디스패치됩니다. ProcessPodcast::dispatch()->onQueue('emails');

작업 성격에 따라 여러 큐를 나눠 운영하면 우선순위 관리가 훨씬 쉬워집니다. 예를 들어, 결제 처리는 high 큐로, 통계 집계는 low 큐로 보내고 워커를 각각 다르게 설정할 수 있습니다.

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

드라이버 안내 및 사전 요구사항

데이터베이스

database 드라이버를 사용하려면 Job을 저장할 데이터베이스 테이블이 필요합니다. 일반적으로 Laravel의 기본 마이그레이션에 포함되어 있지만, 없다면 make:queue-table Artisan 명령으로 마이그레이션을 생성할 수 있습니다.

php artisan make:queue-tablephp artisan migrate

Redis

redis 드라이버를 사용하려면 config/database.php에 Redis 커넥션이 설정되어 있어야 합니다.

WARNING

serializercompression 옵션은 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, ],

블로킹(block_for)

block_for 옵션은 워커가 새 Job을 기다릴 때 Redis에 블로킹 방식으로 대기하는 시간(초)을 지정합니다. 빈 큐를 반복적으로 폴링하는 것보다 훨씬 효율적입니다. 예를 들어 5로 설정하면 최대 5초 동안 Job이 들어오기를 기다립니다.

'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_for0으로 설정하면 Job이 들어올 때까지 워커가 무한정 블로킹됩니다. 이 경우 Job이 처리되기 전에는 SIGTERM 같은 신호도 처리되지 않으므로 주의하세요.

기타 드라이버 사전 요구사항

아래 드라이버를 사용할 때는 해당 Composer 패키지가 필요합니다.

  • Amazon SQS: aws/aws-sdk-php ~3.0
  • Beanstalkd: pda/pheanstalk ~5.0
  • Redis: predis/predis ~2.0 또는 phpredis PHP 확장

소개

웹 애플리케이션을 개발하다 보면, 업로드된 CSV 파일 파싱 및 저장처럼 일반적인 웹 요청 안에서 처리하기엔 너무 오래 걸리는 작업을 만나게 됩니다. Laravel은 이런 작업을 백그라운드에서 처리할 수 있도록 큐(Queue)에 Job을 등록하는 기능을 제공합니다. 무거운 작업을 큐로 분리하면 웹 요청에는 즉시 응답할 수 있어 사용자 경험이 크게 향상됩니다.

Laravel 큐는 Amazon SQS, Redis, 관계형 데이터베이스 등 다양한 백엔드를 단일 API로 추상화하여 제공합니다.

큐 설정은 config/queue.php 파일에 저장되어 있습니다. 이 파일에는 프레임워크에서 지원하는 각 큐 드라이버(database, Amazon SQS, Redis, Beanstalkd)의 커넥션 설정이 포함되어 있습니다. 로컬 개발 환경에서는 Job을 즉시 실행하는 동기(synchronous) 드라이버를 사용할 수 있으며, 큐에 등록된 Job을 모두 버리는 null 드라이버도 제공됩니다.

NOTE

Laravel은 Redis 기반 큐를 위한 대시보드 및 설정 시스템인 Horizon을 공식 제공합니다. 자세한 내용은 Horizon 문서를 참고하세요.

커넥션과 큐의 차이

Laravel 큐를 본격적으로 사용하기 전에, 커넥션(connection)큐(queue) 의 차이를 이해하는 것이 중요합니다.

config/queue.php 파일의 connections 배열은 Amazon SQS, Beanstalkd, Redis 같은 외부 큐 서비스에 대한 연결 정보를 정의합니다. 하나의 커넥션 안에는 여러 개의 가 존재할 수 있으며, 각 큐는 Job이 쌓이는 별도의 채널이라고 생각하면 됩니다.

각 커넥션 설정에는 queue 속성이 있습니다. 이 속성은 Job을 특정 큐를 지정하지 않고 dispatch할 때 사용할 기본 큐를 의미합니다.

use App\Jobs\ProcessPodcast; // 기본 커넥션의 기본 큐로 전송됩니다. ProcessPodcast::dispatch(); // 기본 커넥션의 "emails" 큐로 전송됩니다. ProcessPodcast::dispatch()->onQueue('emails');

단순한 애플리케이션이라면 큐를 하나만 운영해도 충분하지만, 여러 큐를 사용하면 Job의 처리 우선순위를 세밀하게 제어할 수 있습니다. 예를 들어 high 큐에 중요한 Job을 넣고, 아래와 같이 워커를 실행하면 high 큐를 먼저 처리합니다.

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

드라이버 참고사항 및 사전 요건

Database

database 큐 드라이버를 사용하려면 Job을 저장할 데이터베이스 테이블이 필요합니다. 아래 Artisan 명령어로 마이그레이션 파일을 생성한 뒤 실행하세요.

php artisan queue:tablephp artisan migrate

마지막으로 .env 파일의 QUEUE_CONNECTION 값을 변경합니다.

QUEUE_CONNECTION=database

Redis

redis 큐 드라이버를 사용하려면 config/database.php에 Redis 커넥션이 설정되어 있어야 합니다.

WARNING

Redis의 serializercompression 옵션은 redis 큐 드라이버에서 지원되지 않습니다.

Redis 클러스터

Redis 클러스터를 큐 커넥션으로 사용하는 경우, 큐 이름에 반드시 키 해시 태그를 포함해야 합니다. 이렇게 해야 특정 큐에 관련된 모든 Redis 키가 동일한 해시 슬롯에 배치됩니다.

'redis' => [ 'driver' => 'redis', 'connection' => 'default', 'queue' => '{default}', 'retry_after' => 90, ],

블로킹(Blocking)

Redis 큐를 사용할 때 block_for 옵션을 설정하면, 드라이버가 새 Job이 들어올 때까지 지정한 시간(초) 동안 대기한 후 워커 루프를 다시 실행합니다. Redis를 지속적으로 폴링하는 것보다 효율적으로 동작할 수 있습니다.

'redis' => [ 'driver' => 'redis', 'connection' => 'default', 'queue' => 'default', 'retry_after' => 90, 'block_for' => 5, // Job이 생길 때까지 최대 5초 대기 ],

WARNING

block_for0으로 설정하면 워커가 Job이 들어올 때까지 무한정 대기합니다. 이 경우 SIGTERM 같은 시그널이 다음 Job이 처리될 때까지 처리되지 않으니 주의하세요.

그 외 드라이버 사전 요건

아래 큐 드라이버를 사용하려면 Composer로 해당 패키지를 설치해야 합니다.

  • Amazon SQS: aws/aws-sdk-php ~3.0
  • Beanstalkd: pda/pheanstalk ~4.0
  • Redis: predis/predis ~1.0 또는 phpredis PHP 확장 모듈

Job 생성

Job 클래스 생성

큐에 넣을 수 있는 Job 클래스는 기본적으로 app/Jobs 디렉터리에 저장됩니다. 해당 디렉터리가 없더라도 make:job Artisan 명령을 실행하면 자동으로 생성됩니다:

php artisan make:job ProcessPodcast

생성된 클래스는 Illuminate\Contracts\Queue\ShouldQueue 인터페이스를 구현하며, 이를 통해 Laravel은 해당 Job을 큐에 넣어 비동기로 실행해야 함을 인식합니다.

NOTE

Job 스텁은 스텁 퍼블리싱을 통해 커스터마이징할 수 있습니다.

클래스 구조

Job 클래스는 구조가 단순합니다. 핵심은 큐 워커가 Job을 처리할 때 호출하는 handle 메서드입니다. 예를 들어, 팟캐스트 서비스를 운영하면서 업로드된 오디오 파일을 게시 전에 가공해야 하는 상황을 가정해 보겠습니다:

<?php namespace App\Jobs; use App\Models\Podcast; use App\Services\AudioProcessor; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; class ProcessPodcast implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; /** * 새 Job 인스턴스를 생성합니다. */ public function __construct( public Podcast $podcast, ) {} /** * Job을 실행합니다. */ public function handle(AudioProcessor $processor): void { // 업로드된 팟캐스트 처리... } }

이 예제에서 주목할 점은 Eloquent 모델을 Job 생성자에 직접 전달할 수 있다는 것입니다. SerializesModels 트레이트 덕분에 Eloquent 모델과 로드된 연관 관계(relationships)가 Job 처리 시 적절하게 직렬화·역직렬화됩니다.

큐에 넣을 때는 모델의 식별자(primary key)만 직렬화되어 저장됩니다. 실제 Job이 실행될 때 큐 시스템이 데이터베이스에서 모델 인스턴스와 연관 관계를 다시 조회합니다. 이 방식 덕분에 큐 드라이버로 전송되는 Job 페이로드 크기를 최소화할 수 있습니다.

`handle` 메서드 의존성 주입

handle 메서드는 큐 워커가 Job을 처리할 때 호출됩니다. handle 메서드에서 타입 힌트를 사용하면 Laravel 서비스 컨테이너가 해당 의존성을 자동으로 주입해 줍니다.

컨테이너의 의존성 주입 방식을 완전히 직접 제어하고 싶다면, 컨테이너의 bindMethod 메서드를 사용할 수 있습니다. 이 메서드는 Job과 컨테이너를 인수로 받는 콜백을 등록하며, 콜백 내부에서 원하는 방식으로 handle 메서드를 호출할 수 있습니다. 보통 App\Providers\AppServiceProviderboot 메서드에서 호출합니다:

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

이미지 바이너리 데이터 같은 원시(raw) 이진 데이터를 큐에 넣을 Job에 전달할 때는 반드시 base64_encode를 통해 인코딩한 후 전달하세요. 그렇지 않으면 Job을 큐에 저장할 때 JSON 직렬화가 제대로 이루어지지 않을 수 있습니다.

큐에 넣은 Job의 연관 관계 처리

Eloquent 모델에 로드된 연관 관계도 Job이 큐에 들어갈 때 함께 직렬화됩니다. 이로 인해 직렬화된 Job 문자열이 상당히 커질 수 있습니다. 또한, Job이 역직렬화될 때 연관 관계는 데이터베이스에서 전체 레코드로 다시 조회됩니다. 이때 Job을 큐에 넣기 전에 적용했던 쿼리 제약 조건은 복원되지 않습니다. 따라서 특정 연관 관계의 일부만 다루고 싶다면, Job 클래스 내부에서 다시 쿼리 조건을 적용해야 합니다.

연관 관계가 직렬화되지 않도록 하려면, 모델 값을 할당할 때 withoutRelations 메서드를 호출하면 됩니다. 이 메서드는 로드된 연관 관계가 제거된 모델 인스턴스를 반환합니다:

/** * 새 Job 인스턴스를 생성합니다. */ public function __construct(Podcast $podcast) { $this->podcast = $podcast->withoutRelations(); }

PHP 생성자 프로퍼티 승격(constructor property promotion)을 사용하는 경우, WithoutRelations 속성(attribute)을 사용해 연관 관계 직렬화를 방지할 수 있습니다:

use Illuminate\Queue\Attributes\WithoutRelations; /** * 새 Job 인스턴스를 생성합니다. */ public function __construct( #[WithoutRelations] public Podcast $podcast ) { }

단일 모델이 아닌 Eloquent 모델의 컬렉션이나 배열을 Job이 받는 경우, 해당 컬렉션 내 모델들의 연관 관계는 Job 역직렬화 시 복원되지 않습니다. 대량의 모델을 다루는 Job에서 과도한 리소스 사용을 방지하기 위한 설계입니다.

중복 없는 고유 Job

WARNING

고유 Job 기능은 원자적 잠금(atomic lock)을 지원하는 캐시 드라이버가 필요합니다. 현재 memcached, redis, dynamodb, database, file, array 캐시 드라이버가 원자적 잠금을 지원합니다. 또한 고유 Job 제약은 배치(batch) 내 Job에는 적용되지 않습니다.

특정 Job이 큐에 단 하나의 인스턴스만 존재하도록 보장하고 싶을 때가 있습니다. 예를 들어 검색 인덱스 업데이트 Job이 이미 큐에 대기 중이라면 또 넣을 필요가 없겠죠. 이럴 때는 Job 클래스에 ShouldBeUnique 인터페이스를 구현하면 됩니다. 별도 메서드를 추가로 정의할 필요는 없습니다:

<?php use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldBeUnique; class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique { ... }

위 예제에서 UpdateSearchIndex Job은 고유합니다. 동일한 Job이 이미 큐에 있고 아직 처리가 끝나지 않았다면, 새로운 디스패치는 무시됩니다.

고유성을 판별하는 "키"를 직접 지정하거나, 고유 잠금이 유지되는 제한 시간을 설정하고 싶다면 uniqueIduniqueFor 프로퍼티 또는 메서드를 정의할 수 있습니다:

<?php use App\Models\Product; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldBeUnique; class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique { /** * 상품 인스턴스. * * @var \App\Product */ public $product; /** * Job의 고유 잠금이 해제될 때까지의 시간(초). * * @var int */ public $uniqueFor = 3600; /** * Job의 고유 ID를 반환합니다. */ public function uniqueId(): string { return $this->product->id; } }

위 예제에서 UpdateSearchIndex Job은 상품 ID를 기준으로 고유합니다. 동일한 상품 ID로 새 Job을 디스패치하면 기존 Job 처리가 완료될 때까지 무시됩니다. 또한 기존 Job이 1시간 내에 처리되지 않으면 고유 잠금이 해제되어, 동일한 고유 키로 다시 디스패치할 수 있게 됩니다.

WARNING

여러 웹 서버나 컨테이너에서 Job을 디스패치하는 경우, 모든 서버가 동일한 중앙 캐시 서버와 통신하도록 설정해야 합니다. 그래야 Laravel이 Job의 고유성을 정확히 판단할 수 있습니다.

처리 시작 전까지만 고유성 유지

기본적으로 고유 Job의 잠금은 Job이 처리를 완료하거나 모든 재시도가 실패한 후에 해제됩니다. 그런데 처리가 시작되기 직전에 즉시 잠금을 해제하고 싶은 경우도 있습니다. 이때는 ShouldBeUnique 대신 ShouldBeUniqueUntilProcessing 인터페이스를 구현합니다:

<?php use App\Models\Product; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing; class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing { // ... }

고유 Job 잠금 메커니즘

내부적으로 ShouldBeUnique Job이 디스패치되면, Laravel은 uniqueId 키를 사용해 원자적 잠금 획득을 시도합니다. 잠금 획득에 실패하면 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 미들웨어를 사용하는 것이 더 적합합니다.

암호화된 Job

Laravel의 암호화 기능을 활용하면 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 클래스에 반복적으로 작성해야 하는 보일러플레이트 코드를 크게 줄일 수 있습니다.

예를 들어, 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 미들웨어를 활용하면 레이트 리밋 로직을 한 곳에 모아 관리할 수 있습니다. Laravel은 Job 미들웨어의 위치를 별도로 지정하지 않으므로 자유롭게 배치할 수 있습니다. 여기서는 app/Jobs/Middleware 디렉터리에 생성해 보겠습니다:

<?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)을 인자로 받습니다.

미들웨어를 작성한 뒤에는 Job 클래스의 middleware 메서드에서 반환하여 연결할 수 있습니다. make:job Artisan 명령으로 생성한 Job에는 이 메서드가 기본 포함되어 있지 않으므로 직접 추가해야 합니다:

use App\Jobs\Middleware\RateLimited; /** * Job이 통과할 미들웨어 목록을 반환합니다. * * @return array<int, object> */ public function middleware(): array { return [new RateLimited]; }

NOTE

Job 미들웨어는 큐어블 이벤트 리스너, Mailable, 알림(Notification)에도 동일하게 적용할 수 있습니다.

레이트 리밋

위에서 직접 레이트 리밋 미들웨어를 작성하는 방법을 살펴봤지만, Laravel은 이미 Job 레이트 리밋 미들웨어를 기본 제공합니다. 라우트 레이트 리미터와 마찬가지로, RateLimiter 파사드의 for 메서드로 제한 규칙을 정의합니다.

예를 들어, 일반 사용자는 1시간에 한 번만 데이터를 백업할 수 있고 VIP 고객은 제한 없이 백업할 수 있도록 구성하려면, AppServiceProviderboot 메서드에 다음과 같이 정의합니다:

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 메서드에는 임의의 값을 넘길 수 있으며, 주로 사용자별로 제한을 구분하는 데 사용합니다:

return Limit::perMinute(50)->by($job->user->id);

레이트 리미터를 정의했다면, Job의 middleware 메서드에서 Illuminate\Queue\Middleware\RateLimited 미들웨어를 반환하여 적용합니다. 제한을 초과하면 미들웨어가 자동으로 Job을 큐에 다시 등록하며, 재시도 지연 시간은 레이트 리밋 설정에 따라 결정됩니다:

use Illuminate\Queue\Middleware\RateLimited; /** * Job이 통과할 미들웨어 목록을 반환합니다. * * @return array<int, object> */ public function middleware(): array { return [new RateLimited('backups')]; }

레이트 리밋으로 인해 Job이 큐에 다시 등록되더라도 attempts 횟수는 계속 증가합니다. Job 클래스의 triesmaxExceptions 값을 상황에 맞게 조정하거나, retryUntil 메서드로 재시도 종료 시각을 지정하는 것이 좋습니다.

레이트 리밋 상황에서 Job을 큐에 다시 등록하지 않고 그냥 버리려면 dontRelease 메서드를 사용합니다:

/** * Job이 통과할 미들웨어 목록을 반환합니다. * * @return array<int, object> */ public function middleware(): array { return [(new RateLimited('backups'))->dontRelease()]; }

NOTE

Redis를 사용한다면 기본 레이트 리밋 미들웨어보다 Redis에 최적화된 Illuminate\Queue\Middleware\RateLimitedWithRedis 미들웨어를 사용하는 것이 더 효율적입니다.

Job 중복 실행 방지

Laravel은 임의의 키를 기반으로 동일한 Job이 동시에 실행되는 것을 막아주는 Illuminate\Queue\Middleware\WithoutOverlapping 미들웨어를 제공합니다. 같은 리소스를 동시에 수정해서는 안 되는 Job에서 특히 유용합니다.

예를 들어, 사용자의 신용 점수를 갱신하는 Job이 있을 때 동일한 사용자에 대한 갱신 Job이 동시에 실행되지 않도록 하려면 다음과 같이 작성합니다:

use Illuminate\Queue\Middleware\WithoutOverlapping; /** * Job이 통과할 미들웨어 목록을 반환합니다. * * @return array<int, object> */ public function middleware(): array { return [new WithoutOverlapping($this->user->id)]; }

같은 키로 실행 중인 Job이 있으면 새 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 클래스가 동일한 키를 사용하더라도 각자 독립적으로 동작합니다. 만약 서로 다른 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(), ]; } }

예외 스로틀링

Laravel은 Illuminate\Queue\Middleware\ThrottlesExceptions 미들웨어를 통해 예외 발생 횟수에 따라 Job 재시도를 제한할 수 있습니다. 지정한 횟수만큼 예외가 발생하면, 그 이후 시도는 설정한 시간이 지날 때까지 지연됩니다. 불안정한 외부 API나 서드파티 서비스와 통신하는 Job에 특히 유용합니다.

예를 들어, 외부 API와 연동하는 Job에서 예외가 반복 발생하는 경우, ThrottlesExceptions 미들웨어를 적용할 수 있습니다. 이 미들웨어는 보통 시간 기반 재시도와 함께 사용합니다:

use DateTime; use Illuminate\Queue\Middleware\ThrottlesExceptions; /** * Job이 통과할 미들웨어 목록을 반환합니다. * * @return array<int, object> */ public function middleware(): array { return [new ThrottlesExceptions(10, 5)]; } /** * Job의 타임아웃 시각을 반환합니다. */ public function retryUntil(): DateTime { return now()->addMinutes(5); }

생성자의 첫 번째 인자는 스로틀링이 적용되기까지 허용할 예외 횟수이고, 두 번째 인자는 스로틀링 상태에서 재시도 전 대기 시간(분)입니다. 위 예시에서는 5분 이내에 예외가 10회 발생하면 5분을 기다린 후 재시도합니다.

예외가 발생했지만 아직 임계치에 도달하지 않은 경우, Job은 즉시 재시도됩니다. 재시도 전 대기 시간을 주고 싶다면 backoff 메서드로 지연 시간(분)을 지정합니다:

use Illuminate\Queue\Middleware\ThrottlesExceptions; /** * Job이 통과할 미들웨어 목록을 반환합니다. * * @return array<int, object> */ public function middleware(): array { return [(new ThrottlesExceptions(10, 5))->backoff(5)]; }

내부적으로 이 미들웨어는 Laravel의 캐시 시스템을 활용하며, Job 클래스명을 캐시 키로 사용합니다. 같은 외부 서비스를 사용하는 여러 Job이 스로틀링 제한을 공유하도록 하려면 by 메서드로 키를 직접 지정합니다:

use Illuminate\Queue\Middleware\ThrottlesExceptions; /** * Job이 통과할 미들웨어 목록을 반환합니다. * * @return array<int, object> */ public function middleware(): array { return [(new ThrottlesExceptions(10, 10))->by('key')]; }

NOTE

Redis를 사용한다면 기본 예외 스로틀링 미들웨어보다 Redis에 최적화된 Illuminate\Queue\Middleware\ThrottlesExceptionsWithRedis 미들웨어를 사용하는 것이 더 효율적입니다.

Job 디스패치하기

Job 클래스를 작성했다면, Job 자체에 있는 dispatch 메서드를 사용해 디스패치할 수 있습니다. dispatch에 전달한 인자는 Job의 생성자로 그대로 전달됩니다.

<?php namespace App\Http\Controllers; use App\Http\Controllers\Controller; 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'); } }

조건에 따라 디스패치해야 할 때는 dispatchIfdispatchUnless 메서드를 사용할 수 있습니다.

ProcessPodcast::dispatchIf($accountActive, $podcast); ProcessPodcast::dispatchUnless($accountSuspended, $podcast);

새로 설치한 Laravel 애플리케이션의 기본 큐 드라이버는 sync입니다. 이 드라이버는 현재 요청의 포그라운드에서 Job을 동기적으로 즉시 실행하므로 로컬 개발 환경에서 편리하게 사용할 수 있습니다. 백그라운드에서 실제로 큐를 처리하려면 config/queue.php 설정 파일에서 다른 큐 드라이버로 변경하세요.

지연 디스패치

Job을 즉시 처리하지 않고 일정 시간 뒤에 처리되도록 하려면 delay 메서드를 사용하세요. 예를 들어, 디스패치 후 10분이 지나야 워커가 처리할 수 있도록 설정하는 예시입니다.

<?php namespace App\Http\Controllers; use App\Http\Controllers\Controller; 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()->addMinutes(10)); return redirect('/podcasts'); } }

WARNING

Amazon SQS 큐 서비스의 최대 지연 시간은 15분입니다.

브라우저에 응답을 전송한 후 디스패치

웹 서버가 FastCGI를 사용하는 경우, dispatchAfterResponse 메서드를 사용하면 HTTP 응답이 사용자 브라우저에 전송된 직후에 Job을 디스패치할 수 있습니다. 이렇게 하면 큐 Job이 아직 실행 중이더라도 사용자는 즉시 응답을 받을 수 있습니다. 이메일 발송처럼 약 1초 내외로 끝나는 작업에 적합하며, 이 방식으로 디스패치된 Job은 별도의 큐 워커 없이도 현재 HTTP 요청 내에서 처리됩니다.

use App\Jobs\SendNotification; SendNotification::dispatchAfterResponse();

클로저를 dispatch 헬퍼로 디스패치하고 afterResponse 메서드를 체이닝하는 방식도 사용할 수 있습니다.

use App\Mail\WelcomeMessage; use Illuminate\Support\Facades\Mail; dispatch(function () { Mail::to('hello@example.com')->send(new WelcomeMessage); })->afterResponse();

동기 디스패치

Job을 즉시 동기적으로 실행하려면 dispatchSync 메서드를 사용하세요. 이 방식은 Job을 큐에 넣지 않고 현재 프로세스 내에서 바로 실행합니다.

<?php namespace App\Http\Controllers; use App\Http\Controllers\Controller; 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'); } }

Job과 데이터베이스 트랜잭션

데이터베이스 트랜잭션 내에서 Job을 디스패치하는 것 자체는 문제없지만, Job이 정상적으로 실행될 수 있는지 주의가 필요합니다. 트랜잭션 내에서 Job을 디스패치하면, 부모 트랜잭션이 커밋되기 전에 워커가 해당 Job을 처리할 수 있습니다. 이 경우, 트랜잭션 중에 변경하거나 생성한 모델 또는 레코드가 아직 데이터베이스에 반영되지 않은 상태일 수 있습니다.

이 문제를 해결하는 가장 간단한 방법은 큐 연결 설정에서 after_commit 옵션을 활성화하는 것입니다.

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

after_commit 옵션이 true이면, 트랜잭션 내에서 Job을 디스패치해도 Laravel은 열린 트랜잭션이 모두 커밋된 후에 실제로 Job을 디스패치합니다. 현재 열린 트랜잭션이 없다면 Job은 즉시 디스패치됩니다.

트랜잭션 도중 예외가 발생하여 롤백되면, 해당 트랜잭션 내에서 디스패치된 Job은 자동으로 폐기됩니다.

NOTE

after_commit 옵션을 true로 설정하면, 큐로 처리되는 이벤트 리스너, Mailable, 알림, 브로드캐스트 이벤트도 모두 열린 트랜잭션이 커밋된 후에 디스패치됩니다.

인라인으로 커밋 후 디스패치 동작 지정

after_commit 설정을 전역으로 바꾸지 않고, 특정 Job에만 커밋 후 디스패치를 적용하려면 afterCommit 메서드를 체이닝하세요.

use App\Jobs\ProcessPodcast; ProcessPodcast::dispatch($podcast)->afterCommit();

반대로 after_committrue로 설정된 환경에서 특정 Job만 즉시 디스패치하려면 beforeCommit 메서드를 사용하세요.

ProcessPodcast::dispatch($podcast)->beforeCommit();

Job 체이닝

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들이 사용할 커넥션과 큐를 지정하려면 onConnectiononQueue 메서드를 사용하세요. 각 Job에 명시적으로 다른 커넥션이나 큐가 지정되지 않은 경우 이 설정이 기본값으로 적용됩니다.

Bus::chain([ new ProcessPodcast, new OptimizePodcast, new ReleasePodcast, ])->onConnection('redis')->onQueue('podcasts')->dispatch();

체인 실패 처리

체인 내 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

체인 콜백은 직렬화(serialize)되어 나중에 Laravel 큐가 실행하므로, 콜백 내부에서 $this 변수를 사용하면 안 됩니다.

큐와 커넥션 커스터마이징

특정 큐로 디스패치

Job을 서로 다른 큐에 분산하면 Job을 "분류"하고, 각 큐에 할당할 워커 수를 조절해 우선순위를 관리할 수 있습니다. 여기서 큐는 설정 파일에 정의된 별도의 큐 "커넥션"이 아니라, 하나의 커넥션 내의 특정 큐 이름을 의미합니다. onQueue 메서드로 큐를 지정하세요.

<?php namespace App\Http\Controllers; use App\Http\Controllers\Controller; 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\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; class ProcessPodcast implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; /** * 새 Job 인스턴스를 생성합니다. */ public function __construct() { $this->onQueue('processing'); } }

특정 커넥션으로 디스패치

애플리케이션이 여러 큐 커넥션을 사용하는 경우, onConnection 메서드로 Job을 디스패치할 커넥션을 지정할 수 있습니다.

<?php namespace App\Http\Controllers; use App\Http\Controllers\Controller; 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'); } }

onConnectiononQueue를 함께 체이닝하면 커넥션과 큐를 동시에 지정할 수 있습니다.

ProcessPodcast::dispatch($podcast) ->onConnection('sqs') ->onQueue('processing');

Job 생성자에서 onConnection을 호출해 커넥션을 고정하는 방법도 있습니다.

<?php namespace App\Jobs; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; class ProcessPodcast implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; /** * 새 Job 인스턴스를 생성합니다. */ public function __construct() { $this->onConnection('sqs'); } }

최대 시도 횟수 / 타임아웃 설정

최대 시도 횟수

큐 Job에서 오류가 발생했을 때 무한히 재시도하는 것은 바람직하지 않습니다. Laravel은 시도 횟수나 시도 가능 시간을 다양한 방법으로 제한할 수 있습니다.

가장 간단한 방법은 queue:work Artisan 명령어의 --tries 옵션을 사용하는 것입니다. 이 설정은 워커가 처리하는 모든 Job에 기본적으로 적용됩니다.

php artisan queue:work --tries=3

최대 시도 횟수를 초과한 Job은 "실패한 Job"으로 처리됩니다. --tries=0을 지정하면 Job은 무한히 재시도됩니다. 실패한 Job 처리에 대한 자세한 내용은 실패한 Job 문서를 참고하세요.

Job 클래스에 $tries 프로퍼티를 정의하면 해당 Job에 대해 더 세밀하게 제어할 수 있으며, 이 값은 커맨드라인의 --tries 값보다 우선 적용됩니다.

<?php namespace App\Jobs; class ProcessPodcast implements ShouldQueue { /** * Job 최대 시도 횟수 * * @var int */ public $tries = 5; }

동적으로 최대 시도 횟수를 제어해야 한다면 tries 메서드를 정의할 수 있습니다.

/** * Job 최대 시도 횟수를 반환합니다. */ public function tries(): int { return 5; }

시간 기반 재시도

횟수 대신 시간으로 재시도를 제한할 수도 있습니다. 지정한 시각 이후에는 더 이상 시도하지 않으며, 그 이전에는 횟수 제한 없이 시도합니다. retryUntil 메서드를 정의하고 DateTime 인스턴스를 반환하면 됩니다.

use DateTime; /** * Job 재시도 만료 시각을 반환합니다. */ public function retryUntil(): DateTime { return now()->addMinutes(10); }

NOTE

큐로 처리되는 이벤트 리스너에도 tries 프로퍼티나 retryUntil 메서드를 정의할 수 있습니다.

최대 예외 횟수

release 메서드로 직접 큐에 되돌리는 경우와 달리, 처리되지 않은 예외가 일정 횟수 이상 발생하면 Job을 실패로 처리하고 싶을 때 maxExceptions 프로퍼티를 사용하세요.

아래 예시에서 Job은 Redis 락을 획득하지 못하면 10초 후 재시도하며 최대 25번까지 시도합니다. 단, 처리되지 않은 예외가 3번 발생하면 Job은 즉시 실패로 처리됩니다.

<?php namespace App\Jobs; use Illuminate\Support\Facades\Redis; class ProcessPodcast implements ShouldQueue { /** * Job 최대 시도 횟수 * * @var int */ public $tries = 25; /** * 실패 처리 전 허용할 최대 미처리 예외 횟수 * * @var int */ public $maxExceptions = 3; /** * Job을 실행합니다. */ public function handle(): void { Redis::throttle('key')->allow(10)->every(60)->then(function () { // 락 획득 성공, 팟캐스트 처리... }, function () { // 락 획득 실패... return $this->release(10); }); } }

타임아웃

대부분의 경우 Job이 얼마나 걸릴지 대략 예상할 수 있습니다. Laravel은 Job에 "타임아웃"을 지정할 수 있으며, 기본값은 60초입니다. 지정된 시간이 초과되면 워커는 에러와 함께 종료됩니다. 일반적으로 워커는 서버에 설정된 프로세스 매니저에 의해 자동으로 재시작됩니다.

queue:work 명령어의 --timeout 옵션으로 전체 워커의 타임아웃을 설정할 수 있습니다.

php artisan queue:work --timeout=30

타임아웃으로 인해 최대 시도 횟수를 초과하면 Job은 실패로 처리됩니다.

Job 클래스에 $timeout 프로퍼티를 정의하면 해당 Job에 대해 커맨드라인 설정보다 우선 적용됩니다.

<?php namespace App\Jobs; class ProcessPodcast implements ShouldQueue { /** * Job 타임아웃 시간(초) * * @var int */ public $timeout = 120; }

소켓이나 외부 HTTP 요청처럼 IO를 블로킹하는 작업은 Laravel의 타임아웃 설정을 따르지 않을 수 있습니다. 이런 경우에는 해당 라이브러리 자체의 타임아웃 옵션도 함께 설정하세요. 예를 들어 Guzzle을 사용할 때는 커넥션 타임아웃과 요청 타임아웃을 반드시 지정하는 것이 좋습니다.

WARNING

Job 타임아웃을 사용하려면 PHP의 pcntl 확장이 설치되어 있어야 합니다. 또한, Job의 timeout 값은 항상 "retry after" 값보다 작아야 합니다. 그렇지 않으면 Job이 실제로 완료되거나 타임아웃되기 전에 재시도될 수 있습니다.

타임아웃 시 실패 처리

타임아웃 발생 시 Job을 실패로 명시적으로 처리하려면 $failOnTimeout 프로퍼티를 정의하세요.

/** * 타임아웃 시 Job을 실패로 처리할지 여부 * * @var bool */ public $failOnTimeout = true;

에러 처리

Job 처리 중 예외가 발생하면 Job은 자동으로 큐에 다시 반환되어 재시도됩니다. 최대 시도 횟수에 도달할 때까지 계속 재시도됩니다. 최대 시도 횟수는 queue:work 명령어의 --tries 옵션이나 Job 클래스의 $tries 프로퍼티로 설정합니다. 큐 워커 실행에 대한 자세한 내용은 아래 섹션을 참고하세요.

Job 수동 반환

처리를 나중에 다시 시도하도록 Job을 큐에 수동으로 되돌리려면 release 메서드를 호출하세요.

/** * Job을 실행합니다. */ public function handle(): void { // ... $this->release(); }

기본적으로 release는 Job을 즉시 처리 가능한 상태로 큐에 반환합니다. 특정 시간이 지난 후에 처리되도록 하려면 정수(초) 또는 날짜 인스턴스를 전달하세요.

$this->release(10); $this->release(now()->addSeconds(10));

Job 수동 실패 처리

Job을 수동으로 실패 처리해야 할 때는 fail 메서드를 호출하세요.

/** * Job을 실행합니다. */ public function handle(): void { // ... $this->fail(); }

캐치한 예외를 전달하거나, 문자열 메시지를 전달해 예외로 변환할 수도 있습니다.

$this->fail($exception); $this->fail('문제가 발생했습니다.');

NOTE

실패한 Job 처리에 대한 자세한 내용은 Job 실패 처리 문서를 참고하세요.

Job 배치 처리

Laravel의 Job 배치(Batch) 기능을 사용하면 여러 Job을 묶어서 실행하고, 전체 배치가 완료된 시점에 후속 작업을 수행할 수 있습니다. 시작하기 전에, 배치의 완료율 등 메타 정보를 저장할 데이터베이스 테이블을 생성해야 합니다. 아래 Artisan 명령으로 마이그레이션 파일을 생성하고 실행하세요:

php artisan queue:batches-tablephp artisan migrate

배치 가능한 Job 정의하기

배치에서 실행할 Job을 만들 때는 일반적인 큐 Job 생성 방법을 그대로 따르되, Illuminate\Bus\Batchable 트레이트를 추가해야 합니다. 이 트레이트가 제공하는 batch() 메서드를 통해 현재 Job이 속한 배치 인스턴스를 가져올 수 있습니다:

<?php namespace App\Jobs; use Illuminate\Bus\Batchable; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; class ImportCsv implements ShouldQueue { use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels; /** * Job 실행 */ public function handle(): void { if ($this->batch()->cancelled()) { // 배치가 취소된 경우 처리 중단 return; } // CSV 파일의 일부 데이터를 가져오는 로직... } }

배치 디스패치하기

배치를 디스패치하려면 Bus 파사드의 batch() 메서드를 사용합니다. 배치의 진가는 완료 콜백과 함께 쓸 때 발휘됩니다. then, catch, finally 메서드로 각 단계의 콜백을 정의할 수 있으며, 각 콜백은 Illuminate\Bus\Batch 인스턴스를 인자로 받습니다.

아래 예시는 CSV 파일을 100행씩 나눠 처리하는 Job 5개를 배치로 묶어 실행하는 코드입니다:

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를 얻어두면, 디스패치 후에도 배치 상태를 조회하는 데 활용할 수 있습니다.

WARNING

배치 콜백은 나중에 큐를 통해 직렬화되어 실행되므로, 콜백 내부에서 $this를 사용하면 안 됩니다.

배치 이름 지정

Laravel Horizon이나 Laravel Telescope 같은 도구에서 배치를 더 쉽게 식별할 수 있도록 이름을 붙일 수 있습니다. name() 메서드로 원하는 이름을 지정하세요:

$batch = Bus::batch([ // ... ])->then(function (Batch $batch) { // 모든 Job 성공 시 처리 })->name('Import 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) { // ... })->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을 배치에 동적으로 집어넣는 패턴을 사용할 수 있습니다:

$batch = Bus::batch([ new LoadImportBatch, new LoadImportBatch, new LoadImportBatch, ])->then(function (Batch $batch) { // 모든 Job 성공 시 처리 })->name('Import Contacts')->dispatch();

LoadImportBatch Job 내부에서 batch()->add() 메서드로 추가 Job을 배치에 등록합니다:

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 직렬화를 지원하므로, 라우트에서 직접 반환해 완료율 등 배치 정보를 API 응답으로 내려줄 수 있습니다. 이를 활용하면 프론트엔드에서 진행 상황을 실시간으로 표시하는 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()) { return $this->batch()->cancel(); } if ($this->batch()->cancelled()) { return; } }

앞선 예시처럼 Job마다 취소 여부를 직접 확인하는 대신, SkipIfBatchCancelled 미들웨어를 Job에 적용하는 더 간편한 방법도 있습니다. 이 미들웨어는 배치가 취소된 경우 해당 Job의 처리를 자동으로 건너뜁니다:

use Illuminate\Queue\Middleware\SkipIfBatchCancelled; /** * Job이 통과할 미들웨어 반환 */ public function middleware(): array { return [new SkipIfBatchCancelled]; }

배치 실패 처리

배치 내 Job이 실패하면, catch 콜백(정의된 경우)이 호출됩니다. 이 콜백은 배치에서 처음으로 실패한 Job에 대해서만 호출됩니다.

실패 허용 설정

기본적으로 배치 내 Job이 하나라도 실패하면 Laravel은 해당 배치를 "취소됨" 상태로 표시합니다. 실패가 발생해도 배치를 취소 상태로 만들지 않으려면, 디스패치 시 allowFailures() 메서드를 호출하세요:

$batch = Bus::batch([ // ... ])->then(function (Batch $batch) { // 모든 Job 성공 시 처리 })->allowFailures()->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 명령을 스케줄러에 등록해 매일 실행하도록 설정하세요:

$schedule->command('queue:prune-batches')->daily();

기본적으로 24시간이 지난 완료된 배치가 정리됩니다. hours 옵션으로 보존 기간을 조정할 수 있습니다. 아래 예시는 48시간 이상 지난 배치를 삭제합니다:

$schedule->command('queue:prune-batches --hours=48')->daily();

끝내 성공하지 못한 배치(예: Job이 실패하고 재시도도 성공하지 못한 경우)의 레코드는 --unfinished 옵션으로 별도 정리할 수 있습니다:

$schedule->command('queue:prune-batches --hours=48 --unfinished=72')->daily();

마찬가지로 취소된 배치 레코드는 --cancelled 옵션으로 정리합니다:

$schedule->command('queue:prune-batches --hours=48 --cancelled=72')->daily();

DynamoDB에 배치 저장하기

관계형 데이터베이스 대신 Amazon DynamoDB에 배치 메타 정보를 저장할 수도 있습니다. 단, DynamoDB 테이블은 직접 생성해야 합니다.

테이블 이름은 기본적으로 job_batches로 하되, queue 설정 파일의 queue.batching.table 값을 따릅니다.

DynamoDB 테이블 구성

job_batches 테이블은 다음과 같이 구성해야 합니다:

  • 파티션 키 (Partition Key): application (String 타입) — app 설정 파일의 name 값이 사용됩니다. 여러 Laravel 애플리케이션이 같은 테이블을 공유할 수 있습니다.
  • 정렬 키 (Sort Key): id (String 타입)

자동 배치 정리를 활용하려면 ttl 속성도 테이블에 추가하세요.

DynamoDB 설정

먼저 AWS SDK를 설치합니다:

composer require aws/aws-sdk-php

이후 queue.batching.driver 설정 값을 dynamodb로 변경하고, key, secret, region을 설정합니다. dynamodb 드라이버를 사용할 때는 queue.batching.database 설정이 필요하지 않습니다:

'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', ],

DynamoDB에서 배치 정리하기

DynamoDB를 사용하는 경우, 관계형 데이터베이스용 queue:prune-batches 명령은 동작하지 않습니다. 대신 DynamoDB의 TTL 기능을 활용해 오래된 레코드를 자동으로 삭제하도록 설정하세요.

DynamoDB 테이블에 ttl 속성을 추가한 경우, 아래와 같이 Laravel 설정에서 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 클래스를 만들지 않고도 클로저(익명 함수)를 직접 큐에 디스패치할 수 있습니다. 현재 요청 사이클 밖에서 처리해야 하는 간단한 작업에 적합합니다. 큐에 디스패치되는 클로저의 코드 내용은 암호화 서명되어 전송 중에 변조될 수 없습니다.

$podcast = App\Podcast::find(1); dispatch(function () use ($podcast) { $podcast->publish(); });

catch 메서드를 사용하면, 큐에 등록된 클로저가 설정된 재시도 횟수를 모두 소진하고도 성공하지 못했을 때 실행할 실패 처리 클로저를 지정할 수 있습니다.

use Throwable; dispatch(function () use ($podcast) { $podcast->publish(); })->catch(function (Throwable $e) { // Job 실패 시 처리... });

WARNING

catch 콜백은 직렬화(serialize)된 후 나중에 Laravel 큐에 의해 실행되므로, catch 콜백 내부에서 $this 변수를 사용하면 안 됩니다.

큐 워커 실행

`queue:work` 명령어

Laravel은 큐 워커를 시작하고, 큐에 새로운 Job이 추가될 때마다 이를 처리하는 Artisan 명령어를 제공합니다. queue:work 명령어로 워커를 실행할 수 있으며, 한 번 시작된 워커는 수동으로 중단하거나 터미널을 닫기 전까지 계속 실행됩니다.

php artisan queue:work

NOTE

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 설정값으로 워커 수를 지정할 수 있습니다.

커넥션과 큐 지정

워커가 사용할 큐 커넥션을 지정할 수도 있습니다. work 명령어에 전달하는 커넥션 이름은 config/queue.php 설정 파일에 정의된 커넥션 이름과 일치해야 합니다.

php artisan queue:work redis

기본적으로 queue:work 명령어는 해당 커넥션의 기본 큐에 있는 Job만 처리합니다. 특정 큐만 처리하도록 지정할 수도 있습니다. 예를 들어 redis 커넥션의 emails 큐에 있는 Job만 처리하려면 다음과 같이 실행합니다.

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을 처리한 뒤 워커가 정상 종료됩니다. Docker 컨테이너에서 큐를 처리하고, 큐가 비었을 때 컨테이너를 종료하고 싶을 때 유용합니다.

php artisan queue:work --stop-when-empty

일정 시간 동안만 Job 처리

--max-time 옵션을 사용하면 지정한 초(초 단위) 동안 Job을 처리한 후 워커가 종료됩니다. Supervisor와 조합하면 일정 시간마다 워커를 재시작해 누적된 메모리를 해제할 수 있습니다.

<h1 id="job-expiration">1시간 동안 Job을 처리하고 종료</h1> php artisan queue:work --max-time=3600

워커 슬립 시간 설정

큐에 처리할 Job이 있으면 워커는 지연 없이 계속 Job을 처리합니다. 처리할 Job이 없을 때 워커가 몇 초 동안 대기(sleep)할지는 --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 커넥션의 기본 queuelow로 설정하고, 특정 Job은 high 우선순위 큐에 디스패치할 수 있습니다.

dispatch((new Job)->onQueue('high'));

low 큐 Job보다 high 큐 Job을 먼저 처리하려면, 큐 이름을 쉼표로 구분해 work 명령어에 전달합니다.

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

워커는 high 큐가 비어 있을 때만 low 큐의 Job을 처리합니다.

큐 워커와 배포

큐 워커는 장기 실행 프로세스이기 때문에 재시작하지 않으면 코드 변경 사항을 인식하지 못합니다. 따라서 배포 시에는 반드시 큐 워커를 재시작해야 합니다. queue:restart 명령어를 사용하면 모든 워커를 안전하게 재시작할 수 있습니다.

php artisan queue:restart

이 명령어를 실행하면 각 워커는 현재 처리 중인 Job을 완료한 후 종료됩니다. 기존 Job이 유실되지 않으므로 안전합니다. 워커가 종료된 후 자동으로 다시 시작되려면 Supervisor와 같은 프로세스 관리자가 설정되어 있어야 합니다.

NOTE

큐는 재시작 신호를 저장하기 위해 캐시를 사용합니다. 이 기능을 사용하기 전에 캐시 드라이버가 올바르게 설정되어 있는지 확인하세요.

Job 만료와 타임아웃

Job 만료 (`retry_after`)

config/queue.php 설정 파일의 각 큐 커넥션에는 retry_after 옵션이 있습니다. 이 옵션은 처리 중인 Job이 지정된 시간(초) 내에 완료되지 않으면 다시 큐에 반환되도록 합니다. 예를 들어 retry_after90으로 설정되어 있으면, Job이 90초 이내에 완료(삭제 또는 반환)되지 않을 경우 큐로 되돌아갑니다. 일반적으로 Job이 완료되기까지 걸리는 최대 예상 시간을 이 값으로 설정하세요.

WARNING

Amazon SQS는 retry_after 옵션을 지원하지 않습니다. SQS는 AWS 콘솔에서 관리하는 기본 가시성 타임아웃(Default Visibility Timeout)을 기준으로 Job을 재시도합니다.

워커 타임아웃 (`--timeout`)

queue:work 명령어는 --timeout 옵션을 제공합니다. 기본값은 60초이며, Job이 지정된 시간을 초과해 처리되면 워커가 오류와 함께 종료됩니다. 워커는 프로세스 관리자에 의해 자동으로 재시작됩니다.

php artisan queue:work --timeout=60

retry_after 설정과 --timeout 옵션은 서로 다른 역할을 하지만, 함께 작동해 Job이 유실되지 않고 정확히 한 번만 처리되도록 보장합니다.

두 값의 관계를 요약하면 다음과 같습니다.

WARNING

--timeout 값은 항상 retry_after 설정값보다 몇 초 이상 짧게 설정해야 합니다. 그래야 중단된(frozen) Job을 처리하던 워커가 종료된 후, 해당 Job이 큐에 반환되기 전에 재시도가 일어나지 않습니다. --timeoutretry_after보다 길면 동일한 Job이 두 번 처리될 수 있습니다.

Supervisor 설정

프로덕션 환경에서는 queue:work 프로세스가 항상 실행 중인 상태를 유지해야 합니다. 워커 타임아웃 초과, queue:restart 명령 실행 등 다양한 이유로 queue:work 프로세스가 종료될 수 있기 때문입니다.

따라서 queue:work 프로세스가 종료되었을 때 이를 감지하고 자동으로 재시작해주는 프로세스 모니터가 필요합니다. 또한 프로세스 모니터를 사용하면 동시에 실행할 queue:work 프로세스의 수도 지정할 수 있습니다. Supervisor는 Linux 환경에서 널리 사용되는 프로세스 모니터로, 아래에서 설정 방법을 설명합니다.

Supervisor 설치

Supervisor는 Linux 운영체제용 프로세스 모니터로, queue:work 프로세스가 비정상 종료되면 자동으로 재시작해줍니다. Ubuntu에서는 다음 명령으로 설치할 수 있습니다.

sudo apt-get install supervisor

NOTE

Supervisor를 직접 설정하고 관리하는 것이 번거롭다면 Laravel Forge 사용을 고려해보세요. Forge는 프로덕션 Laravel 프로젝트에 Supervisor를 자동으로 설치하고 설정해줍니다.

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 sqs --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의 처리 시간(초)보다 크게 설정해야 합니다. 그렇지 않으면 Job이 완료되기 전에 Supervisor가 프로세스를 강제 종료할 수 있습니다.

Supervisor 시작

설정 파일을 작성한 후, 다음 명령으로 Supervisor 설정을 갱신하고 프로세스를 시작합니다.

sudo supervisorctl rereadsudo supervisorctl updatesudo supervisorctl start "laravel-worker:*"

Supervisor에 대한 자세한 내용은 Supervisor 공식 문서를 참고하세요.

실패한 Job 처리하기

큐에 등록한 Job이 항상 성공한다는 보장은 없습니다. 예상치 못한 오류는 언제든 발생할 수 있습니다. Laravel은 최대 재시도 횟수를 지정하는 편리한 방법을 제공합니다. 비동기 Job이 최대 재시도 횟수를 초과하면 failed_jobs 데이터베이스 테이블에 기록됩니다. 동기 방식으로 디스패치된 Job이 실패한 경우에는 이 테이블에 저장되지 않으며, 예외가 즉시 애플리케이션으로 전파됩니다.

새 Laravel 애플리케이션에는 보통 failed_jobs 테이블을 생성하는 마이그레이션이 이미 포함되어 있습니다. 해당 마이그레이션이 없다면 다음 명령으로 생성할 수 있습니다:

php artisan queue:failed-tablephp artisan migrate

큐 워커를 실행할 때 --tries 옵션으로 최대 재시도 횟수를 지정할 수 있습니다. 이 옵션을 생략하면 Job 클래스의 $tries 프로퍼티에 정의된 값만큼, 혹은 정의되어 있지 않다면 딱 한 번만 시도합니다:

php artisan queue:work redis --tries=3

--backoff 옵션을 사용하면 예외가 발생한 Job을 재시도하기 전에 대기할 시간(초)을 지정할 수 있습니다. 기본적으로 Job은 실패 즉시 큐로 돌아가 재시도됩니다:

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

Job별로 대기 시간을 개별 설정하고 싶다면, Job 클래스에 backoff 프로퍼티를 정의하세요:

/** * 재시도 전 대기할 시간(초). * * @var int */ public $backoff = 3;

더 복잡한 로직이 필요하다면 backoff 메서드를 정의할 수도 있습니다:

/** * 재시도 전 대기할 시간(초)을 계산합니다. */ public function backoff(): int { return 3; }

backoff 메서드에서 배열을 반환하면 지수 백오프(exponential backoff) 를 쉽게 구현할 수 있습니다. 아래 예시에서는 첫 번째 재시도는 1초, 두 번째는 5초, 세 번째는 10초 대기하며, 이후 재시도가 더 남아 있다면 10초를 계속 유지합니다:

/** * 재시도 전 대기할 시간(초)을 계산합니다. * * @return array<int, int> */ public function backoff(): array { return [1, 5, 10]; }

실패한 Job 정리하기

Job이 실패했을 때 사용자에게 알림을 보내거나, 부분적으로 처리된 작업을 롤백해야 할 수 있습니다. 이를 위해 Job 클래스에 failed 메서드를 정의하면 됩니다. 실패 원인이 된 Throwable 인스턴스가 인자로 전달됩니다:

<?php namespace App\Jobs; use App\Models\Podcast; use App\Services\AudioProcessor; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Throwable; class ProcessPodcast implements ShouldQueue { use InteractsWithQueue, Queueable, SerializesModels; /** * 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 재시도하기

failed_jobs 테이블에 기록된 실패 Job 목록을 확인하려면 다음 Artisan 명령을 사용합니다:

php artisan queue:failed

이 명령은 Job ID, 연결, 큐 이름, 실패 시각 등의 정보를 출력합니다. Job ID를 사용해 특정 Job을 재시도할 수 있습니다:

php artisan queue:retry ce7bb17c-cdd8-41f0-a8ec-7b4fef4e5ece

여러 Job을 한 번에 재시도하려면 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-34f94a33c24d

NOTE

Horizon을 사용 중이라면 queue:forget 대신 horizon:forget 명령으로 실패한 Job을 삭제해야 합니다.

failed_jobs 테이블의 모든 실패 기록을 한 번에 삭제하려면 queue:flush 명령을 사용합니다:

php artisan queue:flush

존재하지 않는 모델 무시하기

Job에 Eloquent 모델을 주입하면, 큐에 저장될 때 자동으로 직렬화되고 처리 시점에 데이터베이스에서 다시 조회됩니다. 그런데 Job이 대기하는 동안 해당 모델이 삭제되었다면, 워커가 Job을 처리할 때 ModelNotFoundException이 발생하며 Job이 실패합니다.

이런 상황을 조용히 처리하고 싶다면 Job 클래스의 deleteWhenMissingModels 프로퍼티를 true로 설정하세요. 이 경우 모델을 찾지 못하면 예외 없이 Job을 자동으로 폐기합니다:

/** * 모델이 더 이상 존재하지 않으면 Job을 삭제합니다. * * @var bool */ public $deleteWhenMissingModels = true;

실패한 Job 기록 정리(Pruning)하기

failed_jobs 테이블이 너무 커지지 않도록 오래된 레코드를 주기적으로 정리할 수 있습니다:

php artisan queue:prune-failed

기본적으로 24시간이 지난 레코드가 삭제됩니다. --hours 옵션을 사용하면 보존 기간을 직접 지정할 수 있습니다. 예를 들어 아래 명령은 48시간 이전의 레코드를 모두 삭제합니다:

php artisan queue:prune-failed --hours=48

실패한 Job을 DynamoDB에 저장하기

관계형 데이터베이스 테이블 대신 DynamoDB에 실패 Job 기록을 저장할 수도 있습니다. 단, DynamoDB 테이블은 직접 생성해야 합니다. 테이블 이름은 보통 failed_jobs를 사용하지만, queue 설정 파일의 queue.failed.table 값과 일치시키면 됩니다.

DynamoDB 테이블에는 다음 두 가지 키가 필요합니다:

  • 파티션 키(Partition Key): application (문자열) — app 설정 파일의 name 값이 사용됩니다.
  • 정렬 키(Sort Key): uuid (문자열)

파티션 키에 애플리케이션 이름이 포함되므로, 여러 Laravel 애플리케이션이 동일한 DynamoDB 테이블을 공유할 수 있습니다.

설정 전에 AWS SDK를 설치합니다:

composer require aws/aws-sdk-php

그런 다음 queue 설정 파일의 failed 섹션을 아래와 같이 수정합니다:

'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', ],

dynamodb 드라이버를 사용하는 경우 queue.failed.database 설정은 필요하지 않습니다.

실패한 Job 저장 비활성화하기

실패한 Job을 저장하지 않고 바로 폐기하려면 queue.failed.driver 값을 null로 설정합니다. .env 파일에서 환경 변수로 제어하는 방법이 일반적입니다:

QUEUE_FAILED_DRIVER=null

실패한 Job 이벤트

Job이 실패할 때 호출될 이벤트 리스너를 등록하려면 Queue 파사드의 failing 메서드를 사용합니다. 아래는 AppServiceProviderboot 메서드에서 클로저를 등록하는 예시입니다:

<?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 — 실패한 Job 객체 // $event->exception — 발생한 예외 }); } }

큐에서 Job 삭제하기

NOTE

Horizon을 사용 중이라면 queue:clear 대신 horizon:clear 명령어로 큐를 비워야 합니다.

기본 커넥션의 기본 큐에 있는 모든 Job을 삭제하려면 queue:clear Artisan 명령어를 사용하세요:

php artisan queue:clear

특정 커넥션과 큐를 지정하려면 connection 인수와 --queue 옵션을 함께 사용합니다:

php artisan queue:clear redis --queue=emails

WARNING

큐 비우기 기능은 SQS, Redis, database 큐 드라이버에서만 지원됩니다. 또한 SQS는 메시지 삭제 처리에 최대 60초가 소요되므로, 큐를 비운 직후 60초 이내에 전송된 Job도 함께 삭제될 수 있습니다.

큐 모니터링

큐에 Job이 갑자기 몰려들면 처리가 밀리고 대기 시간이 길어질 수 있습니다. Laravel은 큐의 Job 수가 지정한 임계값을 초과할 때 알림을 보낼 수 있는 기능을 제공합니다.

먼저 queue:monitor 명령어를 1분마다 실행되도록 스케줄에 등록하세요. 명령어에는 모니터링할 큐 이름과 최대 Job 수 임계값을 지정합니다.

php artisan queue:monitor redis:default,redis:deployments --max=100

NOTE

이 명령어를 스케줄에 등록하는 것만으로는 알림이 자동으로 발송되지 않습니다. 임계값 초과 감지 → 이벤트 발생 → 리스너에서 알림 발송, 이 세 단계가 모두 갖춰져야 합니다.

명령어가 임계값을 초과한 큐를 발견하면 Illuminate\Queue\Events\QueueBusy 이벤트를 디스패치합니다. 애플리케이션의 EventServiceProvider에서 이 이벤트를 리스닝하여 개발팀에 알림을 보낼 수 있습니다.

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->connection, // 큐 커넥션 이름 $event->queue, // 큐 이름 $event->size // 현재 Job 수 )); }); }

QueueBusy 이벤트에는 connection(커넥션 이름), queue(큐 이름), size(현재 Job 수) 세 가지 프로퍼티가 포함되어 있으므로, 알림 메시지에 어느 큐가 밀렸는지 상세 정보를 함께 담을 수 있습니다.

테스트

Job을 디스패치하는 코드를 테스트할 때, Job 자체를 실제로 실행하지 않도록 Laravel에 지시할 수 있습니다. Job의 로직은 디스패치 코드와 별도로 직접 테스트할 수 있기 때문입니다. 물론 Job 자체를 테스트하고 싶다면, 테스트 내에서 Job 인스턴스를 직접 생성한 뒤 handle 메서드를 호출하면 됩니다.

Queue 파사드의 fake 메서드를 사용하면 큐에 Job이 실제로 푸시되지 않도록 막을 수 있습니다. fake를 호출한 후에는 애플리케이션이 큐에 Job을 푸시하려 했는지 다양한 어설션으로 검증할 수 있습니다.

<?php namespace Tests\Feature; use App\Jobs\AnotherJob; use App\Jobs\FinalJob; 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, 2); // 특정 Job이 푸시되지 않았는지 확인... Queue::assertNotPushed(AnotherJob::class); // 클로저가 큐에 푸시되었는지 확인... Queue::assertClosurePushed(); // 푸시된 Job의 총 개수 확인... Queue::assertCount(3); } }

assertPushed 또는 assertNotPushed 메서드에 클로저를 전달하면, 특정 조건을 만족하는 Job이 푸시되었는지 세밀하게 검증할 수 있습니다. 조건을 통과하는 Job이 하나라도 존재하면 어설션은 성공합니다.

Queue::assertPushed(function (ShipOrder $job) use ($order) { return $job->order->id === $order->id; });

일부 Job만 Fake 처리하기

특정 Job만 Fake 처리하고 나머지 Job은 실제로 실행되도록 하려면, fake 메서드에 Fake 처리할 Job 클래스명을 배열로 전달하면 됩니다.

public function test_orders_can_be_shipped(): void { Queue::fake([ ShipOrder::class, ]); // 주문 배송 처리 수행... // Job이 두 번 푸시되었는지 확인... Queue::assertPushed(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 클래스명 배열을 전달해도 되지만, 실제 Job 인스턴스 배열을 전달할 수도 있습니다. 인스턴스를 전달하면 Laravel은 클래스 타입뿐만 아니라 프로퍼티 값도 함께 비교합니다.

Bus::assertChained([ new ShipOrder, new RecordShipment, new UpdateInventory, ]);

체인 없이 단독으로 디스패치된 Job을 검증하려면 assertDispatchedWithoutChain 메서드를 사용합니다.

Bus::assertDispatchedWithoutChain(ShipOrder::class);

체인 내 배치 테스트

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; });

디스패치된 배치의 총 개수를 검증하려면 assertBatchCount 메서드를 사용합니다.

Bus::assertBatchCount(3);

아무 배치도 디스패치되지 않았는지 확인하려면 assertNothingBatched를 사용합니다.

Bus::assertNothingBatched();

Job과 배치 간의 상호작용 테스트

개별 Job이 자신이 속한 배치와 어떻게 상호작용하는지 테스트해야 할 때도 있습니다. 예를 들어, 특정 Job이 배치의 나머지 처리를 취소했는지 확인하는 경우가 그렇습니다. 이럴 때는 withFakeBatch 메서드를 사용해 Job에 Fake 배치를 할당할 수 있습니다. withFakeBatch는 Job 인스턴스와 Fake 배치를 담은 튜플을 반환합니다.

[$job, $batch] = (new ShipOrder)->withFakeBatch(); $job->handle(); $this->assertTrue($batch->cancelled()); $this->assertEmpty($batch->added);

Job 이벤트

Queue 파사드beforeafter 메서드를 사용하면, 큐에 등록된 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 — Job 인스턴스 // $event->job->payload() — Job 페이로드 }); } }

Queue 파사드looping 메서드를 사용하면, 워커가 큐에서 Job을 가져오려고 시도하기 전마다 실행할 콜백을 등록할 수 있습니다. 예를 들어, 이전에 실패한 Job이 미처 닫지 못한 데이터베이스 트랜잭션을 롤백하는 용도로 활용할 수 있습니다:

use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Queue; Queue::looping(function () { while (DB::transactionLevel() > 0) { DB::rollBack(); } });

NOTE

looping 콜백은 Job 처리 여부와 무관하게 워커 루프가 돌 때마다 반복 호출됩니다. 열린 트랜잭션 정리처럼 루프 단위로 상태를 초기화해야 할 때 적합합니다.

이 문서는 Laravel 공식 문서(MIT)를 한국 개발자를 위해 번역·재구성한 것입니다.

번역일: 2026년 6월 27일