결정-0021: 남용 방지는 엣지 · 애플리케이션 · 사람 증명 3층으로 나눈다
상태
승인됨. 도입 시점은 2026-08-05에 확정했습니다.
맥락
AUTH-01 구현 과정에서 시도 제한을 auth 컨텍스트 안에 인메모리로 만들었습니다. 보안 검토가 IP와 이메일 축의 비대칭 방향, 로그인 경로의 제한 부재, 프록시 뒤에서의 클라이언트 IP 위조를 지적해 반영했지만 그 과정에서 더 위의 질문 둘이 남았습니다.
- 시도 제한은 이 요구사항 한정인가, 전역이어야 하는가. 당시에는 auth 컨텍스트가 소유하고 인증 흐름에만 걸려 있었습니다.
- 한도에 닿았을 때 차단이 맞는가. 차단은 정상 사용자를 잠그고, 공격자가 남의 계정을 반복해 잠그는 수단이 됩니다.
당시 전제는 아직 배포 전이고 단일 인스턴스라는 것입니다. 파일 저장소로 Cloudflare R2가 이미 범위에 들어 있어 Cloudflare 의존이 이미 존재합니다.
확인한 사실
시도 제한에는 성격이 다른 두 종류가 있습니다.
| 종류 | 판단 기준 | 어디서 가능한가 |
|---|---|---|
| 요청량 제한 | 이 IP가 단위 시간에 몇 번 두드렸는가 | 엣지와 프록시, 앱 어디서나 가능합니다 |
| 의미 기반 제한 | 이 이메일로 코드를 몇 번 받아갔는가, 이 계정에 몇 번 로그인을 시도했는가 | 앱에서만 가능합니다 |
엣지는 요청 본문을 열어 어느 계정에 대한 시도인지 판단하지 않습니다. 따라서 공격자가 IP를 분산하면 계정 하나를 노린 공격은 엣지 규칙에 걸리지 않습니다. 반대로 앱에서만 막으면 모든 요청이 서버까지 도달해 커넥션과 스레드, CPU를 소비한 뒤에 거부됩니다. 둘은 서로를 대체하지 못합니다.
캡챠는 차단 장치가 아니라 비용 장치입니다. 사람을 고용해 푸는 우회 시장이 실재하므로 캡챠를 넣더라도 훨씬 느슨한 상한은 남겨야 합니다.
Cloudflare 무료 플랜의 제약을 2026년 7월에 확인했습니다. 요청률 규칙이 1개이고 측정 기간은 10초 또는 1분만 선택할 수 있으며, 챌린지 액션 사용 시 지속 시간을 지정할 수 없고 스로틀링으로 동작합니다. 규칙을 세밀하게 쪼갤 수 없으므로 가장 넓은 그물 하나로 써야 합니다.
Turnstile 은 R2와 같은 계정에서 발급됩니다. 새 외부 의존이 아니라 이미 있는 의존의 활용이며, 이 사실이 외부 서비스를 하나 더 요구한다는 반대 근거를 없앱니다.
검토한 대안
어디서 막을 것인가
| 안 | 장점 | 기각 사유 |
|---|---|---|
| 앱에서만 | 의미 기반 판단이 가능하고 외부 의존이 없습니다 | 모든 요청이 서버까지 도달하며, 정적 경로와 존재하지 않는 경로에 대한 폭주를 전혀 다루지 못합니다 |
| 엣지에서만 | 서버에 도달조차 하지 않아 가장 쌉니다 | 계정과 이메일 단위 방어가 불가능해 결정-0015가 요구한 이메일별 OTP 발급 제한을 구현할 수 없습니다 |
| 3층으로 나눔 (채택) | 각 층이 자기가 판단할 수 있는 것만 맡습니다 | — |
한도에 닿으면 무엇을 할 것인가
| 안 | 장점 | 기각 사유 |
|---|---|---|
| 차단 | 구현이 가장 단순합니다 | 비밀번호를 잊은 정상 사용자가 잠기고, 공격자가 피해자 이메일로 오답을 반복해 그 계정을 무기한 잠글 수 있습니다 |
| 지연(백오프) | 사용자 체감은 덜 나쁩니다 | 공격자는 요청을 병렬로 던지므로 지연이 방어가 되지 않습니다 |
| 사람 증명 요구 (채택) | 차단이 아니라 사람이면 계속입니다. 정상 사용자는 몇 초를 더 쓰고 넘어가고 자동화된 반복은 비용이 급등합니다. 표적 계정 잠금이 원천적으로 사라집니다 | — |
결정
3층으로 나누고 각 층은 자기가 판단할 수 있는 것만 맡습니다.
| 층 | 담당 | 판단 근거 | 비용 |
|---|---|---|---|
| 1. 엣지 | IP당 요청률, 봇 트래픽 | 요청 메타데이터 | 설정만 하며 코드가 0줄입니다 |
| 2. 애플리케이션 | 이메일별 · 계정별 · 용도별 시도 | 도메인 지식 | 이미 구현되어 있습니다 |
| 3. 사람 증명 | 자동화된 반복 시도 | 챌린지 통과 여부 | 서버와 프론트엔드 양쪽 |
- 엣지는 Cloudflare 가 담당하되 그것에 묶이지 않습니다. 무료 플랜의 규칙 1개 제약에 맞춰 API 경로 전체에 넓은 그물 하나만 겁니다. 세밀한 판단은 2층에 맡깁니다. Cloudflare 를 쓰지 않는 배포에서는 리버스 프록시의 요청률 제한이 같은 일을 합니다. 배포 전제가 이미 리버스 프록시이므로 1층은 새 외부 의존 없이도 성립하며, 이 사실이 아래 10번의 도입 시점 유예를 감당 가능하게 만듭니다.
- 2층은 현행 구조를 유지합니다. 시도 제한 포트는 auth 컨텍스트가 소유하고 두 번째 소비자 컨텍스트가 실제로 생길 때 공용으로 승격합니다. 미리 만들지 않는다는 패키지 배치와 참조 규칙의 원칙을 따릅니다.
- 인메모리 구현은 단일 인스턴스 전제입니다. 다중 인스턴스로 배포하면 카운터가 인스턴스마다 갈려 한도가 사실상 배수가 되고 재배포마다 초기화됩니다. 그 시점에 공유 저장소 어댑터로 교체하며, 포트 뒤에 있으므로 구현체 교체로 끝납니다.
- 사람 증명은 Turnstile 로 하고 로그인과 OTP 확인 경로에서 N회 실패 후 요구합니다. 챌린지를 통과하면 시도를 계속할 수 있습니다. 2층의 상한은 없애지 않고 느슨하게 남깁니다. 캡챠는 비용 장치이지 차단 장치가 아니기 때문입니다.
- 캡챠 검증은 비밀번호 해시 계산보다 먼저 합니다. bcrypt 는 의도적으로 느리므로 검증을 뒤에 두면 캡챠가 오히려 CPU 고갈의 증폭기가 됩니다.
- 키가 없으면 캡챠는 꺼진 채 동작합니다. 이 저장소를 받은 사람이 Cloudflare 설정 없이도 즉시 실행할 수 있어야 하며, 키를 넣는 순간 켜집니다.
- 캡챠도 포트 뒤에 둡니다. 다른 캡챠 서비스로 바꾸면 어댑터만 교체합니다.
- 신뢰 프록시 목록에 Cloudflare 대역을 넣습니다. 서버 IP 대역이 공개되어 있으므로 짐작으로 채우던 값을 공식 문서 값으로 확정할 수 있습니다.
- 엣지를 쓰지 않는 배포에서는 1층이 비어 있음을 문서로 경고합니다. 앱에 전역 IP 스로틀을 안전망으로 넣지 않습니다. 지금 실익이 없고, 필요해지면 엣지 없는 환경에서 켜는 옵션으로 추가하는 편이 구조가 깔끔합니다.
- 1층과 3층은 배포 시점에 도입하며 그때까지 2층만 제공합니다.
미리 만들 이득이 없다는 것이 근거이며 세 가지를 확인했습니다. 1층은 배포하면서 얻어집니다. Cloudflare 면 설정만이고 아니면 리버스 프록시의 요청률 제한이며, 어느 쪽이든 애플리케이션 코드는 0줄이라 지금 앞당겨도 나중에 할 일이 줄지 않습니다. 3층은 포트 뒤에 있습니다. 지금 붙이든 배포 직전에 붙이든 서버 작업량이 같고, 지금 하면 프론트엔드 위젯 작업만 먼저 딸려옵니다. 엣지 규칙의 수치는 실제 트래픽 없이 정할 수 없습니다. 임계를 짐작으로 박아 두면 첫 배포 때 어차피 다시 잡습니다.
도입 시점에 정할 것은 넷이며 지금 정하지 않는 것이 본 결정의 내용입니다. 엣지 규칙의 구체 수치, 캡챠 발동 임계 횟수, 캡챠를 로그인에만 걸지 OTP 확인에도 걸지, 캡챠 서비스 장애 시의 정책이 그것입니다.
유예하는 동안 남는 위험 — 표적 계정 잠금
주의
이것 하나가 캡챠를 미루는 대가입니다
공격자가 피해자의 이메일로 로그인을 계정 한도까지 실패시키면 그 계정이 한도 기간 동안 막힙니다. 분당 한 번만 던져도 무기한 유지할 수 있고 피해자는 스스로 풀 수 없습니다. 푸는 조건이 로그인 성공인데 그 로그인이 막혀 있기 때문입니다. 다른 위협인 크리덴셜 스터핑과 해시 CPU 고갈, OTP 대입은 2층이 이미 막고 있으며 유예로 약해지지 않습니다.
받아들이는 근거는 아직 배포 전이고 사용자가 없다는 것입니다. 이 공격은 특정 계정을 아는 공격자가 지속적으로 수행해야 성립하므로 공개 서비스가 되기 전에는 성립 조건 자체가 없습니다. 배포와 함께 3층을 도입하면 이 위험은 그 시점에 사라지며, 유예 기간과 위험 노출 기간이 겹치지 않는다는 점이 이 유예를 감당 가능하게 만듭니다.
되살아나는 신호는 배포 일정이 잡히는 것이며, 그때 3층 도입이 배포 체크리스트에 들어갑니다.
재검토 조건
- 다중 인스턴스로 배포할 때 2층 어댑터를 공유 저장소로 교체합니다. 본 결정 자체는 유지됩니다.
- 캡챠 우회가 실제로 측정될 때 임계를 조이거나 위험 신호 기반 판단을 검토합니다.
- Cloudflare 를 쓰지 않게 될 때 1층은 리버스 프록시의 요청률 제한으로 대신합니다. 그것마저 없는 배포라면 1층이 비므로 앱 전역 스로틀 도입을 그때 결정기록으로 남깁니다.
- 두 번째 컨텍스트가 시도 제한을 필요로 할 때 포트를 공용으로 승격합니다.
결과
1층과 3층 도입 전에는 다음과 같습니다.
- 각 층이 자기가 판단할 수 있는 것만 맡는다는 구조가 정해져, 나중에 무엇을 어디에 붙일지 다시 논의하지 않습니다.
- 개발 비용이 지금은 0입니다. 2층은 이미 있고 1층은 배포 설정으로, 3층은 포트 뒤 어댑터로 나중에 붙습니다.
- 대가는 표적 계정 잠금 하나이며 위 절에 근거와 함께 기록했습니다.
1층과 3층 도입 이후에는 다음과 같습니다.
- 엣지가 못 하는 계정 단위 방어와 앱이 하기 비싼 요청량 방어가 각각 제자리를 찾습니다.
- 정상 사용자가 잠기지 않습니다. 비밀번호를 잊어 여러 번 틀려도 사람임을 증명하면 계속할 수 있습니다.
- 표적 계정 잠금이 사라집니다. 공격자가 남의 계정에 오답을 넣어도 그 계정 주인이 막히지 않습니다.
- 비밀번호 검증 앞에 사람 증명이 서므로 느린 해시 계산 자체를 노린 자원 고갈이 막힙니다.
- 감수하는 것
- 프론트엔드 작업이 따라옵니다. 로그인 화면에 위젯을 띄우고 토큰을 함께 보내야 합니다.
- 캡챠 서비스 장애가 로그인 장애가 됩니다. 장애 시 정책을 구현 시점에 정해야 합니다.
- 접근성 부담이 생깁니다. 스크린리더와 저시력 사용자에게 캡챠는 진입 장벽이며, Turnstile 은 대부분 상호작용 없이 통과하지만 0은 아닙니다.
- 3층은 Cloudflare 에 묶입니다. 다만 R2로 이미 묶여 있어 새로 생기는 종속은 아니고, 포트 뒤에 있어 다른 서비스로 교체할 수 있습니다. 1층은 결정 1에 따라 Cloudflare 없이도 성립합니다.
개정
- 2026-08-17: 결정 2가 걸어 둔 승격 조건이 충족되어 시도 제한 포트를 공용으로 옮겼습니다. 결정 자체는 바뀌지 않습니다. todo 컨텍스트가 담당자 지정에 시도 제한을 요구하면서 두 번째 소비자가 실제로 생겼습니다.
AttemptRateLimiter와 인메모리 구현이auth아래에서common.ratelimit으로 이동했으며 auth 의 사용처는 임포트만 바뀌었습니다. 승격 시점을 미룬 대가는 이동 커밋 하나였고, 미리 만들었다면 소비자가 하나뿐인 채로 공용 위치를 정해야 했습니다. 2층이 담당하는 축에 컨텍스트별 조회 시도가 추가되어, 담당자 지정은 존재 확인보다 먼저 한도를 묻습니다. 순서를 뒤집으면 거절 응답 자체가 이메일 훑기 수단이 됩니다.