무엇을 만들고 있나

우측 편마비 환자용 다리 보조기구다. 한 줄로 요약하면 이렇다. 건강한 쪽 다리의 관절 각도를 읽어, 마비된 쪽 다리가 따라가야 할 목표 각도를 8비트 MCU 위의 신경망이 실시간으로 생성한다.

항목내용
기간2025.07 ~ 진행 중
제어기Arduino Nano (ATmega328P)
구동L298N 모터 드라이버 2개 + JGB37-520 DC 기어드모터·엔코더 2개
기구카본튜브 6×4 링크, 3590S 다회전 포텐셔미터, 12V 파워서플라이, KiCad 회로 설계
소프트웨어Arduino C++ / Unity 6000.0.68f1 (ArticulationBody 시뮬 + 자동 검증) / Python 3 · PyTorch
역할1인 개발 — 기구·회로 설계, 펌웨어, 물리 시뮬레이션, 생체역학 해석, 학습 파이프라인

우측 편마비 사용자를 상정하고 설계했다. 지금은 구현 중이고, 이 글에 적은 검증은 전부 계산과 PC 대조 단계다. 사람이 착용한 상태로 시험한 단계가 아니다.

Unity 시뮬레이션 화면. 대퇴와 하퇴로 이루어진 다리 모델에 카본 링크와 커프, 관절 모듈이 붙어 있다
Unity에 실측 치수만으로 생성한 다리와 보조기 조립. 초록색이 커프, 빨간색이 관절 모듈이다.
이 프로젝트에서 가장 신경 쓴 것"동작한다"가 아니라 "왜 이 수치인지 증명할 수 있다". 모든 설계 수치는 손계산 또는 문헌값과 교차 검증했고, 자동 검사 30항목으로 회귀를 막고 있다.

문제 — 이건 보조기구가 아니라 조종기였다

초기 시스템은 전기적으로는 완성돼 있었다. 빵판 위에서 폐루프 위치 제어, PID, 스톨 감지, 소프트 리밋, EEPROM 캘리브레이션까지 전부 동작했다.

그런데 제어 구조를 한 문장으로 요약해 보니 이랬다.

당시 구조사용자가 손가락으로 가변저항을 돌린 만큼 모터가 그 각도로 간다. 즉 "전동 조종기" 이며, 시스템이 스스로 판단하는 부분이 0이다.

잔존 근력이 없는 사용자에게 "두 관절을 손가락으로 매 순간 조종하세요"라고 요구하는 장치는 쓸모가 없다. 여기서 구조를 갈아엎었다.

구분기존 (v5)개선 (v6)
입력손가락 가변저항 1~2ch건측 고관절·무릎 각도 + 보행 위상
변환선형 map()학습된 목표각 생성기 (MLP)
사용자 역할매 순간 두 관절을 손으로 조종평소처럼 걷기 (의도 표현만)
개인화없음본인 데이터로 파인튜닝

6개 계층으로 나누기

제어를 여섯 계층으로 분리했다. 원칙은 두 가지다. 각 계층은 위 계층이 실패해도 단독으로 동작한다, 그리고 상위 계층의 어떤 출력도 안전층을 우회할 수 없다.

계층역할상태
L0 안전층기계 스토퍼 · 비상정지 · 소프트 리밋 · 슬루레이트 · 스톨 감지. 모든 출력이 여기를 통과구현 완료
L1 대칭 미러링건측 각도를 보행 반주기만큼 지연. 학습 없는 규칙 기반 → 대조군구현 완료
L2 학습 목표각 생성기건측 각도·각속도·보행위상 → 환측 목표각. 실제 배포 모델파이프라인 완료
L3 중력·마찰 보상 FF관절각별 유지 PWM 테이블을 전향 보상으로 가산설계 완료
L4 온라인 적응 (ILC)걸음 단위 반복 오차를 EMA로 누적해 선보상계획
L5 RL 정책Unity ML-Agents 균형 정책. 실기 배포 안 함계획
L5(강화학습)를 실기에 올리지 않는 이유sim-to-real 격차가 크고, 정책이 학습하지 않은 상태에 들어가면 예측 불가능한 토크를 낸다. 사람 다리에 붙는 장치에서 이건 허용할 수 없는 위험이다. RL은 시뮬레이션 검증과 시각적 시연 용도로 명확히 분리했다.

