NIPA 위성 변화탐지 AI 플랫폼 - MSA 설계
아키텍처 - MSA
FastAPI
Go
Python
Python + ONNX
변화탐지 AI 호출 흐름
- 1웹 뷰어전·후 영상 선택 · 분석 신청→
- 2API 서버작업 생성 · 큐 발행→
- 3RabbitMQack/nack · DLQ→
- 4변화탐지 AIONNX 추론 (consumer)→
- 5후처리결과 저장 · 타일화→
- 6웹 뷰어지도 위 변화 가시화
CI/CD 파이프라인
- 1Bitbucket코드 커밋 & 푸시→
- 2Argo Eventswebhook → NATS → Sensor 트리거→
- 3Argo Workflows빌드 · 테스트 · 이미지 생성 · OPA 게이트→
- 4Pulp이미지 · 패키지 저장소→
- 5ArgoCDGit 선언 상태 ↔ 클러스터 동기화→
- 6클러스터 배포새 버전 롤아웃
NIPA(정보통신산업진흥원) 지원 사업으로, 서로 다른 두 시점의 위성 영상을 비교해 지표 변화를 자동으로 탐지하는 AI 플랫폼입니다. 9개 서비스로 구성된 MSA 구조의 설계·구현을 주도했고, 클러스터 네트워킹·시크릿·배포 등 인프라 고도화는 팀원과 설계 방향을 협의하며 함께 확장하고 있습니다.
이전 시스템은 기능 하나만 고쳐도 전체를 재배포해야 하는 모놀리식 구조였습니다. 서비스를 FastAPI 기반 독립 배포 단위로 쪼개고, 수집 → 전처리 → 추론 → 후처리로 이어지는 처리 파이프라인은 RabbitMQ ack/nack 비동기 큐로 처음 도입해 병목 단계만 골라 확장할 수 있게 만들었습니다.
반면 프론트엔드는 굳이 서비스를 나눌 이유가 없어, 하나의 Next.js 15 코드베이스를 K8s 환경변수(MAP_TYPE: EARTH | MOON)만 바꿔 지구 변화탐지·달지도 두 배포판으로 나누는 방식을 택했습니다. 이 구조 위에서 지역별 변화 통계, 달지도 (아폴로 탐사 경로·크레이터) 같은 도메인 특화 뷰어 기능을 구현했습니다.
인프라
CI/CD - Jenkins/Nexus → Argo Events·Workflows·ArgoCD·Pulp 전환웹훅 트리거부터 클러스터 동기화까지 GitOps로 일원화
CI/CD
웹훅 트리거부터 클러스터 동기화까지 GitOps로 일원화
기존에는 Jenkins가 빌드·배포를 함께 수행하고 아티팩트는 Nexus에 저장했습니다. 파이프라인 정의가 Jenkins UI·스크립트에 흩어져 있어 변경 이력 추적이 어렵고, 배포 상태가 클러스터 실제 상태와 어긋나도 감지할 방법이 없었습니다.
Bitbucket 푸시를 Argo Events(webhook → NATS → Sensor)가 감지해Argo Workflows를 트리거하고, 빌드·테스트·이미지 생성을 수행합니다. 생성된 이미지·패키지는 Pulp에 저장하고,ArgoCD가 Git에 선언된 상태와 클러스터 실제 상태를 지속적으로 동기화합니다. 배포 전에는 Workflows에 OPA 정책 게이트를 통합해 정책 위반 이미지가 클러스터에 반영되기 전에 차단합니다.
| 구분 | 이전 (Jenkins·Nexus) | 이후 (Argo·Pulp) |
|---|---|---|
| 빌드·배포 트리거 | Jenkins 스케줄/수동 트리거 | Bitbucket 웹훅 → Argo Events |
| 빌드 정의 | Jenkinsfile·UI 설정 혼재 | Argo Workflows 선언적 정의 |
| 아티팩트 저장소 | Nexus | Pulp |
| 배포 상태 | 클러스터 상태와 어긋나도 미감지 | ArgoCD가 Git↔클러스터 상태 지속 동기화·자동 교정 |
| 정책 검증 | 배포 후 수동 확인 | Workflows 단계에 OPA 게이트 통합 |
Argo Workflows는 빌드까지만 책임지고, ArgoCD는 배포를 직접 실행하는 대신 Git 상태를 클러스터가 계속 따라가게 만드는 역할이라 - 이 전환 과정과 OPA 게이트 도입 배경은 별도 글로 자세히 정리했습니다.
클러스터 네트워킹·보안 - Cilium · kube-vip · OpenBaokube-proxy 대체, control-plane HA, 시크릿 중앙 관리
클러스터 인프라
kube-proxy 대체, control-plane HA, 시크릿 중앙 관리
Cilium(eBPF)을 CNI로 도입해 kube-proxy를 대체하고 L3~L7 네트워크 정책을 하나의 계층으로 일원화했습니다. Hubble 기반 egress 관측으로 비인가 통신을 탐지할 수 있게 됐습니다. control-plane은 3노드 HA로 구성하고kube-vip로 고정 VIP를 두어, 특정 노드가 죽어도 API 서버 진입점이 바뀌지 않도록 했습니다.
시크릿은 OpenBao(Vault 계열)에 중앙 저장하고External Secrets Operator가 이를 각 네임스페이스의 Kubernetes Secret으로 동기화·주입합니다. 서비스 코드가 시크릿 저장소를 직접 호출하지 않아도 되고, 시크릿 로테이션 시 배포 재시작만으로 반영됩니다.
네트워킹·HA 영역은 서비스 증가에 따른 처리 오버헤드와 단일 장애점이라는 문제 자체는 같이 겪었지만, 구체적인 설계 방향(Cilium 도입 범위, control-plane HA 구성)은 팀원과 협의하며 정했습니다.
관측 스택 - OTel · Prometheus · Grafana · Tempo · OpenSearchCI/CD·게이트웨이·앱·DB 전 구간 계측 - 장애 원인분석 1~2시간 → 30분 이내
observability
CI/CD·게이트웨이·앱·DB 전 구간 계측 - 장애 원인분석 1~2시간 → 30분 이내
OTel(OpenTelemetry)로 계측 표준을 통일해 메트릭·트레이스·로그를 CI/CD 파이프라인부터 게이트웨이, 앱 Pod, DB까지 전 구간에서 수집합니다. 수집된 데이터는 Prometheus(메트릭)·Tempo(분산 트레이싱)·OpenSearch(로그)로 각각 적재되고, Grafana에서 하나의 대시보드로 조회합니다.
서비스가 9개로 분리되며 장애 시 어느 단계에서 문제가 발생했는지 파악하는 데 걸리는 시간이 길어질 위험이 있었는데, 요청 하나를 트레이스 ID로 전 구간(Envoy Gateway → 앱 Pod → DB)에서 추적할 수 있게 되면서 장애 원인분석 시간을 1~2시간에서 30분 이내로 단축했습니다. 요청이 죽은 구간이나 지연 구간을 로그를 수동으로 뒤지지 않고 대시보드에서 바로 특정할 수 있게 됐습니다.
단계별 큐 분리와 워커 수평 확장단일 큐로는 병목 단계만 골라 확장 불가 → 처리 워커 1개 → 15개 수평 확장
처리 파이프라인
단일 큐로는 병목 단계만 골라 확장 불가 → 처리 워커 1개 → 15개 수평 확장
큐를 하나만 두고 워커를 늘리는 방법도 있었지만, 파이프라인 단계마다 부하 특성이 달랐습니다. AI 추론은 무겁고 후처리는 가벼운데, 단일 큐로는 병목 구간만 골라 확장할 수 없었습니다.
그래서 수집 → 전처리 → 추론 → 후처리 단계별로 큐를 분리했습니다. 각 단계 워커는 자기 큐에서 메시지를 consume하고, 끝나면 결과를 다음 큐에 publish합니다. 단계 간 직접 통신 없이 큐로만 데이터를 넘기는 구조라, 한 단계가 일시적으로 느려져도 큐가 버퍼 역할을 해서 앞뒤 단계가 영향을 받지 않습니다.
| 단일 큐 | 단계별 큐 분리 | |
|---|---|---|
| 확장 단위 | 전체 워커 일괄 확장 | 병목 단계 워커만 선택 확장 |
| 결합도 | 한 단계 지연이 전체 파이프라인에 전파 | 큐가 버퍼 역할 - 앞뒤 단계 영향 차단 |
| DB 의존 | 작업 상태 조회를 위해 DB 폴링 필요 | 작업 상태가 큐에 있어 폴링 불필요 |
추론 단계가 병목이면 추론 워커만 늘리고, 후처리가 가벼우면 워커를 줄이는 식으로 단계마다 자원을 따로 조절할 수 있게 되면서, 처리 워커 컨테이너를1개에서 15개로 수평 확장했습니다. 요청을 큐로 받아 워커에 분산하는 이 패턴은 더 큰 트래픽의 AI 추론 인프라에도 그대로 적용됩니다.
백엔드
Envoy Gateway + Keycloak OIDC - 게이트웨이 레벨 인증각 서비스 인증 코드 제거 · SecurityPolicy로 HTTPRoute 단위 적용
백엔드 공통
각 서비스 인증 코드 제거 · SecurityPolicy로 HTTPRoute 단위 적용
초기에는 각 FastAPI 서비스가 JWT 검증 로직을 직접 처리했습니다. 서비스가 늘어날수록 인증 코드가 중복되고, 인증 정책 변경 시 전 서비스를 동시에 수정해야 했습니다.
Keycloak을 OIDC Provider로 도입하고Envoy Gateway SecurityPolicy를 HTTPRoute 단위로 적용해 인증을 게이트웨이 레벨로 끌어올렸습니다. 인증된 요청에만 forwardAccessToken: true로 Access Token을 헤더에 실어 백엔드로 전달하고, 백엔드 서비스는 토큰 검증 코드 없이 헤더의 사용자 정보만 사용합니다.
| 구분 | 이전 (서비스 내 JWT) | 이후 (게이트웨이 OIDC) |
|---|---|---|
| 인증 위치 | 각 FastAPI 서비스 | Envoy Gateway SecurityPolicy |
| 정책 변경 | 전 서비스 동시 수정 | SecurityPolicy 1개만 수정 |
| 신규 서비스 | JWT 미들웨어 직접 추가 | HTTPRoute에 Policy 연결만 |
| SSO | 직접 구현 불가 | Keycloak 세션 공유 기본 제공 |
Keycloak 클라이언트를 서비스별로 분리해 권한 범위를 세밀하게 제어하고, SealedSecret으로 클라이언트 시크릿을 암호화해 Git에 커밋할 수 있게 했습니다.
RabbitMQ ack/nack · DLQ 비동기 파이프라인Salt 폴링 작업 고착 → 작업 유실 0건
백엔드 공통
Salt 폴링 작업 고착 → 작업 유실 0건
메시지 큐 도입을 앞두고 Kafka와 RabbitMQ 중 하나를 선택해야 했습니다. Kafka는 처리량이 높지만 운영 오버헤드가 큽니다. 위성 처리 작업은 단위가 명확하고 ack/nack 기반의 확실한 전달 보장이 더 중요해 RabbitMQ를 선택했습니다.
이전 프로젝트에서 Salt 스케줄러로 작업을 디스패치했을 때 노드가 재시작되면 완료 콜백이 호출되지 않아 작업이 RUNNING 상태로 고착됐습니다. 타임아웃 복구 전까지 후속 작업이 쌓이고 수동 DB 수정이 반복됐습니다.
변화탐지 작업은 수 분~수십 분이 소요됩니다. 처음부터 ack/nack 기반 큐로 설계해 이 문제를 원천 차단했습니다. worker는 처리를 완전히 마친 뒤에만 ack를 보내고, 처리 중 노드가 죽으면 RabbitMQ가 자동으로 메시지를 재투입합니다. 3회 초과 실패 시 DLQ로 격리해 운영자가 원인을 파악한 뒤 수동 republish합니다.
# DLX 설정 - 재시도 3회 초과 시 DLQ 격리
channel.exchange_declare(exchange='gprocessor.dlx', exchange_type='direct', durable=True)
channel.queue_declare(queue='gprocessor.dlq', durable=True)
channel.queue_declare(
queue='gprocessor', durable=True,
arguments={'x-dead-letter-exchange': 'gprocessor.dlx'}
)
def callback(ch, method, properties, body):
death_count = len((properties.headers or {}).get('x-death', []))
try:
process(body)
ch.basic_ack(delivery_tag=method.delivery_tag) # 성공: 큐에서 제거
except Exception:
if death_count >= 3:
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False) # DLQ 격리
else:
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True) # 재시도모놀리식 → MSA · FastAPI 전환재배포 월 10건 → 1건 · 배포 속도 4분 → 30초
전체 시스템
재배포 월 10건 → 1건 · 배포 속도 4분 → 30초
기존 구조는 기능 하나를 배포하려면 전체 서비스를 재시작해야 했습니다. 영상 처리 로직 수정이 인증 서비스 다운타임으로 이어졌습니다. 전 서비스를 독립 서비스로 분리하고Envoy Gateway를 게이트웨이로 두어 경로별 라우팅과 OIDC 인증을 처리했습니다.
ETL·AI 추론 워커들은 Python 스크립트·셸로 운영되던 것을 전부 FastAPI로 전환했습니다. 표준화된 인터페이스 덕분에 Envoy Gateway 라우팅에 그대로 편입할 수 있었고, 헬스체크·메트릭 엔드포인트도 일관되게 붙일 수 있었습니다.
| 역할 | 기술 | 담당 | API |
|---|---|---|---|
| API 서버 | FastAPI | CRUD · 인증 · 작업 관리 | 28개 |
| 영상 서빙 | Go | 위성 영상 서빙 · WMS/WMTS · 타일 캐싱 | 8개 |
| 수집 | Python | 위성 영상 수집 · 스케줄링 | 4개 |
| 카탈로깅 | Python | 전처리 · DB 카탈로깅 | 3개 |
| AI 추론 | Python + ONNX | 변화탐지 AI 추론 (RabbitMQ consumer) | 3개 |
| 후처리 | Python | 변화탐지 후처리 · 결과 저장 | 3개 |
| 메시지 큐 | RabbitMQ | 비동기 메시지 큐 StatefulSet | - |
| 데이터베이스 | PostgreSQL + PostGIS | 공간 데이터 저장소 | - |
| 게이트웨이 | Envoy Gateway · Keycloak | OIDC 인증 · 경로 라우팅 · TLS · 업로드 제한 | - |
| 웹 뷰어 | Next.js 15 | CesiumJS 웹 UI · 달지도 · 지역 통계 | - |
서비스 분리로 배포 속도 4분 → 30초, 월 재배포 횟수 10건 → 1건으로 줄었습니다.
db-api 계층화 · 위성영상 메타 조회 성능 개선models·crud·router 단일 파일 → 3계층 분리 · 컬럼 프로젝션·페이지네이션 도입
db-api
models·crud·router 단일 파일 → 3계층 분리 · 컬럼 프로젝션·페이지네이션 도입
초기 db-api는 FastAPI 튜토리얼 구조를 그대로 사용해 테이블이 늘어날 때마다 models.py·crud.py·router.py 각 1개 파일에 로직이 계속 쌓이는 구조였습니다. 리소스별 관심사가 한 파일에 뒤섞여 있어 변경 영향 범위를 파악하기 어려웠습니다.
테이블(엔티티) 단위로 models를, CRUD 로직을 services의 서비스 클래스로, HTTP 라우팅을 api의 라우터 모듈로 분리했습니다. 이후 즐겨찾기· 페이지네이션 같은 신규 리소스는 이 3계층 패턴을 그대로 따라 확장됐습니다.
위성영상 메타 조회 성능 개선
메타데이터 조회 API는 ORM 풀 엔티티를 하이드레이션한 뒤 재직렬화하는 구조였고, 이미 4326 SRID로 저장된 컬럼에 불필요한 ST_SetSRID를 매번 재적용하고 있었습니다. 페이지네이션도 없어 대량 결과를 한 번에 반환했습니다.
| 항목 | 이전 | 이후 |
|---|---|---|
| 조회 방식 | ORM 풀 엔티티 하이드레이션·재직렬화 | 필요한 컬럼만 select하는 컬럼 프로젝션 |
| SRID 변환 | 이미 4326인 컬럼에 매번 재적용 | 불필요한 ST_SetSRID 제거 |
| 필터 | 필드 검증 없음 | 모델에 실제 존재하는 컬럼만 허용하는 화이트리스트 |
| 응답 크기 | 페이지네이션 없이 전체 반환 | page/limit 페이지네이션, 기본 1000건 |
반복 파싱되던 datetime 값도 행마다 재계산하지 않도록 루프 밖에서 한 번만 계산하도록 바꾸고, fetch·serialize 단계를 분리 계측할 수 있는 타이밍 로그를 추가했습니다.
AI 탐지 품질 개선조명·색상 차이와 그림자가 변화로 오탐 → 전처리 색상 정규화 · 후처리 필터링
변화탐지 AI
조명·색상 차이와 그림자가 변화로 오탐 → 전처리 색상 정규화 · 후처리 필터링
완성된 AI 모듈을 그대로 연동하던 기존 방식에서는, 전후 영상의 조명·색상 차이와 그림자가 변화로 오탐되는 문제가 누적됐습니다. 모델 출력을 후처리 없이 그대로 사용해 노이즈가 결과에 그대로 반영됐습니다.
모델 출력을 직접 분석해 전처리에 그림자 제거와 색상 정규화를 추가하고, 후처리에 면적 기반 필터링과 모폴로지 연산을 적용해 픽셀 단위 노이즈를 제거했습니다.
| 단계 | 적용 기법 | 목적 |
|---|---|---|
| 전처리 | 그림자 제거 · 색상 정규화 | 조명·색상 차이로 인한 오탐 방지 |
| 후처리 | 면적 기반 필터링 | 작은 노이즈 폴리곤 제거 |
| 후처리 | 모폴로지 연산 | 변화 폴리곤 경계 정제 |
프론트엔드
Next.js 15 FSD · 멀티 배포 뷰어 · 달지도 · 지역 통계Thymeleaf → FSD 마이그레이션 · 동일 이미지를 K8s env로 지구/달 모드 분리 배포
웹 뷰어
Thymeleaf → FSD 마이그레이션 · 동일 이미지를 K8s env로 지구/달 모드 분리 배포
기존 Thymeleaf 기반 SSR은 페이지·컴포넌트 경계가 불명확해 수정 범위 예측이 어렵고 타입 안전성이 없었습니다. Next.js 15를 처음 도입하면서 FSD(Feature-Sliced Design)를 함께 적용해 지구 변화탐지·지역통계·달지도를 독립 feature slice로 분리했습니다.
피처를 분리해두면 동일한 Docker 이미지를 K8s pod의 환경변수만 바꿔 여러 배포판으로 나눌 수 있습니다.MAP_TYPE: EARTH|MOON 하나로 뷰어 모드가 결정되고, 백엔드 URL은 BACKEND_API_URL로만 주입돼 클라이언트에 노출되지 않습니다.dynamic import로 달지도 청크는 MOON 모드 진입 시점에만 로드됩니다.
# viewer-nipa.pod.yaml (지구 변화탐지) / viewer-moon.pod.yaml (달지도)
# 동일 이미지, env만 다름
env:
- name: MAP_TYPE
value: "EARTH" # ← MOON으로 바꾸면 달지도 모드
- name: VIEWER_TITLE
value: "국토 변화 정보 서비스"
- name: BACKEND_API_URL # 서버사이드 프록시 - 클라이언트에 백엔드 URL 미노출
value: "http://[내부IP]:16103"
- name: UI_MAP_INDEX_VISIBLE
value: "true" # MOON 배포에서는 false - 변화 인덱스 위젯 숨김// Client - MAP_TYPE env에 따라 피처 청크를 필요 시점에 동적 로드
const EarthViewer = dynamic(() => import("@/features/earth-viewer"));
const MoonViewer = dynamic(() => import("@/features/moon-viewer"));
export function ViewerWidget({ mapType }: { mapType: string }) {
return mapType === "MOON"
? <MoonViewer /> // 달지도 청크 - MOON 배포에서만 로드
: <EarthViewer />; // 지구 변화탐지 청크
}지구 모드 - PostGIS 지역별 변화 통계
변화 폴리곤과 행정구역을 공간 조인해 시·도 / 시·군·구 / 사용자 AOI 단위 변화 면적·변화율을 집계합니다. 지역 클릭 시 시계열 차트, 임계값 초과 지역은 히트맵으로 강조됩니다.
달 모드 - 아폴로 경로 · 크레이터
| 레이어 | 데이터 출처 | 표현 방식 |
|---|---|---|
| 달 기본 지형 | JAXA SELENE / NASA LRO 타일 | CesiumJS ImageryLayer |
| 아폴로 탐사 경로 | NASA 아폴로 11 ~ 17호 EVA 좌표 | Polyline (임무별 색상) |
| 크레이터 | IAU 크레이터 카탈로그 GeoJSON | Point · 직경 비례 크기 스케일 |
| 착륙 지점 | 아폴로 착륙 좌표 | Billboard (임무 아이콘) |
CesiumJS는 레이어 재정렬을 지원하지 않아 내부 imageryLayers를 직접 조작했고, 토글 시 삭제 대신 layer.show = false로 WebGL 텍스처를 보존했습니다. MVT·MBTiles·ImageLayer·달지도 등 이종 레이어를 단일 인터페이스로 추상화해 신규 레이어 타입 추가 시 기존 코드 수정 없이 확장 가능했습니다.