website: nginx에 레이트리미팅 3단계 존 도입
nginx에 요청 제한이 전혀 없다는 걸 확인하고, 지원서·댓글·구독·비번찾기 같은 permitAll 엔드포인트부터 막았다.
시작은 악의적 트래픽에 어떻게 대응하고 있냐는 질문이었다. 답을 하려고 실제 설정을 들여다봤는데, nginx에 rate limit 관련 설정이 하나도 없었다. 백엔드 쪽도 확인해보니 bucket4j 같은 앱 레벨 스로틀링이 없었고, pm/docs/feed-write-policy.md에는 "봇 가드 — 추후"라고 이미 스코프 밖으로 명시돼 있었다. 즉 이 부분은 처음부터 나중으로 미뤄둔 채 아무도 손대지 않은 상태였다.
문제는 permitAll()로 열려 있는 엔드포인트들이었다. /api/applications(지원서), /api/posts/*/comments(댓글), /api/notifications/subscribe(구독), /api/admin/password/forgot(비번재설정 메일) — 인증 없이 누구나 반복 호출할 수 있는 구조였다. 특히 password/forgot는 이메일을 실제로 발송하는 엔드포인트라서, 남용되면 OCI Email Delivery 월 3,000통 한도까지 위협할 수 있는 게 걸렸다. 업로드 엔드포인트(/api/feed/images, /api/admin/staff/images)도 로그인은 필요하지만 요청 횟수 제한은 없어서, 로그인만 하면 무제한으로 때릴 수 있는 상태였다.
전체를 하나의 rate limit으로 묶을지, 엔드포인트별로 나눌지를 고민했는데, 트래픽 성격이 너무 달라서 하나로 묶는 건 맞지 않다고 판단했다. 정상적인 페이지 탐색(GET 위주)까지 걸리게 하면 그 자체로 서비스 저하고, 반대로 지원서/댓글 같은 쓰기 액션까지 느슨하게 풀어두면 원래 문제로 돌아간다. 그래서 존을 세 단계로 나눴다.
general: 10r/s, burst 20 — 일반 조회. 사람이 쓰는 페이지 탐색에서 걸릴 일이 없도록 넉넉하게.sensitive: 1r/s, burst 3 — 지원서/댓글/구독/비번찾기. 사람이 초당 1번씩 반복할 이유가 없는 액션들이라 빡빡하게.upload: 2r/s, burst 10 — 이미지 업로드. 로그인된 사용자가 한 세션에 여러 장 올리는 건 허용해야 해서 sensitive보다는 여유를 뒀다.
여기에 limit_conn_zone으로 IP당 동시 연결 20개 제한을 걸고, slowloris류(연결을 일부러 느리게 끌어서 워커를 계속 붙잡아두는 공격) 대응으로 client_body_timeout, client_header_timeout, send_timeout을 10초로 잡았다. 키는 전부 $binary_remote_addr라 IP별로 독립 집계된다. 덤으로 server_tokens off도 넣어서 응답 헤더에서 nginx 버전이 노출되지 않게 했다 — 직접적인 트래픽 제어는 아니지만 취약점 스캐닝 표면을 줄이는 차원에서 같이 처리했다.
설정만 넣고 끝내지 않고 실제로 검증했다. /api/applications에 연속으로 요청을 쏴서 burst 3을 넘긴 요청이 429로 즉시 막히는 걸 확인했고, 동시에 /api/posts 같은 일반 조회는 영향 없이 계속 200을 반환하는 것도 같이 확인했다. 배포는 DB 복원 절차 때와 같은 패턴으로 갔다 — nginx -t로 문법 검증하고, reload로 무중단 적용한 다음, 헬스체크로 마무리.
infra/nginx.conf는 gitignore라 레포에 실물이 없어서, infra/CLAUDE.md가 실제 구조를 확인할 수 있는 유일한 문서다. 그래서 이번에도 서버에 반영한 값을 그대로 문서에 옮겨 적었다. nginx를 바꿀 때마다 이 문서도 같이 갱신해야 나중에 "실제로 뭐가 켜져 있는지" 헷갈리지 않는다.