X-Forwarded-For의 첫 값은 누구의 것인가
문제 상황
해축이모는 로그인 없이도 좋아요와 투표, 댓글을 할 수 있습니다. 유저를 늘리기 위한 정책 중 하나입니다.
이를 위해 첫 방문 브라우저에게 게스트 세션을 발급합니다. BE가 users 테이블에 게스트 행을 하나 만들고 access,refresh 토큰을 주면 fe가 HttpOnly 쿠키로 심습니다.
다만 발급은 곧 db 행 생성입니다. 누구나 토큰 없이 부를 수 있는 post이고 그래서 be측에서 상한을 뒀습니다.
@PostMapping("/guest") @RateLimit(key = "auth-guest", count = 60, windowSeconds = 60, scope = RateLimit.Scope.IP) public ApiResponse<GuestLoginResponse> guest(RequestOrigin origin) { ... }
상한은 주체 하나가 창 하나 안에서 몇번까지 로 이루어집니다. be의 ratelimitaspect는 레디스 고정 창으로 셉니다.
주체라고 했을 때 주체를 구분할 수 있는건 ip 입니다. 로그인 api라면 사용자 아이디, 토큰을을 세면 되지만 게스트 발급은 정의상 아직 아무도 아닌 사람이 부릅니다.
/** 요청 IP. ALB 뒤라 X-Forwarded-For의 첫 값(원 클라이언트)을 쓰고, 없으면 소켓 주소. */ private static String clientIp() { String forwarded = request.getHeader("X-Forwarded-For"); if (forwarded != null && !forwarded.isBlank()) { return forwarded.split(",")[0].trim(); } return request.getRemoteAddr(); }
프록시 뒤 클라이언트 아이피
HTTP요청은 TCP 연결 위에서 갑니다. 소켓이 알려 주는 상대 주소는 나와 직접 연결한 대상입니다. 서버 앞에 프록시나 로드밸런서가 하나라도 있으면 소켓 주소는 해당 프록시 혹은 로드밸런서의 주소이지 사용자의 주소가 아닙니다. 그래서 프록시들은 자기가 본 상대의 IP를 X-Forwarded-For 헤더에 적어 뒤로 넘깁니다. 즉 프록시는 받은 값을 지우지 않고 자기가 본 아이피를 쉼표 뒤에 덧붙입니다.
X-Forwarded-For: <맨 처음 보낸 쪽이 적은 값>, <프록시1이 본 IP>, <프록시2가 본 IP>
여기서 맨 왼쪽, 첫 값은 신뢰도가 떨어집니다. 요청자가 고의로 쓸 수 있는 값입니다.
근데 BE의 clientIp()는 alb 뒤라 첫 값이 원 클라이언트 라고 적어 뒀습니다. alb하나만 앞에 있고 그 alb가 외부에서 직접 받는 구조면 맞지만, 저희는 alb 하나만 지나지 않습니다.
요청 경로
브라우저
→ CloudFront (m.plick.co.kr)
→ 퍼블릭 ALB (FE)
→ FE EC2, Next 서버
→ BE 내부 ALB (API_BASE_URL)
→ BE EC2, Spring
순서를 생각하면, cloudfront는 뷰어 아이피를 X-Forwarded-For에 덧붙입니다. 뷰어가 헤더를 들고 왔으면 그 뒤에 붙이고, 없으면 새로 만듭니다.
또 cf자신이 뷰어 아이피를 확정해 담은 CloudFront-Viewer-Address 같은 자체 헤더도 만들 수 있는데, 이건 오리진 요청 정책에 넣어야 오리진으로 나갑니다. 저희는 현재 기본 비헤이비어는 AllViewer라 뷰어가 보낸 헤더는 다 넘기지만 클라우드프론트가 만드는 헤더는 안넘깁니다.
퍼블릭 ALB는 자기와 연결한 쪽, 즉 CF엣지의 아이피를 X-Forwarded-For에 덧붙입니다. 이 ALB는 CF만 받습니다. 보안 그룹이 CF 관리형 프리픽스 리스트만 열어두고, 리스너 규칙이 CF오리진 설정에서 넣어주는 X-Origin-Verify 시크릿 헤더를 and 조건으로 검사합니다. 그래서 fe에 도착한 요청은 CF를 거쳤다는게 보장되고, CF가 적은 값은 신뢰도가 있습니다.
fe에 도착한 체인
X-Forwarded-For: [뷰어가 지어낸 값들...], <CloudFront가 본 뷰어 IP>, <CloudFront 엣지 IP>
믿을 수 있는 방문자 IP는 뒤에서 두 번째입니다. 첫 값이 아닙니다.
FE에서 나가는 통로

넥스트 앱의 코드는 fe서버에서도 돌고 브라우저에서도 돕니다. 서버 측에서 요청이 지나는 자리가 세개 있고 위 표와 같습니다.
그리고 fe 도메인으로 오는 주소는 접두어로 셋으로 나뉩니다.

이를 놓고 be로 가는 두 길을 그리면 아래와 같습니다

