본문 바로가기
← 아티클 목록
실무 가이드Laravel 13큐멱등성트랜잭션

큐 작업이 두 번 실행돼도 집계가 한 번만 늘어나게 만들기

작성: 라라벨 코리아

발행: 2026년 10월 10일

페이지 조회 집계를 예로 재시도와 중복 효과를 분리합니다. 고유 이벤트 ID, DB 트랜잭션, timeout과 retry_after의 차이를 설명하고 중복 전달·롤백 시나리오를 테스트합니다.

“작업 성공”과 “한 번만 처리”는 다른 조건이다

페이지 조회 집계를 큐로 넘겼다고 가정해 봅시다. 워커가 DB의 조회 수를 1 올린 직후 종료되면, 큐에서는 작업 완료를 확인하지 못할 수 있습니다. 같은 작업이 다시 전달되어 똑같이 1을 더하면 사용자는 한 번 봤지만 통계는 두 번 올라갑니다.

이 글의 목표는 워커의 실행 횟수를 항상 1로 만드는 것이 아닙니다. 같은 이벤트가 재전달되어도 DB에 남는 집계 효과를 한 번으로 만드는 것입니다. 결제·이메일처럼 외부 서비스에 효과가 생기는 작업은 이 예제만으로 해결되지 않습니다.

실습 범위

Laravel 13, 트랜잭션을 지원하는 관계형 DB, Pest 4를 가정합니다. 아래는 사이트에 이미 설치된 통계 기능을 설명하는 코드가 아니라, 작은 기능을 직접 설계하는 별도 실습입니다. 새 테이블을 만드는 과정은 개발·테스트 DB에서 진행하세요. SQLite 테스트 결과를 MySQL·PostgreSQL의 경합 성능 보장으로 해석해서는 안 됩니다.

1. 재시도해도 바뀌지 않는 이벤트 ID를 정한다

“같은 작업”을 알아보려면 식별자가 필요합니다. 요청을 처음 수락할 때 UUID를 한 번 만들고 Job의 생성자에 전달합니다. handle() 안에서 새 UUID를 만들면 재시도마다 다른 이벤트가 되어 중복 방지가 작동하지 않습니다.

날짜 역시 워커가 처리한 시각 대신 이벤트가 발생한 시각에서 한 번 정합니다. 23:59의 작업이 다음 날 재시도되어도 원래 날짜의 집계를 가리켜야 합니다. 서비스 시간대가 한국이라면 날짜를 결정하는 경계에서 Asia/Seoul을 명시하세요.

2. “처리 기록”과 “집계 변경”을 같은 트랜잭션에 넣는다

실습용 마이그레이션의 up() 안에 다음 두 테이블을 만듭니다. 기존 앱에 같은 이름이 있다면 별도 이름을 사용하고 코드도 맞춰 바꾸세요. down()에서는 daily_metrics, processed_view_events 순서로 삭제합니다.

Schema::create('processed_view_events', function (Blueprint $table): void { $table->uuid('event_id')->primary(); }); Schema::create('daily_metrics', function (Blueprint $table): void { $table->date('day')->primary(); $table->unsignedBigInteger('views')->default(0); });

Schema는 Illuminate\Support\Facades\Schema, Blueprint는 Illuminate\Database\Schema\Blueprint를 import합니다. 이벤트 ID에 기본 키가 없으면 중복을 판별할 근거가 없어집니다. 애플리케이션에서 exists()를 먼저 확인하는 것만으로는 두 워커가 동시에 “없음”을 읽는 상황을 막지 못합니다.

다음은 app/Jobs/RecordPageView.php에 넣을 Job 전체입니다. eventId와 day는 서버가 정한 UUID와 Y-m-d 문자열이며, 검증되지 않은 브라우저 입력을 그대로 넘기지 않습니다.

<?php namespace App\Jobs; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\DB; use Throwable; class RecordPageView implements ShouldQueue { use Queueable; public int $tries = 3; public int $timeout = 30; public function __construct( public string $eventId, public string $day, ) {} public function backoff(): array { return [10, 60]; } public function handle(): void { DB::transaction(function (): void { $inserted = DB::table('processed_view_events')->insertOrIgnore([ 'event_id' => $this->eventId, ]); if ($inserted === 0) { return; } DB::table('daily_metrics')->insertOrIgnore([ 'day' => $this->day, 'views' => 0, ]); DB::table('daily_metrics') ->where('day', $this->day) ->increment('views'); }); } public function failed(?Throwable $exception): void { if ($exception !== null) { report($exception); } } }

