토큰이 없으면 기존 환경으로, 토큰에 테넌트 정보가 있으면 SaaS 환경의 해당 테넌트로. 같은 주소 www.example.com으로 보낸 요청이 dev에서 정확히 그렇게 갈라졌다. CloudFront Function, S3 프론트엔드 배포 파이프라인, 롤백까지 다 만들어 둔 상태였다. 남은 일은 실제 도메인을 CloudFront로 돌리는 것 하나였다.

그 마지막 한 걸음에서 멈췄다. 기존 진입점 뒤에 출발지 IP를 보고 판단하는 장치가 두 군데 있었고, 둘 다 우리가 혼자 고칠 수 없었다. 구현이 어려워서 접은 게 아니라, 기존 진입점에 누가 무엇을 기대고 있는지 늦게 조사해서 접었다.

같은 주소를 두 환경이 나눠 써야 했다

하나의 서비스를 여러 고객사가 각자 쓰도록 바꾸는 멀티 테넌트 SaaS 전환 프로젝트였다. 사용자가 이미 쓰고 있는 기존 환경(A)이 있었고, 고객사별 네임스페이스로 나뉜 SaaS 환경(B)이 옆에 새로 섰다. A는 운영사 운영팀이 관리하고 NLB(고정 IP 2개), ALB, EKS 순으로 들어온다. B는 고객사마다 {테넌트}.saas.example.com을 받아 NLB와 Istio Gateway를 거친다.

모바일 앱과 웹 프론트엔드는 www.example.com 하나로 접속하고, 사용자의 소속은 로그인하고 나서야 정해진다. 그래서 CloudFront를 www 앞에 세우고, Function이 Authorization 헤더의 JWT에서 payload만 읽어 오리진을 바꾸게 했다.

// JWT payload에서 테넌트 정보만 읽는다 (라우팅 용도로만 읽고 인증은 뒤에서 한다)
var saasTenantId   = String(payload.saasTenantId   || '').trim();
var saasRootDomain = String(payload.saasRootDomain || '').trim();

if (!saasTenantId || !saasRootDomain) return request;          // 정보가 없으면 A로
if (!isAllowedRootDomain(saasRootDomain)) return request;     // 허용한 루트 도메인만

var domainName = saasTenantId + '.' + saasRootDomain;
cf.updateRequestOrigin({ domainName: domainName, hostHeader: domainName, sni: domainName });

리다이렉트가 아니라서 사용자 주소창은 그대로이고, 기존 사용자는 아무것도 바꾸지 않아도 된다. /mo, /cpn, /ecommerce 같은 프론트엔드 경로는 S3에서 내보냈다. 그 배포 파이프라인에서 겪은 일은 롤백 때 index.html을 건너뛰는 aws s3 sync와 카나리를 만들어 놓고 복사 방식으로 돌아왔다에 나눠 적었다.

앞단이 바뀌면 뒤에서 보이는 출발지가 바뀐다

dev CloudFront로 요청을 흘려 보니 토큰 하나로 같은 URL이 A와 B로 갈라졌다. 토큰에 saasTenantId가 있으면 /auth-api/가 B 테넌트(server: istio-envoy)에서 200을 줬고, 토큰이 없으면 A ALB에서 200을 줬다. 그런데 토큰 없이 /cp를 부르면 A의 관리자 화면이 떠야 하는데 404와 빈 응답이 왔다.

