SaaS 환경(B)의 테넌트 네임스페이스에서 모바일 백엔드가 http://svc-auth-back:60081/auth-api/api/v1을 부른다. 같은 네임스페이스에 인증 모듈이 떠 있으면 거기로 가고, 없으면 이름을 못 찾는다. 지금까지는 이렇게 동작했는데, 이번에 정한 규칙은 달랐다. 이 주소로 가는 호출은 B에 인증 모듈이 있든 없든 항상 기존 환경(A)으로 보내야 했다.
하나의 서비스를 여러 고객사가 각자 쓰도록 바꾸는 멀티 테넌트 SaaS 전환 프로젝트에서 나온 요구다. 인증이나 연계 시스템 같은 공통 백엔드는 모듈끼리 얽혀 있어 일부만 떼어 B로 옮기기 어려웠다. 그래서 공통 백엔드는 A에 늘 전부 떠 있고, B 테넌트의 모듈은 그걸 빌려 쓰는 구조로 정리됐다. 미리 밝혀 두면, 이 설계는 PoC와 구현 브랜치까지 갔고 실제 환경에서 켜지는 않았다.
호출하는 쪽이 아니라 호출받는 이름에 건다
모듈마다 application-*.yml에 interface 블록이 있고, 여기에 형제 서비스 주소가 클러스터 내부 Service 이름으로 적혀 있다.
# 모바일 백엔드(mo)의 dev 프로파일 일부
interface:
ctocHost: http://svc-c2c-back:60084/c2c/api/v1
ctocIotHost: http://svc-c2c-iot-back:60085/c2c/api/v1
ctocPark: http://svc-c2c-park-back:60086/c2c/api/v1
ctocFacilityHost: http://svc-c2c-facility-back:60087/c2c/api/v1
authHost: http://svc-auth-back:60081/auth-api/api/v1
A로 보낼 대상은 팀과 협의해 인증, 연계 메인, IoT·주차·커뮤니티 시설·SST·쇼핑몰 연계까지 7종으로 확정했다. 처음 떠오르는 방법은 B에 배포할 때 interface.authHost 같은 Spring 설정을 환경변수로 A 주소로 덮는 것이다. 그런데 dev 프로파일 기준으로 interface 블록을 쓰는 레포가 9개이고, 그 안에서 7종을 가리키는 키를 세면 26개다. 같은 대상을 ctocIotHost와 iotHost처럼 레포마다 다른 이름으로 불렀고, path도 /api/v1까지 적은 곳과 /auth-api에서 끝난 곳이 섞여 있었다. 모듈이 하나 늘면 덮어쓸 목록도 늘어난다.
모듈은 어차피 svc-auth-back:60081이라는 같은 이름으로 부른다. 그 이름으로 나가는 트래픽을 Istio 사이드카가 A로 틀면, 대상 7종에 한 번씩만 설정하면 된다. 키 이름이나 path 표기는 상관없어진다.
이게 되는 건 A의 진입점 구조 덕분이다. A의 ALB는 Host 헤더와 path로 규칙을 고르는데, 공개 path가 모듈 내부의 contextPath와 같고 ALB가 path를 자르지 않고 넘긴다. 그래서 사이드카는 path를 손대지 않고 Host 헤더만 A의 도메인으로 바꿔 보내면 된다. VirtualService에 gateways: [mesh]를 두면 이 라우트는 사이드카에만 붙는다. A로 꺾이는 건 모듈끼리 부르는 east-west 호출뿐이고, 외부에서 Istio Gateway로 들어오는 north-south 요청은 그대로 B의 로컬 Pod로 간다.
flowchart LR
subgraph B["SaaS 환경(B) - tenant-a 네임스페이스"]
MO["mo-back Pod"] --> SC["istio-proxy 사이드카"]
GW["Istio Gateway<br/>(외부 인입)"] --> LOCAL["auth-back Pod<br/>(배포돼 있으면)"]
end
SC -->|"VirtualService(gateways: mesh)<br/>Host를 dev.example.com으로"| SE["ServiceEntry<br/>dev.example.com:80"]
SE --> ALB["A 진입점 ALB<br/>Host + path 규칙"]
ALB --> AAUTH["A의 인증 백엔드"]처음 만든 조건부 스텁은 조건 때문에 무너졌다
PoC를 설계할 때 나는 규칙을 “B에 없을 때만 A로”라고 이해하고 있었다. 테넌트의 앱 목록에서 각 앱은 is_use 값으로 켜고 끈다(이 토글은 앱 목록을 정하는 자리를 옮긴 글에서 따로 다룬다). 그래서 첫 구현은 형제 모듈이 is_use=false일 때만 그 이름의 스텁 Service(selector 없음)와 Istio 리소스를 만들고, true면 아무것도 만들지 않았다.
막상 온보딩 파이프라인에 넣어 보니 세 군데서 걸렸다. 테넌트를 만드는 온보딩 스크립트와 앱을 켜고 끄는 앱 갱신 스크립트가 각각 is_use를 읽어 스텁을 만들거나 지워야 했고, 온보딩 쪽 조건 블록만 50줄쯤 됐다. 스텁 Service와 진짜 Service는 이름이 같은데(svc-auth-back) 소유하는 ArgoCD 앱이 달라서, is_use를 바꾸는 순간 같은 이름의 리소스를 두 앱이 주고받아야 했다. 그리고 팀과 다시 맞춰 보니 규칙 자체가 달랐다. B에 같은 모듈이 겹쳐 배포되더라도 east-west 호출은 항상 A로 가야 했다.
세 번째가 답이었다. “없을 때만 A로”와 “항상 A로”는 말로는 한 단어 차이인데, 구현에서는 스크립트 하나와 소유권 이동 시나리오 전체만큼 차이가 났다.
정체성과 정책을 두 파일로 나눴다
리팩토링은 “이 이름이 존재한다”와 “이 이름으로 가는 호출을 어디로 보낸다”를 떼어 놓는 것이었다.
[정체성] routing/interface-services.yaml
Service 7개가 모든 테넌트에 항상 존재한다. 소유자는 routing ArgoCD 앱 하나로 고정.
- 앱이 배포됨(is_use=true) : selector가 Pod와 맞아 endpoints가 찬다
- 앱이 없음(is_use=false) : endpoints가 빈 Service. 이름과 포트는 항상 풀린다
[정책] routing/redirect-to-a.yaml <- 이 파일 하나가 토글
ServiceEntry 1 + DestinationRule 1 + VirtualService 7
east-west 라우팅을 A로 덮어쓴다. endpoints 유무와 무관
Service 7개는 앱 디렉토리에서 routing 디렉토리의 파일 하나로 옮겼다. routing 앱이 늘 들고 있으니 앱을 켜고 꺼도 Service는 움직이지 않고, 소유권이 오갈 일이 없어 이름 충돌도 생기지 않는다. 정책 파일은 변수가 ${TENANT_ID}와 ${A_EDGE_HOST} 둘뿐인 정적 YAML이다.
# routing/redirect-to-a.yaml (VirtualService 부분만)
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: ${TENANT_ID}-auth-back-redirect-to-a
namespace: ${TENANT_ID}
spec:
exportTo: ["."]
gateways: [mesh] # 사이드카에만 적용. Gateway 인입은 건드리지 않는다
hosts:
- svc-auth-back.${TENANT_ID}.svc.cluster.local
http:
- rewrite:
authority: ${A_EDGE_HOST} # Host만 바꾼다. path는 그대로
route:
- destination:
host: ${A_EDGE_HOST}
port:
number: 80
# ... 나머지 6종도 hosts만 다른 같은 모양
토글은 온보딩 스크립트에서 REDIRECT_TO_A_ENABLED가 true가 아니거나 공통 테넌트(www)면 redirect-to-a.yaml을 지우는 것으로 끝난다. 50줄쯤 되던 조건 블록은 지웠고, 앱 갱신 스크립트에는 redirect 로직이 한 줄도 들어가지 않는다. redirect를 켜면 east-west는 앱 배포 여부와 상관없이 항상 A로 가고, 끄면 리팩토링 전과 같은 결과가 나온다.
PoC에서는 200이 아니라 404를 기다렸다
설계보다 먼저 2026-06-05에 인증 모듈 하나로 PoC를 돌렸다. B 클러스터에 격리 네임스페이스를 만들고 스텁 Service, ServiceEntry, VirtualService, DestinationRule 네 개를 넣은 뒤, 사이드카가 붙은 curl Pod에서 내부 이름으로 호출했다.
kubectl -n egress-poc exec poc-client -c poc-client -- \
curl -s -m 10 -w '\n[HTTP %{http_code}]\n' \
http://svc-auth-back:60081/auth-api/api/v1
이 경로에는 실제 API가 없어서 성공 신호는 200이 아니라 응답 본문이었다. A의 인증 백엔드는 이 경로에 Spring 형식의 JSON 404를 돌려주고, ALB가 백엔드를 못 찾으면 본문이 빈 기본 404를 돌려준다. 같은 404라도 JSON 본문이 오면 백엔드까지 갔다는 뜻이다.
{"timestamp":"...","status":404,"error":"Not Found","path":"/auth-api/api/v1"}
결과는 Spring JSON 404였다. 응답 헤더에는 ALB의 AWSALB 쿠키와 A 쪽 APM 에이전트의 쿠키가 붙어 있었고 server: envoy가 찍혔다. 사이드카가 요청을 처리했고, A의 ALB를 거쳐 A의 인증 백엔드까지 갔다는 증거가 한 응답에 다 들어 있었다.
PoC는 A의 기존 공개 ALB를 쓰려고 443으로 나가며 사이드카가 TLS를 씌우는 구성이었고, 최종 설계는 A에 새로 둘 internal ALB가 80으로 받는다는 전제로 평문으로 바꿨다. 80 평문 구성은 internal ALB가 아직 없어서 실제 호출로 확인하지 못했고, 구현 브랜치에서는 셸 문법 검사와 렌더링 결과까지만 확인했다. 저장소 기록으로 보면 이 변경은 2026-06-10에 로컬 기능 브랜치에 커밋된 채로 남아 있고 원격의 dev·stg·prd 브랜치에는 들어가지 않았다. 켜려면 A 쪽에 internal ALB, B VPC에 연결할 Route53 Private Hosted Zone, 보안그룹, Transit Gateway 경로가 먼저 필요했다.