gwimong's blog Software Engineer

artifact-transformer 작업 일지 #43

실기체를 모의로 갈아 끼우며 경계를 세 번 다시 그은 날


이 글은 2026-09-09 하루의 기록이다. 오전에는 산출물에 남아 있던 갭 일곱을 신규 분류 둘로 닫았고, 오후에는 SUGCS 컨테이너 다이어그램에서 실기체를 시뮬레이터로 갈아 끼웠고, 저녁에는 그 시뮬레이터를 실제로 만들 소프트웨어의 규격을 썼다. 세 갈래가 서로 다른 저장소에서 돌았는데, 하루가 끝나고 보니 전부 같은 질문이었다 — 무엇을 경계 안에 두고 무엇을 밖으로 낼 것인가.

배경 — 남은 갭 일곱

지난 회차에서 구현 대비 산출물의 빈자리를 목록으로만 남겨 뒀다. 오늘 그걸 전부 반영했다.

먼저 분류부터 정했다. 일곱 개를 기존 분류에 욱여넣을지, 새 분류를 세울지가 갈림길이었다. 구현 코드에서 각 영역의 실제 범위를 훑고 나서 신규 2분류 + 기존 2분류 확장으로 잡았다.

분류내용
TSK 작업 관리 (신규)정본 작업 저장소(SQLite 9테이블)·계층/관계/이력/첨부·동기화 아웃박스
OPS 운영 관리 (신규)프로젝트 등록·스캐폴딩, 요청 지표·잡 현황, 감사 로그·계정 연동
COL 확장식별자 채번 서비스(선점 원장), 원격 동기화 충돌 해결(3-way 의미 병합)
INT 확장Bitbucket PR 브리지, Confluence 발행·역복원, MCP 도구 서버

분류가 정해지면 나머지는 순서 문제다. 원천성이 위인 것부터 아래로 — 업무 구성도 → 요구사항 → 기능 → 유스케이스 → 참여자 → C4 → 시퀀스 → 논리·물리 데이터 → 인터페이스 → 시나리오 → 시험 → 흐름도 → README까지 19단계로 내려갔다.

결정 1 — 컨테이너 밖으로 내보낸 것들

C4 레벨2를 손대면서 세 가지를 협업 API 컨테이너 밖으로 뺐다.

MCP 도구 서버와 계정 서비스는 별도 프로세스다. 같은 컨테이너 안에 컴포넌트로 그리면 배포 단위가 하나인 것처럼 읽힌다. 각각 L2 컨테이너로 세우고 자기 CMPDG partial을 붙였다.

작업 DB(build/state.db)도 마찬가지다. 이건 Git 대상이 아니라 런타임 상태라, Collaboration State Store라는 별도 컨테이너로 뒀다. 컨테이너 4→7, 외부 시스템 5→8이 됐다.

반대로 만들지 않기로 한 것도 있다. PR 브리지와 충돌 해결은 코드에 # BATON-CMP-reviewFlow / gitStore 마커가 이미 붙어 있었다. 신규 컴포넌트를 세우면 같은 코드가 두 컴포넌트에 매달린다. 기존 컴포넌트의 책임으로 두는 편이 맞다.

갭을 닫는다는 건 빈칸을 채우는 게 아니라, 무엇이 이미 채워져 있는지를 먼저 확인하는 일이다.

SFDG(상태 흐름도)도 하나 안 만들었다. OPS는 지표·감사가 추가 전용이라 상태기계가 없다. 없는 걸 억지로 그리는 대신 왜 없는지를 카탈로그 범례에 적었다.

결정 2 — 반영 규모와 검증

구분변화
분석업무 13→24 · 요구사항 18→29 · 기능 18→29 · 유스케이스 14→25
설계시퀀스 14→25 · 논리/물리 데이터 각 10→17 · 인터페이스 5→8 · 컴포넌트 17→25
시험단위 17→25 · 통합 5→7 · 운영 13→24 · 인수 5→7
다이어그램신규 27건 + 상위 조합 루트·C4 3레벨 갱신, 총 77건 재렌더