원인은 CloudFront 자체가 아니라 CloudFront의 위치였다. A 환경은 NLB에서 ALB로 넘기는 대상 그룹이 클라이언트 IP를 보존하도록(preserve_client_ip=true) 되어 있다. 평소에는 이 설정 덕분에 ALB가 실제 사용자 IP를 보지만, CloudFront가 앞에 서면 같은 설정이 CloudFront 엣지 IP를 그대로 ALB까지 전달한다. ALB에는 /cp만이 아니라 다섯 개 규칙 그룹이, 맨 아래 /* 폴백 규칙까지 포함해 “사무실 IP에서 온 요청만 허용” 조건을 달고 있었다.

직접 접속:        사무실 IP     → ALB 규칙 통과   → 관리자 화면
CloudFront 경유:  CloudFront IP → 규칙 불일치     → 기본 응답 404

규칙을 고치면 끝날 것 같았지만, 태그(ingress.k8s.aws/stack, elbv2.k8s.aws/cluster)를 보니 AWS Load Balancer Controller가 A 클러스터의 Ingress를 보고 만든 ALB였다. 콘솔에서 리스너 규칙을 바꿔도 컨트롤러가 다음 동기화 때 어노테이션대로 되돌린다. 실제 변경 권한은 운영사 운영팀의 Ingress 설정에 있었다.

두 번째는 더 컸다. 주차·IoT 같은 외부 솔루션이 우리 인증 API를 부르려고 A 환경 NLB의 고정 IP 2개를 자기 쪽 방화벽의 나가는 허용 목록에 등록해 두고 있었다. 운영사 운영팀이 말한 규모는 약 100곳이었다(당시 추정치). www.example.com을 CloudFront로 돌리면 이 이름이 가리키는 IP가 고정 IP가 없는 CloudFront 대역으로 바뀐다. CloudFront를 만들어 두는 것 자체는 문제가 없고, 문제는 DNS를 CloudFront로 돌리는 순간이다. NLB와 고정 IP는 그대로 살아 있으니 IP로 직접 부르는 연동처는 영향이 없고, 도메인 이름으로 부르는 연동처가 끊긴다.

이건 추정으로 끝나지 않았다. dev에서 도메인 레코드가 75분 동안 CloudFront를 가리켰다가 원래대로 돌아온 일이 있었다. 그 앞뒤 시간의 ALB 액세스 로그를 클라이언트 IP별로 묶어 보니, 분당 한 번꼴로 꾸준히 연계 API를 부르던 출발지 3곳이 전환 시각에 정확히 사라졌다가 원복 후 돌아왔다. 같은 시간의 CloudFront 로그에도 이들은 없었다.

접기로 했다

피하는 길은 다섯 가지를 따져 봤다. 연동처 약 100곳에 허용 목록 변경을 요청하는 건 곳곳과 개별 협의가 필요해 일정상 불가였고, CloudFront에 고정 IP를 붙이는 안은 비용 문제로 뺐다. ALB 규칙을 출발지 IP 대신 CloudFront가 붙이는 비밀 헤더 조건으로 바꾸려면 운영사 운영팀의 Ingress를 고쳐야 했다. CloudFront를 NLB 뒤로 보내면 CloudFront가 요청을 먼저 받지 못해 토큰으로 오리진을 고른다는 목적이 사라진다. 당시 가장 유력했던 건 www는 NLB에 두고 앱 전용 새 호스트명만 CloudFront로 보내는 도메인 분리였다. 그런데 앱이 네이티브 호출과 웹뷰에서 서로 다른 호스트명을 쓰고 있어 앱 릴리스로 둘 다 바꿔야 했고, www로 들어오는 요청에는 테넌트 분기가 걸리지 않는다.

A와 B를 가르는 자리를 운영사 ALB에서 B 환경의 Istio Gateway로 가져오는 파사드 안도 검토했다. 도메인과 고정 IP는 그대로라 연동처 영향이 없지만, A 환경의 전체 운영 트래픽이 B 클러스터를 지나게 되고 PoC와 협의까지 한 달 단위가 걸리는 안이었다. 그 자리에 들어갈 후보를 비교한 이야기는 JWT로 목적지를 바꾸는 일은 API Gateway가 못 한다에 있고, 이 안도 결국 채택하지 않았다.

어느 길로 가든 운영사 운영팀이나 외부 연동처, 혹은 둘 다의 협조가 필요했다. 협의에 걸리는 시간은 구현 시간보다 길고 우리가 줄일 수 없다. 프로젝트 기간도 얼마 남지 않아 CloudFront를 기존 도메인 앞에 두는 구조는 적용하지 않기로 했다. 다 만든 뒤라 아까웠다.

PoC에서는 왜 안 보였나

dev 검증은 CloudFront 자체 도메인(*.cloudfront.net)으로 한 것이었다. 그 주소로 부르면 Host가 오리진 인증서와 맞지 않아 502가 나고, 그 502를 피해 테스트하는 동안에는 기존 도메인에 걸린 제약이 보일 자리가 없었다.

실제 진입 경로로 흘려 보는 건 어렵지 않았다. DNS를 바꾸지 않고도 curl의 --connect-to로 “이름은 www.example.com, 연결은 CloudFront로”를 만들 수 있다. 사무실 PC에서 아래 두 줄을 실행하면 직접 접속은 200, CloudFront 경유는 404로 문제가 바로 재현된다.

# 직접 접속: DNS대로 NLB → ALB, 출발지 = 사무실 IP
curl -s -o /dev/null -w "%{http_code}\n" https://www.example.com/cp

# CloudFront 경유: 이름은 그대로 두고 연결만 CloudFront로 돌린다
curl -s -o /dev/null -w "%{http_code}\n" https://www.example.com/cp \
  --connect-to www.example.com:443:dxxxxxxxxxxxxx.cloudfront.net:443

단, 사무실이 아닌 곳에서 실행하면 직접 접속도 404다. 작업 서버의 IP도 사무실 IP가 아니었다. 다음에 기존 서비스의 앞단을 바꾸게 된다면 Function 코드보다 먼저 로드밸런서의 출발지 IP 조건, 우리 고정 IP를 허용 목록에 넣어 둔 연동처, 그 로드밸런서를 누가 관리하는지부터 확인할 것이다.