AI에게 Octave를 맡겨봤습니다 — 이틀간 만난 함정 다섯 개

요즘 AI 에이전트한테 이것저것 시켜보는 재미로 삽니다. 이번에는 GNU Octave 공부를 하다가 문득 궁금해졌습니다. 실습을 아예 통째로 맡기면 어떻게 될까? 그래서 터미널에서 Claude Code를 띄워놓고 이틀 동안 스크립트 19개를 쓰고 돌리게 해봤습니다.

결론부터 말씀드리면, 다섯 번 넘어졌습니다. 그런데 그중 넷은 에러 메시지라도 내줘서 고마운 넘어짐이었고, 진짜 무서운 놈은 따로 있었습니다. 에러 없이 조용히 틀리는 네 번째 함정입니다. 이건 뒤에서 자세히 말씀드리겠습니다.

뭘 하고 있었냐면요

구성은 별 게 없습니다. MCP 서버도 Jupyter도 없이, 에이전트가 octave --no-gui script.m을 셸에서 직접 실행할 수 있게 허용만 해줬습니다. 그리고 학습 노트를 읽힌 다음 이렇게 시켰습니다.

“실습 스크립트를 만들어 돌리고, 각 단계마다 기대값과 실제값을 같이 출력해라.”

이 “기대값을 같이 출력해라”가 이번 실험의 핵심이었습니다. 덕분에 스크립트가 그냥 돌기만 하는 게 아니라 맞았는지가 출력에 드러납니다. 칼만 필터면 측정 오차와 추정 오차를 나란히 찍고, 필터면 통과 대역과 차단 대역의 에너지 비율을 찍는 식입니다. 아래 함정들은 전부 이 루프 안에서 튀어나왔습니다.

함정 1 — 필터를 걸었는데 결과가 전부 NaN?

목소리 샘플에 60 Hz 전원 험(hum)을 일부러 섞고, 대역소거(notch) 필터로 지우는 실습이었습니다. 교과서적으로 쓰면 이렇게 됩니다.

fs = 48000;
[b, a] = butter(4, [55 65] / (fs/2), 'stop');
y = filtfilt(b, a, x);

에러요? 안 납니다. 조용히 잘 돕니다. 그런데 감쇠량을 계산해 보니 NaN dB. 필터를 통과한 신호가 전부 NaN이 되어 있었습니다.

범인은 수치 불안정입니다. 48 kHz 샘플링에서 55~65 Hz는 정규화 주파수로 0.0023~0.0027 — 이렇게 좁은 대역의 IIR 계수는 배정밀도로도 버티지 못합니다. 알고 보니 좁은 notch에는 전용 함수가 따로 있었습니다.

[b, a] = pei_tseng_notch(60/(fs/2), 10/(fs/2));   % 중심 60 Hz, 폭 10 Hz

이걸로 바꾸니 60 Hz 성분이 28.8 dB 감쇠됐고, 스펙트로그램에서 60 Hz 가로줄이 사라지는 것까지 확인했습니다.

60 Hz 험 추가 후(위)와 notch 필터 후(아래)의 스펙트로그램 — 아래 그림에서 60 Hz 가로줄이 사라졌습니다

교훈: filtfilt가 에러 없이 끝났다고 안심하면 안 됩니다. 필터를 걸었으면 감쇠량을 숫자로 확인하는 한 줄을 꼭 넣읍시다.

함정 2 — 함수를 분명히 정의했는데 undefined라니요

PID 시뮬레이션 루프를 함수로 빼서, MATLAB에서 하던 버릇대로 파일 맨 끝에 붙였습니다. 그랬더니:

error: 'simulate_pid' undefined near line 32

아니, 바로 아래에 정의가 있는데요?

MATLAB은 파일 끝에 둔 로컬 함수를 알아서 끌어올려 줍니다. 그런데 Octave 스크립트는 실행 흐름이 함수 정의 줄을 지나가야 그 함수가 등록됩니다. 함수 정의를 스크립트 상단으로 옮기니 바로 해결됐습니다. Octave 문서의 “한 파일에 여러 함수 정의 가능”이라는 문장만 믿으면 이 순서 제약은 눈에 안 들어옵니다. 저희도 문서를 읽고도 몰랐습니다.

함정 3 — legend에 ‘position’을 쓰면 안 됩니다

이건 좀 억울한 종류입니다.

legend('target', 'position', 'location', 'southeast');
error: invalid value for array property "position"

