인증번호 오입력 제한을 시간 윈도우로 세면 재발송으로 우회됩니다
재발송 시 오입력 횟수를 초기화하려면 이전 코드 폐기가 전제입니다
인증번호 API 에 서버측 제한이 없어서 호출만으로 우회가 가능한 상태였습니다. 제한을 도입하기로 하면서 기획에서 요청이 하나 추가됐습니다. "재발송하면 오입력 횟수도 같이 초기화되는 게 사용자 경험상 맞다"는 요청입니다.
타당한 요구입니다. 문자를 수신하지 못해 다시 수신했는데 입력 기회가 0회로 남아 있으면 아무것도 할 수 없으니까요. 그런데 이걸 그대로 적용하면 제한이 오히려 완화됩니다. 재발송 시 이전 코드를 폐기한다는 전제가 누락돼 있었기 때문입니다.
재발송 초기화가 안전한 이유는 "정답이 바뀐다" 입니다
초기화가 괜찮아 보이는 근거는 명확합니다. 재발송하면 맞혀야 할 정답이 변경됩니다. 새 코드는 이전 코드와 무관한 난수라서, 이전 시도로 축소해 둔 후보가 무의미해집니다. 그러니 횟수를 복원해도 공격자가 얻는 게 없습니다.
문제는 기존 구현에서 그 전제가 성립하지 않았다는 것입니다.
-- 검증 쿼리 (요지만)
WHERE code = :code
AND target = :target
AND processed = 'N'
ORDER BY created_at DESC LIMIT 1
코드 값으로 먼저 조회합니다. 그래서 유효시간 안에 발급한 코드가 전부 동시에 유효했습니다. 재발송하면 정답이 변경되지 않고, 유효한 정답이 하나씩 증가합니다.
이 상태에서 오입력 초기화만 적용하면 어떻게 되는지, 윈도우당 발송 한도를 S, 코드당 오입력 한도를 A 로 설정하고 계산해봤습니다.
| 윈도우당 시도 | 동시에 유효한 정답 | |
|---|---|---|
| 시간 윈도우 카운터 | A | 최대 S |
| 코드 단위 + 폐기 없음 | S × A | 최대 S |
| 코드 단위 + 이전 코드 폐기 | S × A | 1개 |
가운데 줄은 재발송할수록 공격자가 유리해지는 구성입니다. 그래서 오입력 제한을 코드 1개당으로 집계하는 것과 재발송 시 이전 코드를 폐기하는 것을 같이 적용했습니다. 둘 중 하나만 적용하면 의미가 없습니다.
구현은 대상(채널과 수신자)당 키 하나를 사용합니다. 재발송하면 이 레코드를 교체하므로, 이전 코드 폐기와 오입력 횟수 초기화가 한 번의 쓰기로 함께 처리됩니다.
verify:{channel}:{recipient} = { code, expiresAt, attempts } TTL 600
만료와 폐기는 레코드 존재로 가릅니다
discarded 같은 플래그는 도입하지 않았습니다. attempts >= 한도 면 폐기입니다.
- 키가 없다 → 만료 (
EXPIRED) - 키가 있고 횟수를 소진했다 → 폐기 (
ATTEMPTS_EXCEEDED)
폐기할 때 레코드를 삭제하고 싶었는데, 삭제하면 이 둘을 응답에서 구분할 수 없게 됩니다. 두 경우의 화면 문구가 달라야 했습니다. 만료는 "다시 받기", 폐기는 "잘못 입력한 횟수를 다 쓰셨습니다" 입니다. 그래서 레코드를 유지하고 상태는 카운터로 판별합니다.
발송 한도 소진과 코드 만료가 겹치는 상태는 생기지 않습니다
이 부분은 나중에 코드를 다시 볼 때 가장 잘못 추론하기 쉬운 지점이었습니다. 반대로 추론하면 실제로는 발생하지 않는 상태를 위한 화면을 구현하게 됩니다.
두 시간 값의 기준 시각이 다릅니다.
- 발송 윈도우는 첫 발송 기준 10분입니다. 가장 오래된 발송이 윈도우에서 제외돼야 슬롯이 회복됩니다
- 코드 유효시간은 마지막 발송 기준 10분입니다
마지막 발송이 첫 발송보다 늦으므로 슬롯 회복 시각 ≤ 코드 만료 시각 이 항상 성립합니다.
14:20:00 발송 1
14:20:05 발송 2
14:20:10 발송 3 → 한도 소진 (입력만 가능)
14:30:00 발송 1 이 윈도우에서 빠짐 → 재발송 1회 부활
14:30:10 코드 3 만료 ← 이미 막힌 상태가 아닙니다
따라서 "입력도 못 하고 재발송도 못 하는" 상태는 오입력 횟수를 소진한 경우에만 발생합니다. 그때 대기 시간은 10분 − (마지막 발송 − 첫 발송) 이라, 짧은 간격으로 연속 발송했으면 10분에 가깝고 간격을 두고 발송했으면 짧습니다.
두 값이 모두 10분이라서 성립하는 성질입니다. 유효시간이나 윈도우 길이 중 하나만 변경되면 성립하지 않습니다. 나중에 값을 조정할 사람을 위해 이 조건을 문서에 기록해 뒀습니다.
한도 차감은 발송 성공 시점입니다
기존 구현은 전송 구간을 try/catch(log.error) 로 감싸고, 게이트웨이가 success=false 를 반환해도 warn 로그만 기록한 뒤 200 을 반환했습니다. 프론트는 실패를 알 수 없으니, 문자가 도착하지 않은 경우가 모두 사용자의 재발송 요청으로 이어졌습니다.
여기에 발송 한도를 추가하면, 정상 사용자가 한도에 차단되는 경우 대부분이 남용 때문이 아니라 도착하지 않는 문자를 대기하며 재발송을 반복해 한도를 소진한 경우가 됩니다.
그래서 게이트웨이가 거절한 발송은 한도에서 차감하지 않고, 실패를 DELIVERY_FAILED 로 응답에 반환하고, 직전 코드도 폐기하지 않게 했습니다.
같이 본 것
- 자릿수는 변경하지 않았습니다. 채널에 따라 코드가 짧은 쪽이 있고, 짧은 쪽은 방어를 사실상 발송 한도가 전담하는 구조입니다. 대신 코드 생성기를
java.util.Random에서SecureRandom으로 변경했습니다. 자릿수를 유지하기로 했으므로 이 변경은 필수였습니다 - 대상 정규화가 없으면 제한 자체가 무의미합니다. 전화번호는 하이픈 제거가 이미 있었는데 이메일은 대소문자를 그대로 저장·비교하고 있었습니다. 대소문자만 변경해서 새 한도를 획득할 수 있었습니다
- 한도 값 세 개는 일단 상수로 정의했습니다. 운영 중 조정이 필요해지면 설정 값으로 분리하는 것이 적절합니다
최종 형태는 발송은 10분 윈도우 기준 채널별 한도, 오입력은 코드 1개당 한도, 재발송 시 이전 코드 폐기입니다. 총 입력 횟수 제한과 일 발송 한도는 도입하지 않았습니다. 앞의 두 제한이 먼저 적용되므로 추가 제한은 효과가 없고, 정상 사용자만 더 차단되기 때문입니다.