큐·Job·이벤트·스케줄링
큐·Job·이벤트·스케줄링
요약: 메일·이미지처럼 느린 일은 Job 큐로 넘기고, 이벤트와 스케줄러로 후처리와 매일 할 일을 운영한다.
1. 왜 요청 안에서 메일을 보내지 않나
HTTP 요청은 짧게 끝나야 한다. 아래는 요청 스레드에 두지 않는다.
- 메일·SMS
- 이미지 리사이즈
- 외부 API를 여러 번 호출
- 대량 CSV 내보내기
큐(queue) 는 “나중에 할 일”을 저장하는 대기열이다. Job은 그 대기열에 넣는 작업 한 건이다. 사용자는 201을 바로 받고, 워커 프로세스가 메일을 보낸다.
QUEUE_CONNECTION=database
# 운영은 redis를 흔히 쓴다
php artisan queue:table
php artisan queue:failed-table
php artisan migrate
php artisan queue:work
queue:work가 안 떠 있으면 Job은 테이블에만 쌓이고 아무 일도 안 일어난다. 로컬에서 “메일이 안 간다”의 원인 1위가 이것이다.
2. Job
php artisan make:job SendWelcomeEmail
class SendWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 10;
public function __construct(
public User $user,
) {}
public function handle(Mailer $mailer): void
{
$mailer->to($this->user)->send(new WelcomeMail($this->user));
}
public function failed(?Throwable $e): void
{
logger()->error('welcome mail failed', [
'user_id' => $this->user->id,
'error' => $e?->getMessage(),
]);
}
}
ShouldQueue가 있어야 비동기다. 없으면 dispatch해도 그 자리에서 handle이 돈다.
SendWelcomeEmail::dispatch($user);
SendWelcomeEmail::dispatch($user)->delay(now()->addMinutes(5));
SendWelcomeEmail::dispatch($user)->onQueue('mail');
SerializesModels는 모델 전체 대신 id를 큐에 넣는다. 워커가 실행할 때 다시 조회한다. 그래서 트랜잭션이 끝나기 전에 디스패치하면 아직 없는 행을 워커가 읽는다.
3. 멱등성과 중복
큐는 최소 한 번이 아니라 적어도 한 번, 가끔 두 번이라고 가정한다. 네트워크 타임아웃 후 재시도가 겹칠 수 있다.
같은 환영 메일이 두 번 나가지 않게:
class SendWelcomeEmail implements ShouldQueue, ShouldBeUnique
{
public function uniqueId(): string
{
return (string) $this->user->id;
}
}
결제·웹훅은 unique만으로 부족하다. 처리한 id를 테이블에 남기는 idempotency key와 함께 설계한다.
4. 이벤트와 리스너
이벤트는 “주문이 접수됐다”처럼 도메인에서 일어난 일의 이름이다. 주문 서비스가 메일·재고·통계를 직접 알 필요가 없다. 리스너가 각자 구독한다.
php artisan make:event OrderPlaced
php artisan make:listener SendOrderConfirmation --event=OrderPlaced
OrderPlaced::dispatch($order);
리스너에 ShouldQueue를 붙이면 발행은 가볍고 후처리는 워커가 한다. 서비스 클래스가 비대해지는 것을 막는 것이 목적이다. 이벤트를 남발하면 흐름이 안 보인다. 여러 관심사가 한 유스케이스에 붙을 때만 쓴다.
5. 커밋 후에 디스패치
DB::transaction(function () use ($data) {
$order = Order::create(/* ... */);
DB::afterCommit(fn () => OrderPlaced::dispatch($order));
});
또는 Job에 ShouldDispatchAfterCommit을 붙인다. 커밋 전 디스패치 → 롤백됐는데 메일은 나가고, 워커는 없는 주문을 찾는 레이스가 난다.
class SendWelcomeEmail implements ShouldQueue, ShouldDispatchAfterCommit
6. Horizon
Horizon은 Redis 큐를 위한 대시보드이자 프로세스 관리자다. 큐별 워커 수, 대기 시간, 실패 Job을 UI로 본다. QUEUE_CONNECTION=database면 Horizon의 본래 가치가 없다. Redis일 때 쓴다.
composer require laravel/horizon
php artisan horizon:install
php artisan horizon
운영에서는 queue:work를 SSH에 띄워 두지 않는다. Supervisor(또는 컨테이너)가 Horizon/워커를 재시작한다.
7. 실패와 스케줄
php artisan queue:failed
php artisan queue:retry all
public function retryUntil(): DateTime
{
return now()->addHour();
}
실패를 삼키고 failed()도 없으면 사용자는 “메일이 안 온다”만 본다. Slack/메일 알림 또는 Horizon 알림을 붙인다. $timeout을 안 주면 워커가 한 Job에 영원히 묶일 수 있다.
스케줄은 Laravel 11+에서 routes/console.php에 둔다. 서버 cron은 1분마다 schedule:run 한 줄만 있으면 된다.
use Illuminate\Support\Facades\Schedule;
Schedule::command('reports:daily')->dailyAt('01:00');
Schedule::job(new PruneOldTokens)->hourly();
Schedule::call(fn () => Cache::forget('homepage'))->everyFiveMinutes();
* * * * * cd /path && php artisan schedule:run >> /dev/null 2>&1
겹쳐 돌면 안 되는 작업은 withoutOverlapping()을, 여러 서버면 onOneServer()를 쓴다.
8. 메일·알림
php artisan make:mail WelcomeMail --markdown=mail.welcome
php artisan make:notification InvoicePaid
$user->notify(new InvoicePaid($invoice));
채널은 mail, database, slack 등이다. 로컬은 Mailpit 또는 log 드라이버로 실제 발송을 끈다.
9. 흔한 실수
- 워커를 안 켜 두고 Job이 “먹통”이라고 한다.
- 트랜잭션 안에서 바로
dispatch한다. - 큐 payload에 HTML·파일 전체를 넣는다. id만 넣고 워커가 읽는다.
$tries/$timeout이 없어 무한 재시도·무한 점유.- 메일·결제 Job을 우선순위 없이
default한 줄에 넣는다.mail/high로 나눈다.
연습
- 회원가입 후
SendWelcomeEmail을 afterCommit으로 보낸다. OrderPlaced이벤트와 큐 리스너를 만든다.- Job을 실패시키고
queue:failed를 확인한다. - 매일 자정 임시 파일 정리 스케줄을 등록한다.
댓글
0아직 댓글이 없습니다. 첫 댓글을 남겨보세요.