요청은 짧게, 후속 작업은 뒤에서
게시글 작성 기능은 이제 꽤 현실적인 모습을 갖추었다. 사용자는 로그인하고, 글을 작성하고, 작성자만 수정하거나 삭제할 수 있다. 하지만 실무 서비스에서는 글을 저장하는 순간 더 많은 일이 함께 일어난다.
예를 들어 글이 공개되면 작성자에게 이메일을 보내고, 관리자에게 알림을 남기고, 검색 서버에 글을 등록하고, 외부 분석 API에 이벤트를 전송할 수 있다. 이 작업을 모두 컨트롤러 안에서 바로 실행하면 사용자는 저장 버튼을 누른 뒤 오래 기다려야 한다. 외부 API가 느리거나 메일 서버가 잠시 응답하지 않으면 글 저장 자체가 실패한 것처럼 보일 수도 있다.
이 장의 기준은 단순하다. 사용자 요청에서 반드시 끝내야 하는 일과 나중에 처리해도 되는 일을 나눈다. 데이터베이스에 글을 저장하고 응답을 돌려주는 일은 요청 안에서 끝낸다. 이메일 발송, 이미지 처리, 외부 API 호출, 통계 생성처럼 시간이 걸리거나 실패 가능성이 높은 일은 백그라운드로 넘긴다. Laravel은 이를 위해 Event, Listener, Job, Queue, Notification, Scheduler를 제공한다.1
AI 생성 이미지
Event와 Listener로 후속 작업 분리하기
이벤트는 '무슨 일이 일어났는가'를 표현한다. 리스너는 '그 일이 일어난 뒤 무엇을 할 것인가'를 담당한다. 컨트롤러가 모든 후속 작업을 직접 알 필요가 없어지는 것이 핵심이다.
게시글이 공개되었을 때 실행할 이벤트를 만들어 보자.
php artisan make:event PostPublished
php artisan make:listener SendPostPublishedNotification --event=PostPublished이벤트 클래스는 보통 데이터를 담는 역할에 가깝다.
class PostPublished
{
use Dispatchable, SerializesModels;
public function __construct(public Post $post) {}
}컨트롤러에서는 글을 저장한 뒤 이벤트만 발생시킨다.
public function store(StorePostRequest $request)
{
$post = $request->user()->posts()->create($request->validated());
PostPublished::dispatch($post);
return redirect()->route('posts.show', $post)
->with('status', '글이 저장되었습니다.');
}이제 컨트롤러는 글 저장 이후에 어떤 알림이 몇 개나 실행되는지 몰라도 된다. 그 책임은 리스너가 가진다.
class SendPostPublishedNotification
{
public function handle(PostPublished $event): void
{
$event->post->user->notify(
new PostPublishedNotification($event->post)
);
}
}이 구조의 장점은 기능이 늘어날수록 분명해진다. 검색 색인 갱신이 필요하면 SyncPostToSearchIndex 같은 Job을 큐에 넣는 리스너를 추가하고, 관리자 알림이 필요하면 NotifyAdministrators 리스너를 추가한다. 컨트롤러는 여전히 PostPublished::dispatch($post) 한 줄만 유지한다.
다만 이벤트는 모든 곳에 붙이는 장식이 아니다. '글 제목을 대문자로 바꾸기'처럼 저장 과정의 핵심 규칙은 이벤트보다 서비스나 모델 로직에 가까울 수 있다. 이벤트는 회원가입 완료, 주문 생성, 글 공개, 결제 성공처럼 다른 후속 작업을 자연스럽게 불러오는 지점에 잘 맞는다.
Job과 Queue로 느린 작업 넘기기
이벤트와 리스너로 책임을 나누어도 리스너가 메일 발송이나 외부 API 호출을 즉시 실행하면 요청은 여전히 느려진다. 이때 큐를 사용한다. 큐는 해야 할 일을 대기열에 넣고, 별도의 워커가 뒤에서 하나씩 처리하는 구조다.2
개발 환경에서 데이터베이스 큐를 사용하려면 .env를 다음처럼 설정할 수 있다.
QUEUE_CONNECTION=database프로젝트에 큐 테이블이 없다면 생성 후 마이그레이션한다. Laravel 버전에 따라 기본 마이그레이션 포함 여부가 다를 수 있으므로 공식 문서를 함께 확인하자.
php artisan make:queue-table
php artisan migrate외부 검색 서비스에 게시글을 동기화하는 작업을 Job으로 분리해 보자.
php artisan make:job SyncPostToSearchIndexclass SyncPostToSearchIndex implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 30;
public function __construct(public int $postId) {}
public function handle(SearchIndexClient $client): void
{
$post = Post::findOrFail($this->postId);
$client->updatePost($post);
}
public function backoff(): array
{
return [10, 60, 300];
}
}이 Job은 게시글 ID만 큐에 담는다. Eloquent 모델을 직접 전달할 수도 있지만, 큐 작업은 나중에 실행된다는 점을 기억해야 한다. 작업이 실행될 때 최신 데이터가 필요하다면 ID로 다시 조회하는 방식이 이해하기 쉽고 안전하다.
Job은 컨트롤러나 리스너에서 큐에 넣을 수 있다.
SyncPostToSearchIndex::dispatch($post->id)->onQueue('external');큐에 넣은 작업은 워커가 처리한다.
php artisan queue:work --queue=external,mail,default여기서 중요한 점은 두 가지다. 첫째, 워커가 실행 중이 아니면 작업은 큐에 쌓이기만 한다. 둘째, 운영 서버에서는 터미널을 열어 수동으로 워커를 켜 두는 방식이 아니라 Supervisor 같은 프로세스 관리 도구로 워커를 계속 실행시켜야 한다.
또 하나 조심할 부분이 있다. 데이터베이스 트랜잭션 안에서 Job이나 알림을 큐에 넣으면, 워커가 트랜잭션 커밋보다 먼저 작업을 가져가 아직 저장되지 않은 데이터를 조회하려 할 수 있다. 이런 구조라면 큐 연결의 after_commit 설정을 확인하거나, 필요한 곳에서 afterCommit()을 사용해 커밋 이후에 작업이 넘어가도록 처리하자.
메일 발송, 이미지 리사이즈, 외부 API 호출은 큐에 잘 맞는다. 반대로 사용자가 응답을 받기 전에 반드시 결과를 알아야 하는 작업은 무조건 큐로 넘기면 안 된다. 예를 들어 결제 승인 여부를 확인해야 주문 완료 화면을 보여 줄 수 있다면, 최소한의 결제 확인은 요청 안에서 끝내야 한다.
Mail과 Notification으로 알림 구조 만들기
메일은 이메일 한 채널에 집중한다. Notification은 사용자에게 알림을 보내는 더 넓은 구조다. 같은 알림을 이메일, 데이터베이스, Slack 같은 여러 채널로 보낼 수 있다.3
게시글 공개 알림을 만들어 보자.
php artisan make:notification PostPublishedNotification데이터베이스 알림도 저장하려면 알림 테이블이 필요하다.
php artisan notifications:table
php artisan migrate알림 클래스는 어떤 채널로 보낼지 via()에서 결정한다.
class PostPublishedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public Post $post) {}
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('게시글이 공개되었습니다')
->line('글이 공개되었습니다: '.$this->post->title)
->action('글 보기', route('posts.show', $this->post));
}
public function toDatabase(object $notifiable): array
{
return [
'post_id' => $this->post->id,
'title' => $this->post->title,
'message' => '게시글이 공개되었습니다.',
];
}
}ShouldQueue를 구현했기 때문에 큐 설정과 워커가 준비되어 있다면 이 알림은 발송 요청을 받았을 때 큐에 들어간다. 사용자는 글 저장 응답을 빠르게 받고, 실제 메일 발송은 워커가 처리한다. 알림 대상과 채널 조합마다 큐 작업이 만들어질 수 있으므로, 관리자 여러 명에게 여러 채널로 알림을 보낼 때는 작업 수가 예상보다 많아질 수 있다는 점도 함께 생각하자.
알림을 보내는 코드는 단순하다.
$user->notify(new PostPublishedNotification($post));관리자 여러 명에게 보내야 한다면 Facade를 사용할 수도 있다.
Notification::send($admins, new PostPublishedNotification($post));Notification을 쓰면 컨트롤러나 리스너가 '이 사용자는 이메일을 받을지, 앱 안 알림만 받을지'를 직접 판단하지 않아도 된다. 그 결정은 알림 클래스 안에 모을 수 있다. Slack, SMS 같은 채널은 별도 패키지나 서비스 설정이 필요할 수 있으므로 도입 전에 현재 Laravel 버전의 공식 문서를 확인하는 습관이 필요하다.
Scheduler로 반복 작업을 코드로 관리하기
지금까지는 사용자의 행동이 출발점이었다. 글이 작성되면 이벤트가 발생하고, 큐 작업이 실행된다. 하지만 어떤 작업은 사용자가 아무것도 하지 않아도 정해진 시간에 실행되어야 한다.
예를 들어 다음과 같은 작업이 있다.
30일 넘게 수정되지 않은 임시 글 삭제
매일 자정 게시글 통계 생성
만료된 초대 링크 비활성화
실패한 외부 동기화 작업 재점검
서버의 crontab에 명령을 여러 줄 직접 넣으면 시간이 지날수록 관리가 어려워진다. Laravel Scheduler는 반복 작업 정의를 코드 안에서 관리하게 해 준다.4
먼저 오래된 임시 글을 삭제하는 Artisan 명령을 만든다.
php artisan make:command PruneExpiredDraftsclass PruneExpiredDrafts extends Command
{
protected $signature = 'posts:prune-expired-drafts';
public function handle(): int
{
Post::where('is_draft', true)
->where('updated_at', '<', now()->subDays(30))
->delete();
return 0;
}
}그리고 routes/console.php에 실행 주기를 정의한다.
use IlluminateSupportFacadesSchedule;
Schedule::command('posts:prune-expired-drafts')
->dailyAt('03:00')
->withoutOverlapping();
Schedule::job(new GenerateDailyPostStats)
->dailyAt('00:10');withoutOverlapping()은 이전 실행이 끝나지 않았는데 같은 작업이 다시 시작되는 일을 막아 준다. 통계 생성이나 외부 API 동기화처럼 중복 실행되면 문제가 생길 수 있는 작업에 특히 유용하다.
로컬에서는 다음 명령으로 스케줄 동작을 확인할 수 있다.
php artisan schedule:list
php artisan schedule:work운영 서버에서는 보통 시스템 cron이 매분 Laravel 스케줄러를 깨우고, Laravel이 현재 시간에 실행할 작업을 판단한다.
* * * * * cd /path/to/project && php artisan schedule:run >> /dev/null 2>&1즉, crontab은 Laravel을 호출하는 한 줄만 갖고, 실제 반복 작업 목록은 애플리케이션 코드에서 관리한다.
하나의 흐름으로 연결하기
이 장의 도구들은 따로 떨어진 기능이 아니다. 실무에서는 자주 함께 쓰인다.
게시글이 공개되는 흐름을 다시 보면 다음과 같다.
컨트롤러가 게시글을 저장한다.
PostPublished이벤트를 발생시킨다.리스너가 작성자 알림을 보내고, 검색 색인 갱신 Job을 큐에 넣는다.
Queue Worker가 메일 발송과 외부 API 호출을 백그라운드에서 처리한다.
Scheduler가 매일 오래된 임시 글을 삭제하고 통계를 생성한다.
이렇게 나누면 사용자의 요청은 짧아지고, 실패 가능성이 높은 작업은 재시도 가능한 구조로 이동한다. 컨트롤러는 요청 처리에 집중하고, 이벤트는 발생한 일을 표현하며, Job은 오래 걸리는 일을 담당하고, Notification은 알림 채널을 정리하며, Scheduler는 반복 작업을 코드로 관리한다.
문제가 생겼을 때도 확인할 위치가 분명하다. 알림이 안 오면 Notification과 큐 워커를 본다. 외부 동기화가 안 되면 Job 실패 기록과 로그를 본다. 매일 실행되어야 할 작업이 돌지 않으면 schedule:list, 서버 cron, 시간대 설정을 확인한다.
비동기와 자동화는 애플리케이션을 복잡하게 만들기 위한 기술이 아니다. 사용자의 기다림을 줄이고, 실패를 격리하고, 반복 작업을 예측 가능한 코드로 남기기 위한 구조다. 이 기준을 지키면 기능이 늘어나도 요청-응답 흐름은 단단하게 유지된다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.