10만명에게 보상을 지급해요 (feat.Queue + Job)

Zain·2025년 3월 22일
post-thumbnail

앱 서비스가 시작되면서 어드민에 다양한 기능들이 추가될 예정
그중 모든 사용자, 혹은 특정 사용자들에게 보상을 지급하는 기능이 도입된다.

만약 사용자 수가 10만명 이상이라면 대량의 데이터를 어떻게 처리하는가?

일단 Quere를 사용하기 이전에 라라벨 공식 문서에서도

Laravel의 큐(Queue) 기능을 사용하기 전에, 먼저 "Connections(연결)" 와 "Queues(큐)" 의 차이를 이해하는 게 중요합니다.

라고 설명하고 있음.

Connection

어디에 작업을 저장하고 관리할지 결정하는 백엔드 서비스와의 연결 정보
작업(Job) 저장소의 종류/위치
ex)

  • Database: MySQL, PostgreSQL 같은 DB 테이블에 작업을 저장
  • Redis: Redis 서버에 작업 저장
  • Amazon SQS: AWS의 Simple Queue Service 사용
  • Beanstalkd, RabbitMQ 등 다른 큐 시스템도 가능
// 작업을 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특징어디에 작업 저장?
DatabaseDB 테이블에 Job 저장MySQL, PostgreSQL, Aurora 등 RDBMS 테이블
Redis빠른 메모리 기반 QueueRedis 서버 메모리에 저장
Beanstalkd고성능 Queue 전용 서버Beanstalkd 서버
Amazon SQSAWS에서 제공하는 관리형 Queue 서비스AWS SQS 서비스
RabbitMQ메시지 브로커, 복잡한 메시징 패턴에 적합RabbitMQ 서버
SyncQueue를 쓰지 않고 즉시 실행 (동기 처리)저장 X (즉시 실행)
NullQueue를 꺼두고 아무 작업도 하지 않음 (테스트용)저장 X (작업 무시)

구글에 검색해보니 주로 Redis를 사용한다 함. 메모리에 저장해서 처리 속도가 빠른 이점 때문인듯


Queues

Queue는 해야 할 작업(Job)의 대기열
처리해야 할 작업들을 순서대로 쌓아두고, Worker가 하나씩 처리해 나가는 구조 (Queue에 작업을 쌓고, 나중에 백그라운드에서 처리)

Queue 구성 요소

  • Connection (연결):Queue 저장 방식과 위치 설정. 예: Redis, DB 등
  • Queue (큐):ob들이 순서대로 저장되어 있는 대기열
  • Job (작업):실제로 실행할 로직
  • Worker (워커):큐에서 Job을 꺼내와서 실제로 처리하는 프로세스

Queue 동작 흐름

  1. Job 생성 & Dispatch (사용자에게 보상을 지급하는 Job 생성)
  2. Job이 특정 Queue에 저장
  3. Worker가 Queue에서 Job 가져와서 실행
  4. 실행 완료 → Job 제거 or 실패 처리

Queue 특징

  • 비동기 처리 가능 → 사용자는 빠르게 응답받고, 무거운 작업은 나중에 처리
  • 재시도(재처리) 가능 → 실패한 Job은 일정 시간 후 재시도 설정 가능 (retry_after)
  • 우선순위 설정 가능 → 특정 Queue 이름별로 Worker 수 조절해 처리 속도 조정
  • 병렬 처리 확장 가능 → Worker 여러 개 돌려서 대량 작업도 빠르게 소화

궁금증

Q1. 즉시 실행과 워커가 잡을 실행시키는거랑 차이점?

예시
"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 수작업 재처리 필요.

➡️ Queue + Worker 방식

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

한 워커가 무거운 잡 하나 vs 여러 가벼운 잡 나눠서 처리?

  • 무거운 Job 하나 (한번에 10만명)
    • 코드 단순
    • 한 번에 끝나면 깔끔
    • 메모리 많이 사용
    • DB 커넥션 오래 점유
    • 중간 실패 시 전체 실패/롤백
    • 병렬화 어려움
  • 가벼운 Job 여러 개 (1000명씩 쪼개기)
    • 잡 수가 많아 보일 수 있음
    • 관리 필요
    • 병렬 처리 가능 (Worker 여러 개 활용)
    • 메모리 사용량 적음
    • DB 커넥션 짧고 빠름
    • 실패 시 일부만 재시도
    • 스케일링 쉬움

워커가 잡을 가져가서 실행되는 시점에 서버,DB 리소스를 차지하게 된다. 무거운 잡은 그만큼 많은 리소스를 차지하고, 처리속도가 오래걸리기 때문에, 잡을 잘게 나누는 것이 더 안정성 측면에서 장점이 많다고 느껴진다.

profile
Vue, Laravel | TS, JS, PHP, MySQL

0개의 댓글