그래프에 “목표”와 “위치” 두 곡선이 있어서 라벨을 target, position으로 붙였을 뿐인데요. 하필 position이 그래프 객체의 속성 이름이라, legend가 이걸 라벨이 아니라 속성/값 쌍의 시작으로 해석해 버립니다. 라벨을 'x(t)'로 바꾸니 끝. position, location, orientation 같은 단어는 라벨로 안 쓰는 게 정신 건강에 좋습니다.

함정 4 — 에러도 안 나고 조용히 틀립니다 (제일 무서운 놈)

이번 이틀의 최고 함정입니다. 외란을 준 뒤 시스템이 회복되는 시점을 찾는 코드였습니다.

rec = find(t > t(i_dip) & abs(xs - r)' < 0.02, 1);
%                                    ^ 이 전치(')가 문제

t > t(i_dip)는 500×1 열벡터, abs(xs - r)'는 1×500 행벡터입니다. 이 둘을 &로 묶으면 어떻게 될까요? 에러가 날 것 같지만, Octave는 아무 말 없이 500×500 행렬로 브로드캐스팅합니다. find는 그 거대한 행렬의 선형 인덱스 42253을 태연히 돌려주고, 에러는 한참 뒤 t(42253)에서 “out of bound”로 터집니다. 죄를 지은 줄과 잡히는 줄이 다른 겁니다.

find 결과가 벡터 길이보다 터무니없이 크면 이걸 의심하시면 됩니다. 근본 처방은 벡터 연산 전에 size()로 방향부터 맞추는 습관입니다. 파이썬 NumPy에서 브로드캐스팅에 데어본 분이라면 아시는 그 맛입니다.

함정 5 — 노트에 적어두고도 밟았습니다

학습 노트의 “흔한 함정” 절 1번 항목이 뭐였냐면, “pkg load를 까먹어 함수가 없다는 오류”였습니다. 그걸 적어둔 바로 그 저장소에서, 이틀째에 xcorr를 쓰면서 pkg load control만 하고 signal을 빼먹었습니다.

error: 'xcorr' undefined ... belongs to the signal package ... which you
have installed but not loaded.

사람도 까먹고 AI도 까먹습니다. 그나마 위안은 Octave의 이 에러 메시지가 상당히 친절하다는 겁니다 — “설치는 되어 있는데 로드를 안 하셨네요”까지 알려줘서 10초 만에 잡힙니다. 결국 유일하게 동작하는 예방책은 스크립트 템플릿 맨 위에 pkg load signal control을 박아두는 것뿐이었습니다.

보너스 — 함정은 아닌데 제일 공부가 된 사건

손으로 짠 PID 오일러 시뮬레이션과 control 패키지의 feedback(C*G,1) 계단 응답을 교차 검증했더니, 최대 0.63이나 어긋났습니다. 어느 쪽이 버그인지 한참 찾았는데… 둘 다 맞는 코드였습니다.

이상적 PID는 계단 목표의 미분에서 임펄스를 만듭니다. 이른바 미분 킥(derivative kick)입니다. 손 시뮬레이션은 직전 오차를 e_prev = r로 초기화하면서 그 킥을 자기도 모르게 억제하고 있었고, 연속시간 해에는 킥이 살아 있었던 겁니다. “t=0에 계단이 도착했다”로 초기 조건을 맞추니 차이가 0.068로 뚝 떨어졌습니다. 같은 시스템도 초기 조건 하나로 서로 다른 문제가 된다는 걸 몸으로 배웠습니다.

정리하겠습니다

# 증상 원인 처방
1 필터 출력 전부 NaN 좁은 notch를 butter로 설계 pei_tseng_notch
2 '함수명' undefined 스크립트 내 함수를 파일 끝에 정의 정의를 상단으로
3 invalid value for array property legend 라벨이 속성 이름과 충돌 라벨 단어 교체
4 find가 거대한 인덱스 반환 행/열 벡터 혼합 브로드캐스팅 size()로 방향 확인
5 'xcorr' undefined pkg load 누락 템플릿 맨 위에 고정

에이전트에게 Octave를 맡기실 생각이라면 챙길 건 딱 하나입니다. 기대값과 실제값을 나란히 출력하게 시키세요. 다섯 함정 모두 그 출력 덕분에 즉시 드러났습니다. 특히 에러가 안 나는 함정(1번, 4번)일수록 이게 유일한 안전망입니다.

같은 에러 메시지를 들고 검색으로 여기까지 오신 분이라면, 위 표가 10초짜리 해결이 되기를 바랍니다. 저는 이틀 걸렸습니다. 아니, 정확히는 AI가 이틀 걸렸는데 옆에서 지켜보던 제가 더 배웠습니다.

댓글 남기기

이 사이트는 Akismet을 사용하여 스팸을 줄입니다. 댓글 데이터가 어떻게 처리되는지 알아보세요.