check.py --strict exit 0, 개정 이력 1.1.0 → 1.2.0으로 올리고 67d41e9로 커밋했다. 110파일, +8972/−311.

작은 부산물 하나. --work-dir 실행이 저장소 루트에 .generate.env / .parse.env를 만드는데, 안에 로컬 절대 경로가 들어간다. .gitignore에 넣었다.

오후 — 실기체를 시뮬레이터로 갈아 끼우다

저장소를 SUGCS 문서로 옮겼다. 요구는 한 줄이었다 — 컨테이너 다이어그램에서 UAV 대신 SITL 기반 시뮬레이터가 텔레메트리를 내보내고, 영상 임무 장비 대신 Video Player가 Take Off 이벤트를 받아 영상을 재생하는 형태로.

이전이후
System_Ext(uav, "UAV")System_Ext(sitl, "SITL 시뮬레이터")
Container_Ext(videoPayload, "영상 임무 장비")삭제
Container_Ext(uavDataLink, "DataLink Module <Airborne>")Container_Ext(sitlLink, "MAVLink Endpoint <Simulated>")
—Container_Ext(sitlVehicle, "SITL Vehicle Model") 신설

여기서 경계를 한 번 잘못 그었다. Video Player를 SUGCS 경계 안에 뒀다. 재생을 트리거하는 게 SUGCS의 telemetryPublisher이고 출력을 받는 것도 SUGCS의 videoRelay라, 사이에 낀 것을 안쪽으로 읽은 것이다.

틀렸다. Video Player는 영상 임무 장비를 대체하는 물건이고, 임무 장비는 원래 기체 쪽이다. 밖으로 내보내면서 그룹 이름도 바꿔야 했다 — sitl이라는 이름으로는 영상 모의를 포괄하지 못하니 simEnv(시뮬레이터 환경)로 올렸다. Container → Container_Ext로 스테레오타입도 바꿔 회색으로 렌더되게 했다.

결과적으로 SUGCS 경계에는 videoRelay만 남았다.

선 정리는 두 번 뒤집혔다

가독성 개선 요청이 들어와서, 실제 파일을 건드리기 전에 스크래치패드에 레이아웃 변형 6종(A~F)을 만들어 전부 렌더해 비교했다.

D안이 이겼다. skinparam linetype ortho로 직교 라우팅을 걸고, 컨테이너 선언 순서를 좌→우 데이터 흐름 순으로 재배치했다.

fileStorage → vsm → dli → sucsDataLink → sucsApp → videoRelay → telemetryPublisher → missionPublisher

이미지가 2008×2063에서 2154×1608로 바뀌었다. 세로로 늘어지던 게 가로형이 됐다.

채택하지 않은 것도 적어 둔다. Lay_L/Lay_R 배치 힌트는 의도와 반대로 작동했다. 시뮬레이터 환경이 오른쪽으로 밀리고 VSM/DLI 계통이 하단으로 늘어졌다. 힌트를 빼고 선언 순서만 두는 편이 GraphViz가 더 잘 배치한다.

그런데 다음 세션에서 요구가 뒤집혔다 — 시뮬레이터 환경을 아래로 내리고, 직각 라우팅은 지양하고 선 배치로 정리해 달라는 것. linetype ortho를 걷어내고 nodesep 60→70, ranksep 90→110으로 간격만 벌린 뒤, 랭크가 실제로 내려가도록 진입 관계의 방향 힌트를 다시 짰다.

관계변경
telemetryPublisher → videoPlayerRel_L → Rel_D (Take Off 트리거를 하향)
videoPlayer → videoRelayRel_R → Rel_U (모의 영상 스트림을 상향)
sitlVehicle → sitlLinkRel → Rel_L (클러스터 내부 동일 랭크)

D안을 버린 게 아깝지는 않다. 6종을 렌더해 본 덕에 무엇이 어떤 이유로 배치를 바꾸는지를 알고 있었고, 요구가 뒤집혔을 때 다시 실험하지 않고 바로 조정할 수 있었다.

