티스토리 뷰

들어가며 (다이어그램 출처)

인프라 다이어그램을 다시 정리하던 중이었습니다. AWS 네트워크 토폴로지 그림 속 ALB 노드 옆에는 "path-based → :8081-8086"이라는 짧은 라벨이 붙어 있습니다. 처음에는 그냥 지나쳤습니다. 경로 기반 라우팅이야 흔한 구성이고, 뒤에 붙은 포트 목록도 마이크로서비스 여섯 개의 컨테이너 포트를 나열한 것뿐이라고 생각했습니다. 그런데 이 라벨을 실제 리스너 규칙으로 펼쳐 보니, 생각보다 훨씬 구체적인 사실이 담겨 있었습니다. ALB는 /auth/*를 8083번 포트로, /chatbot/*을 8084번 포트로, /agent/*를 8086번 포트로, 게이트웨이를 거치지 않고 곧장 넘기고 있었습니다.

 

이 시스템의 설계 문서에는 "api-gateway가 중앙 진입점이며 JWT 검증을 담당한다"고 적혀 있습니다. 그런데 리스너 규칙만 보면 다섯 개 서비스가 이미 게이트웨이 없이 직접 트래픽을 받을 수 있는 구조였습니다. 이 둘 사이의 간극이 단순한 설명 부족인지, 아니면 실제로 문제가 되는 지점인지 확인하려고 코드를 따라 들어갔습니다. 결과부터 말하면, 다섯 개 서비스 중 대부분은 이미 스스로를 지키고 있었지만 하나는 그렇지 않았습니다. 이 글은 그 라벨 한 줄을 코드까지 쫓아간 과정과, 발견한 문제를 고친 방법을 정리한 기록입니다. 구체적인 공격 재현 절차는 다루지 않고, 무엇이 문제였고 왜 위험했으며 어떻게 고쳤는지에 집중하겠습니다.


리스너 규칙을 펼쳐 보면 우선순위가 다르다

ALB의 리스너 규칙은 하나의 리스너 안에 여러 개가 동시에 존재할 수 있고, 각 규칙은 우선순위와 조건, 그리고 조건이 맞았을 때 수행할 액션으로 구성됩니다. AWS 공식 문서는 이 규칙들이 낮은 숫자부터 높은 숫자 순서로 평가되며, 조건이 없는 기본 규칙(default rule)은 언제나 가장 마지막에 평가된다고 설명합니다. 즉 숫자가 작을수록 먼저 검사되고, 먼저 매칭되는 규칙이 이깁니다.

 

이 시스템의 리스너 규칙을 우선순위 순서로 나열하면 이렇게 됩니다. /auth/*는 우선순위 100으로 api-auth(8083)에, /emerging-tech/*는 110으로 api-emerging-tech(8082)에, /chatbot/*은 120으로 api-chatbot(8084)에, /bookmark/*는 130으로 api-bookmark(8085)에, /agent/*는 140으로 api-agent(8086)에 각각 매핑됩니다. api-gateway는 우선순위 1000, 조건은 /*로 사실상 default rule에 가까운 위치에 있습니다. 즉 위 다섯 경로 중 하나에 걸리는 요청은 게이트웨이의 규칙까지 내려가지 않고 그 자리에서 끝납니다. api-gateway가 실제로 처리하는 것은 이 다섯 패턴에 걸리지 않는 나머지 요청뿐입니다.

 

이 사실 자체는 놀랍지 않습니다. 하나의 ALB로 여러 마이크로서비스를 서빙할 때 경로 기반으로 직접 라우팅하는 것은 AWS 문서에도 나오는 일반적인 패턴입니다. 문제는 이 라우팅 방식이 "게이트웨이가 모든 요청의 첫 관문"이라는 설계 문장과 정확히 어긋난다는 점이었습니다. 설계 문서와 실제 인프라 설정이 서로 다른 그림을 그리고 있다면, 둘 중 하나는 틀렸거나, 아니면 둘 다 맞는데 그 사이를 메우는 다른 장치가 있어야 합니다.

 

이 구조 자체를 나쁜 선택이라고 보기는 어렵습니다. 모든 요청이 반드시 게이트웨이 컨테이너 하나를 거치도록 강제했다면, 요청마다 홉이 하나 더 늘고 그 컨테이너에 부하가 몰립니다. ALB가 경로만 보고 곧장 목적지 서비스로 넘기면 그 홉을 아낄 수 있습니다. 여섯 개 서비스 중 다섯 개를 ALB가 직접 나눠 주고 게이트웨이는 나머지 트래픽만 받는 지금 구조는, 오히려 트래픽 분산과 지연 시간 관점에서는 합리적인 선택에 가깝습니다. 다만 이 구조를 선택하는 순간, "게이트웨이가 인증까지 처리해 준다"는 전제는 이 다섯 개 경로에는 적용되지 않는다는 사실도 함께 따라옵니다. 라우팅을 나눈 것과 인증을 누가 책임질지를 정하는 것은 서로 다른 결정인데, 이 시스템에서는 그 둘이 하나의 설계 문장 안에 섞여 있었던 셈입니다.


서비스마다 코드를 열어 확인한 결과

그래서 다섯 개 서비스의 코드를 하나씩 열어 봤습니다. 확인하고 싶었던 것은 단순합니다. 게이트웨이 없이 이 서비스에 직접 요청이 들어와도, 서비스 자신이 그 요청을 인증하는가.

 

api-auth, api-chatbot, api-bookmark는 이미 스스로를 지키고 있었습니다. 세 서비스 모두 공유 보안 모듈을 의존성으로 가지고 있었고, 각 서비스의 설정 클래스가 그 모듈의 시큐리티 설정을 컴포넌트 스캔과 @Import로 명시적으로 불러오고 있었습니다. 이 설정은 JWT 인증 필터를 필터 체인에 등록하고, 기본적으로 모든 요청에 인증을 요구합니다. 실제로 컨트롤러 코드를 보면 @AuthenticationPrincipal로 인증된 사용자 정보를 받아 쓰고 있었습니다. 이 애너테이션은 Authorization 헤더의 토큰을 서비스 자신이 검증하고, 그 검증 결과로 만든 인증 객체에서만 값을 채웁니다. 클라이언트가 아무 헤더나 보낸다고 해서 채워지는 값이 아닙니다. 즉 이 세 서비스는 게이트웨이를 거치든 안 거치든, 유효한 JWT 없이는 요청을 처리하지 않습니다.

 

api-emerging-tech는 조금 다른 패턴이었습니다. 이 서비스에는 공유 보안 모듈이 없었지만, 대신 조회성 API는 원래부터 공개로 설계돼 있었고, 데이터를 바꾸는 API만 별도의 내부 API 키 검증 로직으로 보호하고 있었습니다. JWT와는 무관한 방식이지만, 적어도 "아무나 아무거나 바꿀 수 있는" 상태는 아니었습니다.

 

문제는 api-agent였습니다. 이 서비스에는 공유 보안 모듈 의존성이 아예 없었고, Spring Security 자체가 동작하지 않는 상태였습니다. 그런데 컨트롤러의 여섯 개 엔드포인트 전부가 @RequestHeader로 x-user-id라는 헤더값을 그대로 받아, 그 값을 마치 검증된 사용자 식별자인 것처럼 서비스 계층으로 넘기고 있었습니다.


헤더로 전파된 신뢰가 깨지는 지점

여기서 짚어야 할 것은 x-user-id라는 헤더가 원래 누가 채워 주는 값인가입니다. 게이트웨이의 인증 필터를 보면, 이 필터는 Authorization 헤더의 토큰을 검증한 뒤에만 x-user-id, x-user-email, x-user-role 세 헤더를 요청에 새로 주입하고, 원본 Authorization 헤더도 그대로 하위로 전달합니다. api-agent의 컨트롤러가 이 헤더를 신뢰한 것은, "이 요청은 반드시 게이트웨이를 거쳐 왔을 것"이라는 전제 위에서만 성립하는 판단이었습니다.

 

그런데 앞서 확인한 리스너 규칙이 정확히 그 전제를 깨뜨릴 수 있는 구조였습니다. /agent/* 경로는 우선순위 140으로 게이트웨이의 폴백 규칙(1000)보다 먼저 평가되고, 곧장 api-agent로 넘어갑니다. 이 경로로 들어오는 요청에는 게이트웨이가 검증한 적이 없으므로, x-user-id 헤더도 게이트웨이가 넣어 준 것이 아닙니다. api-agent 입장에서는 그 헤더가 신뢰할 수 있는 값인지 스스로 확인할 방법이 전혀 없었습니다. 헤더 이름과 값이 같다는 이유만으로 그것을 검증된 정보처럼 다룬 것이 문제의 핵심입니다.

 

이 문제를 조금 더 일반화하면 이렇습니다. 여러 계층으로 이루어진 시스템에서 "앞 계층이 이미 확인했다"는 가정을 뒤 계층이 그대로 물려받는 설계는, 그 가정이 실제로 항상 성립할 때만 안전합니다. 게이트웨이 하나만 있고 모든 트래픽이 반드시 그 문을 통과한다면 이 가정은 유효합니다. 하지만 인프라 계층에 또 다른 라우팅 경로가 하나라도 생기는 순간, 그 가정은 코드 어디에도 강제되지 않은 채 조용히 깨질 수 있습니다. 이번 경우에는 그 경로가 ALB의 리스너 규칙이었습니다.

 

동시에 auth·chatbot·bookmark 세 서비스가 왜 이미 안전했는지도 같은 논리로 설명됩니다. 이 세 서비스는 "게이트웨이가 검증했을 것"이라는 가정에 기대지 않고, 자기에게 들어오는 모든 요청을 스스로 다시 검증하고 있었습니다. 같은 저장소 안에서 서비스마다 이 원칙을 지키는 정도가 달랐다는 것이, 이번 문제의 실질적인 원인이었습니다.


고친 방법 — 새로 만들지 않고 이미 있는 패턴을 가져다 쓰기

고치는 방향은 명확했습니다. api-agent도 auth·chatbot·bookmark와 똑같은 방식으로 스스로를 지키게 만드는 것입니다. 새로운 인증 메커니즘을 설계할 필요는 없었습니다. 이미 검증된 패턴이 같은 저장소 안에 세 번이나 반복돼 있었기 때문입니다.

 

먼저 api-agent에 공유 보안 모듈을 의존성으로 추가하고, 설정 클래스가 그 모듈의 컴포넌트와 시큐리티 설정을 불러오도록 했습니다. 그다음 컨트롤러의 @RequestHeader("x-user-id")를 전부 @AuthenticationPrincipal로 바꿨습니다. 이제 사용자 식별자는 클라이언트가 보낸 헤더가 아니라, 서비스 자신이 Authorization 헤더의 토큰을 검증해서 얻은 값에서만 나옵니다. 게이트웨이를 거치든 ALB에서 곧장 들어오든, 유효한 토큰이 없으면 요청은 거기서 막힙니다.

 

한 가지 더 확인할 지점이 있었습니다. 게이트웨이 설정을 보면 /agent/* 경로는 관리자 전용 경로 목록에 올라 있었습니다. 즉 원래 설계 의도는 이 경로를 ADMIN 역할을 가진 사용자만 쓸 수 있게 하는 것이었는데, 그 제약이 게이트웨이에만 있고 서비스 자신에게는 없었습니다. 그래서 공유 보안 설정에 이 경로에 대한 역할 검사를 한 줄 추가해, 게이트웨이가 지키는 규칙과 서비스가 지키는 규칙을 같은 기준으로 맞췄습니다. "게이트웨이가 지키는 목록"과 "서비스가 지키는 목록"이 서로 다른 곳에 따로 적혀 있으면, 둘 중 하나만 갱신되고 나머지는 방치되는 식으로 이런 간극이 다시 생길 수 있다는 것도 이번에 배운 부분입니다.

 

수정한 뒤에는 api-agent 자신의 테스트뿐 아니라, 같은 공유 보안 설정을 쓰는 auth·chatbot·bookmark 세 서비스의 테스트까지 모두 다시 돌려 회귀가 없는지 확인했습니다. 공유 설정 파일에 규칙을 한 줄 추가하는 변경이었으므로, 그 파일을 쓰는 다른 서비스의 기존 경로 조건과 충돌하지 않는지가 특히 중요했습니다. 새로 추가한 규칙은 api-agent의 경로 패턴에만 걸리도록 조건을 좁혀 뒀기 때문에, 다른 세 서비스는 원래 쓰던 인증 규칙을 그대로 유지한 채 통과했습니다.

 

api-agent의 테스트 코드도 함께 손봐야 했습니다. 기존 테스트는 x-user-id 헤더를 직접 채워 요청을 보내는 방식으로 작성돼 있었는데, 컨트롤러가 더 이상 그 헤더를 읽지 않으므로 이 방식 자체가 무의미해졌습니다. chatbot 서비스의 테스트가 이미 쓰고 있던 방식, 즉 인증된 사용자 정보를 가짜로 주입하는 테스트 전용 리졸버를 그대로 가져와 api-agent 테스트에도 적용했습니다. 프로덕션 코드뿐 아니라 테스트 코드까지 저장소 안에 이미 있던 패턴을 재사용한 셈입니다.


다이어그램 라벨 하나가 남긴 습관

이 문제를 찾아낸 경로를 돌아보면 조금 우회적입니다. 원래는 인프라 다이어그램을 정리하다가, 라벨 한 줄이 실제로 무엇을 의미하는지 궁금해서 리스너 규칙까지 내려갔을 뿐입니다. 그런데 그 규칙을 설계 문서의 문장과 나란히 놓고 보니 어긋나는 지점이 보였고, 그 어긋남을 확인하려고 컨트롤러 코드까지 내려갔습니다. 다이어그램, 설계 문서, 실제 인프라 설정, 실제 컨트롤러 코드. 이 네 가지는 각각 다른 시점에, 다른 사람이, 다른 목적으로 만들어지기 때문에 서로 어긋날 수 있다는 것을 이번에 다시 확인했습니다.

 

개인적으로 남은 인상은, "게이트웨이가 다 처리해 준다"는 문장이 설계 문서에 적혀 있다는 사실만으로는 그 문장이 코드로 지켜지고 있다는 보장이 되지 않는다는 점이었습니다. 그 문장이 실제로 지켜지는지는 인프라 설정과 서비스 코드를 나란히 놓고 확인해야만 알 수 있었습니다. 다이어그램의 라벨 하나를 그냥 지나치지 않고 끝까지 따라가 본 것이, 결과적으로는 실제 문제를 찾아내는 가장 확실한 방법이었습니다. 앞으로 비슷한 구조의 시스템을 볼 때, "이 경로가 정말 저 관문을 거쳐야만 하는가"를 인프라 설정에서 직접 확인하는 습관을 하나 얻은 셈입니다.


참고한 공식 문서

\