[실전 보안 06] 요청 폭주를 세어서 막는다

지난 편에서 사람과 봇을 구분하는 조용한 검증 위젯을 다뤘다. 하지만 사람으로 판별된 뒤에도, 심지어 정상 계정이라도 짧은 시간에 요청을 폭주시키면 사이트가 흔들린다고 했다. 이번 편은 그 폭주를 눌러 내는 장치, 요청 횟수 제한에 대한 이야기다. 흔히 레이트 리밋이라고 부른다. 개념은 단순하다. 같은 출처가 정해진 시간 안에 보낼 수 있는 요청 수에 상한을 두는 것이다. 하지만 막상 우리 커뮤니티에 적용해 보니 어디에 얼마의 상한을 둘지 결정하는 일이 생각보다 섬세한 작업이었다.


1. 왜 횟수를 세야 하는가

요청 횟수 제한이 왜 필요한지는 몇 가지 공격 장면을 떠올리면 분명해진다. 첫째는 무차별 대입이다. 비밀번호를 모르는 공격자가 로그인 창에 후보를 하나씩 계속 넣어 보는 공격인데, 시도 횟수에 제한이 없으면 언젠가는 맞춘다. 하지만 한 계정에 대한 로그인 시도를 짧은 시간 안에 몇 번으로 제한하면, 공격자는 사실상 대입을 포기해야 한다.


둘째는 자원 소진이다. 서버가 처리할 수 있는 요청량에는 한계가 있다. 누군가 짧은 시간에 엄청난 요청을 쏟아부으면 정상 사용자의 요청까지 밀려 응답이 느려지거나 멈춘다. 특히 나처럼 요청량에 따라 비용이 매겨지는 환경에서는 폭주가 곧바로 요금 폭탄으로 이어진다. 횟수 제한은 이런 폭주를 초입에서 잘라 비용과 안정성을 동시에 지켜 준다.


셋째는 데이터 긁어 가기다. 우리 글을 통째로 복사하려는 자동 수집은 짧은 시간에 수많은 페이지를 순서대로 훑는다. 사람이라면 그렇게 빠르고 규칙적으로 페이지를 넘기지 않는다. 이 비정상적인 속도와 규칙성을 횟수 제한으로 걸러 내면 대량 수집을 크게 늦출 수 있다. 요청의 내용이 아니라 빈도만 봐도 남용의 상당수가 드러난다는 게 이 장치의 핵심이다.


2. 무엇을 기준으로 셀 것인가

횟수를 세려면 먼저 누구의 요청인지를 묶는 기준이 있어야 한다. 가장 흔한 기준은 요청이 온 출처, 즉 아이피 주소다. 같은 주소에서 짧은 시간에 너무 많은 요청이 오면 제한하는 방식이다. 구현이 간단하고 로그인하지 않은 방문자에게도 적용할 수 있어 기본으로 삼기 좋았다. 다만 한 건물이나 통신망이 같은 출구 주소를 공유하는 경우, 여러 사람이 한 주소로 묶여 억울하게 제한될 수 있다는 한계가 있었다.


그래서 로그인한 사용자에게는 계정을 기준으로도 셌다. 누가 요청하는지 아니까 계정별로 정확히 제한할 수 있다. 또 어떤 행동이냐에 따라 기준을 달리했다. 로그인 시도, 글쓰기, 댓글, 검색은 각각 위험도와 정상 사용 빈도가 다르다. 그래서 하나의 상한을 전부에 똑같이 적용하지 않고, 행동마다 별도의 상한을 두는 편이 훨씬 합리적이었다.


여기서 어려운 건 정상 사용자의 자연스러운 사용을 방해하지 않으면서 공격만 걸러 내는 경계를 찾는 일이었다. 상한을 너무 낮게 잡으면 열심히 활동하는 진짜 사용자가 자꾸 막혀 불편해하고, 너무 높게 잡으면 공격을 못 거른다. 이 경계는 책상에서 정할 수 없었다. 실제 로그를 보며 정상 사용자의 최대 활동량이 어느 정도인지 관찰하고, 그보다 넉넉히 위에, 그러나 공격으로 보기엔 확실히 과한 지점에 선을 그었다.


3. 시간 창을 어떻게 나눌까

횟수 제한은 항상 시간과 짝을 이룬다. 몇 번이라는 숫자만으로는 의미가 없고, 얼마 동안 몇 번인지가 있어야 한다. 예를 들어 한 계정의 로그인 시도를 짧은 시간 창 안에서 몇 번으로 제한하는 식이다. 이 시간 창을 어떻게 다루느냐에 따라 방식이 갈린다. 딱 잘라 일정 시간마다 횟수를 초기화하는 단순한 방식은 구현이 쉽지만, 창의 경계에서 순간적으로 두 배의 요청이 몰릴 수 있는 허점이 있다.


그래서 좀 더 부드럽게, 최근 얼마 동안의 요청을 이어서 세는 방식이 선호된다. 지나간 시간만큼 조금씩 여유가 회복되는 방식으로 생각하면 편하다. 물통에 물이 일정 속도로 빠지면서 새 요청이 물을 채우고, 물이 넘치면 제한하는 그림을 흔히 든다. 이렇게 하면 경계에서의 순간 폭주를 막으면서도 정상 사용자에게는 자연스럽게 느껴진다. 내 환경에서는 요청 정보를 짧게 기억하는 빠른 저장소를 활용해 이 계수를 구현했다.