브라우저 길은 넥스트가 요청을 다시 만드는 개념이 아닙니다. 들어온 요청을 그대로 흘려보냅니다. 들어온 X-Forwarded-For 를 건들지 않으므로 위 체인이 그대로 be로 가고 be내부 alb가 fe인스턴스 아이피를 하나 더 추가합니다. 그래서 방문자 아이피가 be까지 갑니다.
서버 길은 다릅니다. 게스트 발급이 서버 길을 통합니다. 라우트 핸들러로 요청이 가고, 이는 be내부 alb로 새 연결을 엽니다. 브라우저의 post가 이 호출을 일으킨 건 맞지만, 그 사실은 fe코드 안에만 있고 네트워크엔 흔적이 없습니다. be 가 아는 fe 인스턴스 10.0.x.x 가 나한테 연결해 왔다 뿐입니다.
X-Forwarded-For: <FE 인스턴스 사설 IP>

왜 다른 아이피는 괜찮고 게스트 발급만 문제지
서버 길을 타는 것은 게스트 발급만이 아닙니다.
페이지 첫 렌더의 서버 컴포넌트 fetch도, 프록시의 토큰 재발급도 fe 서버가 새 요청을 만드니 be에는 전부 fe ip로 닿습니다. 다만 이때는 문제가 없습니다. be가 사람을 아이피로 식별하지 않고, 쿠키에 든 토큰, 그 토큰 안의 사용자 아이디로 누군지를 식별합니다.
게스트 발급은 다릅니다. 아직 토큰이 없습니다. be가 같은 사람이 너무 많이 받아가는지를 구분 할 방법이 아이피 뿐입니다. 그래서 이 엔드포인트에만 아이피 상한을 걸었고, 제가 이를 인지하지 못하고 그냥 넥스트 아이피가 가고 있었습니다. 1분에 60번 발급이라 현재로선 문제가 없어보였던 것 뿐입니다.
게스트 발급이 왜 브라우저 길(/be)이 아니라고 한다면, BE의 발급 API는 토큰을 JSON 본문으로 돌려줍니다. 저희는 토큰을 JS가 못 읽는 HttpOnly 쿠키에만 두는 정책이라, 누군가 FE 도메인에서 “BE 응답 본문을 받아 Set-Cookie로 바꿔 주는” 일을 해야 합니다. HttpOnly 쿠키는 서버 응답으로만 심을 수 있으니 서버 자리여야 하고, 그 자리가 라우트 핸들러입니다. /be로 가면 BE 응답이 그대로 브라우저에 돌아가 그 변환을 할 자리가 없습니다.
이전에는 이 역할을 프록시가 했는데, 저희가 지난 렌더링 및 캐싱 과정 개선에서 페이지에 토큰이 포함되지 않게 하면서 라우트 핸들러로 빼버렸습니다.(토큰 발급을)
이건 서버 쪽 렌더링이나 BFF구조에서 쉽게 생기는 일반적인 문제입니다. 서버가 사용자를 대신해 뒤를 부르는 순간 뒤가 보는 클라이언트는 넥스트 서버입니다.
즉, 넥스트 서버가 일부러 심어줘야 합니다(아이피를)
단순한 해결방법?
해결책이 되게 쉬워 보입니다. FE는 방문자 아이피를 압니다. 들어온 요청의 X-Forwarded-For에 이미 존재하고, 라우트가 그 헤더를 BE 호출에 그대로 실어 주면 됩니다.
headers: { "X-Forwarded-For": request.headers.get("x-forwarded-for") ?? "" }
대참사가 날 수 있는건, 아까 말한 첫번째 아이피는 조작된 것일 수 있다는 것입니다.
첫째 아이피는 뷰어가 써서 보낸값일 수 있습니다.
이걸 그대로 넘기면 BE가 첫 아이피값을 보니, 요청마다 첫 값을 바꿔 끼우는 것만으로 매 요청이 새 주체가 됩니다. 상한이 없는 것과 같은 현상이 되는 것입니다.
문제 전체 요약
1.게스트 발급 상한의 주체가 방문자가 아니라 FE 인스턴스입니다. FE 서버가 BE를 직접 부르는 호출에 방문자 IP가 실리지 않아서입니다. 프록시가 발급하던 KAN-514 때부터 같은 구조였습니다.
2.그래서 상한은 인스턴스당 분당 60회 공용입니다. 유입이 몰리면 진짜 방문자가 503을 받고, 한 사람이 초당 한두 번만 두드려도 신규 유입 전체의 세션 발급을 막을 수 있습니다. FE의 UA·출처 거름은 스크립트를 막지 못합니다.
3.받은 X-Forwarded-For를 그대로 넘기는 쉬운 수정은 첫 값을 뷰어가 지어낼 수 있어 상한을 아예 없앱니다. 지금보다 나쁩니다.
4.믿을 수 있는 방문자 IP는 CloudFront가 붙인 값입니다. ALB가 X-Origin-Verify와 프리픽스 리스트로 CloudFront 요청만 받으니 그 값은 믿어도 됩니다. FE 체인에서는 뒤에서 두 번째이거나, 오리진 요청 정책을 바꾸면 CloudFront-Viewer-Address로 따로 받을 수 있습니다.
검증
여기까지는 코드와 인프라 문서 기반 클로드가 분석 해준 내용입니다.
이에 실패하는 테스트와, dev 방문 흔적, prod ALB로그를 확인했습니다.
1. 실패 테스트
KAN-675에서 만든 테스트 도구는 전역 fetch를 가짜로 바꿔 라우트가 BE에 보내는 요청의 경로와 헤더를 기록합니다. 거기에 "되어야 하는 모양"을 먼저 적었습니다. CloudFront를 거쳐 온 것처럼 생긴 요청을 라우트에 넣고, BE 호출의 X-Forwarded-For가 방문자 IP 하나여야 한다는 단언입니다.
test("발급 호출에 CloudFront가 본 방문자 IP를 X-Forwarded-For로 싣는다 (KAN-680)", async () => { stub = stubFetch(() => beOk(GUEST)); await POST( post({ headers: { /* 뷰어가 지어낸 값, CloudFront가 본 방문자 IP, CloudFront 엣지 IP(ALB가 덧붙임) 순 */ "x-forwarded-for": "1.2.3.4, 203.0.113.7, 13.224.0.10", "cloudfront-viewer-address": "203.0.113.7:51234", }, }), ); const { headers } = stub.calls[0]; assert.equal(headers.get("X-Forwarded-For"), "203.0.113.7"); });
지금 코드로 돌리면 두 앱 모두 이렇게 떨어집니다.
not ok 1 - 발급 호출에 CloudFront가 본 방문자 IP를 X-Forwarded-For로 싣는다 (KAN-680)
Expected values to be strictly equal:
+ actual - expected
+ null
- '203.0.113.7'
null입니다. 체인을 잘못 넘기는 것도 아니고 아예 안 넘깁니다. BE 호출에 방문자 IP가 한 번도 실린 적이 없다는 1번 결론이 테스트로 고정됐습니다. 이 케이스는 고친 뒤 초록이 되는 게 "후"의 기준이 됩니다.
2. dev 방문 흐름 흔적
dev(dev-m.plick.co.kr)에 쿠키 없는 브라우저로 홈을 한 번 열었습니다. 네트워크 탭에 POST /api/session/guest가 200으로 찍혔고 sessionKind=guest 쿠키가 생겼습니다. 발급 시각은 14:28:35입니다. 이 한 번의 방문이 네 홉을 지나며 남긴 흔적을 세 자리에서 꺼냈습니다.
첫째, 공용 ALB의 액세스 로그입니다. 제 공인 IP는 121.162.157.81인데 로그의 클라이언트 주소는 다릅니다.
05:28:35Z 15.158.3.142:51838 200 "POST https://dev-m.plick.co.kr:443/api/session/guest"
15.158.3.142는 CloudFront 엣지입니다. ALB가 직접 연결에서 본 상대는 CloudFront이지 제가 아닙니다. 제 IP는 이 시점에 X-Forwarded-For 체인 안에만 있습니다.
둘째, 사설 ALB의 액세스 로그입니다. 같은 초에 BE로 간 발급 호출입니다.
05:28:35Z 172.16.4.140:49158 → 172.16.4.156:8080 200 "POST http://dev-main.plick.co.kr:80/api/v1/auth/guest"
클라이언트가 172.16.4.140입니다. dev FE 인스턴스 두 대 중 하나의 사설 IP입니다. 타깃 172.16.4.156은 BE 인스턴스입니다. 사설 ALB는 이 값을 X-Forwarded-For에 적어 BE로 넘깁니다. 제 IP도, CloudFront 엣지 IP도 여기엔 없습니다.
셋째, BE가 실제로 센 키입니다. 상한 키는 60초 뒤 사라지므로 발급 직후 dev Valkey에 바로 물어봤습니다. BE 인스턴스에 redis-cli가 없어서 SSM으로 작은 파이썬 스크립트를 보내 RESP 프로토콜로 KEYS와 TTL을 직접 날렸습니다.
now(UTC) 2026-10-27T05:29:07Z
KEYS ratelimit:* -> ['ratelimit:auth-guest:172.16.4.140']
ratelimit:auth-guest:172.16.4.140 value=1 ttl=27
발급 32초 뒤에 키 하나가 있고, 주체는 172.16.4.140이며, 남은 수명은 27초입니다. 제가 방문했는데 BE가 센 건 FE 서버입니다. 다음 방문자가 누구든 이 키에 2가 더해집니다.

