📖 12주차 02강: 작업 스케줄링(Task Scheduling)과 자정 배치 자동화
실습 프로젝트: 지니샵(JinyShop) — 온라인 도서 쇼핑몰
학습 목표: 리눅스 서버에 단 하나의 크론(Cron)만 등록해 두고, 라라벨 스케줄러(routes/console.php)를 통해 매일 자정 24시간 미결제 주문 자동 취소 및 품절 임박 도서 통계 배치를 안정적으로 자동화합니다.
등장인물: 🐱 지니(시니어 멘토), 👧 도로시(주니어 개발자), 🐶 토토(개발 보조견)
💬 지니와 도로시의 티키타카 회의
👧 도로시: “지니! 고객들이 무통장 입금으로 도서를 주문해두고 입금을 안 해서 책 50권이 일주일째 ‘주문 대기’ 상태로 묶여 있어! 다른 손님들이 책을 못 사고 있대. 매일 밤 12시에 내가 야근하면서 24시간 지난 주문을 하나씩 찾아서 취소 버튼을 눌러야 해?”
🐱 지니: “도로시, 잠은 푹 자야지! 라라벨의 작업 스케줄러(Task Scheduling)가 있잖니! 리눅스 크론탭에 php artisan schedule:run 딱 한 줄만 심어두면, 매일 자정(dailyAt('00:00'))에 24시간 지난 미결제 주문을 찾아 일괄 취소하고 묶였던 재고를 자동으로 서고에 복구해 준단다!”
🐶 토토: “멍멍! 이전 작업이 안 끝났는데 다음 작업이 또 돌아 중복 취소되는 대참사를 막으려면 withoutOverlapping() 한 줄을 꼭 달아야 안전하다멍!”
🎯 실습 목표 및 서점 시나리오
- 미결제 주문 자동 취소 커맨드 제작:
CancelUnpaidOrdersCommand(shop:cancel-unpaid-orders) - 라라벨 11 스케줄 정의:
routes/console.php에 스케줄 규칙 등록 - 스케줄 보호 옵션 적용:
- 중복 실행 방지:
withoutOverlapping(60) - 백그라운드 병렬 실행:
runInBackground() - 로그 파일 보관:
appendOutputTo() - 단일 서버 실행:
onOneServer()(다중 웹서버 환경)
- 중복 실행 방지:
- 로컬 스케줄러 테스트 및 검증:
php artisan schedule:list,php artisan schedule:work,php artisan schedule:test
🛠️ 단계별 실습 절차
1단계: 24시간 미결제 주문 자동 취소 커맨드 작성
24시간 동안 입금되지 않은 pending 상태의 주문을 취소하고 책 재고를 복원하는 커맨드를 생성합니다.
php artisan make:command CancelUnpaidOrdersCommand
app/Console/Commands/CancelUnpaidOrdersCommand.php:
<?php
namespace App\Console\Commands;
use App\Models\Order;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class CancelUnpaidOrdersCommand extends Command
{
protected $signature = 'shop:cancel-unpaid-orders {--hours=24 : 미결제 경과 기준 시간}';
protected $description = '지정된 시간 동안 결제되지 않은 주문을 자동 취소하고 도서 재고를 복원합니다.';
public function handle(): int
{
$hours = (int) $this->option('hours');
$cutoffTime = now()->subHours($hours);
$this->info("{$hours}시간 이상 미결제 상태인 주문 취소 배치를 시작합니다. (기준 시각: {$cutoffTime->toDateTimeString()})");
// 24시간 지난 pending 주문 조회
$unpaidOrders = Order::where('status', 'pending')
->where('created_at', '<=', $cutoffTime)
->with('items.book')
->get();
if ($unpaidOrders->isEmpty()) {
$this->info('취소 대상 미결제 주문이 없습니다.');
return self::SUCCESS;
}
$cancelledCount = 0;
foreach ($unpaidOrders as $order) {
DB::transaction(function () use ($order, &$cancelledCount) {
// 1. 주문 상세 품목별 재고 복원
foreach ($order->items as $item) {
$item->book->increment('stock', $item->quantity);
if ($item->book->is_sold_out && $item->book->stock > 0) {
$item->book->update(['is_sold_out' => false]);
}
}
// 2. 주문 상태를 'cancelled'로 변경
$order->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'cancel_reason' => '입금 기한(24시간) 만료에 따른 시스템 자동 취소',
]);
$cancelledCount++;
});
Log::info("[스케줄러] 미결제 주문 자동 취소 완료: 주문번호 #{$order->order_number}");
}
$this->info("총 {$cancelledCount}건의 미결제 주문이 성공적으로 취소되고 재고가 복구되었습니다.");
return self::SUCCESS;
}
}
2단계: 라라벨 11 스케줄러 등록 (routes/console.php)
라라벨 11에서는 콘솔 스케줄을 routes/console.php 파일에 직접 등록합니다!
routes/console.php를 열고 다음과 같이 스케줄을 구성합니다:
<?php
use Illuminate\Support\Facades\Schedule;
use App\Console\Commands\CancelUnpaidOrdersCommand;
use App\Models\Book;
use Illuminate\Support\Facades\Log;
/*
|--------------------------------------------------------------------------
| Console Task Scheduling
|--------------------------------------------------------------------------
*/
// 1. 매일 자정(00:00) 24시간 미결제 주문 자동 취소
Schedule::command(CancelUnpaidOrdersCommand::class, ['--hours' => 24])
->dailyAt('00:00')
->withoutOverlapping(60) // 최대 60분 락
->runInBackground() // 다음 작업 지연 방지
->appendOutputTo(storage_path('logs/scheduler-orders.log'))
->onSuccess(function () {
Log::info('[스케줄러] 자정 미결제 주문 취소 배치가 성공적으로 완료되었습니다.');
})
->onFailure(function () {
Log::critical('[스케줄러 긴급] 자정 미결제 주문 취소 배치가 실패했습니다!');
});
// 2. 매일 아침 09:00 품절 임박(재고 3권 이하) 도서 점검 인라인 클로저
Schedule::call(function () {
$lowStockBooks = Book::where('stock', '<=', 3)->where('is_sold_out', false)->get();
if ($lowStockBooks->isNotEmpty()) {
Log::warning("[재고 경보] 품절 임박 도서가 {$lowStockBooks->count()}종 있습니다. 발주 검토가 필요합니다.");
}
})->dailyAt('09:00')->name('check-low-stock-books');
// 3. 매주 월요일 새벽 02:00 주간 베스트셀러 집계 캐시 갱신
Schedule::command('shop:generate-bestsellers')
->weeklyOn(1, '02:00')
->onOneServer(); // 서버가 여러 대여도 한 대에서만 단독 실행
3단계: 스케줄 목록 및 실시간 실행 검증
등록된 스케줄 목록을 Artisan 명령어로 확인합니다.
php artisan schedule:list
출력 예시:
00:00 App\Console\Commands\CancelUnpaidOrdersCommand ........... Next Due: 21 hours from now
09:00 Closure at routes/console.php:28 .......................... Next Due: 6 hours from now
02:00 Mon shop:generate-bestsellers ............................. Next Due: 3 days from now
개발 환경에서 크론처럼 1분마다 스케줄을 감시하며 실행하는 커맨드:
php artisan schedule:work
특정 스케줄 커맨드 강제 즉시 실행 테스트:
php artisan schedule:test --name="shop:cancel-unpaid-orders"
4단계: 운영 서버(Ubuntu/Linux) 크론 등록
운영 서버에서는 시스템 크론탭에 단 한 줄만 등록하면 끝납니다:
crontab -e
다음 라인을 추가합니다:
* * * * * cd /home/deploy/jinyshop && php artisan schedule:run >> /dev/null 2>&1
💡 스케줄러 동작 원리:
리눅스 크론은 매 분마다schedule:run을 깨웁니다. 라라벨 스케줄러가 깨어나서 현재 시각(예: 00:00, 월요일 02:00 등)에 일치하는 등록된 작업들만 골라서 실행합니다!
🔍 라라벨 공식 문서 원리 심층 분석
1. 스케줄 실행 빈도 메소드 모음
| 메소드 | 실행 주기 |
|---|---|
->everyMinute() |
매 1분마다 |
->everyFiveMinutes() |
매 5분마다 |
->hourly() |
매 시간 정각에 |
->daily() |
매일 자정 00:00에 |
->dailyAt('13:00') |
매일 13:00에 |
->twiceDaily(1, 13) |
하루 두 번 (01:00, 13:00) |
->weekdays() |
평일(월~금)에만 |
->weekly() |
매주 일요일 00:00에 |
->monthly() |
매월 1일 00:00에 |
->cron('0 0 * * *') |
표준 5필드 크론 표현식 직접 지정 |
2. 유지보수 모드에서의 스케줄러 동작
서버 점검 중(php artisan down)일 때 배치 작업이 돌면 안 된다면 기본적으로 스케줄러는 멈춥니다. 만약 점검 중에도 반드시 돌아야 하는 작업이 있다면 ->evenInMaintenanceMode()를 체이닝하면 됩니다.
🐶 토토의 트러블슈팅 & 주의사항
- 중복 실행 방지(withoutOverlapping)의 데드락 방지:
->withoutOverlapping()만 적어두면 작업이 예기치 않게 강제 종료(SIGKILL)되었을 때 락(Lock) 파일이 영구히 남아 다음 날부터 스케줄이 영원히 돌지 않는 사고가 날 수 있습니다. 반드시 만료 시간(withoutOverlapping(60))을 인자로 지정하세요! - 단일 서버 실행(onOneServer)의 필수 조건:
AWS EC2나 오토스케일링 환경에서 서버 5대가 동시에 같은 스케줄을 돌려 5번 중복 취소되는 것을 방지하는onOneServer()는 Redis나 Memcached 같은 중앙 캐시 드라이버가 연동되어 있어야 락을 공유할 수 있습니다.
💡 자가진단 퀴즈 & 실습 과제
- 퀴즈: 여러 대의 웹 서버가 로드밸런서 뒤에 있을 때, 스케줄 작업이 딱 한 대의 서버에서만 실행되도록 보장하는 메소드는 무엇일까요?
- 정답:
->onOneServer()
- 정답:
- 과제: 매월 마지막 날 23시 59분에 이번 달 최다 서평을 남긴 독자 3명을 선정해 적립금 5,000원을 지급하는 월간 이벤트 스케줄(
monthlyOn(...))을 작성해 보세요!