우회 경로를 만들지 않는 것이 목표다

안전층은 "막아 준다"고 말하는 것만으로는 부족하다. 코드 구조상 우회가 불가능해야 한다. 모드가 무엇이든 모터에 도달하기 전 같은 경로를 지나가는 것을 목표로 잡았다. 아직 거기까지는 못 갔다. 아래 코드에서 수동 모드는 공통 경로를 타지 않고 바로 빠져나간다 — 구동 펌웨어를 붙이기 전에 정리해야 할 부분이다.

// 모드가 무엇이든 최종 출력은 단 하나의 경로로만 나간다
switch (mode) {
  case MODE_MANUAL: targetFromPot(hip); targetFromPot(knee); return;
  case MODE_MIRROR: mirrorTargets(&tHip, &tKnee); break;         // L1
  case MODE_MODEL:  modelInfer(features, &tHip, &tKnee); break;  // L2
}
// ── 이 아래는 공통. 우회 경로 없음 ──
tHip  = clampSoftLimit(tHip,  HIP_MIN,  HIP_MAX);
tKnee = clampSoftLimit(tKnee, KNEE_MIN, KNEE_MAX);
hip.target  = slewLimit(hip.target,  tHip,  SLEW_DEG_PER_S * DT);
knee.target = slewLimit(knee.target, tKnee, SLEW_DEG_PER_S * DT);
if (!deadmanHeld() || eStopLatched()) { allOff(); return; }

안전층 함수는 전부 순수 정적 함수로 만들었다. 덕분에 펌웨어(C)로 1:1 이식이 가능하고, Unity 플레이 모드 없이 단위 검사를 돌릴 수 있다.

8비트 MCU에서 신경망 돌리기

L2의 목표각 생성기는 7-16-16-2 MLP, 파라미터 434개다. 이걸 int16 고정소수점으로 양자화해 ATmega328P에서 실행한다. 활성값과 가중치는 Q12, 누산기는 int32, tanh 는 65점 룩업 테이블과 선형보간으로 대체했다.

/* 자동 생성된 헤더의 추론 함수 (일부) — AVR PROGMEM 대응 */
static void legassist_infer(const int16_t *raw_in, int16_t *out_d10)
{
  int16_t x[LEGASSIST_N_IN];
  int16_t h1[LEGASSIST_N_HIDDEN];

  /* 입력 정규화: Q16 역표준편차를 곱하고 Q12로 시프트 */
  for (i = 0; i < LEGASSIST_N_IN; i++) {
    int32_t diff = (int32_t)raw_in[i] - LA_RD16(la_x_mean_raw, i);
    int32_t scaled = (diff * LA_RD32(la_x_inv_std, i)) >> (LA_NORM_SHIFT - LA_ACT_SHIFT);
    x[i] = la_clamp16(scaled);
  }

  /* 1층: int32 누산기에 Q24로 쌓고 Q12로 내린 뒤 tanh LUT */
  for (i = 0; i < LEGASSIST_N_HIDDEN; i++) {
    int32_t acc = LA_RD32(la_b1, i);
    for (j = 0; j < LEGASSIST_N_IN; j++) {
      acc += (int32_t)LA_RD16(la_w1, i * LEGASSIST_N_IN + j) * (int32_t)x[j];
    }
    h1[i] = la_tanh(acc >> LA_ACT_SHIFT);
  }
  /* ... 2층, 출력층, 역정규화 ... */
}

설계 판단 1 — 모델은 각도를 출력한다. 토크나 PWM이 아니다.

건측과 환측의 각도 관계는 운동학적 관계다. 누가 얼마나 힘을 냈는지와 무관하다는 뜻이다.

그래서 지금의 약한 모터로 모은 데이터로 학습해도, 나중에 모터를 바꿨을 때 모델을 다시 학습할 필요가 없다. 바뀌는 것은 하위 추종 제어기(PID 게인, 중력보상)뿐이다. 토크를 출력하도록 설계했다면 구동계를 바꿀 때마다 전부 다시 해야 했다.

설계 판단 2 — tanh 룩업 테이블을 헤더에 함께 내보낸다