홉을 하나 지날 때마다 "클라이언트"가 바뀌고, 마지막 두 줄이 같은 값이라는 게 이 표의 전부입니다.
3. prod 이력
“prod에서 429를 관측한 적이 없다”고 썼는데, 그건 안 본 것이지 없던 게 아니었습니다. prod 사설 ALB의 액세스 로그가 9월 28일부터 남아 있어서 POST /api/v1/auth/guest를 전부 셌습니다.

181건은 세 날에 몰려 있습니다.

분 단위로 펼치면 상한의 모양이 그대로 보입니다. 10월 4일 23:08(KST)의 1분입니다.
FE 192.168.2.93 200 × 60, 429 × 22
FE 192.168.4.75 200 × 60, 429 × 19
인스턴스마다 정확히 60개가 통과하고 그 뒤는 전부 429입니다. 다음 1분은 두 인스턴스 모두 200이 하나도 없고 429만 22개, 20개입니다. 10월 20일 09:14와 09:15도 같은 모양입니다. 60 다음에 429, 60 다음에 429. "인스턴스당 분당 60회 공용 바구니"가 숫자로 찍혀 있습니다.

그 1분 동안 공용 ALB에는 무엇이 왔는지도 봤습니다. 23:08에 페이지 요청이 148건이었고 그중 147건이 같은 UA 하나였습니다.
Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36
경로는 기사 78건, 개인정보처리방침 15건, 약관 14건, 인물 13건입니다. 약관과 개인정보 페이지를 1분에 열다섯 번 여는 사람은 없습니다. 안드로이드 크롬처럼 보이는 UA를 단 스캐너가 사이트를 훑은 겁니다. 다음 1분에는 같은 UA가 /.env.development와 /actuator/env를 찔렀습니다. 10월 12일은 윈도우 크롬 UA 하나가 2분에 410건, 경로는 /index.php, 그 사이에 셸쇼크 페이로드를 UA에 단 요청 6건이 섞여 있습니다. 10월 20일은 10월 4일과 같은 안드로이드 UA가 기사, 선수, 인물, 라이브 경기 페이지를 2분에 281건 돌았습니다.

