웹/클라우드/인프라로 돌아가기
웹/클라우드/인프라신윤섭·2026년 8월 17일

IP로 차단했는데 왜 안 막히나: 클라이언트 IP는 헤더에 적힌 주장일 뿐이다

공유

로컬에서는 완벽했던 차단 기능

Claude Code한테 시켰다. "로그인 5번 틀리면 그 IP를 10분간 막아줘." 코드가 나온다. 대충 이렇게 생겼다.

// 로그인 실패를 세는 부분은 생략. 여기서는 "누구의 실패인지" 정하는 한 줄만 본다
const ip = req.headers['x-forwarded-for']?.split(',')[0] ?? req.socket.remoteAddress;
if (failCount.get(ip) >= 5) return res.status(429).send('too many attempts');

로컬에서 다섯 번 틀려보니 정확히 막힌다. 잘 돌아가는 걸 확인하고 배포한다. 그리고 며칠 뒤, 둘 중 하나가 벌어진다.

하나는 이렇다. 누군가 비밀번호를 몇 만 번 찍어보고 있는데 429가 한 번도 안 뜬다. 로그를 열어보니 요청마다 IP가 다르다. 전 세계에서 골고루 들어온 것처럼 보이지만, 실은 한 사람이 요청 헤더의 글자만 바꿔가며 보내고 있다.

다른 하나는 정반대다. 어느 날 갑자기 아무도 로그인을 못 한다. 비밀번호를 틀린 사람이 없는데 전부 429를 받는다. 로그를 보면 모든 요청의 IP가 똑같은 값 하나로 찍혀 있다.

정반대로 보이는 두 사고지만 뿌리는 하나다. 서버가 "접속한 사람의 IP"라고 부르는 그 값이, 서버가 확인한 사실이 아니라 누군가 적어서 보낸 글자였다는 것.

서버가 진짜로 아는 주소는 딱 하나다

한 겹 아래로 내려가야 한다. 웹 요청은 HTTP 이전에 TCP 연결로 시작한다. 누군가 내 서버에 연결을 걸면 운영체제는 그 연결의 상대편 주소를 알고 있다. Node로 치면 req.socket.remoteAddress, 우리말로 하면 "지금 나와 연결을 맺고 있는 저쪽 끝의 주소"다.

이 값은 함부로 속이기 어렵다. TCP는 보내자마자 데이터가 흐르는 방식이 아니라, 서버가 "받았다"는 답장을 보내고 그걸 다시 받아야 연결이 열리는 방식이기 때문이다. 보내는 주소를 거짓으로 적으면 답장이 엉뚱한 곳으로 날아가 연결이 열리지 않고, 연결이 없으면 로그인 요청을 시작할 수조차 없다. 절대 안전하다고 할 수는 없어도, 적어도 이 주소는 서버가 주고받으며 확인한 값이다.

문제는 요즘 앱 앞에 서 있는 것들이다. Cloudflare를 붙였다면 사용자는 Cloudflare에 접속하고 Cloudflare가 내 서버에 접속한다. Vercel이나 Railway라면 플랫폼의 라우팅 계층이 먼저 요청을 받고, AWS라면 로드 밸런서가 앞에 선다. 이때 내 서버가 연결을 맺고 있는 상대는 언제나 그 중간 장비다. 사용자가 아니다.

택배로 옮겨보면 이해가 빠르다. 문을 열면 눈앞에 서 있는 건 택배 기사다. 서버가 확실히 아는 건 여기까지다. "이 물건을 원래 누가 보냈는가"는 문 앞에서 확인할 방법이 없다. 송장에 적힌 발신인 칸을 읽는 수밖에 없다.

프록시 뒤에서 서버가 실제로 보는 주소와 헤더에 쌓이는 값
프록시 뒤에서 서버가 실제로 보는 주소와 헤더에 쌓이는 값

X-Forwarded-For는 프록시가 적어주는 메모다

그 송장의 발신인 칸이 X-Forwarded-For 헤더다. 중간에 선 프록시가 "원래 이 요청을 보낸 사람의 주소는 이거였습니다"라고 뒤쪽 서버에 알려주려고 붙이는 메모다.

MDN이 정리한 형식은 이렇다.

X-Forwarded-For: <client>, <proxy1>, <proxy2>, ..., <proxyN>

