독립 배포를 유지하면서 AI 서버를 Docker 내부망으로 격리한 과정
어대GO는 서울어린이대공원 방문자에게 개인화된 이동 경로를 추천하는 서비스다.
사용자가 원하는 장소와 조건을 선택하면 Spring Boot 서버가 데이터를 가공해 FastAPI 기반 AI 서버에 전달하고, AI 서버는 추천 경로를 생성해 반환한다.
초기에는 기능을 만드는 데 집중했다. Flutter에서 요청을 보내고, Spring Boot가 데이터를 처리하고, FastAPI가 추천 결과를 돌려주면 됐다.
문제는 배포를 준비하면서 시작됐다.
AI 서버는 일반 사용자가 직접 호출할 이유가 없었다. 그런데 별도의 서버로 분리해 배포하다 보니, 아무 생각 없이 포트를 열면 외부에서도 AI API에 접근할 수 있었다.
API Key를 붙이면 되는 문제처럼 보였지만, 굳이 외부에 공개할 필요가 없는 서버를 인터넷에 노출한 뒤 인증으로 막는 방식이 맞는지 의문이 들었다.
결국 다음 조건을 만족하는 구조가 필요했다.
- Spring Boot와 FastAPI는 서로 다른 저장소에서 독립적으로 배포할 수 있어야 했다.
- 외부 요청은 Spring Boot를 통해서만 들어와야 했다.
- AI 서버는 외부 포트를 열지 않아도 Spring Boot와 통신할 수 있어야 했다.
- AI 서버가 사용하는 외부 API와 LLM 서버에는 계속 접근할 수 있어야 했다.
- 두 서버가 같은 Synology NAS에서 Docker 컨테이너로 실행되는 현재 환경을 활용해야 했다.
이 글에서는 이 조건을 만족하기 위해 Docker 사용자 정의 브리지 네트워크를 적용한 과정과, 배포 이후 실제로 마주친 HTTP 프로토콜 문제를 정리했다.
서비스를 둘로 나누기는 했지만, 이 구조를 MSA라고 부르지는 않는다. 독립된 두 애플리케이션을 역할에 따라 분리해 배포한 구조에 가깝다.
1. AI 기능을 별도 서버로 분리한 이유
어대GO의 백엔드는 크게 두 부분으로 나뉜다.
Spring Boot 서버는 다음 역할을 담당한다.
- 회원과 관리자 기능
- 장소와 코스 데이터 관리
- Flutter에 제공하는 서비스 API
- AI 요청에 필요한 데이터 조회와 가공
- AI 응답을 서비스 응답 형식으로 변환
FastAPI 서버는 다음 역할을 담당한다.
- 추천 입력 검증
- 추천 경로 생성
- 프롬프트와 모델 관리
- LLM 서버 호출
- AI 처리용 데이터 관리
flowchart LR
F[Flutter] --> B[Spring Boot]
B --> A[FastAPI AI Server]
A --> L[LLM Server]
A --> E[External APIs]
AI 기능을 Spring Boot 프로젝트 안에 넣는 방법도 있었지만, Python 생태계를 사용하는 편이 모델 호출과 데이터 처리에 편했다.
역할도 명확히 나뉘었다. 나는 AI 서버와 배포 환경을 담당했고, 다른 백엔드 개발자들은 서비스 API와 CRUD 기능을 담당했다.
데이터베이스도 목적에 따라 분리했다.
- Spring Boot 데이터베이스: 사용자에게 제공되는 서비스 데이터
- AI 데이터베이스: 추천 생성에 필요한 원본 및 가공 데이터
이렇게 나누면 각 담당자가 자기 저장소와 배포 파이프라인을 관리할 수 있었다. 한쪽 서버를 수정했다고 해서 항상 다른 서버까지 함께 배포할 필요도 없었다.
다만 애플리케이션을 분리한 순간, 두 서버를 어떻게 안전하게 연결할 것인지라는 새로운 문제가 생겼다.
2. 외부에서 AI 서버를 직접 호출할 이유가 없었다
전체 요청 흐름은 다음과 같다.
sequenceDiagram
participant U as Flutter
participant R as Synology Reverse Proxy
participant B as Spring Boot
participant A as FastAPI
participant L as LLM Server
U->>R: 코스 추천 요청
R->>B: HTTPS 요청 전달
B->>B: 사용자 및 장소 데이터 조회
B->>A: 내부 추천 API 호출
A->>L: LLM 요청
L-->>A: 추천 결과
A-->>B: 추천 코스 반환
B-->>U: 서비스 응답 반환
Flutter는 AI 서버를 직접 호출하지 않는다.
관리자 화면도 마찬가지다. 관리자 브라우저가 AI API를 직접 호출하는 것이 아니라, Spring Boot의 Thymeleaf 컨트롤러와 서비스를 거쳐 AI 서버에 접근한다. AI 관련 관리자 기능과 실제 코스 추천 기능 모두 Spring Boot에 등록된 공통 AI 클라이언트를 사용한다.
즉, AI 서버의 클라이언트는 사실상 Spring Boot 하나였다.
그런데 FastAPI 컨테이너를 다음처럼 실행하면 상황이 달라진다.
docker run -p 8001:8000 eodaego-ai
-p 8001:8000은 컨테이너의 8000번 포트를 Docker 호스트의 8001번 포트에 연결한다.
방화벽과 공유기 설정에 따라 접근 가능 범위가 달라지기는 하지만, 적어도 Docker 호스트에 AI 서버로 들어가는 경로가 생긴다.
API Key를 검사한다고 해도 불필요한 포트가 열리면 공격 표면은 넓어진다.
- 인증되지 않은 반복 요청
- API Key 탈취 후 직접 호출
- 예상하지 못한 요청 크기나 형식
- FastAPI 문서 및 스키마 경로 노출
- 애플리케이션 취약점 탐색
- CPU, 메모리 및 LLM 자원 소모
유효한 인증 없이 곧바로 유료 LLM 비용이 발생하는 구조는 아니지만, 외부 접근 경로 자체가 없다면 고려하지 않아도 될 요청을 추가로 방어해야 한다.
따라서 목표는 AI 서버를 인증으로만 보호하는 것이 아니라, 처음부터 외부 인바운드 경로를 제공하지 않는 것이었다.
3. 검토한 방법
3.1 AI 포트를 공개하고 API Key만 검사하기
가장 구현하기 쉬운 방법이었다.
Internet ├── Spring Boot └── FastAPI + API Key
Spring Boot와 AI 서버에 각각 도메인이나 포트를 연결하고, AI API에 X-Internal-Api-Key를 요구하면 된다.
구현 난이도는 낮지만 구조와 맞지 않았다. AI 서버를 호출할 주체가 Spring Boot 하나뿐인데, 먼저 인터넷에 공개한 뒤 인증을 추가하는 순서가 불필요하게 느껴졌다.
API Key가 유출되면 외부에서 AI 서버를 직접 호출할 수도 있다.
3.2 방화벽에서 Spring Boot만 허용하기
AI 포트를 열어 두되 특정 IP만 허용하는 방법도 검토했다.
서버가 서로 다른 고정 IP 환경에 있다면 사용할 수 있다. 하지만 현재 두 컨테이너는 같은 NAS에서 실행되고 있었다. 외부 네트워크를 거쳐 다시 같은 서버로 들어오게 만드는 구조가 복잡할 뿐이었다.
향후 컨테이너나 서버 구성이 바뀔 때 IP 규칙을 함께 관리해야 한다는 문제도 있었다.
3.3 Docker 네트워크로 컨테이너끼리만 연결하기
두 컨테이너를 같은 Docker 사용자 정의 브리지 네트워크에 연결하고, AI 컨테이너의 호스트 포트를 공개하지 않는 방법이다.
Internet │ ▼ Spring Boot Container │ │ eodaego-internal ▼ FastAPI Container
Docker 네트워크에 참여한 컨테이너끼리는 컨테이너 이름을 이용해 통신할 수 있다.
Spring Boot는 다음 주소로 AI 서버를 호출한다.
http://eodaego-ai:8000
외부에는 AI 서버의 호스트 포트를 열지 않는다.
현재 환경에서는 이 방법이 가장 단순했다. 같은 Docker 호스트에서 실행되는 두 서버를 외부 네트워크로 내보낼 필요가 없었고, 기존의 독립 배포 구조도 유지할 수 있었다.
4. Docker Compose를 사용하지 않은 이유
두 컨테이너를 함께 실행한다고 하면 Docker Compose가 먼저 떠오른다.
Compose를 사용하면 다음 설정을 하나의 파일에서 선언할 수 있다.
- 서비스
- 네트워크
- 볼륨
- 환경 변수
- 의존 관계
- 헬스 체크
로컬에서 전체 환경을 재현하기도 편하다.
다만 Compose를 사용하면 모든 서비스를 매번 함께 재배포해야 한다는 설명은 정확하지 않다. Compose에서도 특정 서비스만 빌드하거나 다시 실행할 수 있다.
어대GO에서 Compose를 사용하지 않은 이유는 기능의 한계가 아니라 운영 방식 때문이었다.
Spring Boot와 FastAPI는 처음부터 저장소와 담당자가 분리되어 있었다.
eodaego-BE └── Spring Boot CI/CD eodaego-AI └── FastAPI CI/CD
각 저장소는 자신의 GitHub Actions 워크플로로 이미지를 빌드하고 NAS에 배포한다. Spring Boot 배포 워크플로와 AI 배포 워크플로 모두 eodaego-internal 네트워크에 컨테이너를 연결한다.
이 상황에서 공용 Compose 파일을 도입하면 다음 결정을 추가로 해야 했다.
- Compose 파일을 어느 저장소에서 관리할 것인가
- AI 서버 변경 시 BE 저장소의 배포 설정도 수정해야 하는가
- 공용 환경 변수와 Secret의 소유자는 누구인가
- 한 서비스만 배포할 때 공용 파일의 변경을 어떻게 동기화할 것인가
- 기존 GitHub Actions를 어느 범위까지 다시 작성할 것인가
현재 규모에서는 공용 배포 파일을 새로 관리하는 비용이 더 컸다.
그래서 각 서비스의 독립 배포는 유지하고, 두 워크플로가 동일한 Docker 네트워크에 참여하도록 구성했다.
Compose를 사용할 수 없어서 선택한 것이 아니라, 현재 팀의 저장소와 역할 분리 방식에 맞춰 사용하지 않은 것이다.
5. 사용자 정의 브리지 네트워크 구성
사용한 네트워크 이름은 eodaego-internal이다.
각 배포 워크플로에서는 네트워크가 존재하는지 확인하고, 없으면 생성한다.
docker network inspect eodaego-internal >/dev/null 2>&1 \ || docker network create eodaego-internal
이후 컨테이너를 실행할 때 같은 네트워크에 연결한다.
docker run \ --name eodaego-ai \ --network eodaego-internal \ eodaego-ai
Spring Boot 컨테이너도 같은 방식으로 네트워크에 연결한다.
docker run \ --name eodaego-be \ --network eodaego-internal \ -p 8083:8080 \ eodaego-be
외부 요청을 받아야 하는 Spring Boot는 호스트 포트를 공개한다.
반면 AI 서버는 운영 환경에서 -p 옵션을 제거하는 것이 목표다.
flowchart TB
I[Internet]
subgraph NAS[Synology NAS]
RP[Reverse Proxy]
subgraph DN[Docker Network: eodaego-internal]
BE[Spring Boot<br/>eodaego-be:8080]
AI[FastAPI<br/>eodaego-ai:8000]
end
end
I --> RP
RP --> BE
BE --> AI
AI Dockerfile에는 EXPOSE 8000이 작성돼 있지만, EXPOSE만으로 호스트 포트가 열리지는 않는다.
EXPOSE 8000 CMD [ "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000" ]
Uvicorn의 0.0.0.0은 컨테이너 내부의 모든 네트워크 인터페이스에서 요청을 받겠다는 의미다.
호스트에 공개하겠다는 의미는 아니다. 호스트 포트 공개 여부는 docker run의 -p 옵션으로 결정된다.
Spring Boot와 FastAPI가 둘 다 내부적으로 8000번이나 8080번 같은 포트를 사용해도 충돌하지 않는다. 컨테이너마다 별도의 네트워크 네임스페이스를 사용하기 때문이다.
6. 컨테이너 이름으로 AI 서버 찾기
기본 브리지 네트워크가 아닌 사용자 정의 브리지 네트워크를 사용하면 Docker의 내장 DNS를 이용할 수 있다.
Spring Boot는 AI 컨테이너의 IP 주소를 직접 알 필요가 없다.
ai-server: base-url: http://eodaego-ai:8000
여기서 eodaego-ai는 컨테이너 이름이다.
AI 컨테이너가 재시작되면서 내부 IP가 바뀌어도 Spring Boot 설정은 수정하지 않아도 된다.
eodaego-ai
│
└── Docker DNS가 현재 컨테이너 IP로 해석
처음에는 두 서버를 분리하면 각각의 도메인이나 IP가 필요하다고 생각하기 쉽다.
하지만 같은 Docker 호스트에서 같은 사용자 정의 네트워크에 참여한다면 컨테이너 이름 자체가 서비스 주소 역할을 한다.
7. 네트워크 격리만 믿지 않고 API Key를 함께 사용했다
AI 서버의 호스트 포트를 제거해도 인증을 없애지는 않았다.
Spring Boot는 모든 AI 요청에 다음 헤더를 붙인다.
X-Internal-Api-Key: ********
FastAPI에서는 공통 의존성을 통해 키를 검사한다.
def verify_internal_api_key( x_internal_api_key: str = Header(...) ) -> None: if x_internal_api_key != settings.internal_api_key: raise HTTPException(status_code=401)
각 AI 라우터에는 해당 검증 함수가 의존성으로 등록돼 있다. /health는 모니터링 목적으로 예외 처리돼 있고, 나머지 API는 공통 키 검사를 거친다.
네트워크와 인증은 서로 다른 문제를 막는다.
| 계층 | 막으려는 문제 |
|---|---|
| 호스트 포트 미공개 | 외부에서 AI 서버로 직접 들어오는 경로 |
| Docker 네트워크 | 지정한 컨테이너 간 이름 기반 통신 |
| 내부 API Key | 네트워크에 접근한 요청의 호출 주체 검증 |
| 방화벽 및 Reverse Proxy | 호스트와 공개 서비스의 외부 접근 범위 제한 |
사용자 정의 브리지 네트워크에 들어왔다고 해서 무조건 신뢰할 수 있는 것은 아니다.
같은 네트워크에 연결된 다른 컨테이너도 AI 서버에 접근할 수 있다. Docker 호스트 자체가 침해되면 컨테이너 네트워크 경계도 큰 의미가 없다.
그래서 내부망에서도 API Key 검사를 유지했다.
현재 키는 AI 서버의 환경 변수와 Spring Boot 운영 설정에 각각 저장된다. GitHub Actions에서는 Secret을 통해 배포 환경에 전달한다.
다만 현재 구현은 정적 공유 키 방식이다. 키가 유출되면 AI와 BE 설정을 모두 변경하고 다시 배포해야 한다.
8. 왜 --internal 네트워크는 사용하지 않았나
Docker에는 외부 연결을 차단하는 --internal 네트워크 옵션도 있다.
docker network create --internal eodaego-internal
이 옵션을 사용하면 해당 네트워크에만 연결된 컨테이너는 일반적으로 외부 네트워크로 나갈 수 없다.
어대GO의 AI 서버는 외부 통신이 필요하다.
- 외부 데이터 API 호출
- 내부 또는 별도 서버에 있는 LLM 호출
- 향후 Vertex AI 호출
따라서 현재 eodaego-internal은 --internal 옵션이 없는 일반 사용자 정의 브리지 네트워크다.
이 네트워크의 이름에 internal이 들어가지만, Docker의 --internal 속성이 적용됐다는 의미는 아니다.
현재 구조의 목적은 AI 서버의 아웃바운드 통신까지 차단하는 것이 아니라, 호스트 포트를 공개하지 않아 외부 인바운드 경로를 제거하는 것이다.
더 강한 격리가 필요하다면 다음과 같은 이중 네트워크 구성을 검토할 수 있다.
flowchart LR
BE[Spring Boot]
subgraph PRIVATE[내부 전용 네트워크]
BE
AI[FastAPI]
end
subgraph EGRESS[외부 통신 네트워크]
AI
end
AI --> EXT[External API / LLM]
Spring Boot와 AI 사이에는 --internal 네트워크를 사용하고, AI 서버만 별도의 외부 통신용 네트워크에 추가로 연결하는 방식이다.
다만 현재 규모에서는 네트워크 구성이 복잡해지는 것에 비해 얻는 이점이 크지 않다고 판단했다.
9. Spring Boot에서 AI 서버를 호출하는 방식
Spring Boot에서는 RestClient를 사용해 AI 서버 호출 설정을 공통으로 관리한다.
주요 설정은 다음과 같다.
- AI 서버 기본 URL
- 내부 API Key
- 연결 타임아웃
- 응답 타임아웃
- HTTP 프로토콜 버전
@Bean public RestClient aiServerRestClient( AiServerProperties properties ) { // 공통 URL, Header, Timeout 설정 }
추천 코스 생성, AI 서버 상태 확인, 관리자 프롬프트 기능이 같은 클라이언트 설정을 사용한다.
요청마다 URL과 API Key를 직접 작성하지 않아도 되고, 호출 정책을 한곳에서 변경할 수 있다.
BE 코드의 구체적인 구현은 백엔드 담당자들이 관리했다. 나는 AI 서버와 배포 환경을 담당하면서, 컨테이너 간 통신 방식과 AI API 규격을 함께 조율했다.
처음에는 Docker 네트워크와 API Key만 정상적으로 설정하면 통신 문제가 끝날 것이라고 생각했다.
실제 배포에서는 전혀 다른 문제가 발생했다.
10. 로컬에서는 되는데 배포 서버에서는 503이 발생했다
코스 추천 API는 로컬 환경에서 정상적으로 동작했다.
그런데 Docker 내부망으로 배포한 뒤에는 Spring Boot가 503 응답을 반환했다.
AI 서버 로그에는 다음과 비슷한 메시지가 남았다.
Unsupported upgrade request
AI 서버는 400 응답을 반환했고, Spring Boot는 이를 AI 서버 호출 실패로 처리해 사용자에게 503을 반환했다.
처음에는 다음 항목을 의심했다.
- 컨테이너 이름이 잘못됐는가
- Docker DNS가 동작하지 않는가
- 내부 API Key가 전달되지 않았는가
- 요청 DTO 형식이 다른가
- FastAPI 라우터 경로가 잘못됐는가
- 방화벽이 컨테이너 통신까지 차단하는가
하지만 네트워크 연결 자체는 되고 있었다. AI 서버 로그에 요청이 남았기 때문이다.
문제는 HTTP 프로토콜이었다.
11. HTTP/2 업그레이드 요청을 Uvicorn이 처리하지 못했다
Spring Boot의 HTTP 클라이언트는 JdkClientHttpRequestFactory를 사용하고 있었다.
내부에서 사용하는 Java HttpClient는 HTTP/2를 선호할 수 있다. HTTPS가 아닌 평문 HTTP 환경에서는 HTTP/1.1 연결을 HTTP/2로 올리기 위한 h2c 업그레이드 요청이 발생할 수 있다.
배포 환경에서는 Spring Boot가 다음 주소로 FastAPI를 직접 호출했다.
http://eodaego-ai:8000
중간에 HTTPS Reverse Proxy가 없는 평문 HTTP 통신이었다.
Uvicorn은 해당 업그레이드 요청을 정상적인 FastAPI 요청으로 처리하지 못했고 Unsupported upgrade request를 남겼다.
sequenceDiagram
participant B as Spring Boot
participant A as Uvicorn
B->>A: HTTP/1.1 + Upgrade: h2c
A-->>B: 400 Bad Request
Note over A: Unsupported upgrade request
B-->>B: AI_SERVER_UNAVAILABLE
반면 로컬 테스트에서는 HTTPS 프록시를 거치는 흐름이 포함돼 있었다. 프록시 구간에서 HTTP/1.1로 통신하면서 문제가 드러나지 않았다.
같은 애플리케이션 코드라도 실제 요청 경로가 달라지면서 동작이 달라진 것이다. 해당 문제와 수정 과정은 배포 이슈로 정리됐다.
해결 방법은 Java HttpClient가 HTTP/1.1만 사용하도록 명시하는 것이었다.
HttpClient httpClient = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_1_1) .build();
이를 JdkClientHttpRequestFactory에 연결한 뒤 요청이 정상적으로 처리됐다.
이 문제를 겪으면서 “로컬에서 정상 동작했다”는 결과만으로는 충분하지 않다는 점을 다시 확인했다.
로컬과 운영 환경에서 다음 조건이 달랐다.
| 구분 | 로컬 | 배포 |
|---|---|---|
| 호출 주소 | 프록시 또는 외부 주소 | Docker 컨테이너 이름 |
| 프로토콜 | HTTPS 구간 존재 | 평문 HTTP |
| 중간 프록시 | 있음 | 컨테이너 간 직접 통신 |
| HTTP 협상 | HTTP/1.1로 동작 | h2c 업그레이드 시도 |
| 결과 | 정상 | Uvicorn 400, BE 503 |
Docker 네트워크가 잘못된 것이 아니라, 네트워크 구조를 바꾸면서 이전에는 가려져 있던 HTTP 클라이언트 동작이 드러난 사례였다.
12. AI 요청을 고려한 타임아웃 설정
AI 요청은 일반 CRUD API보다 응답 시간이 길 수 있다.
어대GO의 추천 요청은 다음 구간을 통과한다.
Flutter ↓ Synology Reverse Proxy ↓ Spring Boot RestClient ↓ FastAPI ↓ 서버 내부 Nginx ↓ LLM Server
각 구간에는 별도의 타임아웃이 설정돼 있다.
AI 서버에서 LLM 서버를 호출하는 공개 코드 기준 설정은 다음과 같다.
_CONNECT_TIMEOUT_SECONDS = 5 _READ_TIMEOUT_SECONDS = 120
response = requests.post( url, headers=headers, json=body, timeout=( _CONNECT_TIMEOUT_SECONDS, _READ_TIMEOUT_SECONDS, ), )
연결 자체가 되지 않는 상황은 5초 안에 빠르게 실패시키고, LLM 추론 결과는 최대 120초 동안 기다린다.
Spring Boot와 서버 내부 Nginx, Synology Reverse Proxy에도 각각 타임아웃이 설정돼 있다. 운영 설정의 구체적인 숫자는 공개 저장소에 포함하지 않았다.
중요한 것은 단순히 모든 값을 길게 설정하는 것이 아니다.
안쪽 요청을 기다리는 동안 바깥 계층이 먼저 연결을 끊지 않도록 구성해야 한다.
AI → LLM 제한 시간
<
BE → AI 제한 시간
<
Reverse Proxy → BE 제한 시간
예를 들어 AI 서버가 LLM 응답을 120초까지 기다리는데 Reverse Proxy가 60초에 연결을 종료하면, 사용자는 이미 실패 응답을 받은 뒤에도 내부 LLM 작업이 계속 실행될 수 있다.
각 구간의 시간에는 다음 항목도 포함된다.
- 네트워크 지연
- Spring Boot의 데이터 조회 및 가공
- FastAPI의 입력 검증
- LLM 추론
- AI 결과 후처리
- 응답 직렬화
따라서 운영 전에는 각 계층의 타임아웃이 안쪽에서 바깥쪽으로 일관되게 설정돼 있는지 확인해야 한다.
현재 AI 클라이언트의 공개 코드 값은 120초이며, 180초는 AI 코드에서 확인되는 값이 아니다. 180초가 사용됐다면 BE 운영 설정이나 Nginx, Reverse Proxy 설정일 가능성이 있다.
13. 헬스 체크가 확인하는 범위
FastAPI에는 /health API가 있다.
이 API는 단순히 프로세스가 실행 중인지 확인하는 데서 끝나지 않는다. AI 데이터베이스에 SELECT 1을 실행해 연결 상태까지 검사한다.
GET /health
├── FastAPI 실행 여부
└── AI DB 연결 여부
데이터베이스 연결에 실패하면 503을 반환한다.
Spring Boot도 해당 API를 호출하는 AI 헬스 체크 기능을 가지고 있다. AI 서버 연결에 실패하거나 RestClient 예외가 발생하면 서비스용 에러로 변환한다.
현재 /health는 Dockerfile의 HEALTHCHECK 명령으로 직접 연결돼 있지는 않다. 외부 모니터링과 향후 컨테이너 헬스 체크에 사용할 수 있도록 만들어 둔 API에 가깝다.
또한 DB 연결까지 검사하기 때문에 순수한 liveness보다 readiness에 가까운 성격을 가진다.
- Liveness: 프로세스가 살아 있는가
- Readiness: 실제 요청을 처리할 준비가 됐는가
향후에는 두 목적을 분리하는 것이 더 명확하다.
/live └── FastAPI 프로세스 상태 /ready ├── 데이터베이스 ├── 필수 설정 └── 의존 서비스 상태
LLM 서버까지 readiness에 포함할지는 별도로 판단해야 한다. LLM 서버의 일시적인 장애 때문에 AI 컨테이너 자체를 반복 재시작하는 것은 오히려 장애를 키울 수 있기 때문이다.
14. 개발 편의를 위해 임시로 AI 포트를 열어 둔 상태
운영 목표는 AI 서버의 호스트 포트를 공개하지 않는 것이다.
다만 개발 과정에서는 백엔드 담당자가 AI API를 직접 확인할 수 있도록 AI 배포 워크플로에 다음 설정을 임시로 추가했다.
-p 8001:8000
워크플로에도 운영 전 제거해야 한다는 주석을 남겨 두었다.
현재 이 포트는 다음 방식으로 추가 보호하고 있다.
- 서버 방화벽
- Synology Reverse Proxy 설정
- 내부 API Key 검증
- 접근 경로 제한
하지만 방어 장치를 추가했다고 해서 포트를 계속 유지할 이유가 생기는 것은 아니다.
이 포트는 개발 편의를 위한 예외다. 운영 전에는 -p 8001:8000을 제거해 설계한 구조와 실제 배포 구조를 일치시킬 예정이다.
이 과정을 통해 개발 환경의 임시 설정에는 반드시 제거 조건을 남겨야 한다는 점도 배웠다.
주석만 남기는 것보다 배포 체크리스트나 CI 검사로 확인하는 편이 안전하다.
예를 들어 운영 워크플로에 다음과 같은 검사를 추가할 수 있다.
if grep -q -- "-p 8001:8000" deploy-command.sh; then echo "AI production port must not be published" exit 1 fi
15. 수동으로 확인한 항목
현재 검증은 자동화된 통합 테스트보다 실제 배포 환경에서의 수동 확인을 중심으로 진행했다.
| 확인 항목 | 결과 |
|---|---|
| 각 저장소의 독립 배포 | 정상 |
두 컨테이너의 eodaego-internal 참여 |
정상 |
Spring Boot에서 eodaego-ai 이름 해석 |
정상 |
Spring Boot에서 FastAPI /health 호출 |
정상 |
| 올바른 내부 API Key 요청 | 정상 |
| 잘못된 API Key 요청 차단 | 정상 |
| 추천 경로 API 호출 | 정상 |
| 관리자 화면에서 AI API 호출 | 정상 |
| AI 서버 중단 시 BE 오류 변환 | 정상 |
| 지연 응답에 대한 타임아웃 처리 | 정상 |
| HTTP/1.1 강제 후 배포 환경 호출 | 정상 |
초기 AI 연동 작업에서도 AI 서버가 없을 때의 503 응답, 정상 응답, 지연 응답과 타임아웃을 수동으로 확인했다. 다만 해당 검증은 자동 테스트로 작성돼 있지 않다.
수동 테스트로 실제 환경의 문제를 발견할 수 있었지만, 재발 방지 측면에서는 부족하다.
특히 HTTP/2 문제는 코드를 수정한 뒤에도 배포 환경과 유사한 조건에서 다시 검증해야 한다.
향후에는 Testcontainers 등을 이용해 Spring Boot와 FastAPI를 동일한 Docker 네트워크에 띄우는 통합 테스트를 검토할 수 있다.
Spring Boot Test Container
│
│ HTTP/1.1
▼
FastAPI Test Container
최소한 다음 항목은 자동화할 가치가 있다.
- 컨테이너 이름을 이용한 통신
- 내부 API Key 전달
- AI 서버 오류 응답 변환
- 응답 지연 시 타임아웃
- 추천 요청 및 응답 역직렬화
- HTTP 클라이언트 프로토콜 설정
16. 현재 구조의 한계
16.1 Docker 호스트를 신뢰하는 구조다
AI 서버의 호스트 포트를 제거해도 Docker 호스트 관리자는 컨테이너와 네트워크에 접근할 수 있다.
공격자가 NAS나 Docker 권한을 획득하면 다음 작업이 가능하다.
- AI 컨테이너에 직접 접근
- 환경 변수 확인
- 내부 API Key 탈취
- 같은 네트워크에 악성 컨테이너 연결
- 컨테이너 파일 시스템 확인
따라서 Docker 내부망은 호스트 침해까지 막는 보안 경계가 아니다.
SSH 키 관리, 관리자 계정 분리, 방화벽, Docker Socket 보호와 같은 호스트 보안이 함께 필요하다.
16.2 내부 통신은 평문 HTTP다
Spring Boot와 FastAPI는 같은 Docker 호스트 안에서 HTTP로 통신한다.
현재 위협 모델에서는 NAS와 Docker 브리지 네트워크를 신뢰하고 있어 TLS를 적용하지 않았다.
다른 서버나 신뢰할 수 없는 네트워크를 통과하게 된다면 동일한 구조를 그대로 사용할 수 없다.
16.3 정적 공유 키를 사용한다
현재 X-Internal-Api-Key는 하나의 고정된 값을 공유한다.
구현은 단순하지만 다음 기능은 없다.
- 자동 키 순환
- 키 버전
- 서비스별 키
- 만료 시간
- 요청 서명
- 키 사용 감사 로그
키를 변경하면 AI와 BE를 함께 갱신해야 한다.
또한 현재 문자열 비교는 일반 비교 연산을 사용한다. 보안 수준을 높이려면 hmac.compare_digest와 같은 상수 시간 비교 함수도 검토할 수 있다.
16.4 네트워크 생성 경쟁 조건이 존재할 수 있다
AI와 BE 워크플로 모두 다음 형태로 네트워크를 생성한다.
docker network inspect eodaego-internal \ || docker network create eodaego-internal
대부분의 배포에서는 문제가 없지만, 네트워크가 없는 최초 상태에서 두 워크플로가 동시에 실행되면 둘 다 생성 명령을 시도할 가능성이 있다.
현재는 발생 가능성이 낮고 재실행으로 복구할 수 있지만, 인프라 생성 책임을 별도로 두는 편이 더 명확하다.
16.5 AI 컨테이너가 실행 중이어야 실제 요청이 성공한다
두 배포 파이프라인은 독립적이다.
어느 저장소를 먼저 배포해도 Docker 네트워크 자체는 만들어질 수 있다. 하지만 AI 서버가 실행되지 않은 상태에서 Spring Boot가 AI 기능을 호출하면 당연히 실패한다.
독립 배포는 실행 의존성이 없다는 뜻이 아니다.
BE에서는 이를 503 오류로 변환하고 헬스 체크로 확인하지만, 기능 배포 순서가 중요한 변경이라면 팀 내 배포 절차가 필요하다.
16.6 긴 AI 요청을 동기 HTTP로 처리한다
현재 추천 요청은 하나의 HTTP 요청 안에서 LLM 결과까지 기다린다.
응답 시간이 허용 범위 안에 있을 때는 단순하고 구현하기 쉽다. 하지만 요청이 많아지거나 모델 처리 시간이 길어지면 다음 문제가 발생할 수 있다.
- 서버 연결 장시간 점유
- Reverse Proxy 연결 증가
- 사용자 재시도로 인한 중복 요청
- LLM 서버 작업 누적
- 타임아웃 이후에도 내부 작업 지속
트래픽과 처리 시간이 증가한다면 비동기 작업 구조를 검토해야 한다.
사용자 요청 ↓ 작업 생성 ↓ Queue ↓ AI Worker ↓ 결과 저장 ↓ Polling 또는 Push
현재 규모에서는 동기 요청이 더 단순하지만, 계속 유지할 수 있다고 단정하지는 않았다.
17. 향후 개선 방향
17.1 운영 배포에서 AI 호스트 포트 제거
가장 먼저 해야 할 작업이다.
docker run \ --name eodaego-ai \ --network eodaego-internal \ - -p 8001:8000 \ eodaego-ai
배포 후 NAS 호스트에서 다음 항목을 확인해야 한다.
docker ps docker port eodaego-ai docker network inspect eodaego-internal
AI 컨테이너에 공개된 호스트 포트가 없어야 한다.
17.2 키 순환 절차 만들기
정적 공유 키를 유지하더라도 최소한 교체 절차는 필요하다.
한 번에 하나의 키만 허용하면 배포 순서에 따라 잠시 통신이 끊길 수 있다.
이를 줄이기 위해 일정 기간 두 개의 키를 허용할 수 있다.
현재 키: KEY_V1 신규 키: KEY_V2 1. AI가 V1, V2 모두 허용 2. BE를 V2로 변경 3. V1 사용 중지
규모가 커지면 Vault, AWS Secrets Manager, Google Secret Manager 같은 별도 Secret 관리 도구도 검토할 수 있다.
17.3 네트워크를 사전에 생성하기
애플리케이션 배포 워크플로에서 네트워크를 매번 만들기보다 NAS 초기 설정이나 별도 인프라 스크립트에서 생성할 수 있다.
docker network create eodaego-internal
애플리케이션 워크플로에서는 네트워크가 없으면 배포를 실패시키고, 원인을 명확히 알리는 방식도 가능하다.
17.4 Liveness와 Readiness 분리
현재 /health는 데이터베이스까지 확인한다.
이를 /live와 /ready로 분리하면 모니터링과 자동 복구 정책을 더 세밀하게 설정할 수 있다.
17.5 FastAPI 문서 경로 관리
FastAPI는 기본적으로 다음 문서 경로를 제공한다.
/docs/redoc/openapi.json
AI 포트를 외부에 공개하지 않으면 접근 범위가 줄어들지만, 내부 네트워크의 모든 컨테이너에 문서를 공개할 필요도 없다.
운영 환경에서는 다음처럼 비활성화할 수 있다.
app = FastAPI( docs_url=None, redoc_url=None, openapi_url=None, )
개발 환경과 운영 환경의 설정을 분리하는 방식도 가능하다.
17.6 Vertex AI fallback
현재 AI 서버는 내부 LLM 서버를 호출한다.
Vertex AI fallback은 아직 구현하지 않았다. 향후 내부 모델 호출이 실패하거나 특정 조건을 만족하지 못했을 때 유료 Vertex AI를 호출하는 방식을 검토하고 있다.
다만 fallback은 호출을 한 번 더 추가한다고 끝나는 문제가 아니다.
현재 내부 LLM read timeout이 120초인데, 이를 모두 기다린 뒤 Vertex AI를 다시 호출하면 전체 요청 시간이 과도하게 길어진다.
전체 요청 시간 예산 ├── 입력 처리 ├── 내부 LLM 호출 ├── fallback 호출 └── 후처리
fallback을 도입할 때는 다음을 함께 결정해야 한다.
- 전체 요청 제한 시간
- 1차 모델에 배정할 시간
- 어떤 오류에서 fallback할지
- 타임아웃도 fallback 대상으로 볼지
- 중복 과금 방지
- 동일 요청 중복 실행 방지
- fallback 사용 로그와 비용 추적
내부 LLM이 느리다는 이유만으로 매번 유료 모델을 호출하면 비용 예측이 어려워질 수 있다.
17.7 AI 서버가 다른 서버로 이동하는 경우
현재 방식은 두 컨테이너가 같은 Docker 호스트에 있기 때문에 단순하다.
AI 서버를 별도 물리 서버나 클라우드로 옮기면 Docker 브리지 네트워크만으로는 연결할 수 없다.
그때는 다음 순서로 검토할 수 있다.
같은 사설 클라우드 네트워크
두 서버를 같은 VPC 또는 사설 서브넷에 배치한다.
Private Subnet ├── Spring Boot └── FastAPI
보안 그룹으로 Spring Boot에서 AI 포트로 들어오는 요청만 허용한다.
VPN 연결
NAS와 외부 AI 서버 사이에 WireGuard 같은 VPN을 구성한다.
Synology NAS
│
│ VPN Tunnel
▼
AI Server
Spring Boot는 VPN 내부 IP로 AI 서버를 호출한다.
mTLS 적용
서버 간 TLS 인증서를 사용해 암호화와 상호 인증을 함께 적용한다.
일반 API Key보다 설정은 복잡하지만, 서버가 서로 다른 네트워크에 있을 때는 더 강한 인증 수단이 된다.
외부 공개 API
사설망이나 VPN 구성이 불가능할 때만 HTTPS 공개 API를 고려한다.
이 경우 최소한 다음 보호가 필요하다.
- HTTPS
- API Key 또는 요청 서명
- IP 허용 목록
- Rate Limit
- 요청 크기 제한
- 접근 로그
- 키 순환
- 가능하다면 mTLS
현재처럼 호스트 포트를 열지 않는 구조를 그대로 유지할 수 있다면, 외부 공개 API는 마지막 선택지에 가깝다.
18. 서버를 나눈 뒤에야 보이기 시작한 것들
처음에는 Spring Boot와 FastAPI를 연결하는 일이 단순하다고 생각했다.
AI 서버 포트를 열고 주소와 API Key를 전달하면 기능은 동작한다.
하지만 실제 배포를 준비하면서 질문이 달라졌다.
- 이 서버를 정말 외부에 공개해야 하는가
- 내부 통신도 인증해야 하는가
- 독립 배포와 공용 네트워크를 함께 유지할 수 있는가
- 로컬과 운영의 HTTP 경로가 같은가
- AI 요청 시간을 어느 계층까지 기다릴 것인가
- 장애가 발생했을 때 어느 서버가 어떤 응답을 반환해야 하는가
Docker 사용자 정의 브리지 네트워크를 사용한 것 자체는 복잡한 기술이 아니었다.
더 어려웠던 부분은 배포 방식, 담당자 역할, 보안 범위와 장애 처리 기준을 현재 팀에 맞게 정하는 일이었다.
최종적으로 선택한 구조는 다음과 같다.
flowchart LR
U[Flutter]
R[Synology Reverse Proxy]
B[Spring Boot]
A[FastAPI]
D1[(Service DB)]
D2[(AI DB)]
N[Internal Nginx]
L[LLM Server]
U --> R
R --> B
B --> D1
B -->|Docker DNS + API Key| A
A --> D2
A --> N
N --> L
Spring Boot와 FastAPI는 각자의 저장소에서 독립적으로 배포한다.
두 컨테이너는 eodaego-internal 네트워크에서 통신한다.
외부 사용자는 Spring Boot를 통해서만 AI 기능을 사용한다.
AI 서버는 운영 환경에서 호스트 포트를 공개하지 않는다.
이 구조가 완전한 보안 경계를 만드는 것은 아니다. 같은 Docker 호스트를 신뢰하고 있으며, 정적 공유 키와 동기 요청 방식 같은 한계도 남아 있다.
그래도 현재 규모에서 불필요한 외부 노출을 줄이면서 독립 배포를 유지할 수 있었다.
무엇보다 로컬에서 기능이 동작하는 것과 운영 환경에서 안정적으로 통신하는 것은 다른 문제라는 점을 배웠다.
Docker 네트워크를 연결한 뒤 발생한 HTTP/2 문제는 코드만 봐서는 발견하기 어려웠다. 실제 요청 경로와 프로토콜을 확인하면서 해결할 수 있었다.
서버를 분리하는 일은 프로젝트를 두 개로 나누는 것으로 끝나지 않았다. 그 사이의 연결을 어떤 조건에서 신뢰할 것인지까지 정해야 했다.
첫 댓글을 남겨보세요.