Python 참조 구현과 C 구현이 같은 표를 쓰면, 근사 오차가 서로 상쇄되는 게 아니라 아예 동일해진다. 그래서 3,000표본 검증에서 차이가 0으로 나온다. 이건 운이 아니라 설계다.

학습 모델이 새로 만드는 위험

신경망을 넣으면 기존에 없던 고장 방식이 생긴다. 그걸 따로 정의하고 방어책을 붙였다.

위험발생 상황방어
분포 밖 입력 (OOD)학습하지 않은 자세에서 모델이 엉뚱한 각도 출력입력이 학습 분포 1~99 퍼센타일을 벗어나면 즉시 L1 미러링으로 강등
센서 단선·고착포텐셔미터 선이 빠져 ADC 값이 고정무변화 2초 지속 시 경고 및 폴트
위상 추정 실패발 스위치 오접촉으로 좌우 위상이 뒤집힘주기 연속성 검사, 비정상 주기는 채택하지 않고 직전 주기 유지
모델 갱신 사고잘못된 가중치가 올라감내보내기 단계에서 오차 0.5도 초과 시 헤더 생성 자체를 중단

실기에 올릴 수 있는 크기까지는 왔다

아래 표의 검증은 전부 PC 쪽에서 한 것이다. gcc 로 빌드해 파이썬 참조 구현과 출력을 맞춰 보고, avr-gcc 로 빌드해 플래시·RAM 이 들어가는지 확인했다. 보드에 올려 돌린 결과는 아직 없다. 그건 다음 단계다.

검증 항목결과검증 방법
C 구현 vs Python 참조3,000표본 + 극단 입력 차이 0gcc 컴파일 후 실행 결과 직접 대조
PyTorch(float) vs 정수 구현최대 오차 0.27도 (기준 0.5도)동일 입력 15,769표본
ATmega328P 빌드플래시 2,412 B (7.4%), SRAM 18 B (0.9%)avr-gcc -Os 실제 빌드
자동 검사 스위트30항목 전부 통과안전층·지연선·위상추정·필터·역동역학
원래는 ESP32로 갈 생각이었다신경망을 올린다니까 8비트로는 무리라고 보고 처음에는 ESP32로 넘어갈 계획이었다. 그런데 실제로 빌드해 보니 플래시 2,412 B, 전체의 7.4% 만 썼다. 92%가 남는다. 계산상의 주장이 아니라 실물 빌드로 확인된 숫자라 여기서 결정이 갈렸다 — Nano로 충분하다. 부품값이 그대로 유지되고, 저비용 웨어러블이라는 이 프로젝트의 전제도 지켜진다.

데이터 수집 펌웨어는 모터를 아예 안 돌린다

50Hz CSV 스트리밍, 활동 라벨 전환, 캘리브레이션 EEPROM 저장을 지원하는 별도 펌웨어를 만들었다. 여기서는 L298N 제어핀을 setup 에서 LOW로 고정하고 그 뒤로 건드리지 않는다.

데이터를 모으는 단계에서 모터가 움직일 이유가 없고, 움직이면 위험만 늘어난다. 구동은 검증이 끝난 뒤 별도 펌웨어로 분리했다.

AVR에서 50Hz를 맞추는 법AVR의 float 출력은 한 값당 1ms에 가깝다. 값 몇 개만 보내도 50Hz(20ms)를 못 맞춘다. 그래서 각도를 0.1도 단위 정수로 보내고 PC에서 나눈다. 빠르고 오차도 없다.

1편 정리, 그리고 다음

여기까지가 "조종기"를 "스스로 목표를 만드는 장치"로 바꾸는 구조 설계와, 그 모델을 8비트 MCU에 실제로 올린 이야기다.

다음 편에서는 숫자 쪽을 쓴다. 필요 토크가 969 N·m(정상의 20배)로 계산돼 나온 사건과 그 원인, Unity 물리 시뮬레이션이 3D 출력 전에 잡아낸 설계 오류, 그리고 감속비를 아무리 조정해도 보행이 불가능하다는 결론에 도달한 과정이다.

그 결론이 프로젝트의 방향 자체를 바꿨다. 지금은 관절 구축 예방을 위한 느린 가동범위 운동을 1차 목표로 두고 있다.