왼쪽 끝이 최초 클라이언트고, 오른쪽으로 갈수록 최근에 거친 프록시다. 프록시가 여러 대면 각자 자기가 받은 상대의 주소를 뒤에 이어 붙이면서 목록이 길어진다. 이 관례를 표준으로 정리한 Forwarded 헤더가 RFC 7239로 나와 있긴 하지만, MDN이 훨씬 덜 쓰인다고 적어둘 만큼 보급이 더디다.

여기까지는 편리한 관례다. 그런데 결정적인 사실이 하나 있다. 이건 그냥 평범한 HTTP 요청 헤더다. 서명도 암호도 검증 절차도 없고, 브라우저만 보낼 수 있는 것도 아니다. 터미널에서 한 줄이면 누구든 원하는 값을 채워 넣는다.

curl -H "X-Forwarded-For: 1.2.3.4" https://myapp.com/login

송장의 발신인 칸을 아무나 볼펜으로 채워 넣을 수 있는 셈이다. 그러니 이 헤더를 읽는 건 사실 확인이 아니라, 상대의 주장을 받아 적는 일에 가깝다.

왼쪽 끝을 믿으면 문이 열린다

앞의 코드로 돌아가 보자. .split(',')[0], 즉 목록의 왼쪽 끝을 클라이언트로 삼았다. MDN 설명대로라면 맞는 위치다. 그런데 이 위치가 하필 공격자가 가장 쉽게 채워 넣는 자리다.

AWS 애플리케이션 로드 밸런서(ALB)의 기본 동작을 보면 이유가 선명해진다. 들어온 요청에 X-Forwarded-For가 이미 있으면 지우지 않고, 실제 클라이언트 주소를 그 뒤에 이어 붙인다. 공식 문서의 예시 표가 그대로 보여준다. 클라이언트가 X-Forwarded-For: 127.0.0.4를 넣어 보내면 백엔드에는 X-Forwarded-For: 127.0.0.4, 127.0.0.1이 도착한다. 앞의 값은 사용자가 쓴 글자고, 뒤의 값이 로드 밸런서가 확인한 주소다.

그러니 이 구성에서 왼쪽 끝을 읽는 코드는 정확히 공격자가 쓴 값을 읽는다. 진짜 주소는 오른쪽에 얌전히 붙어 있는데 아무도 보지 않는다. 요청마다 첫 값을 다른 IP로 바꿔 적으면 서버 눈에는 매번 새 사람이 처음 로그인을 시도하는 것으로 보인다. 다섯 번 제한은 사실상 없는 셈이다.

MDN도 이 지점을 경고로 못 박아뒀다. 레이트 리밋이나 IP 기반 접근 통제처럼 보안에 쓰이는 용도라면 신뢰하는 프록시가 직접 붙인 주소만 써야 하고, 그러지 않으면 레이트 리밋 회피나 접근 통제 우회로 이어진다고 적혀 있다.

이론이 아니라 반복해서 터진 사고다. 분산형 SNS 소프트웨어 Misskey의 CVE-2025-66482(GHSA-wwrj-3hvj-prpm)가 정확히 이 문제였다. 위조한 X-Forwarded-For 하나로 로그인 시도 횟수 제한이 뚫렸다. 프록시를 믿을지 정하는 trustProxy 설정이 2025.9.1에 생기긴 했지만 기본값이 "믿는다" 쪽이었고, 2025.12.0에서 그 기본값을 뒤집으며 정리됐다.

Mastodon의 권고(GHSA-c2r5-cfqr-c553)는 방향이 반대라 같이 볼 만하다. Rails의 IP 추출 미들웨어는 목록의 마지막 값을, 그것이 믿을 수 없는 곳에서 왔더라도 신뢰한다. "가장 가까운 프록시가 적은 칸이 제일 믿을 만하다"는 그 자체로는 합리적인 규칙이다. 문제는 앞에 프록시가 없어 서버가 인터넷에 직접 노출된 배포였다는 점이다. 그러면 마지막 칸을 채우는 사람이 공격자가 되고, 거기에 127.0.0.1을 적어 보내면 레이트 리밋 예외로 빠졌다. 권고문이 영향도를 중간(CVSS 4.8)으로 잡은 것도 그런 배포에서만 성립하는 조건이기 때문이다.

두 사고를 겹쳐 보면 규칙이 드러난다. 왼쪽이 맞느냐 오른쪽이 맞느냐는 헤더가 정하지 않는다. 그 칸을 누가 채웠는지가 정한다.

아무것도 안 믿으면 이번엔 전원이 막힌다

그럼 반대로 하면 되지 않나 싶다. 헤더를 아예 무시하고 연결 상대만 보면 위조는 통하지 않는다. 실제로 Express의 기본 설정이 그렇다. trust proxy의 기본값은 false이고, 이때 req.ipreq.socket.remoteAddress에서 나온다.

