한화에어로스페이스 인턴 8주: 정답 없는 통신 이상을 먼저 정의하다
댓글 불러오는 중…
댓글 불러오는 중…
2026년 1월부터 2월까지 8주 동안 한화에어로스페이스 LS사업부 자율SW팀에서 인턴으로 일했다. 맡은 일은 하나였다. 무인지상차량(UGV)과 통제소 사이 통신이 흔들릴 때, 무엇이 얼마나 나빠졌는지 누구나 바로 볼 수 있는 도구를 만드는 것. 결과물이 UGV-MON이다.

UGV-MON의 첫 화면. 통신 품질 지표, 장치 상태, 운용 모드, 비상정지 원인을 한곳에서 본다.
현장에서는 통신이 불안정하면 "끊겼다"는 사실만 알 수 있었다. 원인을 보려면 매번 패킷 분석기를 띄워야 했다. 요구 조건은 세 가지였다.
환경은 인터넷이 막힌 폐쇄망 리눅스였다. 쓸 수 있는 라이브러리 안에서 수집, 분석, 화면을 모두 만들어야 했다.
8주 안에 끝까지 가려면 중간에 요구가 늘어도 버티는 구조가 필요했다. 그래서 기능보다 층을 먼저 나눴다.
1[수집] PacketSniffer → PacketProcessor → ProtocolParser
2 ↓
3[저장] PacketStore (1시간 분량을 담는 deque)
4 ↓
5[계산] CaptureService · StatsService · MLService · DashboardBuilder
6 ↓
7[화면] 실시간 패널 11개저장은 보관만, 계산은 계산만, 화면은 표시만 한다. 이 덕분에 중간에 저장 자료구조를 바꿀 때 위층 코드는 한 줄도 건드리지 않았다.
장비 없이 개발하는 문제도 같은 방식으로 풀었다. 데이터 공급 방식을 하나의 약속으로 정해 두고, 가짜 데이터와 실제 캡처가 같은 약속을 따르게 했다. 실장비가 없는 날에도 개발과 테스트를 이어갈 수 있었다.
1class DataProviderProtocol(Protocol):
2 def get_latest_records(self, n: int) -> list[PacketRecord]: ...
3 def get_kpi_snapshot(self) -> KPISnapshot: ...패킷은 계속 오는데 처리가 늦으면. 수집 루프에서 파싱까지 하면 패킷을 흘린다. 수집하는 쪽은 큐에 넣기만 하고, 다른 스레드가 꺼내서 무거운 파싱을 하도록 나눴다.
종이 위의 프로토콜과 실제 비트. 사내 프로토콜 문서는 있었지만 실제 데이터는 바이트 덩어리였다. 엔디언 표기 하나만 틀려도 엉뚱한 값이 나왔다. 메시지 구조를 데이터 클래스로 옮기고 파서를 따로 떼어, 파서만 검증하는 테스트를 붙였다.
지터를 잘못 정의했다. 처음엔 도착 간격을 지터라고 생각했다. 그런데 패킷이 0.1초마다 정확히 오면 간격은 0.1초여도 지터는 0이어야 한다. 간격의 변화량으로 다시 정의하자, 통신이 흔들릴 때만 값이 튀는 그래프가 나왔다. 95번째 백분위 값도 함께 계산해 순간 튐과 지속적인 불안정을 구분했다.
pandas에 한 줄씩 붙이면 느려진다. 실시간 수집은 가벼운 deque에 담고, 통계가 필요할 때만 표로 바꿨다.
고정 임계값 알람은 오탐이 너무 많았다. 네트워크마다 "정상"이 달랐기 때문이다. 그렇다고 지도 학습을 할 수도 없었다. 이상이 난 데이터가 없었으니까. 그래서 정답 없이 쓸 수 있고 폐쇄망에서도 가벼운 Isolation Forest를 골랐다.
모델에 넣는 특징은 프로토콜의 운용 패턴을 보고 직접 설계했다.
| 특징 | 뜻 |
|---|---|
| 현재 지터 | 도착 간격의 변화량 (ms) |
| 지터 95번째 백분위 | 순간 튐과 지속 불안정을 가른다 |
| 지터 변동성 | 백분위 값과 현재 값의 차이 비율 |
| 초당 패킷 추세 | 급락과 급등 |
| 손실률 | 시퀀스 번호로 센 패킷 손실 |
| 품질 점수 | 지터와 손실률을 합친 0~100 점수 |
문제는 시작 직후였다. 모델은 데이터가 쌓여야 의미 있는 판단을 한다. 그래서 처음에는 규칙 세 개(지터, 손실률, 체크섬 실패율 상한)가 바로 판단하고, 데이터가 충분해지면 모델 점수를 합치게 했다. 모델이 준비될 때까지 아무것도 못 보는 시간을 없애려는 것이었다.
"이상입니다"만으로는 엔지니어가 움직이지 않는다. 그래서 판정마다 어느 지표가 평소보다 얼마나 벗어났는지 표준점수로 함께 보여 줬다. "지터 급등(z=3.2), 초당 패킷 저하(z=2.8)" 같은 식이다. 신뢰도도 0과 1이 아니라 0~100% 사이 값으로 보여 줬다.

