공개된 서비스는 언제나 두 종류의 손님을 함께 맞는다. 정직하게 필요한 만큼만 부르는 손님이 있고, 서비스를 지치게 하거나 허점을 노리며 두드리는 손님이 있다. 나는 처음 서비스를 열었을 때 앞의 손님만 상상했다가, 얼마 지나지 않아 뒤의 손님이 늘 함께 온다는 사실을 배웠다. 요청 제한과 보안은 이 두 손님을 갈라내어, 정직한 요청은 매끄럽게 받되 과한 요청과 악의를 막아내는 문지기 노릇이다.
이번 편에서는 서비스로 쏟아지는 요청을 어떻게 다스리고 그 요청들로부터 시스템을 어떻게 지킬지를 다룬다. 지나친 요청을 걸러내는 요청 제한이 어떤 원리로 동작하고 제한에 걸린 요청에 무엇을 응답하는지, 들어오는 값을 어떻게 의심하고 걸러내는지, 응답에 어떤 신호를 실어 브라우저에 방어를 부탁하는지를 살펴본다. 이 문지기가 부실하면 서비스는 과부하로 주저앉거나, 파고든 악의에 시스템 안쪽을 내주게 된다.
지나친 요청을 걸러내는 이유
요청 제한이 필요한 첫째 이유는 자원이 유한하기 때문이다. 서버가 한 순간에 처리할 수 있는 양에는 한계가 있고, 그 한계를 넘는 요청이 쏟아지면 모두의 응답이 함께 느려진다. 한 손님이 지나치게 많은 요청을 퍼부으면, 그 부담이 정직한 다른 손님들에게 고스란히 옮겨간다. 나는 요청 제한을 한정된 자원을 여럿이 공평하게 나눠 쓰게 하는 배분의 장치로 이해한다.
둘째 이유는 악의적인 남용을 막는 데 있다. 어떤 요청은 실수가 아니라 작정하고 서비스를 무너뜨리려는 것이거나, 비밀번호를 무수히 바꿔가며 맞혀보려는 시도이거나, 데이터를 통째로 긁어가려는 것이다. 이런 요청은 하나하나는 정상처럼 보여도, 짧은 시간에 엄청난 수로 몰아친다는 공통점이 있다. 요청의 빈도에 제한을 두면, 이런 폭주형 남용을 그 폭주라는 성질로 걸러낼 수 있다.
요청 제한의 기본 발상은 단순하다. 한 요청자가 일정한 시간 안에 보낼 수 있는 요청의 수에 상한을 두고, 그 상한을 넘으면 잠시 거절하는 것이다. 정직한 손님은 그 상한에 좀처럼 닿지 않으니 아무 불편을 느끼지 않고, 상한을 훌쩍 넘겨 두드리는 손님만 제지를 받는다. 이렇게 상한 하나로 정상적인 사용은 건드리지 않으면서 과한 사용만 눌러낸다. 나는 이 상한을 정할 때 정직한 사용의 여유를 넉넉히 남긴다.
제한의 기준을 누구로 잡느냐도 중요하다. 요청을 보낸 곳의 주소로 셀 수도 있고, 인증된 신원으로 셀 수도 있고, 발급한 열쇠로 셀 수도 있다. 신원으로 세면 같은 사람의 요청을 어디서 오든 하나로 묶어 셀 수 있어 더 정밀하지만, 인증 전의 요청에는 쓸 수 없다. 그래서 로그인 전의 요청은 주소로, 로그인 후에는 신원으로 세는 식으로 층을 나누기도 한다. 나는 지키려는 대상에 맞춰 기준을 고른다.
상한을 세는 방식에도 여러 결이 있다. 정해진 시간 구간마다 세어 구간이 바뀌면 다시 0에서 시작하는 방식이 있고, 지나간 시간에 비례해 조금씩 여유가 차오르는 방식도 있다. 앞의 것은 단순하지만 구간의 경계에서 몰림이 생길 수 있고, 뒤의 것은 그 몰림을 부드럽게 흩는다. 세는 방식이 다르면 같은 상한이라도 체감이 달라진다. 나는 이 방식을 서비스의 성격과 트래픽의 모양을 보고 정한다.
요청 제한은 막기만 하는 장치가 아니라 알리는 장치이기도 하다. 정직한 손님이 뜻하지 않게 상한에 닿았을 때, 그에게 지금 상한에 걸렸으니 잠시 뒤에 다시 오라고 분명히 알려주어야 한다. 아무 설명 없이 요청이 실패하면 그는 시스템이 고장 났다고 오해한다. 그래서 요청 제한의 응답은 막는 동시에 안내하는 역할을 함께 진다. 다음 절에서 이 응답을 자세히 본다.
제한에 걸린 요청에 답하기
요청이 상한을 넘겼을 때 서버는 그 사실을 뜻하는 분명한 응답을 돌려준다. 이때 쓰는 것이 요청이 너무 많다는 뜻의 상태 코드다. 이 코드는 앞 편에서 본 요청자 쪽의 문제를 뜻하는 400번대에 속하는데, 여기서는 요청 자체가 틀렸다기보다 그 빈도가 과하다는 뜻이다. 클라이언트는 이 코드를 받으면 요청을 고칠 것이 아니라 속도를 늦춰야 한다는 것을 안다.
이 응답에 반드시 곁들여야 할 것이 언제 다시 시도하면 되는지에 대한 안내다. Retry-After라는 헤더에 얼마 뒤에 다시 오라는 값을 담아, 클라이언트가 그 시간만큼 기다렸다가 다시 시도하게 한다. 이 안내가 없으면 클라이언트는 언제 풀릴지 몰라 곧바로 다시 두드리고, 그 재시도가 다시 제한에 걸리는 악순환이 벌어진다. 나는 요청 제한 응답에 이 기다림의 안내를 늘 함께 실어 이 악순환을 끊는다.
제한에 아직 걸리지 않은 평소의 응답에도 상한의 상태를 함께 알려주면 좋다. 지금 허용된 상한이 얼마이고, 그중 얼마가 남았으며, 언제 다시 채워지는지를 응답 헤더로 알리는 것이다. 이렇게 하면 클라이언트는 자신이 상한에 얼마나 다가갔는지를 미리 보고 스스로 속도를 조절할 수 있다. 벽에 부딪히고 나서야 아는 것보다, 벽이 다가옴을 미리 보는 편이 훨씬 낫다. 나는 이 남은 여유의 표시를 유용한 배려로 여긴다.
잘 만든 클라이언트는 이 신호들에 협조한다. 제한에 걸리면 안내받은 시간만큼 기다리고, 곧바로 다시 몰아치지 않는다. 특히 여러 요청이 한꺼번에 실패했을 때 모두가 같은 순간에 다시 시도하면 또 함께 몰리므로, 재시도 시점을 조금씩 흩어 몰림을 피한다. 나는 클라이언트를 만들 때 이 협조적인 재시도를 심어둔다. 문지기가 세운 규칙을 존중하는 클라이언트라야, 자신도 서비스도 함께 편해진다.
요청 제한을 설계할 때 조심할 것은, 정직한 사용을 옥죄지 않는 것이다. 상한을 지나치게 빡빡하게 잡으면, 평범하게 쓰는 손님까지 자꾸 벽에 부딪혀 서비스가 답답해진다. 반대로 너무 느슨하면 남용을 걸러내지 못한다. 나는 이 상한을 실제 사용 양상을 지켜보며 정하고, 한번 정하고 방치하기보다 트래픽이 자라면 그에 맞춰 다시 손본다. 상한은 고정된 값이 아니라 지켜보며 조율하는 값이다.
제한을 층으로 두는 것도 방법이다. 서비스 전체에 대한 큰 상한과, 특정한 무거운 요청에 대한 더 좁은 상한을 나누어 두는 것이다. 자원을 많이 잡아먹는 요청은 더 엄격히, 가벼운 요청은 더 넉넉히 다루면, 부담이 큰 쪽을 집중해 지키면서 가벼운 사용은 자유롭게 열어둘 수 있다. 나는 모든 요청에 하나의 상한을 똑같이 걸기보다, 요청의 무게에 따라 제한을 달리하는 편을 선호한다.
들어오는 값을 의심한다
요청 제한이 요청의 양을 다스린다면, 보안의 다른 축은 요청에 담긴 값을 의심하는 것이다. 밖에서 들어오는 모든 값은 일단 믿을 수 없는 것으로 대해야 한다. 정직한 손님이 채운 값일 수도 있지만, 시스템을 속이려고 정교하게 조작된 값일 수도 있기 때문이다. 나는 들어오는 값을 예의 바른 손님의 말이 아니라, 확인하기 전까지는 진위를 알 수 없는 주장으로 다룬다.
이 의심의 첫 실천이 들어오는 값을 검증하는 것이다. 각 값이 기대하는 형식과 범위에 맞는지를 받아들이기 전에 확인하고, 어긋나면 앞 편에서 다룬 방식으로 실패를 돌려준다. 숫자여야 할 자리에 엉뚱한 것이 오지 않았는지, 길이가 터무니없이 길지 않은지, 허용된 값의 범위를 벗어나지 않았는지를 살핀다. 이 검증을 통과한 값만 시스템 안쪽으로 들인다. 문 앞에서 거르는 것이 안에서 수습하는 것보다 늘 낫다.
특히 위험한 것이 들어온 값이 다른 곳에서 명령처럼 해석되는 경우다. 사용자가 넣은 값이 그대로 어떤 질의나 명령의 일부로 끼어들면, 교묘하게 조작된 값이 의도치 않은 명령으로 둔갑할 수 있다. 이를 막으려면 값을 명령의 뼈대와 섞지 않고, 언제나 순수한 데이터로만 다뤄 안쪽으로 넘겨야 한다. 값이 코드로 변신할 틈을 주지 않는 것이 핵심이다. 나는 외부 값이 명령에 직접 이어 붙는 구조를 가장 위험한 형태로 본다.
화면에 되비치는 값도 조심해야 한다. 사용자가 넣은 값이 그대로 다른 사용자의 화면에 그려질 때, 그 값 안에 실행되는 무언가가 숨어 있으면 남의 화면에서 그것이 살아난다. 그래서 밖에서 온 값을 화면에 내보낼 때는, 그것이 그저 글자로만 보이고 결코 실행되지 않도록 처리해야 한다. 들어올 때 의심하고 나갈 때 무해하게 만드는 두 겹의 방어가 필요하다. 나는 이 안팎 양쪽을 함께 챙긴다.
검증은 반드시 서버에서 해야 한다는 원칙도 빼놓을 수 없다. 클라이언트 쪽에서 하는 검증은 사용자의 편의를 위한 것일 뿐, 악의를 막는 방어가 되지 못한다. 클라이언트의 검증은 얼마든지 우회할 수 있어서, 조작된 요청은 그 검증을 건너뛰고 곧장 서버로 온다. 그러니 신뢰의 경계인 서버에서 다시 검증하는 것이 진짜 방어다. 나는 클라이언트 검증을 편의로, 서버 검증을 방어선으로 분명히 나눠 생각한다.
값을 의심하는 이 태도는 특정 기법의 문제가 아니라 하나의 습관이다. 어떤 값이 어디서 들어오든, 그것이 안전하다고 증명되기 전까지는 위험할 수 있다고 전제하는 것이다. 이 전제를 늘 깔아두면, 새로운 형태의 공격을 만나도 그 밑바탕의 방어가 흔들리지 않는다. 나는 보안을 개별 구멍을 막는 일이 아니라, 밖에서 오는 모든 것을 의심하는 자세를 몸에 붙이는 일로 이해한다.
응답에 방어의 신호를 싣는다
서버가 지키는 방어와 별개로, 브라우저에도 방어를 부탁할 수 있다. 응답에 특정한 신호를 실어, 브라우저가 이 콘텐츠를 다룰 때 지켜야 할 규칙을 알려주는 것이다. 브라우저는 이 신호를 읽고 그에 맞게 더 조심스럽게 행동한다. 나는 이 방식을, 방어를 서버 혼자 짊어지지 않고 콘텐츠를 실제로 그려내는 브라우저와 나눠 지는 협력으로 이해한다.
이런 신호의 하나는 이 페이지가 불러올 수 있는 자원의 출처를 제한하는 것이다. 어디에서 온 스크립트나 자원만 허용할지를 응답으로 미리 정해두면, 몰래 끼어든 낯선 출처의 코드는 브라우저가 실행을 거부한다. 앞 절에서 본, 화면에 숨어드는 실행형 공격에 대한 또 하나의 방어막인 셈이다. 서버의 처리를 뚫고 무언가 끼어들더라도, 브라우저 단에서 한 번 더 걸리게 하는 것이다.
또 다른 신호는 이 응답을 브라우저가 어떻게 해석할지를 못박는 것이다. 서버가 이것은 이런 종류의 콘텐츠라고 분명히 밝히고 브라우저가 멋대로 다르게 짐작하지 못하게 하면, 콘텐츠의 종류를 오해해서 생기는 위험을 줄일 수 있다. 데이터로 준 것을 브라우저가 실행 가능한 무언가로 오해하는 일을 막는 것이다. 나는 응답의 종류를 분명히 밝히고 그 해석을 브라우저의 짐작에 맡기지 않는다.
통신을 안전한 통로로만 하도록 브라우저에 요구하는 신호도 있다. 한번 안전한 통로로 접속한 뒤에는, 이후로 이 서비스에는 늘 안전한 통로로만 오라고 브라우저에 새겨두는 것이다. 그러면 사용자가 무심코 보호되지 않은 통로로 접근하려 해도 브라우저가 이를 안전한 통로로 바꿔준다. 통로의 보호 자체는 다음 편의 주제이지만, 그 보호를 강제하는 이런 신호는 여기서 함께 다뤄둘 만하다.
이 방어 신호들은 서버 쪽 방어를 대신하는 것이 아니라 겹쳐 쌓는 것이다. 앞서 본 값의 검증과 무해화가 서버가 세운 안쪽 방어라면, 이 신호들은 브라우저에 부탁한 바깥 방어다. 어느 한 겹이 뚫려도 다른 겹이 버티도록, 방어는 여러 층으로 겹쳐 두는 것이 원칙이다. 나는 하나의 완벽한 방어를 믿기보다, 서로 다른 여러 방어를 겹쳐 어느 하나가 무너져도 전체가 무너지지 않게 한다.
정리하면 요청 제한과 보안은 서비스로 몰려드는 요청을 다스리고 그 요청으로부터 시스템을 지키는 문지기다. 요청의 빈도에 상한을 두어 과부하와 남용을 걸러내고 걸린 요청에는 언제 다시 오라 안내하며, 들어오는 값은 모두 의심해 서버에서 검증하고, 응답에 실은 신호로 브라우저에도 방어를 나눠 맡긴다. 여러 겹의 방어를 겹치는 것이 요체다. 다음 편에서는 이 모든 통신이 오가는 통로 자체를 지키는 문제, 곧 안전한 연결과 그 위에서 빨라진 통신 방식을 다뤄 보려 한다.