여기서 두 번째 사고가 나온다. 앱이 Cloudflare나 로드 밸런서 뒤에 있다면 연결 상대는 언제나 그 프록시다. 사용자가 만 명이든 십만 명이든 req.ip는 똑같은 값 하나로 찍힌다. 사용자별로 세라고 만든 카운터가 서비스 전체를 한 통에 담아 세는 카운터로 바뀌고, 누군가 한도를 채우는 순간 로그인하려던 모든 사람이 429를 받는다.

놓치기 쉬운 게 하나 더 있다. 이 설정은 req.ipreq.protocol처럼 Express가 계산해주는 값에만 적용된다. 글 맨 앞의 코드처럼 req.headers['x-forwarded-for']를 직접 꺼내 읽으면 trust proxy를 어떻게 잡아뒀든 아무 영향이 없다. 설정만 고치고 코드는 그대로 두면 안전해졌다고 믿는 채로 여전히 뚫려 있다. 설정을 맞추는 일과 req.ip로 갈아타는 일은 한 묶음이다.

Node 진영에서 가장 많이 쓰이는 express-rate-limit은 이 두 실패를 각각 다른 경고 코드로 분리해뒀다. 헤더는 들어오는데 trust proxyfalseERR_ERL_UNEXPECTED_X_FORWARDED_FOR를 띄운다. 사용자별이 아니라 전역으로 제한이 걸릴 수 있다는 뜻이다. true로 열어두면 ERR_ERL_PERMISSIVE_TRUST_PROXY를 띄운다. 왼쪽 끝을 그대로 클라이언트로 삼으니 제한을 우회당할 수 있다는 뜻이다. 양쪽을 다 경고하는 이유는 truefalse 어느 쪽도 그 자체로는 정답이 아니기 때문이다.

프록시가 몇 대인지 세는 일

그래서 정해야 할 건 목록에서 내가 통제하는 장비가 채운 칸이 어디부터인지다. 이 구분선을 신뢰 경계라고 부른다. 내 로드 밸런서가 적은 칸은 그 장비가 직접 본 연결 상대를 적은 것이니 믿을 수 있고, 그보다 왼쪽은 보증할 수 없다. 목록 전체를 읽는 대신 이 선부터 잘라 읽으면 된다.

Express는 그 잘라 읽기를 trust proxy로 표현한다. 숫자를 넣으면 내 앱에서 몇 홉 떨어진 곳까지가 내 프록시인지를 지정하는 뜻이 된다. Cloudflare 하나만 앞에 뒀다면 1, 그 뒤에 로드 밸런서를 하나 더 뒀다면 2다. 숫자 대신 믿을 IP 대역을 나열할 수도 있다. 어느 쪽이든 실제 배포 구성과 숫자가 맞아야 한다. 실제보다 크게 잡는 순간 공격자가 적어 넣은 칸까지 신뢰 구간 안으로 들어온다. Express 문서가 true에 단서를 붙여둔 것도 같은 이유다. 마지막 신뢰 프록시가 X-Forwarded-* 헤더를 지우거나 덮어쓰지 않으면 클라이언트가 아무 값이나 넣을 수 있다고 적혀 있는데, 앞단이 append 방식인데 true를 켜두면 딱 그 상황이 된다.

신뢰 경계를 잘못 잡았을 때와 제대로 잡았을 때
신뢰 경계를 잘못 잡았을 때와 제대로 잡았을 때

플랫폼마다 적는 방식이 다르다

같은 코드라도 어디에 올렸느냐에 따라 결과가 달라진다. 자주 쓰는 세 곳의 답이 전부 다르다.

Cloudflare는 append 쪽이다. 들어온 요청에 X-Forwarded-For가 이미 있으면 자기 앞의 프록시 주소를 뒤에 이어 붙인다. 대신 원래 방문자 주소를 담은 전용 헤더 CF-Connecting-IPTrue-Client-IP를 따로 주고, Cloudflare 자신도 로그에는 목록형 헤더 대신 값이 하나로 고정된 이쪽을 쓰라고 권한다. 그러니 Cloudflare 뒤라면 CF-Connecting-IP 하나만 읽으면 된다. 다만 이 헤더는 요청이 반드시 Cloudflare를 거쳐 들어올 때만 믿을 만하다. 원본 서버 IP가 노출되어 누군가 그 주소로 직접 연결할 수 있다면 그 사람은 CF-Connecting-IP도 손으로 적어 보낸다. 앞에서 본 Mastodon 사고가 정확히 이 모양이었다. 전용 헤더를 읽는 일과 원본 서버를 프록시 대역에서만 열어두는 일은 한 짝이다.