텔레메트리 선 하나가 박스를 탈출했다

Video Player에서 MAVLink Endpoint로 영상 텔레메트리를 보내는 관계를 추가했더니, 새 선이 시뮬레이터 박스 위쪽으로 크게 우회했다. SITL Vehicle Model이 두 노드 사이에 끼어 있었기 때문이다.

sitlVehicle → sitlLink를 Rel_L에서 Rel_R로 바꿔 배치를 다시 세웠다.

SITL Vehicle Model → MAVLink Endpoint ← Video Player

이제 두 텔레메트리 선 모두 박스 안에서 인접 노드 간 직선이 된다. 라벨도 “Video 텔레메트리 전송”에서 **“모의 영상 텔레메트리”**로 통일했다. 같은 박스 안의 모의 비행 상태 텔레메트리와 대구를 이룬다.

실기체 쪽 다이어그램(SUGCS-CNTDG-OVERVIEW)에도 대응하는 선을 넣었다. 이쪽은 실기체 구성이라 라벨이 “영상 텔레메트리”다. 모의/실기체가 라벨 층위에서 구분된다. 버전을 1.1.5 → 1.1.6으로 올리고 8effcbe, 0513052 두 커밋으로 푸시했다.

의도치 않은 함정

파일명을 바꿔도 따라오지 않는 것이 있다. 커밋 직전에 보니 세션 밖에서 SUGCS-CNTDG-OVERVIEW2.puml이 SUGCS-CNTDG-SILS-ENV-OVERVIEW.puml로 바뀌어 있었다. 그런데 13행 @startuml SUGCS-CNTDG-OVERVIEW2는 옛 이름 그대로였다.

이 식별자가 렌더링 출력 파일명을 결정한다. 그대로 두면 계속 SUGCS-CNTDG-OVERVIEW2.png가 나온다. 파일명과 출력물 이름이 조용히 어긋난 채로 쌓였을 것이다.

또 하나. 한글 리터럴이 든 패치를 heredoc으로 stdin에 밀어 넣었더니 문자가 깨졌다. 스크립트를 파일로 써서 실행하는 방식으로 바꿨더니 이번에는 백슬래시가 전달 과정에서 흡수됐다. 결국 chr(92)로 우회했다. 도구를 거쳐 전달되는 문자열은 도구의 수만큼 이스케이프 층이 쌓인다.

저녁 — 시뮬레이터의 규격을 쓰다

워크트리를 하나 파고 sugcs-sils 규격 작업으로 옮겼다. ULog와 영상, 영상 텔레메트리를 Kafka·RTSP로 내보내는 소프트웨어다. 개발은 다른 에이전트가 한다.