여기서 중요한 건 날짜입니다. 세 날 모두 10월 23일 이전입니다. 그때 prod는 아직 프록시가 "쿠키 없는 페이지 요청마다" 게스트를 발급하던 구조였습니다. 쿠키 없는 스캐너가 페이지 150개를 훑으면 발급 호출도 150번 나가고, 인스턴스당 분당 80건 남짓이 들어오니 60개는 40초쯤에 바닥납니다. 그 뒤 다음 창이 열릴 때까지 그 인스턴스로 떨어진 진짜 첫 방문자는 세션 없이 화면을 봤습니다. 직전 1분에는 인스타그램 인앱 브라우저 요청이 19건 있었으니 그 시간대에 사람이 들어오고 있던 건 맞습니다. 누가 막혔는지는 로그로 특정할 수 없지만, 막히는 구조였다는 건 특정됐습니다.
10월 23일에 발급이 전용 POST 라우트로 바뀐 뒤(KAN-665) JS를 돌리지 않는 스캐너는 발급을 일으키지 못하게 됐고, prod 발급 호출은 하루 500건에서 1,300건 사이에서 25건에서 56건으로 떨어졌습니다. 그래서 이 트리거는 사라졌습니다. 하지만 바구니의 주체가 FE 인스턴스라는 구조는 2번의 dev 관찰 그대로입니다. 같은 출처 헤더를 붙인 요청이 분당 60번 넘게 오면 지금도 같은 모양으로 찍힙니다.
검증 뒤에 바뀐 것
문제 정의 1번부터 4번은 그대로이고, "증거가 없다"던 문장만 틀렸습니다.
1번(주체가 FE 인스턴스)은 dev Valkey 키와 사설 ALB 로그로 확인됐습니다.
2번(인스턴스당 60회 공용)은 prod 로그에서 200이 정확히 60개 찍힌 뒤 429로 넘어가는 분이 세 날에 걸쳐 여섯 번 나왔습니다.
그리고 하나 더 알게 됐습니다. 60제한을 비운 건 발급 주소를 노린 공격이 아니라 쿠키 없이 페이지를 훑는 평범한 스캐너였습니다. 상한이 사람별이었다면 스캐너 한 대가 자기 몫 60개를 쓰고 막혔을 일이, 공용이라서 그 인스턴스의 모든 첫 방문자 몫까지 비웠습니다.
해결 방안
고칠 자리는 한 곳입니다. 라우트 핸들러가 issueGuest를 부르기 직전, BE 호출에 무엇을 실어 보낼 것인가. 그런데 "무엇을"이 전부라서, 후보를 다 꺼내 놓고 각각이 공격자를 막는지, 진짜 방문자를 해치지 않는지, 그리고 틀어졌을 때 어느 방향으로 틀어지는지를 따졌습니다. 마지막 기준이 결정적이었습니다.