Vercel은 반대로 간다. 다른 프록시 뒤에 Vercel을 두더라도 X-Forwarded-For를 덮어쓰고 외부에서 들어온 IP는 전달하지 않는다고, 그 이유가 IP 스푸핑을 막기 위해서라고 문서에 못 박아뒀다. 신뢰 경계를 플랫폼이 대신 정해주는 방식이라 이쪽에서는 x-forwarded-for를 그냥 읽어도 된다.

AWS ALB는 선택권을 준다. routing.http.xff_header_processing.modeappend, preserve, remove 세 값을 받는다. 기본값인 append라면 내 코드는 왼쪽이 아니라 ALB가 마지막에 붙인 오른쪽 끝을 읽어야 하고, 헤더를 지우는 remove라면 읽을 것이 없으니 연결 상대 주소를 쓰면 된다.

세 곳의 답이 다르다는 게 요점이다. 클라이언트 IP를 어떻게 읽을지는 코드가 혼자 정할 수 있는 문제가 아니다.

에이전트에게 시킬 때 빠지는 한 줄

여기서 바이브 코딩 특유의 함정이 나온다. Claude Code나 Codex는 내가 어디에 배포할지 모른다. 프롬프트에 배치를 적어주지 않으면 에이전트가 아는 건 언어와 프레임워크뿐이고, 그 상태에서 IP 차단을 시키면 어느 환경에서나 그럭저럭 동작해 보이는 관용구가 나온다. 글 맨 앞의 그 코드가 딱 그렇다. 게다가 로컬에서는 완벽하게 돈다. 프록시가 없으니 헤더도 없고 소켓 주소가 곧 내 주소다. 배포하기 전까지 아무 신호도 뜨지 않는다.

그래서 요청을 한 겹 더 얹어야 한다. "Cloudflare 뒤에 배포하고 프록시는 한 대다. 클라이언트 IP는 CF-Connecting-IP에서 읽고 Express trust proxy는 1로 잡아줘"까지 적어주면 대개 맞게 짜준다. 배포 지형을 말하지 않으면 대개 틀린다. 이미 나온 코드를 점검할 때는 x-forwarded-fortrust proxy 두 단어로 검색해보는 것만으로 절반은 걸러진다.

챙길 게 하나 더 있다. IP에 매달린 기능이 레이트 리밋뿐이 아니다. 관리자 페이지를 특정 IP에서만 열리게 막아둔 코드, 국가별 접근 제한, 접속 기록을 남기는 감사 로그가 전부 같은 값을 읽는다. 앞의 둘은 밀면 열리는 문이 되고, 마지막 하나는 사고가 터졌을 때 엉뚱한 곳을 가리키는 기록이 된다.

정리

서버가 직접 확인한 사실은 연결 상대의 주소 하나뿐이고, 프록시 뒤에서 그건 사용자가 아니라 프록시의 주소다. 그 너머 사용자의 주소는 X-Forwarded-For에 적힌 글자로만 전해진다. 통째로 믿으면 요청마다 새 사람인 척하는 공격자에게 문을 열어주고, 통째로 무시하면 아무 상관없는 사용자들을 한 사람으로 묶어 함께 막는다.

읽을 자리를 정하는 건 코드가 아니라 배포 구성이다. 앞에 프록시가 몇 대 서 있는지 세고, 그 수만큼만 믿고, 플랫폼이 값 하나짜리 전용 헤더를 주면 그걸 쓰고, 그 헤더가 통하도록 원본 서버는 프록시 대역에서만 열어둔다. 그리고 이 배치를 에이전트에게 말로 알려줘야 한다. 말해주지 않으면 에이전트가 아는 배포 환경은 내 노트북뿐이다.

YS

신윤섭

데이너스 대표 | AI 교육 & AX 컨설팅

90개 이상의 AI/AX 교육 과정을 설계하고, 50여 교육·협업 기관에서 강의했습니다. 강남세브란스, 삼성전자, 현대자동차 등 다양한 조직의 AI 역량 강화를 지원하고 있습니다.

AI 교육이 필요하신가요?

조직에 맞는 맞춤형 AI/AX 교육 프로그램을 설계해드립니다. 커리큘럼 상담부터 시작해보세요.

같은 주제의 다른 글