이상 탐지 패널. 3D 산점도, 점수 흐름, 신뢰도, 원인 후보를 한 화면에서 본다.
테스트 중에는 통신이 끊기기 직전의 지터 패턴을 이상 신호로 잡은 적도 있었다. 다만 표시된 이상 데이터가 없었기 때문에 이 관찰을 정확도 같은 숫자로 부풀리지는 않았다.
막바지에 실장비 모드로 시험하는데, 화면은 뜨는데 모든 그래프가 0에서 멈춰 있었다. 오류 로그도 없었다. 화면 갱신 요청은 2초마다 제대로 가고 있었고, 수집·처리·저장도 각각은 멀쩡해 보였다.
이틀 만에 찾은 원인은 초기화 코드였다. 수집기가 패킷을 넣는 큐와 처리기가 꺼내는 큐가 서로 다른 객체였다.
1queue_A = PacketQueue()
2sniffer = PacketSniffer(queue=queue_A) # A에 넣는다
3
4queue_B = PacketQueue()
5processor = PacketProcessor(queue=queue_B) # 텅 빈 B를 본다처음에 세워 둔 의존성 관리 원칙을 초기화 코드 한 곳에서 지키지 않은 대가였다. 객체를 누가 만들고 누가 넘겨받는지를 한곳에서 관리해야 한다는 걸 이때 제대로 배웠다.
8주 끝에 내부 프로토타입 v1.0을 남겼다. 실시간 패널 11개, 자동 테스트 54개, 기술 문서 9건이다. 테스트는 프로토콜 경계값, 모델 학습 전후의 동작 차이, 시퀀스 번호가 한 바퀴 도는 경우처럼 사람이 놓치기 쉬운 곳을 주로 겨눴다. 다음 사람이 고쳐도 안전하다는 증거로 남기고 싶었다.

시간을 받는 쪽 기준으로 쟀다. 모든 계산이 패킷을 받은 시각에 기대고 있어서, 내가 잰 건 보낸 속도가 아니라 받은 속도였다. 처리 지연이 섞여 순수한 네트워크 지터를 떼어 낼 수 없었다. 다시 한다면 시각 동기화와 고정밀 타이머로 처리 지연을 분리하겠다.
메시지 종류를 섞어서 학습했다. 특정 주기 메시지가 잠깐 늘어나면 초당 패킷이 튀고, 모델이 이를 이상으로 잘못 읽었다. 메시지 종류마다 따로 보는 편이 맞았다. 오탐이 얼마나 줄지는 실험으로 확인해야 한다.
성능을 숫자로 재지 못했다. 가장 아쉬운 점이다. 다시 한다면 갑작스러운 패킷 급감, 서서히 늘어나는 지터 같은 합성 이상 시나리오부터 만들고, 그걸로 잴 수 있는 목표를 먼저 세운 뒤 개발하겠다.
8주에서 가져온 건 세 가지다. 구조를 먼저 잡았기 때문에 중간에 늘어난 요구를 버텼다. 테스트가 있었기 때문에 고치는 게 두렵지 않았다. 그리고 완벽한 시스템보다, 어디가 부족한지 설명할 수 있는 시스템이 더 쓸모 있다는 것.
UGV-MON은 패킷 분석기만으로는 보기 어려웠던 통신 품질 변화를 더 빨리 보기 위한 내부 도구로 남았다.