또 하나 중요한 건 제한에 걸렸을 때 어떻게 응답하느냐였다. 무작정 막기만 하면 정상 사용자는 무슨 일인지 몰라 당황한다. 그래서 제한에 걸리면 잠시 후 다시 시도해 달라는 안내와 함께, 얼마나 기다려야 하는지를 알려 주는 응답을 돌려주도록 했다. 자동화된 클라이언트도 이 신호를 보고 요청 속도를 스스로 늦추게 하려는 의도였다. 막는 것과 안내하는 것은 함께 가야 했다.


4. 애플리케이션에서 직접 제어하기

내가 쓰는 클라우드 환경에도 앞단에서 요청을 제한하는 기능이 있었다. 하지만 내가 쓰는 요금제에서는 세밀한 숫자 조건을 다루는 데 제약이 있어, 결국 상당 부분을 애플리케이션 코드 안에서 직접 제어하기로 했다. 서버 코드가 요청을 처리하기 전에, 이 출처나 계정이 최근 얼마 동안 이 행동을 몇 번 했는지 확인하고 상한을 넘었으면 거절하는 방식이다.


직접 제어의 장점은 우리 서비스의 맥락을 반영할 수 있다는 것이었다. 예를 들어 로그인 실패는 엄격하게 제한하되 성공한 로그인은 느슨하게 둔다든지, 새 계정은 더 조이고 오래된 신뢰 계정은 풀어 준다든지, 우리 사이트만의 규칙을 촘촘히 넣을 수 있었다. 앞단 방화벽이 넓고 거친 그물이라면, 애플리케이션 제한은 상황을 아는 정교한 손길이었다. 이 둘을 겹쳐 쓰는 게 이상적이었다.


다만 직접 구현에는 함정도 있었다. 요청마다 계수 저장소를 확인하고 갱신하는 작업 자체가 비용이고, 잘못하면 이 방어 장치가 오히려 부하를 키운다. 그래서 계수는 아주 가볍고 빠른 저장소에 두고, 확인 로직도 최대한 단순하게 유지했다. 방어 장치가 지켜야 할 대상보다 무거워지면 안 된다는 것, 이것도 실제로 만들어 보며 얻은 균형 감각이었다.


5. 횟수 제한 너머

요청 횟수 제한을 갖추자 무차별 대입과 단순 폭주는 상당히 눌렸다. 하지만 이 장치는 요청의 빈도만 볼 뿐, 요청의 내용이 악의적인지는 판단하지 못한다. 정상 속도로 들어오지만 그 안에 공격 코드가 담긴 요청은 횟수 제한을 유유히 통과한다. 예를 들어 주소나 입력값에 데이터베이스를 노리는 조작을 끼워 넣는 시도는 한두 번의 요청만으로도 치명적이다.


이런 악성 내용을 걸러 내려면 요청의 형태와 내용을 검사하는 장치가 필요하다. 알려진 공격 패턴을 담은 규칙으로 들어오는 요청을 훑어, 수상한 것을 우리 서버에 닿기 전에 차단하는 방식이다. 이것이 흔히 웹 방화벽이라 부르는 계층이다. 요청 제한이 얼마나 자주 오느냐를 본다면, 웹 방화벽은 무엇이 담겨 오느냐를 본다.


요청 제한을 운영하며 얻은 감각 하나는, 이 장치가 방어이자 동시에 조기 경보라는 것이었다. 어떤 계정이나 출처가 자꾸 제한에 걸린다면, 그건 그곳에서 비정상적인 일이 벌어지고 있다는 신호다. 평소에 잠잠하던 지점에서 갑자기 제한이 빗발치면 새로운 공격이 시작됐다는 뜻일 수 있다. 그래서 나는 제한에 걸린 기록을 그냥 버리지 않고, 어떤 패턴으로 걸리는지 이따금 살폈다. 방어 장치가 남기는 기록은 바깥 위협을 읽는 값진 자료였다.


또 유의한 건 제한을 우회하려는 시도 자체도 있다는 점이었다. 출처 주소를 계속 바꿔 가며 각 주소로는 상한 아래로 요청을 나눠 보내는 식이다. 이런 분산된 접근은 단순한 출처 기준 제한만으로는 잡기 어려웠다. 그래서 계정 기준, 행동 기준, 그리고 앞 편의 봇 검증까지 여러 기준을 함께 걸어, 한 기준을 피해도 다른 기준에 걸리도록 겹쳐 두는 게 중요했다. 단일 장치를 정교하게 만드는 것보다 서로 다른 장치를 겹치는 편이 우회에 강했다.


그래서 다음 편에서는 웹 방화벽으로 악성 트래픽을 거르는 이야기를 하려 한다. 이 장치가 어떤 공격을 걸러 주고, 반대로 어떤 한계를 갖는지, 그리고 신생 사이트 운영자가 앞단 방화벽에 무엇을 맡기고 무엇을 스스로 감당해야 하는지를 실제 경험을 바탕으로 짚어 보겠다. 요청 제한과 웹 방화벽이 각자 다른 각도에서 같은 목표를 지킨다는 걸 확인하는 편이 될 것이다.