1.XFF를 그대로 넘기기: 지금보다 더 심각해짐
문제 상황에서 이미 적었지만 비교표에 올려야 하는 이유가 있습니다. 가장 먼저 떠오르고 가장 짧은 수정이라서입니다. 라우트가 받은 X-Forwarded-For를 그대로 BE 호출에 실으면 BE는 첫 값을 봅니다. 첫 값은 요청자가 헤더에 써서 보낸 값일 수 있고, CloudFront도 ALB도 그걸 지우지 않습니다. 요청마다 첫 값을 바꾸면 요청마다 새 주체가 됩니다. 지금은 "인스턴스당 60개"라는 바구니라도 있는데 이건 바구니 자체를 없앱니다. 검증에서 본 스캐너가 이 상태를 만났다면 1분에 150개가 아니라 원하는 만큼 게스트 행을 만들었을 겁니다.
2.FE 메모리 카운터: 문제를 옮길 뿐
라우트 안에 Map<string, number>를 두고 분당 횟수를 세는 안입니다. 세 가지가 걸립니다. ASG로 인스턴스가 여럿이라 각자 따로 세니 상한이 인스턴스 수만큼 느슨해지고, 재배포마다 비워집니다. 그리고 무엇을 키로 셀지 정하는 순간 "믿을 수 있는 방문자 IP가 무엇인가"라는 똑같은 질문으로 돌아옵니다. 그 질문에 답하고 나면 BE에 그 값을 넘기는 게 더 쉽습니다. BE에는 이미 레디스로 인스턴스 사이를 묶어 세는 상한이 있습니다.
3.WAF rate rule: 입구는 막지만 60 카운트 바구니는 여전히..
CloudFront에 붙은 WAF에 URI path = /api/session/guest, 같은 IP가 1분에 N회 넘으면 Block이라는 규칙을 두는 안입니다. 장점이 분명합니다. WAF가 보는 IP는 CloudFront가 직접 본 연결 상대라 조작할 수 없고, 공격자는 FE에 닿기 전에 걸러집니다. 창은 1·2·5·10분 중 고를 수 있고 하한은 10회라 “1분에 10회”처럼 빡빡하게도 둘 수 있습니다.
그런데 이건 BE 바구니의 주체를 고치는 게 아닙니다. WAF가 한 IP를 분당 10회로 막아도, 통과한 요청이 BE에 닿을 때 세어지는 주체는 여전히 FE 인스턴스입니다. 서로 다른 IP 여섯 개가 분당 10회씩만 보내면 바구니 60개는 그대로 비워집니다. 통신사 NAT나 사무실 공유기 뒤의 사람들이 한 IP로 묶여 정상 트래픽이 걸릴 수 있어 Count로 며칠 두고 봐야 하고, 규칙당 월 과금도 붙습니다. 주 해법이 아니라 주 해법 위에 얹는 보조 장치이고, 주 해법을 적용한 뒤 BE 상한이 사람별로 돌면 그것만으로 충분한지 먼저 봅니다. 그래서 보류입니다.
4.1b. 체인 뒤에서 두 번째: 코드만으로 되지만 ..
FE에 도착한 체인은 [요청자가 적은 값들..., CloudFront가 본 방문자 IP, CloudFront 엣지 IP]입니다. 믿는 홉이 둘(CloudFront, ALB)이니 뒤에서 두 번째가 방문자입니다. 그 값 하나를 뽑아 BE 호출의 X-Forwarded-For로 싣습니다. 인프라를 안 건드리고 코드 몇 줄과 상수 하나로 끝나고, dev에서 바로 확인됩니다. nginx의 real_ip_recursive나 Express의 trust proxy: 2가 하는 일과 같아서 낯선 방식도 아닙니다.
걸리는 건 "홉 수 2"가 코드에 박힌 가정이라는 점입니다. 언젠가 ALB 앞뒤에 프록시가 하나 더 끼면 뒤에서 두 번째는 CloudFront 엣지 IP가 되고, 상한은 "엣지당 공용"으로 조용히 바뀝니다. 반대로 CloudFront가 빠지는 구성이 되면 뒤에서 두 번째는 요청자가 적은 값이 됩니다. 두 경우 모두 에러가 나지 않습니다. 테스트는 가짜 체인으로 돌아가니 초록이고, 사고는 BE 레디스 키 이름에만 남습니다. 상수에 주석을 달아도 "조용히"라는 성질은 남습니다.
4.1a. CloudFront-Viewer-Address: 값 하나, 해석 없음
CloudFront는 방문자의 IP와 포트를 CloudFront-Viewer-Address 헤더에 담아 오리진에 보낼 수 있습니다. 체인이 아니라 값 하나이고, CloudFront가 직접 채우는 헤더라 요청 체인을 통해 조작되는 대상이 아닙니다. 지금은 오리진 요청 정책이 AllViewer라 이 헤더가 오리진까지 안 옵니다. 정책을 관리형 AllViewerAndCloudFrontHeaders-2022-06으로 바꾸면 옵니다. 네 배포(dev·prod × 모바일·웹)의 기본 비헤이비어만 바꾸고, S3 비헤이비어는 건드리지 않습니다. 오리진 요청 정책은 캐시 키와 별개라 캐시에는 영향이 없지만 적용 뒤 Hit가 유지되는지는 확인합니다.
라우트는 이 헤더가 있으면 포트를 떼고 그 값 하나를 BE 호출의 X-Forwarded-For로 싣습니다. 없으면(로컬, 또는 정책이 아직 안 바뀐 배포) 안 싣습니다. 이 "없으면 안 싣는다"가 핵심입니다. 틀어지는 방향이 현상 유지입니다. 정책 적용을 빠뜨린 배포가 있어도, 헤더 이름을 잘못 읽어도, 결과는 오늘과 같은 인스턴스 공용 바구니이지 그보다 나쁜 상태가 아닙니다. 1b가 틀어지면 조작 가능한 값으로 갈 수 있는 것과 반대 방향입니다.
ALB가 X-Origin-Verify와 CloudFront 프리픽스 리스트로 CloudFront 요청만 받는다는 전제는 1a와 1b 모두에 필요한데, 1a는 그 위에 “CloudFront가 만든 값”이라는 조건이 하나 더 얹히는 셈이라 믿는 근거가 코드 안의 숫자가 아니라 인프라 구성에 있습니다.
남는 확인 항목이 둘 있습니다. 값은 198.51.100.10:46532 형식이라 포트를 떼야 하고, IPv6일 때의 형식은 문서에 예시가 없어 적용 뒤 실제 값을 보고 자르는 규칙을 정합니다. 그리고 뷰어가 같은 이름의 헤더를 직접 보냈을 때 CloudFront가 덮어쓰는지도 문서에 명시가 없어서, 정책을 바꾼 뒤 dev에 그런 요청을 하나 보내 오리진에 무엇이 닿는지 확인합니다. 공격이 아니라 요청 한 번입니다.
채택
1a입니다. 두 길 모두 “CloudFront가 본 방문자 IP”라는 같은 값을 쓰지만, 1b는 체인을 해석해서 얻고 1a는 값을 받아서 씁니다. 해석에는 가정이 들어가고 가정은 조용히 깨집니다. 받아서 쓰는 쪽은 못 받으면 못 받은 대로 멈춥니다. 콘솔 작업 네 번이 그 차이의 값입니다.
둘을 섞지는 않습니다. "헤더가 있으면 1a, 없으면 1b"로 두면 어느 배포에서 어느 값이 쓰였는지 레디스 키만 봐서는 알 수 없고, 1b의 조용한 실패가 폴백이라는 이름으로 다시 들어옵니다.
WAF는 보류, FE 카운터와 XFF 그대로는 탈락입니다.
설명 보강
앞서 설명에서 내 아이피가 조작이 가능하다는 식으로 설명했는데, 사실 이는 정확히 말하면 앞에 끼워넣을 수 있다는 것입니다.
보통 사용자 :
브라우저는 X-Forwarded-For를 보내지 않습니다. 그러면 CloudFront가 헤더를 새로 만들고 자기가 본 연결 주소를 적습니다. ALB가 뒤에 CloudFront 엣지 IP를 덧붙입니다.
X-Forwarded-For: <내 IP>, <CloudFront 엣지 IP>
첫 값이 내 IP입니다. BE가 첫 값을 봐도 맞습니다. 그래서 평소에는 문제가 안 보입니다.
헤더를 써서 보내는 사용자 :
요청에 X-Forwarded-For: 1.2.3.4를 달아 보내면 CloudFront는 그 값을 지우지 않고 뒤에 덧붙입니다.
X-Forwarded-For: 1.2.3.4, <내 IP>, <CloudFront 엣지 IP>
내 IP는 그대로 있습니다. 다만 첫 값이 아니라 두 번째가 됐습니다. BE가 첫 값을 보면 1.2.3.4를 믿게 됩니다.
그래서 규칙이 "뒤에서 세기"가 되는 이유 :
앞에 뭘 끼워 넣든 뒤쪽은 안 바뀝니다. 맨 뒤는 ALB가 적은 값, 그 앞은 CloudFront가 적은 값입니다. 이 둘은 연결 주소라 조작이 안 되고, 요청자가 끼워 넣은 값은 전부 그 앞에만 쌓입니다. 그래서 "첫 값"은 요청자가 통제하는 자리이고 "뒤에서 n번째"는 프록시가 통제하는 자리입니다.
정리하면 이렇습니다.

