앱 서비스가 시작되면서 어드민에 다양한 기능들이 추가될 예정
그중 모든 사용자, 혹은 특정 사용자들에게 보상을 지급하는 기능이 도입된다.만약 사용자 수가 10만명 이상이라면 대량의 데이터를 어떻게 처리하는가?
일단 Quere를 사용하기 이전에 라라벨 공식 문서에서도
Laravel의 큐(Queue) 기능을 사용하기 전에, 먼저 "Connections(연결)" 와 "Queues(큐)" 의 차이를 이해하는 게 중요합니다.
라고 설명하고 있음.
어디에 작업을 저장하고 관리할지 결정하는 백엔드 서비스와의 연결 정보
작업(Job) 저장소의 종류/위치
ex)
// 작업을 RDBMS 테이블에 저장하는 경우
'connections' => [
'database' => [
'driver' => 'database',
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 90,
],
],
Database Queue 방식에서는 Job을 "대기시키기 위해 반드시 테이블이 필요
Laravel에서 Queue를 Database 방식으로 설정하면, Laravel이 Queue 시스템을 "DB에 저장된 테이블"을 통해 구현됨
동작 순서
1️⃣ Job을 dispatch → jobs 테이블에 insert
2️⃣ Worker가 DB에서 읽어서 실행
3️⃣ 성공 시 → 해당 row 삭제
4️⃣ 실패 시 → 실패 처리 후 재시도 가능
테이블이 필요 없는 경우도 있나?
| Driver | 특징 | 어디에 작업 저장? |
|---|---|---|
| Database | DB 테이블에 Job 저장 | MySQL, PostgreSQL, Aurora 등 RDBMS 테이블 |
| Redis | 빠른 메모리 기반 Queue | Redis 서버 메모리에 저장 |
| Beanstalkd | 고성능 Queue 전용 서버 | Beanstalkd 서버 |
| Amazon SQS | AWS에서 제공하는 관리형 Queue 서비스 | AWS SQS 서비스 |
| RabbitMQ | 메시지 브로커, 복잡한 메시징 패턴에 적합 | RabbitMQ 서버 |
| Sync | Queue를 쓰지 않고 즉시 실행 (동기 처리) | 저장 X (즉시 실행) |
| Null | Queue를 꺼두고 아무 작업도 하지 않음 (테스트용) | 저장 X (작업 무시) |
구글에 검색해보니 주로 Redis를 사용한다 함. 메모리에 저장해서 처리 속도가 빠른 이점 때문인듯
Queue는 해야 할 작업(Job)의 대기열
처리해야 할 작업들을 순서대로 쌓아두고, Worker가 하나씩 처리해 나가는 구조 (Queue에 작업을 쌓고, 나중에 백그라운드에서 처리)
예시
"10만명에게 1000포인트를 지급해준다"
$chunkSize = 1000;
User::chunk($chunkSize, function ($users) {
$insertData = [];
foreach ($users as $user) {
$insertData[] = [
'user_id' => $user->id,
'point' => 1000,
'reason' => 'Event reward',
'created_at' => now(),
'updated_at' => now(),
];
}
Point::insert($insertData);
});
일단 메모리 폭발 방지는 해두었으나 발생되는 문제점은
- 모든 chunk가 끝날 때까지 하나의 PHP 프로세스가 계속 실행됨
- PHP + DB 커넥션 + 메모리 계속 점유 → 다른 요청 처리에 자원 부족 가능성 증가.
- Timeout 위험
- DB 커넥션을 길게 물고 있음. 커넥션 풀 제한에 가까워질 수 있음
- 장애 대응이 어려움, 작업중 서버 다운, 에러 발생 시 모든 작업 실패 → 롤백 or 수작업 재처리 필요.
// Controller
dispatch(new ProcessAllUsersJob());
//ProcessAllUsersJob.php
class ProcessAllUsersJob implements ShouldQueue
{
public function handle()
{
$chunkSize = 1000;
User::chunk($chunkSize, function ($users) {
$insertData = [];
foreach ($users as $user) {
$insertData[] = [
'user_id' => $user->id,
'point' => 1000,
'reason' => 'Event reward',
'created_at' => now(),
'updated_at' => now(),
];
}
Point::insert($insertData);
});
}
}
자 코드에서 뭔가 싸한점이 있을거다.
일단 로직 자체를 백그라운드에서 처리하는건 맞지만, 10만명에게 보상을 지급하는 로직이 하나의 잡 안에 있다..
1차 : 유저를 백그라운드 처리
dispatch(new ProcessAllUsersJob());
class ProcessAllUsersJob implements ShouldQueue
{
public function handle()
{
$chunkSize = 1000;
User::chunk($chunkSize, function ($users) {
// 여기서 또 개별 Job으로 나눔
dispatch(new InsertPointsJob($users));
});
}
}
2차 : chunk 하나당 insert 담당 Job
class InsertPointsJob implements ShouldQueue
{
protected $users;
public function __construct($users)
{
$this->users = $users->map(function ($user) {
return [
'user_id' => $user->id,
'point' => 1000,
'reason' => 'Event reward',
'created_at' => now(),
'updated_at' => now(),
];
})->toArray();
}
// 이 handle 매서드는 워커가 잡을 가져오는 시점에 실행된다
public function handle()
{
Point::insert($this->users);
}
}
워커가 잡을 가져가서 실행되는 시점에 서버,DB 리소스를 차지하게 된다. 무거운 잡은 그만큼 많은 리소스를 차지하고, 처리속도가 오래걸리기 때문에, 잡을 잘게 나누는 것이 더 안정성 측면에서 장점이 많다고 느껴진다.