기능 하나 배포했는데 로그인이 같이 죽었다 - 위성 변화탐지 플랫폼을 MSA로 쪼갠 이유
한 달에 10번, 재배포할 때마다 관련 없는 서비스까지 같이 내려가던 모놀리식을 9개 서비스로 쪼갠 과정. 그 과정에서 작업이 통째로 사라지던 큐 문제를 ack/nack과 단계별 큐 분리로 풀었다.
문제: 영상처리 로직을 고쳤는데 로그인이 죽는다
위성 변화탐지 플랫폼은 원래 하나의 애플리케이션이었다. 인증, 영상 수집, 전처리, AI 추론, 결과 서빙이 전부 같은 프로세스 안에 있었다. 문제는 배포 단위도 하나라는 것이다.
영상 전처리 로직 하나를 고쳐도 전체를 재시작해야 했고, 재시작 도중에는 로그인도 안 됐다. 기능적으로 전혀 관련 없는 두 영역이 배포 시점에는 강하게 묶여 있었던 셈이다. 월 10건 정도의 재배포가 있었는데, 매번 "이번엔 어디까지 영향이 갈까"를 걱정해야 했다.
여기에 더해 작업 처리 쪽에는 별도의 문제가 있었다. 위성 영상 처리 작업은 스케줄러가 노드에 dispatch하는 방식이었는데, 처리 도중 노드가 재시작되면 완료 콜백이 영영 오지 않았다. 작업은 DB에 RUNNING 상태로 고착되고, 이런 작업이 쌓이면 백로그가 되고, 결국 사람이 DB를 직접 열어 상태를 고쳐야 했다. 배포 문제와 별개로, "작업이 조용히 사라질 수 있다"는 것 자체가 신뢰할 수 없는 구조였다.
두 문제는 원인이 다르지만 해법의 방향은 같았다. 강하게 묶인 것을 풀어내는 것. 서비스 배포 단위를 쪼개고, 작업 처리를 요청-응답이 아니라 확인 가능한 비동기 흐름으로 바꾸는 것.
시도한 것: 무엇을 쪼개고, 무엇으로 이을 것인가
서비스 경계를 어디에 그을 것인가
전체를 한 번에 쪼개는 대신 책임 단위로 나눴다. 최종적으로 API 서버(CRUD·인증·작업 관리), 영상 서빙(WMS/WMTS 타일), 수집, 카탈로깅(전처리·DB 적재), AI 추론, 후처리까지 9개 서비스로 갈라졌다.
| 역할 | 기술 | 담당 |
|---|---|---|
| API 서버 | FastAPI | CRUD·인증·작업 관리 |
| 영상 서빙 | Go | WMS/WMTS·타일 캐싱 |
| 수집 | Python | 위성 영상 수집·스케줄링 |
| 카탈로깅 | Python | 전처리·DB 카탈로깅 |
| AI 추론 | Python + ONNX | 변화탐지 추론 (큐 컨슈머) |
| 후처리 | Python | 결과 저장·타일링 |
기존에 스크립트/셸로 돌아가던 수집·전처리·추론 워커도 전부 FastAPI로 표준화했다. 이유는 단순하다 - 헬스체크(/healthz)와 메트릭(/metrics) 엔드포인트를 통일해야 게이트웨이 라우팅과 모니터링에 태울 수 있기 때문이다. 서비스마다 살아있는지 확인하는 방법이 다르면 운영 자동화 자체가 성립하지 않는다.
db-api 쪽 내부 구조도 함께 손봤다. 처음엔 FastAPI 튜토리얼 구조 그대로 models.py/crud.py/router.py에 로직이 계속 쌓이면서 관심사가 섞였다. 엔티티는 models, 비즈니스 로직은 services, HTTP 처리는 api 라우터로 계층을 분리했고, 이후 즐겨찾기·페이지네이션 같은 기능이 이 구조를 그대로 따라 확장됐다.
처리량보다 확실한 전달이 필요했다
작업 유실 문제를 풀 때 큐 도입 자체는 자연스러운 선택이었지만, 어떤 큐를 쓸지는 트레이드오프였다. Kafka는 처리량에서 앞서지만 운영 부담이 크고, 위성 처리 작업은 대량 스트림이 아니라 개별 단위 작업이다. 초당 처리량보다 "한 건도 빠뜨리지 않는 것"이 더 중요한 요구였고, 그래서 ack/nack 기반 전달 보장이 확실한 RabbitMQ를 선택했다.
핵심은 워커가 작업을 완전히 끝낸 뒤에만 ack를 보내는 것이다.
def on_message(channel, method, properties, body):
try:
process_job(body)
channel.basic_ack(delivery_tag=method.delivery_tag)
except Exception:
headers = properties.headers or {}
death_count = _get_retry_count(headers) # x-death 헤더에서 재시도 횟수 확인
if death_count >= 3:
# 3회 실패 - 더 이상 재시도하지 않고 DLQ로 격리
channel.basic_nack(delivery_tag=method.delivery_tag, requeue=False)
else:
channel.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
워커가 처리 도중 죽으면 ack가 오지 않으므로 RabbitMQ가 메시지를 자동으로 다시 큐에 넣는다. 3회 연속 실패한 메시지는 x-dead-letter-exchange 설정에 따라 DLQ로 격리되어, 운영자가 원인을 확인하고 수동으로 재발행할 수 있다. 예전처럼 DB에 고착된 RUNNING 행을 손으로 고치는 대신, "실패가 감지되면 자동으로 재시도하고, 반복 실패만 사람이 본다"는 흐름으로 바뀐 것이다.
큐를 하나로 둘까, 단계마다 나눌까
워커를 늘리는 김에 큐를 하나로 두고 컨슈머만 늘리는 방법도 검토했다. 구조는 단순해지지만, 파이프라인 단계별로 부하 특성이 완전히 다르다는 게 문제였다. AI 추론은 무겁고, 후처리는 가볍다. 큐가 하나면 무거운 단계 하나 때문에 전체가 밀리거나, 가벼운 단계까지 필요 이상으로 워커를 늘려야 한다.
그래서 단계별로 큐를 나눴다.
수집 큐 → [수집 워커] → 카탈로깅 큐 → [카탈로깅 워커]
→ 추론 큐 → [추론 워커] → 후처리 큐 → [후처리 워커]
각 단계는 자기 큐만 보고, 끝나면 다음 큐로 넘긴다. 단계 간 직접 호출이 없으므로 큐 자체가 버퍼 역할을 하고, 느린 단계 하나가 뒤 단계로 지연을 그대로 전파하지 않는다. 부수 효과도 있었다 - 작업 상태가 큐 안에 있으니 상태 확인을 위해 DB를 폴링할 필요가 없어졌다. 단일 큐 방식이었다면 지금도 "이 작업 어디까지 갔지"를 확인하려고 DB를 계속 찔러봐야 했을 것이다.
최종 구조
[웹 뷰어] 전/후 영상 선택 → 분석 요청
│
▼
[API 서버] 작업 생성 → 큐에 발행
│
▼
[RabbitMQ] 단계별 큐 (수집→전처리→추론→후처리) · ack/nack · 3회 실패 시 DLQ
│
▼
[변화탐지 AI] ONNX 추론 (큐 컨슈머, 워커 1→15개 수평 확장)
│
▼
[후처리] 결과 저장·타일링
│
▼
[웹 뷰어] 지도 위에 변화 영역 시각화
배포 경로도 Envoy Gateway가 경로 단위로 라우팅하면서 서비스별로 독립적으로 배포할 수 있게 됐다. 영상 전처리 로직을 고쳐도 인증 서비스는 그대로 살아있다.
결과
| 지표 | Before | After |
|---|---|---|
| 월 재배포 건수 | 10건 | 1건 |
| 배포 소요 시간 | 4분 | 30초 |
| 기존 모듈 수정 범위 | - | 1/5로 감소 |
| 신규 커스텀 프로젝트 리드타임 | 6개월 견적 | 1개월 내 테스트·배포 |
| 작업(메시지) 유실 | 발생 (수동 DB 수정) | 0건 |
| 추론 워커 수 | 1개 | 15개 (수평 확장) |
가장 체감이 큰 변화는 "재배포"의 의미 자체가 달라졌다는 것이다. 예전엔 재배포가 곧 전체 다운타임이었지만, 지금은 바뀐 서비스 하나만 30초 안에 교체된다. 큐 쪽은 숫자보다 성격이 바뀐 게 크다 - 실패가 발생해도 사람이 개입하는 지점이 "감지"가 아니라 "3회 실패 후 검토"로 밀렸다.
이 큐 기반 단계 분리 패턴은 다른 대용량 AI 추론 인프라에도 그대로 재사용할 수 있는 구조라고 보고 있다 - 단계별 부하가 다르고, 유실 없이 처리해야 하는 작업이라면 동일하게 적용된다.
남은 문제
변화탐지 모델 자체는 아직 확정이 아니다. ECT, MambaCD, MINIMA 세 후보를 놓고 정확도를 비교하는 중이고, 오탐(그림자·조도 차이로 인한 거짓 변화 탐지)을 줄이기 위한 전/후처리(그림자 제거, 색상 정규화, 면적 기반 필터링)는 붙여둔 상태다. 아키텍처가 안정된 지금은 모델 자체의 정확도 - IoU나 정밀도/재현율 같은 정량 지표 - 를 어떻게 검증하고 비교할지가 다음 과제다.