여기서 insertOrIgnore()의 반환값이 0이면 이미 처리한 이벤트이므로 집계를 건드리지 않습니다. 새 이벤트면 처리 기록과 집계 증가가 함께 커밋됩니다. 증가 도중 예외가 나면 처리 기록도 롤백되어 다음 시도에서 다시 처리할 수 있습니다.

insertOrIgnore()는 DB 엔진에 따라 중복 외의 오류도 무시할 수 있습니다. 따라서 입력 형태를 검증하고, 테스트 DB와 운영 DB의 동작을 모두 확인해야 합니다. 임의의 페이로드 저장에 이 코드를 그대로 확장하지 마세요. 여기서는 서버가 정한 UUID와 날짜, 상수 0만 저장합니다.

3. 재시도 시나리오를 표로 먼저 정리한다

시나리오처리 기록 수기대하는 집계
이벤트 A를 처음 전달11
같은 A를 다시 전달11
새 이벤트 B를 같은 날짜에 전달22
처리 기록 작성 뒤 집계 전에 예외해당 트랜잭션의 기록 없음증가 없음
실패했던 이벤트를 다시 전달성공한 이벤트 기록 생성1 증가

마이그레이션이 적용되는 테스트 환경에서 아래 테스트를 실행합니다. Pest에 RefreshDatabase를 적용하고, 위 Job을 만든 상태여야 합니다.

<?php use App\Jobs\RecordPageView; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; test('repeated event changes the metric once', function () { $eventId = (string) Str::uuid(); $job = new RecordPageView($eventId, '2026-10-09'); $job->handle(); $job->handle(); expect(DB::table('processed_view_events')->count())->toBe(1); expect((int) DB::table('daily_metrics') ->where('day', '2026-10-09')->value('views'))->toBe(1); });

이 테스트는 같은 핸들러를 순차적으로 두 번 호출합니다. 큐 데몬이나 Redis가 정상이라는 검증은 아닙니다. 별도 스테이징에서는 워커 두 개, 실제 큐 연결, 강제 종료 상황도 확인하세요. Laravel 코리아의 회귀 테스트에서는 새 이벤트와 롤백 후 재시도도 검사합니다.

4. timeout을 늘리기 전에 시간을 구분한다

  • timeout: 워커가 작업 실행을 허용하는 시간입니다. Job에 설정한 값이 워커 옵션보다 우선할 수 있습니다.
  • retry_after: Redis·DB 큐에서 예약된 작업을 다시 처리 대상으로 돌리는 시간입니다.
  • backoff: 실패 뒤 다음 시도까지의 대기 시간입니다.

이 예제의 Job timeout은 30초입니다. Redis·DB 연결의 retry_after는 여유를 두어 더 길게 설정합니다. 예를 들어 90초를 사용할 수 있지만, 이는 모든 서비스의 정답이 아니라 실습용 값입니다. SQS는 같은 설정 대신 서비스의 visibility timeout을 확인해야 합니다.

ShouldBeUnique로 중복 enqueue를 줄일 수도 있지만, 실제 효과 저장과 큐의 완료 확인 사이의 장애를 모두 해결하는 것은 아닙니다. 중복 실행이 허용되어도 결과가 유지되는 설계가 별도로 필요합니다. DB 트랜잭션 안에서 새 레코드와 관련된 Job을 보낸다면 afterCommit()도 검토하세요. 이것은 커밋 전 실행 문제를 해결하며, 효과 중복 방지와는 역할이 다릅니다.

운영으로 옮기기 전에 결정할 것

처리 기록은 언제 지울지 정해야 합니다. 재전달 가능한 기간보다 먼저 지우면 오래된 이벤트를 다시 셀 수 있습니다. 반대로 영구 보관하면 저장소가 계속 증가합니다. 재시도·재전송 정책에 맞춰 보관 기간을 정하고, 만료 이후 재전달은 별도의 정책으로 처리하세요.

외부 이메일 발송이나 결제 API 호출을 이 트랜잭션 안에 넣는다고 원자성이 생기지는 않습니다. 외부 서비스의 멱등 키 지원, 발송 상태 조정, outbox 등 다른 설계가 필요합니다. 여기서 보장하는 것은 한 DB 안의 이벤트 기록과 집계 변경입니다.

다음은 배포 후 워커가 새 코드를 읽는지 확인하는 절차입니다.

출처와 검증 범위

Queues, Database Transactions, Query Builder Inserts를 대조했습니다. 예제의 실패 시나리오와 집계 구조는 학습용으로 설계했습니다. AI 작성·원문 대조이며, 운영 환경의 동시성 부하 시험이나 외부 서비스의 exactly-once 보장을 주장하지 않습니다.