1시간 만에 4종을 냈다 — README(범위·정본·가정 5건·TBD 10건), SRS(요구사항 81건), IFS(ULog→토픽 매핑, Kafka 14토픽 스키마, RTSP/RTP, MAVLink 주기표), VER(검증 항목 47건). 토픽·필드·키·주기는 추측하지 않고 data/interface-data-mcs.json 1.0.3과 kafka-samples/*.jsonl을 그대로 따랐다.

여기서 요구를 적으면서 모순 하나가 드러났다. “kafka·rtsp로 전송”이라는 말은 SILS가 SUGCS 자리를 대체해 MCS 연동면을 직접 구동한다는 뜻인데, 오후에 그린 SILS-ENV-OVERVIEW는 SILS가 기체를 대체해 MAVLink로 GCS에 붙는 구성이다. 같은 이름의 물건이 서로 다른 자리를 대신하고 있었다.

둘 다 유효해서 kafka / mavlink / both 모드로 정의하고 kafka를 주 용도로 잡았다. 오후의 다이어그램이 저녁의 규격을 검증한 셈이다.

검토 #001 — 시뮬레이터를 만드는 이유가 빠져 있었다

상세 검토를 걸었다. 지적 32건이 나왔고 요구사항이 81 → 118건으로 늘었다.

1.0.0의 공백 셋은 이랬다.

연동이 단방향 재생뿐이었다. MCS가 명령이나 임무를 내려도 SILS 세계는 변하지 않는다. 그러면 MCS의 폐루프 로직은 검증할 수 없다.

이상 상황을 만들 수단이 없었다. 통신 두절·GNSS 상실·배터리 급감을 시뮬레이터가 못 만들면, MCS의 경보·재계획 로직은 실비행 사고를 기다려야 검증된다. 시뮬레이터를 만드는 가장 큰 이유가 규격에서 빠져 있었다.

PX4 매핑이 틀리면 시험 전체가 무효인데 대조 기준이 없었다. 실 SUGCS 발행 레코드를 캡처해 무변경 재생하고, ULog 재생 결과와 대조하는 요구를 세워 M1으로 앞당겼다.

설계 충돌도 하나 잡혔다. NFR-009(MCS가 실데이터와 모의데이터를 코드 변경 없이 동일 처리)와 OPS-006(시뮬레이션 표식)이 정면으로 부딪혔다. 페이로드에 표식을 넣으면 무구분 소비가 깨지고, 안 넣으면 구별이 불가능하다.

Kafka 레코드 헤더로 분리해 풀었다(x-sils-simulated, x-sils-run-id). 페이로드 스키마는 불변, 소비자는 헤더를 무시해도 정상 동작, 감사·필터는 헤더로 가능하다. 여기서도 경계 문제였다 — 표식을 데이터 안에 둘 것인가 봉투에 둘 것인가.

정량 기준도 손봤다. 재생 타이밍 P95 20ms는 50Hz 토픽의 주기와 같아서 한 주기 밀림을 허용하는 값이었다. 10ms로 강화했다. 주기 판정도 규격 공칭값이 아니라 로그 실측 간격 대비로 바꿨다. 로그 자체의 편차를 SILS 결함으로 오판하지 않도록.

검토 #002 — 코드가 스택을 결정했다

기술 스택 검토는 추측 대신 sugcs-app 작업 사본(develop, ac056b5)을 직접 열어 사실 9건을 근거로 잡았다. 결정을 바꾼 발견이 셋이었다.

사내 MAVLink 라이브러리가 netstandard2.0 타깃이었다. 즉 .NET 8에서 Linux 포함 그대로 재사용된다. 게다가 SUCS 전용 dialect에 VISION_TARGET_TRACKING_*, SDCS_CMD 같은 커스텀 메시지가 있어, 표준 dialect만으로는 군집·영상 추적 상태가 전달되지 않는다. 이게 런타임 선택을 사실상 끝냈다. Go나 Python으로 가면 dialect를 재생성·재검증해야 하고 불일치 위험을 새로 만든다.

PX4 기반이 거의 확실했다. Px4Mode.cs에 PX4 custom mode 매핑이 있고 ArduPilot 모드 enum은 없다. ULog냐 .bin이냐를 두고 남겨 뒀던 미결의 위험이 크게 내려갔고, 덤으로 flightMode 변환표의 정본이 이 파일이라는 것도 확정됐다.

Windows 타이머 해상도가 최대 기술 위험이었다. 기본 해상도가 약 15.6ms라, 일반적인 비동기 지연에 의존하면 P95 ≤ 10ms를 구조적으로 만족할 수 없다. 50Hz 주기 20ms가 통째로 밀린다. 스택 선택보다 중요한 구현 제약이라 대응책을 명시하고, 확보 실패 시 조용히 미달하지 않도록 경고 후 자동 강등하는 요구를 새로 세웠다.

타깃도 갈랐다. sugcs-app이 net8.0-windows라고 따라가면 Ubuntu 요구가 즉시 깨진다. 코어는 net8.0(WPF 비의존), RTSP는 MediaMTX 보조 프로세스다.

직렬화는 권고를 조건부로 달았다. GCS는 Newtonsoft를 쓰는데 System.Text.Json과는 부동소수 표기·null 생략·시각 형식에서 결과가 다를 수 있다. 스키마가 아니라 바이트 표기 문제라 스키마 검사로는 안 잡히고, 그대로 무구분 소비 위반이 된다.

확인 못 한 것도 그대로 적었다 — Kafka 클라이언트는 이 체크아웃에 없었고, 문서가 인용한 커밋이 로컬 브랜치 어디에도 없었다. 없다는 뜻이 아니라 못 봤다는 뜻이다. 검토 문서에서 이 둘을 섞으면 다음 사람이 “확인됨”으로 읽는다.

구조 — 정확한 시각에 I/O를 하지 않는다

마지막으로 SDD와 PLAN, C4 다이어그램 3종을 썼다.

핵심은 파이프라인 형상이었다. P95 ≤ 10ms를 만족하려면 정확한 시각에 해야 할 일이 I/O여서는 안 된다. Kafka 전송 한 번이 수십 ms 걸리면 재생 타이밍이 오염된다. 디스패처는 “싱크 큐에 넣는 시각”까지만 책임지고 실제 전송은 워커가 비동기로 한다. 순서 보장은 vehicleId 해시 샤딩 + 샤드당 워커 1개로 락 없이 얻는다.

폐루프에도 선을 그었다. 재생 중인 로그는 “이미 일어난 과거”고 MCS 명령은 “지금 내려온 지시”라, 둘이 같은 필드를 건드릴 때 무엇이 이기는지 정해야 한다. 로그가 기본값, 상태 모델 오버라이드가 우선하는 방식으로 했다. 다만 위치·자세처럼 궤적 자체를 바꾸는 명령은 범위 밖으로 못 박았다. 이 선을 안 그으면 SILS가 비행 시뮬레이터를 재구현하게 된다.

결함 주입도 두 지점으로 나눴다. 상태 변조형은 소스 뒤, 전송 열화형은 싱크 앞이다. 섞으면 MCS 측에서 “값이 이상한 것”과 “전달이 이상한 것”을 구분할 수 없다.

다이어그램은 저장소 최상위 diagrams/가 아니라 specs/sugcs-sils/diagrams/에 뒀다. 최상위는 data/*.json으로 역추출되는 파이프라인의 입력이라, SUGCS 산출물이 아닌 SILS 내부 설계도를 섞으면 정합성 검사가 오염된다.

문서 8종·다이어그램 3종, 요구 122건·검증 70건, 미정의 참조 0. 아직 커밋하지 않았다.

마무리

오늘 남는 것은 둘이다.

  • 경계는 기능이 아니라 대체 관계가 정한다. Video Player를 SUGCS 안에 뒀던 건 그것이 SUGCS의 두 컴포넌트 사이에 끼어 있었기 때문이다. 하지만 그 물건은 임무 장비를 대체하는 것이고, 임무 장비는 기체 쪽이다. MCP 서버를 협업 API 밖으로 뺀 것도, 코어 타깃을 net8.0-windows에서 가른 것도, 시뮬레이션 표식을 페이로드가 아닌 헤더로 옮긴 것도 같은 판단이다. 누구와 붙어 있느냐가 아니라 무엇의 자리에 서느냐를 봐야 한다.
  • 못 본 것과 없는 것을 구분해서 적어야 한다. Kafka 클라이언트를 확인하지 못했다고 쓴 자리를 “미사용”으로 적었다면, 다음 사람은 확인 없이 직렬화 설정을 새로 정했을 것이다. 규격 문서에서 이 둘의 구분이 무너지면 검증되지 않은 가정이 결정으로 승격된다.

한 가지 더. 오후에 그린 다이어그램이 저녁에 쓴 규격의 전제를 반박했다. 같은 대상을 다른 형식으로 두 번 표현하면 어긋난 자리가 드러난다. 산출물을 여러 벌 만드는 비용은 대개 이 지점에서 회수된다.

남은 일은 셋이다. SILS 규격 8종의 커밋 여부 결정, 실 SUGCS Kafka 설정 확보(스파이크 2 전제), 표본 .ulg 확보(스파이크 3 전제). 나머지 미결 6건은 기본안으로 진행하다 결정이 오면 바꾸면 된다.


Comments

Content