첫 값은 요청자에 따라 바뀌고, 뒤에서 두 번째는 어느 줄이든 내 IP입니다. 1b가 "뒤에서 두 번째"를 쓰는 이유이고, 1a는 아예 체인을 안 보고 CloudFront가 따로 적어 준 값 하나를 쓰는 겁니다.
적용
적용은 네 단계 입니다. 콘솔에서 오리진 요청 정책을 바꾸고, 라우트가 그 헤더를 읽어 BE 호출에 싣게 하고, 빨간 테스트가 초록이 되는 걸 보고, 검증 때 했던 관찰을 그대로 다시 돌려 레디스 키가 제 IP로 바뀌는 걸 보는 것입니다. 순서는 정책이 먼저입니다. 코드가 먼저 나가도 헤더가 없으면 아무것도 안 실으니 위험은 없지만, "후"를 보려면 정책이 깔려 있어야 합니다.
1.콘솔: 오리진 요청 정책을 AllViewer에서 AllViewerAndCloudFrontHeaders-2022-06으로
CloudFront 콘솔에서 배포를 열고 동작(Behaviors) 탭의 기본 비헤이비어(Default (*), ALB 오리진)를 편집합니다. 오리진 요청 정책(Origin request policy)을 Managed-AllViewer에서 Managed-AllViewerAndCloudFrontHeaders-2022-06으로 바꿉니다. 캐시 정책은 그대로 둡니다(plick-documents). S3 오리진을 보는 정적 비헤이비어는 건드리지 않습니다. 거기에 CloudFront 헤더를 보내면 S3 서명 검증이 깨져 403이 납니다.

