AI 서버를 별도 레포로 분리하며 마주친 문제: Docker 내부망으로 포트 없이 연결하기
배경
어대GO는 서울어린이대공원 방문객에게 AI 추천 코스를 제공하는 서비스다. 코스 추천 로직(개인 LLM 서버 호출, 실패 시 Google Vertex AI로 fallback)과 크롤링, 스케줄링 같은 작업은 Java보다 Python 생태계가 훨씬 유리했고, 담당 인력도 Spring Boot 백엔드와 AI 서버가 서로 다른 사람이었다. 그래서 AI 관련 기능을 eodaego-ai라는 별도 Python(FastAPI) 레포로 분리하기로 했다.
레포를 분리하고 나니 곧바로 다음 문제가 따라왔다. Spring Boot 백엔드(eodaego-server)가 AI 서버를 호출해야 하는데, 이걸 어떻게 안전하게 연결할 것인가.
선택지는 크게 두 가지였다.
- AI 서버 포트를 외부에 공개하고, API 키 검증만으로 방어한다.
- AI 서버를 아예 인터넷에서 접근 불가능하게 만들고, 백엔드만 호출할 수 있게 한다.
왜 "포트 공개 + 인증"만으로는 부족한가
처음에는 1번(포트 공개 + 인증)도 충분히 고려했다. 하지만 따져볼수록 구멍이 많았다.
- 정적 API 키는 언젠가 유출될 수 있다. 로그, 에러 메시지, 스크린샷 어디서든 새어나갈 수 있고, 유출되는 순간 인증은 무력화된다.
- 포트가 열려 있으면 인증 로직에 도달하기 전 단계(TCP 커넥션 자체)부터 공격 표면이 생긴다. 무차별 요청만으로도 뒷단의 LLM/Vertex AI 호출 비용을 소진시키거나 서비스를 과부하시킬 수 있다.
- 백엔드를 거치지 않고 AI 서버가 직접 호출되면, 백엔드가 담당하는 날씨 데이터 주입이나 요청 검증 로직을 그대로 우회하게 된다.
그래서 네트워크 격리를 1차 방어선으로, 공유 시크릿 인증을 2차 방어선(defense-in-depth)으로 두는 이중 구조를 선택했다. 인증 하나에만 기대지 않고, 애초에 도달할 수 없게 만드는 것이 먼저였다.
Docker 커스텀 브리지 네트워크로 내부망 만들기
배포는 단일 서버(NAS) 위에서 서비스별로 독립된 컨테이너를 띄우는 구조다. 그리고 BE와 AI 레포는 각자 GitHub Actions에서 SSH로 서버에 접속해 docker run으로 배포하는 완전히 독립된 CI/CD 파이프라인을 유지한다. 두 레포의 배포 파이프라인을 억지로 docker-compose 하나로 합치고 싶지 않았기 때문이다.
대신 커스텀 브리지 네트워크 하나(eodaego-internal)를 만들어 두 컨테이너를 여기에만 join시키는 방식을 택했다. 어느 쪽이 먼저 배포되어도 안전하도록, 양쪽 배포 스크립트 모두에 "없으면 생성, 있으면 스킵" 체크를 넣었다.
if ! sudo docker network inspect eodaego-internal >/dev/null 2>&1; then
sudo docker network create eodaego-internal
fi
AI 컨테이너를 띄울 때는 -p(포트 publish) 옵션을 아예 넣지 않는다.
docker run -d \
--name eodaego-ai \
--network eodaego-internal \
-e INTERNAL_API_KEY=$INTERNAL_API_KEY \
eodaego-ai-image
# -p 옵션 없음 → 호스트/외부에서 접근 불가
컨테이너 간 통신의 원리 — 포트 충돌을 걱정하지 않아도 되는 이유
이 작업을 하면서 가장 명확해진 개념이 하나 있다. -p host:container 포트 매핑은 오직 "호스트 바깥에서 들어오는 트래픽"에만 적용되는 개념이라는 점이다.
같은 Docker 네트워크에 속한 컨테이너끼리는 이 매핑을 아예 거치지 않는다. 대신 컨테이너 이름 + 컨테이너 내부 포트로 직접 통신한다. Docker의 내장 DNS가 컨테이너 이름을 네트워크 내부 주소로 해석해주기 때문이다. 그래서 백엔드는 http://eodaego-ai:8000처럼 컨테이너 이름으로 바로 AI 서버를 호출할 수 있다.
각 컨테이너는 독립된 네트워크 네임스페이스를 갖는다. 그래서 다른 컨테이너가 내부적으로 똑같이 8000번 포트를 쓰더라도 전혀 충돌하지 않는다. 충돌이 발생할 수 있는 지점은 오직 호스트의 포트를 -p로 실제 점유할 때뿐이다. AI 컨테이너는 애초에 호스트 포트를 하나도 점유하지 않으므로, 이 문제 자체가 발생하지 않는다.
단, 여기서 주의할 게 하나 있다. 컨테이너 내부에서 FastAPI(uvicorn)는 반드시 --host 0.0.0.0으로 바인딩해야 한다. 127.0.0.1로 바인딩하면 같은 네트워크에 있는 다른 컨테이너에서도 접근할 수 없다. "외부 노출 차단"과 "내부망 통신 가능"은 완전히 다른 문제이고, 후자를 위해서는 이 바인딩 설정이 필수 조건이다.
2차 방어 — 공유 시크릿
네트워크 격리를 뚫을 방법은 사실상 없지만, 설정 실수(예: 디버깅하다가 포트를 열어두고 되돌리지 않는 경우)에 대비해 애플리케이션 레벨 인증도 함께 넣었다. 백엔드가 AI 서버를 호출할 때마다 X-Internal-Api-Key 헤더를 실어 보내고, AI 서버는 이 값을 검증해 불일치 시 401을 반환한다. 사용자 인증이 아니라 "백엔드만 호출 가능"을 보장하는 내부 신뢰 채널이므로, OAuth2/JWT 같은 무거운 방식 대신 정적 공유 키로 충분했다.
Spring Boot 쪽 구현
백엔드에서는 @ConfigurationProperties로 AI 서버 연동 정보를 관리하는 설정 클래스를 만들고, 이를 기반으로 RestClient Bean을 구성했다.
@Configuration
@RequiredArgsConstructor
@EnableConfigurationProperties(AiServerProperties.class)
public class AiServerConfig {
private final AiServerProperties aiServerProperties;
@Bean
public RestClient aiServerRestClient() {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(aiServerProperties.connectTimeout());
requestFactory.setReadTimeout(aiServerProperties.readTimeout());
return RestClient.builder()
.baseUrl(aiServerProperties.baseUrl())
.requestFactory(requestFactory)
.defaultHeader("X-Internal-Api-Key", aiServerProperties.internalApiKey())
.build();
}
}
AI 서버가 응답하지 않을 때 요청이 무한 대기하지 않도록 connect/read 타임아웃을 명시적으로 설정한 것이 포인트다. 내부망이라고 해서 항상 응답이 즉시 온다는 보장은 없기 때문이다.
이후에는 이 연결이 실제로 살아있는지 백엔드에서 바로 확인할 수 있도록 헬스체크 엔드포인트(GET /api/v1/ai/health)도 추가했다. AI 서버 연결 실패를 별도 에러 코드로 구분하고, 이 경로는 인증 예외 목록에 추가해 배포 후 상태 확인을 쉽게 만들었다.
결과
- AI 서버는 인터넷 어디에서도 물리적으로 도달할 수 없다. 관리자 페이지(프롬프트·크롤링 스케줄 설정)조차 AI를 직접 호출하지 않고, 백엔드 내부 SSR 페이지가 대신 호출하도록 구조를 잡아서 AI를 브라우저에 노출할 필요 자체를 없앴다.
- 백엔드는 내부망을 통해서만, 그것도 인증 헤더까지 검증된 요청만 AI 서버에 도달할 수 있다.
- 배포 파이프라인은 두 레포가 여전히 완전히 독립적으로 유지되면서도, 네트워크 이름 하나만 공유해 안전하게 연결됐다.
- 이 모든 게 별도 인프라(리버스 프록시, VPN, 서비스 메시)를 도입하지 않고 Docker 커스텀 네트워크 하나로 해결됐다.
MSA를 도입할 때 항상 "서비스 간 통신을 어떻게 안전하게 할 것인가"라는 질문이 따라온다는 걸 이번에 체감했다. 답은 거창한 솔루션이 아니라, Docker가 이미 제공하는 네트워크 격리 기능을 제대로 이해하고 쓰는 것만으로도 충분한 경우가 많았다.
첫 댓글을 남겨보세요.