[자율주행] MCAP 완전 정복 — 인지·융합·계획·제어를 한 파일에 담는 법
자율주행/데이터
· 2026-09-05
본문의 점선 밑줄 친 전문용어를 누르면 설명이 열립니다.
로그 데이터와 포맷에서 을 한 절로 소개했다. 이 글은 그 한 절을 통째로 펼친다. 이유는 단순하다. 기록 포맷을 바꾸는 건 파일 확장자를 바꾸는 일이 아니라, 앞으로 몇 년치 데이터가 어떤 질문에 답할 수 있는지를 정하는 일이기 때문이다.
포맷을 갈아엎는 시점에 실제로 결정되는 것들은 이렇다. 3년 뒤에도 이 로그를 열 수 있는가. 인지가 틀렸을 때 그게 계획 탓인지 센서 탓인지 한 화면에서 가려낼 수 있는가. 라벨을 어디에 둘 것인가. 페타바이트에서 한 장면을 몇 초에 꺼낼 수 있는가.
사양서는 mcap.dev에 있다. 여기서는 사양을 읽고 나서도 남는 질문들 — 무엇을 어떻게 담을지 — 을 다룬다.
왜 바꾸는가
바꾸는 쪽의 논거는 대개 넷이다.
- 탐색(seek) — 은 대용량에서 임의 시점 접근이 약하다. 30GB 로그의 37.5초 지점을 보려고 앞부분을 훑는 일이 생긴다. 은 파일 끝의 인덱스로 곧장 점프한다.
- 스키마 결합 — rosbag은 ROS 메시지 정의에 묶인다. 그 정의가 없는 환경(학습 클러스터, 클라우드 분석, 외부 협력사)에서는 파일이 반쯤 불투명해진다.
- 이종 메시지 — 인지 출력, 계획 후보 , 모델 텐서, 진단 로그를 한 시간축에 같이 담고 싶은데 ROS 메시지 정의만으로는 어색하다.
- 도구 — 표준 스키마로 적어두면 시각화 도구가 그냥 열어 그린다. 사내 뷰어를 계속 만들지 않아도 된다.
바꾸지 않는 쪽의 논거도 진짜다. 기존 도구와 스크립트가 전부 옛 포맷을 전제한다. 그래서 전환의 성패는 포맷 자체가 아니라 이행기를 어떻게 설계하느냐에서 갈린다. 마지막 절에서 다룬다.
파일 안쪽
파일은 이렇게 생겼다.
[매직바이트]
Header 프로파일("ros2"/"" 등), 기록한 라이브러리
─── 데이터 구역 ──────────────────────────────────
Schema 메시지 해석 방법 — 정의 자체가 바이트로 들어간다
Channel 토픽 하나 = 채널 하나, 스키마 id 를 가리킨다
Chunk Message 들을 묶어 압축한 덩어리
└ Message channel_id + log_time + publish_time + 페이로드
MessageIndex 청크 안에서 채널별 (시각 → 오프셋)
Attachment 임의 파일 (캘리브레이션, 지도 조각…)
Metadata 키-값 맵 (차량 id, 커밋 해시…)
DataEnd
─── 요약 구역 ──────────────────────────────────
Schema/Channel 사본 인덱스만 읽어도 목차가 나오게
ChunkIndex 각 청크의 시간 범위 + 바이트 오프셋
Attachment/MetadataIndex
Statistics 메시지 총수, 채널별 개수, 시작/끝 시각
Footer 요약 구역의 시작 위치, CRC
[매직바이트]
여기서 실무적으로 중요한 성질 셋만 뽑으면,
하나. 목차가 파일 끝에 있다. 그래서 mcap info 는 30GB 파일이어도 즉시 답한다. 꼬리만 읽기 때문이다. 에 올려두고 Range 요청으로 꼬리 → 필요한 만 당겨오는 접근이 여기서 나온다. 페타바이트를 옮기지 않고 다루는 실질적 근거다.
둘. 그 목차는 정상 종료했을 때만 쓰인다. 기록 중 프로세스가 죽으면 이 안 쓰인 채 파일이 끝난다. 데이터는 살아 있지만 인덱스가 없어 seek 가 안 된다. mcap recover 로 데이터 구역을 훑어 되살릴 수 있지만, 차량에서 전원이 끊기는 일은 드물지 않으므로 복구 절차를 파이프라인에 미리 넣어두는 편이 낫다.
셋. 압축과 탐색의 단위가 청크다. 청크 하나를 풀어야 그 안의 메시지 하나를 본다. 이 사실이 뒤에 나올 분할 정책의 전부다.
스키마 — 3년 뒤에도 읽히게
MCAP 이 self-describing 이라는 말의 실체는 스키마 정의가 파일 안에 바이트로 박혀 있다는 것이다. 여기서 첫 번째 큰 결정이 나온다.
| 인코딩 | 스키마 형태 | 필드 추가에 견디는가 |
|---|---|---|
ros1msg / ros2msg | .msg 텍스트 정의 | 약함 — 정의가 바뀌면 과거 로그와 어긋난다 |
protobuf | FileDescriptorSet 바이트 | 강함 — 필드 번호로 매칭, 모르는 필드는 건너뛴다 |
jsonschema | JSON Schema 문서 | 중간 — 유연하지만 용량·파싱 비용이 크다 |
스택이면 ros2msg + cdr 이 자연스럽다. 하지만 몇 년치 로그를 쌓을 작정이라면 를 한 번은 검토할 값어치가 있다. 은 필드가 순서대로 붙어 있고 번호가 없어서, 메시지 정의 가운데에 필드 하나를 끼워 넣는 순간 그 앞뒤로 해석이 어긋난다. 인지 출력 메시지에 "속성 하나 추가"는 반년에 한 번씩 벌어지는 일이다.
CDR 을 유지한다면 규약으로 막아야 한다.
- 필드는 끝에만 추가한다. 가운데 삽입·삭제 금지.
- 호환 불가능하게 바뀌면 스키마 이름에 버전을 붙인다(
perception_msgs/Objects_v2). - 어떤 스키마 버전이 언제부터 쓰였는지를 로그 밖 레지스트리에도 남긴다. 파일 안의 정의는 해석을 위한 것이고, 레지스트리는 집계를 위한 것이다.
그리고 반드시 지킬 것 하나. 스키마를 외부 저장소에만 두지 마라. MCAP 이 주는 가장 큰 이점을 스스로 버리는 일이다. 그 저장소가 사라지거나 정의가 덮어써지면 과거 로그가 통째로 해석 불능이 된다.
세 개의 시계
이 글에서 딱 하나만 기억한다면 이 절이다.
MCAP 메시지는 시각을 둘 들고 있다.
log_time— 기록기가 파일에 적은 시각. 인덱스와 seek 의 기준.publish_time— 발행자가 내보낸 시각. 전송 지연을 잰다.
그리고 페이로드 안에 세 번째 시계가 있다. 센서가 실제로 세상을 본 시각 — ROS 라면 header.stamp, 이라면 점마다 붙은 상대 시각이다.
캡처 시각 ────▶ publish_time ────▶ log_time
(센서가 본 순간) (발행) (파일에 적힌 순간)
▲ ▲
융합·좌표변환은 탐색·리플레이는
여기를 쓴다 여기를 쓴다
섞으면 조용히 틀린다. log_time 으로 라이다와 카메라를 맞추면 큐 지연과 전송 지연만큼 어긋나는데, 60km/h 에서 10ms 는 17cm 다. 박스가 물체에서 미끄러지고, 아무도 을 의심하지 않은 채 몇 주가 지난다.
그래서 전환 시점에 못 박아야 할 규약:
- 모든 센서 메시지는 캡처 시각을 페이로드 안에 들고 다닌다. 점군은 점별 상대 시각까지. 이게 없으면 을 영영 못 한다 — 나중에 되돌릴 수 없는 결정이다.
- 인지·계획·제어 출력은 자기가 소비한 입력의 시각을 함께 싣는다.
/perception/objects메시지에 "이건 t=1234.567 의 라이다 프레임에서 나왔다"가 적혀 있으면 인과 사슬을 되짚을 수 있다. 없으면 추측해야 한다. log_time은 단조 증가해야 한다. 기록기가 여러 스레드에서 쓰면서 순서가 깨지면 인덱스가 이상해지고 리플레이가 흔들린다.
토픽 설계 — 인지(perception)
이름은 바꿀 수 없는 계약이라고 생각하는 편이 안전하다. 나중에 이름을 바꾸면 과거 로그 전부가 새 질의에서 빠진다.
/sensor/lidar/top/points 점군 (x,y,z,intensity,ring,time)
/sensor/lidar/top/packets 원본 패킷 — 재처리용, 선택
/sensor/camera/front/image 압축 영상
/sensor/camera/front/camera_info K, D, 왜곡 모델, 해상도
/sensor/radar/front/targets
/vehicle/imu, /vehicle/gnss, /vehicle/can
/perception/objects 3D 박스 + 클래스 + 트랙 id + 속도
/perception/occupancy 점유 격자
/perception/lanes 차선 폴리라인 + 위상
/perception/ground_markings 노면 표시
/perception/traffic_lights 검출 + 상태 + 매칭된 차선
/perception/parking_slots 주차 슬롯
/perception/freespace
여기서 자주 하는 실수 셋.
이미지 옆에 camera_info 를 안 담는다. 이미지만 있는 로그는 3년 뒤에 기하 정보를 못 살린다. K 도 왜곡 계수도 어느 해상도 기준인지도 모르게 된다. 정적인 값이라도 로그마다 한 번은 반드시 발행해야 한다. 이 바뀌는 순간을 로그가 스스로 증언하게 하는 효과도 있다.
에서 필드를 미리 버린다. x, y, z 만 남기고 intensity·ring·점별 시각·리턴 종류를 버리는 결정은 되돌릴 수 없다. intensity 가 없으면 차선 도색을 못 읽고, 점별 시각이 없으면 을 못 하고, ring 이 없으면 지면 제거가 나빠진다. 원본 로그에서는 전부 남긴다. 줄이는 건 파생 데이터를 구울 때 한다.
인지 출력을 내부 스키마로만 담는다. 그러면 시각화 도구가 못 그린다. 절충은 간단하다 — 정밀한 내부 스키마를 정본으로 두고, 시각화용 미러 토픽(/viz/objects 같은)을 로 하나 더 발행한다. 용량은 얼마 안 되고, 뷰어를 새로 만드는 비용이 사라진다.
토픽 설계 — 센서 융합과 좌표계
의 성능 상한은 모델이 아니라 와 좌표 정합이 정한다. 그리고 그 둘은 전부 로그 설계 문제다.
은 두 종류로 나눠 담는다.
/tf_static base_link → lidar_top, base_link → camera_front (장착 위치)
/tf map → odom → base_link (차의 움직임)
표준 변환 메시지로 적으면 뷰어가 알아서 트리를 세워 여러 센서를 한 3D 공간에 겹친다. 실무 함정은 조회 시각이다. 라이다 점을 이미지에 투영할 때 "가장 최근 변환"을 쓰면 고속에서 수십 cm 가 밀린다. 그 점의 캡처 시각으로 보간해서 조회해야 한다.
정적 변환에는 또 하나의 결정이 걸려 있다. 값을 /tf_static 으로만 담을지, 로 원본 캘리브레이션 파일까지 함께 넣을지. 권장은 둘 다다.
/tf_static— 도구가 바로 쓰는 형태- 첨부
calibration.yaml+ 의calibration_version— 사람이 감사하고 재현하는 형태
캘리브레이션 DB 만 믿으면, DB 스키마가 바뀌거나 값이 덮어써지는 순간 과거 로그의 좌표 변환을 재현할 수 없게 된다. 로그는 자기 해석에 필요한 것을 스스로 들고 있어야 한다.
융합 단계의 출력도 남긴다.
/localization/pose 최종 에고 포즈 (+ 공분산)
/fusion/tracks 센서별 검출을 합친 트랙
/fusion/associations 어느 검출이 어느 트랙에 붙었는지 ← 이게 디버깅을 살린다
마지막 줄이 핵심이다. 융합이 틀렸을 때 "왜 그 레이더 검출을 이 트랙에 붙였나"에 답하려면 연관 결과가 남아 있어야 한다. 최종 트랙만 남기면 사후 분석이 추측이 된다.
토픽 설계 — 계획과 제어
여기가 MCAP 전환에서 가장 크게 남는 장사다. 인지 로그와 계획·제어 로그가 서로 다른 파일·다른 시계에 흩어져 있으면 "왜 안 피했나"에 답하려고 두 파일을 손으로 정렬해야 한다. 한 파일에 한 시간축으로 들어 있으면 스크럽 한 번이면 된다.
/prediction/trajectories 주체별 후보 궤적 + 확률
/planning/route 어느 길로
/planning/behavior 차선 변경·양보·추월 같은 이산 결정 + 그 이유
/planning/candidates 평가한 후보 궤적들 + 비용 항목별 점수 ← 반드시
/planning/trajectory 선택된 궤적
/control/command 목표 조향각·가감속
/vehicle/status CAN 피드백 — 실제 조향각·속도·기어
/diagnostics 모듈별 상태·지연
두 줄만 강조한다.
/planning/candidates — 선택된 만 남기면 "왜 그걸 골랐나"에 답할 수 없다. 후보들과 각각의 비용(충돌 위험, 편안함, 법규, 경로 이탈) 점수가 남아 있으면, 잘못된 결정의 원인이 인지의 잘못된 입력인지 비용 가중치인지 즉시 갈린다. 용량은 미미하다.
/control/command 와 /vehicle/status 를 쌍으로 — 목표 조향각과 실제 조향각을 나란히 보면 문제가 계획에 있는지 차량 응답에 있는지 한눈에 갈린다. 그리고 두 의 타임스탬프 차이가 곧 시스템 반응 지연이다. 지연은 로그의 시각으로만 잴 수 있다.
로그에 넣을 것과 밖에 둘 것
원칙 하나로 정리된다. 로그는 불변이고, 라벨은 계속 바뀐다.
| 로그 안에 | 로그 밖 사이드카에 | |
|---|---|---|
| 센서 원본 | 넣는다 | |
| 인지·계획·제어 출력 | 넣는다 (그때 그 스택이 낸 값) | |
| 캘리브레이션 | 첨부 + /tf_static | 감사용 DB에도 사본 |
| 차량 id·커밋· 태그 | 카탈로그 테이블에도 | |
| 사람이 만든 | 넣지 않는다 | /Delta, (log_id, t_ns) 로 조인 |
| 자동 라벨 결과 | 넣지 않는다 | 버전 달아 별도로 |
| 파생 신호·태그 | 넣지 않는다 | 컬럼형 |
어노테이션을 로그 안에 넣고 싶은 유혹이 크다. 한 파일에 다 있으면 편하니까. 하지만 라벨은 스펙이 바뀌고 재작업되고 이 갱신한다. 그때마다 페타바이트짜리 불변 로그를 다시 쓸 수는 없다.
조인 키만 정확히 정해두면 된다 — log_id(파일 식별자) + t_ns(캡처 시각 기준) + topic. 이 셋이 라벨과 로그를 잇는 계약이다.
청크·압축·분할 정책
압축
zstd— 압축률이 높다. 아카이브·전송 비용이 중요한 쪽.lz4— 해제가 훨씬 빠르다. 차량에서 실시간 기록하거나 학습에서 반복해 읽는 쪽.- 없음 — 이미 압축된 페이로드(JPEG, )에는 이중 압축의 이득이 거의 없다.
크기
크게 잡으면 압축률이 좋고 인덱스가 작아진다. 작게 잡으면 seek 가 정밀해진다. 정답은 읽는 방식이 정한다. 로그 전체를 순차 리플레이하는 게 주 용도면 크게, 페타바이트에서 3초짜리 조각을 무작위로 꺼내는 게 주 용도면 작게.
파일 분할
이게 생각보다 큰 결정이다.
- 하나의 MCAP 은 순차 스트림이라 파일 안을 쪼개 병렬 처리하기 어렵다. 분산 처리의 병렬성 단위가 곧 파일 개수다. 30분짜리 한 덩어리로 두면 클러스터가 놀고, 5초씩 쪼개면 파일 수백만 개가 리스팅을 죽인다.
- 실무 감각은 30초
2분, 혹은 14GB 근처다. 여기에 "씬 하나 = 파일 하나"가 되도록 경계를 맞추면 이 쉬워진다. - 고빈도 대용량 토픽(라이다·영상)과 저빈도 토픽(계획·진단)을 별도 파일로 분리하는 것도 흔한 최적화다. 계획 로그만 훑는 분석이 영상 수 TB 를 지나치지 않아도 된다. 다만 파일이 갈리면 "한 시간축에서 보기"라는 이점이 약해지므로, 나눌 거면 동일한
log_id접두사로 묶어 도구가 다시 합쳐 볼 수 있게 한다.
읽기 실전
인덱스가 있으니 필요한 구간만 뽑을 수 있다.
1from mcap.reader import make_reader 2 3with open("drive_0042.mcap", "rb") as f: 4 reader = make_reader(f) 5 6 # 1) 목차부터 — 파일 전체를 훑지 않는다 7 summary = reader.get_summary() 8 print(summary.statistics.message_count) 9 for ch in summary.channels.values(): 10 print(ch.topic, ch.message_encoding) 11 12 # 2) 로그에 동봉된 캘리브레이션 꺼내기 13 for attachment in reader.iter_attachments(): 14 if attachment.name == "calibration.yaml": 15 calib = attachment.data.decode() 16 17 # 3) 필요한 토픽 · 시간 구간만 18 for schema, channel, message in reader.iter_messages( 19 topics=["/perception/objects", "/planning/trajectory"], 20 start_time=100_000_000_000, # ns 21 end_time=130_000_000_000, 22 ): 23 msg = decode(schema, message.data) # 인코딩에 맞는 디코더 24 # 주의: 정렬과 융합은 message.log_time 이 아니라 25 # 페이로드 안의 캡처 시각으로 한다
로 담았다면 디코더를 직접 붙일 필요가 없다.
1from mcap_protobuf.decoder import DecoderFactory 2 3reader = make_reader(f, decoder_factories=[DecoderFactory()]) 4for schema, channel, message, proto in reader.iter_decoded_messages(): 5 ...
분석 층으로 넘길 때는 앞 글에서 다룬 대로 파생 신호만 으로 굽는다. 원본 을 Parquet 에 밀어 넣는 게 아니라 "이 프레임의 객체 수·최근접 거리·에고 속도·기동 종류" 같은 질의용 신호를 뽑는다.
마이그레이션 플레이북
포맷 전환은 코드 변경이 아니라 이행 프로젝트다. 순서가 있다.
1단계 — 규약을 먼저 문서로 못 박는다
코드보다 먼저다. 토픽 이름 규칙, 스키마 인코딩, 시계 규약(어느 시각이 log_time 이고 캡처 시각은 어디에), 필수 토픽 목록(/tf, camera_info, /vehicle/status 는 빠지면 안 된다), 메타데이터 필수 키, 파일 분할·명명 규칙. 이걸 안 정하고 변환부터 하면 반년 뒤 팀마다 다른 규약의 MCAP 이 쌓인다. 포맷은 통일했는데 데이터는 여전히 안 통일된 상태가 최악이다.
2단계 — 이중 기록
한동안 옛 포맷과 MCAP 을 동시에 기록한다. 디스크가 아깝지만, 되돌릴 수 없는 데이터를 다루는 일에서 이건 보험이 아니라 필수다.
3단계 — 변환과 검증
과거 로그는 mcap convert 로 옮긴다. 그리고 반드시 대조한다.
- 채널별 메시지 수 — 원본에서 센 값과 의 채널별 개수 비교
- 시간 범위 — 시작·끝 시각 일치
- 표본 페이로드 — 무작위 N개를 디코딩해 바이트 단위 비교
mcap doctor— 인덱스·CRC 무결성. CI 에 넣는다.
"변환은 됐는데 몇 %가 조용히 빠졌다"는 사고가 실제로 난다. 개수 대조 하나로 대부분 잡힌다.
4단계 — 읽는 쪽을 추상화한다
파이프라인 코드에 open_bag() 같은 호출이 수백 군데 박혀 있으면 전환이 지옥이 된다. 로그 접근을 얇은 계층 하나로 감싸고 그 안에서 포맷을 분기한다. 옛 로그와 새 로그를 같은 코드로 읽을 수 있고, 다음에 또 포맷이 바뀌어도 한 곳만 고친다.
5단계 — 층을 다시 굽는다
MCAP 으로 옮기면서 파생 신호 스키마도 손보게 된다. 이때 재현성 앵커를 남긴다 — 어떤 로그 버전에서 어떤 변환 코드로 구운 테이블인지. 이 여기서 필요해진다.
6단계 — 롤백 조건을 미리 정한다
"언제까지 옛 포맷을 지우지 않는다"를 날짜로 정해둔다. 대개 새 포맷으로 학습 한 사이클이 완전히 돌고 결과가 이전과 일치하는 걸 확인할 때까지다.
흔한 함정
정리해두면 예방이 된다.
log_time으로 융합 — 가장 비싸고 가장 조용한 사고. 캡처 시각을 쓴다.camera_info누락 — 이미지만 남기면 기하 정보를 못 살린다.- 점군 필드 축소 — intensity·ring·점별 시각을 버리면 되돌릴 수 없다.
/tf_static누락 — 뷰어가 센서를 겹치지 못하고 을 재현할 수 없다.- 스키마를 외부에만 보관 — self-describing 의 이점을 스스로 버린다.
- 정의 가운데 필드 삽입 — 과거 로그가 조용히 깨진다.
- 비정상 종료 파일 방치 — 인덱스 없는 로그가 쌓인다. 복구를 파이프라인에 넣는다.
- 을 로그 안에 — 라벨이 갱신될 때마다 페타바이트를 다시 쓰게 된다.
- 파일을 너무 크게/작게 분할 — 각각 병렬성과 리스팅을 죽인다.
- 이미 압축된 페이로드를 또 압축 — CPU 만 쓰고 이득이 없다.
- 토픽 이름 변경 — 과거 로그가 질의에서 빠진다. 이름은 계약이다.
정리
- MCAP 은 이종 메시지 + 자기설명 스키마 + 파일 끝 인덱스의 조합이다. 값어치는 대부분 인덱스와 스키마 동봉에서 나온다.
- 시계가 셋이다. 탐색은
log_time, 융합과 은 캡처 시각. 섞으면 조용히 틀린다. - 인지·융합·계획·제어를 한 파일 한 시간축에 담는 것이 전환의 진짜 이득이다. 실패의 인과 사슬을 한 화면에서 되짚을 수 있다.
- 로그에는 그때 그 순간의 사실(센서·출력·캘리브레이션·맥락)을 담고, 바뀌는 것(·파생 신호)은 밖에 둔다.
- 전환의 성패는 변환 스크립트가 아니라 규약 문서, 이중 기록, 개수 대조 검증, 읽기 계층 추상화에서 갈린다.
포맷을 바꾸는 시점은 흔치 않은 기회이기도 하다. 그동안 "다음에 고치자"고 미뤄둔 것들 — 점별 타임스탬프, 계획 후보 로깅, 태그 — 을 한꺼번에 넣을 수 있는 유일한 창이다. 지금 안 넣으면 그 기간의 데이터에는 영원히 없다.
이어서 볼 글: 요약본은 로그 데이터와 포맷, 사람이 로그를 들여다보는 층은 자율주행 시각화, 그 위에 쌓는 저장·질의 인프라는 AV 데이터 플랫폼에서 다룬다.