← 프로젝트

BARO — 자율주행 택시 호출 · 관제 플랫폼

  • 2026.04 – 06
  • 5인 팀 · BE / DevOps
  • AWS ECS · Terraform
  • MQTT · Kafka
  • OpenStack · K3s

개요

자율주행 택시가 실제로 도로를 달리려면, 차량 수백 대의 위치를 끊김 없이 받아 가장 가까운 빈 차를 배차하고, 운행이 끝난 차는 다시 수요가 많은 곳으로 보내야 해요. BARO는 이 과정을 호출 → 배차 → 이동 → 재배치까지 한 흐름으로 묶은 무인 택시 호출 · 관제 플랫폼이에요.

실제 차량 대신 Python 시뮬레이터가 서울 택시승차대 254곳에서 출발하는 차량 1,500대를 띄워요. 서비스는 AWS(ECS Fargate와 자체 운영 Kafka · Mosquitto)에서, 분석 · 관측 · 장기 저장은 온프레미스 OpenStack 위의 K3s에서 돌려요. 두 환경은 Site-to-Site VPN으로 연결했어요.

현대오토에버 모빌리티 SW스쿨 3기 클라우드 최종 프로젝트로, 5명이 2026년 4월부터 6월까지 만들었어요. 저는 백엔드와 DevOps를 맡았어요.

아키텍처

스크롤을 내리면 왼쪽 설명을 따라 오른쪽 다이어그램이 움직여요. 노드에 마우스를 올리면 사양이 보여요.

한눈에 보기

BARO는 승객 앱 · 차량, 서비스가 도는 AWS, 분석 · 관측을 맡은 온프레미스(OpenStack K3s)로 나뉘어요. AWS와 온프레미스는 Site-to-Site VPN으로 이어져 있어요.

ECS Fargate ×7EC2: Kafka · MosquittoIPsec VPN

1차량 위치가 흐르는 길

차량 1500대가 3초마다 위치를 MQTT(QoS0)로 보내요. control은 공유 구독으로 받아서, 인스턴스를 늘려도 한 메시지는 한 곳에서만 처리돼요.

vehicles/{id}/telemetry$share/control-service

관제 화면에는 SSE로 곧장 뿌리고, 나머지는 Kafka vehicle-data-topic으로 보내요. key=carId라 같은 차량의 위치는 순서가 보장돼요.

4 partitionskey = carId

dispatch는 10초 넘은 메시지를 버리고 빈 차만 Valkey GEO에 올려요. 온프레미스 consumer는 VPN 너머에서 같은 토픽을 받아 TimescaleDB에 쌓아요.

GEO dispatch:cars:idle:geohypertable vehicle_data

2호출 한 건이 배차되기까지

호출은 ALB → gateway를 지나요. JWT는 user가 발급하고 gateway 한 곳에서만 검증해요. dispatch는 Kakao로 요금 · 경로를 먼저 계산해 보여줘요(PRE배차).

X-Authenticated-User-Idquote TTL 10 min

확정하면 15km 안 후보 10대를 찾고, 300초 넘게 소식 없는 차는 빼고, SETNX 선점 락(30초)으로 두 승객이 같은 차를 잡지 못하게 해요. 배차는 트랜잭션으로 저장해요.

GEOSEARCH 15kmSETNX TTL 30s

명령은 MQTT(QoS1)로 차량에 가고 ACK가 돌아와요. 10초 안에 ACK가 없으면 다음 차량으로 자동 재배차해요.

vehicles/{id}/commandsACK timeout 10s

승객 앱엔 SSE로 차량 위치와 도착 상태(픽업 → 목적지)를 실시간으로 밀어줘요.

SSE vehicle-location

3운행이 끝나면 — 재배치

목적지 도착 이벤트를 받은 control이 relocation에 비동기로 알려요. 배차 흐름과 엮이지 않도록 분리한 구조예요.

ARRIVED(to_dest)202 Accepted

PostGIS로 주변 승차대에 점수(0.7 · 수요 − 0.3 · 거리)를 매겨 가장 좋은 곳으로 RELOCATE. 이동 중에도 배차 가능한 상태라 호출이 오면 바로 받아요.

ST_DWithin 10→30kmstatus = relocating

4밤사이 — 수요 학습

매일 02:00, Airflow가 VPN 너머 internal ALB로 전날 배차 데이터를 가져와 승차대 × 요일 × 시간대 수요를 집계해요.

gzip CSV exportX-Internal-Api-Key

XGBoost로 학습한 승차대 가중치를 relocation에 보내면, 다음 재배치부터 바로 반영돼요.

