임베디드에서 AI Agents: 스펙 기반 TDD
임베디드 팀이 TDD를 건너뛴 이유는 비용이 너무 높았기 때문입니다. AI 에이전트가 그 경제성을 바꿉니다. Yocto에서 IMU 센서 드라이버를 스펙 기반 테스트와 구조화된 JSON 피드백으로 검증했습니다.
On this page
Raspberry Pi 4에 올린 IMU 센서 드라이버를 테스트하라고 Claude Code한테 시켰다. ssh pi lsmod | grep imu를 돌리고, 이어서 ssh pi cat /sys/class/imu0/data, 그다음 ssh pi dmesg | tail -30. 도구 호출 세 번. 경과 시간 15초. 그러곤 lsmod에 모듈이 잡히고 /sys/class/imu0/data가 숫자를 뱉었다는 이유로 “드라이버 정상”이라고 선언했다.
하지만 그 숫자는 쓰레기였다. 캐시 동기화가 빠져서 DMA 전송이 세 번째 샘플마다 데이터를 깨고 있었다. dmesg에는 다 찍혀 있었다. Unhandled fault가 정상 로그 사이에 간헐적으로 끼어 있었다. 문제는 에이전트가 마지막 30줄만 읽었고, 하필 그 구간이 깨끗했다는 점이다.
이게 2026년 3월, AI가 붙은 임베디드 리눅스 개발의 현주소다. 도구는 잘 돈다. 워크플로가 안 돈다.
답은 TDD다. 임베디드 팀도 이걸 수년간 알고 있었다. 그냥 타겟 하드웨어에 테스트를 짜고 돌리는 비용이 너무 커서 건너뛰었다. 크로스 컴파일, 배포, SSH 접속, 로그 확인, 다시 반복. 에이전트는 이 방정식을 바꾼다. 스펙에서 테스트를 뽑고, 구조화된 래퍼로 돌리고, 사람 손 안 대고 실패를 물고 늘어진다. 새 방법론이 나온 게 아니다. 값이 싸졌을 뿐이다.
이 시리즈의 예제는 전부 Anthropic의 에이전트 코딩 도구 Claude Code로 돌렸다. 스펙 기반 테스팅, 구조화된 피드백, 컨텍스트 엔지니어링 같은 워크플로 원칙은 다른 AI 코딩 에이전트에도 그대로 옮겨간다. 다만 CLAUDE.md, 서브에이전트 오케스트레이션, /clear는 Claude Code 전용이다.
깨진 내부 루프
RunSafe Security 설문(2026년 1월 발행, 2025년 채택 현황 조사)을 보면 임베디드 팀의 80% 이상이 지금 AI 도구를 쓰고, 83%는 AI가 짠 코드를 프로덕션에 올려봤다고 한다. 수치 자체는 좀 걸러 봐야 한다. 설문 대상이 얼리 어답터로 쏠려 있어서다. 그래도 방향은 뚜렷하다. 채택은 거의 다 됐다. 진짜 물음은 이 도구로 사람들이 뭘 하느냐다.
대부분은 AI 출력을 2015년이랑 똑같은 편집-컴파일-scp-ssh-테스트 사이클에 붙여넣는다. 에이전트가 코드는 더 빨리 뽑는다. 그런데 병목은 거기, 코드 생성 속도에 있지 않다. “코드 다 짰다”와 “코드 검증 끝났다” 사이의 배포-테스트-해석 사이클, 그게 병목이다. Yocto scarthgap 이미지를 돌리는 Raspberry Pi 4에서 크로스 컴파일에 scp, insmod, dmesg 확인까지 한 바퀴 도는 데 최소 2분에서 3분이 든다. 드라이버 기능 하나당 이걸 10번에서 20번 반복한다. 기능 하나에 30분에서 60분이 커널 모듈 로드, dmesg 읽기, printk 추가, 재배포로 날아간다. 그 대부분은 그냥 기다리면서 터미널 출력을 노려보는 시간이다.
피드백 루프 개선
임베디드 리눅스 프로젝트(Yocto BSP, 커널 모듈, SPI/I2C 센서 드라이버)에서 6개월간 Claude Code를 굴려 보니, 결국 차이를 만든 건 구조적 변화 두 개였다. 더 나은 프롬프트나 더 똑똑한 모델 덕은 아니었다. 에이전트가 실제로 써먹을 수 있는 피드백 채널을 깔아 준 것, 그게 전부다.
스펙을 먼저 작성하라
효과가 가장 컸던 변화 하나. 에이전트가 코드를 짜기 전에, 테스트 가능한 인수 기준을 담은 spec.md를 먼저 쓰게 한다.
스펙이 없으면 에이전트는 정상 경로만 건드린다. “모듈 로드되나?”, “sysfs에서 읽히나?” 확인하고 끝이다. IMU 드라이버가 1kHz 샘플링을 1% 오차 안에서 버텨야 한다는 것, 10,000회 연속 DMA 전송 뒤에도 데이터가 안 깨져야 한다는 것, 1시간 돌린 뒤 RSS가 2MB 밑이어야 한다는 것. 이런 건 알 리가 없다.
| ID | Criterion | Metric |
|---|---|---|
| AC-1 | 샘플링 레이트 1kHz, 1% 이내 오차 | 초당 샘플 수 |
| AC-2 | 10K DMA 전송 후 데이터 무결성 | CRC32 일치율 |
| AC-3 | 1시간 가동 후 RSS 메모리 < 2MB | ps RSS 측정 |
| AC-4 | SPI 버스 에러 후 3초 이내 복구 | 장애 주입 + 타이머 |
| AC-5 | IRQ-to-userspace 레이턴시 < 500us | 타임스탬프 차이 |
| AC-6 | CPU 스트레스 상황에서 데이터 손실 없음 | stress-ng + 샘플 수 |
Chalmers/University of Gothenburg 연구는 이거랑 맞닿은 개념을 “AI-Friendly Artifacts”라고 부른다. AI 도구가 안정적으로 파싱할 수 있는 구조화된 문서라는 뜻이다. 위 스펙 테이블이 그 연구에서 나온 건 아니다. 그래도 직관은 똑같다. 에이전트에 구조화된 입력을 주면 구조화된 행동이 돌아온다. 실제로 에이전트는 인수 기준 하나당 테스트 함수 하나를 만들고, 결과가 스펙에 1:1로 붙는다. 커버리지를 추적할 수 있고, 빈틈이 눈에 보인다.
스펙에는 코드만 봐선 에이전트가 못 알아내는 하드웨어 고유 제약도 들어간다. 샘플링 허용 오차, 레이턴시 예산, 메모리 상한, 장애 복구 동작. 이게 문서에 없으면 에이전트는 딱 웹 개발자 수준 테스트를 뽑는다. 돌아가긴 하는데, 정작 타겟 디바이스에서 중요한 제약엔 깜깜하다.
구조화된 테스트 결과를 반환하라
즉석에서 날리던 SSH 명령을, 배포와 테스트와 JSON 반환을 한 방에 처리하는 단일 명령 래퍼로 바꾼다. N번 왕복하던 걸 한 번으로.
L1 (대부분 여기 있다): 에이전트가 SSH 명령을 하나씩 날린다. ssh target lsmod, ssh target cat /sys/class/imu0/data, ssh target dmesg | tail. 호출당 3초에서 5초. 에이전트가 구조 없는 터미널 출력을 눈대중으로 해석한다. 나도 처음엔 이렇게 시작했고, 앞의 DMA corruption 버그가 딱 이래서 안 잡혔다.
L2 (호스트에 pytest 있고 타겟에 SSH 접근 되면 오후 반나절이면 된다): scp에 insmod, pytest까지 한 번에 돌리고 구조화된 JSON을 뱉는 래퍼 스크립트다. 에이전트가 로그를 넘겨짚는 대신 pass/fail 결과를 그냥 읽는다. labgrid를 맨바닥에서 세우면 inventory.yaml, SSH 키 설정, Yocto 패키지 구성에 하루쯤 더 붙는다.
L3 (성숙한 프로젝트에 하는 투자): 타겟에 상주하는 테스트 데몬을 둔다. 에이전트가 POST 요청을 보내면 JSON이 돌아온다. 제일 빠르다. 대신 타겟 쪽 인프라를 계속 관리해야 한다.
아래는 Yocto 기반 Pi 4에서 IMU 센서 드라이버에 쓰는 L2 래퍼다. labgrid(Pengutronix가 만든)가 타겟 추상화를 맡고, pytest-json-report(pip install pytest-json-report)가 기계가 읽는 출력을 만든다:
#!/bin/bash
set -euo pipefail
MODULE_PATH="${1:?Usage: $0 <module-path>}"
TARGET="${TARGET_HOST:-192.168.1.100}"
TEST_DIR="$(dirname "$0")/tests"
REPORT_FILE=$(mktemp /tmp/test-report-XXXXXX.json)
# 1. Deploy module
scp -q "$MODULE_PATH" "root@${TARGET}:/lib/modules/$(ssh root@${TARGET} uname -r)/extra/"
ssh -q "root@${TARGET}" "depmod -a && modprobe -r imu_sensor 2>/dev/null; modprobe imu_sensor"
# 2. Run tests on host -- labgrid reaches the target via SSH
cd "$TEST_DIR"
pytest --lg-env ../inventory.yaml \
--json-report --json-report-file="$REPORT_FILE" \
-q 2>/dev/null || true
cat "$REPORT_FILE"
rm -f "$REPORT_FILE"
|| true는 테스트가 실패해도 스크립트를 계속 굴려서 JSON 리포트까지 쓰게 만든다. 인프라가 터지면(pytest 크래시, labgrid 설정 누락, 타겟 연결 불가) 빈 리포트나 엉뚱한 리포트가 남을 수 있다. 프로덕션에서 쓸 거라면 리포트 파일이 비었는지 확인하고 stderr를 로그로 잡아 둔다.
에이전트가 ./run_target_tests.sh build/imu_sensor.ko를 호출하면 구조화된 JSON이 돌아온다(여기선 간추렸다. 실제 pytest-json-report는 에러 상세를 call 스테이지 객체 안에 중첩한다):
{
"created": 1773628800,
"duration": 4.2,
"exitcode": 1,
"summary": {"passed": 4, "failed": 2, "total": 6},
"tests": [
{"nodeid": "test_imu::test_module_loads", "outcome": "passed"},
{"nodeid": "test_imu::test_sysfs_readable", "outcome": "passed"},
{"nodeid": "test_imu::test_sampling_rate_1khz", "outcome": "passed"},
{"nodeid": "test_imu::test_dma_data_integrity", "outcome": "failed",
"longrepr": "AssertionError: CRC mismatch in 847 of 10000 transfers (91.5% pass, need 100%)"},
{"nodeid": "test_imu::test_memory_soak", "outcome": "passed"},
{"nodeid": "test_imu::test_irq_latency", "outcome": "failed",
"longrepr": "AssertionError: p99 latency 1.2ms exceeds 500us limit"}
]
}
왕복 한 번. 4초. 구조화된 pass/fail. 에이전트가 뭐가 왜 실패했는지 바로 안다.
맨 앞에서 나온 DMA 데이터 손상 버그는? AC-2가 첫 실행에서 잡았다. 에이전트가 빠진 dma_sync_single_for_cpu() 호출을 넣고 다시 돌렸다. 버그 발견에서 수정까지 3분이 안 걸렸다. The Embedded Kit은 DUT 10대에 Pluma 도구를 물려 비슷한 Yocto 테스트 자동화 인프라를 공개했다. 이 패턴이 프로덕션 CI까지 확장된다는 증거로 볼 수 있다.
이전 전형적인 오전. 30분 동안 드라이버 코드 작성, 크로스 컴파일, Pi로 .ko를 scp, ssh 접속, rmmod에 insmod, sysfs cat, 문제 발견, printk 추가, 재컴파일, 또 scp, insmod, dmesg 확인. 점심 될 때까지 편집-테스트 사이클을 3번쯤 돌리면, 기능 하나의 정상 경로만 겨우 덮었다. 엣지 케이스도 없고 장애 주입도 없다. 품질 기준이라곤 “보드에서 로드된다” 하나였다.
이후: 처음 30분에서 40분을 spec.md 쓰거나 고치는 데 쓴다. 바로 코드로 뛰어드는 것보다 느리고, 처음 몇 번은 헛수고처럼 느껴진다. 성과는 에이전트가 스펙에서 테스트를 뽑고, 드라이버 변경을 구현하고, 명령 하나로 전체 스위트를 돌릴 때 나온다. 코드 변경에서 테스트 결과까지, 보통 사이클은 테스트 수에 따라 3분에서 8분이다.
진짜 빨라진 건 이렇다. 내부 루프가 반복당 30분에서 60분 걸리던 게 3분에서 8분으로. 테스트 생성은 예전엔 건너뛰었는데 이제 스펙에서 자동으로 나온다. 코드 리뷰 턴어라운드도, 에이전트가 PR 올리기 전에 컨벤션 위반을 잡아 준다. 안 변하는 건 이렇다. 물리 계층에서 깨질 때의 하드웨어 디버깅, 아키텍처 결정, 에라타 시트 읽기. 그리고 SPI 버스가 왜 멈췄나 20분을 붙잡고 있다가 칩 셀렉트 극성이 뒤집혔다는 걸 깨닫는 그 순간.
Example-Based를 넘어서: Property-Based Testing
위의 스펙 기반 접근은 이미 아는 인수 기준에는 잘 먹힌다. 다만 못 잡는 버그가 한 부류 있다. 명시하지 않은 엣지 케이스다.
Example-based 테스트(AC-1부터 AC-6까지)는 내가 직접 적어 둔 시나리오만 덮는다. SPI 프레임 파서에 특정 바이트 시퀀스에서 터지는 버퍼 처리 버그가 있다고 치자. 그 시퀀스를 미리 콕 집어 넣어 둘 만큼 용한 게 아니면, 어떤 인수 기준도 이걸 못 잡는다.
Property-based testing이 이 빈틈을 메운다. 테스트 케이스를 하나하나 적는 대신 불변 조건(“유효한 입력이면 이 속성이 항상 성립한다”)을 정의하고, 생성기가 입력 수천 개를 알아서 훑는다. 실패를 찾으면 재현되는 가장 작은 케이스까지 줄여 준다.
호스트에서 돌릴 수 있는 임베디드 C 코드라면 패턴은 이렇다. 모듈을 공유 라이브러리로 컴파일하고, Python CFFI(C 함수를 직접 부르는 외부 함수 인터페이스)로 로드하고, Hypothesis로 두들긴다. pip install cffi hypothesis로 깐다:
# tests/test_imu_properties.py
from hypothesis import given, strategies as st
import cffi
ffi = cffi.FFI()
ffi.cdef("""
typedef struct { int16_t accel[3]; int16_t gyro[3]; uint32_t timestamp; } imu_sample_t;
int parse_imu_frame(const uint8_t *buf, int len, imu_sample_t *out);
""")
lib = ffi.dlopen("./build/libimu_host.so") # cc -shared -o build/libimu_host.so imu_spi.c
@given(data=st.binary(min_size=1, max_size=64))
def test_parser_never_crashes(data):
"""어떤 입력도 파서를 크래시시키면 안 된다. 성공 또는 에러 코드 반환."""
buf = ffi.new("uint8_t[]", data)
sample = ffi.new("imu_sample_t *")
ret = lib.parse_imu_frame(buf, len(data), sample)
assert ret in (0, -1) # 0 = 유효 프레임, -1 = 파싱 에러
호스트에서 밀리초 단위로 끝난다. 타겟 하드웨어가 아예 필요 없다. Hypothesis는 내 프레임 파서에서 10초 만에 버그를 찾아냈다. 유효한 헤더처럼 보이는데 페이로드가 잘린 특정 바이트 조합이었고, 그게 파서가 버퍼 밖을 읽게 만들었다. 줄여 낸 실패 케이스는 7바이트. 그 시퀀스를 겨냥한 테스트를 내가 손으로 짰을 리가 없다.
Property-based 테스트는 로직 레이어를, 스펙 기반 테스트는 통합 레이어를 덮는다. 둘을 같이 쓰면 두 층에서 다 버그를 잡는다. 전체 파이프라인 포스트에서 이것들이 퍼징, 하드웨어-인-더-루프 검증과 함께 4계층 테스팅 피라미드에 어떻게 맞물리는지 보여준다.
이 접근법이 깨지는 지점
스펙 기반 래퍼는 보통의 임베디드 리눅스 드라이버에서 중요한 것의 절반쯤을 덮는다. 나머지 절반은 이렇다.
동시성. 레이스 컨디션, 락 경합, 우선순위 역전. 에이전트가 ThreadSanitizer나 Helgrind를 돌릴 순 있다. 하지만 특정 레이스를 유발하는 워크로드를 설계하진 못한다. 그런 버그를 겪어 본 사람이 있어야 한다.
하드웨어 장애. 전원 손실, SPI 버스 멈춤, 인터럽트 폭주. 에이전트가 소프트웨어 장애 주입(stress-ng, 강제 모듈 언로드/재로드)은 짤 수 있다. 하지만 물리적 장애는 물리적 장비가 있어야 한다. labgrid가 PDU와 USB 전원 스위치(예: SiS-PM)로 전원 제어를 지원하긴 한다. 그래도 누군가는 배선을 하고 “복구”가 뭔지 정의해 줘야 한다.
에이전트 자율성 그 자체. 드라이버 코드라고 다 위험도가 같진 않다. 테스트 스위트나 Yocto 레시피는 에이전트가 큰 감독 없이 만들게 둔다. 대신 레지스터 설정, 인터럽트 핸들러, DMA 셋업을 건드리는 코드는 한 줄씩 뜯어본다. 그 선을 어디 그을지 아는 건 에이전트한테 없는 경험이 필요한 일이다.
그리고 이 접근이 잘 맞는 영역 안이라고 마찰이 아예 없진 않다. 지난주에 에이전트가 AC-4(SPI 버스 에러 복구) 테스트를 짜면서 spi-fault-inject라는 걸 끌어 썼다. 최신 커널 staging 트리에 있는 도구다. 그런데 우리 Yocto 이미지는 커널 6.1 LTS에 고정돼 있고, 거기엔 이 모듈이 없다. 타겟에서 modprobe: FATAL: Module spi_fault_inject not found가 뜨면서 테스트가 깨졌다. CLAUDE.md(에이전트가 영속 제약을 읽어 가는 프로젝트 루트 파일)에 커널 버전 제약을 박아 넣기 전까진, 에이전트가 이걸 알 도리가 없었다. 이런 삽질은 원인 찾는 데만 10분에서 15분이 걸리고, 매끈한 워크플로 설명엔 절대 안 나온다.
새 병목은 스펙 품질이다. 스펙이 두루뭉술하면(“드라이버가 에러를 처리해야 함”) 에이전트는 대충 통과하는 두루뭉술한 테스트를 뽑는다. 스펙이 날카로우면(“SPI 버스 에러 후 3초 안에 복구, 센서 재초기화, 데이터 갭 없이 샘플링 재개”) 진짜로 버그를 잡는 테스트를 뽑는다.
나머지는 사람이 설계한 시나리오, 오래 돌리는 CI, 물리적 테스트 장비가 있어야 한다. 그 이상으로 자동 커버리지가 된다고 우기는 사람은 뭔가를 팔고 있는 것이다.
어디서 시작할까
지금 붙잡고 있는 드라이버나 커널 모듈 하나를 고른다. 인수 기준 5개를 담은 spec.md를 쓴다. pytest에 labgrid 래퍼를 붙인다(아니면 확인 몇 개 돌리고 pass/fail만 찍는 bash 스크립트라도 된다). 에이전트한테 스펙에서 테스트를 뽑게 하고, 명령 하나로 돌린다. 구조화된 피드백 루프가 어떤 느낌인지 직접 겪어 본다.
파서나 데이터 처리 모듈이 있으면 Hypothesis property 테스트를 얹는다. C를 .so로 컴파일하고, CFFI로 로드하고, 불변 조건 하나를 정의한다. 놓쳤던 엣지 케이스가 튀어나오는 걸 지켜본다.
에이전트가 JSON 테스트 실패를 보고 1분 안에 버그를 고치는 순간, 뭔가 딱 온다. 에이전트가 똑똑해서 그런 게 아니다. 피드백 루프가 드디어 반복을 돌릴 만큼 빨라진 덕이다.
임베디드에서 TDD가 맞는 길이라는 건 늘 알고 있었다. 그 값을 감당 못 했을 뿐이다. 이제는 된다.다음 포스트에서는 완벽한 스펙이 있어도 에이전트가 왜 여전히 망가진 DMA 핸들러를 짜는지, 그리고 컨텍스트 엔지니어링이 이걸 어떻게 잡는지 다룬다. 마지막 포스트에서는 완전한 프로젝트 구조를 한자리에 모아 보여준다.
댓글
댓글 불러오는 중...