dev 두 배포(dev-m.plick.co.kr, dev.plick.co.kr)에 먼저 적용하고, 배포 상태가 Deployed가 될 때까지 몇 분 기다립니다. prod 두 배포(m.plick.co.kr, plick.co.kr)는 코드가 main에 릴리스된 뒤에 같은 방법으로 바꿉니다.
바꾼 뒤 확인할 게 하나 있습니다. 오리진 요청 정책은 캐시 키와 별개라 문서 캐시에 영향이 없어야 하는데, 홈을 두 번 열어 두 번째 응답의 x-cache가 Hit from cloudfront인지 봅니다.

2. 코드: 값 하나를 읽어 값 하나를 싣는다
두 앱의 route.ts가 한 글자도 다르지 않아서 로직은 @plick/core에 함수 하나로 뺐습니다. packages/core/src/viewer-ip.ts입니다.
/** CloudFront가 오리진 요청 정책에 따라 담아 주는 방문자 `ip:port` */ export const VIEWER_ADDRESS_HEADER = "cloudfront-viewer-address"; /** BE `RateLimitAspect.clientIp()`가 첫 값을 읽는 헤더 */ export const FORWARDED_FOR_HEADER = "X-Forwarded-For"; export function stripViewerPort(value: string | null | undefined): string | null { if (!value) return null; const raw = value.trim(); if (!raw || /[\s,]/.test(raw)) return null; const bracket = raw.match(/^\[([^\]]+)\](?::\d+)?$/); if (bracket) return bracket[1] ?? null; const last = raw.lastIndexOf(":"); if (last === -1) return raw; const port = raw.slice(last + 1); if (!/^\d+$/.test(port)) return null; return raw.slice(0, last) || null; } export function viewerForwardedHeaders(headers: Headers): Record<string, string> { const ip = stripViewerPort(headers.get(VIEWER_ADDRESS_HEADER)); return ip ? { [FORWARDED_FOR_HEADER]: ip } : {}; }
세 가지를 일부러 그렇게 했습니다.
들어온 X-Forwarded-For는 아예 읽지 않습니다. 함수 어디에도 그 헤더 이름이 없습니다. 체인을 해석하지 않는다는 결정을 코드 모양으로 남긴 겁니다. 쉼표나 공백이 섞인 값은 버립니다. BE는 X-Forwarded-For를 쉼표로 쪼개 첫 값을 보니, 실어 보내는 값에 쉼표가 들어가면 그 자체가 체인 주입입니다. 포트는 마지막 콜론 뒤를 뗍니다. IPv4는 198.51.100.10:46532이고, IPv6는 대괄호가 있는지 문서에 예시가 없어 [2001:db8::1]:443과 2001:db8::1:443 둘 다 받습니다. CloudFront는 항상 포트를 붙이니 마지막 콜론 뒤가 포트라는 가정이 성립합니다.
라우트 쪽은 issueGuest를 부르는 한 줄입니다.
guest = await issueGuest({ ...analyticsHeaders(request, userAgent, isCrawler), /* 방문자 IP를 BE 상한의 주체로 (KAN-680). CloudFront 헤더가 없으면 안 싣는다 */ ...viewerForwardedHeaders(request.headers), });
BE 내부 ALB는 이 값 뒤에 FE 인스턴스 IP를 덧붙일 테고, BE는 지금 코드 그대로 첫 값을 읽습니다. BE 수정이 없습니다.
하나를 놓칠 뻔했습니다. push 전에 돌리는 헤드리스 리뷰가 "로그아웃 서버 액션의 issueGuest()는 방문자 IP를 싣지 않는다"고 짚었습니다. 로그아웃하면 토큰을 지우고 곧바로 게스트를 다시 발급하는데, 그 호출이 두 앱 _services/auth.ts에 따로 있었습니다. 라우트의 request.headers만 보고 서버 액션은 요청 객체가 없다고 넘겨 버린 건데, 서버 액션도 next/headers의 headers()로 들어온 요청 헤더를 읽을 수 있습니다. 같은 헬퍼를 한 줄로넣었습니다.
guest = await issueGuest(viewerForwardedHeaders(await headers()));
3. 테스트: 빨간 하나가 초록이 되고, 경계 셋이 더 붙었다
검증 때 남겨 둔 빨간 케이스가 그대로 초록이 됐습니다. 거기에 세 가지 경계를 더 고정했습니다.
CloudFront 헤더가 없고 X-Forwarded-For 체인만 있으면 BE 호출에 아무것도 싣지 않습니다. 로컬과 정책 미적용 배포가 이 경우이고, "틀어지면 현상 유지"가 이 케이스입니다. IPv6 두 형식에서 포트만 뗍니다. 헬퍼 단위 테스트로 빈 값, 공백, 쉼표가 섞인 값, 포트 자리가 숫자가 아닌 값을 전부 null로 버리는지 봅니다.
4. 로컬 런타임에서 한 번 더
단위 테스트는 가짜 fetch로 라우트를 직접 부르는 것이라, 실제 Next 서버가 요청을 받아 BE로 보내는 경로에서도 같은지 확인했습니다. 받은 헤더를 그대로 찍는 열다섯 줄짜리 가짜 BE를 8099에 띄우고, 모바일 앱을 API_BASE_URL만 그쪽으로 돌려 next dev로 올린 뒤 curl로 세 번 불렀습니다.