weights 0–1

5지켜보는 눈 — 관측

온프레미스 Prometheus가 VPN 너머 서비스 · Kafka · EC2 지표를 모으고, 알림은 AWS · 온프레미스 · 서비스 3개 Slack 채널로 나눠 보내요.

JMX :9404CloudWatch exporterAlertmanager → Slack
한눈에 보기1 / 13
클라이언트 · 차량AWS · ap-northeast-2VPC 10.20.0.0/16온프레미스 · K3sSITE-TO-SITE VPN · IPSEC승객 모바일 웹React · SSE 수신관제 화면baro-admin · SSE차량 ×1500asyncio 시뮬레이터Kakao Mobility외부 경로·요금 APISlack알림 3채널 분리Public ALBHTTPS · 경로 라우팅gatewayJWT · 서킷브레이커user회원 · 토큰 발급MosquittoEC2 · MQTTdispatch배차 · 선점 락 · SSEcontrolMQTT ↔ Kafka · 관제ValkeyElastiCache · GEORDS PostgresPostGIS · 스키마 분리KafkaEC2 KRaft · 4 파티션relocation승차대 점수 · 재배치Internal ALB/internal · 메트릭PrometheusGrafana · 알림 룰Airflow매일 02:00 수요 학습TimescaleDB시계열 hypertablekafka-consumerVPN 너머 소비 · 적재

직접 해보기

지도 위 차량 수백 대가 움직이고, 호출 → 배차 → 이동 → 재배치가 저절로 진행되는 데모

다음 업데이트에서 공개해요.

운영 기록

Kafka가 멈추자 관제 화면도 멈췄다

증상
관제 화면에 차량이 1대만 보이고, Mosquitto 로그에 Broken pipe가 쌓였어요.
원인
Kafka producer의 기본 대기 시간(max.block.ms 60초) 동안 MQTT 수신 스레드가 붙잡혀 브로커 큐가 넘쳤어요.
조치
max.block.ms를 500ms로, 재시도를 0으로 줄여 Kafka 장애가 MQTT 수신까지 번지지 않게 격리했어요.
결과
Kafka가 끊겨도 MQTT 수신과 관제 화면 SSE는 계속 동작해요.

control-service는 MQTT로 받은 위치를 두 곳으로 보내요. 관제 화면(SSE)과 Kafka예요. Kafka 브로커가 끊기자 producer가 메타데이터를 기다리며 send()에서 최대 60초 동안 멈췄고, 이 호출은 MQTT 수신 스레드 안에서 일어나고 있었어요. 수신이 멈춘 사이 Mosquitto 쪽 큐가 가득 차 연결이 끊겼고(Broken pipe), 관제 화면에는 마지막으로 들어온 차량 1대만 남았어요.

Kafka는 위치를 쌓아 두는 용도라 잠깐 유실돼도 괜찮지만, 관제 화면은 멈추면 안 돼요. 그래서 발행이 오래 걸리면 바로 실패하도록 바꾸고, 실패 수는 메트릭(baro_control_telemetry_kafka_publish_failed_total)으로 보이게 했어요.

배운 점 동기 호출의 기본 타임아웃은 장애를 옆 시스템으로 옮기는 통로가 될 수 있어요. 기본값을 그대로 믿지 않고 경로마다 타임아웃을 정해요.

consumer lag 2.9M, 범인은 hot path의 DB 조회

증상
dispatch의 위치 소비가 밀려 Kafka consumer lag이 2.9M까지 쌓였어요.
원인
위치 메시지마다(초당 약 333회) 해당 차량의 진행 중 배차를 DB에서 조회하고 있었어요.
조치
차량 → 배차 매핑을 메모리에 캐시해 메시지 처리 경로에서 DB 조회를 없앴어요.
결과
메시지마다 나가던 DB 쿼리가 사라지고 lag이 해소됐어요.

dispatch는 Kafka로 들어오는 모든 위치 메시지를 보고, 배차 중인 차량이면 승객 앱에 SSE로 위치를 밀어줘요. 문제는 “이 차가 배차 중인가”를 메시지마다 DB에 물어보고 있었다는 점이에요. 차량 1,500대가 몇 초마다 위치를 보내니 쿼리가 초당 수백 번 나갔고, 소비 속도가 발행 속도를 따라가지 못해 lag이 2.9M까지 불었어요.

이제 매핑은 배차가 생기거나 끝날 때만 갱신하고, 메시지를 처리하는 동안에는 메모리만 봐요.

