제출 폼이 스팸에 뚫리기 전에: throttle 미들웨어로 횟수를 제한하고 429 응답을 테스트하기
작성: 라라벨 코리아
발행: 2026년 10월 10일
패키지·쇼케이스 제출처럼 로그인 사용자가 반복 호출할 수 있는 폼에 요청 제한을 거는 방법을 정리합니다. throttle:5,60 표기의 의미, 제한 기준을 IP에서 사용자로 바꾸는 RateLimiter 정의, 429 응답과 Retry-After 헤더를 Pest로 확인하는 절차를 다룹니다.
로그인 뒤에 있는 폼도 제한이 필요하다
라라벨 코리아의 패키지 제출 폼은 로그인한 사용자만 보낼 수 있습니다. 그런데 로그인이 곧 신뢰는 아닙니다. 제출 한 건마다 서버가 Packagist에 메타데이터를 요청하고 관리자 검토 대기열에 행을 추가하므로, 한 계정이 스크립트로 수백 번 보내면 외부 API 할당량과 검토자의 시간을 소모합니다. 그래서 해당 경로에는 throttle:5,60이 걸려 있습니다. 60분 동안 5번까지입니다.
이 글은 그 한 줄이 실제로 무엇을 하는지, 기본 기준인 IP가 왜 로그인 폼에 맞지 않는지, 그리고 제한이 걸린다는 사실을 어떻게 테스트로 남기는지 설명합니다.
적용 환경
Laravel 13, Pest 4, 캐시 드라이버는 테스트에서 array를 가정합니다. 요청 제한은 캐시에 횟수를 저장하므로 운영에서는 모든 웹 서버가 같은 Redis나 DB 캐시를 바라봐야 합니다. 서버마다 파일 캐시를 쓰면 서버 수만큼 한도가 늘어납니다.
1. throttle:5,60이 하는 일
throttle 미들웨어는 두 숫자를 받습니다. 첫 번째가 허용 횟수, 두 번째가 분 단위 기간입니다. 기본 식별 기준은 로그인했다면 사용자 ID, 아니면 IP 주소입니다. 한도 안에서는 응답에 X-RateLimit-Limit과 X-RateLimit-Remaining을 붙이고, 넘으면 본문을 실행하지 않고 429를 돌려주며 Retry-After에 몇 초 뒤에 다시 시도할 수 있는지 적습니다.
주의할 점은 컨트롤러가 실행되기 전에 횟수가 올라간다는 것입니다. 검증 실패로 422가 나도 한 번으로 셉니다. 사용자가 실수로 다섯 번 틀리면 여섯 번째 정답도 막힙니다. 그래서 숫자는 "정상 사용자가 한 시간에 몇 번이나 보내겠는가"에 여유를 더해 정합니다. 라라벨 코리아의 5회는 한 사람이 한 시간에 다섯 패키지를 등록할 일이 드물다는 판단입니다.
2. 제한 기준을 직접 정의한다
숫자 두 개로 부족한 경우가 있습니다. 로그인 사용자와 비로그인 방문자에게 다른 한도를 주고 싶거나, 같은 회사의 NAT 뒤에 있는 여러 사용자가 IP 하나를 공유하는 경우입니다. 이때는 AppServiceProvider::boot()에 이름 있는 제한을 정의합니다.
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('submissions', function (Request $request) {
return $request->user()
? Limit::perHour(5)->by('user:'.$request->user()->id)
: Limit::perHour(2)->by('ip:'.$request->ip());
});라우트에는 ->middleware('throttle:submissions')처럼 이름만 적습니다. by()에 넘기는 문자열이 캐시 키의 일부가 되므로 user:와 ip: 접두어로 두 종류가 섞이지 않게 합니다. 라라벨 코리아는 외부 AI 호출에도 같은 방식으로 분당 30회 제한을 정의해 두고 있습니다.
3. 429가 실제로 나는지 테스트한다
"미들웨어를 붙였다"와 "제한이 작동한다"는 다릅니다. 캐시 드라이버가 null이면 조용히 모든 요청이 통과합니다. 아래 테스트는 라우트를 테스트 안에서 등록해 저장소의 다른 경로와 무관하게 실행됩니다. tests/Feature/SubmissionThrottleTest.php에 넣습니다.
<?php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
test('the third submission within a minute is refused with a retry hint', function () {
Route::post('/example/submit', fn () => response('accepted', 200))
->middleware('throttle:2,1');
$this->post('/example/submit')
->assertOk()
->assertHeader('X-RateLimit-Limit', '2')
->assertHeader('X-RateLimit-Remaining', '1');
$this->post('/example/submit')
->assertOk()
->assertHeader('X-RateLimit-Remaining', '0');
$refused = $this->post('/example/submit')->assertStatus(429);
expect((int) $refused->headers->get('Retry-After'))->toBeGreaterThan(0)->toBeLessThanOrEqual(60);
});
test('a named limiter counts each origin separately', function () {
RateLimiter::for('example-origin', fn (Request $request) => Limit::perMinute(1)->by('ip:'.$request->ip()));
Route::post('/example/origin', fn () => response('accepted', 200))
->middleware('throttle:example-origin');
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.10'])->post('/example/origin')->assertOk();
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.10'])->post('/example/origin')->assertStatus(429);
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.11'])->post('/example/origin')->assertOk();
});첫 테스트는 한도가 2일 때 세 번째가 막히는지, 그리고 남은 횟수 헤더가 1, 0으로 내려가는지 봅니다. Retry-After가 1분 기간을 넘지 않는지도 확인합니다. 두 번째 테스트는 서로 다른 IP가 독립적으로 세어지는지 봅니다. 두 번째 IP가 429를 받는다면 by()가 요청마다 같은 값을 돌려주고 있다는 뜻입니다.
php artisan test --compact tests/Feature/SubmissionThrottleTest.php테스트 환경의 캐시가 array라서 각 테스트는 깨끗한 횟수에서 시작합니다. 같은 테스트 안에서 횟수를 초기화하고 싶다면 RateLimiter::clear($key)를 사용합니다.
4. 429를 받은 사용자에게 무엇을 보여줄 것인가
제한은 공격자만 받는 것이 아닙니다. 폼을 쓰는 사용자가 429를 만났을 때 흰 화면이나 JSON 오류를 보게 두지 마세요.
- Inertia·일반 폼: 429도 예외 핸들러를 거치므로
Retry-After값을 읽어 "N분 뒤 다시 시도할 수 있습니다"를 보여주는 오류 페이지를 둡니다. - API: 클라이언트가
Retry-After를 읽고 그만큼 기다리도록 문서에 적습니다. 즉시 재시도하는 클라이언트는 한도를 더 빨리 소진합니다. - 로그: 429가 특정 사용자에게 반복되면 봇이거나 UI에 문제가 있는 것입니다. 둘 다 알아야 하므로 사용자 ID와 경로를 함께 기록합니다.
프록시나 CDN 뒤에 있다면 $request->ip()가 프록시 주소를 돌려줄 수 있습니다. 모든 방문자가 같은 IP로 보여 한 명이 한도를 쓰면 전부 막힙니다. 신뢰할 프록시를 설정해 실제 클라이언트 주소를 읽는지 먼저 확인하세요.
어디에 걸고 어디에 걸지 않을 것인가
| 경로 | 제한 | 이유 |
|---|---|---|
| 제출·등록 폼 | 사용자당 시간당 몇 회 | 외부 API 호출과 검토 대기열을 보호 |
| 로그인·OAuth 콜백 | IP당 분당 소수 | 자격 증명 추측 방지 |
| 외부로 요청을 보내는 테스트 버튼 | 매우 낮게 | 서버가 임의 주소로 요청하는 유일한 경로 |
| 공개 읽기 페이지 | 보통 없음 | 캐시·CDN이 더 알맞음 |
라라벨 코리아에서 알림 웹훅 테스트 발송은 10분에 5회로 가장 낮습니다. 사용자가 고른 주소로 서버가 요청을 보내는 기능이기 때문입니다. 받는 쪽 서명 검증은 웹훅 서명 검증 가이드에서 다룹니다.
출처와 검증 범위
Laravel 13 요청 제한의 throttle 표기와 RateLimiter::for, 캐시 문서의 드라이버 설명, MDN 429의 Retry-After 의미를 대조했습니다. 본문의 테스트는 이 사이트의 회귀 테스트에서 그대로 실행됩니다. AI 작성·원문 대조이며, Redis 다중 서버에서의 원자성이나 실제 공격 트래픽에 대한 효과를 측정한 것은 아닙니다.