A에서 체인의 첫 값 1.2.3.4는 어디에도 없습니다. 덤으로 같은 로그에 문제 상황에서 그린 두 길이 나란히 찍혔습니다. 브라우저가 홈을 열 때 서버 컴포넌트가 부른 /api/v1/articles는 X-Forwarded-For가 없고, 브라우저가 /be로 부른 /api/v1/events는 Next가 소켓 주소 ::1을 채워 넘겼습니다. 서버 길에는 아무것도 없고 브라우저 길에는 연결 주소가 있다는 그 그림입니다.
5. dev 배포 뒤, 같은 관찰을 다시
코드가 develop에 들어가 dev에 배포되고 정책도 바뀐 상태에서 검증 2번을 그대로 반복했습니다. 쿠키 없는 브라우저로 dev 홈을 한 번 열고, 발급 60초 안에 dev Valkey의 키를 봤습니다.
POST /api/session/guest가 200, 발급 시각은 15:46:03. 15초 뒤 dev Valkey의 키입니다.
now(UTC) 2026-10-27T06:46:18Z
KEYS ratelimit:* -> ['ratelimit:auth-guest:121.162.157.81']
ratelimit:auth-guest:121.162.157.81 value=1 ttl=45
121.162.157.81은 제 공인 IP입니다. 한 시간 전 같은 자리에 있던 값은 172.16.4.140, FE 인스턴스였습니다. 브라우저도 홉도 BE 코드도 같고, 바뀐 건 FE가 BE 호출에 실어 보낸 값 하나입니다.

사설 ALB 로그의 클라이언트는 여전히 FE 인스턴스입니다. 그건 연결 주소라 바뀔 리 없고 바뀌면 안 됩니다. 바뀐 건 BE가 세는 키뿐입니다.
공용 ALB 06:46:03.335Z 15.158.3.238 → POST /api/session/guest 200
사설 ALB 06:46:03.324Z 172.16.4.22 → 172.16.4.156:8080 POST /api/v1/auth/guest 200
확인 항목 두 개도 여기서 해결됩니다. 뷰어가 CloudFront-Viewer-Address라는 이름의 헤더를 직접 써서 보내면 CloudFront가 덮어쓰는지, 그리고 IPv6 값의 실제 형식입니다.
CloudFront-Viewer-Address: 203.0.113.9:1을 직접 단 요청을 하나 보냈더니 응답은 200이었고, 직후 키에 203.0.113.9는 없고 제 IP 키만 1에서 3으로 늘어 있었습니다. CloudFront가 뷰어가 보낸 같은 이름의 헤더를 자기 값으로 덮어씁니다. IPv6는 이 회선에 IPv6가 없어 확인하지 못했고, 코드는 대괄호 유무 둘 다 받게 둔 채 남겨 뒀습니다.

마무리
요청이 CloudFront, ALB, FE, BE를 지나는 동안 "클라이언트 IP"는 홉마다 다른 것을 가리켰습니다. 공용 ALB에게는 CloudFront 엣지였고, BE에게는 FE 서버였습니다. 서버가 사용자를 대신해 뒷단을 부르는 순간 뒷단이 보는 클라이언트는 그 서버이고, 사용자의 정체를 뒤로 보내는 건 코드가 일부러 해야 하는 일입니다. 그 한 줄이 비어 있어서 사람별 상한이 인스턴스 공용 상한으로 돌고 있었고, prod에서는 쿠키 없는 스캐너 한 대가 바구니를 세 번 비웠습니다.
가장 짧은 수정이 가장 나쁜 수정이었다는 점이 이번 일에서 제일 남는 부분입니다. X-Forwarded-For를 그대로 넘기면 첫 값은 요청자가 쓰는 자리라 상한이 아예 사라집니다. 믿을 수 있는 건 체인의 첫 값이 아니라 믿는 프록시가 적은 값이고, 저희 구성에서는 CloudFront가 따로 적어 주는 값 하나를 받아 쓰는 쪽을 골랐습니다. 틀어져도 지금과 같은 상태로 남는다는 게 이유였습니다.