배운 점 초당 수백 번 도는 경로에서는 쿼리 한 번도 비싸요. 핫 패스에서 I/O를 먼저 걷어내요.

차량 2,000대에서 Mosquitto가 메시지를 버렸다

증상
차량을 2,000대로 늘린 부하 테스트에서 메시지 드롭이 발생했어요.
원인
Mosquitto의 max_queued_messages 기본값(1,000)을 넘은 메시지가 버려지고 있었어요.
조치
한도를 10,000으로, 이어서 50,000으로 올렸어요. 무제한(0)은 메모리 부족(OOM) 위험 때문에 택하지 않았어요.

부하를 늘려 보며 차량 수를 2,000대까지 올렸을 때, 브로커가 구독자에게 전달하지 못한 메시지를 큐 한도에서 잘라내고 있었어요. 한도를 올리는 건 쉽지만, 무제한으로 두면 구독자가 느려질 때 브로커 메모리가 끝없이 늘어나요. t3.micro 한 대에서 도는 브로커라 상한을 50,000으로 두고, 차량 접속도 10대마다 0.05초씩 나눠 연결 폭주를 줄였어요.

배운 점 큐 한도는 "막히면 버린다"는 정책이에요. 늘릴 때도 무제한 대신 메모리로 감당할 수 있는 상한을 정해요.

설계 결정

AWS IoT Core 대신 EC2에서 Mosquitto를 직접 운영

배경
처음에는 AWS IoT Core(mTLS, X.509 인증서)로 차량과 통신했어요. 시뮬레이터 차량을 늘리자 연결 속도 제한 때문에 접속을 10대마다 1.2초씩 늦춰야 했고, 비용도 차량 수에 비례해 늘었어요.
검토한 대안
  • AWS IoT Core 유지(기존 구성)
선택 이유
브로커 설정(큐 한도 · 인증 · 세션)을 직접 조정할 수 있고, 연결 속도 제한이 없어 10대마다 0.05초 간격으로 1,000대를 약 5초 만에 붙일 수 있어요.
트레이드오프
가용성 · 패치 · 모니터링을 직접 책임져야 해요. 인증 정보는 Secrets Manager에 두고, 배포는 SSM으로 자동화해 운영 부담을 줄였어요.

Kafka를 ECS Fargate에서 EC2 + EBS(KRaft 단일 노드)로 이전

배경
처음엔 Kafka를 ECS Fargate 태스크로 띄우고 데이터를 EFS에 뒀어요. 클라이언트가 접속할 advertised listener 주소를 안정적으로 고정하기 어려웠고, 로그 저장소도 네트워크 파일시스템 위에 있었어요.
검토한 대안
  • ECS Fargate + EFS 유지(기존 구성)
  • Amazon MSK
선택 이유
고정 사설 IP와 Cloud Map 이름(kafka.baro.internal)으로 advertised listener를 고정하고, 로그는 EBS gp3에 둬요. vehicle-data-topic은 4 파티션으로 두어 dispatch 소비 동시성 4와 맞췄어요.
트레이드오프
브로커 1대(RF=1)라 브로커 장애 시 위치 스트림이 멈춰요. 위치는 1시간만 보관하는 휘발성 데이터라 비용을 우선했어요.

위치는 QoS0, 명령은 QoS1 — 그리고 공유 구독

배경
위치는 차량 수만큼 몇 초마다 쏟아지고 곧 새 값으로 바뀌어요. 반면 배차 명령 · 도착 이벤트 · ACK는 한 건만 빠져도 배차가 꼬여요. 관제 서비스도 여러 대로 늘릴 수 있어야 했어요.
검토한 대안
  • 모든 메시지를 QoS1로
  • control 인스턴스마다 전체 토픽 구독
선택 이유
위치는 QoS0로 가볍게, 명령 · 이벤트 · ACK는 QoS1로 확실하게 보내요. control은 공유 구독($share/control-service/…)과 인스턴스별 clientId를 써서 여러 대가 떠 있어도 한 메시지는 한 인스턴스만 처리해요.
트레이드오프
QoS0 위치는 연결이 끊기면 유실돼요. 차량 쪽에 최근 200건 버퍼를 두고, 재연결 때 QoS1 토픽(telemetry/buffered)으로 다시 보내 보완했어요.

실시간 전달은 WebSocket 대신 SSE

배경
승객 앱과 관제 화면은 서버가 보내는 위치 · 상태를 받기만 하면 돼요.
검토한 대안
  • WebSocket
선택 이유
단방향 push에는 SSE로 충분하고, 일반 HTTP 위에서 동작해 nginx와 Vercel 함수 프록시를 그대로 통과해요. 연결이 끊기면 클라이언트가 지수 백오프로 다시 붙어요.
트레이드오프
SSE 연결을 서버 메모리가 들고 있어서, 인스턴스를 늘리면 연결이 인스턴스마다 나뉘어요(한계 · 회고 참고).

인증은 gateway 한 곳에서, 내부 경로는 이중으로 차단

배경
서비스가 다섯 개로 나뉘면서, 서비스마다 JWT를 검증하면 검증 로직과 키 관리가 흩어져요.
검토한 대안
  • 서비스마다 JWT 검증
선택 이유
JWT는 gateway에서만 검증하고, 사용자 정보는 X-Authenticated-User-* 헤더로 넘겨요. 클라이언트가 같은 이름의 헤더를 보내면 gateway가 지워요. 서비스 간 호출은 X-Internal-Api-Key로 확인하고, 내부 경로는 ALB(403)와 gateway(404)에서 이중으로 막아요.
트레이드오프
서비스는 gateway 뒤에 있다는 전제로 헤더를 믿어요. 그래서 내부 경로가 외부에 열리지 않도록 ALB · 보안그룹 규칙을 함께 지켜야 해요.

RDS 한 대에 서비스별 스키마

배경
서비스마다 DB 인스턴스를 따로 두면 비용이 서비스 수만큼 늘어나요.
검토한 대안
  • 서비스마다 RDS 인스턴스
선택 이유
RDS PostgreSQL 한 대(db.t4g.micro)에 user · dispatch · relocation · control 스키마를 나눠, 서비스가 서로의 테이블을 직접 건드리지 않게 했어요. 스키마는 일회성 ECS 태스크(db-init)가 만들어요.
트레이드오프
DB 장애와 부하는 모든 서비스가 함께 겪어요. 트래픽이 늘면 서비스별 인스턴스로 나누는 게 다음 단계예요.

인프라 · 비용

git pushmainActionsGitHub · 경로 감지빌드 · 테스트JDK 21 · GradleECR서비스별 이미지ECS 배포CI 통과분만

Terraform으로 만든 AWS

  • envs/dev 한 환경을 Terraform으로 관리해요. VPC(2 AZ, public · private 서브넷), 공개 · 내부 ALB, ECS Fargate 서비스 7개, RDS PostgreSQL, ElastiCache(Valkey), Kafka · Mosquitto용 EC2, Cloud Map, Secrets Manager, Site-to-Site VPN까지 코드로 정의했어요.
  • 상태는 S3와 DynamoDB 락으로 보관해요. PR에서는 fmt · validate · plan, main에서는 apply → db-init → 엣지 배포(SSM)까지 GitHub Actions가 이어서 실행해요.
  • 실수로 지우지 않도록 destroy는 destroy-dev 같은 확인 문자열을 입력해야만 돌아가요.

배포 파이프라인

서버는 변경된 경로만 감지해 빌드 · 테스트하고, CI를 통과한 서비스만 ECR에 올려 ECS에 배포해요. 승객 모바일 웹은 CodeDeploy Blue/Green으로 배포하고, 실패하면 자동으로 되돌려요.

비용을 줄인 방법

  • runtime_enabled=false 한 번으로 NAT · ALB · ECS · RDS · EC2를 내리고, VPC · ECR · Secrets처럼 다시 만들기 번거로운 것만 남겨요.
  • 내리기 전에 RDS 스냅샷을 자동으로 만들고, 다음 apply 때 가장 최근 스냅샷으로 자동 복원해요.
  • Kafka EC2를 t3.medium에서 t3.small로 줄이고, bastion을 없애 SSM으로만 접속하고, 로그 보존을 7일로 줄이고, 차량별 로그를 DEBUG로 내려 CloudWatch 비용을 줄였어요.

내 기여

이 섹션은 준비 중이에요.

한계 · 회고

알고 있는 한계와, 다시 한다면 바꿀 부분이에요.

  • 인메모리 상태 때문에 수평 확장이 막혀 있어요. control의 차량 상태, dispatch의 ACK 대기 목록, SSE 연결이 모두 JVM 메모리에 있어서 인스턴스를 늘리면 상태가 갈라져요. Redis 같은 공유 저장소나 pub/sub로 옮기는 게 다음 단계예요.
  • 단일 장애점이 남아 있어요. Kafka는 브로커 1대(RF=1), NAT Gateway는 1개, RDS는 단일 AZ예요. 비용을 우선한 선택이었고, 운영 환경이라면 브로커 3대 · AZ별 NAT · Multi-AZ RDS로 가야 해요.